deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py
2026-09-07 18:24:55 +08:00

2373 lines
108 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Scheduled task orchestration service."""
from __future__ import annotations
import asyncio
import json
import logging
import mimetypes
import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from zoneinfo import ZoneInfo
from croniter import croniter
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, load_agent_soul
from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.serialization import serialize_channel_values
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.utils.time import now_iso
logger = logging.getLogger(__name__)
# A single execution attempt may not exceed this wall-clock budget. When it
# does, the run is treated as stuck (the agent loop hung, a tool blocked, etc.)
# and retried. The watchdog uses the same window to recover runs orphaned by a
# crash or restart — those stay "running" in the DB with no in-process owner.
_RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3
# Max HTML length (chars) inlined into the run's ``result_json``. Beyond this
# the inline copy is dropped to stay under MySQL's 64 KB ``TEXT`` cap; the full
# document is always retrievable from the page store (LONGTEXT) + index.html.
_MAX_INLINE_RESULT_HTML = 60000
# Minimum length (in characters) the generated Markdown file's content must
# reach when a task has ``require_markdown`` enabled. The model is NOT told about
# this threshold — it is enforced silently by the service.
_MARKDOWN_MIN_CHARS = 100
# 模板填充模式:转换结果未通过结构校验时,把错误回喂大模型自动修正重试的最大次数
# (总尝试次数 = 1 + 该值)。
_MAX_TEMPLATE_FILL_RETRIES = 2
# Instruction appended to the prompt when ``require_markdown`` is on. It only
# asks the model to produce a ``.md`` file; the length requirement is enforced
# in code (not surfaced to the model).
_MARKDOWN_INSTRUCTION = (
"\n\n【输出要求】请将本次任务的最终结果生成为一个 Markdown(.md) 格式的文件,"
"保存到 /mnt/user-data/outputs 目录,并使用 present_files 展示该文件。"
"Markdown 内的目录/锚点跳转必须只跳转到当前这个 Markdown 文件内部:"
"只使用 [标题](#section-id),不要使用 [标题](xxx.md#section-id)、"
"[标题](xxx.html#section-id) 或任何跨文件锚点。"
)
_LOCAL_DOC_MARKDOWN_LINK_RE = re.compile(
r"^(?![a-z][a-z0-9+.-]*:)(?!//)(?P<path>[^#?]*\.(?:html?|md|markdown))(?P<query>\?[^#]*)?(?P<fragment>#.+)?$",
re.IGNORECASE,
)
_MARKDOWN_INLINE_LINK_RE = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
_MARKDOWN_REFERENCE_LINK_RE = re.compile(r"^(\s*\[[^\]]+\]:\s*)(\S+)(.*)$", re.MULTILINE)
class MarkdownRequirementError(Exception):
"""Raised when a ``require_markdown`` task did not produce a valid .md file.
The run is marked failed (no retry) so the user can see the output did not
meet the requirement (a Markdown file longer than the minimum length).
"""
class HtmlGenerationError(Exception):
"""Raised when an ``html_page`` run did not produce a complete, valid HTML
document (missing doctype/html/head/body, empty body, or residual scripts
after the sanitize + repair passes). The run is marked failed — a page only
counts as successful when it is a full, previewable HTML document."""
class TemplateFillError(Exception):
"""Raised when a 模板填充 run could not produce a valid template payload —
the source JSON was empty, the model never returned parseable JSON, or the
converted data failed structure validation after all retries. The run is
marked failed so the user sees why (no template page is archived)."""
class DuplicateFavoriteError(Exception):
"""A user already bookmarked this page. Surfaced as HTTP 409 by the router
so the same page cannot be favorited twice."""
def __init__(self, page_id: str) -> None:
self.page_id = page_id
super().__init__(f"Page {page_id!r} is already favorited")
class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name.
Task names are unique per user (``uq_scheduled_tasks_user_name``). The
service raises this instead of letting the raw DB ``IntegrityError`` bubble
up so the API layer can return a friendly 409 rather than a 500.
"""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"A scheduled task named {name!r} already exists")
@dataclass
class _RuntimeUser:
id: str
def _coerce_datetime(value: datetime | str | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
dt = value
else:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
def _normalize_timezone(timezone: str | None) -> str:
tz = (timezone or "UTC").strip() or "UTC"
try:
ZoneInfo(tz)
return tz
except Exception:
return "UTC"
def _schedule_to_cron(schedule: str) -> str:
value = schedule.strip()
if re.fullmatch(r"(\S+\s+){4}\S+", value):
return value
lower = value.lower()
hour_min = re.search(r"(?:(?:at|@)\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", lower)
cn_hour_min = re.search(r"(\d{1,2})\s*[点時时](?:(\d{1,2})\s*分?)?", value)
hour = 9
minute = 0
ampm = None
if hour_min:
hour = int(hour_min.group(1))
minute = int(hour_min.group(2) or 0)
ampm = hour_min.group(3)
elif cn_hour_min:
hour = int(cn_hour_min.group(1))
minute = int(cn_hour_min.group(2) or 0)
if ampm == "pm" and hour < 12:
hour += 12
if ampm == "am" and hour == 12:
hour = 0
hour = max(0, min(23, hour))
minute = max(0, min(59, minute))
if any(token in lower for token in ("weekday", "weekdays", "workday")) or "工作日" in value:
return f"{minute} {hour} * * 1-5"
weekdays = {
"monday": 1,
"tuesday": 2,
"wednesday": 3,
"thursday": 4,
"friday": 5,
"saturday": 6,
"sunday": 0,
"周一": 1,
"星期一": 1,
"周二": 2,
"星期二": 2,
"周三": 3,
"星期三": 3,
"周四": 4,
"星期四": 4,
"周五": 5,
"星期五": 5,
"周六": 6,
"星期六": 6,
"周日": 0,
"周天": 0,
"星期日": 0,
"星期天": 0,
}
for token, weekday in weekdays.items():
if token in lower or token in value:
return f"{minute} {hour} * * {weekday}"
every_hours = re.search(r"every\s+(\d+)\s*h", lower)
cn_every_hours = re.search(r"每隔\s*(\d+)\s*小?时", value)
if every_hours or cn_every_hours:
hours = int((every_hours or cn_every_hours).group(1))
hours = max(1, min(23, hours))
return f"0 */{hours} * * *"
return f"{minute} {hour} * * *"
def _next_run_at(cron_expr: str, timezone: str, base: datetime | None = None) -> datetime:
tz = ZoneInfo(timezone)
base_local = (base or datetime.now(UTC)).astimezone(tz)
next_local = croniter(cron_expr, base_local).get_next(datetime)
if next_local.tzinfo is None:
next_local = next_local.replace(tzinfo=tz)
return next_local.astimezone(UTC)
def _looks_like_markdown(content: str) -> bool:
markdown_patterns = (
r"^#{1,6}\s+",
r"^\s*[-*+]\s+",
r"^\s*\d+\.\s+",
r"```",
r"\|.+\|",
r"\[[^\]]+\]\([^)]+\)",
)
return any(re.search(pattern, content, flags=re.MULTILINE) for pattern in markdown_patterns)
def _task_requires_markdown(task: dict[str, Any]) -> bool:
"""Whether a task has the ``require_markdown`` flag set.
The flag lives inside ``execution_context`` (no dedicated column) so the
feature works without a schema migration on existing databases.
"""
ctx = task.get("execution_context")
return bool(isinstance(ctx, dict) and ctx.get("require_markdown"))
def _task_requires_html(task: dict[str, Any]) -> bool:
"""Whether a task must produce an HTML page (the ``必须生成 HTML`` switch).
The flag lives inside ``execution_context.require_html``. For backward
compatibility, tasks created under the old two-tab UI (which stored
``task_kind == "html_page"``) are still treated as HTML tasks, so no data
migration is needed. ``require_html`` and ``require_markdown`` are
independent — both may be on at once.
"""
ctx = task.get("execution_context")
if not isinstance(ctx, dict):
return False
if ctx.get("require_html"):
return True
return ctx.get("task_kind") == "html_page"
def _task_template_id(task: dict[str, Any]) -> str | None:
"""The HTML template a 模板填充 task targets, or ``None`` for a normal task.
Stored at ``execution_context.template_config.template_id``. When set, the
run converts the source JSON into that template's ``DATA`` structure and
fills it — a distinct mode from the free-form HTML render pipeline.
"""
ctx = task.get("execution_context")
if not isinstance(ctx, dict):
return None
cfg = ctx.get("template_config")
if not isinstance(cfg, dict):
return None
template_id = cfg.get("template_id")
return str(template_id) if template_id else None
def _has_valid_markdown_file(result_files: list[dict[str, Any]] | None) -> bool:
"""True when the run produced a ``.md`` file whose text exceeds the minimum.
For tasks with ``require_markdown`` enabled the run only succeeds if at least
one generated file is Markdown (``.md``/``.markdown``) and its content is
longer than :data:`_MARKDOWN_MIN_CHARS` characters. The file is read from its
archived path; binary/undecodable files never qualify.
"""
for item in result_files or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if not name.lower().endswith((".md", ".markdown")):
continue
path = item.get("path")
if not isinstance(path, str) or not path:
continue
try:
text = Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
if len(text.strip()) > _MARKDOWN_MIN_CHARS:
return True
return False
def _normalize_same_page_markdown_target(target: str) -> str | None:
value = (target or "").strip()
if not value or value.startswith("#"):
return value
match = _LOCAL_DOC_MARKDOWN_LINK_RE.match(value)
if not match:
return value
fragment = match.group("fragment")
return fragment or None
def _constrain_markdown_same_page_anchor_links(content: str) -> str:
"""Keep generated Markdown document jumps inside the current Markdown file."""
if not content or not isinstance(content, str):
return content or ""
def replace_inline(match: re.Match[str]) -> str:
label = match.group(1)
target = match.group(2)
normalized = _normalize_same_page_markdown_target(target)
if normalized is None:
return label
return f"[{label}]({normalized})"
def replace_reference(match: re.Match[str]) -> str:
prefix = match.group(1)
target = match.group(2)
suffix = match.group(3)
normalized = _normalize_same_page_markdown_target(target)
if normalized is None:
return f"{prefix}#"
return f"{prefix}{normalized}{suffix}"
content = _MARKDOWN_INLINE_LINK_RE.sub(replace_inline, content)
return _MARKDOWN_REFERENCE_LINK_RE.sub(replace_reference, content)
def _build_result(
task: dict[str, Any],
*,
scheduled_for: datetime,
content: str | None,
files: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
result_content = content or f"Scheduled task completed: {task['name']}"
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
return {
"title": f"{task['name']} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": result_content,
"format": "markdown" if _looks_like_markdown(result_content) else "text",
"files": files or [],
"created_at": datetime.now(UTC).isoformat(),
}
class ScheduledTaskService:
def __init__(self, app: Any, store: ScheduledTaskStore, *, poll_interval_seconds: float = 30.0) -> None:
self.app = app
self.store = store
self.poll_interval_seconds = poll_interval_seconds
self._loop_task: asyncio.Task | None = None
self._stopping = asyncio.Event()
self._running_task_ids: set[str] = set()
# All scheduled jobs deliver into one per-user scheduler thread. The
# current RunManager rejects concurrent runs on the same thread, so
# serialize starts here instead of letting reports fail with conflicts.
self._agent_run_lock = asyncio.Lock()
self._element_client = ElementDeliveryClient.from_env()
async def start(self) -> None:
if self._loop_task is None or self._loop_task.done():
self._stopping.clear()
self._loop_task = asyncio.create_task(self._run_loop(), name="scheduled-task-loop")
async def stop(self) -> None:
self._stopping.set()
if self._loop_task is not None:
self._loop_task.cancel()
try:
await self._loop_task
except asyncio.CancelledError:
pass
async def ensure_scheduler_thread(self, user_id: str) -> dict[str, Any]:
thread = await self.store.get_or_create_scheduler_thread(user_id, lambda: str(uuid.uuid4()))
await self._ensure_thread_meta(user_id, thread["thread_id"])
return thread
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
owned = await self.store.list_tasks(user_id)
for task in owned:
task["role"] = "owner"
subscribed = await self.store.list_subscribed_tasks(user_id)
owned_ids = {task["task_id"] for task in owned}
for task in subscribed:
if task["task_id"] in owned_ids:
continue
task["role"] = "subscriber"
owned.append(task)
return owned
async def list_published(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
"""Public discovery list. Strips author prompt details to a short summary."""
tasks = await self.store.list_published_tasks(limit=limit, offset=offset)
result: list[dict[str, Any]] = []
for task in tasks:
prompt = (task.get("prompt") or "").strip()
summary = prompt if len(prompt) <= 200 else prompt[:200].rstrip() + "…"
result.append(
{
"task_id": task["task_id"],
"name": task.get("name"),
"prompt_summary": summary,
"schedule_text": task.get("schedule_text"),
"timezone": task.get("timezone"),
"published_at": task.get("published_at"),
"owner_user_id": task.get("user_id"),
}
)
return result
async def _accessible_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
task = await self.store.get_task_any(task_id)
if task is None:
return None
if task["user_id"] == user_id:
task["role"] = "owner"
return task
sub = await self.store.get_subscription(task_id, user_id)
if sub is None:
return None
task["role"] = "subscriber"
task["_subscription"] = sub
return task
async def list_runs(self, user_id: str, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
accessible = await self._accessible_task(user_id, task_id)
if accessible is None:
return []
runs = await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
can_edit = accessible.get("role") == "owner"
for run in runs:
run["can_edit"] = can_edit
return runs
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
run = await self.store.get_run(run_id, user_id)
if run is not None:
run["can_edit"] = True
return run
# Subscriber visibility: look up the run unfiltered, then verify the
# caller has a subscription on the parent task.
candidate = await self.store.get_run_any(run_id)
if candidate is None:
return None
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
return None
candidate["can_edit"] = False
return candidate
async def update_run_file(
self,
user_id: str,
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
"""Overwrite an archived run file in place. Owner-only.
Returns updated file metadata. Raises:
- PermissionError if the caller is not the run owner (subscribers blocked)
- FileNotFoundError if the run or file index does not exist
- ValueError if the file is not editable (binary / not previewable)
"""
run = await self.store.get_run(run_id, user_id)
if run is None:
candidate = await self.store.get_run_any(run_id)
if candidate is None:
raise FileNotFoundError(f"Run {run_id} not found")
raise PermissionError("Only the run owner may edit result files")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_file(
self,
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
"""Owner-less variant of :meth:`update_run_file` used by the public viewer.
The public scheduled-task page is unauthenticated, so it cannot supply a
``user_id``. Mutations from that page route here instead. The disk-write
and result-row update logic is identical to the owner path.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_body(self, run_id: str, new_content: str) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Used by the public scheduled-task viewer so anonymous editors can fix
typos in the "正文" tab. Unlike :meth:`admin_update_run_file`, no disk
artifact backs the body — it lives entirely in the run row.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
result = run.get("result") if isinstance(run.get("result"), dict) else {}
new_result = {**result, "content": new_content}
await self.store.update_run(run_id, {"result": new_result})
return {"content": new_content}
async def _apply_run_file_edit(
self,
run: dict[str, Any],
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
result = run.get("result") or {}
files = result.get("files") if isinstance(result, dict) else None
if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
raise FileNotFoundError(f"File index {file_index} not found in run {run_id}")
item = files[file_index]
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise FileNotFoundError(f"File index {file_index} has no archive path")
archive_path = Path(item["path"])
if not archive_path.exists() or not archive_path.is_file():
raise FileNotFoundError(f"Archived file {archive_path} is no longer available")
# Constrain edits to text-previewable files. We mirror the same rule the
# router uses for previews so binary files cannot be silently corrupted.
suffix = archive_path.suffix.lower()
mime_type = item.get("mime_type")
is_text = (
(isinstance(mime_type, str) and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}))
or suffix in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
)
if not is_text:
raise ValueError("Only text-previewable files (markdown, txt, json, etc.) can be edited")
# Make sure we stay inside the per-run archive root before touching disk.
archive_root = (get_paths().base_dir / "scheduled-task-runs" / run_id).resolve()
try:
archive_path.resolve().relative_to(archive_root)
except ValueError as exc:
raise PermissionError("Refusing to edit file outside the run archive") from exc
archive_path.write_text(new_content, encoding="utf-8", newline="")
new_size = archive_path.stat().st_size
# Reflect the new size in the run row so subsequent list/preview calls
# report the latest figure. Other fields stay untouched.
files[file_index] = {**item, "size": new_size}
new_result = {**result, "files": files}
await self.store.update_run(run_id, {"result": new_result})
return {
"index": file_index,
"name": item.get("name") or archive_path.name,
"size": new_size,
"mime_type": mime_type,
"kind": item.get("kind"),
}
@staticmethod
def _remove_run_archive(run_id: str) -> None:
"""Best-effort cleanup of a run's on-disk result archive."""
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
if archive_dir.exists():
try:
shutil.rmtree(archive_dir)
except Exception:
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
async def delete_run(self, user_id: str, run_id: str) -> bool:
deleted = await self.store.delete_run(run_id, user_id)
if deleted:
self._remove_run_archive(run_id)
return True
candidate = await self.store.get_run_any(run_id)
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
raise PermissionError("Only the run owner may delete scheduled task runs")
return deleted
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
return await self.store.get_delivery_profile(user_id)
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
return await self.store.update_delivery_profile(user_id, data)
async def _assert_name_available(self, user_id: str, name: str, *, exclude_task_id: str | None = None) -> None:
"""Raise :class:`DuplicateTaskNameError` if ``user_id`` already owns ``name``.
Enforces ``uq_scheduled_tasks_user_name`` proactively so callers get a
clean conflict error instead of a raw DB ``IntegrityError``. The check
compares the trimmed name; ``exclude_task_id`` skips the task being
renamed so an unchanged name on update is not flagged as a collision.
"""
for existing in await self.store.list_tasks(user_id):
if exclude_task_id is not None and existing.get("task_id") == exclude_task_id:
continue
if (existing.get("name") or "").strip() == name:
raise DuplicateTaskNameError(name)
async def create_task(
self,
*,
user_id: str,
name: str,
prompt: str,
schedule: str,
timezone: str | None = None,
description: str | None = None,
source_thread_id: str | None = None,
source_agent_name: str | None = None,
execution_agent_name: str | None = None,
execution_context: dict[str, Any] | None = None,
require_markdown: bool = False,
) -> dict[str, Any]:
tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule)
next_run_at = _next_run_at(cron_expr, tz)
final_name = name.strip() or "Scheduled task"
await self._assert_name_available(user_id, final_name)
scheduler_thread = await self.ensure_scheduler_thread(user_id)
context = dict(execution_context or {})
if execution_agent_name:
context["agent_name"] = execution_agent_name
context["require_markdown"] = bool(require_markdown)
normalized_description = description.strip() if isinstance(description, str) else None
task = await self.store.create_task(
{
"task_id": str(uuid.uuid4()),
"user_id": user_id,
"scheduler_thread_id": scheduler_thread["thread_id"],
"source_thread_id": source_thread_id,
"source_agent_name": source_agent_name,
"execution_agent_name": execution_agent_name,
"name": final_name,
"description": normalized_description or None,
"prompt": prompt.strip(),
"schedule_text": schedule.strip(),
"cron_expr": cron_expr,
"timezone": tz,
"enabled": True,
"next_run_at": next_run_at,
"execution_context": context,
}
)
return task
@staticmethod
def _build_task_update(data: dict[str, Any], existing: dict[str, Any] | None) -> dict[str, Any]:
"""Translate an API update payload into a storage update dict.
Shared by owner-scoped :meth:`update_task` and admin :meth:`admin_update_task`
so both apply identical schedule/timezone normalization.
"""
update_data: dict[str, Any] = {}
for key in ("name", "prompt", "enabled", "execution_agent_name"):
if key in data and data[key] is not None:
update_data[key] = data[key]
# Description is nullable — accept ``None`` (clear) and empty strings
# (also clear) explicitly, so the field can be removed via PATCH.
if "description" in data:
raw = data["description"]
if isinstance(raw, str):
trimmed = raw.strip()
update_data["description"] = trimmed or None
else:
update_data["description"] = None
# Names are matched against the per-user uniqueness constraint, so keep
# the stored value trimmed to match create_task's normalization.
if isinstance(update_data.get("name"), str):
update_data["name"] = update_data["name"].strip() or "Scheduled task"
timezone = data.get("timezone")
if timezone is not None:
update_data["timezone"] = _normalize_timezone(timezone)
if data.get("schedule") is not None:
schedule = str(data["schedule"])
cron_expr = _schedule_to_cron(schedule)
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
update_data["schedule_text"] = schedule
update_data["cron_expr"] = cron_expr
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None:
update_data["execution_context"] = data["execution_context"]
# ``require_markdown`` is persisted inside execution_context (no dedicated
# column), so merge it into the context being written — preferring a
# context supplied in the same request, else the task's existing one.
if data.get("require_markdown") is not None:
base_ctx = (
update_data["execution_context"]
if "execution_context" in update_data
else (existing or {}).get("execution_context")
)
ctx = dict(base_ctx or {})
ctx["require_markdown"] = bool(data["require_markdown"])
update_data["execution_context"] = ctx
# Re-enabling a task must also schedule it forward from now, otherwise a
# paused task with a stale/empty next_run_at would never fire again.
if update_data.get("enabled") is True and "next_run_at" not in update_data:
cron = (existing or {}).get("cron_expr")
if cron:
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
update_data["next_run_at"] = _next_run_at(cron, tz)
return update_data
async def update_task(self, user_id: str, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
existing = await self.store.get_task(task_id, user_id)
if existing is None:
return None
update_data = self._build_task_update(data, existing)
if "name" in update_data:
await self._assert_name_available(user_id, update_data["name"], exclude_task_id=task_id)
return await self.store.update_task(task_id, user_id, update_data)
async def delete_task(self, user_id: str, task_id: str) -> bool:
return await self.store.delete_task(task_id, user_id)
# --- Admin (cross-user management) ---
async def admin_list_tasks(self) -> list[dict[str, Any]]:
"""List every scheduled task across all users. Admin-only callers."""
tasks = await self.store.list_all_tasks()
for task in tasks:
task["role"] = "owner"
return tasks
async def admin_update_task(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any task regardless of owner. Admin-only callers."""
existing = await self.store.get_task_any(task_id)
if existing is None:
return None
update_data = self._build_task_update(data, existing)
if "name" in update_data:
await self._assert_name_available(existing["user_id"], update_data["name"], exclude_task_id=task_id)
return await self.store.update_task_any(task_id, update_data)
async def admin_delete_task(self, task_id: str) -> bool:
"""Delete any task regardless of owner. Admin-only callers."""
return await self.store.delete_task_any(task_id)
async def admin_delete_run(self, run_id: str) -> bool:
"""Delete a single run regardless of owner. Admin-only callers."""
deleted = await self.store.delete_run_any(run_id)
if deleted:
self._remove_run_archive(run_id)
return deleted
async def admin_list_runs(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
"""List runs for any task regardless of owner. Admin-only callers."""
runs = await self.store.list_runs_any(task_id, limit=limit)
for run in runs:
run["can_edit"] = True
return runs
async def admin_get_run(self, run_id: str) -> dict[str, Any] | None:
"""Fetch a single run regardless of owner. Admin-only callers."""
run = await self.store.get_run_any(run_id)
if run is not None:
run["can_edit"] = True
return run
async def admin_trigger_task(self, task_id: str) -> dict[str, Any] | None:
"""Trigger an immediate run of any task regardless of owner."""
task = await self.store.get_task_any(task_id)
if task is None:
return None
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
return task
async def pause_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.update_task(task_id, user_id, {"enabled": False})
async def resume_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
existing = await self.store.get_task(task_id, user_id)
if existing is None:
return None
next_run_at = _next_run_at(existing["cron_expr"], existing.get("timezone") or "UTC")
return await self.store.update_task(task_id, user_id, {"enabled": True, "next_run_at": next_run_at})
async def trigger_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
task = await self.store.get_task(task_id, user_id)
if task is None:
return None
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
return task
async def _run_loop(self) -> None:
while not self._stopping.is_set():
# Recover hung / orphaned runs first so a single stuck task cannot
# silently wedge the scheduler indefinitely.
try:
await self._reconcile_stuck_runs()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Scheduled task watchdog scan failed")
try:
due = await self.store.due_tasks(datetime.now(UTC), limit=20)
for task in due:
task_id = task["task_id"]
if task_id in self._running_task_ids:
continue
self._running_task_ids.add(task_id)
asyncio.create_task(self._execute_and_untrack(task))
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Scheduled task scan failed")
try:
await asyncio.wait_for(self._stopping.wait(), timeout=self.poll_interval_seconds)
except TimeoutError:
pass
async def _execute_and_untrack(self, task: dict[str, Any]) -> None:
try:
scheduled_for = _coerce_datetime(task.get("next_run_at")) or datetime.now(UTC)
await self._execute_task(task, scheduled_for=scheduled_for, manual=False)
finally:
self._running_task_ids.discard(task["task_id"])
async def _execute_tracked(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
"""Run a task while marking it busy so the watchdog won't double-drive it."""
self._running_task_ids.add(task["task_id"])
try:
await self._execute_task(task, scheduled_for=scheduled_for, manual=manual)
finally:
self._running_task_ids.discard(task["task_id"])
async def _reconcile_stuck_runs(self) -> None:
"""Recover runs left in the ``running`` state with no in-process owner.
A run becomes stuck when the agent loop hangs past the per-attempt
timeout, or when the process crashes / restarts mid-run (the original
asyncio task is gone but the DB row still says ``running``). For each
such run we retry up to ``_MAX_RUN_RETRIES`` times, then mark it
``failed`` so it no longer shows as perpetually executing.
"""
cutoff = datetime.now(UTC) - timedelta(seconds=_RUN_ATTEMPT_TIMEOUT_SECONDS)
stuck = await self.store.list_stuck_runs(cutoff, limit=20)
for run in stuck:
task_id = run["task_id"]
# A run still owned by a live in-process execution is handled by
# that execution's own timeout/retry loop — don't touch it here.
if task_id in self._running_task_ids:
continue
self._running_task_ids.add(task_id)
asyncio.create_task(self._recover_and_untrack(run))
async def _recover_and_untrack(self, run: dict[str, Any]) -> None:
try:
await self._recover_stuck_run(run)
except Exception:
logger.exception("Failed to recover stuck scheduled task run %s", run.get("id"))
finally:
self._running_task_ids.discard(run["task_id"])
async def _recover_stuck_run(self, run: dict[str, Any]) -> None:
run_id = run["id"]
retry_count = int(run.get("retry_count") or 0)
task = await self.store.get_task_any(run["task_id"])
scheduled_for = _coerce_datetime(run.get("scheduled_for")) or datetime.now(UTC)
if task is None:
# Parent task was deleted — just close out the orphaned run.
await self.store.update_run(
run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": "执行超时:所属任务已被删除"},
)
return
if retry_count >= _MAX_RUN_RETRIES:
await self._fail_run(
task,
run_id,
scheduled_for,
error=f"执行超时:超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟未完成,重试 {_MAX_RUN_RETRIES} 次后仍失败",
retry_count=retry_count,
)
return
logger.warning("Recovering stuck scheduled task run %s (retry budget %s/%s)", run_id, retry_count, _MAX_RUN_RETRIES)
await self._execute_task(task, scheduled_for=scheduled_for, manual=False, run_row=run)
async def _fail_run(
self,
task: dict[str, Any],
run_id: str,
scheduled_for: datetime,
*,
error: str,
retry_count: int = 0,
) -> None:
"""Mark a run failed and reflect the outcome on the parent task."""
result = _build_result(task, scheduled_for=scheduled_for, content=f"定时任务执行失败:{error}")
await self.store.update_run(
run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
)
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "failed"})
async def _execute_task(
self,
task: dict[str, Any],
*,
scheduled_for: datetime,
manual: bool,
run_row: dict[str, Any] | None = None,
) -> None:
# Each run uses its own throwaway thread so message history cannot accumulate
# across runs and blow past the model's input window. The per-user
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
# we keep it on the task row only for backwards compatibility.
is_recovery = run_row is not None
if run_row is None:
run_row = await self.store.create_run_once(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"user_id": task["user_id"],
"scheduler_thread_id": str(uuid.uuid4()),
"scheduled_for": scheduled_for,
"status": "running",
"started_at": datetime.now(UTC),
}
)
if run_row is None:
return
else:
# Recovery path: re-claim an existing (orphaned) run row.
await self.store.update_run(run_row["id"], {"status": "running", "started_at": datetime.now(UTC)})
# Advancing the schedule only happens once, when the run is first
# created — recovery retries reuse the original scheduled_for.
if not manual and not is_recovery:
next_run_at = _next_run_at(task["cron_expr"], task.get("timezone") or "UTC", base=scheduled_for)
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
run_id = run_row["id"]
retry_count = int(run_row.get("retry_count") or 0)
while True:
runtime_thread_id = str(uuid.uuid4())
try:
result = await asyncio.wait_for(
self._run_task_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
)
except TimeoutError:
# The attempt overran the 30-minute cap. Retry a bounded number
# of times, then give up and mark the run failed.
if retry_count >= _MAX_RUN_RETRIES:
await self._fail_run(
task,
run_id,
scheduled_for,
error=f"执行超时:单次执行超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟,重试 {_MAX_RUN_RETRIES} 次后仍失败",
retry_count=retry_count,
)
return
retry_count += 1
logger.warning("Scheduled task %s timed out; retry %s/%s", task["task_id"], retry_count, _MAX_RUN_RETRIES)
await self.store.update_run(
run_id,
{
"retry_count": retry_count,
"status": "running",
"started_at": datetime.now(UTC),
"error": f"执行超时,正在重试(第 {retry_count}/{_MAX_RUN_RETRIES} 次)",
},
)
continue
except Exception as exc:
logger.exception("Scheduled task %s failed", task["task_id"])
await self._fail_run(task, run_id, scheduled_for, error=str(exc), retry_count=retry_count)
return
await self.store.update_run(
run_id,
{"status": "succeeded", "finished_at": datetime.now(UTC), "result": result, "retry_count": retry_count, "error": None},
)
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "succeeded"})
await self._deliver_element_result(task, result["content"])
return
async def _run_task_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Dispatch a single execution attempt by the task's output requirements.
A task carries two independent switches in ``execution_context``:
``require_html`` (必须生成 HTML) and ``require_markdown`` (必须生成
Markdown). When ``require_html`` is set the run goes through the HTML
render pipeline (which also enforces the Markdown requirement when both
switches are on); otherwise it runs the regular lead-agent attempt.
Both flags live in ``execution_context`` so no schema migration is
needed, and old ``task_kind == "html_page"`` tasks still resolve to the
HTML pipeline via :func:`_task_requires_html`.
"""
# 模板填充模式优先:选中了 HTML 模板就走「转换 JSON → 填充固定模板」分支,
# 与「检索数据 → 自由渲染 HTML」(require_html) 是两套不同流程。
if _task_template_id(task) is not None:
return await self._run_template_fill_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if _task_requires_html(task):
return await self._run_html_page_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
return await self._run_agent_attempt(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
@staticmethod
def _html_config(task: dict[str, Any]) -> dict[str, Any]:
ctx = task.get("execution_context")
cfg = ctx.get("html_config") if isinstance(ctx, dict) else None
return cfg if isinstance(cfg, dict) else {}
async def _invoke_chat_model(self, model_name: str | None, prompt: str) -> str:
"""Invoke a chat model once and return its text content.
Used for the GLM5 HTML generation / repair / style-extraction passes.
Thinking is disabled to keep structured-JSON output stable.
"""
from langchain_core.messages import HumanMessage
from deerflow.models import create_chat_model
# No hardcoded model: use exactly the selected model; only when none was
# selected do we fall back to the system default (config models[0]).
model = create_chat_model(name=model_name or None, thinking_enabled=False)
response = await model.ainvoke([HumanMessage(content=prompt)])
content = getattr(response, "content", response)
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
return "\n".join(p for p in parts if p)
return str(content)
async def _gather_html_data(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> tuple[str, list[dict[str, Any]]]:
"""Run the lead agent to collect source data for the HTML page.
Reuses the regular agent-run primitives (with the task's search query
folded into the prompt) and returns ``(text, files)`` — the agent's
final text (feeds the render step as ``本次数据``) plus any files it
produced. The files are only captured/needed when the task also has the
Markdown switch on (both 必须生成 HTML and 必须生成 Markdown), so the run
can validate and surface the ``.md`` file alongside the HTML page;
otherwise the list is empty.
"""
cfg = self._html_config(task)
page_prompt = str(cfg.get("page_prompt") or "").strip()
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
also_markdown = _task_requires_markdown(task)
gather_instruction = (
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
)
parts = [gather_instruction]
if page_prompt:
parts.append(f"页面需求:{page_prompt}")
base_prompt = str(task.get("prompt") or "").strip()
if base_prompt:
parts.append(f"补充说明:{base_prompt}")
# Apply the user's constraints to data gathering too, not just rendering
# — a "限制条件" like "只要最近 24 小时的新闻" or "排除某主题" must shape
# which sources/data the agent collects, otherwise it silently leaks in.
if negative_prompt:
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
gather_prompt = "\n".join(parts)
# When the Markdown switch is also on, this same gather pass must save a
# .md file. We do NOT append _MARKDOWN_INSTRUCTION here — `_start_agent_run`
# already appends it exactly once when `require_markdown` is present in the
# execution context (kept below). Appending it here too made the prompt
# carry the 【输出要求】 block twice when both HTML and Markdown were on.
gather_task = {**task, "prompt": gather_prompt}
gather_ctx = dict(task.get("execution_context") or {})
# Strip the HTML-specific flags so the gather pass is a plain agent run.
gather_ctx.pop("require_html", None)
gather_ctx.pop("task_kind", None)
gather_ctx.pop("html_config", None)
# Keep ``require_markdown`` only when both switches are on, so the agent
# is told (via _start_agent_run) to produce the .md file.
if not also_markdown:
gather_ctx.pop("require_markdown", None)
# Use the task's selected model for the data-gathering agent too, so the
# whole HTML pipeline honors ``html_config.model_name`` instead of
# silently falling back to the default model for this step.
gather_model = str(cfg.get("model_name") or "").strip()
if gather_model:
gather_ctx["model_name"] = gather_model
gather_task["execution_context"] = gather_ctx
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
files: list[dict[str, Any]] = []
async with self._agent_run_lock:
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"]) if also_markdown else None
record = await self._start_agent_run(gather_task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
if record.task is not None:
await record.task
text = await self._extract_latest_ai_text(runtime_thread_id)
if also_markdown:
files = await self._extract_result_files(
runtime_thread_id,
task["user_id"],
run_id=run_id,
output_snapshot=output_snapshot,
)
return text or "", files
def _collect_html_skill_instructions(self) -> str:
"""Concatenate the bodies of enabled skills marked for HTML pages.
A skill opts in via ``metadata.kind: html_page`` in its SKILL.md
frontmatter. Only enabled skills (per the skills settings the user
toggles) are injected, so the render step honors the user's skill
selection. Best-effort — any failure yields an empty string.
"""
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=True)
except Exception:
logger.debug("Failed to load skills for HTML render injection", exc_info=True)
return ""
blocks: list[str] = []
for skill in skills:
try:
text = Path(skill.skill_file).read_text(encoding="utf-8")
except Exception:
continue
frontmatter, body = html_page_utils.parse_frontmatter(text)
meta = frontmatter.get("metadata") if isinstance(frontmatter, dict) else None
if not (isinstance(meta, dict) and str(meta.get("kind")) == "html_page"):
continue
body = body.strip()
if body:
blocks.append(f"### 技能:{skill.name}\n{body}")
return "\n\n".join(blocks)
def _archive_html_index(self, run_id: str, html: str) -> Path | None:
"""Write the generated HTML to the run's archive dir as ``index.html``."""
try:
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
archive_dir.mkdir(parents=True, exist_ok=True)
target = archive_dir / "index.html"
target.write_text(html, encoding="utf-8")
return target
except Exception:
logger.warning("Failed to archive generated HTML for run %s", run_id, exc_info=True)
return None
async def _run_html_page_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Generate an HTML page for an ``html_page`` task.
Pipeline: gather data via the lead agent → optionally load a reference
favorite's style/layout → GLM5 render → sanitize → validate → (one
GLM5 repair pass if invalid) → best-effort screenshot → archive
``index.html`` → persist a ``scheduled_html_pages`` row → build the run
result payload (``format: "html"`` with the full HTML inline).
"""
cfg = self._html_config(task)
# The user's selected model only — no hardcoded GLM-5. Empty → None →
# system default (config models[0]).
model_name = str(cfg.get("model_name") or "").strip() or None
page_prompt = str(cfg.get("page_prompt") or "").strip()
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
reference_favorite_id = cfg.get("reference_favorite_id") or None
# 选了内置模板作参考(``tpl:<id>``) → 用**真实模板**出页,保留全部子页面;
# 绝不走下面的自由渲染(模型重画会丢掉 JS 驱动的多页/子页面)。
tpl_ref_id = (
template_fill.reference_template_id(str(reference_favorite_id))
if reference_favorite_id
else None
)
if tpl_ref_id is not None:
return await self._run_html_template_reference(
task, tpl_id=tpl_ref_id, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
# 1) Gather source data (real, via the agent's search tools). When the
# Markdown switch is also on, the gather pass additionally saves the .md
# file, returned here so we can validate + surface it next to the page.
also_markdown = _task_requires_markdown(task)
data_text, gathered_files = await self._gather_html_data(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if also_markdown and not _has_valid_markdown_file(gathered_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
# 2) Load reference style/layout from a favorite if one was selected.
# (模板参考 ``tpl:<id>`` 已在上面分支提前 return,这里只剩用户收藏的页面。)
reference_style = None
reference_layout = None
if reference_favorite_id:
store = self._html_store
if store is not None:
try:
favorite = await store.get_favorite(str(reference_favorite_id))
except Exception:
favorite = None
if favorite is not None:
reference_style = favorite.get("reuse_prompt") or favorite.get("style_summary")
reference_layout = favorite.get("layout_summary")
# 3) Render with the chosen model, injecting any enabled HTML skills.
skill_instructions = self._collect_html_skill_instructions()
gen_prompt = html_page_utils.build_generation_prompt(
page_prompt=page_prompt,
negative_prompt=negative_prompt,
data=data_text,
reference_style=reference_style,
reference_layout=reference_layout,
skill_instructions=skill_instructions,
)
raw = await self._invoke_chat_model(model_name, gen_prompt)
parsed = html_page_utils.extract_json_object(raw) or {}
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
html = parsed.get("html")
if not isinstance(html, str) or "<" not in html:
# The JSON wrapper couldn't be parsed (the model emitted literal
# newlines / unescaped quotes inside the "html" value), or it replied
# with bare HTML. Either way, recover just the HTML document so we
# never archive the {"title":...,"html":"..."} JSON blob as the page.
recovered = html_page_utils.recover_html_document(raw)
if recovered is None:
raise ValueError("模型未返回有效的 HTML(JSON 解析失败)")
html = recovered
style_summary = str(parsed.get("style_summary") or "").strip() or None
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
# 4) Sanitize + validate, with one repair pass on failure.
html, _removed = html_page_utils.sanitize_html(html)
html, _link_changes = html_page_utils.constrain_same_page_anchor_links(html)
errors = html_page_utils.validate_html(html)
if errors:
logger.info("Generated HTML failed validation (%s); attempting repair", errors)
repair_prompt = html_page_utils.build_repair_prompt(
html=html, errors=errors, requirements=page_prompt or task.get("prompt")
)
try:
repaired_raw = await self._invoke_chat_model(model_name, repair_prompt)
repaired = html_page_utils.extract_json_object(repaired_raw) or {}
fixed = repaired.get("fixed_html")
if isinstance(fixed, str) and "<" in fixed:
fixed_clean, _ = html_page_utils.sanitize_html(fixed)
fixed_clean, _ = html_page_utils.constrain_same_page_anchor_links(fixed_clean)
if not html_page_utils.validate_html(fixed_clean):
html = fixed_clean
except Exception:
logger.warning("HTML repair pass failed; keeping sanitized original", exc_info=True)
# 4b) Completeness gate — a page only counts as successful when it is a
# full, valid HTML document. If it still fails validation after the
# repair pass, fail the run (no half-baked page is archived/persisted).
final_errors = html_page_utils.validate_html(html)
if final_errors:
raise HtmlGenerationError(
"HTML 生成未通过完整性校验:" + ";".join(final_errors) + "。请调整页面要求或重试。"
)
# 5) Best-effort screenshot QA (no-op without Playwright).
screenshot_path = None
try:
shot_target = get_paths().base_dir / "scheduled-task-runs" / run_id / "files" / "preview.png"
shot_target.parent.mkdir(parents=True, exist_ok=True)
screenshot_path = await html_page_utils.screenshot_html(html, shot_target)
except Exception:
screenshot_path = None
# 6) Archive index.html.
archive_path = self._archive_html_index(run_id, html)
files: list[dict[str, Any]] = []
# When both switches are on, surface the gathered Markdown file(s)
# alongside the generated page so the run delivers both artifacts.
if also_markdown:
for item in gathered_files:
name = str(item.get("name") or "") if isinstance(item, dict) else ""
if name.lower().endswith((".md", ".markdown")):
files.append(item)
if archive_path is not None:
files.append(
{
"name": "index.html",
"path": str(archive_path),
"virtual_path": "/mnt/user-data/outputs/index.html",
"mime_type": "text/html",
"size": archive_path.stat().st_size if archive_path.exists() else None,
"kind": "html",
}
)
if screenshot_path:
shot = Path(screenshot_path)
files.append(
{
"name": "preview.png",
"path": str(shot),
"mime_type": "image/png",
"size": shot.stat().st_size if shot.exists() else None,
"kind": "artifact",
}
)
data_snapshot = {"data_text": data_text[:8000] if isinstance(data_text, str) else ""}
prompt_snapshot = {
"page_prompt": page_prompt,
"negative_prompt": negative_prompt,
"reference_favorite_id": reference_favorite_id,
"model_name": model_name,
}
# 7) Persist the page asset (best-effort — failure must not fail the run).
store = self._html_store
if store is not None:
try:
await store.create_page(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"run_id": run_id,
"user_id": task["user_id"],
"title": title,
"html_content": html,
"html_file_path": str(archive_path) if archive_path else None,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"reference_favorite_id": reference_favorite_id,
"style_summary": style_summary,
"layout_summary": layout_summary,
}
)
except Exception:
logger.warning("Failed to persist scheduled_html_pages row for run %s", run_id, exc_info=True)
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
# ``scheduled_task_runs.result_json`` is TEXT (64 KB) on MySQL, so only
# inline the HTML when it comfortably fits. The full document always
# lives in ``scheduled_html_pages.html_content`` (LONGTEXT) + the
# archived ``index.html``; the ``/runs/{id}/html`` endpoints read from
# there, so previews work even when the inline copy is omitted here.
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
return {
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": page_prompt or f"HTML 页面已生成:{title}",
"format": "html",
"html": inline_html,
"style_summary": style_summary,
"layout_summary": layout_summary,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
@staticmethod
def _build_conversion_prompt(
skeleton_json: str,
source: str,
embedded_json: Any | None = None,
instructions: str | None = None,
) -> str:
parts = [
"请把下面的【源数据】转换成【目标结构】要求的 JSON,用于填充一个 HTML 展示模板。",
"模板只是一层**样式外壳**:它的字段名固定(页面脚本按字段名读取,绝不能改),"
"但每个字段的**含义要按源数据的主题重新解释**——把主题内容语义映射进各分区,"
"例如:总览指标填主题的核心数字;分组/区域类列表填主题的分区、品类或阵营;"
"组织/单位类列表填主题的机构、厂商或层级体系;装备/条目类列表填主题的产品、"
"技术或关键要素;事件/项目类列表填主题的时间线事项。内容必须全部来自【源数据】"
"的主题,不要被模板原有示例的领域限制住。",
"源数据可能是一段 JSON、一段纯文本、或包含 JSON 的文本——"
"其中若有结构化 JSON 以 JSON 内容为准;若是纯文本请理解其语义后据实填充;"
"源里没有对应内容的字段按其类型留空(\"\" / [] / null),不要臆造。",
"若下面的【任务要求】里带有对页面的明确修改指示(如“标题改为…”“只更新某部分”),"
"优先满足这些指示,其余字段可留空(页面将沿用模板现有内容);"
"没有指示到的部分按源数据自由发挥,尽量把各分区填满。",
"字段名必须与【目标结构】逐字一致(包括中文键名,原样复制),"
"严禁自造、翻译或改写字段名;数组元素的字段集合必须与样例元素相同,"
"源里没有的字段按类型留空即可,绝不能用自造的字段名顶替,也不要照抄任何示例值。",
"另外请在输出的 JSON 里额外携带两个顶层字符串字段:「页面标题」(按主题拟一个"
"页面大标题;若任务要求里明确给了标题,就用它)和「页面副标题」(一句话副标题)。",
"",
"【目标结构】(字段与类型的空占位骨架,数组只示意 1 个样例元素,实际按源数据填充全部元素):",
"```json\n" + skeleton_json + "\n```",
"",
]
if instructions and instructions.strip():
parts.append("【任务要求】(用户对本次页面的要求/修改指示,优先级最高):")
parts.append(instructions.strip())
parts.append("")
if embedded_json is not None:
# 已从源数据里识别出结构化 JSON,单独标注帮助(尤其弱)模型对齐。
parts.append("【从源数据中识别到的结构化数据】(优先以此为准):")
parts.append("```json\n" + json.dumps(embedded_json, ensure_ascii=False) + "\n```")
parts.append("")
parts.append("【源数据原文】(含上面 JSON 之外的补充说明,一并参考):")
else:
parts.append("【源数据】:")
parts.append(source)
parts.append("")
parts.append("只输出一个 ```json 代码块,内含填充好的完整目标 JSON(含「页面标题」「页面副标题」),不要任何解释文字。")
return "\n".join(parts)
@staticmethod
def _build_retry_prompt(base_prompt: str, problems: list[str], gaps: list[str]) -> str:
lines = [base_prompt, "\n\n上一轮转换结果未通过校验,请严格修正后重新输出**完整**的目标 JSON:"]
if problems:
lines.append("- " + ";".join(problems[:12]))
if gaps:
lines.append("- 字段缺失(请补齐):" + "、".join(gaps[:30]))
return "\n".join(lines)
async def _invoke_conversion_model(self, model_name: str | None, soul: str | None, user_prompt: str) -> str:
"""Invoke the conversion model once (SOUL as system, prompt as human).
Thinking is disabled for stable JSON output. Returns the model's text.
"""
from langchain_core.messages import HumanMessage, SystemMessage
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name or None, thinking_enabled=False)
messages: list[Any] = []
if soul:
messages.append(SystemMessage(content=soul))
messages.append(HumanMessage(content=user_prompt))
response = await model.ainvoke(messages)
content = getattr(response, "content", response)
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [part.get("text", "") if isinstance(part, dict) else str(part) for part in content]
return "\n".join(p for p in parts if p)
return str(content)
async def _agent_soul_and_model(
self, task: dict[str, Any], model_name: str | None
) -> tuple[str | None, str | None]:
"""Resolve the task agent's SOUL; fall back to its model when none chosen.
Used by both template pipelines so the **task's selected agent** shapes
content conversion: the SOUL carries the agent's domain instructions,
and when the user didn't pick an explicit model the agent's configured
model is preferred over the system default. Returns ``(soul, model)``.
"""
agent_ref = task.get("execution_agent_name")
agent_id = await self._resolve_agent_id(agent_ref, task["user_id"]) if agent_ref else None
soul = load_agent_soul(agent_id) if agent_id else None
if not model_name and agent_id:
try:
agent_cfg = load_agent_config(agent_id)
model_name = (agent_cfg.model if agent_cfg else None) or None
except Exception:
model_name = None
return soul, model_name
async def _convert_to_template_data(
self,
template_id: str,
source: str,
*,
model_name: str | None,
soul: str | None,
run_id: str,
instructions: str | None = None,
) -> dict[str, Any]:
"""把源数据(JSON/文本/含JSON文本)转换成模板的 DATA 结构,校验+回喂重试。
通过(结构+字段对齐)返回校验过的对象;重试耗尽时**尽力而为**——只要解析
出过 JSON 对象就返回最后一个(缺顶层键/字段错位的分支由随后的
``merge_with_reference`` 回退模板默认数据,抽多少用多少);只有全程没解析
出 JSON 才抛 :class:`TemplateFillError`(由调用方兜底为默认数据,不让
run 失败)。
"""
skeleton = template_fill.schema_skeleton_json(template_id)
# 源数据可能是纯 JSON / 纯文本 / 含 JSON 的文本:先尽力抽出内嵌 JSON 单独标注。
embedded_json = template_fill.extract_embedded_json(source)
base_prompt = self._build_conversion_prompt(skeleton, source, embedded_json, instructions=instructions)
last_problems: list[str] = []
prompt = base_prompt
candidate: dict[str, Any] | None = None
for attempt in range(_MAX_TEMPLATE_FILL_RETRIES + 1):
raw = await self._invoke_conversion_model(model_name, soul, prompt)
parsed = html_page_utils.extract_json_object(raw)
if parsed is None:
last_problems = ["模型未返回可解析的 JSON 对象"]
else:
candidate = parsed # 记住最后一个可解析结果,供重试耗尽时尽力返回
errors = [f"结构错误:{e}" for e in template_fill.validate_data(template_id, candidate)]
mismatches = [f"字段错位:{m}" for m in template_fill.shape_mismatches(template_id, candidate)]
if not errors and not mismatches:
return candidate
last_problems = errors + mismatches
gaps = template_fill.field_coverage_gaps(template_id, candidate)
prompt = self._build_retry_prompt(base_prompt, last_problems, gaps)
logger.info(
"模板填充[run=%s]第 %d 次校验未通过:%s",
run_id,
attempt + 1,
";".join(last_problems[:5]),
)
if candidate is not None:
logger.warning(
"模板填充[run=%s]重试耗尽仍未通过校验,尽力返回待兜底:%s",
run_id,
";".join(last_problems[:5]),
)
return candidate
raise TemplateFillError("转换结果未通过结构校验:" + ";".join(last_problems[:8]))
async def _finalize_template_html(
self,
task: dict[str, Any],
*,
spec: Any,
template_id: str,
html: str,
run_id: str,
scheduled_for: datetime,
content: str,
source_chars: int,
used_default: bool,
extra_files: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""归档 index.html + 持久化 scheduled_html_pages + 构造 format=html 结果。
``extra_files`` 会排在 index.html 之前一并带上(如必须生成 Markdown 时的 .md)。
"""
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
archive_path = self._archive_html_index(run_id, html)
title = str(task.get("name") or spec.name).strip()
files: list[dict[str, Any]] = list(extra_files or [])
if archive_path is not None:
files.append(
{
"name": "index.html",
"path": str(archive_path),
"virtual_path": "/mnt/user-data/outputs/index.html",
"mime_type": "text/html",
"size": archive_path.stat().st_size if archive_path.exists() else None,
"kind": "html",
}
)
data_snapshot = {"template_id": template_id, "source_chars": source_chars, "used_default": used_default}
prompt_snapshot = {"template_id": template_id, "used_default": used_default}
store = self._html_store
if store is not None:
try:
await store.create_page(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"run_id": run_id,
"user_id": task["user_id"],
"title": title,
"html_content": html,
"html_file_path": str(archive_path) if archive_path else None,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"reference_favorite_id": None,
"style_summary": None,
"layout_summary": None,
}
)
except Exception:
logger.warning("Failed to persist scheduled_html_pages row for template run %s", run_id, exc_info=True)
inline_html = html if len(html) <= _MAX_INLINE_RESULT_HTML else None
return {
"title": f"{title} - {local_time.strftime('%Y-%m-%d %H:%M')}",
"content": content,
"format": "html",
"html": inline_html,
"data_snapshot": data_snapshot,
"prompt_snapshot": prompt_snapshot,
"files": files,
"created_at": datetime.now(UTC).isoformat(),
}
async def _run_html_template_reference(
self,
task: dict[str, Any],
*,
tpl_id: str,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""require_html 模式选了内置模板(``tpl:<id>``):用**真实模板**出页,保留全部子页面。
模板是 JS 驱动的多页应用,子页面靠脚本从 DATA 渲染——绝不能让模型重画(会丢子
页面)。故:可转换模板=先用智能体检索真实数据→转换成模板 DATA→填进真模板;只展示
模板=原样输出。转换失败兜底为模板默认数据,至少保证页面与子页面可用。
"""
spec = template_fill.get_template(tpl_id)
if spec is None:
raise HtmlGenerationError(f"未知的 HTML 模板:{tpl_id}")
cfg = self._html_config(task)
model_name = str(cfg.get("model_name") or "").strip() or None
# 「执行数据抽取」开关**只**控制 HTML 用「抽取数据」还是「模板默认数据」,
# 不影响 Markdown 生成等其它正常操作。缺省(未存该键的老任务)视为开启:
# 模板有真实数据来源后,默认走「智能体检索 → 转换填充」,显式 False 才关闭。
extract = cfg.get("template_extract") is not False
also_markdown = _task_requires_markdown(task)
prompt = str(task.get("prompt") or "").strip()
description = str(task.get("description") or "").strip()
# 真正抽取 HTML 数据:开关开 且 有描述/任务 prompt 且 模板可转换(只展示模板恒不抽取)。
do_extract = extract and bool(prompt or description) and spec.convertible
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
def _pick_md(gathered: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
item
for item in (gathered or [])
if isinstance(item, dict) and str(item.get("name") or "").lower().endswith((".md", ".markdown"))
]
# gather:HTML 要抽取 或 需要 Markdown 时才跑(同一次 agent 运行可兼顾两者);
# 都不需要则完全不跑 agent。Markdown 校验独立于抽取开关。
data_text = ""
md_files: list[dict[str, Any]] = []
if do_extract or also_markdown:
data_text, gathered = await self._gather_html_data(
task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id
)
if also_markdown:
if not _has_valid_markdown_file(gathered):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
md_files = _pick_md(gathered)
# 只展示模板:HTML 原样输出(含全部子页面);Markdown 已按需独立生成。
if not spec.convertible:
html = template_fill.read_template_text(tpl_id)
return await self._finalize_template_html(
task,
spec=spec,
template_id=tpl_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=f"已套用模板「{spec.name}」(含全部子页面)",
source_chars=0,
used_default=True,
extra_files=md_files,
)
# HTML 数据来源:抽取(开关开 + 有数据)→ 转换填充;否则用模板默认数据(子页面齐全)。
source = (data_text or "").strip()
used_default = True
page_title: str | None = None
page_subtitle: str | None = None
if do_extract and source:
# 转换阶段同样带上任务所选智能体:SOUL 提供领域口径,未显式选模型时
# 兜底用该智能体配置的模型(与模板填充模式一致)。
soul, conv_model = await self._agent_soul_and_model(task, model_name)
# 任务的描述/prompt 作为「任务要求」注入:用户对页面的修改指示(如
# "标题改为…"、"只更新某部分")优先级最高,没指示就按检索内容自由发挥。
instructions = "\n".join(part for part in (description, prompt) if part)
try:
converted = await self._convert_to_template_data(
tpl_id, source, model_name=conv_model, soul=soul, run_id=run_id, instructions=instructions or None
)
# 页面标题/副标题是额外键,摘出来替换页头,不进 DATA。
page_title, page_subtitle = template_fill.pop_page_titles(converted)
# 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。
data_obj = template_fill.merge_with_reference(tpl_id, converted)
content = f"已检索数据并套用模板「{spec.name}」(空子页面回退原数据,含全部子页面)"
used_default = False
except TemplateFillError:
logger.warning("模板参考转换失败,回退默认数据 run=%s tpl=%s", run_id, tpl_id, exc_info=True)
data_obj = template_fill.reference_data(tpl_id)
content = f"数据转换未通过,已回退模板「{spec.name}」默认数据(含全部子页面)"
else:
data_obj = template_fill.reference_data(tpl_id)
content = (
f"未开启数据抽取,已用模板「{spec.name}」默认数据(含全部子页面)"
if not extract
else f"未提供描述/未获取到数据,已用模板「{spec.name}」默认数据(含全部子页面)"
)
try:
html = template_fill.fill_template(
tpl_id, data_obj, update_date=local_time.date(), title=page_title, subtitle=page_subtitle
)
except template_fill.TemplateError as exc:
raise HtmlGenerationError(f"模板填充失败:{exc}") from exc
return await self._finalize_template_html(
task,
spec=spec,
template_id=tpl_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=content,
source_chars=len(source),
used_default=used_default,
extra_files=md_files,
)
async def _run_template_fill_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""模板填充模式:把源 JSON 转换成所选模板的 DATA 结构,校验后填充产出 HTML。
Pipeline: 取源 JSON(任务 prompt) → 用内置「模板数据转换器」agent 的 SOUL +
模型把它映射成目标结构 → 结构校验(不过则回喂错误重试) → 填充模板 → 归档
``index.html`` + 持久化 ``scheduled_html_pages`` → 返回 ``format: "html"``
结果(复用现有 HTML 预览)。转换质量问题一律**不失败**:抽不到/抽错的分支由
``merge_with_reference`` 回退模板原有数据,转换彻底失败则整页用模板默认数据;
只有模板文件本身缺失/损坏才抛 :class:`TemplateFillError` 让 run 失败。
"""
template_id = _task_template_id(task)
spec = template_fill.get_template(template_id) if template_id else None
if spec is None:
raise TemplateFillError(f"未知的 HTML 模板:{template_id!r}")
source = str(task.get("prompt") or "").strip()
# 「只展示」模板:原样输出已有 HTML(含全部子页面)。
if not spec.convertible:
html = template_fill.read_template_text(template_id)
return await self._finalize_template_html(
task,
spec=spec,
template_id=template_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=f"已展示模板「{spec.name}」的现有数据",
source_chars=len(source),
used_default=True,
)
page_title: str | None = None
page_subtitle: str | None = None
if not source:
# 未提供源数据 → 用模板自带默认数据(不调模型、不校验)。
data_obj = template_fill.reference_data(template_id)
content = f"未提供源数据,已用模板「{spec.name}」的默认数据生成页面"
used_default = True
else:
ctx = task.get("execution_context") or {}
tcfg = ctx.get("template_config") if isinstance(ctx, dict) else {}
tcfg = tcfg if isinstance(tcfg, dict) else {}
# 选中的内置 agent 的 SOUL + 模型(template_config.model_name 优先,其次 agent.model)。
model_name = str(tcfg.get("model_name") or "").strip() or None
soul, model_name = await self._agent_soul_and_model(task, model_name)
try:
converted = await self._convert_to_template_data(
template_id, source, model_name=model_name, soul=soul, run_id=run_id
)
except TemplateFillError:
# 转换彻底失败(全程没解析出 JSON)→ 也不让 run 失败:用模板默认
# 数据出页,与 require_html 模板参考路径的兜底一致。
logger.warning(
"模板填充转换失败,回退默认数据 run=%s tpl=%s", run_id, template_id, exc_info=True
)
data_obj = template_fill.reference_data(template_id)
content = f"数据转换未通过,已用模板「{spec.name}」的默认数据生成页面(含全部子页面)"
used_default = True
else:
# 页面标题/副标题是额外键,摘出来替换页头,不进 DATA。
page_title, page_subtitle = template_fill.pop_page_titles(converted)
# 抽不到/抽错的子页面与字段回退模板原有数据,避免子页面空白或 undefined。
data_obj = template_fill.merge_with_reference(template_id, converted)
content = f"已根据源数据填充模板「{spec.name}」生成页面(抽取不到的部分沿用模板原有数据)"
used_default = False
task_tz = ZoneInfo(task.get("timezone") or "UTC")
local_time = scheduled_for.astimezone(task_tz)
try:
html = template_fill.fill_template(
template_id, data_obj, update_date=local_time.date(), title=page_title, subtitle=page_subtitle
)
except template_fill.TemplateError as exc:
raise TemplateFillError(f"模板填充失败:{exc}") from exc
return await self._finalize_template_html(
task,
spec=spec,
template_id=template_id,
html=html,
run_id=run_id,
scheduled_for=scheduled_for,
content=content,
source_chars=len(source),
used_default=used_default,
)
async def _run_agent_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Run the agent once and build the result payload.
Wrapped in :func:`asyncio.wait_for` by the caller so a hung agent loop
cannot hold the shared run lock — and the whole scheduler — forever.
"""
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
async with self._agent_run_lock:
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
if record.task is not None:
await record.task
result_text = await self._extract_latest_ai_text(runtime_thread_id)
result_files = await self._extract_result_files(
runtime_thread_id,
task["user_id"],
run_id=run_id,
output_snapshot=output_snapshot,
)
if _task_requires_markdown(task) and not _has_valid_markdown_file(result_files):
raise MarkdownRequirementError(
f"未满足输出要求:需生成 Markdown(.md) 文件,且文件正文不少于 {_MARKDOWN_MIN_CHARS} 字"
)
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
token = set_current_user(_RuntimeUser(id=user_id))
try:
thread_store = self.app.state.thread_store
existing = await thread_store.get(thread_id)
if existing is None:
await thread_store.create(
thread_id,
assistant_id="lead_agent",
display_name="定时任务助手",
metadata={"thread_type": "scheduler", "system": True},
)
checkpointer = self.app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{
"step": -1,
"source": "input",
"writes": None,
"parents": {},
"created_at": now_iso(),
"thread_type": "scheduler",
},
{},
)
finally:
reset_current_user(token)
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime, runtime_thread_id: str):
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
token = set_current_user(_RuntimeUser(id=task["user_id"]))
try:
# 虚拟 request:带空 state(真请求由 auth 中间件写 state.user/state.auth;
# 进程内调用没有这层,下游读 request.state.X 必须拿到 None 而非 AttributeError)。
request = SimpleNamespace(app=self.app, state=SimpleNamespace(user=None, auth=None))
execution_context = dict(task.get("execution_context") or {})
stored_agent_ref = task.get("execution_agent_name")
if stored_agent_ref:
resolved_agent_id = await self._resolve_agent_id(stored_agent_ref, task["user_id"])
if resolved_agent_id:
execution_context["agent_name"] = resolved_agent_id
else:
logger.warning(
"Scheduled task %s references unknown agent %r; falling back to default lead agent",
task.get("task_id"),
stored_agent_ref,
)
execution_context.update(
{
"is_scheduled_run": True,
"thread_id": runtime_thread_id,
"thinking_enabled": execution_context.get("thinking_enabled", True),
"is_plan_mode": execution_context.get("is_plan_mode", True),
}
)
content = (
f"【定时任务触发】{task['name']}\n"
f"计划时间:{scheduled_for.astimezone(ZoneInfo(task.get('timezone') or 'UTC')).isoformat()}\n\n"
f"{task['prompt']}"
)
if execution_context.get("require_markdown"):
content += _MARKDOWN_INSTRUCTION
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": content}]},
metadata={"scheduled_task_id": task["task_id"], "scheduled_task_name": task["name"]},
context=execution_context,
multitask_strategy="enqueue" if False else "reject",
)
return await start_run(body, runtime_thread_id, request) # type: ignore[arg-type]
finally:
reset_current_user(token)
async def _resolve_agent_id(self, stored_ref: str, user_id: str) -> str | None:
"""Resolve a scheduled task's ``execution_agent_name`` to a stable agent id.
Older tasks (created before the frontend was fixed) stored the agent's
Chinese **display name** here; the runtime validator only accepts ids
matching ``^[A-Za-z0-9_-]+$``, so those rows would 422 forever. We
gracefully recover by looking the stored value up first as an id, then
as a display name. Returns ``None`` when nothing visible matches — the
caller then falls back to the default lead agent rather than crashing.
"""
store = getattr(self.app.state, "agent_store", None)
if store is None:
return stored_ref if AGENT_ID_PATTERN.fullmatch(stored_ref) else None
if AGENT_ID_PATTERN.fullmatch(stored_ref):
try:
row = await store.get_visible(stored_ref, user_id)
except Exception:
row = None
if row is not None:
return row["id"]
try:
agents = await store.list_visible(user_id)
except Exception:
return None
for agent in agents:
if agent.get("name") == stored_ref:
return agent.get("id")
return None
async def _extract_latest_ai_text(self, thread_id: str) -> str | None:
checkpointer = self.app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await checkpointer.aget_tuple(config)
if checkpoint_tuple is None:
return None
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {})
values = serialize_channel_values(channel_values)
messages = values.get("messages") or []
for message in reversed(messages):
if not isinstance(message, dict):
continue
message_type = message.get("type") or message.get("role")
if message_type not in {"ai", "assistant"}:
continue
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
parts.append(part["text"])
if parts:
return "\n".join(parts)
return None
def _result_scan_dirs(self, thread_id: str, user_id: str) -> list[tuple[str, Path]]:
paths = get_paths()
return [
("/mnt/user-data/outputs", paths.sandbox_outputs_dir(thread_id, user_id=user_id)),
("/mnt/user-data/workspace", paths.sandbox_work_dir(thread_id, user_id=user_id)),
]
def _snapshot_result_files(self, thread_id: str, user_id: str) -> dict[str, tuple[int, int]]:
snapshot: dict[str, tuple[int, int]] = {}
for _, directory in self._result_scan_dirs(thread_id, user_id):
if not directory.exists() or not directory.is_dir():
continue
try:
for path in directory.rglob("*"):
if not path.is_file():
continue
stat = path.stat()
snapshot[str(path.resolve())] = (stat.st_mtime_ns, stat.st_size)
except Exception:
logger.debug("Failed to snapshot scheduled task result dir %s", directory, exc_info=True)
return snapshot
def _virtual_result_path(self, virtual_prefix: str, result_dir: Path, path: Path) -> str | None:
try:
relative = path.resolve().relative_to(result_dir.resolve())
except ValueError:
return None
return f"{virtual_prefix}/{relative.as_posix()}"
def _collect_changed_output_files(
self,
thread_id: str,
user_id: str,
output_snapshot: dict[str, tuple[int, int]],
) -> list[tuple[str, Path]]:
changed: list[tuple[str, Path]] = []
for virtual_prefix, directory in self._result_scan_dirs(thread_id, user_id):
if not directory.exists() or not directory.is_dir():
continue
try:
for path in sorted(directory.rglob("*"), key=lambda item: item.as_posix()):
if not path.is_file():
continue
resolved = path.resolve()
stat = path.stat()
previous = output_snapshot.get(str(resolved))
if previous == (stat.st_mtime_ns, stat.st_size):
continue
virtual_path = self._virtual_result_path(virtual_prefix, directory, resolved)
if virtual_path is not None:
changed.append((virtual_path, resolved))
except Exception:
logger.debug("Failed to collect changed scheduled task result files from %s", directory, exc_info=True)
return changed
def _is_in_result_scan_dirs(self, thread_id: str, user_id: str, path: Path) -> bool:
try:
resolved = path.resolve()
except Exception:
return False
for _, directory in self._result_scan_dirs(thread_id, user_id):
try:
resolved.relative_to(directory.resolve())
return True
except ValueError:
continue
return False
def _archive_result_file(self, run_id: str, path: Path) -> Path | None:
if not path.exists() or not path.is_file():
return None
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id / "files"
archive_dir.mkdir(parents=True, exist_ok=True)
candidate = archive_dir / path.name
if candidate.exists():
stem = path.stem or "file"
suffix = path.suffix
index = 2
while candidate.exists():
candidate = archive_dir / f"{stem}-{index}{suffix}"
index += 1
shutil.copy2(path, candidate)
if candidate.suffix.lower() in {".md", ".markdown"}:
try:
original = candidate.read_text(encoding="utf-8", errors="ignore")
constrained = _constrain_markdown_same_page_anchor_links(original)
if constrained != original:
candidate.write_text(constrained, encoding="utf-8", newline="")
except Exception:
logger.debug("Failed to constrain scheduled markdown links in %s", candidate, exc_info=True)
return candidate
def _build_file_result(self, *, virtual_path: str, source_path: Path, archive_path: Path) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(archive_path.name)
suffix = archive_path.suffix.lower()
return {
"name": archive_path.name,
"path": str(archive_path),
"source_path": str(source_path),
"virtual_path": virtual_path,
"mime_type": mime_type or ("text/markdown" if suffix == ".md" else None),
"size": archive_path.stat().st_size if archive_path.exists() and archive_path.is_file() else None,
"kind": "markdown" if suffix == ".md" else "artifact",
}
def _is_new_or_changed_output_file(
self,
thread_id: str,
user_id: str,
path: Path,
output_snapshot: dict[str, tuple[int, int]],
) -> bool:
if not self._is_in_result_scan_dirs(thread_id, user_id, path):
return True
resolved = path.resolve()
if not resolved.exists() or not resolved.is_file():
return False
stat = resolved.stat()
return output_snapshot.get(str(resolved)) != (stat.st_mtime_ns, stat.st_size)
async def _extract_result_files(
self,
thread_id: str,
user_id: str,
*,
run_id: str,
output_snapshot: dict[str, tuple[int, int]] | None = None,
) -> list[dict[str, Any]]:
checkpointer = self.app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await checkpointer.aget_tuple(config)
artifacts: list[Any] = []
if checkpoint_tuple is not None:
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {})
values = serialize_channel_values(channel_values)
raw_artifacts = values.get("artifacts") or []
if isinstance(raw_artifacts, list):
artifacts = raw_artifacts
result_files: list[dict[str, Any]] = []
seen: set[str] = set()
def add_file(virtual_path: str, source_path: Path) -> None:
resolved_source = source_path.resolve()
seen_key = str(resolved_source)
if seen_key in seen:
return
seen.add(seen_key)
try:
archive_path = self._archive_result_file(run_id, resolved_source)
except Exception:
logger.debug("Failed to archive scheduled task artifact %s", resolved_source, exc_info=True)
return
if archive_path is None:
return
result_files.append(self._build_file_result(virtual_path=virtual_path, source_path=resolved_source, archive_path=archive_path))
for artifact in artifacts:
if not isinstance(artifact, str) or not artifact:
continue
try:
actual_path = get_paths().resolve_virtual_path(thread_id, artifact, user_id=user_id)
except TypeError:
actual_path = get_paths().resolve_virtual_path(thread_id, artifact)
except Exception:
logger.debug("Failed to resolve scheduled task artifact %s", artifact, exc_info=True)
continue
path = Path(actual_path)
if self._is_new_or_changed_output_file(thread_id, user_id, path, output_snapshot or {}):
add_file(artifact, path)
for virtual_path, path in self._collect_changed_output_files(thread_id, user_id, output_snapshot or {}):
add_file(virtual_path, path)
return result_files
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
if self._element_client is None:
return
body = f"【定时任务】{task['name']}\n\n{result_text}"
# 1) Author
await self._deliver_to_user(task["user_id"], task, body)
# 2) Subscribers who opted into notifications
try:
subscribers = await self.store.list_notify_subscribers(task["task_id"])
except Exception:
logger.exception("Failed to list scheduled task subscribers for %s", task["task_id"])
subscribers = []
for sub in subscribers:
sub_user_id = sub.get("user_id")
if not sub_user_id or sub_user_id == task["user_id"]:
continue
await self._deliver_to_user(sub_user_id, task, body)
async def _deliver_to_user(self, user_id: str, task: dict[str, Any], body: str) -> None:
if self._element_client is None:
return
try:
profile = await self.store.get_delivery_profile(user_id)
except Exception:
logger.exception("Failed to load delivery profile for user %s", user_id)
return
element_user_id = profile.get("element_user_id")
if not isinstance(element_user_id, str) or not element_user_id.strip():
return
existing_room_id: str | None = profile.get("element_room_id") or None
try:
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
if room_id and room_id != existing_room_id:
await self.store.update_delivery_profile(user_id, {"element_room_id": room_id})
except Exception:
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
# --- Publish / subscribe ---
async def publish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.set_task_published(task_id, user_id, published=True)
async def unpublish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.set_task_published(task_id, user_id, published=False)
async def subscribe_task(
self, user_id: str, task_id: str, *, notify_element: bool
) -> dict[str, Any] | None:
task = await self.store.get_task_any(task_id)
if task is None:
return None
if task["user_id"] == user_id:
# Authors don't need to subscribe to their own tasks.
return None
existing = await self.store.get_subscription(task_id, user_id)
if existing is None and not task.get("published"):
# First-time subscription requires the task to currently be published.
return None
return await self.store.upsert_subscription(task_id, user_id, notify_element=notify_element)
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
return await self.store.delete_subscription(task_id, user_id)
# --- HTML pages / favorites ---
@property
def _html_store(self):
state = getattr(self.app, "state", None)
return getattr(state, "html_page_store", None)
async def get_html_page_for_run(self, run_id: str) -> dict[str, Any] | None:
"""Return the generated HTML page for a run (page-store row or run result)."""
store = self._html_store
if store is not None:
page = await store.get_page_by_run(run_id)
if page is not None:
return page
# Fallback to the inline HTML on the run result.
run = await self.store.get_run_any(run_id)
if run is None:
return None
result = run.get("result") if isinstance(run.get("result"), dict) else None
if not result or not isinstance(result.get("html"), str):
return None
return {
"run_id": run_id,
"task_id": run.get("task_id"),
"title": result.get("title"),
"html_content": result.get("html"),
"style_summary": result.get("style_summary"),
"layout_summary": result.get("layout_summary"),
"created_at": result.get("created_at"),
}
async def get_latest_html_page_for_task(self, task_id: str) -> dict[str, Any] | None:
store = self._html_store
if store is not None:
page = await store.latest_page_for_task(task_id)
if page is not None:
return page
# Fallback: newest run that carries inline HTML.
runs = await self.store.list_runs_any(task_id, limit=20)
for run in runs:
result = run.get("result") if isinstance(run.get("result"), dict) else None
if result and isinstance(result.get("html"), str):
return await self.get_html_page_for_run(run["id"])
return None
async def list_html_page_favorites(self, user_id: str) -> list[dict[str, Any]]:
store = self._html_store
if store is None:
return []
return await store.list_favorites(user_id)
async def get_html_page_favorite(self, user_id: str, favorite_id: str) -> dict[str, Any] | None:
store = self._html_store
if store is None:
return None
favorite = await store.get_favorite(favorite_id)
if favorite is None or favorite.get("user_id") != user_id:
return None
return favorite
async def delete_html_page_favorite(self, user_id: str, favorite_id: str) -> bool:
store = self._html_store
if store is None:
return False
return await store.delete_favorite(favorite_id, user_id)
async def create_html_page_favorite(
self,
user_id: str,
page_id: str,
*,
title: str | None = None,
description: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any] | None:
"""Bookmark a generated page, extracting a reusable style summary via GLM5.
Returns ``None`` when the page does not exist or is not owned by the
caller. Style extraction is best-effort — a failure still produces a
favorite (without the reuse hints) so the user's bookmark is never lost.
"""
store = self._html_store
if store is None:
return None
page = await store.get_page(page_id)
if page is None or page.get("user_id") != user_id:
return None
# A page can only be bookmarked once per user.
existing = await store.get_favorite_by_page(user_id, page_id)
if existing is not None:
raise DuplicateFavoriteError(page_id)
html = page.get("html_content") or ""
style_summary = page.get("style_summary")
layout_summary = page.get("layout_summary")
content_structure_summary = None
reuse_prompt = None
if html:
try:
# Reuse the model the page was generated with (recorded in the
# page's prompt snapshot) so favoriting doesn't fall back to the
# default model.
extract_model = (page.get("prompt_snapshot") or {}).get("model_name")
extract_prompt = html_page_utils.build_style_extract_prompt(html=html)
raw = await self._invoke_chat_model(extract_model, extract_prompt)
extracted = html_page_utils.extract_json_object(raw) or {}
style_summary = extracted.get("style_summary") or style_summary
layout_summary = extracted.get("layout_summary") or layout_summary
content_structure_summary = extracted.get("content_structure_summary")
reuse_prompt = extracted.get("reuse_prompt")
except Exception:
logger.warning("HTML style extraction failed for page %s; saving favorite without hints", page_id, exc_info=True)
return await store.create_favorite(
{
"id": str(uuid.uuid4()),
"user_id": user_id,
"page_id": page_id,
"title": (title or page.get("title") or "收藏页面").strip(),
"description": (description or None),
"tags": tags or [],
"html_snapshot": html,
"style_summary": style_summary,
"layout_summary": layout_summary,
"content_structure_summary": content_structure_summary,
"reuse_prompt": reuse_prompt,
}
)