Files
AgentLab/backend/app/api/schedules.py
T
ThearchyHelios 277982ead3 普通用户建应用(后端):
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表
- 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号
- 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period)
- 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
2026-10-15 12:09:29 +08:00

689 lines
34 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.
"""定时任务:按周期自动发起工作流运行,结束、失败、停在审批时推送 Webhook,运行成功结束后和同一任务上一次成功的运行
自动对比。
形状按前后端约定(Schedule、Fire 两种对象,见 _schedule_out、_fire_outs)。Webhook 地址和签名密钥加密存放,接口
只给主机和末 4 位(url_hint)、有没有密钥(has_secret)。请求体自己解析、自己校验:错误一律回中文 detail,表单
据 field 把报错落到那一格(422 {detail, code, field},同 api/coded.py)。后台的调度循环在 app/scheduler 里。
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.runs import actor_of
from app.core.crypto import decrypt, encrypt
from app.db.base import get_session, utcnow
from app.db.models import Run, Schedule, ScheduleFire, WebhookDelivery, Workflow, WorkflowVersion
from app.scheduler import auto_compare, cron, service, webhook
router = APIRouter(prefix="/api/schedules", tags=["schedules"])
#: 请求不合规(422)的机器码。field 是出错的那一项:name、workflow_id、cron、timezone、run_class、input_template、
#: enabled、overlap、misfire_grace_s、webhook、version、limit、before
SCHEDULE_INVALID = "schedule_invalid"
_GONE = "定时任务不存在,可能已被删除"
#: 错过的触发在多少秒内补发:默认 1 小时,最长 7 天
DEFAULT_GRACE_S = 3600
MAX_GRACE_S = 7 * 24 * 3600
#: 预览最多给几次
MAX_PREVIEW = 20
def _invalid(detail: str, field: str | None = None) -> CodedHTTPException:
return CodedHTTPException(422, detail, SCHEDULE_INVALID, field=field)
def _iso(moment: datetime | None) -> str | None:
return service.as_utc(moment).isoformat() if moment is not None else None
#: 查询串把「+」解成空格之后,时间末尾的时区偏移:「…03.376320 00:00」
_SPACED_OFFSET = re.compile(r"(.*\d) (\d{2}:?\d{2})")
def _cursor(moment: datetime) -> str:
"""触发历史的翻页游标:UTC 写成 Z 结尾、精确到微秒。写成 +00:00 时照接口说明原样拼进地址,「+」在查询串里会被
解成空格,回 422(2026-10 验收);Z 结尾的放进地址不用编码,和运行列表的游标一个写法。"""
return service.as_utc(moment).isoformat().replace("+00:00", "Z")
def _cursor_time(before: str) -> datetime:
"""before → 时间:Z、+00:00 都认,也认「+」被查询串解成空格的「 00:00」(老的游标照原样拼进地址时就是这样)。
不是时间的照样抛 ValueError。"""
text = before.strip()
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
spaced = _SPACED_OFFSET.fullmatch(text)
if spaced is None:
raise
return datetime.fromisoformat(f"{spaced[1]}+{spaced[2]}")
async def _body(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
raise _invalid("请求内容需要是 JSON 对象")
return data
# --------------------------------------------------------------------------
# 逐项校验:返回规范化之后的值,不对抛 422(带 field)
# --------------------------------------------------------------------------
def _name(value: Any) -> str:
text = value.strip() if isinstance(value, str) else ""
if not text:
raise _invalid("请填写定时任务的名称", "name")
if len(text) > 200:
raise _invalid("定时任务的名称不能超过 200 个字符", "name")
return text
def _workflow_id(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
raise _invalid("请选择要定时运行的工作流", "workflow_id")
return value.strip()
def _cron(value: Any) -> str:
try:
return cron.parse(value if isinstance(value, str) else "").expr
except cron.CronError as e:
raise _invalid(str(e), "cron") from e
def _timezone(value: Any) -> str:
try:
return cron.timezone_name(value if isinstance(value, str) else "")
except cron.TimezoneError as e:
raise _invalid(str(e), "timezone") from e
def _run_class(value: Any) -> str:
if value not in service.RUN_CLASSES:
raise _invalid(f"运行类别需要选择正式运行或探索运行{_current(value)}", "run_class")
return value
def _template(value: Any) -> dict[str, str]:
try:
return service.check_template(value)
except service.TemplateError as e:
raise _invalid(str(e), "input_template") from e
def _current(value: Any) -> str:
return service.current_value(value)
def _enabled(value: Any) -> bool:
if not isinstance(value, bool):
raise _invalid(f"启用状态需要是 true 或 false{_current(value)}", "enabled")
return value
def _overlap(value: Any) -> str:
if value != "skip":
raise _invalid(f"重叠处理目前只支持「跳过」:上一次运行还没结束时跳过这次触发{_current(value)}", "overlap")
return value
def _grace(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= MAX_GRACE_S:
raise _invalid(f"补发宽限需要是 0 到 {MAX_GRACE_S} 之间的整数秒数(最长 7 天){_current(value)}",
"misfire_grace_s")
return value
def _format(value: Any) -> str:
if value not in webhook.FORMATS:
labels = "、".join(webhook.FORMAT_LABEL[f] for f in webhook.FORMATS)
raise _invalid(f"Webhook 格式需要是{labels}之一{_current(value)}", "webhook")
return value
def _events(value: Any) -> list[str]:
labels = "、".join(webhook.EVENT_LABEL[e] for e in webhook.EVENTS)
if not isinstance(value, list):
raise _invalid(f"Webhook 的通知事件需要从{labels}中选择", "webhook")
unknown = [e for e in value if e not in webhook.EVENTS]
if unknown:
first = unknown[0]
named = f"「{first}」" if isinstance(first, str) and first.strip() else "中有一项"
raise _invalid(f"Webhook 的通知事件{named}不可选,只能从{labels}中选择", "webhook")
return [e for e in webhook.EVENTS if e in value]
def _url(value: Any) -> str:
try:
return webhook.check_url(value)
except ValueError as e:
raise _invalid(str(e), "webhook") from e
def _secret(value: Any) -> str | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
if not isinstance(value, str):
raise _invalid("Webhook 签名密钥需要是文本", "webhook")
return value.strip()
def _blank(value: Any) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def _matched_format(value: Any, url: str) -> str:
"""校验格式,并要求和地址对得上:群机器人的地址只认自己平台的格式(见 webhook.bot_format)。"""
fmt = _format(value)
if problem := webhook.format_problem(fmt, url):
raise _invalid(problem, "webhook")
return fmt
def _new_webhook(value: Any) -> dict[str, Any] | None:
"""创建时的 webhook:null 或 url 为空表示不配;否则校验格式、地址、密钥、事件(事件省略表示三种都要)。
格式省略时按地址认:群机器人的地址用它平台的格式,别的用通用格式。"""
if value is None:
return None
if not isinstance(value, dict):
raise _invalid("Webhook 设置需要是一个对象,或者为空表示不推送", "webhook")
if _blank(value.get("url")):
return None
url = _url(value.get("url"))
given = value.get("format")
fmt = _matched_format(given, url) if given is not None else webhook.bot_format(url) or "generic"
return {"format": fmt, "url": url, "secret": _secret(value.get("secret")),
"events": _events(value.get("events", list(webhook.EVENTS)))}
def _webhook_columns(hook: dict[str, Any] | None) -> dict[str, Any]:
if hook is None:
return {"webhook_format": None, "webhook_url": None, "webhook_secret": None, "webhook_events": []}
return {"webhook_format": hook["format"], "webhook_url": encrypt(hook["url"]),
"webhook_secret": encrypt(hook["secret"]) if hook["secret"] else None, "webhook_events": hook["events"]}
def _patched_webhook(row: Schedule, value: Any) -> dict[str, Any]:
"""PATCH 的 webhook → 要写的列。null 或 url 写空串:删除整个 Webhook;url 省略:地址不变;secret 省略:密钥不变、
写空串:去掉密钥。format 省略:换了地址的按新地址认(群机器人用它平台的格式),没换的不变。
地址或格式真的变了才查两者对不对得上:库里已有对不上的设置(这条校验之前存的)时,整份表单回写只为停用、
改名,不能因为 Webhook 被拦下。"""
if value is None:
return _webhook_columns(None)
if not isinstance(value, dict):
raise _invalid("Webhook 设置需要是一个对象,或者为空表示不推送", "webhook")
if "url" in value and _blank(value.get("url")):
return _webhook_columns(None)
has_url = bool(row.webhook_url)
if "url" not in value and not has_url:
raise _invalid("请填写 Webhook 地址", "webhook")
url = _url(value.get("url")) if "url" in value else None
if value.get("format") is not None:
fmt = _format(value["format"])
elif url is not None:
fmt = webhook.bot_format(url) or (row.webhook_format if has_url else "generic")
else:
fmt = row.webhook_format
if url is not None or fmt != row.webhook_format:
target = url or decrypt(row.webhook_url)
if target and (problem := webhook.format_problem(fmt, target)):
raise _invalid(problem, "webhook")
out: dict[str, Any] = {
"webhook_format": _format(fmt),
"webhook_events": _events(value.get("events", list(row.webhook_events or []) if has_url
else list(webhook.EVENTS))),
}
if url is not None:
out["webhook_url"] = encrypt(url)
if "secret" in value:
secret = _secret(value.get("secret"))
out["webhook_secret"] = encrypt(secret) if secret else None
return out
async def _check_workflow(session: AsyncSession, workflow_id: str, run_class: str, template: dict[str, str]) -> Workflow:
"""工作流要在;正式运行要有发布版本;模板里的字段名对得上入口字段,必填项都有(按这次要用的那张图查)。"""
workflow = await session.get(Workflow, workflow_id)
if workflow is None:
raise _invalid("工作流不存在,可能已被删除", "workflow_id")
graph: Any = workflow.graph
if run_class == "formal":
if not workflow.published_version:
raise _invalid(f"「{workflow.name}」还没有发布版本,正式运行只能从发布版本发起。"
"请先在编排页发布,或改为探索运行", "run_class")
graph = await _published_graph(session, workflow) or graph
try:
service.check_fields(template, service.input_fields(graph), workflow.name)
except service.TemplateError as e:
raise _invalid(str(e), "input_template") from e
return workflow
async def _published_graph(session: AsyncSession, workflow: Workflow) -> Any:
return await session.scalar(select(WorkflowVersion.graph).where(
WorkflowVersion.workflow_id == workflow.id, WorkflowVersion.version == workflow.published_version))
# --------------------------------------------------------------------------
# 对外的形状
# --------------------------------------------------------------------------
def _description(row: Schedule) -> str:
try:
return cron.describe(row.cron, row.timezone)
except cron.CronError:
return f"按 cron 表达式 {row.cron}({row.timezone})"
def _webhook_out(row: Schedule) -> dict[str, Any] | None:
if not row.webhook_url:
return None
plain = decrypt(row.webhook_url)
return {
"format": row.webhook_format,
# 解不开(加密密钥换过)时不猜:照实说,请人重新填
"url_hint": webhook.url_hint(plain) if plain else "(地址无法解密,请重新填写)",
"has_secret": bool(row.webhook_secret),
"events": [e for e in webhook.EVENTS if e in (row.webhook_events or [])],
}
async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[dict[str, Any]]:
"""Fire 对象:run_status 是运行现在的状态,notified 是这次触发的推送记录 [{event, status, at}](按时间先后),
compare 是运行成功结束后和同一任务上一次成功的运行的自动对比 {base_run_id, summary, previous}(summary 同对比接口
的计数,previous 说基准是不是紧挨着的上一次),没有为 null。
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也
给 null:运行详情里同名的入口比的是另一次。"""
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
compares = await auto_compare.briefs(list(compared.values()))
stale = await auto_compare.outdated(session, compares)
run_ids = {f.run_id for f in fires if f.run_id} | {brief["base_run_id"] for brief in compares.values()}
# 运行现在的状态和清单哈希:run_status 照写,对比按它核对两边的封存
current = {run_id: (status, seal) for run_id, status, seal in (await session.execute(
select(Run.id, Run.status, Run.manifest_hash).where(Run.id.in_(run_ids)))).all()} if run_ids else {}
def compare_of(f: ScheduleFire) -> dict[str, Any] | None:
artifact = compared.get(f.id) or ""
brief = compares.get(artifact)
if brief is None or brief["target_run_id"] != f.run_id or artifact in stale:
return None
for run_id, seal in ((brief["target_run_id"], brief["target_manifest"]),
(brief["base_run_id"], brief["base_manifest"])):
status, now_seal = current.get(run_id, (None, None))
if status not in auto_compare.TERMINAL or now_seal != seal:
return None
return auto_compare.public(brief)
notified: dict[str, list[dict[str, Any]]] = {}
if fires:
rows = (await session.execute(
select(WebhookDelivery).where(WebhookDelivery.fire_id.in_([f.id for f in fires]))
.order_by(WebhookDelivery.created_at)
)).scalars()
for d in rows:
notified.setdefault(d.fire_id, []).append({"event": d.event, "status": d.status, "at": _iso(d.created_at)})
# 正在发起的触发:库里是占位的「未能发起」(为了进程被强杀时照实说),进程还在、只是还没发起完,按已发起给
launching = {f.id for f in fires if service.launching(f.id)}
return [{
"id": f.id,
"fire_at": _iso(f.fire_at),
"kind": f.kind,
"status": "started" if f.id in launching else f.status,
"run_id": f.run_id,
"run_status": current.get(f.run_id, (None, None))[0] if f.run_id else None,
"reason": None if f.id in launching else f.reason,
"notified": notified.get(f.id, []),
"compare": compare_of(f),
"created_at": _iso(f.created_at),
} for f in fires]
async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[dict[str, Any]]:
ids = [r.id for r in rows]
names: dict[str, str] = {}
last: dict[str, dict[str, Any]] = {}
if rows:
names = dict((await session.execute(
select(Workflow.id, Workflow.name).where(Workflow.id.in_({r.workflow_id for r in rows})))).all())
newest = (select(ScheduleFire.schedule_id, func.max(ScheduleFire.created_at).label("at"))
.where(ScheduleFire.schedule_id.in_(ids)).group_by(ScheduleFire.schedule_id).subquery())
fires = list((await session.execute(select(ScheduleFire).join(newest, and_(
ScheduleFire.schedule_id == newest.c.schedule_id, ScheduleFire.created_at == newest.c.at)))).scalars())
for out, fire in zip(await _fire_outs(session, fires), fires):
last.setdefault(fire.schedule_id, out)
return [{
"id": r.id,
"name": r.name,
"workflow_id": r.workflow_id,
# 工作流还在就给它现在的名字;删掉之后用保存时记下的那一份
"workflow_name": names.get(r.workflow_id) or r.workflow_name,
"cron": r.cron,
"timezone": r.timezone,
"run_class": r.run_class,
"input_template": dict(r.input_template or {}),
"enabled": r.enabled,
"overlap": r.overlap,
"misfire_grace_s": r.misfire_grace_s,
"webhook": _webhook_out(r),
"next_run_at": _iso(r.next_run_at),
"last_fire": last.get(r.id),
"description_text": _description(r),
"created_by": r.created_by,
"created_at": _iso(r.created_at),
"updated_at": _iso(r.updated_at),
"version": r.version,
} for r in rows]
async def _schedule_out(session: AsyncSession, row: Schedule) -> dict[str, Any]:
return (await _schedule_outs(session, [row]))[0]
async def _get(session: AsyncSession, schedule_id: str) -> Schedule:
row = await session.get(Schedule, schedule_id)
if row is None:
raise HTTPException(404, _GONE)
return row
# --------------------------------------------------------------------------
# 固定路径(要排在 /{schedule_id} 前面)
# --------------------------------------------------------------------------
@router.get("/variables")
async def list_variables(tz: str | None = Query(default=None, alias="timezone")) -> list[dict[str, str]]:
"""输入模板能用的变量:[{name, label, example, note}]。示例按现在这一刻、timezone(默认 Asia/Shanghai)触发算;
note 是补充说明(ISO 周的跨年规则),没有的是空串。"""
zone = _timezone(tz) if tz is not None else service.DEFAULT_TIMEZONE
return service.variables(tz=zone)
@router.post("/preview")
async def preview(request: Request, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""{cron, timezone, input_template?, workflow_id?, n?=5} → {description_text, next: [ISO(UTC)…], inputs}。
inputs 是按下一次触发渲染的输入。带了 workflow_id 时对照入口字段的名字;必填项没填不在这里报——用户还在填。
"""
body = await _body(request)
cron_expr, tz = _cron(body.get("cron")), _timezone(body.get("timezone"))
template = _template(body.get("input_template"))
n = body.get("n", 5)
n = min(max(n, 1), MAX_PREVIEW) if isinstance(n, int) and not isinstance(n, bool) else 5
workflow_id = body.get("workflow_id")
if isinstance(workflow_id, str) and workflow_id.strip():
workflow = await session.get(Workflow, workflow_id.strip())
if workflow is not None:
# 还不知道按哪一类运行:发布版本和画布的字段都认
published = await _published_graph(session, workflow) if workflow.published_version else None
fields = service.input_fields(workflow.graph) + service.input_fields(published)
try:
service.check_fields(template, fields, workflow.name, require=False)
except service.TemplateError as e:
raise _invalid(str(e), "input_template") from e
upcoming = cron.preview(cron_expr, tz, n)
first = upcoming[0] if upcoming else utcnow()
return {
"description_text": cron.describe(cron_expr, tz),
"next": [_iso(m) for m in upcoming],
"inputs": service.render_inputs(template, service.template_values(first, tz, cron_expr)),
}
# --------------------------------------------------------------------------
# 增删改查
# --------------------------------------------------------------------------
@router.get("")
async def list_schedules(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
rows = list((await session.execute(select(Schedule).order_by(Schedule.created_at.desc()))).scalars())
return {"items": await _schedule_outs(session, rows)}
@router.post("", status_code=201)
async def create_schedule(request: Request, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
row = await create_schedule_row(session, await _body(request), actor=actor_of(x_actor))
return await _schedule_out(session, row)
def schedule_values(body: dict[str, Any]) -> dict[str, Any]:
"""建定时任务的请求体 → 规范化之后的列(不含工作流名、下一次触发),逐项校验,不对抛 422(带 field)。只读,
不碰库:发布单的预检用它「只检查」,建任务的接口和发布单建任务都先过它。"""
values = {"name": _name(body.get("name")), "workflow_id": _workflow_id(body.get("workflow_id")),
"cron": _cron(body.get("cron")), "timezone": _timezone(body.get("timezone")),
"run_class": _run_class(body.get("run_class", "formal")),
"input_template": _template(body.get("input_template")),
"enabled": _enabled(body.get("enabled", True)), "overlap": _overlap(body.get("overlap", "skip")),
"misfire_grace_s": _grace(body.get("misfire_grace_s", DEFAULT_GRACE_S))}
return {**values, "webhook": _new_webhook(body.get("webhook"))}
def template_problem(template: dict[str, str], graph: Any, workflow_name: str) -> str | None:
"""输入模板对不上这张图的入口字段(字段名、必填项):说明;对得上为 None。发布单的预检按将要发布的图查。"""
try:
service.check_fields(template, service.input_fields(graph), workflow_name)
except service.TemplateError as e:
return str(e)
return None
async def create_schedule_row(session: AsyncSession, body: dict[str, Any], *, actor: str | None) -> Schedule:
"""校验并建一个定时任务(提交)。建任务的接口和发布单(停用着建,应用发布后再启用)共用。"""
values = schedule_values(body)
hook = values.pop("webhook")
workflow = await _check_workflow(session, values["workflow_id"], values["run_class"], values["input_template"])
row = Schedule(**values, workflow_name=workflow.name,
next_run_at=service.next_run(values["cron"], values["timezone"], values["enabled"]),
created_by=actor, **_webhook_columns(hook))
session.add(row)
await session.commit()
await session.refresh(row)
return row
@router.get("/{schedule_id}")
async def get_schedule(schedule_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
return await _schedule_out(session, await _get(session, schedule_id))
@router.patch("/{schedule_id}")
async def update_schedule(schedule_id: str, request: Request,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""改任意可改的项,带上读到的 version;对不上回 409。只读的项(id、next_run_at、last_fire……)带了也不理。
看的是值变没变,不是键在不在:编辑表单把整份字段带回来是正常用法。
- 工作流、运行类别、输入模板真改了才按改后的组合重新校验。只停用、改名不查工作流——工作流删了、撤了发布,
也要能先把任务停下来。
- cron、时区、启用状态真改了才重算下一次触发(见 _next_after_edit)。没改的不动它:到点了、这一拍还没来时
保存一次,这一次照常发起,不会被推到下一次、连记录都没有就丢了。
"""
body = await _body(request)
row = await _get(session, schedule_id)
version = body.get("version")
if isinstance(version, bool) or not isinstance(version, int):
raise _invalid("保存时需要带上读到的版本号 version,请刷新后重试", "version")
if version != row.version:
raise HTTPException(409, f"这个定时任务已被修改(当前是第 {row.version} 版,提交的是第 {version} 版),"
"请刷新后再保存")
values: dict[str, Any] = {}
if "name" in body:
values["name"] = _name(body["name"])
if "workflow_id" in body:
values["workflow_id"] = _workflow_id(body["workflow_id"])
if "cron" in body:
values["cron"] = _cron(body["cron"])
if "timezone" in body:
values["timezone"] = _timezone(body["timezone"])
if "run_class" in body:
values["run_class"] = _run_class(body["run_class"])
if "input_template" in body:
values["input_template"] = _template(body["input_template"])
if "enabled" in body:
values["enabled"] = _enabled(body["enabled"])
if "overlap" in body:
values["overlap"] = _overlap(body["overlap"])
if "misfire_grace_s" in body:
values["misfire_grace_s"] = _grace(body["misfire_grace_s"])
if "webhook" in body:
values |= _patched_webhook(row, body["webhook"])
def merged(key: str) -> Any:
return values.get(key, getattr(row, key))
def changed(key: str) -> bool:
if key not in values:
return False
stored = getattr(row, key)
return values[key] != (dict(stored or {}) if key == "input_template" else stored)
if changed("workflow_id") or changed("run_class") or changed("input_template"):
workflow = await _check_workflow(session, merged("workflow_id"), merged("run_class"), merged("input_template"))
values["workflow_name"] = workflow.name
# cron、时区在上面已经规范化(字段之间一个空格、时区的标准写法),和库里存的同一种写法,直接比
if changed("cron") or changed("timezone") or changed("enabled"):
values["next_run_at"] = _next_after_edit(row, merged("cron"), merged("timezone"), merged("enabled"))
# 条件 UPDATE 才是真的乐观锁:两个人同时读到第 3 版、同时保存,只有先到的那个改得动
result = await session.execute(
update(Schedule).where(Schedule.id == schedule_id, Schedule.version == version)
.values(**values, version=version + 1).execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise HTTPException(409, "这个定时任务刚被别人修改过,请刷新后再保存")
await session.commit()
session.expire_all()
return await _schedule_out(session, await _get(session, schedule_id))
async def retime(session: AsyncSession, row: Schedule, body: dict[str, Any]) -> Schedule:
"""按新的设置改一个已有的定时任务(提交):名称、cron、时区、输入模板,校验同建任务;启用状态不动。cron、时区真改了才
按 PATCH 同一个规则重算下一次触发。发布单给已经发布过的蓝图再发一版时用它沿用原来的任务,不另建一个(两个任务会
在同一时刻各发起一次)。"""
values = schedule_values({**body, "workflow_id": row.workflow_id, "enabled": row.enabled})
workflow = await _check_workflow(session, row.workflow_id, row.run_class, values["input_template"])
changed = {"name": values["name"], "cron": values["cron"], "timezone": values["timezone"],
"input_template": values["input_template"], "workflow_name": workflow.name}
if values["cron"] != row.cron or values["timezone"] != row.timezone:
changed["next_run_at"] = _next_after_edit(row, values["cron"], values["timezone"], row.enabled)
await session.execute(update(Schedule).where(Schedule.id == row.id)
.values(**changed, version=row.version + 1).execution_options(synchronize_session=False))
await session.commit()
await session.refresh(row)
return row
async def set_enabled(session: AsyncSession, row: Schedule, enabled: bool) -> Schedule:
"""启用、停用一个定时任务(提交),下一次触发按 PATCH 同一个规则算,版本号加一。发布单在应用发布之后用它启用
先停用着建的任务;已经是这个状态的不动。"""
if row.enabled == enabled:
return row
await session.execute(
update(Schedule).where(Schedule.id == row.id)
.values(enabled=enabled, next_run_at=_next_after_edit(row, row.cron, row.timezone, enabled),
version=row.version + 1).execution_options(synchronize_session=False))
await session.commit()
# 只重读这一行:调用方(发布单)同一个会话里还拿着别的对象,不能整个会话过期
await session.refresh(row)
return row
def _next_after_edit(row: Schedule, cron_expr: str, tz: str, enabled: bool) -> datetime | None:
"""改了 cron、时区或启用状态之后的下一次触发。
停用:没有下一次。刚启用:从现在算,不补停用期间错过的。启用着改了 cron、时区:原来那一次已经到点、这一拍还没来
的,从那一刻接着按新的表达式算——新表达式也覆盖那一刻(每天 → 工作日)就还发起这一次,只属于旧表达式的不再发起。
"""
if not enabled:
return None
now = utcnow()
due = service.as_utc(row.next_run_at) if row.enabled and row.next_run_at is not None else None
if due is not None and due <= now:
return cron.next_fire(cron_expr, tz, due - timedelta(microseconds=1))
return service.next_run(cron_expr, tz, True, now)
@router.delete("/{schedule_id}", status_code=204)
async def delete_schedule(schedule_id: str, session: AsyncSession = Depends(get_session)) -> Response:
"""删除任务,连同它的触发历史和推送记录。它发起过的运行留着(运行上的来源 id 也留着):那是追溯凭证。"""
await _get(session, schedule_id)
# 外键是 ON DELETE CASCADE;这里照样按顺序删一遍,不依赖连接上开没开外键约束
await session.execute(delete(WebhookDelivery).where(WebhookDelivery.schedule_id == schedule_id))
await session.execute(delete(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id))
await session.execute(delete(Schedule).where(Schedule.id == schedule_id))
await session.commit()
return Response(status_code=204)
# --------------------------------------------------------------------------
# 立即运行、触发历史、测试 Webhook
# --------------------------------------------------------------------------
@router.post("/{schedule_id}/run")
async def run_now(schedule_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""立即触发一次(kind=manual),停用的任务也可以。规则和到点触发相同:上一次运行没结束就跳过,正式运行只从当前
发布版本发起。运行记在点按钮的人名下,没有署名时记在「定时任务:<名字>」名下。返回 {fire, run_id}。
触发时间取到秒:连点两下落在同一秒的,交回同一条触发记录,不会发起两次。"""
await _get(session, schedule_id)
fire = await service.fire_once(schedule_id, utcnow().replace(microsecond=0), "manual", actor=actor_of(x_actor))
if fire is None:
raise HTTPException(404, _GONE)
return {"fire": (await _fire_outs(session, [fire]))[0], "run_id": fire.run_id}
@router.get("/{schedule_id}/fires")
async def list_fires(schedule_id: str, limit: str | None = None, before: str | None = None,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来(Z 结尾,拼进地址
不用编码);没有更多时为空。"""
await _get(session, schedule_id)
size = 20
if limit is not None:
if not limit.strip().isdecimal() or not 1 <= int(limit) <= 100:
raise _invalid(f"每页条数需要是 1 到 100 之间的整数,当前为「{limit}」", "limit")
size = int(limit)
stmt = select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id)
if before is not None:
try:
cursor = _cursor_time(before)
except ValueError as e:
raise _invalid(f"翻页位置需要是接口返回的 next_before,当前为「{before}」", "before") from e
stmt = stmt.where(ScheduleFire.created_at < service.as_utc(cursor))
rows = list((await session.execute(
stmt.order_by(ScheduleFire.created_at.desc(), ScheduleFire.id.desc()).limit(size + 1))).scalars())
items = rows[:size]
return {"items": await _fire_outs(session, items),
"next_before": _cursor(items[-1].created_at) if len(rows) > size else None}
@router.post("/{schedule_id}/webhook/test")
async def test_webhook(schedule_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按保存的 Webhook 发一条测试消息(不重试):{ok, http_status, message}。失败的说明里只有主机名。"""
await _get(session, schedule_id)
try:
return await webhook.send_test(schedule_id)
except KeyError as e:
raise HTTPException(404, _GONE) from e
except ValueError as e:
raise _invalid(str(e), "webhook") from e