Files
AgentLab/backend/app/api/schedules.py
T

1145 lines
62 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,运行成功结束后和同一任务上一次成功的运行
自动对比。
**目标种类**(docs/design/next-round/00-总方案.md X4:定时只有一套):target_kind 是 workflow(缺省,字段和行为一字不变)、
eval_suite(跑一次评测题集)或 source_maintenance(维护一个数据源)。非工作流目标写 target_id,不写 workflow_id、输入模板、
运行类别和 Webhook:核对目标交给各自的模块(scheduler/service.check_target → app/eval/scheduled、app/data/source_maintenance);
它们的通知走通知渠道的订阅(定时评测有退步、定时评测没有评完……),不走定时任务的 Webhook——Webhook 的三种事件(运行
结束、运行失败、等待审批)说的是这一次发起的那次运行,非工作流目标没有那一次运行,配上了也收不到,所以照实拒收。接口返回带
target_kind、target_id、target_name(目标现在的名字,不在了用保存时记下的那一份)。
**触发方式**(docs/design/orchestration-round/00-总方案.md §4,一套定时机制):trigger_kind 是 cron(缺省,字段和行为一字不变)
或 event(由数据事件触发,规则在 scheduler/triggers.py、派发在 scheduler/outbox.py)。event 时请求体写 trigger(事件种类、对象、
过滤、去抖),目标只能是工作流,cron 存空串、下一次触发为空;核对对象、输入模板里的 {{ event.* }}、workflow.finished 串成的环
(A→B→A 拒收,说清是哪几个)都在保存时做。触发方式建好之后不能改。Webhook 照旧可配。接口返回另带 trigger_kind、trigger、
trigger_text(给人看的触发说明,事件触发时也是 description_text)、trigger_state(几条事件在等、去抖到什么时候、调度开没开)。
形状按前后端约定(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, outbox, service, triggers, webhook
router = APIRouter(prefix="/api/schedules", tags=["schedules"])
#: 请求不合规(422)的机器码。field 是出错的那一项:name、target_kind、target_id、workflow_id、cron、timezone、run_class、
#: input_template、enabled、overlap、misfire_grace_s、webhook、version、limit、before、trigger_kind、trigger、debounce_s
SCHEDULE_INVALID = "schedule_invalid"
#: 非工作流目标配了 Webhook(见模块说明)
NO_WEBHOOK = ("目标是「{kind}」的定时任务不推 Webhook:Webhook 的事件说的是定时发起的那次运行。要收这类定时的通知,"
"请在「设置 → 通知」的渠道里订阅对应的事件")
_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 _target_kind(value: Any) -> str:
if value not in service.TARGET_KINDS:
labels = "、".join(service.TARGET_LABEL[k] for k in service.TARGET_KINDS)
raise _invalid(f"定时任务的目标需要是{labels}之一{_current(value)}", "target_kind")
return value
def _target_id(value: Any, kind: str) -> str:
if not isinstance(value, str) or not value.strip():
raise _invalid(f"请选择要定时的{service.TARGET_LABEL[kind]}", "target_id")
return value.strip()
def _target_extras(body: dict[str, Any], kind: str) -> None:
"""非工作流目标不写工作流、输入模板、Webhook(见模块说明);运行类别带了也只认合法的写法,不存。"""
label = service.TARGET_LABEL[kind]
if not _blank(body.get("workflow_id")):
raise _invalid(f"目标是「{label}」的定时任务不指定工作流", "workflow_id")
if body.get("input_template") not in (None, {}):
raise _invalid(f"目标是「{label}」的定时任务没有输入模板", "input_template")
hook = body.get("webhook")
if isinstance(hook, dict) and not _blank(hook.get("url")):
raise _invalid(NO_WEBHOOK.format(kind=label), "webhook")
if hook is not None and not isinstance(hook, dict):
raise _invalid("Webhook 设置需要是一个对象,或者为空表示不推送", "webhook")
if "run_class" in body:
_run_class(body["run_class"])
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, event: str | None = None) -> dict[str, str]:
try:
return service.check_template(value, event=event)
except service.TemplateError as e:
raise _invalid(str(e), "input_template") from e
#: 触发方式:按 cron 到点、由数据事件触发
TRIGGER_KINDS = ("cron", "event")
def _trigger_kind(value: Any) -> str:
if value not in TRIGGER_KINDS:
raise _invalid(f"触发方式需要是定时或事件触发{_current(value)}", "trigger_kind")
return value
def _trigger(value: Any) -> dict[str, Any]:
try:
return triggers.normalize(value)
except triggers.TriggerError as e:
raise _invalid(str(e), e.field) from e
async def _check_trigger(session: AsyncSession, trigger: dict[str, Any], *, schedule_id: str | None,
target_workflow_id: str) -> triggers.Checked:
"""核对触发条件里的对象,再查 workflow.finished 串没串成环。不对抛 422(field=trigger)。"""
try:
checked = await triggers.check(session, trigger)
except triggers.TriggerError as e:
raise _invalid(str(e), e.field) from e
cycle = await triggers.find_cycle(session, schedule_id=schedule_id, trigger=trigger,
target_workflow_id=target_workflow_id)
if cycle:
names = dict((await session.execute(select(Workflow.id, Workflow.name).where(Workflow.id.in_(set(cycle))))).all())
raise _invalid(triggers.cycle_text([names.get(w, w) for w in cycle]), "trigger")
return checked
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 _event_kind(row: Schedule) -> bool:
return (getattr(row, "trigger_kind", None) or "cron") == "event"
async def _trigger_names(session: AsyncSession, rows: list[Schedule]) -> dict[str, str]:
"""事件触发任务的对象、过滤里的应用现在叫什么:{id: 名字}。不在了的不在里面(说法里用保存时的名字)。"""
from app.db.models import App, DataSource
wanted: dict[str, set[str]] = {"source": set(), "workflow": set(), "app": set()}
for r in rows:
trigger = r.trigger if _event_kind(r) and isinstance(r.trigger, dict) else None
if not trigger:
continue
subject = triggers.subject_of(trigger)
wanted["workflow" if trigger.get("event") == "workflow.finished" else "source"].add(subject)
if trigger.get("app_id"):
wanted["app"].add(trigger["app_id"])
out: dict[str, str] = {}
for model, ids in ((DataSource, wanted["source"]), (Workflow, wanted["workflow"]), (App, wanted["app"])):
if ids:
out |= dict((await session.execute(select(model.id, model.name).where(model.id.in_(ids)))).all())
return out
def _trigger_text(row: Schedule, names: dict[str, str]) -> str:
trigger = row.trigger if isinstance(row.trigger, dict) else {}
subject = triggers.subject_of(trigger)
stored = (trigger.get("subject_name") if isinstance(trigger.get("subject_name"), str) else None)
return triggers.describe(trigger, names.get(subject) or stored, app_name=names.get(trigger.get("app_id") or ""))
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:运行详情里同名的入口比的是另一次。
inputs 只在目标是数据源维护时给:{steps, jobs: [{step, kind, id, tables, scope?}], skipped: [{step, reason}]}——这一次
建了哪几个探查、剖析任务,哪几步没执行和原因(app/data/source_maintenance 记在触发上的那一份)。维护没有运行,触发历史、
「上次触发」和立即运行的答复靠它写出建了什么,不写「未发起运行」。别的目标给 null:工作流触发的 inputs 是按模板渲染的
工作流输入,触发历史不列。"""
maintenance = set((await session.execute(select(Schedule.id).where(
Schedule.id.in_({f.schedule_id for f in fires}), Schedule.target_kind == "source_maintenance"))).scalars()) \
if fires else set()
def inputs_of(f: ScheduleFire) -> dict[str, Any] | None:
if f.schedule_id not in maintenance:
return None
raw = f.inputs if isinstance(f.inputs, dict) else {}
return {key: raw[key] for key in ("steps", "jobs", "skipped") if key in raw}
def event_of(f: ScheduleFire) -> dict[str, Any] | None:
"""事件触发的那几次(派发的、事件触发任务的立即运行):事件的说明(scheduler/outbox.EventFire.brief),没有为 null。"""
raw = f.inputs.get("event") if isinstance(f.inputs, dict) and f.schedule_id not in maintenance else None
if not isinstance(raw, dict):
return None
# windows / periods 只在几个过了宽限的窗口合成一条跳过时有:合了几个窗口、合掉的期次(上传表格)
return {key: raw.get(key) for key in ("event", "subject_id", "subject_name", "text", "merged", "first_at",
"last_at", "depth", "sample", "real", "windows", "periods")}
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, f)}
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,
# 目标是评测题集时这一次触发建的那次评测(没有运行 id)
"eval_run_id": f.eval_run_id,
"inputs": inputs_of(f),
# 事件触发的那几次:事件的说明(种类、对象、给人看的一句、合并了几次、触发深度、是不是立即运行的示例)
"event": event_of(f),
"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] = {}
targets: dict[str, str | None] = {}
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 if r.workflow_id})))).all())
for r in rows:
kind, target_id = service.target_of(r)
if kind != "workflow":
targets[r.id] = await service.target_name(session, kind, target_id)
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)
trigger_names = await _trigger_names(session, rows) if rows else {}
states: dict[str, dict[str, Any]] = {}
if any(_event_kind(r) for r in rows):
from app import scheduler
on = scheduler.scheduler_enabled()
# 一次查齐(outbox.pending_for_many),不再每行各查一次
waiting = await outbox.pending_for_many(session, [r for r in rows if _event_kind(r)])
states = {sid: {**state, "scheduler_on": on} for sid, state in waiting.items()}
def target(r: Schedule) -> dict[str, Any]:
kind, target_id = service.target_of(r)
name = names.get(r.workflow_id) if kind == "workflow" else targets.get(r.id)
# 目标还在就给它现在的名字;删掉之后用保存时记下的那一份
return {"target_kind": kind, "target_id": target_id, "target_label": service.TARGET_LABEL.get(kind, kind),
"target_name": name or r.workflow_name, "target_exists": name is not None}
def trigger(r: Schedule) -> dict[str, Any]:
"""触发方式:cron 的 trigger、trigger_text、trigger_state 为 null;事件触发的给规范化后的触发条件、说法、在等的事件。"""
if not _event_kind(r):
return {"trigger_kind": "cron", "trigger": None, "trigger_text": None, "trigger_state": None}
# subject_name 是保存时记下的对象名(对象删掉之后界面写它);带回来时不认(triggers.normalize)
stored = dict(r.trigger or {}) if isinstance(r.trigger, dict) else {}
return {"trigger_kind": "event", "trigger": stored, "trigger_text": _trigger_text(r, trigger_names),
"trigger_state": states.get(r.id)}
return [{
"id": r.id,
"name": r.name,
**target(r),
**trigger(r),
"workflow_id": r.workflow_id,
# 工作流还在就给它现在的名字;删掉之后用保存时记下的那一份。非工作流目标这一项是目标的名字(同 target_name)
"workflow_name": names.get(r.workflow_id) or targets.get(r.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": _trigger_text(r, trigger_names) if _event_kind(r) else _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"), trigger_kind: str | None = None,
event: str | None = None) -> list[dict[str, str]]:
"""输入模板能用的变量:[{name, label, example, note}]。示例按现在这一刻、timezone(默认 Asia/Shanghai)触发算;
note 是补充说明(ISO 周的跨年规则),没有的是空串。trigger_kind=event 时按 event(事件种类)另给 {{ event.* }}、去掉
上一次计划触发时间。"""
zone = _timezone(tz) if tz is not None else service.DEFAULT_TIMEZONE
if trigger_kind is None or _trigger_kind(trigger_kind) == "cron":
return service.variables(tz=zone)
if event not in triggers.EVENTS:
raise _invalid("请指明事件种类:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化", "trigger")
return service.variables(tz=zone, event=event)
#: 选对象时一页最多给几个(按名字排;更多的按关键字查)
TRIGGER_OPTIONS_LIMIT = 100
@router.get("/trigger-options")
async def trigger_options(event: str | None = None, q: str | None = None, selected: str | None = None,
limit: int = Query(default=TRIGGER_OPTIONS_LIMIT, ge=1, le=500),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""新建、修改事件触发任务时能选的:每种事件的对象(能不能选、为什么不能,由这里判,界面不自己判)、「只认某种来源」的
来源、「只认某个应用」的应用、去抖的缺省值、触发链的上限。
对象按名字排、一页最多 limit 个,more 说后面还有没有:数据源、工作流成百上千时不一次全给,界面按关键字(q,名字里含它)
再查。event 给了只查这一种事件的对象;selected 是当前选中的那一个(修改时),总在里面,不被分页挤掉、不显示成「已不存在」。
{events: [{event, label, subject_label, objects: [{id, name, ok, reason}], more}], origins: [{kind, label}],
apps: [{id, name}], default_debounce_s, max_debounce_s, max_depth}"""
from app.db.models import App, DataSource
if event is not None and event not in triggers.EVENTS:
raise _invalid(f"不认识的事件种类:{event}", "event")
wanted = [event] if event else list(triggers.EVENTS)
words = (q or "").strip()
async def page(model: Any, *conds: Any) -> tuple[list[Any], bool]:
query = select(model).where(*conds)
if words:
query = query.where(model.name.contains(words, autoescape=True))
rows = list((await session.execute(query.order_by(model.name, model.id).limit(limit + 1))).scalars())
more = len(rows) > limit
rows = rows[:limit]
if selected and all(r.id != selected for r in rows):
if (picked := (await session.execute(select(model).where(model.id == selected, *conds))).scalar_one_or_none()):
rows.append(picked)
return rows, more
async def tables() -> tuple[list[dict[str, Any]], bool]:
# 只有上传表格有「新的一期」:按来源筛在库里做(老行没写来源的是手工接的源)
rows, more = await page(DataSource, DataSource.origin == "upload")
out = []
for src in rows:
ok = await triggers.accumulating(session, src)
out.append({"id": src.id, "name": src.name, "ok": ok,
"reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"})
return out, more
async def flows() -> tuple[list[dict[str, Any]], bool]:
rows, more = await page(Workflow, Workflow.is_template.is_(False))
return [{"id": w.id, "name": w.name, "ok": bool(w.published_version),
"reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"}
for w in rows], more
async def sources() -> tuple[list[dict[str, Any]], bool]:
rows, more = await page(DataSource)
return [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in rows], more
makers = {"table.version": tables, "workflow.finished": flows, "source.changed": sources}
entries = []
for e in wanted:
objects, more = await makers[e]()
entries.append({"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e],
"objects": objects, "more": more})
apps = (await session.execute(select(App.id, App.name).where(App.status != "archived").order_by(App.name))).all()
return {
"events": entries,
"origins": [{"kind": k, "label": triggers.ORIGIN_LABEL[k]} for k in triggers.FORMAL_ORIGINS],
"apps": [{"id": i, "name": n} for i, n in apps],
"default_debounce_s": triggers.DEFAULT_DEBOUNCE_S, "max_debounce_s": triggers.MAX_DEBOUNCE_S,
"max_depth": triggers.MAX_DEPTH,
}
@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 时对照入口字段的名字;必填项没填不在这里报——用户还在填。
trigger_kind=event 时带 trigger、不带 cron:description_text 是触发说明,next 为空,inputs 按一个示例事件渲染(对象最近
一次真实的事件,没有时是示例值),另给 event {text, real}。
"""
body = await _body(request)
if _trigger_kind(body.get("trigger_kind", "cron")) == "event":
return await _preview_event(session, body)
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
await _preview_fields(session, body, template)
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)),
}
async def _preview_fields(session: AsyncSession, body: dict[str, Any], template: dict[str, str]) -> None:
"""预览时带了 workflow_id:对照入口字段的名字(发布版本和画布的字段都认,还不知道按哪一类运行)。"""
workflow_id = body.get("workflow_id")
if not (isinstance(workflow_id, str) and workflow_id.strip()):
return
workflow = await session.get(Workflow, workflow_id.strip())
if workflow is None:
return
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
async def _preview_event(session: AsyncSession, body: dict[str, Any]) -> dict[str, Any]:
if not _blank(body.get("cron")):
raise _invalid(EVENT_NO_CRON, "cron")
tz = _timezone(body.get("timezone", service.DEFAULT_TIMEZONE))
trigger = _trigger(body.get("trigger"))
template = _template(body.get("input_template"), trigger["event"])
await _preview_fields(session, body, template)
try:
checked = await triggers.check(session, trigger)
except triggers.TriggerError as e:
raise _invalid(str(e), e.field) from e
sample = await triggers.sample(session, trigger)
fire = outbox.sample_fire(sample, tz)
now = utcnow().replace(microsecond=0)
return {
"description_text": triggers.describe(trigger, checked.subject_name, app_name=checked.app_name),
"next": [],
"inputs": service.render_inputs(template, service.template_values(now, tz, ""), event=fire.values),
"event": {"text": fire.text, "real": sample.real},
}
# --------------------------------------------------------------------------
# 增删改查
# --------------------------------------------------------------------------
@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)。只读,
不碰库:发布单的预检用它「只检查」,建任务的接口和发布单建任务都先过它。
target_kind 缺省 workflow:交回的键和以前一字不差。非工作流目标交回 target_kind、target_id,运行类别存探索(不用它)、
输入模板为空、没有 Webhook。
trigger_kind 缺省 cron:同上一字不差。event 时目标只能是工作流,不写 cron(存空串),时区缺省 Asia/Shanghai(日期变量按它算),
另交回 trigger_kind、trigger(规范化之后的触发条件),输入模板按事件种类核对。"""
if _trigger_kind(body.get("trigger_kind", "cron")) == "event":
return _event_values(body)
kind = _target_kind(body.get("target_kind", "workflow"))
if kind != "workflow":
_target_extras(body, kind)
return {"name": _name(body.get("name")), "target_kind": kind, "target_id": _target_id(body.get("target_id"), kind),
"cron": _cron(body.get("cron")), "timezone": _timezone(body.get("timezone")),
"run_class": "exploratory", "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)), "webhook": None}
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"))}
#: 事件触发的任务不写 cron
EVENT_NO_CRON = "事件触发的定时任务不写 cron 表达式:它在事件发生时触发,不按时间"
#: 事件触发只能指向工作流
EVENT_WORKFLOW_ONLY = "事件触发的定时任务只能指向工作流:评测题集、数据源维护请用定时触发"
def _event_values(body: dict[str, Any]) -> dict[str, Any]:
"""事件触发任务的请求体 → 规范化之后的列(同 schedule_values,不碰库)。"""
if _target_kind(body.get("target_kind", "workflow")) != "workflow":
raise _invalid(EVENT_WORKFLOW_ONLY, "target_kind")
if not _blank(body.get("cron")):
raise _invalid(EVENT_NO_CRON, "cron")
trigger = _trigger(body.get("trigger"))
values = {"name": _name(body.get("name")), "workflow_id": _workflow_id(body.get("workflow_id")), "cron": "",
"timezone": _timezone(body.get("timezone", service.DEFAULT_TIMEZONE)),
"run_class": _run_class(body.get("run_class", "formal")),
"input_template": _template(body.get("input_template"), trigger["event"]),
"enabled": _enabled(body.get("enabled", True)), "overlap": _overlap(body.get("overlap", "skip")),
"misfire_grace_s": _grace(body.get("misfire_grace_s", DEFAULT_GRACE_S)),
"trigger_kind": "event", "trigger": trigger}
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 _check_target(session: AsyncSession, kind: str, target_id: str) -> str:
"""非工作流目标:交给它的模块核对(存在、可用),交回目标现在的名字。不对抛 422(field=target_id)。"""
if problem := await service.check_target(session, kind, target_id):
raise _invalid(problem, "target_id")
return await service.target_name(session, kind, target_id) or ""
async def create_schedule_row(session: AsyncSession, body: dict[str, Any], *, actor: str | None) -> Schedule:
"""校验并建一个定时任务(提交)。建任务的接口和发布单(停用着建,应用发布后再启用)共用。"""
values = schedule_values(body)
hook = values.pop("webhook")
if values.get("target_kind", "workflow") != "workflow":
kind, target_id = values.pop("target_kind"), values.pop("target_id")
name = await _check_target(session, kind, target_id)
# 目标的几列只经 scheduler/service 的一处封装写(非工作流目标:workflow_id 存空串);workflow_name 记保存时的目标名
values |= service.target_columns(kind, target_id)
row = Schedule(**values, workflow_name=name,
next_run_at=service.next_run(values["cron"], values["timezone"], values["enabled"]),
created_by=actor, **_webhook_columns(None))
session.add(row)
await session.commit()
await session.refresh(row)
return row
workflow = await _check_workflow(session, values["workflow_id"], values["run_class"], values["input_template"])
next_run_at = None
if values.get("trigger_kind") == "event":
# 事件触发:核对对象、查环;记下对象现在的名字(对象删掉之后说法里用它)。没有下一次「到点」
checked = await _check_trigger(session, values["trigger"], schedule_id=None,
target_workflow_id=values["workflow_id"])
values["trigger"] = {**values["trigger"], "subject_name": checked.subject_name}
else:
next_run_at = service.next_run(values["cron"], values["timezone"], values["enabled"])
# 目标的几列只经 scheduler/service 的一处封装写(工作流目标:target_id 同 workflow_id)
values |= service.target_columns("workflow", values["workflow_id"])
row = Schedule(**values, workflow_name=workflow.name, next_run_at=next_run_at,
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] = {}
kind, current_target = service.target_of(row)
if "target_kind" in body and _target_kind(body["target_kind"]) != kind:
raise _invalid("定时任务的目标种类建好之后不能改:请新建一个定时任务", "target_kind")
event = _event_kind(row)
if "trigger_kind" in body and _trigger_kind(body["trigger_kind"]) != ("event" if event else "cron"):
raise _invalid("定时任务的触发方式建好之后不能改:请新建一个定时任务", "trigger_kind")
if kind != "workflow":
return await _update_target_schedule(session, row, body, kind, current_target, version)
if event:
return await _update_event_schedule(session, row, body, version)
if "name" in body:
values["name"] = _name(body["name"])
if "workflow_id" in body:
# 目标的几列一起改(scheduler/service.target_columns):只改 workflow_id 的话 target_id 还指着原来的工作流
values |= service.target_columns("workflow", _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 _update_event_schedule(session: AsyncSession, row: Schedule, body: dict[str, Any],
version: int) -> dict[str, Any]:
"""改一个事件触发的定时任务:名称、目标工作流、运行类别、输入模板、触发条件、时区、启用、重叠、补发宽限、Webhook。cron 不适用
(整份表单带回来的空串不算);下一次触发一直为空。看值变没变(同 update_schedule):触发条件、目标真改了才重新核对对象、查环,
模板、事件种类、目标、运行类别真改了才按改后的组合核对模板和入口字段。"""
if "cron" in body and not _blank(body["cron"]):
raise _invalid(EVENT_NO_CRON, "cron")
values: dict[str, Any] = {}
stored = dict(row.trigger or {}) if isinstance(row.trigger, dict) else {}
current = {k: v for k, v in stored.items() if k != "subject_name"}
trigger = _trigger(body["trigger"]) if "trigger" in body else current
if "name" in body:
values["name"] = _name(body["name"])
if "workflow_id" in body:
values |= service.target_columns("workflow", _workflow_id(body["workflow_id"]))
for key, check in (("timezone", _timezone), ("run_class", _run_class), ("enabled", _enabled),
("overlap", _overlap), ("misfire_grace_s", _grace)):
if key in body:
values[key] = check(body[key])
if "input_template" in body:
values["input_template"] = _template(body["input_template"], trigger.get("event"))
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
return values[key] != (dict(getattr(row, key) or {}) if key == "input_template" else getattr(row, key))
trigger_changed = trigger != current
if trigger_changed or changed("workflow_id"):
checked = await _check_trigger(session, trigger, schedule_id=row.id, target_workflow_id=merged("workflow_id"))
values["trigger"] = {**trigger, "subject_name": checked.subject_name}
if trigger_changed and "input_template" not in values:
# 换了事件种类:原来的模板里的 {{ event.* }} 不一定还认
values["input_template"] = _template(dict(row.input_template or {}), trigger.get("event"))
if changed("workflow_id") or changed("run_class") or changed("input_template") or trigger_changed:
workflow = await _check_workflow(session, merged("workflow_id"), merged("run_class"), merged("input_template"))
values["workflow_name"] = workflow.name
result = await session.execute(
update(Schedule).where(Schedule.id == row.id, Schedule.version == version)
.values(**values, version=version + 1).execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise HTTPException(409, "这个定时任务刚被别人修改过,请刷新后再保存")
schedule_id = row.id
await session.commit()
session.expire_all()
return await _schedule_out(session, await _get(session, schedule_id))
async def _update_target_schedule(session: AsyncSession, row: Schedule, body: dict[str, Any], kind: str,
current_target: str, version: int) -> dict[str, Any]:
"""改一个非工作流目标的定时任务:名称、目标(同一种)、cron、时区、启用、重叠、补发宽限。工作流、输入模板、运行类别、
Webhook 不适用(带了照实拒;整份表单带回来的空值不算)。目标真改了才重新核对。"""
probe = {k: body[k] for k in ("workflow_id", "input_template", "webhook", "run_class") if k in body}
if probe.get("webhook") is None:
probe.pop("webhook", None)
_target_extras(probe, kind)
values: dict[str, Any] = {}
if "name" in body:
values["name"] = _name(body["name"])
if "target_id" in body:
target_id = _target_id(body["target_id"], kind)
if target_id != current_target:
values["workflow_name"] = await _check_target(session, kind, target_id)
values |= service.target_columns(kind, target_id)
for key, check in (("cron", _cron), ("timezone", _timezone), ("enabled", _enabled), ("overlap", _overlap),
("misfire_grace_s", _grace)):
if key in body:
values[key] = check(body[key])
if any(k in values and values[k] != getattr(row, k) for k in ("cron", "timezone", "enabled")):
values["next_run_at"] = _next_after_edit(row, values.get("cron", row.cron), values.get("timezone", row.timezone),
values.get("enabled", row.enabled))
result = await session.execute(
update(Schedule).where(Schedule.id == row.id, Schedule.version == version)
.values(**values, version=version + 1).execution_options(synchronize_session=False))
if result.rowcount != 1:
await session.rollback()
raise HTTPException(409, "这个定时任务刚被别人修改过,请刷新后再保存")
schedule_id = row.id
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=None if _event_kind(row) else _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};目标是评测题集的,
发起的是一次评测,fire.eval_run_id 是它,run_id 为空。
触发时间取到秒:连点两下落在同一秒的,交回同一条触发记录,不会发起两次。
事件触发的任务按示例事件发起(scheduler/triggers.sample:对象最近一次真实的事件):运行来源里记 trigger(sample=true),
触发链从这里起算。对象还没发生过这种事件、模板又用到 {{ event.* }} 的照实记一次未能发起。"""
row = await _get(session, schedule_id)
event = None
if _event_kind(row) and isinstance(row.trigger, dict):
event = outbox.sample_fire(await triggers.sample(session, row.trigger), row.timezone)
fire = await service.fire_once(schedule_id, utcnow().replace(microsecond=0), "manual", actor=actor_of(x_actor),
event=event)
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