合入 W1-c 数据事件触发:定时任务加「事件触发」(上传表格出新一期、上游工作流正式运行成功结束、数据源有要处理的变化),事件在发生处写进待派发事件、调度循环去抖派发;保存时与运行时防环;{{ event.* }} 模板变量;途经驳回、应用补算的缺省不认(可勾选);积压一拍处理完、过期的合成一条跳过;按期次分窗口;定时页可选「定时 / 事件触发」

This commit is contained in:
ThearchyHelios
2026-10-18 18:23:33 +08:00
29 changed files with 3924 additions and 119 deletions
+304 -26
View File
@@ -8,6 +8,12 @@ eval_suite(跑一次评测题集)或 source_maintenance(维护一个数据
结束、运行失败、等待审批)说的是这一次发起的那次运行,非工作流目标没有那一次运行,配上了也收不到,所以照实拒收。接口返回带
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 里。
@@ -27,12 +33,12 @@ 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
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
#: 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 的事件说的是定时发起的那次运行。要收这类定时的通知,"
@@ -155,13 +161,45 @@ def _run_class(value: Any) -> str:
return value
def _template(value: Any) -> dict[str, str]:
def _template(value: Any, event: str | None = None) -> dict[str, str]:
try:
return service.check_template(value)
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)
@@ -328,6 +366,37 @@ def _description(row: Schedule) -> str:
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
@@ -365,6 +434,15 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
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)
@@ -405,6 +483,8 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
# 目标是评测题集时这一次触发建的那次评测(没有运行 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),
@@ -430,6 +510,16 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di
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()
for r in rows:
if _event_kind(r):
states[r.id] = {**await outbox.pending_for(session, r), "scheduler_on": on}
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)
@@ -437,10 +527,20 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di
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,
@@ -454,7 +554,7 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di
"webhook": _webhook_out(r),
"next_run_at": _iso(r.next_run_at),
"last_fire": last.get(r.id),
"description_text": _description(r),
"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),
@@ -479,11 +579,54 @@ async def _get(session: AsyncSession, schedule_id: str) -> Schedule:
@router.get("/variables")
async def list_variables(tz: str | None = Query(default=None, alias="timezone")) -> list[dict[str, str]]:
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 周的跨年规则),没有的是空串。"""
note 是补充说明(ISO 周的跨年规则),没有的是空串。trigger_kind=event 时按 event(事件种类)另给 {{ event.* }}、去掉
上一次计划触发时间。"""
zone = _timezone(tz) if tz is not None else service.DEFAULT_TIMEZONE
return service.variables(tz=zone)
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)
@router.get("/trigger-options")
async def trigger_options(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""新建、修改事件触发任务时能选的:每种事件的对象(能不能选、为什么不能,由这里判,界面不自己判)、「只认某种来源」的
来源、「只认某个应用」的应用、去抖的缺省值、触发链的上限。
{events: [{event, label, subject_label, objects: [{id, name, ok, reason}]}], origins: [{kind, label}],
apps: [{id, name}], default_debounce_s, max_debounce_s, max_depth}"""
from app.db.models import App, DataSource
sources = list((await session.execute(select(DataSource).order_by(DataSource.name))).scalars())
flows = list((await session.execute(select(Workflow).where(Workflow.is_template.is_(False))
.order_by(Workflow.name))).scalars())
tables = []
for src in sources:
if (src.origin or "manual") != "upload":
continue
ok = await triggers.accumulating(session, src)
tables.append({"id": src.id, "name": src.name, "ok": ok,
"reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"})
objects = {
"table.version": tables,
"workflow.finished": [{"id": w.id, "name": w.name, "ok": bool(w.published_version),
"reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"}
for w in flows],
"source.changed": [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in sources],
}
apps = (await session.execute(select(App.id, App.name).where(App.status != "archived").order_by(App.name))).all()
return {
"events": [{"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e],
"objects": objects[e]} for e in triggers.EVENTS],
"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")
@@ -491,23 +634,18 @@ async def preview(request: Request, session: AsyncSession = Depends(get_session)
"""{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
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
await _preview_fields(session, body, template)
upcoming = cron.preview(cron_expr, tz, n)
first = upcoming[0] if upcoming else utcnow()
return {
@@ -517,6 +655,44 @@ async def preview(request: Request, session: AsyncSession = Depends(get_session)
}
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},
}
# --------------------------------------------------------------------------
# 增删改查
# --------------------------------------------------------------------------
@@ -540,7 +716,12 @@ def schedule_values(body: dict[str, Any]) -> dict[str, Any]:
不碰库:发布单的预检用它「只检查」,建任务的接口和发布单建任务都先过它。
target_kind 缺省 workflow:交回的键和以前一字不差。非工作流目标交回 target_kind、target_id,运行类别存探索(不用它)、
输入模板为空、没有 Webhook。"""
输入模板为空、没有 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)
@@ -558,6 +739,29 @@ def schedule_values(body: dict[str, Any]) -> dict[str, Any]:
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:
@@ -591,10 +795,17 @@ async def create_schedule_row(session: AsyncSession, body: dict[str, Any], *, ac
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=service.next_run(values["cron"], values["timezone"], values["enabled"]),
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()
@@ -630,8 +841,13 @@ async def update_schedule(schedule_id: str, request: Request,
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:
@@ -681,6 +897,60 @@ async def update_schedule(schedule_id: str, request: Request,
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、时区、启用、重叠、补发宽限。工作流、输入模板、运行类别、
@@ -740,7 +1010,8 @@ async def set_enabled(session: AsyncSession, row: Schedule, enabled: bool) -> Sc
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),
.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()
# 只重读这一行:调用方(发布单)同一个会话里还拿着别的对象,不能整个会话过期
@@ -787,9 +1058,16 @@ async def run_now(schedule_id: str, session: AsyncSession = Depends(get_session)
发布版本发起。运行记在点按钮的人名下,没有署名时记在「定时任务:<名字>」名下。返回 {fire, run_id};目标是评测题集的,
发起的是一次评测,fire.eval_run_id 是它,run_id 为空。
触发时间取到秒:连点两下落在同一秒的,交回同一条触发记录,不会发起两次。"""
await _get(session, schedule_id)
fire = await service.fire_once(schedule_id, utcnow().replace(microsecond=0), "manual", actor=actor_of(x_actor))
触发时间取到秒:连点两下落在同一秒的,交回同一条触发记录,不会发起两次。
事件触发的任务按示例事件发起(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}
+5 -1
View File
@@ -290,7 +290,11 @@ async def _from_text(session: AsyncSession, source: Source) -> str:
n = await session.scalar(select(func.count(Run.id)).where(
Run.schedule_id == run.schedule_id, Run.created_at <= run.created_at))
name = schedule.name if schedule is not None else "已删除的定时任务"
return f"定时任务「{name}」第 {int(n or 1)} 次"
# 由数据事件触发的(scheduler/outbox:运行来源里记着 trigger)照实写是什么事件;立即运行按示例事件发起的不算
trigger = (run.origin or {}).get("trigger") if isinstance(run.origin, dict) else None
event = isinstance(trigger, dict) and trigger.get("text") and not trigger.get("sample")
lead = f"由事件触发:{trigger['text']}," if event else ""
return f"{lead}定时任务「{name}」第 {int(n or 1)} 次"
return "正式运行"
+5
View File
@@ -345,6 +345,11 @@ async def notify_after_job(source_id: str, job_id: str) -> int:
except Exception: # noqa: BLE001
logger.exception("任务 %s 结束后推送数据源变化的通知时出错", job_id)
n = 0
if n:
# 数据事件触发(scheduler/outbox):这一批「要处理」的变化记一条 source.changed(一个任务一条)
from app.scheduler import outbox
await outbox.emit_source_changed(source_id, job_id, n)
try:
# 口径账本的召回(next-round 02 做法 7 三):这次记下的变化里表或列被删、类型变了的,标用过它的答案「依据的表结构已变」
from app.ledger import recall
+16
View File
@@ -1081,6 +1081,16 @@ def _activation_row(source_id: str, snapshot: str, previous: str | None, activat
)
async def _version_event(session: AsyncSession, source_id: str, name: str, snap_id: str, previous: str | None,
fields: dict[str, Any], seq: int | None) -> None:
"""启用按期累积的新一期时记一条待派发事件(table.version,scheduler/outbox.stage_table_version),随这次提交一起落库。"""
from app.scheduler import outbox
await outbox.stage_table_version(session, source_id=source_id, source_name=name, snapshot_id=snap_id,
previous_snapshot_id=previous, period_start=fields.get("period_start"),
period_end=fields.get("period_end"), seq=seq)
def _usable_tmp(tmp: Path) -> bool:
"""临时库在、非空、在上传目录里。不在上传目录里的一律不认:发布结束要删它,不能删到别处去。"""
try:
@@ -1230,6 +1240,9 @@ async def publish_build(
if before_commit is not None:
await before_commit(imp_id, snap_id)
session.add(_activation_row(source_id, snap_id, previous, activation, default_import=imp_id))
if snap_mode == "accumulate":
# 数据事件触发(scheduler/outbox):按期累积的新一期启用了,和切指针同一个事务记一条待派发事件
await _version_event(session, source_id, source.name, snap_id, previous, import_fields, seq)
session.add(source)
source.kind, source.origin, source.readonly = "sqlite", "upload", True
@@ -1708,6 +1721,9 @@ async def publish_snapshot(
await before_commit(new_import.import_id if new_import is not None else None, snap_id)
session.add(_activation_row(source_id, snap_id, previous, activation,
default_import=new_import.import_id if new_import is not None else None))
if mode == "accumulate" and new_import is not None:
# 数据事件触发(scheduler/outbox):按期累积的新一期启用了(移除、作废没有新的一期,不算)
await _version_event(session, source_id, source.name, snap_id, previous, new_import.fields, seq)
source.current_snapshot_id = snap_id
source.schema_cache, source.schema_synced_at = cache, now
# database 只作显示:查询一律从快照解析
+2
View File
@@ -1094,3 +1094,5 @@ from app.db.models_blueprints import ( # noqa: E402,F401
# 下一轮第 2 波(docs/design/next-round):数据源对象索引与后台探查(models_sources)、评测(models_eval)。整模块导入,
# 各自的模型类由那两个文件自己登记,这里不逐个列名
from app.db import models_chat, models_eval, models_sources # noqa: E402,F401
# 编排完善第 1 波(docs/design/orchestration-round/00-总方案.md §4):数据事件触发的待派发事件和去抖等待
from app.db import models_triggers # noqa: E402,F401
+94
View File
@@ -0,0 +1,94 @@
"""数据事件触发的待派发事件和去抖等待(docs/design/orchestration-round/00-总方案.md §4)。
由 app/db/models.py 末尾整模块导入,create_all 建表(都是新表,只加不改)。这里不导入 app.db.models:models.py 导入本模块时
自己还没加载完,外键一律按表名写。
**为什么要一张「待派发事件」表,而不是在发生处直接发起运行。** 事件发生在别的事务里:启用上传表格的新版本、正式运行的
收尾、记下数据源的变化。在那里直接发起运行,发起失败要不要回滚那次启用?重复发生、服务重启时发起到一半又算什么?说不清。
改成在发生处写一行 TriggerEvent,调度循环一拍取走、按各定时任务的触发条件派发:发起运行的规矩(重叠跳过、正式运行只从
发布版本发起、触发记录唯一)全部沿用定时任务那一套。
什么时候和那件事同一个事务:table.version(切指针那一下)、workflow.finished(写终态那一下)是同一个事务——发生了就一定有
这一行,没发生就一定没有。source.changed 不是:一次探查、剖析的变化分好几批落库,「这一批只触发一次」要等任务结束才知道,
事件在任务结束后另开会话写(data/source_changes.notify_after_job)。中间进程死了、库被锁住写不进,变化记录在、事件没有:
调度的一拍按任务号补写(scheduler/outbox.backfill_source_changes),dedupe_key(source.changed:<任务号>)唯一,补写和正常的
那一次不会记成两条。
调度关着(AGENTLAB_SCHEDULER)时没人取,事件留在表里,接口照实说有几条在等。第 2 波多进程时,取走靠的是条件 UPDATE,
多个进程也只有一个取得到。
- TriggerEvent 一次事件:种类(table.version / workflow.finished / source.changed)、对象 id、给模板用的事件内容、触发链
的深度和经过的运行、发生时刻、去重键(只有 source.changed 用)。status:pending 还没派发;routed 已经按触发条件派给了
matched 个定时任务(0 个也算派完)。
- TriggerDelivery 一个事件派给一个定时任务、等着去抖:同一个定时任务窗口内(第一条事件发生之后 debounce_s 秒)的几条
合成一次触发,fire_id 是那次触发记录;status:pending 等着、fired 合进了某次触发(发起、跳过、未能发起都算)、dropped
定时任务在等待期间被停用或改了触发条件,不再派发。(定时任务, 事件) 唯一:同一个事件不会派给同一个任务两次。
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UTCDateTime, new_id, utcnow
#: 事件种类:上传表格启用了新的一期、工作流的正式运行成功结束、数据源出现要处理的变化
EVENT_KINDS = ("table.version", "workflow.finished", "source.changed")
#: 事件的状态:还没派发、已经派发(派给了 matched 个定时任务)
EVENT_STATUSES = ("pending", "routed")
#: 派给一个定时任务之后的状态:等着去抖、合进了一次触发、不再派发
DELIVERY_STATUSES = ("pending", "fired", "dropped")
class TriggerEvent(Base):
"""一次待派发的事件。payload 只放给输入模板用的值({{ event.* }},见 scheduler/triggers.py),workflow.finished 的
出口字段不复制进来:派发时按 run_id 读那次运行的成果(运行记录已经落库、不会再变)。"""
__tablename__ = "trigger_events"
__table_args__ = (
Index("ix_trigger_events_status", "status", "created_at"),
Index("ix_trigger_events_subject", "event", "subject_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
event: Mapped[str] = mapped_column(String(30))
#: 对象 id:表格数据源 id(table.version)、工作流 id(workflow.finished)、数据源 id(source.changed)
subject_id: Mapped[str] = mapped_column(String(32), default="")
#: 对象当时的名字(给人看的说法里用;对象之后改名、删除都不影响这一条)
subject_name: Mapped[str] = mapped_column(String(200), default="")
payload: Mapped[dict[str, Any]] = mapped_column(default=dict)
#: 触发链的深度:workflow.finished 时是上游那次运行自己由事件触发了几层(不是事件触发的为 0),别的种类为 0。
#: 派发时新运行的深度是它加 1,超过上限(scheduler/triggers.MAX_DEPTH)不发起
depth: Mapped[int] = mapped_column(Integer, default=0)
#: 经过的运行 id(从最上游起),给记录、排查循环用;只截最近的几条
chain: Mapped[list[Any]] = mapped_column(default=list)
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
status: Mapped[str] = mapped_column(String(20), default="pending")
routed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
#: 派给了几个定时任务
matched: Mapped[int] = mapped_column(Integer, default=0)
#: 去重键:同一件事只记一条。source.changed 是 source.changed:<任务号>(任务结束时记、没记上的补写,两边撞上唯一约束);
#: 别的种类为空(它们和那件事同一个事务,不会记两遍)
dedupe_key: Mapped[str | None] = mapped_column(String(80), default=None, unique=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class TriggerDelivery(Base):
"""一个事件派给了一个定时任务,等着去抖、合成一次触发。"""
__tablename__ = "trigger_deliveries"
__table_args__ = (
UniqueConstraint("schedule_id", "event_id"),
Index("ix_trigger_deliveries_waiting", "schedule_id", "status", "occurred_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
schedule_id: Mapped[str] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"))
event_id: Mapped[str] = mapped_column(ForeignKey("trigger_events.id", ondelete="CASCADE"))
#: 同事件的发生时刻(去抖窗口按它算)
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
status: Mapped[str] = mapped_column(String(20), default="pending")
#: 合进的那次触发(schedule_fires.id)
fire_id: Mapped[str | None] = mapped_column(String(32), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
+2
View File
@@ -1063,6 +1063,8 @@ class RunManager:
await _close_approvals(session, run_id, actor, now)
if event is not None:
await _stage(session, event, run)
from app.scheduler import outbox # 数据事件触发:正式运行成功结束,同一个事务记一条待派发事件
outbox.stage_run_finished(session, run)
await session.commit()
if event is not None:
await bus.publish(event)
+2
View File
@@ -6,6 +6,8 @@
- webhook.py:通知推送(通用 / 飞书 / 企业微信 / 钉钉)
- auto_compare.py:运行成功结束后和同一定时任务上一次成功的运行自动对比,结果记在触发记录上,通知里只带计数摘要和
对比页链接
- triggers.py、outbox.py:数据事件触发(trigger_kind=event,docs/design/orchestration-round/00-总方案.md §4)——触发条件的
规则和说法、防环;待派发事件的写入(发生处)与每一拍的派发、去抖
服务启动时(main.py 的 lifespan)只在 scheduler_enabled() 时调 start,关闭时总是调 stop——stop 要能在没 start
过时安全返回。start 自己也看一次开关:拷来的数据目录(检查脚本的 --stacks、验收副本)不管从哪条路径起服务,
+19 -1
View File
@@ -6,6 +6,9 @@
错过的触发(服务停机、进程卡住):只看最近应触发的那一次——在宽限内补一次(kind=misfire),超出就记一条
skipped_misfire;更早的积压只记一条跳过,不补。下一次一律推进到现在之后,不会醒来就把积压的一口气全发起。
事件触发的任务(docs/design/orchestration-round/00-总方案.md §4)不在 fire_due 里:它们没有 next_run_at。每一拍发完到点的,
接着派待派发事件(scheduler/outbox.dispatch_due)。调度关着时这一拍不存在,事件留在表里。
"""
from __future__ import annotations
@@ -112,14 +115,28 @@ def _span(first: datetime, last: datetime, tz: str) -> str:
async def tick(now: datetime | None = None) -> None:
"""完整的一拍:发起到点的,处理流程的时限和核实,再推送该推送的通知。测试和手动调用用;循环里分开跑(见 _Runner._loop)。"""
"""完整的一拍:发起到点的,派发数据事件,处理流程的时限和核实,再推送该推送的通知。测试和手动调用用;循环里分开跑
(见 _Runner._loop)。"""
from app.scheduler import webhook
await fire_due(now)
await dispatch_events(now)
await process_due(now)
await webhook.notify_due(now)
async def dispatch_events(now: datetime | None = None) -> None:
"""派发待派发事件(scheduler/outbox)。出错只记日志:这一拍出错不该让调度循环停下,也不该挡着后面的流程处理。"""
from app.scheduler import outbox
try:
done = await outbox.dispatch_due(now)
if done:
logger.info("事件触发:这一拍记下了 %d 次触发", len(done))
except Exception: # noqa: BLE001
logger.exception("派发数据事件这一拍出错,下一拍照常进行")
async def process_due(now: datetime | None = None) -> None:
"""流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查;
上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉
@@ -175,6 +192,7 @@ class _Runner:
while not stopping.is_set():
try:
await fire_due()
await dispatch_events()
await process_due()
# stop 撞在这一拍的发起当中时,收尾不再新起通知扫描:stop 只等它动手时手上的那一份
if not stopping.is_set() and (self.notify is None or self.notify.done()):
+507
View File
@@ -0,0 +1,507 @@
"""待派发事件(docs/design/orchestration-round/00-总方案.md §4):在发生处写、调度循环一拍取走、按触发条件派给定时任务、去抖
合并、发起。触发条件的写法和说法在 triggers.py,发起在 service.fire_once。
**写(发生处)。** 三处,各自只加一两行调用这里:
- 上传表格按期累积的新一期启用(data/table_versions 的 publish_build / publish_snapshot,切指针的同一个事务):stage_table_version;
- 正式运行成功收尾(engine/runner 的 _finalize,写终态的同一个事务):stage_run_finished;途经驳回的(W1-b 的驳回出口)带上
rejected_via,缺省不认它的任务不派;
- 探查、剖析任务结束、这一批新记了「要处理」的变化(data/source_changes.notify_after_job):emit_source_changed,一个任务一条。
这一处不和变化记录同一个事务(一批只触发一次要等任务结束才知道),中间丢了的由每一拍的 backfill_source_changes 按任务号补写,
去重键唯一、不会记成两条。
发生处只写一行 TriggerEvent,不查有没有任务在等、不发起运行:事务边界、重复与重启都在这边说清。
**取(调度循环的一拍,dispatch_due)。**
1. 派:pending 的事件逐条按条件 UPDATE 认领成 routed(多个进程也只有一个认领得到),同一个事务里给每个合条件、启用着的
事件触发任务写一行 TriggerDelivery。没人合条件的也认领掉(matched=0)。
2. 发:每个有等待中的 delivery 的任务,按发生时刻把它们切成去抖窗口:窗口从第一条的发生时刻起算 debounce_s 秒,落在里面的
几条合成一次触发,内容取最新的那一条,fire_at 是窗口的结束时刻(同一个窗口算出来的永远是同一个时刻,触发记录
(任务, fire_at, kind) 唯一,重复派发、重启后补派都不会发起两次)。上传表格按期次分窗口(triggers.window_key):9 月、10 月
连着启用各有各的触发,同一期的重传照旧合并。一拍里把到点的窗口处理完(最多 MAX_WINDOWS 个),不是一拍一个:以前积压时
一拍只走一个窗口,后面的越等越久、最后全过了宽限。到点的窗口里已经过了补发宽限的(排在前面)合成**一条** skipped_misfire,
写合并了几次事件、几个窗口、时间跨度——派给任务就已经晚了(调度停过)才说「调度未运行」,否则照实说积压;之后宽限内的
照常发起:上一次的运行还没结束照 overlap=skip 记 skipped_overlap,触发链超过上限记未能发起。处理过的标 fired。任务停用了、
改了触发条件,等着的标 dropped,不再派发。
调度关着(AGENTLAB_SCHEDULER)时没有这一拍:事件留在表里,接口照实说有几条在等(pending_for)。
**留多久。** 派完的事件、合进触发的 delivery 留 RETAIN_DAYS 天给排查,之后由这一拍顺手清掉(每小时最多一次)。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import delete, func, select, update
from app.db.base import SessionLocal, utcnow
from app.db.models import Run, Schedule, ScheduleFire
from app.db.models_triggers import TriggerDelivery, TriggerEvent
from app.scheduler import triggers
logger = logging.getLogger(__name__)
#: 一拍最多派多少条事件(积压时分几拍派完,不让一拍拖太久)
ROUTE_BATCH = 500
#: 派完的事件、合进触发的 delivery 留多少天
RETAIN_DAYS = 30
#: 清理最多多久一次
CLEAN_EVERY = timedelta(hours=1)
#: 触发链里记几条运行 id(从最近的往前)
CHAIN_KEEP = 10
#: 一次触发里记几条合并进来的事件 id
MERGED_KEEP = 20
#: 一个任务一拍最多处理几个到点的去抖窗口、读几条等着的事件(积压特别多时分几拍处理完,不让一拍拖太久)
MAX_WINDOWS = 100
DRAIN_BATCH = 5000
#: source.changed 往回补写几天内结束的任务;补写最多多久一次(刚起的服务第一拍就补)
BACKFILL_DAYS = 2
BACKFILL_EVERY = timedelta(minutes=10)
# --------------------------------------------------------------------------
# 写(发生处)
# --------------------------------------------------------------------------
async def stage_table_version(session: Any, *, source_id: str, source_name: str, snapshot_id: str,
previous_snapshot_id: str | None, period_start: str | None, period_end: str | None,
seq: int | None) -> None:
"""按期累积的上传表格启用了新的一期:往调用方的会话里加一条 table.version(不提交,随切指针一起提交)。这一期在上一个
当前版本里已经有了(统计期相同)就是「替换了已有的一期」(replaced),只认新增一期的任务不认它。读上一个版本时不自动刷写
会话:调用方(table_versions)挂着还没刷进库的导入记录、快照。"""
replaced = False
if previous_snapshot_id and period_start:
from app.db.models import SourceSnapshot, TableImport
with session.no_autoflush:
prev = await session.get(SourceSnapshot, previous_snapshot_id)
ids = [str(x) for x in (prev.imports or [])] if prev is not None else []
if ids:
replaced = bool((await session.execute(select(func.count()).select_from(TableImport).where(
TableImport.id.in_(ids), TableImport.period_start == period_start,
TableImport.period_end == period_end))).scalar())
now = utcnow()
session.add(TriggerEvent(
event="table.version", subject_id=source_id, subject_name=(source_name or "")[:200], occurred_at=now,
payload=triggers.table_payload(source_name, snapshot_id=snapshot_id, period_start=period_start,
period_end=period_end, activated_at=now, replaced=replaced, seq=seq)))
def stage_run_finished(session: Any, run: Run, rejected_via: Any = None) -> None:
"""正式运行成功结束:往收尾的会话里加一条 workflow.finished(不提交,随终态一起提交)。探索运行、没挂在工作流上的不算。
这次运行自己是事件触发的,事件带上它的触发深度和经过的运行,下游据此防环。
rejected_via:这次运行途经了哪几个关卡的驳回(W1-b 的驳回出口:按驳回那条路走完,状态仍是 succeeded;runner 收尾时把
_rejected_via(state) 交进来),缺省 None 是没途经驳回。照发,事件里带上它:缺省不认途经驳回的任务不派(triggers.matches)。"""
if run.status != "succeeded" or run.run_class != "formal" or not run.workflow_id:
return
upstream = (run.origin or {}).get("trigger") if run.origin_kind == "schedule" and isinstance(run.origin, dict) else None
upstream = upstream if isinstance(upstream, dict) else {}
depth = upstream.get("depth") if isinstance(upstream.get("depth"), int) else 0
chain = [str(x) for x in (upstream.get("chain") or []) if x][-(CHAIN_KEEP - 1):] + [run.id]
session.add(TriggerEvent(
event="workflow.finished", subject_id=run.workflow_id, subject_name=(run.workflow_name or "")[:200],
payload=triggers.run_payload(run, rejected_via), depth=depth, chain=chain,
occurred_at=run.finished_at or utcnow()))
def source_key(job_id: str) -> str:
"""source.changed 的去重键:一个任务一条。"""
return f"source.changed:{job_id}"
async def emit_source_changed(source_id: str, job_id: str, new: int, *, occurred_at: datetime | None = None) -> bool:
"""一次探查、剖析任务结束、新记了 new 条「要处理」的变化:记一条 source.changed(自己开会话提交),返回记没记下。同一个任务
只记一条(去重键唯一):「同一次探查 / 剖析产生的一批只触发一次」,补写(backfill_source_changes)和这里撞上也只有一条。
出错只记日志:变化记录本身已经落库,没记上的由补写接着记。"""
from sqlalchemy.exc import IntegrityError
from app.data import source_changes
from app.db.models import DataSource
try:
async with SessionLocal() as session:
if await session.scalar(select(TriggerEvent.id).where(TriggerEvent.dedupe_key == source_key(job_id))):
return False
source = await session.get(DataSource, source_id)
got = await source_changes.counts(session, source_id)
by = got.get("by_severity") or {}
name = source.name if source is not None else ""
session.add(TriggerEvent(
event="source.changed", subject_id=source_id, subject_name=name[:200], dedupe_key=source_key(job_id),
occurred_at=occurred_at or utcnow(),
payload={"source": name, "source_id": source_id, "job_id": job_id, "counts": {
"new": int(new), "action": int(by.get("action") or 0), "warning": int(by.get("warning") or 0),
"info": int(by.get("info") or 0), "pending": int(got.get("pending") or 0)}}))
try:
await session.commit()
except IntegrityError:
await session.rollback() # 同一个任务刚被别处记上(补写和收尾撞在一起)
return False
return True
except Exception: # noqa: BLE001
logger.exception("任务 %s 结束后记数据源变化的待派发事件时出错", job_id)
return False
_backfilled_at: datetime | None = None
async def backfill_source_changes(now: datetime | None = None) -> int:
"""补写 source.changed:BACKFILL_DAYS 天内测到、还开着的「要处理」级变化,按任务号分组;任务已经结束(探查任务;剖析任务
只算取值观察那几种,同 notify_after_job)、却没有那个任务的事件的,补一条(发生时刻取任务结束的时刻)。返回补了几条。
在 Python 里按任务号分组,不用各库写法不同的 JSON 取值。"""
from app.data.catalog_profile_jobs import OBSERVING_SCOPES
from app.db.models import CatalogProfileJob
from app.db.models_sources import IntrospectJob, SourceChange
now = now or utcnow()
async with SessionLocal() as session:
rows = (await session.execute(select(SourceChange.source_id, SourceChange.detected_by).where(
SourceChange.severity == "action", SourceChange.status == "open",
SourceChange.detected_at >= now - timedelta(days=BACKFILL_DAYS)))).all()
jobs: dict[tuple[str, str, str], int] = {}
for source_id, detected in rows:
detected = detected if isinstance(detected, dict) else {}
job_id, kind = detected.get("job_id"), detected.get("job_kind")
if job_id and kind in ("introspect", "profile"):
key = (str(kind), str(job_id), source_id)
jobs[key] = jobs.get(key, 0) + 1
if not jobs:
return 0
have = set((await session.execute(select(TriggerEvent.dedupe_key).where(
TriggerEvent.dedupe_key.in_([source_key(j) for _, j, _ in jobs])))).scalars())
todo: list[tuple[str, str, int, datetime | None]] = []
for (kind, job_id, source_id), n in jobs.items():
if source_key(job_id) in have:
continue
job = await session.get(IntrospectJob if kind == "introspect" else CatalogProfileJob, job_id)
if job is None or job.status in ("queued", "running"):
continue # 还在进行:结束时自己会记
if kind == "profile" and job.scope not in OBSERVING_SCOPES:
continue
todo.append((source_id, job_id, n, job.finished_at))
made = 0
for source_id, job_id, n, finished in todo:
if await emit_source_changed(source_id, job_id, n, occurred_at=finished):
made += 1
logger.info("补写了任务 %s 的数据源变化事件(它结束时没记上)", job_id)
return made
# --------------------------------------------------------------------------
# 一次触发要带的事件
# --------------------------------------------------------------------------
@dataclass
class EventFire:
"""交给 service.fire_once 的事件:模板里 {{ event.* }} 的值、写进触发记录和运行来源的说明、新运行的触发深度。
sample:立即运行时按对象现在的情况凑的(不是真来了一次事件);real 为假时是示例值,用到 event.* 的模板不拿它发起。"""
event: str
subject_id: str
subject_name: str
values: dict[str, Any]
text: str
depth: int = 1
chain: list[str] = field(default_factory=list)
event_ids: list[str] = field(default_factory=list)
first_at: str | None = None
last_at: str | None = None
sample: bool = False
real: bool = True
def brief(self) -> dict[str, Any]:
"""写进触发记录 inputs.event 的那一份(触发历史、通知读它):不含出口字段的值。"""
return {"event": self.event, "subject_id": self.subject_id, "subject_name": self.subject_name,
"text": self.text, "event_ids": self.event_ids[-MERGED_KEEP:], "merged": len(self.event_ids),
"first_at": self.first_at, "last_at": self.last_at, "depth": self.depth, "sample": self.sample,
"real": self.real}
def origin(self) -> dict[str, Any]:
"""运行来源里的 trigger:{kind, event, 对象, 事件 id……},在封存范围内(engine/origin)。记录页据 text 写「由事件触发:…」。"""
return {"kind": "event", "event": self.event, "subject_id": self.subject_id, "subject_name": self.subject_name,
"event_id": self.event_ids[-1] if self.event_ids else None, "event_ids": self.event_ids[-MERGED_KEEP:],
"merged": len(self.event_ids), "depth": self.depth, "chain": self.chain[-CHAIN_KEEP:],
"text": self.text, "sample": self.sample, "real": self.real}
def sample_fire(sample: triggers.Sample, tz: str) -> EventFire:
"""立即运行:按示例事件发起(触发链从这里起算,第 1 层)。是不是示例值记在 real 上,说法不另加后缀:界面、印章按
sample / real 自己说「立即运行,按最近一次事件」「事件为示例值」。"""
text = triggers.event_text(sample.event, sample.subject_name, sample.payload)
return EventFire(event=sample.event, subject_id=sample.subject_id, subject_name=sample.subject_name,
values=triggers.values(sample.event, sample.payload, tz, outputs=sample.outputs),
text=text, sample=True, real=sample.real,
chain=[sample.payload["run_id"]] if sample.event == "workflow.finished" and sample.payload.get(
"run_id") else [])
# --------------------------------------------------------------------------
# 取(调度循环的一拍)
# --------------------------------------------------------------------------
_cleaned_at: datetime | None = None
async def dispatch_due(now: datetime | None = None) -> list[ScheduleFire]:
"""完整的一拍:补写没记上的 source.changed(每 BACKFILL_EVERY 一次,刚起的服务第一拍就补)、派新来的事件、发去抖窗口到了的
触发、顺手清理。返回这一拍记下的触发。一个任务出错不影响别的。"""
global _backfilled_at
now = now or utcnow()
if _backfilled_at is None or now - _backfilled_at >= BACKFILL_EVERY:
_backfilled_at = now
try:
await backfill_source_changes(now)
except Exception: # noqa: BLE001 - 补写是兜底,出错不挡派发
logger.exception("补写数据源变化的待派发事件时出错")
await route(now)
out = await fire_waiting(now)
await _clean(now)
return out
async def route(now: datetime | None = None) -> int:
"""把 pending 的事件派给合条件、启用着的事件触发任务。返回这一拍派了几条事件。"""
now = now or utcnow()
async with SessionLocal() as session:
events = list((await session.execute(select(TriggerEvent).where(TriggerEvent.status == "pending")
.order_by(TriggerEvent.created_at, TriggerEvent.id)
.limit(ROUTE_BATCH))).scalars())
if not events:
return 0
rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.target_kind).where(
Schedule.trigger_kind == "event", Schedule.enabled.is_(True)))).all()
listeners = [(sid, trigger) for sid, trigger, kind in rows if (kind or "workflow") == "workflow"]
routed = 0
for ev in events:
matched = [sid for sid, trigger in listeners if triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)]
try:
async with SessionLocal() as session:
# 条件 UPDATE 认领:两拍撞在一起(以后多开了进程)只有一拍改得动这一行
claimed = await session.execute(
update(TriggerEvent).where(TriggerEvent.id == ev.id, TriggerEvent.status == "pending")
.values(status="routed", routed_at=now, matched=len(matched))
.execution_options(synchronize_session=False))
if claimed.rowcount != 1:
await session.rollback()
continue
for sid in matched:
session.add(TriggerDelivery(schedule_id=sid, event_id=ev.id, occurred_at=ev.occurred_at))
await session.commit()
routed += 1
except Exception: # noqa: BLE001 - 一条出错(任务刚被删掉、外键)不拖住别的,下一拍再派
logger.exception("派发事件 %s 时出错", ev.id)
return routed
async def fire_waiting(now: datetime | None = None) -> list[ScheduleFire]:
"""有等着的事件的任务,各把到点的去抖窗口处理完。"""
now = now or utcnow()
async with SessionLocal() as session:
waiting = list((await session.execute(
select(TriggerDelivery.schedule_id).where(TriggerDelivery.status == "pending").distinct())).scalars())
out: list[ScheduleFire] = []
for schedule_id in waiting:
try:
out.extend(await _drain(schedule_id, now))
except Exception: # noqa: BLE001 - 一个任务出错不能拖住这一拍的其他任务
logger.exception("事件触发的定时任务 %s 这一拍处理失败", schedule_id)
return out
@dataclass
class _Window:
"""一个去抖窗口:同一个分窗口的键(上传表格是期次)、落在里面的几条(delivery id, 事件, 派给任务的时刻)、结束的时刻。"""
key: str
start: datetime
due: datetime
items: list[tuple[str, TriggerEvent, datetime]] = field(default_factory=list)
@property
def events(self) -> list[TriggerEvent]:
return [ev for _, ev, _ in self.items]
def windows(items: list[tuple[str, TriggerEvent, datetime]], debounce_s: int) -> list[_Window]:
"""按发生时刻切成去抖窗口(items 已按发生时刻排好):每个分窗口的键各自一串,窗口从第一条起算 debounce_s 秒;按结束时刻
排。两个窗口结束在同一刻(不同期次同一刻启用)时后一个往后挪 1 微秒:fire_at 不撞触发记录的唯一约束,同样的输入永远切出
同样的窗口。"""
from app.scheduler import service
span = timedelta(seconds=debounce_s)
current: dict[str, _Window] = {}
out: list[_Window] = []
for did, ev, routed in items:
at = service.as_utc(ev.occurred_at)
key = triggers.window_key(ev.event, ev.payload)
win = current.get(key)
if win is None or at > win.due:
win = current[key] = _Window(key, at, at + span)
out.append(win)
win.items.append((did, ev, routed))
out.sort(key=lambda w: (w.due, w.key))
for prev, cur in zip(out, out[1:]):
if cur.due <= prev.due:
cur.due = prev.due + timedelta(microseconds=1)
return out
async def _drain(schedule_id: str, now: datetime) -> list[ScheduleFire]:
"""一个任务这一拍到点的窗口:已过补发宽限的合成一条跳过,其余照常发起。"""
from app.scheduler import loop, service
async with SessionLocal() as session:
schedule = await session.get(Schedule, schedule_id)
if schedule is None:
return []
trigger, tz = schedule.trigger if isinstance(schedule.trigger, dict) else {}, schedule.timezone
if not schedule.enabled or schedule.trigger_kind != "event":
# 等着的时候被停用、改成了别的触发方式:停用是「别再触发了」,等着的不再派发
await _close(session, _pending(schedule_id), "dropped")
return []
rows = (await session.execute(
select(TriggerDelivery.id, TriggerDelivery.created_at, TriggerEvent)
.join(TriggerEvent, TriggerEvent.id == TriggerDelivery.event_id)
.where(TriggerDelivery.schedule_id == schedule_id, TriggerDelivery.status == "pending")
.order_by(TriggerDelivery.occurred_at, TriggerEvent.created_at).limit(DRAIN_BATCH))).all()
# 等着的时候改了触发条件(换了对象、加了过滤):已经不合条件的不再派发
stale = [did for did, _, ev in rows if not triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)]
if stale:
await _close(session, TriggerDelivery.id.in_(stale), "dropped")
live = [(did, ev, service.as_utc(routed)) for did, routed, ev in rows if did not in set(stale)]
due = [w for w in windows(live, triggers.debounce_of(trigger)) if w.due <= now][:MAX_WINDOWS]
if not due:
return []
grace = max(int(schedule.misfire_grace_s or 0), 0)
limit = max(loop.ON_TIME_S, grace)
missed = [w for w in due if (now - w.due).total_seconds() > limit]
timely = [w for w in due if w not in missed]
outputs = {id(w): await _outputs_of(session, w.events[-1]) for w in timely}
out: list[ScheduleFire] = []
if missed:
fire = await _skip_missed(schedule_id, missed, tz, grace)
if fire is not None:
out.append(fire)
await _close_window(missed, fire.id)
for w in timely:
fire = await service.fire_once(schedule_id, w.due, "event", event=_event_fire(w.events, tz, outputs[id(w)]))
if fire is not None:
out.append(fire)
await _close_window([w], fire.id)
return out
def _event_fire(events: list[TriggerEvent], tz: str, outputs: dict[str, Any]) -> EventFire:
"""几条合成一次的事件:内容取最新的那一条,深度取最深的那一条加 1。"""
latest = events[-1]
return EventFire(
event=latest.event, subject_id=latest.subject_id, subject_name=latest.subject_name,
values=triggers.values(latest.event, latest.payload, tz, outputs=outputs),
text=triggers.event_text(latest.event, latest.subject_name, latest.payload),
depth=max(int(ev.depth or 0) for ev in events) + 1, chain=[str(x) for x in latest.chain or []],
event_ids=[ev.id for ev in events], first_at=_iso(events[0].occurred_at), last_at=_iso(latest.occurred_at))
async def _skip_missed(schedule_id: str, missed: list[_Window], tz: str, grace: int) -> ScheduleFire | None:
"""已过补发宽限的几个窗口合成一条 skipped_misfire(fire_at 取最后一个的结束时刻):合并了几次事件、几个窗口、时间跨度、
合掉的期次。派给任务的时刻离事件发生已经超过宽限的(调度停过、没在派)才说「调度未运行」,否则照实说积压。"""
from app.scheduler import service
events = [ev for w in missed for ev in w.events]
events.sort(key=lambda ev: service.as_utc(ev.occurred_at))
late_routed = any((routed - service.as_utc(ev.occurred_at)).total_seconds() > grace
for w in missed for _, ev, routed in w.items)
first, last = service.local_text(events[0].occurred_at, tz), service.local_text(events[-1].occurred_at, tz)
span = first if first == last else f"{first} 至 {last}"
count = f"{len(events)} 次事件" + (f"({len(missed)} 个去抖窗口)" if len(missed) > 1 else "")
if late_routed:
why = "服务当时没有派发(调度未运行或已停止)"
else:
why = "事件积压,轮到派发时"
reason = f"{span} 的 {count}:{why},已超出补发宽限({service.duration_text(grace)}),没有发起;积压的事件不补"
brief = _event_fire(events, tz, {}).brief()
brief["windows"] = len(missed)
periods = [p for p in dict.fromkeys(str((ev.payload or {}).get("period") or "") for ev in events) if p]
if events[-1].event == "table.version" and periods:
brief["periods"] = periods
return await service.record_fire(schedule_id, max(w.due for w in missed), "event", "skipped_misfire", reason,
{"event": brief})
async def _close_window(wins: list[_Window], fire_id: str) -> None:
async with SessionLocal() as session:
await _close(session, TriggerDelivery.id.in_([did for w in wins for did, _, _ in w.items]), "fired",
fire_id=fire_id)
def _pending(schedule_id: str) -> Any:
return (TriggerDelivery.schedule_id == schedule_id) & (TriggerDelivery.status == "pending")
async def _close(session: Any, cond: Any, status: str, *, fire_id: str | None = None) -> None:
await session.execute(update(TriggerDelivery).where(cond, TriggerDelivery.status == "pending")
.values(status=status, fire_id=fire_id).execution_options(synchronize_session=False))
await session.commit()
async def _outputs_of(session: Any, ev: TriggerEvent) -> dict[str, Any]:
"""workflow.finished 的出口字段:读那次运行的成果(运行已经结束、不会再变),过滤同预览、立即运行(triggers.outputs_of)。
读不到为空。"""
if ev.event != "workflow.finished":
return {}
run_id = (ev.payload or {}).get("run_id")
run = await session.get(Run, run_id) if run_id else None
return triggers.outputs_of(run.output if run is not None else None)
def _iso(moment: datetime | None) -> str | None:
from app.scheduler import service
return service.as_utc(moment).isoformat() if moment is not None else None
async def _clean(now: datetime) -> None:
"""派完超过 RETAIN_DAYS 天的事件、合进触发或不再派发的 delivery 清掉。每小时最多一次;出错只记日志。"""
global _cleaned_at
if _cleaned_at is not None and now - _cleaned_at < CLEAN_EVERY:
return
_cleaned_at = now
cutoff = now - timedelta(days=RETAIN_DAYS)
try:
async with SessionLocal() as session:
await session.execute(delete(TriggerDelivery).where(TriggerDelivery.status != "pending",
TriggerDelivery.created_at < cutoff))
waiting = select(TriggerDelivery.id).where(TriggerDelivery.event_id == TriggerEvent.id).exists()
await session.execute(delete(TriggerEvent).where(TriggerEvent.status == "routed",
TriggerEvent.created_at < cutoff, ~waiting)
.execution_options(synchronize_session=False))
await session.commit()
except Exception: # noqa: BLE001
logger.exception("清理派完的待派发事件时出错")
# --------------------------------------------------------------------------
# 给接口看的
# --------------------------------------------------------------------------
async def pending_for(session: Any, schedule: Schedule) -> dict[str, Any]:
"""一个事件触发任务现在有几条事件在等:events 还没派发的(调度关着时一直留在这里)、waiting 已经派给它、在去抖窗口里的;
due_at 是窗口结束、会合成一次触发的时刻(没有在等的为空)。"""
trigger = schedule.trigger if isinstance(schedule.trigger, dict) else {}
subject, event = triggers.subject_of(trigger), trigger.get("event") or ""
rows = (await session.execute(select(TriggerEvent.payload).where(
TriggerEvent.status == "pending", TriggerEvent.event == event, TriggerEvent.subject_id == subject)
.limit(ROUTE_BATCH))).scalars() if subject else []
unrouted = sum(1 for payload in rows if triggers.matches(trigger, event, subject, payload))
waiting, first = (await session.execute(select(func.count(), func.min(TriggerDelivery.occurred_at)).where(
TriggerDelivery.schedule_id == schedule.id, TriggerDelivery.status == "pending"))).one()
from app.scheduler import service
due = service.as_utc(first) + timedelta(seconds=triggers.debounce_of(trigger)) if first is not None else None
return {"events": unrouted, "waiting": int(waiting or 0), "due_at": due.isoformat() if due else None}
+101 -22
View File
@@ -4,6 +4,11 @@
只有一份。发起本身走 engine/launch.py 的 launch_run,和 HTTP 发起是同一套规则——正式运行只能从当前发布版本
发起,这一条不会在定时任务这边被绕过。
**事件触发**(docs/design/orchestration-round/00-总方案.md §4):trigger_kind=event 的任务不按 cron 到点,而是由数据事件触发
(scheduler/triggers.py 的规则、scheduler/outbox.py 的派发)。派发到了照样调 fire_once(kind=event,带上事件 EventFire):
重叠跳过、记触发、发起运行还是这一份;多出来的只有三样——输入模板里能用 {{ event.* }}、运行来源里记 trigger、触发链超过
上限不发起。触发记录的 inputs 写 {event: 事件的说明, inputs: 渲染出的输入}。
**定时的目标**(docs/design/next-round/00-总方案.md X4:定时只有一套):一个定时任务指向一个目标——工作流、评测题集
(app/eval/scheduled.py)或数据源维护(app/data/source_maintenance.py)。认领、补跑、重叠跳过、触发记录、通知都只有这一套,
fire_once 按目标种类分派;建任务、改目标时核对目标也按种类交给各自的模块(check_target)。
@@ -182,6 +187,7 @@ def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec)
按 fire_at 算而不是按「现在」:补发晚了一小时、立即运行在零点前后点下去,「昨天」都是那次触发的昨天。
ISO 周的年份是周所在的年(isocalendar),不是日历年:2027-01-01 是 2026-W53,2025-12-29 是 2026-W01。
last_fire_time 是按 cron 往前找的上一次计划触发,不看历史记录:同一个 (cron, 时区, fire_at) 永远渲染出同一份输入。
事件触发的任务没有 cron(存空串),last_fire_time 为空(这个变量在事件触发的模板里也不让用,见 check_template)。
"""
zone = cron.zone(tz)
local = fire_at.astimezone(zone)
@@ -190,7 +196,7 @@ def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec)
month_start = today.replace(day=1)
last_month_end = month_start - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
previous = cron.prev_fire(cron_expr, zone, fire_at)
previous = cron.prev_fire(cron_expr, zone, fire_at) if cron_expr else None
return {
"today": _day(today),
"yesterday": _day(today - timedelta(days=1)),
@@ -225,17 +231,28 @@ def _month(value: date) -> str:
return f"{value.year:04d}-{value.month:02d}"
def render_inputs(template: dict[str, str], values: dict[str, str]) -> dict[str, str]:
"""渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下。"""
ctx = {"schedule": values}
def render_inputs(template: dict[str, str], values: dict[str, str], *,
event: dict[str, Any] | None = None) -> dict[str, str]:
"""渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下;事件触发的另有
event(scheduler/triggers.values)。"""
ctx: dict[str, Any] = {"schedule": values}
if event is not None:
ctx["event"] = event
return {name: render_template(text, ctx) for name, text in template.items()}
def check_template(template: Any) -> dict[str, str]:
#: 事件触发的模板里不能用的日期变量:没有 cron,就没有「上一次计划触发」
EVENT_UNSUPPORTED = ("schedule.last_fire_time",)
def check_template(template: Any, *, event: str | None = None) -> dict[str, str]:
"""校验输入模板,原样返回(None 当空模板)。写错抛 TemplateError。
项目的模板渲染取不到值时渲染成空串、不报错——画布上的半成品图要能运行。定时任务没人盯着,拼错的变量名
会让每一次运行都拿到空的日期,所以保存和预览时就要说出来。
event 是事件触发任务的事件种类:这时还能用这种事件的 {{ event.* }}(scheduler/triggers),不能用 schedule.last_fire_time。
不给(按 cron 触发的任务)时规则一字不变。
"""
if template is None:
return {}
@@ -252,18 +269,20 @@ def check_template(template: Any) -> dict[str, str]:
if not isinstance(text, str):
raise TemplateError(f"输入「{name}」的值需要是文本(可以带 {{{{ schedule.yesterday }}}} 这样的变量)"
f"{current_value(text)}")
_check_text(name, text)
_check_text(name, text, event)
out[name] = text
return out
def _check_text(name: str, text: str) -> None:
def _check_text(name: str, text: str, event: str | None = None) -> None:
for match in _TEMPLATE_RE.finditer(text):
expr = match.group(1).strip()
if not expr:
raise TemplateError(f"输入「{name}」的模板里有空的 {{{{ }}}},请在中间写变量名,例如 schedule.yesterday")
path, *filters = (part.strip() for part in expr.split("|"))
if path not in _NAMES:
if event is not None:
_check_event_path(name, path, event)
elif path not in _NAMES:
raise TemplateError(f"输入「{name}」的模板里「{path}」不是可用的变量,可用的变量:{'、'.join(_NAMES)}")
for filt in filters:
if filt not in _FILTERS:
@@ -273,15 +292,43 @@ def _check_text(name: str, text: str) -> None:
raise TemplateError(f"输入「{name}」的模板里 {{{{ 和 }}}} 没有成对出现,请检查是否少写了一半")
def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE) -> list[dict[str, str]]:
def _check_event_path(name: str, path: str, event: str) -> None:
"""事件触发任务的模板里的一个变量:schedule.*(上一次计划触发时间除外)或这种事件的 event.*。"""
from app.scheduler import triggers
if path in _NAMES and path not in EVENT_UNSUPPORTED:
return
if path in EVENT_UNSUPPORTED:
raise TemplateError(f"输入「{name}」的模板里「{path}」在事件触发的任务里不可用:没有按时间排的上一次触发")
if triggers.allowed_path(path, event):
return
usable = [n for n in _NAMES if n not in EVENT_UNSUPPORTED] + list(triggers.names(event))
raise TemplateError(f"输入「{name}」的模板里「{path}」不是可用的变量,可用的变量:{'、'.join(usable)}")
def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE, *,
event: str | None = None) -> list[dict[str, str]]:
"""变量清单:[{name, label, example, note}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron
可依,示例按每天同一时刻触发算。note 是补充说明(ISO 周的跨年规则),没有的是空串。"""
可依,示例按每天同一时刻触发算。note 是补充说明(ISO 周的跨年规则),没有的是空串。
event 是事件触发任务的事件种类:去掉上一次计划触发时间,后面接这种事件的 {{ event.* }}(示例是写法示意)。"""
moment = (now or utcnow()).replace(second=0, microsecond=0)
local = moment.astimezone(cron.zone(tz))
daily = f"{local.minute} {local.hour} * * *"
values = template_values(moment, tz, daily)
return [{"name": f"schedule.{key}", "label": label, "example": values[key], "note": note}
for key, label, note in VARIABLES]
out = [{"name": f"schedule.{key}", "label": label, "example": values[key], "note": note}
for key, label, note in VARIABLES]
if event is None:
return out
from app.scheduler import triggers
kept = [({**v, "label": EVENT_FIRE_TIME} if v["name"] == "schedule.fire_time" else v)
for v in out if v["name"] not in EVENT_UNSUPPORTED]
return kept + triggers.variables(event)
#: 事件触发的任务里 schedule.fire_time 的说法:不是「计划」的时刻,是去抖窗口结束、合成这次触发的时刻
EVENT_FIRE_TIME = "本次触发时间(去抖结束的时刻,带时区偏移的 ISO 时间)"
# --------------------------------------------------------------------------
@@ -368,12 +415,15 @@ def next_run(cron_expr: str, tz: str, enabled: bool, now: datetime | None = None
async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None = None,
) -> ScheduleFire | None:
event: Any = None) -> ScheduleFire | None:
"""处理一次触发,返回记下的触发记录。同一 (任务, fire_at, kind) 已经有记录(两拍撞在一起、立即运行连点两下)
时不再发起,返回已有的那一条;任务不存在返回 None。
按目标种类分派(target_of):工作流在这里发起(_fire_workflow),评测题集、数据源维护交给各自模块的 fire(同样持着
launch_lock,触发记录用 record_fire 写);不认识的种类记 failed_to_start、写明原因,不发起。
event 是事件触发的那一次事件(scheduler/outbox.EventFire):派发时(kind=event)、事件触发任务的立即运行(kind=manual,
示例事件)带上。事件触发只能指向工作流。
"""
async with launch_lock():
async with SessionLocal() as session:
@@ -383,6 +433,8 @@ async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: st
target_kind, target_id = target_of(schedule)
name, run_class = schedule.name, schedule.run_class
tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template
if target_kind != "workflow" and event is not None:
return await _record(schedule_id, fire_at, kind, "failed_to_start", EVENT_TARGET_ONLY, {})
if target_kind != "workflow":
handler = target_handler(target_kind)
if handler is None:
@@ -391,39 +443,66 @@ async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: st
return await handler.fire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, actor=actor,
target_id=target_id, name=name, tz=tz, cron_expr=cron_expr)
return await _fire_workflow(schedule_id, fire_at, kind, actor=actor, name=name, workflow_id=target_id,
run_class=run_class, tz=tz, cron_expr=cron_expr, template=template)
run_class=run_class, tz=tz, cron_expr=cron_expr, template=template, event=event)
#: 事件触发只能指向工作流(库里被改成别的目标时照实记)
EVENT_TARGET_ONLY = "事件触发的定时任务只能指向工作流,这次触发没有发起"
#: 触发链超过上限(scheduler/triggers.MAX_DEPTH)
DEPTH_REASON = ("这次运行会是事件触发链上的第 {depth} 层(上限 {limit} 层):为防止工作流之间循环触发,没有发起。"
"请检查「上游工作流运行成功结束」的定时任务是不是串成了环")
#: 立即运行事件触发的任务、对象还没发生过这种事件,模板又用到了 {{ event.* }}
NO_SAMPLE_REASON = "还没有发生过这种事件,输入模板里的 {{ event.* }} 没有可用的值,这次立即运行没有发起"
async def _fire_workflow(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None, name: str,
workflow_id: str, run_class: str, tz: str, cron_expr: str, template: Any,
) -> ScheduleFire | None:
event: Any = None) -> ScheduleFire | None:
"""目标是工作流的一次触发(持着 launch_lock)。
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
事件触发(event 不为空):模板按事件种类校验、渲染 {{ event.* }};触发链超过上限先记 failed_to_start(防环的兜底,比重叠
跳过先判);触发记录的 inputs 写 {event: 说明, inputs: 渲染出的输入};运行来源里带 trigger。
"""
from app.scheduler import triggers
brief = event.brief() if event is not None else None
def recorded(inputs: dict[str, Any]) -> dict[str, Any]:
return inputs if brief is None else {"event": brief, "inputs": inputs}
if event is not None and event.depth > triggers.MAX_DEPTH:
return await _record(schedule_id, fire_at, kind, "failed_to_start",
DEPTH_REASON.format(depth=event.depth, limit=triggers.MAX_DEPTH), recorded({}))
try:
inputs = render_inputs(check_template(template), template_values(fire_at, tz, cron_expr))
inputs = render_inputs(check_template(template, event=event.event if event is not None else None),
template_values(fire_at, tz, cron_expr),
event=event.values if event is not None else None)
except (TemplateError, cron.CronError, cron.TimezoneError) as e:
# 存进库之后才坏掉的(手改过库、时区库升级删了时区):照实说是哪一项,不发起
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"无法生成这次运行的输入:{e}", {})
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"无法生成这次运行的输入:{e}", recorded({}))
if event is not None and not event.real and triggers.uses_event(template):
return await _record(schedule_id, fire_at, kind, "failed_to_start", NO_SAMPLE_REASON, recorded({}))
busy = await _active_run(schedule_id)
if busy is not None:
return await _record(schedule_id, fire_at, kind, "skipped_overlap", overlap_reason(*busy), inputs)
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, inputs, fresh=True)
return await _record(schedule_id, fire_at, kind, "skipped_overlap", overlap_reason(*busy), recorded(inputs))
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, recorded(inputs), fresh=True)
if fire is None:
return await _existing(schedule_id, fire_at, kind)
from app.engine.launch import LaunchRefused, launch_run
origin: dict[str, Any] = {"schedule_id": schedule_id, "schedule": name, "fire_id": fire.id, "fire_kind": kind}
if event is not None:
origin["trigger"] = event.origin()
_LAUNCHING.add(fire.id)
try:
try:
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=workflow_id, run_class=run_class,
input_payload=inputs, actor=actor or actor_label(name),
schedule_id=schedule_id, origin_kind="schedule",
origin={"schedule_id": schedule_id, "schedule": name,
"fire_id": fire.id, "fire_kind": kind})
schedule_id=schedule_id, origin_kind="schedule", origin=origin)
except LaunchRefused as e:
return await _finish(fire.id, status="failed_to_start", reason=e.message)
except Exception: # noqa: BLE001 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
+610
View File
@@ -0,0 +1,610 @@
"""数据事件触发的规则(docs/design/orchestration-round/00-总方案.md §4):触发条件的写法与校验、给人看的说法、输入模板里的
{{ event.* }}、示例事件、防环。
**一套定时机制(X4)。** 事件触发不是另一套调度:它就是一个 trigger_kind=event 的定时任务,目标只能是工作流。认领、重叠跳过、
触发记录、通知、正式运行只从发布版本发起,全部沿用定时任务那一套(scheduler/service.fire_once);不同的只是「什么时候触发」
——不是到点,而是三种数据事件之一(已拍板:不接「口径发布」):
- table.version:一张按期累积的上传表格启用了新的一期(data/table_versions 启用那一下)。可选只认新增的一期(period=new):
重传、替换已有那一期(改错数)的不算;
- workflow.finished:一个工作流的正式运行成功结束。可选只认某个应用(app_id)发起的、某种来源(origin_kind)的;
下游的输入模板能引用这次运行的出口字段({{ event.outputs.<字段> }})。两种成功结束缺省不认:途经驳回的(审批接了驳回出口、
按驳回那条路走完,状态仍是 succeeded,事件里带 rejected_via)——下游会以为「A 成功出了结果」,其实那一期被驳回了,
include_rejected 时才认、模板里有 {{ event.rejected }};应用补算的(app_backfill,升版后按历次输入补算,一连发好几条、
各是某一期的历史输入),include_backfill 或只认这种来源时才认;
- source.changed:一个数据源出现「要处理」级的变化。按源去抖:同一次探查、剖析产生的一批只记一条事件(data/source_changes)。
trigger 的形状:{event, 对象(source_id / workflow_id / source_id), 过滤(period;app_id、origin_kind、include_rejected、
include_backfill), debounce_s(缺省 60)}。同一个定时任务在 debounce_s 内的几次事件合成一次触发,上传表格按期次分窗口
(window_key,scheduler/outbox)。
**防环。** workflow.finished 能把工作流串起来:A 跑完触发 B、B 跑完又触发 A 就停不下来。保存时查串联成不成环(find_cycle,
停用的任务也算:启用不该绕过这道检查);运行时按 origin 里记的触发链深度兜底,超过 MAX_DEPTH 层不发起、照实记一次未能发起
(手改过库、经别处间接串成环时还有这一道)。
这里不碰调度循环、不发起运行:派发在 scheduler/outbox.py,发起在 scheduler/service.py。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy import select
#: 事件种类,顺序即界面上的顺序(和 db/models_triggers.EVENT_KINDS 是同一份)
EVENTS = ("table.version", "workflow.finished", "source.changed")
#: 事件种类的叫法(界面另有一份在 lib/termsTrigger.ts,逐字对照)
EVENT_LABEL = {
"table.version": "上传表格出了新的一期",
"workflow.finished": "上游工作流运行成功结束",
"source.changed": "数据源有要处理的变化",
}
#: 每种事件的对象写在 trigger 的哪个键上、叫什么
SUBJECT_KEY = {"table.version": "source_id", "workflow.finished": "workflow_id", "source.changed": "source_id"}
SUBJECT_LABEL = {"table.version": "上传表格", "workflow.finished": "工作流", "source.changed": "数据源"}
DEFAULT_DEBOUNCE_S = 60
MAX_DEBOUNCE_S = 24 * 3600
#: 触发链最多几层:A 触发 B 是第 1 层,B 再触发 C 是第 2 层……超过就不发起
MAX_DEPTH = 5
#: 「期次规则」:any 新增的一期和替换已有的一期都认(缺省);new 只认新增的一期
PERIOD_RULES = ("any", "new")
PERIOD_RULE_LABEL = {"any": "新增的一期和替换已有的一期都认", "new": "只认新增的一期"}
#: 能发起正式运行的来源:workflow.finished 的「只认某种来源」从这几种里选(问数据、画布试跑、评测发起的都是探索运行,
#: 不会有 workflow.finished)
FORMAL_ORIGINS = ("launcher", "schedule", "app_entry", "app_view", "app_backfill", "hook", "api", "ledger_issue")
#: 来源的叫法(同前端 lib/terms.ts 的 ORIGIN_LABEL)
ORIGIN_LABEL = {
"launcher": "手动正式运行", "schedule": "定时任务", "app_entry": "应用入口", "app_view": "应用视图",
"app_backfill": "应用补算", "hook": "入站钩子", "api": "接口调用", "ledger_issue": "按口径正式出具",
}
#: trigger 里认得的键(多出来的拒收:拼错的过滤条件悄悄不生效,比报错更糟)。subject_name 是保存时记下的对象名(接口不给、
#: 带回来也不认,对象删掉之后说法里用它)
_KEYS = {"event", "source_id", "workflow_id", "period", "app_id", "origin_kind", "debounce_s", "subject_name",
"include_rejected", "include_backfill"}
#: 应用补算发起的运行的来源(engine/origin.py)
BACKFILL_ORIGIN = "app_backfill"
#: 示例事件里给不出真实值时写的占位说明
EXAMPLE_NOTE = "示例值"
class TriggerError(ValueError):
"""触发条件写错了、对象不在或不合用。str() 是给人看的中文说明,field 是表单上的那一项(缺省 trigger)。"""
def __init__(self, message: str, field: str = "trigger") -> None:
super().__init__(message)
self.field = field
# --------------------------------------------------------------------------
# 写法
# --------------------------------------------------------------------------
def _text(value: Any) -> str | None:
return value.strip() if isinstance(value, str) and value.strip() else None
def normalize(value: Any) -> dict[str, Any]:
"""校验 trigger 的形状并规范化(不碰库):{event, 对象键, debounce_s, 过滤…},没写的过滤不出现。不对抛 TriggerError。"""
if not isinstance(value, dict):
raise TriggerError("事件触发需要写明事件种类和对象,例如 {event: table.version, source_id: …}")
event = value.get("event")
if event not in EVENTS:
labels = "、".join(EVENT_LABEL[e] for e in EVENTS)
raise TriggerError(f"事件种类需要是{labels}之一")
unknown = sorted(k for k in value if k not in _KEYS)
if unknown:
raise TriggerError(f"事件触发的设置里有不认识的项:{'、'.join(unknown)}")
key = SUBJECT_KEY[event]
subject = _text(value.get(key))
if subject is None:
raise TriggerError(f"请选择{SUBJECT_LABEL[event]}")
for other in {"source_id", "workflow_id"} - {key}:
if _text(value.get(other)):
raise TriggerError(f"「{EVENT_LABEL[event]}」只选{SUBJECT_LABEL[event]},不写别的对象")
out: dict[str, Any] = {"event": event, key: subject}
period = value.get("period")
if period not in (None, ""):
if event != "table.version":
raise TriggerError("只有「上传表格出了新的一期」能按期次过滤")
if period not in PERIOD_RULES:
raise TriggerError("期次规则需要是「新增的一期和替换已有的一期都认」或「只认新增的一期」")
if period != "any":
out["period"] = period
for name in ("app_id", "origin_kind"):
raw = value.get(name)
if raw in (None, ""):
continue
if event != "workflow.finished":
raise TriggerError("只有「上游工作流运行成功结束」能按应用、来源过滤")
text = _text(raw)
if text is None:
raise TriggerError("只认的应用、来源需要从列表里选")
if name == "origin_kind" and text not in FORMAL_ORIGINS:
labels = "、".join(ORIGIN_LABEL[o] for o in FORMAL_ORIGINS)
raise TriggerError(f"只认的来源需要是{labels}之一")
out[name] = text
for name in ("include_rejected", "include_backfill"):
raw = value.get(name)
if raw in (None, False):
continue
if event != "workflow.finished":
raise TriggerError("只有「上游工作流运行成功结束」能选也认途经驳回的、应用补算的")
if raw is not True:
raise TriggerError("也认途经驳回的、应用补算的需要是 true 或 false")
# 只认补算的:补算本来就认,不再另记
if name == "include_backfill" and out.get("origin_kind") == BACKFILL_ORIGIN:
continue
out[name] = True
debounce = value.get("debounce_s", DEFAULT_DEBOUNCE_S)
if isinstance(debounce, bool) or not isinstance(debounce, int) or not 0 <= debounce <= MAX_DEBOUNCE_S:
raise TriggerError(f"去抖需要是 0 到 {MAX_DEBOUNCE_S} 之间的整数秒数(最长 1 天)", "debounce_s")
out["debounce_s"] = debounce
return out
def subject_of(trigger: dict[str, Any] | None) -> str:
"""触发条件里的对象 id;不成形的为空串。"""
trigger = trigger if isinstance(trigger, dict) else {}
key = SUBJECT_KEY.get(trigger.get("event") or "")
return str(trigger.get(key) or "") if key else ""
def debounce_of(trigger: dict[str, Any] | None) -> int:
value = (trigger or {}).get("debounce_s", DEFAULT_DEBOUNCE_S) if isinstance(trigger, dict) else DEFAULT_DEBOUNCE_S
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else DEFAULT_DEBOUNCE_S
def matches(trigger: dict[str, Any] | None, event: str, subject_id: str, payload: dict[str, Any] | None) -> bool:
"""这条事件合不合这个触发条件:种类、对象对得上,过滤条件都满足。"""
if not isinstance(trigger, dict) or trigger.get("event") != event or subject_of(trigger) != subject_id:
return False
payload = payload or {}
if event == "table.version" and trigger.get("period") == "new" and payload.get("replaced"):
return False
if event == "workflow.finished":
if trigger.get("app_id") and payload.get("app_id") != trigger["app_id"]:
return False
if trigger.get("origin_kind") and payload.get("origin_kind") != trigger["origin_kind"]:
return False
if payload.get("rejected") and not trigger.get("include_rejected"):
return False
if payload.get("origin_kind") == BACKFILL_ORIGIN and not (
trigger.get("include_backfill") or trigger.get("origin_kind") == BACKFILL_ORIGIN):
return False
return True
# --------------------------------------------------------------------------
# 核对对象(建任务、改触发条件时)
# --------------------------------------------------------------------------
@dataclass
class Checked:
"""核对通过的触发条件:对象现在的名字,过滤里的应用名(有的话)。"""
subject_name: str
app_name: str | None = None
async def accumulating(session: Any, source: Any) -> bool:
"""这个上传表格是不是按期累积的:当前版本是累积快照,或者现行配方写的是按期累积(还没导入过第二期的也算)。"""
from app.db.models import SourceSnapshot, TableRecipe
if source.current_snapshot_id:
snap = await session.get(SourceSnapshot, source.current_snapshot_id)
if snap is not None and (snap.mode or "replace") == "accumulate":
return True
if source.current_recipe_id:
recipe = await session.get(TableRecipe, source.current_recipe_id)
if recipe is not None and isinstance(recipe.recipe, dict) and recipe.recipe.get("mode") == "accumulate":
return True
return False
async def check(session: Any, trigger: dict[str, Any]) -> Checked:
"""核对触发条件里的对象(trigger 已经 normalize 过):表格在、是按期累积的上传表格;工作流在、已发布(只有正式运行成功
结束才算事件);数据源在;过滤里的应用在。不对抛 TriggerError。"""
from app.db.models import App, DataSource, Workflow
event, subject = trigger["event"], subject_of(trigger)
if event in ("table.version", "source.changed"):
source = await session.get(DataSource, subject)
if source is None:
raise TriggerError(f"{SUBJECT_LABEL[event]}不存在,可能已被删除")
if event == "table.version":
if (source.origin or "manual") != "upload":
raise TriggerError(f"「{source.name}」不是上传表格:只有上传表格有「出了新的一期」")
if not await accumulating(session, source):
raise TriggerError(f"「{source.name}」不是按期累积的上传表格:每次上传替换整张表的没有「新的一期」。"
"请在导入设置里改为按期累积,或改用「数据源有要处理的变化」")
return Checked(source.name)
workflow = await session.get(Workflow, subject)
if workflow is None:
raise TriggerError("上游工作流不存在,可能已被删除")
if not workflow.published_version:
raise TriggerError(f"上游工作流「{workflow.name}」还没有发布版本:只有正式运行成功结束才算事件,"
"正式运行只能从发布版本发起。请先在编排页发布")
app_name = None
if trigger.get("app_id"):
app = await session.get(App, trigger["app_id"])
if app is None:
raise TriggerError("只认的应用不存在,可能已被删除")
app_name = app.name
return Checked(workflow.name, app_name)
async def find_cycle(session: Any, *, schedule_id: str | None, trigger: dict[str, Any],
target_workflow_id: str) -> list[str] | None:
"""workflow.finished 串成的环:这个任务(A 跑完触发 B)加进去之后,从 B 沿着别的事件触发任务能不能回到 A。成环时返回经过的
工作流 id(A, B, …, A),否则 None。停用的任务也算进去:启用不该绕过这道检查;schedule_id 是正在改的那个任务(它原来的
那条边不算)。"""
if trigger.get("event") != "workflow.finished" or not target_workflow_id:
return None
from app.db.models import Schedule
start, target = subject_of(trigger), target_workflow_id
if start == target:
return [start, start]
edges: dict[str, set[str]] = {}
rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind)
.where(Schedule.trigger_kind == "event"))).all()
for sid, other, workflow_id, kind in rows:
if sid == schedule_id or (kind or "workflow") != "workflow" or not isinstance(other, dict):
continue
if other.get("event") == "workflow.finished" and subject_of(other) and workflow_id:
edges.setdefault(subject_of(other), set()).add(workflow_id)
# 从 B 出发找回 A 的路(广度优先,取最短的一条说给人听)
parent: dict[str, str | None] = {target: None}
queue = [target]
while queue:
node = queue.pop(0)
for nxt in sorted(edges.get(node, ())):
if nxt in parent:
continue
parent[nxt] = node
if nxt == start:
back = [start] # A ← … ← B,倒着记
while back[-1] != target:
back.append(parent[back[-1]] or target)
return [start, *reversed(back)]
queue.append(nxt)
return None
def cycle_text(names: list[str]) -> str:
"""成环时的说明(422):「A」→「B」→「A」,箭头说的是「正式运行成功结束后触发」。"""
chain = "→".join(f"「{n}」" for n in names)
return (f"这样设置会循环触发:{chain}(每个箭头表示前一个工作流的正式运行成功结束后触发后一个),"
"运行会一直接力下去。请去掉其中一环")
# --------------------------------------------------------------------------
# 给人看的说法
# --------------------------------------------------------------------------
def describe(trigger: dict[str, Any], subject_name: str | None, *, app_name: str | None = None) -> str:
"""一个事件触发任务的说法(列表、详情页头、预览,同 cron 任务的 description_text):
「上传表格「门店销售」出了新的一期时触发(只认新增的一期;去抖 60 秒)」。对象不在了写保存时记的名字。"""
event = trigger.get("event")
name = f"「{subject_name}」" if subject_name else ""
if event == "table.version":
head = f"上传表格{name}出了新的一期时触发"
elif event == "workflow.finished":
head = f"工作流{name}的正式运行成功结束时触发"
elif event == "source.changed":
head = f"数据源{name}出现要处理的变化时触发"
else:
return "由事件触发(触发条件无法识别)"
notes: list[str] = []
if trigger.get("period") == "new":
notes.append(PERIOD_RULE_LABEL["new"])
if trigger.get("app_id"):
notes.append(f"只认应用「{app_name}」发起的" if app_name else "只认指定的应用发起的")
if trigger.get("origin_kind"):
notes.append(f"只认来源为「{ORIGIN_LABEL.get(trigger['origin_kind'], trigger['origin_kind'])}」的")
if event == "workflow.finished":
# 缺省不认的两种成功结束,照实写出来
kinds = [("途经驳回", bool(trigger.get("include_rejected")))]
if trigger.get("origin_kind") != BACKFILL_ORIGIN:
kinds.append(("应用补算", bool(trigger.get("include_backfill"))))
excluded = "、".join(k for k, on in kinds if not on)
included = "、".join(k for k, on in kinds if on)
notes += [f"{included}的也算"] if included else []
notes += [f"{excluded}的不算"] if excluded else []
debounce = debounce_of(trigger)
from app.scheduler.service import duration_text
notes.append(f"去抖 {duration_text(debounce)}" if debounce else "不去抖")
return f"{head}({';'.join(notes)})"
def event_text(event: str, subject_name: str, payload: dict[str, Any] | None) -> str:
"""一次事件的说法(触发历史、运行来源、通知):「上传表格「门店销售」出了新的一期(2026-09)」。不写出口字段的值、变化的
内容(可能带着数,通知会推进群里)。"""
payload = payload or {}
name = f"「{subject_name}」" if subject_name else ""
if event == "table.version":
period = payload.get("period") or ""
what = "替换了已有的一期" if payload.get("replaced") else "出了新的一期"
return f"上传表格{name}{what}{f'({period})' if period else ''}"
if event == "workflow.finished":
run = str(payload.get("run_id") or "")
rejected = "(途经驳回)" if payload.get("rejected") else ""
return f"工作流{name}的正式运行成功结束{f'(#{run[:6]})' if run else ''}{rejected}"
if event == "source.changed":
n = (payload.get("counts") or {}).get("new")
return f"数据源{name}出现要处理的变化{f'(新记 {n} 条)' if isinstance(n, int) and n else ''}"
return "数据事件"
# --------------------------------------------------------------------------
# 输入模板里的 {{ event.* }}
# --------------------------------------------------------------------------
#: 每种事件能用的变量:(变量名, 说明, 示例)。outputs.<字段>、counts.<项> 之下的名字是开放的,见 allowed_path
VARIABLES: dict[str, tuple[tuple[str, str, str], ...]] = {
"table.version": (
("table", "上传表格的名字", "门店销售"),
("version", "启用的版本编号", "3f2a9c…"),
("period", "这一期(整月写 YYYY-MM,整周写 YYYY-Www,单日写 YYYY-MM-DD)", "2026-09"),
("period_start", "这一期的第一天(YYYY-MM-DD)", "2026-09-01"),
("period_end", "这一期的最后一天(YYYY-MM-DD)", "2026-09-30"),
("activated_at", "启用的时间(带时区偏移的 ISO 时间)", "2026-10-08T09:30:00+08:00"),
),
"workflow.finished": (
("workflow", "上游工作流的名字", "门店日报"),
("workflow_id", "上游工作流的编号", "wf…"),
("run_id", "上游那次运行的编号", "run…"),
("finished_at", "上游运行结束的时间(带时区偏移的 ISO 时间)", "2026-10-08T09:30:00+08:00"),
("rejected", "上游这次运行有没有途经驳回(true / false;缺省不认途经驳回的,勾了「也认」才会是 true)", "false"),
("outputs", "上游运行的出口字段(写 event.outputs.<字段名>)", "{…}"),
),
"source.changed": (
("source", "数据源的名字", "门店库"),
("source_id", "数据源的编号", "ds…"),
("counts", "变化的条数(写 event.counts.new 这次新记的要处理的条数,"
"event.counts.action / warning / info 各级还要人看的条数)", "{…}"),
),
}
#: 开放的那一级:event.outputs.<字段>、event.counts.<项>
_OPEN = {"workflow.finished": "outputs", "source.changed": "counts"}
_COUNT_KEYS = ("new", "action", "warning", "info", "pending")
def names(event: str) -> tuple[str, ...]:
"""这种事件能写在模板里的变量(带 event. 前缀),给报错列出来。"""
out = [f"event.{key}" for key, _, _ in VARIABLES.get(event, ())]
if event == "workflow.finished":
out = [n if n != "event.outputs" else "event.outputs.<字段名>" for n in out]
if event == "source.changed":
out = [n for n in out if n != "event.counts"] + [f"event.counts.{k}" for k in _COUNT_KEYS]
return tuple(out)
def allowed_path(path: str, event: str) -> bool:
"""event.* 的路径合不合这种事件:固定的几个名字,加上 outputs.<字段>(可以再往下取)、counts.<项>。"""
if not path.startswith("event."):
return False
rest = path[len("event."):]
keys = {key for key, _, _ in VARIABLES.get(event, ())}
head, _, tail = rest.partition(".")
if head not in keys:
return False
if head == _OPEN.get(event):
if event == "source.changed":
return not tail or tail in _COUNT_KEYS
return bool(tail.strip())
return not tail
def _zoned(text: Any, tz: str) -> str:
"""存的 UTC ISO 时间换成任务时区、只到秒的 ISO(同 schedule.fire_time 的写法);读不出来的照原样。"""
if not isinstance(text, str) or not text:
return ""
try:
moment = datetime.fromisoformat(text)
except ValueError:
return text
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
from app.scheduler import cron
try:
return moment.astimezone(cron.zone(tz)).isoformat(timespec="seconds")
except ValueError:
return moment.isoformat(timespec="seconds")
def values(event: str, payload: dict[str, Any] | None, tz: str, *, outputs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""模板里 event 下面的值(render_inputs 的 ctx["event"])。时间按任务时区写;workflow.finished 的出口字段由派发时读到的
那次运行的成果交进来。"""
payload = payload or {}
if event == "table.version":
return {"table": payload.get("table") or "", "version": payload.get("version") or "",
"period": payload.get("period") or "", "period_start": payload.get("period_start") or "",
"period_end": payload.get("period_end") or "", "activated_at": _zoned(payload.get("activated_at"), tz)}
if event == "workflow.finished":
return {"workflow": payload.get("workflow") or "", "workflow_id": payload.get("workflow_id") or "",
"run_id": payload.get("run_id") or "", "finished_at": _zoned(payload.get("finished_at"), tz),
"rejected": "true" if payload.get("rejected") else "false", "outputs": dict(outputs or {})}
if event == "source.changed":
counts = payload.get("counts") if isinstance(payload.get("counts"), dict) else {}
return {"source": payload.get("source") or "", "source_id": payload.get("source_id") or "",
"counts": {k: int(counts.get(k) or 0) for k in _COUNT_KEYS}}
return {}
def variables(event: str) -> list[dict[str, str]]:
"""变量清单接口里 event.* 的那几项:[{name, label, example, note}]。"""
out = []
for key, label, example in VARIABLES.get(event, ()):
name = "event.outputs.<字段名>" if (event, key) == ("workflow.finished", "outputs") else f"event.{key}"
if (event, key) == ("source.changed", "counts"):
name = "event.counts.new"
out.append({"name": name, "label": label, "example": example, "note": ""})
return out
# --------------------------------------------------------------------------
# 期次的写法
# --------------------------------------------------------------------------
def period_label(start: str | None, end: str | None) -> str:
"""这一期写成一个词:整月 2026-09、整季 2026-Q3、整年 2026、ISO 整周 2026-W40、单日 2026-09-30;别的写「起~止」。"""
if not start:
return ""
if not end or end == start:
return start
try:
d0, d1 = date.fromisoformat(start), date.fromisoformat(end)
except ValueError:
return f"{start}~{end}"
last_of_month = (d1 + timedelta(days=1)).day == 1
if d0.day == 1 and last_of_month:
if (d0.year, d0.month) == (d1.year, d1.month):
return f"{d0.year:04d}-{d0.month:02d}"
if d0.year == d1.year and d0.month == 1 and d1.month == 12:
return f"{d0.year:04d}"
if d0.year == d1.year and d0.month in (1, 4, 7, 10) and d1.month == d0.month + 2:
return f"{d0.year:04d}-Q{(d0.month - 1) // 3 + 1}"
if d0.weekday() == 0 and d1 - d0 == timedelta(days=6):
year, week, _ = d0.isocalendar()
return f"{year}-W{week:02d}"
return f"{start}~{end}"
# --------------------------------------------------------------------------
# 示例事件(预览、立即运行)
# --------------------------------------------------------------------------
@dataclass
class Sample:
"""按对象现在的情况凑出的一次事件:real 为假时是示例值(对象还没发生过这种事件),立即运行不拿它发起用到 event.* 的运行。"""
event: str
subject_id: str
subject_name: str
payload: dict[str, Any]
outputs: dict[str, Any] = field(default_factory=dict)
real: bool = True
async def sample(session: Any, trigger: dict[str, Any]) -> Sample:
"""预览、立即运行用的事件:上传表格取当前版本里最近的一期,上游工作流取最近一次成功的正式运行,数据源取现在的变化计数。
对象不在、还没有过这种事件时给示例值(real=False)。"""
from app.db.models import DataSource, Run, SourceSnapshot, TableImport, Workflow
event, subject = trigger.get("event") or "", subject_of(trigger)
if event in ("table.version", "source.changed"):
source = await session.get(DataSource, subject) if subject else None
name = source.name if source is not None else ""
if event == "source.changed":
if source is None:
return Sample(event, subject, name, {"source": name or EXAMPLE_NOTE, "source_id": subject,
"counts": {"new": 2, "action": 2}}, real=False)
from app.data import source_changes
got = await source_changes.counts(session, subject)
by = got.get("by_severity") or {}
return Sample(event, subject, name, {"source": name, "source_id": subject, "counts": {
"new": int(by.get("action") or 0), "action": int(by.get("action") or 0),
"warning": int(by.get("warning") or 0), "info": int(by.get("info") or 0),
"pending": int(got.get("pending") or 0)}})
snap = await session.get(SourceSnapshot, source.current_snapshot_id) \
if source is not None and source.current_snapshot_id else None
imports = list((await session.execute(select(TableImport).where(TableImport.id.in_(
[str(x) for x in snap.imports or []])))).scalars()) if snap is not None else []
latest = max(imports, key=lambda i: (i.period_end or "", i.seq or 0), default=None)
if snap is None or latest is None:
return Sample(event, subject, name, {"table": name or EXAMPLE_NOTE, "version": "", "period": "2026-09",
"period_start": "2026-09-01", "period_end": "2026-09-30",
"activated_at": ""}, real=False)
return Sample(event, subject, name, table_payload(
name, snapshot_id=snap.id, period_start=latest.period_start, period_end=latest.period_end,
activated_at=snap.activated_at or snap.created_at, replaced=False, seq=latest.seq))
workflow = await session.get(Workflow, subject) if subject else None
name = workflow.name if workflow is not None else ""
query = select(Run).where(Run.workflow_id == subject, Run.run_class == "formal", Run.status == "succeeded")
if trigger.get("app_id"):
query = query.where(Run.app_id == trigger["app_id"])
if trigger.get("origin_kind"):
query = query.where(Run.origin_kind == trigger["origin_kind"])
candidates = list((await session.execute(query.order_by(Run.finished_at.desc()).limit(SAMPLE_SCAN)))
.scalars()) if subject else []
# 和真派发同一套条件(matches):缺省不认的途经驳回、应用补算跳过,取合条件的最近一次
for run in candidates:
payload = run_payload(run, await _rejected_via_of(session, run.id))
if matches(trigger, event, subject, payload):
return Sample(event, subject, name, payload, outputs=outputs_of(run.output))
return Sample(event, subject, name, {"workflow": name or EXAMPLE_NOTE, "workflow_id": subject, "run_id": "",
"finished_at": "", "rejected": False}, real=False)
#: 示例事件往回找几次成功的正式运行(找合条件的最近一次)
SAMPLE_SCAN = 20
async def _rejected_via_of(session: Any, run_id: str) -> list[Any] | None:
"""这次运行途经的驳回:run.finished 事件里的 rejected_via(W1-b 的驳回出口写的),没有为 None。"""
from app.db.models import RunEvent
data = (await session.execute(select(RunEvent.data).where(RunEvent.run_id == run_id, RunEvent.type == "run.finished")
.order_by(RunEvent.seq.desc()).limit(1))).scalar()
via = data.get("rejected_via") if isinstance(data, dict) else None
return list(via) if isinstance(via, list) and via else None
def outputs_of(output: Any) -> dict[str, Any]:
"""给模板的出口字段({{ event.outputs.* }}):「_」开头的是系统写的(出具档位之类),不给。真派发、预览、立即运行共用。"""
return {k: v for k, v in output.items() if not str(k).startswith("_")} if isinstance(output, dict) else {}
def table_payload(name: str, *, snapshot_id: str, period_start: str | None, period_end: str | None,
activated_at: datetime | None, replaced: bool, seq: int | None) -> dict[str, Any]:
"""table.version 的事件内容(发生处和示例共用一份写法)。"""
return {"table": name, "version": snapshot_id, "period": period_label(period_start, period_end),
"period_start": period_start or "", "period_end": period_end or "",
"activated_at": _iso(activated_at), "replaced": bool(replaced), "seq": seq}
def run_payload(run: Any, rejected_via: Any = None) -> dict[str, Any]:
"""workflow.finished 的事件内容:不含出口字段(派发时按 run_id 读成果)。rejected_via 是途经了哪几个关卡的驳回(W1-b 的
驳回出口;runner 收尾时交进来),没途经驳回为 None。"""
via = (list(rejected_via) if isinstance(rejected_via, (list, tuple)) else [rejected_via]) if rejected_via else None
return {"workflow": run.workflow_name or "", "workflow_id": run.workflow_id or "", "run_id": run.id,
"finished_at": _iso(run.finished_at), "app_id": run.app_id, "origin_kind": run.origin_kind,
"version": run.version, "rejected": bool(via), "rejected_via": via}
def window_key(event: str, payload: dict[str, Any] | None) -> str:
"""去抖时按什么分窗口:上传表格按期次分(9 月、10 月连着启用,各有各的触发,不把 9 月合掉),别的不分。"""
if event != "table.version":
return ""
payload = payload or {}
return str(payload.get("period") or f"{payload.get('period_start') or ''}~{payload.get('period_end') or ''}")
def _iso(moment: datetime | None) -> str:
if moment is None:
return ""
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(timezone.utc).isoformat()
def uses_event(template: dict[str, str] | None) -> bool:
"""输入模板里有没有用到 {{ event.* }}(立即运行拿示例值时据此决定能不能发起)。"""
from app.scheduler.service import _TEMPLATE_RE
for text in (template or {}).values():
for match in _TEMPLATE_RE.finditer(text if isinstance(text, str) else ""):
if match.group(1).split("|")[0].strip().startswith("event."):
return True
return False
+14 -1
View File
@@ -119,6 +119,8 @@ class Notice:
#: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步
end_reason: str | None = None
next_fire: bool = False
#: 事件触发的那一次(fire_kind=event):事件的说法(scheduler/triggers.event_text),不含出口字段的值、变化的内容
trigger_text: str | None = None
compare: dict[str, Any] | None = None
compare_url: str | None = None
fmt: str | None = None
@@ -195,6 +197,9 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
# 立即运行不写「计划触发」:群里的人会以为报告按时出了
if notice.fire_kind == "manual":
lines.append(("手动触发", when))
elif notice.fire_kind == "event":
# 事件触发没有「计划」:写事件和合成这次触发的时刻(去抖结束的那一刻)
lines.append(("事件触发", f"{notice.trigger_text},{when}" if notice.trigger_text else when))
elif notice.fire_kind == "misfire":
lines.append(("计划触发", f"{when},错过后补发"))
else:
@@ -549,10 +554,18 @@ async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run
error_node=_failed_node(run) if run is not None and (run.status == "failed" or run.end_reason == "rejected")
else None,
end_reason=run.end_reason if run is not None else None,
next_fire=bool(schedule.enabled and schedule.next_run_at is not None),
# 事件触发的任务没有下一次「到点」,启用着就是下一次事件来了照常发起
next_fire=bool(schedule.enabled and (schedule.next_run_at is not None or schedule.trigger_kind == "event")),
trigger_text=_trigger_text(fire),
)
def _trigger_text(fire: ScheduleFire) -> str | None:
event = fire.inputs.get("event") if isinstance(fire.inputs, dict) else None
text = event.get("text") if isinstance(event, dict) else None
return text if isinstance(text, str) and text else None
def _failed_node(run: Run) -> str | None:
"""失败节点在图上的名字(没有名字的给节点 id)。节点名是搭图时起的,不带查询到的数据。"""
if not run.error_node_id:
+494
View File
@@ -0,0 +1,494 @@
"""数据事件触发(docs/design/orchestration-round/00-总方案.md §4;scheduler/triggers.py、scheduler/outbox.py)。
- 三种事件在发生处各记一条待派发事件,调度的一拍取走、派给合条件的事件触发任务、发起一次正式运行:
上传表格按期累积的新一期启用(data/table_versions)、正式运行成功结束(engine/runner 收尾)、探查 / 剖析新记了要处理的变化
(data/source_changes.notify_after_job,一个任务一条);探索运行、失败的运行不算;
- 输入模板里的 {{ event.* }} 按事件渲染(上游的出口字段、这一期、变化的条数);触发记录 kind=event、inputs 带事件的说明;
运行来源 origin_kind=schedule,origin.trigger 带事件种类、对象、事件 id、触发深度;
- 去抖:同一个任务 debounce_s 内的几次事件合成一次,内容取最新的那一条,fire_at 是窗口结束的时刻;窗口外的另起一次;
- 上一次还没结束照 overlap=skip 记 skipped_overlap;触发链超过 5 层不发起、照实记;派发得太晚(超出补发宽限)记 skipped_misfire;
- 任务停用、改了触发条件时等着的不再派发;调度关着(没有这一拍)时事件留在表里;
- 存量的 cron 任务一字不变(老测试照过,见 test_scheduler_loop.py 等)。
工作流、表格、数据源、字段都是虚构的。
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, select
from app.db.base import SessionLocal, utcnow
from app.db.models import DataSource, Run, Schedule, ScheduleFire, Workflow, WorkflowVersion
from app.db.models_sources import SourceChange
from app.db.models_triggers import TriggerDelivery, TriggerEvent
from app.engine.launch import launch_run
from app.engine.runner import run_manager
from app.scheduler import outbox, service, triggers
UTC = timezone.utc
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(TriggerDelivery))
await session.execute(delete(TriggerEvent))
await session.execute(delete(Schedule))
await session.commit()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(fields: list[dict], value: str) -> dict:
return {"nodes": [node("start", "input", fields=fields),
node("out", "output", fields=[{"name": "r", "value": value}, {"name": "rows", "value": "12"}])],
"edges": [{"source": "start", "target": "out"}]}
async def workflow(name: str, *, fields: list[dict] | None = None, value: str = "完成") -> str:
fields = fields if fields is not None else []
async with SessionLocal() as session:
wf = Workflow(name=f"{name}-{uuid.uuid4().hex[:6]}", graph=graph(fields, value), status="published",
published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph(fields, value)))
await session.commit()
return wf.id
async def event_schedule(workflow_id: str, trigger: dict, *, template: dict | None = None, enabled: bool = True,
grace: int = 3600, name: str = "下游汇总") -> str:
async with SessionLocal() as session:
row = Schedule(name=name, workflow_id=workflow_id, target_kind="workflow", target_id=workflow_id, cron="",
timezone="Asia/Shanghai", run_class="formal", input_template=template or {}, enabled=enabled,
misfire_grace_s=grace, next_run_at=None, trigger_kind="event",
trigger=triggers.normalize(trigger))
session.add(row)
await session.commit()
return row.id
async def formal_run(workflow_id: str, inputs: dict | None = None, **origin) -> Run:
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=workflow_id, run_class="formal", input_payload=inputs or {},
actor="检查员", origin_kind=origin.pop("origin_kind", "launcher"), **origin)
return await settle(run.id)
async def settle(run_id: str) -> Run:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
await run_manager.wait_idle(run_id)
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
async def fires(schedule_id: str) -> list[ScheduleFire]:
async with SessionLocal() as session:
return list((await session.execute(select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id)
.order_by(ScheduleFire.created_at))).scalars())
async def events(kind: str | None = None, subject: str | None = None) -> list[TriggerEvent]:
async with SessionLocal() as session:
q = select(TriggerEvent).order_by(TriggerEvent.created_at)
if kind:
q = q.where(TriggerEvent.event == kind)
if subject:
q = q.where(TriggerEvent.subject_id == subject)
return list((await session.execute(q)).scalars())
async def deliveries(schedule_id: str) -> list[TriggerDelivery]:
async with SessionLocal() as session:
return list((await session.execute(select(TriggerDelivery).where(TriggerDelivery.schedule_id == schedule_id)
.order_by(TriggerDelivery.occurred_at))).scalars())
def later(seconds: float = 1) -> datetime:
return utcnow() + timedelta(seconds=seconds)
async def put_event(kind: str, subject: str, payload: dict, at: datetime, *, depth: int = 0,
name: str = "门店日报") -> str:
async with SessionLocal() as session:
ev = TriggerEvent(event=kind, subject_id=subject, subject_name=name, payload=payload, occurred_at=at,
depth=depth, created_at=at)
session.add(ev)
await session.commit()
return ev.id
# --------------------------------------------------------------------------
# 上游工作流运行成功结束
# --------------------------------------------------------------------------
async def test_workflow_finished_triggers_downstream_with_outputs():
upstream = await workflow("门店日报", value="日报完成")
downstream = await workflow("区域汇总", fields=[{"name": "basis", "required": True}, {"name": "run"}])
sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0},
template={"basis": "{{ event.outputs.r }}({{ event.outputs.rows }} 行)",
"run": "{{ event.run_id }}"})
a = await formal_run(upstream)
[ev] = await events("workflow.finished", upstream)
assert ev.status == "pending" and ev.payload["run_id"] == a.id and ev.depth == 0 and ev.chain == [a.id]
# 发生处只记事件、不发起:调度的一拍之前下游什么都没有
assert await fires(sid) == []
[fire] = await outbox.dispatch_due(later())
assert (fire.kind, fire.status) == ("event", "started")
assert fire.fire_at == ev.occurred_at # debounce 0:窗口结束就是事件发生的那一刻
assert fire.inputs["inputs"] == {"basis": "日报完成(12 行)", "run": a.id}
brief = fire.inputs["event"]
assert (brief["event"], brief["subject_id"], brief["merged"], brief["depth"]) == (
"workflow.finished", upstream, 1, 1)
assert brief["text"].startswith("工作流「门店日报-") and f"#{a.id[:6]}" in brief["text"]
b = await settle(fire.run_id)
assert b.status == "succeeded" and b.schedule_id == sid and b.input == {"basis": "日报完成(12 行)", "run": a.id}
assert b.origin_kind == "schedule"
trigger = b.origin["trigger"]
assert (trigger["kind"], trigger["event"], trigger["subject_id"], trigger["event_id"], trigger["depth"]) == (
"event", "workflow.finished", upstream, ev.id, 1)
assert trigger["chain"] == [a.id] and trigger["text"] == brief["text"]
[delivery] = await deliveries(sid)
assert (delivery.status, delivery.fire_id) == ("fired", fire.id)
# 再来一拍:已经派完,不再发起
assert await outbox.dispatch_due(later(2)) == [] and len(await fires(sid)) == 1
# 应用页的印章「来源」照实写由什么事件触发
from app.apps import resolve
async with SessionLocal() as session:
said = await resolve._from_text(session, resolve.Source(id="v", kind="latest_sealed", state="ok", run=b))
assert said == f"由事件触发:{brief['text']},定时任务「下游汇总」第 1 次"
async def test_webhook_says_the_event(monkeypatch):
"""事件触发的那一次推的通知:写「事件触发」和事件的说法,不写「计划触发」"""
import json
import httpx
from app.core.crypto import encrypt
from app.scheduler import webhook
sent: list[dict] = []
monkeypatch.setattr(webhook, "_transport", httpx.MockTransport(
lambda request: sent.append(json.loads(request.content)) or httpx.Response(200, json={})))
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总"),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0})
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == sid).values(
webhook_format="generic", webhook_url=encrypt("https://hooks.example.com/agentlab/inbox-9a1b"),
webhook_events=["finished"]))
await session.commit()
a = await formal_run(upstream)
[fire] = await outbox.dispatch_due(later())
await settle(fire.run_id)
for _ in range(3):
await webhook.notify_due(later(120))
[body] = sent
assert body["fire_kind"] == "event" and body["event"] == "finished"
assert f"事件触发:工作流「门店日报-" in body["text"] and f"(#{a.id[:6]})" in body["text"]
assert "计划触发" not in body["text"]
async def test_only_successful_formal_runs_are_events():
wf = await workflow("门店日报")
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=wf, run_class="exploratory", input_payload={}, actor="检查员",
origin_kind="canvas")
await settle(run.id)
failing = await workflow("坏掉的日报", fields=[{"name": "d", "required": True}])
async with SessionLocal() as session:
bad = await launch_run(session, workflow_id=failing, run_class="formal", input_payload={}, actor="检查员",
origin_kind="launcher")
assert (await settle(bad.id)).status == "failed"
assert await events("workflow.finished", wf) == [] and await events("workflow.finished", failing) == []
async def test_workflow_filters_by_app_and_origin():
upstream = await workflow("门店日报")
downstream = await workflow("区域汇总")
only_hook = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream,
"origin_kind": "hook", "debounce_s": 0})
await formal_run(upstream) # 手动正式运行:不是钩子来的
assert await outbox.dispatch_due(later()) == [] and await fires(only_hook) == []
[ev] = await events("workflow.finished", upstream)
assert (ev.status, ev.matched) == ("routed", 0)
async def test_chain_deeper_than_limit_is_refused():
"""防环的兜底:上游自己已经是事件触发链上的第 5 层,下游会是第 6 层,不发起、照实记"""
upstream = await workflow("门店日报")
downstream = await workflow("区域汇总")
sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0})
await put_event("workflow.finished", upstream, {"workflow": "门店日报", "workflow_id": upstream, "run_id": "r5"},
utcnow(), depth=triggers.MAX_DEPTH)
[fire] = await outbox.dispatch_due(later())
assert (fire.status, fire.run_id) == ("failed_to_start", None)
assert "第 6 层(上限 5 层)" in fire.reason and "循环触发" in fire.reason
assert fire.inputs["event"]["depth"] == 6
async def test_chain_depth_follows_the_runs():
"""A 跑完触发 B:B 的运行来源记第 1 层;B 跑完又是一个事件,深度跟着走、经过的运行接上"""
a = await workflow("门店日报")
b = await workflow("区域汇总")
c = await workflow("全国汇总")
await event_schedule(b, {"event": "workflow.finished", "workflow_id": a, "debounce_s": 0}, name="B")
sc = await event_schedule(c, {"event": "workflow.finished", "workflow_id": b, "debounce_s": 0}, name="C")
first = await formal_run(a)
[fb] = await outbox.dispatch_due(later())
run_b = await settle(fb.run_id)
[ev_b] = await events("workflow.finished", b)
assert ev_b.depth == 1 and ev_b.chain == [first.id, run_b.id]
[fc] = [f for f in await outbox.dispatch_due(later(2)) if f.schedule_id == sc]
run_c = await settle(fc.run_id)
assert run_c.origin["trigger"]["depth"] == 2 and run_c.origin["trigger"]["chain"] == [first.id, run_b.id]
# --------------------------------------------------------------------------
# 去抖、重叠、停用、太晚
# --------------------------------------------------------------------------
async def test_debounce_merges_events_in_the_window():
upstream = await workflow("门店日报")
downstream = await workflow("区域汇总", fields=[{"name": "run"}])
sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60},
template={"run": "{{ event.run_id }}"})
t0 = utcnow().replace(microsecond=0)
payload = lambda rid: {"workflow": "门店日报", "workflow_id": upstream, "run_id": rid} # noqa: E731
e1 = await put_event("workflow.finished", upstream, payload("run-1"), t0)
e2 = await put_event("workflow.finished", upstream, payload("run-2"), t0 + timedelta(seconds=30))
# 窗口还没过:只派、不发
assert await outbox.dispatch_due(t0 + timedelta(seconds=40)) == []
assert [d.status for d in await deliveries(sid)] == ["pending", "pending"]
async with SessionLocal() as session:
state = await outbox.pending_for(session, await session.get(Schedule, sid))
assert state == {"events": 0, "waiting": 2, "due_at": (t0 + timedelta(seconds=60)).isoformat()}
e3 = await put_event("workflow.finished", upstream, payload("run-3"), t0 + timedelta(seconds=70))
[fire] = await outbox.dispatch_due(t0 + timedelta(seconds=61))
assert fire.fire_at == t0 + timedelta(seconds=60) and fire.kind == "event"
assert fire.inputs["event"]["merged"] == 2 and fire.inputs["event"]["event_ids"] == [e1, e2]
# 内容取窗口里最新的那一条
assert fire.inputs["inputs"] == {"run": "run-2"}
await settle(fire.run_id)
states = {d.event_id: (d.status, d.fire_id) for d in await deliveries(sid)}
assert states[e1] == states[e2] == ("fired", fire.id) and states[e3] == ("pending", None)
# 窗口外的那一条另起一次:从它发生起再等 60 秒
assert await outbox.dispatch_due(t0 + timedelta(seconds=100)) == []
[second] = await outbox.dispatch_due(t0 + timedelta(seconds=131))
assert second.fire_at == t0 + timedelta(seconds=130) and second.inputs["event"]["event_ids"] == [e3]
await settle(second.run_id)
async def test_previous_run_still_going_skips_and_consumes_the_events():
upstream = await workflow("门店日报")
downstream = await workflow("区域汇总")
sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0})
async with SessionLocal() as session:
busy = Run(workflow_id=downstream, workflow_name="区域汇总", status="running", schedule_id=sid,
run_class="formal")
session.add(busy)
await session.commit()
await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1"}, utcnow())
[fire] = await outbox.dispatch_due(later())
assert (fire.status, fire.run_id, fire.kind) == ("skipped_overlap", None, "event")
assert f"上一次运行 #{busy.id[:6]} 仍在进行中" in fire.reason
assert [d.status for d in await deliveries(sid)] == ["fired"]
assert await outbox.dispatch_due(later(2)) == []
async def test_disabled_or_changed_schedule_drops_waiting_events():
upstream = await workflow("门店日报")
other = await workflow("别的日报")
downstream = await workflow("区域汇总")
off = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60},
name="停用的")
moved = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60},
name="改了对象的")
t0 = utcnow()
await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1"}, t0)
await outbox.route(t0 + timedelta(seconds=1))
async with SessionLocal() as session:
await session.execute(Schedule.__table__.update().where(Schedule.id == off).values(enabled=False))
await session.execute(Schedule.__table__.update().where(Schedule.id == moved).values(
trigger={"event": "workflow.finished", "workflow_id": other, "debounce_s": 60}))
await session.commit()
assert await outbox.dispatch_due(t0 + timedelta(seconds=61)) == []
assert [d.status for d in await deliveries(off)] == ["dropped"]
assert [d.status for d in await deliveries(moved)] == ["dropped"]
assert await fires(off) == [] and await fires(moved) == []
async def test_disabled_schedule_does_not_receive_events():
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总"),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0}, enabled=False)
await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1"}, utcnow())
assert await outbox.dispatch_due(later()) == [] and await deliveries(sid) == []
async def test_events_left_while_scheduler_was_off_are_skipped_past_grace():
"""调度关着的那几天没人派:事件留在表里;开起来以后超出补发宽限的照实记一次错过、不补发"""
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总"),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}, grace=600)
old = utcnow() - timedelta(days=2)
await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r-old"}, old)
[ev] = await events("workflow.finished", upstream)
assert ev.status == "pending" # 没有调度的一拍:一直等着
async with SessionLocal() as session:
state = await outbox.pending_for(session, await session.get(Schedule, sid))
assert state["events"] == 1 and state["waiting"] == 0
[fire] = await outbox.dispatch_due(later())
assert (fire.status, fire.kind, fire.run_id) == ("skipped_misfire", "event", None)
assert "服务当时没有派发" in fire.reason and "超出补发宽限(10 分钟)" in fire.reason
# --------------------------------------------------------------------------
# 数据源有要处理的变化
# --------------------------------------------------------------------------
async def test_source_changed_once_per_job_with_counts():
async with SessionLocal() as session:
source = DataSource(name=f"门店库-{uuid.uuid4().hex[:6]}", kind="sqlite", database=":memory:")
session.add(source)
await session.flush()
for i, severity in enumerate(("action", "action", "warning")):
session.add(SourceChange(source_id=source.id, source_name=source.name, table_name="orders",
column_name=f"c{i}", kind="column_removed", severity=severity,
detected_by={"job_kind": "introspect", "job_id": "job-7"},
fingerprint=uuid.uuid4().hex, status="open"))
await session.commit()
source_id, source_name = source.id, source.name
downstream = await workflow("变化处理", fields=[{"name": "n"}, {"name": "who"}])
sid = await event_schedule(downstream, {"event": "source.changed", "source_id": source_id, "debounce_s": 0},
template={"n": "{{ event.counts.new }}", "who": "{{ event.source }}"})
from app.data import source_changes
assert await source_changes.notify_after_job(source_id, "job-7") == 2
# 同一个任务没有新记要处理的变化(别的任务号):不记事件
assert await source_changes.notify_after_job(source_id, "job-8") == 0
[ev] = await events("source.changed", source_id)
assert ev.payload["counts"]["new"] == 2 and ev.payload["counts"]["warning"] == 1 and ev.payload["job_id"] == "job-7"
[fire] = await outbox.dispatch_due(later())
assert fire.status == "started" and fire.inputs["inputs"] == {"n": "2", "who": source_name}
assert fire.inputs["event"]["text"] == f"数据源「{source_name}」出现要处理的变化(新记 2 条)"
await settle(fire.run_id)
# --------------------------------------------------------------------------
# 上传表格出了新的一期
# --------------------------------------------------------------------------
from tests.test_table_versions_p3 import Chain, store # noqa: E402,F401 (store:每个测试一个独立的数据目录)
async def test_table_version_on_each_new_period():
chain = Chain()
await chain.first()
[aug] = await events("table.version", chain.sid)
assert aug.payload["period"] == "2026-08" and aug.payload["replaced"] is False
assert aug.payload["version"] == chain.s1 and aug.payload["table"] == chain.name
downstream = await workflow("月报", fields=[{"name": "month", "required": True}, {"name": "table"}])
sid = await event_schedule(downstream, {"event": "table.version", "source_id": chain.sid, "debounce_s": 0},
template={"month": "{{ event.period }}", "table": "{{ event.table }}"})
async with SessionLocal() as session:
await session.execute(TriggerEvent.__table__.update().where(TriggerEvent.id == aug.id)
.values(status="routed")) # 8 月是建任务之前的事:不算
await session.commit()
await chain.second()
sep = [e for e in await events("table.version", chain.sid) if e.id != aug.id]
assert [e.payload["period"] for e in sep] == ["2026-09"] and sep[0].payload["version"] == chain.s2
[fire] = await outbox.dispatch_due(later())
assert fire.status == "started" and fire.inputs["inputs"] == {"month": "2026-09", "table": chain.name}
assert fire.inputs["event"]["text"] == f"上传表格「{chain.name}」出了新的一期(2026-09)"
run = await settle(fire.run_id)
assert run.origin["trigger"]["event"] == "table.version" and run.origin["trigger"]["subject_id"] == chain.sid
async def test_table_version_marks_a_replaced_period():
chain = Chain()
await chain.first()
await chain.second()
async with SessionLocal() as session:
await outbox.stage_table_version(session, source_id=chain.sid, source_name=chain.name, snapshot_id="s3",
previous_snapshot_id=chain.s2, period_start=chain.sep_start,
period_end=chain.sep_end, seq=3)
await session.commit()
replaced = [e for e in await events("table.version", chain.sid) if e.payload["version"] == "s3"]
assert replaced[0].payload["replaced"] is True
trigger = triggers.normalize({"event": "table.version", "source_id": chain.sid, "period": "new"})
assert not triggers.matches(trigger, "table.version", chain.sid, replaced[0].payload)
assert triggers.matches({**trigger, "period": "any"}, "table.version", chain.sid, replaced[0].payload)
assert triggers.event_text("table.version", "门店销售", replaced[0].payload) == "上传表格「门店销售」替换了已有的一期(2026-09)"
# --------------------------------------------------------------------------
# 写法与模板
# --------------------------------------------------------------------------
def test_period_labels():
cases = {("2026-09-01", "2026-09-30"): "2026-09", ("2026-07-01", "2026-09-30"): "2026-Q3",
("2026-01-01", "2026-12-31"): "2026", ("2026-09-28", "2026-10-04"): "2026-W40",
("2026-09-30", "2026-09-30"): "2026-09-30", ("2026-09-03", "2026-09-17"): "2026-09-03~2026-09-17",
(None, None): ""}
assert {k: triggers.period_label(*k) for k in cases} == cases
def test_event_templates_check_and_render():
tpl = {"month": "{{ event.period }}", "basis": "{{ event.outputs.r }}"}
with pytest.raises(service.TemplateError, match="「event.outputs.r」不是可用的变量"):
service.check_template(tpl, event="table.version")
assert service.check_template({"basis": "{{ event.outputs.r | trim }}"}, event="workflow.finished")
with pytest.raises(service.TemplateError, match="在事件触发的任务里不可用"):
service.check_template({"d": "{{ schedule.last_fire_time }}"}, event="source.changed")
with pytest.raises(service.TemplateError, match="event.counts.new"):
service.check_template({"n": "{{ event.counts.total }}"}, event="source.changed")
# 按 cron 触发的任务:event.* 不认,说法一字不变
with pytest.raises(service.TemplateError, match="「event.period」不是可用的变量,可用的变量:schedule.today"):
service.check_template({"m": "{{ event.period }}"})
fire_at = datetime(2026, 10, 8, 1, 30, tzinfo=UTC)
values = triggers.values("table.version", triggers.table_payload(
"门店销售", snapshot_id="snap-9", period_start="2026-09-01", period_end="2026-09-30",
activated_at=fire_at, replaced=False, seq=2), "Asia/Shanghai")
out = service.render_inputs({"m": "{{ event.period }}", "at": "{{ event.activated_at }}",
"d": "{{ schedule.yesterday }}"},
service.template_values(fire_at, "Asia/Shanghai", ""), event=values)
assert out == {"m": "2026-09", "at": "2026-10-08T09:30:00+08:00", "d": "2026-10-07"}
def test_trigger_shape_is_checked():
with pytest.raises(triggers.TriggerError, match="事件种类"):
triggers.normalize({"event": "caliber.published", "source_id": "x"})
with pytest.raises(triggers.TriggerError, match="请选择上传表格"):
triggers.normalize({"event": "table.version"})
with pytest.raises(triggers.TriggerError, match="不认识的项:perid"):
triggers.normalize({"event": "table.version", "source_id": "x", "perid": "new"})
with pytest.raises(triggers.TriggerError, match="按应用、来源过滤"):
triggers.normalize({"event": "table.version", "source_id": "x", "app_id": "a"})
with pytest.raises(triggers.TriggerError) as e:
triggers.normalize({"event": "source.changed", "source_id": "x", "debounce_s": -1})
assert e.value.field == "debounce_s"
assert triggers.normalize({"event": "workflow.finished", "workflow_id": " wf ", "origin_kind": "hook"}) == {
"event": "workflow.finished", "workflow_id": "wf", "origin_kind": "hook", "debounce_s": 60}
+389
View File
@@ -0,0 +1,389 @@
"""事件触发的定时任务接口(api/schedules.py,docs/design/orchestration-round/00-总方案.md §4)。
- 建任务收 trigger_kind(cron 缺省,字段和行为一字不变)/ trigger:事件触发时 cron 存空串、下一次触发为空,目标只能是工作流;
核对对象(表格在、是按期累积的上传表格;上游工作流在、已发布;数据源在;只认的应用在),输入模板按事件种类核对 {{ event.* }};
不对回 422 {detail(中文), field}
- 防环:workflow.finished 串成环(A→B→A、自己触发自己)拒收,说清是哪几个工作流;改触发条件、改目标同样查
- 返回带 trigger_kind、trigger、trigger_text(给人看的触发说明,也写进 description_text)、trigger_state(几条事件在等、
去抖到什么时候、调度开没开);触发历史里事件触发的那几次带 event(事件的说明)
- 预览按一个示例事件渲染(上游最近一次成功的正式运行的出口字段……);变量清单按事件种类给 {{ event.* }};
立即运行按示例事件发起,还没发生过这种事件、模板又用到 {{ event.* }} 的照实记未能发起
- 触发方式建好之后不能改;Webhook 照旧可配
工作流、表格、数据源、应用都是虚构的。
"""
from __future__ import annotations
import asyncio
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, Schedule, TableRecipe, Workflow, WorkflowVersion
from app.db.models_apps import App
from app.db.models_triggers import TriggerDelivery, TriggerEvent
from app.engine.launch import launch_run
from app.engine.runner import run_manager
from app.main import app
HOOK = "https://hooks.example.com/agentlab/inbox-7f2e"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
async with SessionLocal() as session:
await session.execute(delete(TriggerDelivery))
await session.execute(delete(TriggerEvent))
await session.execute(delete(Schedule))
await session.commit()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def graph(fields=(), value="完成") -> dict:
return {"nodes": [node("start", "input", fields=list(fields)),
node("out", "output", fields=[{"name": "r", "value": value}])],
"edges": [{"source": "start", "target": "out"}]}
async def workflow(name: str, *, published: bool = True, fields=(), value: str = "完成") -> tuple[str, str]:
name = f"{name}-{uuid.uuid4().hex[:6]}"
async with SessionLocal() as session:
wf = Workflow(name=name, graph=graph(fields, value), status="published" if published else "draft",
published_version=1 if published else None)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph(fields, value)))
await session.commit()
return wf.id, name
async def upload_table(name: str, *, mode: str = "accumulate") -> str:
async with SessionLocal() as session:
source = DataSource(name=f"{name}-{uuid.uuid4().hex[:6]}", kind="sqlite", origin="upload", readonly=True)
session.add(source)
await session.flush()
recipe = TableRecipe(source_id=source.id, seq=1, recipe={"mode": mode}, status="active")
session.add(recipe)
await session.flush()
source.current_recipe_id = recipe.id
await session.commit()
return source.id
async def manual_source(name: str = "门店库") -> str:
async with SessionLocal() as session:
source = DataSource(name=f"{name}-{uuid.uuid4().hex[:6]}", kind="sqlite", database=":memory:")
session.add(source)
await session.commit()
return source.id
def body(workflow_id: str, trigger: dict, **extra) -> dict:
return {"name": "下游汇总", "workflow_id": workflow_id, "trigger_kind": "event", "trigger": trigger,
"timezone": "Asia/Shanghai", **extra}
async def settle(run_id: str) -> Run:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
await run_manager.wait_idle(run_id)
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
# --------------------------------------------------------------------------
# 建、读
# --------------------------------------------------------------------------
async def test_create_table_version_schedule(client):
table = await upload_table("门店销售")
wf, _ = await workflow("月报", fields=[{"name": "month", "required": True}])
r = await client.post("/api/schedules", json=body(wf, {"event": "table.version", "source_id": table,
"period": "new"},
input_template={"month": "{{ event.period }}"},
webhook={"url": HOOK}))
assert r.status_code == 201, r.text
s = r.json()
assert (s["trigger_kind"], s["cron"], s["next_run_at"], s["target_kind"]) == ("event", "", None, "workflow")
assert {k: v for k, v in s["trigger"].items() if k != "subject_name"} == {
"event": "table.version", "source_id": table, "period": "new", "debounce_s": 60}
assert s["trigger"]["subject_name"].startswith("门店销售-") # 保存时记下的对象名
assert s["trigger_text"].startswith("上传表格「门店销售-") and "(只认新增的一期;去抖 1 分钟)" in s["trigger_text"]
assert s["description_text"] == s["trigger_text"]
assert s["trigger_state"] == {"events": 0, "waiting": 0, "due_at": None, "scheduler_on": False}
assert s["webhook"]["url_hint"].startswith("hooks.example.com")
async with SessionLocal() as session:
row = await session.get(Schedule, s["id"])
assert (row.trigger_kind, row.cron, row.next_run_at) == ("event", "", None)
# 列表里同一份
listed = {x["id"]: x for x in (await client.get("/api/schedules")).json()["items"]}
assert listed[s["id"]]["trigger_text"] == s["trigger_text"]
async def test_cron_schedules_are_unchanged_and_say_so(client):
wf, _ = await workflow("周报", fields=[{"name": "stat_date", "required": True}])
r = await client.post("/api/schedules", json={"name": "周一早报", "workflow_id": wf, "cron": "0 9 * * 1",
"timezone": "Asia/Shanghai",
"input_template": {"stat_date": "{{ schedule.yesterday }}"}})
assert r.status_code == 201, r.text
s = r.json()
assert (s["trigger_kind"], s["trigger"], s["trigger_text"], s["trigger_state"]) == ("cron", None, None, None)
assert s["description_text"] == "每周一 09:00(Asia/Shanghai)" and s["next_run_at"]
@pytest.mark.parametrize("make, trigger, says", [
("replace", {"event": "table.version"}, "不是按期累积的上传表格"),
("manual", {"event": "table.version"}, "不是上传表格"),
("gone", {"event": "table.version"}, "上传表格不存在"),
("draft", {"event": "workflow.finished"}, "还没有发布版本"),
("gone", {"event": "source.changed"}, "数据源不存在"),
])
async def test_trigger_objects_are_checked(client, make, trigger, says):
target, _ = await workflow("汇总")
if make == "replace":
subject = await upload_table("门店销售", mode="replace")
elif make == "manual":
subject = await manual_source()
elif make == "draft":
subject, _ = await workflow("草稿日报", published=False)
else:
subject = "nope"
key = "workflow_id" if trigger["event"] == "workflow.finished" else "source_id"
r = await client.post("/api/schedules", json=body(target, {**trigger, key: subject}))
assert r.status_code == 422 and r.json()["field"] == "trigger" and says in r.json()["detail"], r.text
async def test_event_schedule_rules(client):
src = await manual_source()
wf, _ = await workflow("汇总", fields=[{"name": "n"}])
trigger = {"event": "source.changed", "source_id": src}
cases = [
({"cron": "0 9 * * 1"}, "cron", "不写 cron"),
({"target_kind": "eval_suite", "target_id": "suite-1", "workflow_id": ""}, "target_kind", "只能指向工作流"),
({"input_template": {"n": "{{ event.period }}"}}, "input_template", "event.counts.new"),
({"trigger_kind": "hourly"}, "trigger_kind", "定时或事件触发"),
({"trigger": {**trigger, "debounce_s": 999999}}, "debounce_s", "去抖"),
]
for extra, field, says in cases:
r = await client.post("/api/schedules", json={**body(wf, trigger), **extra})
assert r.status_code == 422, (extra, r.text)
assert r.json()["field"] == field and says in r.json()["detail"], (extra, r.json())
async def test_unknown_app_filter_is_refused(client):
a, _ = await workflow("日报")
b, _ = await workflow("汇总")
r = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
"app_id": "nope"}))
assert r.status_code == 422 and "只认的应用不存在" in r.json()["detail"]
async with SessionLocal() as session:
shop = App(name="门店看板")
session.add(shop)
await session.commit()
app_id = shop.id
r = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
"app_id": app_id}))
assert r.status_code == 201 and "只认应用「门店看板」发起的" in r.json()["trigger_text"]
# --------------------------------------------------------------------------
# 防环
# --------------------------------------------------------------------------
async def test_cycles_are_refused_when_saving(client):
a, a_name = await workflow("日报")
b, b_name = await workflow("汇总")
c, c_name = await workflow("全国")
first = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a}))
assert first.status_code == 201
second = await client.post("/api/schedules", json=body(c, {"event": "workflow.finished", "workflow_id": b}))
assert second.status_code == 201
loop = await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": c}))
assert loop.status_code == 422 and loop.json()["field"] == "trigger"
assert f"「{c_name}」→「{a_name}」→「{b_name}」→「{c_name}」" in loop.json()["detail"], loop.json()
own = await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": a}))
assert own.status_code == 422 and f"「{a_name}」→「{a_name}」" in own.json()["detail"]
# 停用着的也算:启用不该绕过这道检查
s2 = second.json()
off = await client.patch(f"/api/schedules/{s2['id']}", json={"enabled": False, "version": s2["version"]})
assert off.status_code == 200 and off.json()["next_run_at"] is None
assert (await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": c}))
).status_code == 422
# 改目标、改触发条件造出环:同样拒
s1 = first.json()
moved = await client.patch(f"/api/schedules/{s1['id']}", json={
"trigger": {"event": "workflow.finished", "workflow_id": c}, "version": s1["version"]})
assert moved.status_code == 422 and "循环触发" in moved.json()["detail"]
retarget = await client.patch(f"/api/schedules/{s2['id']}", json={"workflow_id": a, "version": off.json()["version"]})
assert retarget.status_code == 422 and "循环触发" in retarget.json()["detail"]
# --------------------------------------------------------------------------
# 改
# --------------------------------------------------------------------------
async def test_patch_event_schedule(client):
src = await manual_source()
wf, _ = await workflow("汇总", fields=[{"name": "n"}])
s = (await client.post("/api/schedules", json=body(wf, {"event": "source.changed", "source_id": src}))).json()
r = await client.patch(f"/api/schedules/{s['id']}", json={"trigger_kind": "cron", "version": s["version"]})
assert r.status_code == 422 and r.json()["field"] == "trigger_kind"
r = await client.patch(f"/api/schedules/{s['id']}", json={"cron": "0 9 * * *", "version": s["version"]})
assert r.status_code == 422 and r.json()["field"] == "cron"
r = await client.patch(f"/api/schedules/{s['id']}", json={
"trigger": {"event": "source.changed", "source_id": src, "debounce_s": 300},
"input_template": {"n": "{{ event.counts.new }}"}, "version": s["version"]})
assert r.status_code == 200, r.text
out = r.json()
assert out["trigger"]["debounce_s"] == 300 and "去抖 5 分钟" in out["trigger_text"]
assert out["version"] == s["version"] + 1 and out["next_run_at"] is None and out["cron"] == ""
# 整份表单带回来(cron 空串、触发条件没变)照常保存
same = await client.patch(f"/api/schedules/{s['id']}", json={
"name": "变化处理", "cron": "", "trigger": out["trigger"], "trigger_kind": "event", "version": out["version"]})
assert same.status_code == 200 and same.json()["name"] == "变化处理"
# 模板里换成别的事件的变量:拒
bad = await client.patch(f"/api/schedules/{s['id']}", json={
"input_template": {"n": "{{ event.outputs.r }}"}, "version": same.json()["version"]})
assert bad.status_code == 422 and bad.json()["field"] == "input_template"
# --------------------------------------------------------------------------
# 预览、变量、立即运行、触发历史
# --------------------------------------------------------------------------
async def test_preview_and_run_now_with_a_sample_event(client):
a, a_name = await workflow("日报", value="日报完成")
b, _ = await workflow("汇总", fields=[{"name": "basis", "required": True}])
trigger = {"event": "workflow.finished", "workflow_id": a}
template = {"basis": "{{ event.outputs.r }} · {{ event.workflow }}"}
# 上游还没成功过:示例值,照实说
r = await client.post("/api/schedules/preview", json={"trigger_kind": "event", "trigger": trigger,
"timezone": "Asia/Shanghai", "input_template": template,
"workflow_id": b})
assert r.status_code == 200, r.text
p = r.json()
assert p["next"] == [] and p["event"]["real"] is False and p["event"]["text"].startswith(f"工作流「{a_name}」")
s = (await client.post("/api/schedules", json=body(b, trigger, input_template=template))).json()
early = (await client.post(f"/api/schedules/{s['id']}/run")).json()
assert early["run_id"] is None and early["fire"]["status"] == "failed_to_start"
assert "还没有发生过这种事件" in early["fire"]["reason"] and early["fire"]["kind"] == "manual"
# 上游成功过一次:预览、立即运行都按它
async with SessionLocal() as session:
up = await launch_run(session, workflow_id=a, run_class="formal", input_payload={}, actor="检查员",
origin_kind="launcher")
await settle(up.id)
p = (await client.post("/api/schedules/preview", json={"trigger_kind": "event", "trigger": trigger,
"timezone": "Asia/Shanghai", "input_template": template,
"workflow_id": b})).json()
assert p["inputs"] == {"basis": f"日报完成 · {a_name}"} and p["event"]["real"] is True
assert p["description_text"].startswith(f"工作流「{a_name}」的正式运行成功结束时触发")
await asyncio.sleep(1.05) # 立即运行按秒去重:上一次落在同一秒就交回那一条
run = (await client.post(f"/api/schedules/{s['id']}/run")).json()
assert run["run_id"] and run["fire"]["kind"] == "manual" and run["fire"]["event"]["sample"] is True
done = await settle(run["run_id"])
assert done.input == {"basis": f"日报完成 · {a_name}"}
assert done.origin["trigger"]["sample"] is True and done.origin["trigger"]["event"] == "workflow.finished"
assert done.origin["trigger"]["real"] is True and done.origin["trigger"]["chain"] == [up.id]
# 应用页印章:立即运行按示例事件发起的不说「由事件触发」
from app.apps import resolve
async with SessionLocal() as session:
said = await resolve._from_text(session, resolve.Source(id="v", kind="latest_sealed", state="ok", run=done))
assert said.startswith("定时任务「下游汇总」第 ")
# 触发历史:事件的说明照写
[newest, *_] = (await client.get(f"/api/schedules/{s['id']}/fires")).json()["items"]
assert newest["event"]["text"].startswith(f"工作流「{a_name}」的正式运行成功结束(#{up.id[:6]})")
async def test_variables_for_an_event(client):
names = [v["name"] for v in (await client.get(
"/api/schedules/variables", params={"trigger_kind": "event", "event": "workflow.finished"})).json()]
assert "event.outputs.<字段名>" in names and "event.run_id" in names and "schedule.yesterday" in names
assert "schedule.last_fire_time" not in names
plain = [v["name"] for v in (await client.get("/api/schedules/variables")).json()]
assert "schedule.last_fire_time" in plain and not any(n.startswith("event.") for n in plain)
bad = await client.get("/api/schedules/variables", params={"trigger_kind": "event", "event": "nope"})
assert bad.status_code == 422
async def test_trigger_state_and_event_fires(client):
"""调度关着:事件留在表里,接口照实说几条在等、调度没开;派发之后触发历史里是 kind=event、带事件的说明"""
from app.scheduler import outbox
from app.db.base import utcnow
from datetime import timedelta
a, a_name = await workflow("日报")
b, _ = await workflow("汇总")
s = (await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
"debounce_s": 0}))).json()
async with SessionLocal() as session:
up = await launch_run(session, workflow_id=a, run_class="formal", input_payload={}, actor="检查员",
origin_kind="launcher")
await settle(up.id)
state = (await client.get(f"/api/schedules/{s['id']}")).json()["trigger_state"]
assert state == {"events": 1, "waiting": 0, "due_at": None, "scheduler_on": False}
[fire] = await outbox.dispatch_due(utcnow() + timedelta(seconds=1))
await settle(fire.run_id)
got = (await client.get(f"/api/schedules/{s['id']}")).json()
assert got["trigger_state"]["events"] == 0 and got["last_fire"]["kind"] == "event"
assert got["last_fire"]["event"]["text"] == f"工作流「{a_name}」的正式运行成功结束(#{up.id[:6]})"
assert got["last_fire"]["inputs"] is None # 工作流目标的输入不在触发历史里列
async def test_trigger_options(client):
table = await upload_table("门店销售")
flat = await upload_table("门店名单", mode="replace")
wf, wf_name = await workflow("日报")
draft, _ = await workflow("草稿", published=False)
r = await client.get("/api/schedules/trigger-options")
assert r.status_code == 200
out = r.json()
events = {e["event"]: e for e in out["events"]}
assert list(events) == ["table.version", "workflow.finished", "source.changed"]
tables = {o["id"]: o for o in events["table.version"]["objects"]}
assert tables[table]["ok"] is True and tables[flat]["ok"] is False and "按期累积" in tables[flat]["reason"]
flows = {o["id"]: o for o in events["workflow.finished"]["objects"]}
assert flows[wf] == {"id": wf, "name": wf_name, "ok": True, "reason": None}
assert flows[draft]["ok"] is False and "发布" in flows[draft]["reason"]
assert "hook" in [o["kind"] for o in out["origins"]] and out["max_depth"] == 5
assert out["default_debounce_s"] == 60
async def test_include_rejected_and_backfill_through_the_api(client):
"""复核 M1、C5:上游工作流的触发缺省不认途经驳回的、应用补算的,说法照实写;勾了「也认」的存下来、说法跟着变;
别的事件带这两项拒收"""
a, a_name = await workflow("日报")
b, _ = await workflow("汇总", fields=[{"name": "rej"}])
plain = (await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a},
input_template={"rej": "{{ event.rejected }}"}))).json()
assert "途经驳回、应用补算的不算" in plain["trigger_text"] and "include_rejected" not in plain["trigger"]
r = await client.patch(f"/api/schedules/{plain['id']}", json={
"trigger": {"event": "workflow.finished", "workflow_id": a, "include_rejected": True}, "version": plain["version"]})
assert r.status_code == 200, r.text
assert r.json()["trigger"]["include_rejected"] is True and "途经驳回的也算;应用补算的不算" in r.json()["trigger_text"]
src = await upload_table("门店销售")
bad = await client.post("/api/schedules", json=body(b, {"event": "table.version", "source_id": src,
"include_backfill": True}))
assert bad.status_code == 422 and bad.json()["field"] == "trigger"
@@ -0,0 +1,277 @@
"""数据事件触发复核意见的修正(docs/design/orchestration-round/00-总方案.md §4;scheduler/outbox.py、triggers.py)。
- M1 途经驳回:正式运行走驳回出口跑完(succeeded,run.finished 带 rejected_via)照发 workflow.finished,事件内容带 rejected_via、
说法接「(途经驳回)」、模板有 {{ event.rejected }};触发条件缺省只认没途经驳回的,include_rejected 时也认;
- C1 去抖积压:一拍处理到没有到点的窗口为止;已过宽限的几个窗口合成一条跳过(合并了几条、时间跨度),「调度未运行」只在
派发确实晚到(派给任务的时刻离事件发生超过宽限)时说,否则照实说积压;积压清完之后新来的事件照常触发;
- C3 source.changed 按任务补写:同一个任务只记一条(dedupe),任务结束后没记上的(进程死了、库被锁)在调度的一拍按 job_id 补写;
- C4 不同期次的 table.version 不合进同一个窗口:各期有各自的触发;同一期的重传照旧合并;
- C5 应用补算(app_backfill)发的 workflow.finished 缺省不认,include_backfill 或只认补算时才认;
- C6 预览、立即运行的示例事件和真派发同一套出口字段过滤(「_」开头的不给模板)。
工作流、表格、数据源都是虚构的。
"""
from __future__ import annotations
import uuid
from datetime import timedelta
from sqlalchemy import select
from app.db.base import SessionLocal, utcnow
from app.db.models import DataSource, Run, Schedule
from app.db.models_sources import IntrospectJob, SourceChange
from app.db.models_triggers import TriggerDelivery, TriggerEvent
from app.scheduler import outbox, triggers
from tests.test_schedule_events import ( # noqa: F401 (engine_up:每个测试清掉定时任务和事件)
deliveries, engine_up, event_schedule, events, fires, formal_run, later, put_event, settle, workflow,
)
# --------------------------------------------------------------------------
# M1 途经驳回
# --------------------------------------------------------------------------
async def test_rejected_via_is_carried_and_excluded_by_default():
upstream = await workflow("门店日报")
plain = await event_schedule(await workflow("区域汇总"),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0}, name="不认驳回")
also = await event_schedule(await workflow("驳回复核", fields=[{"name": "rej"}]),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0,
"include_rejected": True}, template={"rej": "{{ event.rejected }}"}, name="也认驳回")
a = await formal_run(upstream)
async with SessionLocal() as session:
# runner 收尾时把这次运行途经的驳回交进来(W1-b 的 _rejected_via(state)):这里直接调,覆盖那一下
await session.execute(TriggerEvent.__table__.delete().where(TriggerEvent.subject_id == upstream))
outbox.stage_run_finished(session, await session.get(Run, a.id),
rejected_via=[{"node_id": "gate", "label": "主管审核"}])
await session.commit()
[ev] = await events("workflow.finished", upstream)
assert ev.payload["rejected"] is True and ev.payload["rejected_via"] == [{"node_id": "gate", "label": "主管审核"}]
assert triggers.event_text(ev.event, ev.subject_name, ev.payload).endswith("(途经驳回)")
[fire] = await outbox.dispatch_due(later())
assert fire.schedule_id == also and fire.status == "started" and fire.inputs["inputs"] == {"rej": "true"}
assert fire.inputs["event"]["text"].endswith("(途经驳回)")
assert await fires(plain) == [] and await deliveries(plain) == []
await settle(fire.run_id)
async def test_plain_runs_say_not_rejected():
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总", fields=[{"name": "rej"}]),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0},
template={"rej": "{{ event.rejected }}"})
await formal_run(upstream) # runner 收尾没交 rejected_via:没途经驳回
[ev] = await events("workflow.finished", upstream)
assert ev.payload["rejected"] is False and ev.payload["rejected_via"] is None
[fire] = await outbox.dispatch_due(later())
assert fire.schedule_id == sid and fire.inputs["inputs"] == {"rej": "false"}
assert "途经驳回" not in fire.inputs["event"]["text"]
await settle(fire.run_id)
def test_rejected_and_backfill_options_are_described():
base = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf"})
assert "(途经驳回、应用补算的不算;去抖 1 分钟)" in triggers.describe(base, "门店日报")
both = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf", "include_rejected": True,
"include_backfill": True})
assert both["include_rejected"] is True and both["include_backfill"] is True
assert "(途经驳回、应用补算的也算;去抖 1 分钟)" in triggers.describe(both, "门店日报")
# 只认应用补算的:补算本来就要认,不再说「补算的不算」
only = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf", "origin_kind": "app_backfill"})
text = triggers.describe(only, "门店日报")
assert "只认来源为「应用补算」的" in text and "应用补算的不算" not in text and "途经驳回的不算" in text
# 别的事件不收这两项
for key in ("include_rejected", "include_backfill"):
try:
triggers.normalize({"event": "table.version", "source_id": "x", key: True})
except triggers.TriggerError as e:
assert "上游工作流运行成功结束" in str(e)
else:
raise AssertionError(key)
assert "event.rejected" in triggers.names("workflow.finished")
# --------------------------------------------------------------------------
# C5 应用补算
# --------------------------------------------------------------------------
def test_backfill_runs_are_excluded_unless_asked():
payload = {"origin_kind": "app_backfill", "rejected": False}
base = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf"})
assert not triggers.matches(base, "workflow.finished", "wf", payload)
assert triggers.matches(base, "workflow.finished", "wf", {**payload, "origin_kind": "launcher"})
assert triggers.matches({**base, "include_backfill": True}, "workflow.finished", "wf", payload)
assert triggers.matches({**base, "origin_kind": "app_backfill"}, "workflow.finished", "wf", payload)
# --------------------------------------------------------------------------
# C6 示例事件的出口字段
# --------------------------------------------------------------------------
async def test_sample_outputs_use_the_same_filter_as_dispatch():
upstream = await workflow("门店日报")
run = await formal_run(upstream)
async with SessionLocal() as session:
await session.execute(Run.__table__.update().where(Run.id == run.id).values(
output={"r": "日报完成", "_issuance": {"tier": "full"}}))
await session.commit()
got = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream})
assert got.real and got.outputs == {"r": "日报完成"}
assert triggers.outputs_of({"r": 1, "_x": 2}) == {"r": 1}
async def test_sample_skips_rejected_and_backfill_runs_by_default():
upstream = await workflow("门店日报")
plain = await formal_run(upstream)
backfill = await formal_run(upstream, origin_kind="app_backfill")
async with SessionLocal() as session:
got = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream})
wide = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream,
"include_backfill": True})
assert got.payload["run_id"] == plain.id and wide.payload["run_id"] == backfill.id
# --------------------------------------------------------------------------
# C1 去抖积压
# --------------------------------------------------------------------------
async def _backlog(upstream: str, sid: str, *, n: int, gap_s: int, routed_late: bool) -> tuple:
"""n 条相隔 gap_s 秒的事件,派给了任务、还没处理。routed_late:派给任务的时刻是现在(调度停过);否则发生后 1 秒就派了
(调度在跑,只是没处理完)。"""
t0 = utcnow().replace(microsecond=0) - timedelta(seconds=gap_s * (n - 1) + 61)
ids = []
async with SessionLocal() as session:
for i in range(n):
at = t0 + timedelta(seconds=gap_s * i)
ev = TriggerEvent(event="workflow.finished", subject_id=upstream, subject_name="门店日报",
payload={"workflow_id": upstream, "run_id": f"run-{i}", "rejected": False},
occurred_at=at, created_at=at, status="routed", matched=1)
session.add(ev)
await session.flush()
session.add(TriggerDelivery(schedule_id=sid, event_id=ev.id, occurred_at=at,
created_at=utcnow() if routed_late else at + timedelta(seconds=1)))
ids.append(ev.id)
await session.commit()
return t0, ids
async def test_backlog_is_drained_in_one_tick_and_missed_windows_merge_into_one_skip():
"""12 条相隔 5 分钟、去抖 60 秒、宽限 600 秒:一拍处理完。过了宽限的 10 个窗口合成一条跳过(合并 10 次、写时间跨度),
宽限内的两个照常发起(第二个照重叠规则可能跳过);积压清完以后新来的事件照常触发"""
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总", fields=[{"name": "run"}]),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}, grace=600,
template={"run": "{{ event.run_id }}"})
t0, ids = await _backlog(upstream, sid, n=12, gap_s=300, routed_late=True)
done = await outbox.dispatch_due(utcnow())
assert len(done) == 3, [(f.status, f.reason) for f in done]
skip, *rest = done
assert skip.status == "skipped_misfire" and skip.kind == "event"
assert skip.inputs["event"]["merged"] == 10 and skip.inputs["event"]["windows"] == 10
assert skip.inputs["event"]["event_ids"] == ids[:10]
assert "服务当时没有派发(调度未运行或已停止)" in skip.reason and " 至 " in skip.reason and "10 次事件" in skip.reason
assert [f.inputs["event"]["event_ids"] for f in rest] == [[ids[10]], [ids[11]]]
assert rest[0].status == "started" and rest[1].status in ("started", "skipped_overlap")
assert {d.status for d in await deliveries(sid)} == {"fired"}
for f in rest:
if f.run_id:
await settle(f.run_id)
# 积压清完:新来的一条照常触发
new = await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "run-new"}, utcnow())
[fresh] = await outbox.dispatch_due(later(61))
assert fresh.status == "started" and fresh.inputs["event"]["event_ids"] == [new]
await settle(fresh.run_id)
async def test_backlog_while_scheduler_ran_says_backlog_not_scheduler_off():
upstream = await workflow("门店日报")
sid = await event_schedule(await workflow("区域汇总"),
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}, grace=600)
await _backlog(upstream, sid, n=4, gap_s=600, routed_late=False)
skip, *rest = await outbox.dispatch_due(utcnow())
assert skip.status == "skipped_misfire" and skip.inputs["event"]["merged"] == 3
assert "调度未运行" not in skip.reason and "积压" in skip.reason, skip.reason
for f in rest:
if f.run_id:
await settle(f.run_id)
# --------------------------------------------------------------------------
# C4 不同期次各自一个窗口
# --------------------------------------------------------------------------
async def test_different_periods_get_their_own_windows():
table = uuid.uuid4().hex
sid = await event_schedule(await workflow("月报", fields=[{"name": "month"}]),
{"event": "table.version", "source_id": table, "debounce_s": 60},
template={"month": "{{ event.period }}"})
t0 = utcnow().replace(microsecond=0)
payload = lambda p, s, e: {"table": "门店销售", "period": p, "period_start": s, "period_end": e, # noqa: E731
"version": f"snap-{p}", "replaced": False}
sep = await put_event("table.version", table, payload("2026-09", "2026-09-01", "2026-09-30"), t0, name="门店销售")
oct_ = await put_event("table.version", table, payload("2026-10", "2026-10-01", "2026-10-31"),
t0 + timedelta(seconds=30), name="门店销售")
again = await put_event("table.version", table, {**payload("2026-09", "2026-09-01", "2026-09-30"), "replaced": True},
t0 + timedelta(seconds=40), name="门店销售")
[first] = await outbox.dispatch_due(t0 + timedelta(seconds=61))
# 9 月一个窗口:9 月和 9 月的重传合在一起(内容取最新的那一条),10 月不合进来
assert first.inputs["event"]["event_ids"] == [sep, again] and first.inputs["inputs"] == {"month": "2026-09"}
await settle(first.run_id)
[second] = await outbox.dispatch_due(t0 + timedelta(seconds=91))
assert second.inputs["event"]["event_ids"] == [oct_] and second.inputs["inputs"] == {"month": "2026-10"}
assert second.fire_at == t0 + timedelta(seconds=90) and second.status == "started"
await settle(second.run_id)
# --------------------------------------------------------------------------
# C3 source.changed 按任务补写
# --------------------------------------------------------------------------
async def _source_with_changes(job_status: str) -> tuple[str, str]:
async with SessionLocal() as session:
source = DataSource(name=f"门店库-{uuid.uuid4().hex[:6]}", kind="sqlite", database=":memory:")
session.add(source)
await session.flush()
job = IntrospectJob(source_id=source.id, status=job_status,
finished_at=utcnow() if job_status != "running" else None)
session.add(job)
await session.flush()
for i in range(2):
session.add(SourceChange(source_id=source.id, source_name=source.name, table_name="orders",
column_name=f"c{i}", kind="column_removed", severity="action",
detected_by={"job_kind": "introspect", "job_id": job.id},
fingerprint=uuid.uuid4().hex, status="open"))
await session.commit()
return source.id, job.id
async def test_source_changed_is_backfilled_once_per_job():
"""任务结束了、变化记下了,那条事件却没记上(进程在中间死了):调度的一拍按 job_id 补写,只补一次;之后再走到
notify_after_job 也不重复"""
source_id, job_id = await _source_with_changes("succeeded")
running_source, _ = await _source_with_changes("running")
assert await events("source.changed", source_id) == []
assert await outbox.backfill_source_changes(utcnow()) >= 1
[ev] = await events("source.changed", source_id)
assert ev.payload["job_id"] == job_id and ev.payload["counts"]["new"] == 2 and ev.dedupe_key == f"source.changed:{job_id}"
# 还在进行的任务不补:它结束时自己会记
assert await events("source.changed", running_source) == []
await outbox.backfill_source_changes(utcnow())
from app.data import source_changes
await source_changes.notify_after_job(source_id, job_id)
assert len(await events("source.changed", source_id)) == 1
async def test_dispatch_tick_runs_the_backfill():
source_id, _ = await _source_with_changes("succeeded")
outbox._backfilled_at = None # 刚起的服务:第一拍就补
await outbox.dispatch_due(utcnow())
assert len(await events("source.changed", source_id)) == 1
+6 -2
View File
@@ -41,10 +41,14 @@ SCHEDULE_KEYS = {"id", "name", "workflow_id", "workflow_name", "cron", "timezone
"enabled", "overlap", "misfire_grace_s", "webhook", "next_run_at", "last_fire", "description_text",
"created_by", "created_at", "updated_at", "version",
# 定时的目标(next-round X4):种类、id、叫法、现在的名字、还在不在
"target_kind", "target_id", "target_label", "target_name", "target_exists"}
"target_kind", "target_id", "target_label", "target_name", "target_exists",
# 触发方式(orchestration-round §4):cron 任务这几项是 cron / null(test_schedule_events_api.py)
"trigger_kind", "trigger", "trigger_text", "trigger_state"}
FIRE_KEYS = {"id", "fire_at", "kind", "status", "run_id", "run_status", "reason", "notified", "compare", "created_at",
# 目标是评测题集时这一次触发建的那次评测;目标是数据源维护时这一次建了哪些任务、哪几步没执行(别的目标为 null)
"eval_run_id", "inputs"}
"eval_run_id", "inputs",
# 事件触发的那几次:事件的说明(别的为 null,orchestration-round §4)
"event"}
@pytest.fixture(autouse=True)
+10 -2
View File
@@ -8,8 +8,8 @@
* cron / timezone / input_template 哪一项(ApiError.field)。老服务端没有这些接口,回 404 且没有 code
*/
import type {
Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult,
ScheduleTargetCreate, ScheduleVariable, ScheduleWebhookTest,
Schedule, ScheduleCreate, ScheduleEventKind, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest,
ScheduleRunResult, ScheduleTargetCreate, ScheduleTriggerOptions, ScheduleVariable, ScheduleWebhookTest,
} from '../types'
import { del, get, patch, post, qs, request, type RequestOptions } from './http'
@@ -30,6 +30,14 @@ export const schedules = {
request<SchedulePreview>('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }),
/** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */
variables: (opts?: RequestOptions) => get<ScheduleVariable[]>('/schedules/variables', opts),
/**
* 事件触发任务的模板里能用的变量:schedule.*(上一次计划触发时间除外)加上这种事件的 event.*(docs/design/orchestration-round
* §4)。老服务端不认这两个参数,照旧给 cron 的那一份
*/
eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) =>
get<ScheduleVariable[]>(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts),
/** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */
triggerOptions: (opts?: RequestOptions) => get<ScheduleTriggerOptions>('/schedules/trigger-options', opts),
/** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */
run: (id: string) => post<ScheduleRunResult>(`${at(id)}/run`),
/** 触发历史,新的在前。before 是上一页给的 next_before */
+7 -4
View File
@@ -3995,11 +3995,13 @@ export const SCHEDULE_TEXT = {
openRun: '查看运行',
// ---- 触发
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record<import('../types').ScheduleFireKind, string>,
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发', event: '事件' } as Record<
import('../types').ScheduleFireKind, string>,
fireKindHint: {
scheduled: '到点按计划触发',
manual: '手动「立即运行」',
misfire: '错过触发时间后,在补发宽限内恢复时补发的一次',
event: '由数据事件触发(去抖结束的时刻)',
} as Record<import('../types').ScheduleFireKind, string>,
/**
* 错过、没有补发的(服务端记 kind=misfire、status=skipped_misfire):类型按「定时」写。没有补发的原因有两种(超出
@@ -4235,9 +4237,10 @@ export const SCHEDULE_TEXT = {
},
/** 「还需处理」里各项的叫法 */
problemField: {
name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', runClass: '运行类别', inputs: '输入', url: 'Webhook 地址',
secret: '签名密钥', events: '通知事件',
} as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events', string>,
name: '名称', workflow: '工作流', freq: '频率', trigger: '触发事件', timezone: '时区', runClass: '运行类别', inputs: '输入',
url: 'Webhook 地址', secret: '签名密钥', events: '通知事件',
} as Record<'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events',
string>,
// ---- 相对时间(将来)
until: {
+112
View File
@@ -0,0 +1,112 @@
/**
* 数据事件触发的文案(docs/design/orchestration-round/00-总方案.md §4):定时任务页新建时选「定时 / 事件触发」、事件那一节、
* 列表和详情里的等待状态、触发历史里的事件、运行详情页头的「由事件触发:…」。规矩同 terms.ts 开头的术语表:「运行」不写跑,
* 「服务端」不写后端,全角标点。
*
* 能选什么、为什么不能选、触发说明(「上传表格「X」出了新的一期时触发(去抖 1 分钟)」)、一次事件的说法都由服务端给
* (scheduler/triggers.py),这里只有叫法和句式。事件种类的叫法和服务端 triggers.EVENT_LABEL 逐字对照。
*/
import type { ScheduleEventKind, ScheduleTriggerKind } from '../types'
export const TRIGGER_TEXT = {
// ---- 新建时选触发方式
modeLabel: '怎么触发',
mode: { cron: '定时', event: '事件触发' } as Record<ScheduleTriggerKind, string>,
modeHint: {
cron: '按设定的频率到点发起运行',
event: '数据有变化时发起运行:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化',
} as Record<ScheduleTriggerKind, string>,
/** 修改时:触发方式建好之后不能改 */
modeFixed: (label: string) => `触发方式:${label}。建好之后不能改,要换请新建一个定时任务`,
// ---- 事件那一节
section: '触发事件',
eventLabel: '事件',
event: {
'table.version': '上传表格出了新的一期',
'workflow.finished': '上游工作流运行成功结束',
'source.changed': '数据源有要处理的变化',
} as Record<ScheduleEventKind, string>,
eventHint: {
'table.version': '按期累积的上传表格导入了新的一期并启用时触发;每次上传替换整张表的表格没有这个事件',
'workflow.finished': '选中的工作流有一次正式运行成功结束时触发;输入模板可以引用它的出口字段',
'source.changed': '数据源的探查、剖析新记下「要处理」级的变化时触发;同一次探查或剖析产生的一批只触发一次',
} as Record<ScheduleEventKind, string>,
subject: { 'table.version': '上传表格', 'workflow.finished': '上游工作流', 'source.changed': '数据源' } as Record<
ScheduleEventKind, string>,
subjectPlaceholder: (label: string) => `选择${label}`,
subjectEmpty: {
'table.version': '还没有上传表格',
'workflow.finished': '还没有工作流',
'source.changed': '还没有数据源',
} as Record<ScheduleEventKind, string>,
/** 不能选的对象:名字后面接服务端给的原因 */
subjectNotOk: (name: string, reason: string) => `${name}(${reason})`,
subjectGone: (name: string) => `${name}(已不存在)`,
optionsLoading: '正在读取可选的对象…',
optionsFailed: '无法读取可选的对象',
optionsUnsupported: '服务端暂不支持事件触发,请升级服务端后再使用',
retry: '重试',
periodLabel: '期次',
period: { any: '新增的一期和替换已有的一期都认', new: '只认新增的一期' } as Record<'any' | 'new', string>,
periodHint: '替换已有的一期:重新上传了已经导入过的统计期(例如改正了数),选「只认新增的一期」时不触发',
appLabel: '只认应用',
appAny: '不限应用',
originLabel: '只认来源',
originAny: '不限来源',
filterHint: '只在上游那次正式运行由这个应用、这种来源发起时触发',
includeRejected: '也认途经驳回的',
includeRejectedHint: '上游某个审批被驳回、按驳回那条路走完时,运行仍算成功结束。不勾时这类运行不触发;勾了以后,输入模板里的 {{ event.rejected }} 是 true',
includeBackfill: '也认应用补算的',
includeBackfillHint: '应用升版后按历次输入补算,会连着结束好几次正式运行,每次是某一期的历史输入。不勾时补算的运行不触发',
debounce: '去抖',
debounceHint: '这段时间内的几次事件合成一次触发,按最新的那一次渲染输入;上一次运行还没结束时仍按重叠策略跳过',
debounceOption: (span: string) => (span ? `${span}内合成一次` : '不去抖,每次事件都触发'),
timezone: '时区',
timezoneHint: '输入模板里的日期变量、事件里的时间按这个时区写',
chainHint: (n: number) => `上游工作流可以一个接一个地触发下游,最多 ${n} 层;串成环的设置保存时会被拒绝`,
// ---- 预览
preview: '按一次事件预览',
previewIdle: '选好事件和对象后在此预览渲染出的输入',
previewLatest: '按这个对象最近一次的事件渲染',
previewExample: '这个对象还没有发生过这种事件,下面是示例值;立即运行时用到事件变量的输入不会发起',
previewInputs: '渲染出的输入',
previewNoInputs: '没有填写输入,按工作流的默认值',
// ---- 列表、详情的等待状态
chip: '事件触发',
chipTitle: '由数据事件触发,不按时间',
nextEvent: '等待事件',
waiting: (n: number, when: string) => `去抖中:已收到 ${n} 次事件,将在 ${when} 合成一次触发`,
pending: (n: number) => `有 ${n} 条事件等待派发`,
pendingOff: (n: number) => `有 ${n} 条事件在等待,但定时任务调度未开启,不会派发`,
schedulerOff: '定时任务调度未开启:事件会留着,不会派发',
// ---- 触发历史
fireEvent: (text: string) => `由事件触发:${text}`,
/** 立即运行按示例事件发起的那一次:不是事件触发的,写按的是哪一次事件 */
fireSample: (text: string) => `按事件:${text}`,
merged: (n: number) => `合并了 ${n} 次事件`,
depth: (n: number) => `触发链第 ${n} 层`,
sample: '立即运行,按最近一次事件',
/** 几个过了补发宽限的去抖窗口合成一条跳过 */
windows: (n: number) => `${n} 个去抖窗口`,
periods: (list: string) => `期次 ${list}`,
sampleExample: '立即运行,事件为示例值',
// ---- 运行详情页头
runSource: (text: string, name?: string | null) => (name ? `由事件触发:${text}(定时任务「${name}」)` : `由事件触发:${text}`),
runSourceGone: (text: string, name?: string | null) =>
(name ? `由事件触发:${text}(定时任务「${name}」,已删除)` : `由事件触发:${text}(定时任务已删除)`),
/** 立即运行按示例事件发起的运行:写成「来自定时任务」那一类,后面接按的是哪一次事件 */
runSourceSample: (text: string, name?: string | null) =>
(name ? `立即运行定时任务「${name}」,按事件:${text}` : `立即运行定时任务,按事件:${text}`),
// ---- 表单的问题
problemSubject: (label: string) => `请选择${label}`,
problemField: '触发事件',
/** 事件触发时「错过触发时间」那一项的说明 */
misfireHint: '调度停过(服务停机、调度未开启)时,事件在宽限内派发照常发起,超过则跳过',
inputsHint: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值',
}
+1 -1
View File
@@ -595,7 +595,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
</time>
<span>·</span>
{/* 定时任务发起的:写成到那个定时任务的链接(键盘可达;记录页的「定时」标记只能鼠标点) */}
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} /> : (
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} trigger={run.origin?.trigger} /> : (
<span title={run.started_by ? undefined : '发起时未设置署名'}>
{run.started_by || '未署名'} 发起
</span>
+23 -1
View File
@@ -5,7 +5,8 @@ import { api } from '../../api/client'
import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui'
import { formatDateTime, parseServerTime, shortId } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { Schedule, ScheduleFire, ScheduleFireCompare, ScheduleFireEvent } from '../../types'
import { comparePath, type CompareVia } from '../runs/compare/entry'
import { maintenanceJobPath, maintenanceStepText } from './model'
import { FireKindChip, FireStatusChip, ZonedTime, targetKindOf } from './parts'
@@ -170,6 +171,7 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
{maintenance && (jobs.length > 0 || skipped.length > 0) && (
<MaintenanceFire sourceId={schedule.target_id ?? ''} jobs={jobs} skipped={skipped} />
)}
{f.event && <EventFire event={f.event} />}
{reason && (
<div className="mt-1 text-2xs leading-relaxed text-dim">
<span className="text-faint">{T.reason}:</span>{reason}
@@ -190,6 +192,26 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
)
}
/**
* 事件触发的那一次(docs/design/orchestration-round/00-总方案.md §4):由什么事件触发(服务端写好的那一句)、去抖合并了几次、
* 在触发链上第几层;立即运行时按示例事件发起的照实标出来(示例值的另说)
*/
function EventFire({ event }: { event: ScheduleFireEvent }) {
const notes = [
event.merged > 1 ? TT.merged(event.merged) : null,
event.windows && event.windows > 1 ? TT.windows(event.windows) : null,
event.periods && event.periods.length > 1 ? TT.periods(event.periods.join('、')) : null,
event.depth > 1 ? TT.depth(event.depth) : null,
event.sample ? (event.real ? TT.sample : TT.sampleExample) : null,
].filter(Boolean)
return (
<div className="mt-1 text-2xs leading-relaxed text-dim" data-fire-event={event.event}>
{event.sample ? TT.fireSample(event.text) : TT.fireEvent(event.text)}
{notes.length > 0 && <span className="text-faint">({notes.join(';')})</span>}
</div>
)
}
/**
* 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析
* 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果;
+104 -36
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '../../lib/errors'
import { parseServerTime } from '../../lib/format'
import { useLeaveGuard } from '../../lib/leave'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import { useCatalog, useCatalogListState } from '../../store/catalog'
import type { Schedule, SchedulePreview, ScheduleRunClass, ScheduleVariable, Workflow } from '../../types'
import type { Schedule, ScheduleEventKind, SchedulePreview, ScheduleRunClass, ScheduleTrigger, ScheduleVariable, Workflow } from '../../types'
import {
GRACE_OPTIONS, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem, graceLabel, graphFields, patchBody,
rebaseForm, serverFieldValue, templateOf, type Freq, type FormField, type InputField, type ScheduleForm,
rebaseForm, serverFieldValue, templateOf, triggerBody, type Freq, type FormField, type InputField, type ScheduleForm,
} from './model'
import { isUnsupported } from './parts'
import { EventSection, TriggerModePicker } from './TriggerEditor'
import { WebhookFields } from './WebhookFields'
import { WorkflowPicker } from './WorkflowPicker'
@@ -23,6 +25,9 @@ import { WorkflowPicker } from './WorkflowPicker'
* 新建、修改定时任务的表单。宽屏左边是表单、右边是接下来 5 次触发的预览(跟着滚动停在顶上);窄屏预览排在
* 频率下面,改了频率当场看得到。
*
* 新建时先选「定时 / 事件触发」(docs/design/orchestration-round/00-总方案.md §4):事件触发的没有频率,那一节换成事件
* (TriggerEditor 的 EventSection),预览按一次事件渲染输入(不列触发时间),变量多出 {{ event.* }}。触发方式建好之后不能改。
*
* schedule 为 null 是新建。修改时只交改了的字段,带上 version:详情页页头的开关改过之后,version 是开关交回的
* 新版本,表单照常保存;别人在这期间改过就 409,表单不动,给「重新载入」。
*/
@@ -54,17 +59,20 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
} | null>(null)
const workflow = workflows.find((w) => w.id === form.workflowId)
const cron = cronOf(form.freq)
const event = form.triggerKind === 'event'
const cron = event ? '' : cronOf(form.freq)
const template = useMemo(() => templateOf(form.inputs), [form.inputs])
const preview = usePreview(cron, form.timezone, template, form.workflowId)
const trigger = useMemo(() => (event ? triggerBody(form.trigger) : null), [event, form.trigger])
const preview = usePreview(cron, form.timezone, template, form.workflowId, trigger)
const fieldsOf = useInputFields(workflow, form.runClass)
// 修改时工作流列表还没取回来:输入字段是「正在读取」,不是「请先选择工作流」
const fields: FieldsState = !workflow && form.workflowId && workflowsState !== 'ok' ? { status: 'loading' } : fieldsOf
const variables = useVariables()
const variables = useVariables(event ? form.trigger.event : null)
const changes = base ? patchBody(form, initial, base) : null
// 服务端保存时只在新建、或者改了工作流、运行类别、输入时才核对这三样:照着拦,不多拦也不少拦
const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes))
const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes
|| 'trigger' in changes))
const problems = formProblems(form, base, {
published: workflow ? workflow.published_version ?? null : undefined,
fields: fields.status === 'ok' ? fields.fields : undefined,
@@ -75,6 +83,7 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
/** 预览判定非法、并指明是这一项时,服务端的说明 */
const previewSays = (field: string) => (preview.status === 'invalid' && preview.field === field ? preview.message : undefined)
const previewBlocks = previewField === 'cron' || previewField === 'timezone' || previewField === 'input_template'
|| previewField === 'trigger' || previewField === 'debounce_s'
const missing = (Object.keys(problems) as FormField[]).map((k) => T.problemField[k])
const hint = missing.length ? T.missing(missing.join('、')) : previewBlocks ? T.previewBlocks : ''
const canSave = !saving && !missing.length && !previewBlocks && dirty
@@ -178,16 +187,24 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
<WorkflowField form={form} workflows={workflows} state={workflowsState} workflow={workflow} base={base}
error={shown('workflow') ?? serverError(['workflow_id'])}
onPick={pickWorkflow} onBlur={() => touch('workflow')} />
<TriggerModePicker value={form.triggerKind} fixed={!!base} onChange={(triggerKind) => set({ triggerKind })} />
</section>
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
{event ? (
<EventSection value={form.trigger} onChange={(t) => set({ trigger: t })} timezone={form.timezone}
setTimezone={(timezone) => set({ timezone })} savedName={base?.trigger?.subject_name ?? null}
error={previewSays('trigger') ?? serverError(['trigger', 'trigger_kind'])}
debounceError={previewSays('debounce_s') ?? serverError(['debounce_s'])} />
) : (
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
)}
</div>
<aside className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
<div className="lg:sticky lg:top-4">
<PreviewBox preview={preview} />
<PreviewBox preview={preview} event={event} />
</div>
</aside>
@@ -196,13 +213,13 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
error={serverError(['run_class'])}
onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} />
<InputsSection workflow={workflow} fields={fields} inputs={form.inputs} variables={variables}
preview={preview} recheck={recheck}
preview={preview} recheck={recheck} event={event}
error={previewSays('input_template') ?? serverError(['input_template'])}
onChange={(inputs) => set({ inputs })} />
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-overlap-title">
<h2 id="schedule-overlap-title" className="text-xs font-semibold">{T.overlap}</h2>
<p className="text-xs leading-relaxed text-dim">{T.overlapText}</p>
<Field label={T.misfire} htmlFor="schedule-misfire" hint={T.misfireHint} error={misfireError}>
<Field label={T.misfire} htmlFor="schedule-misfire" hint={event ? TT.misfireHint : T.misfireHint} error={misfireError}>
<select id="schedule-misfire" className="field w-auto max-w-full" value={form.misfireGraceS}
aria-invalid={!!misfireError || undefined}
aria-describedby={misfireError ? 'schedule-misfire-error' : 'schedule-misfire-hint'}
@@ -291,13 +308,18 @@ const PREVIEW_DEBOUNCE_MS = 400
/**
* 接下来 5 次触发和渲染出的输入(服务端算,前端不自己解析 cron)。改动中保留上一次的结果并标「正在计算」;
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题。
*
* trigger 不为空是事件触发:交触发条件、不交 cron,服务端按一次示例事件渲染输入(没有触发时间);对象还没选时不问
*/
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string): PreviewState {
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string,
trigger: ScheduleTrigger | null = null): PreviewState {
const [state, setState] = useState<PreviewState>({ status: 'idle' })
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId])
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId,
trigger])
useEffect(() => {
if (!cron || !timezone) {
const subject = trigger ? trigger.source_id ?? trigger.workflow_id : null
if (trigger ? !subject || !timezone : !cron || !timezone) {
setState({ status: 'idle' })
return
}
@@ -306,7 +328,7 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
const ctrl = new AbortController()
const timer = setTimeout(() => {
api.schedules.preview({
cron, timezone, n: 5,
...(trigger ? { trigger_kind: 'event' as const, trigger, cron: '' } : { cron, n: 5 }), timezone,
...(Object.keys(template).length ? { input_template: template } : {}),
...(workflowId ? { workflow_id: workflowId } : {}),
}, { signal: ctrl.signal })
@@ -324,22 +346,23 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
clearTimeout(timer)
ctrl.abort()
}
// key 已经把四项都编进去了
// key 已经把五项都编进去了
}, [key])
return state
}
export function PreviewBox({ preview }: { preview: PreviewState }) {
export function PreviewBox({ preview, event = false }: { preview: PreviewState; event?: boolean }) {
const data = preview.status === 'ok' || preview.status === 'loading' ? preview.data : undefined
const tz = preview.status === 'ok' || preview.status === 'loading' ? preview.timezone : undefined
return (
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-preview-title" aria-live="polite"
data-schedule-preview="" data-status={preview.status}>
data-schedule-preview="" data-status={preview.status} data-preview-mode={event ? 'event' : 'cron'}>
<div className="mb-2 flex items-center gap-2">
<h2 id="schedule-preview-title" className="text-xs font-semibold">{T.preview}</h2>
<h2 id="schedule-preview-title" className="text-xs font-semibold">{event ? TT.preview : T.preview}</h2>
{preview.status === 'loading' && <Spinner size={11} />}
</div>
{data && (
{data && event && <EventPreview data={data} loading={preview.status === 'loading'} />}
{data && !event && (
<>
{data.description_text && <div className="mb-2 text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
{data.next?.length ? (
@@ -362,7 +385,7 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
</>
)}
{!data && preview.status === 'loading' && <p className="text-xs text-faint">{T.previewLoading}</p>}
{preview.status === 'idle' && <p className="text-xs text-faint">{T.previewIdle}</p>}
{preview.status === 'idle' && <p className="text-xs text-faint">{event ? TT.previewIdle : T.previewIdle}</p>}
{preview.status === 'invalid' && <p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">{preview.message}</p>}
{preview.status === 'error' && (
<p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">
@@ -373,6 +396,40 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
)
}
/**
* 事件触发的预览:触发说明、按哪一次事件渲染(对象最近一次的事件,没有时是示例值、照实说),渲染出的输入逐项列出。
* 没有触发时间可列:事件什么时候来不由时间定
*/
function EventPreview({ data, loading }: { data: SchedulePreview; loading: boolean }) {
const inputs = Object.entries(data.inputs ?? {})
return (
<div className={clsx('space-y-2', loading && 'opacity-60')}>
{data.description_text && <div className="text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
{data.event && (
<div className="text-2xs leading-relaxed" data-preview-event={data.event.real ? 'real' : 'example'}>
<div className="text-fg">{data.event.text}</div>
<div className={data.event.real ? 'text-faint' : 'text-[var(--warn)]'}>
{data.event.real ? TT.previewLatest : TT.previewExample}
</div>
</div>
)}
<div>
<div className="text-2xs text-faint">{TT.previewInputs}</div>
{inputs.length ? (
<dl className="mt-0.5 space-y-0.5 text-2xs">
{inputs.map(([k, v]) => (
<div key={k} className="flex min-w-0 gap-1.5" data-preview-input={k}>
<dt className="mono shrink-0 text-dim">{k}</dt>
<dd className="mono min-w-0 break-all text-fg">{v === '' ? T.renderedEmpty : v}</dd>
</div>
))}
</dl>
) : <p className="text-2xs text-faint">{TT.previewNoInputs}</p>}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// 运行类别
// ---------------------------------------------------------------------------
@@ -477,25 +534,34 @@ function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunCla
return fetched?.key === key ? fetched.state : { status: 'loading' }
}
/** 输入模板里能用的变量:服务端给,各处共用一份;取不到时为空表,变量按钮照样在,只是说没有可用的 */
let variablesOnce: Promise<ScheduleVariable[]> | null = null
function useVariables(): ScheduleVariable[] | null {
const [vars, setVars] = useState<ScheduleVariable[] | null>(null)
/**
* 输入模板里能用的变量:服务端给,各处共用一份(按 cron 的一份,事件触发按事件种类各一份);取不到时为空表,变量按钮
* 照样在,只是说没有可用的
*/
const variablesOnce = new Map<string, Promise<ScheduleVariable[]>>()
function useVariables(event: ScheduleEventKind | null = null): ScheduleVariable[] | null {
const [vars, setVars] = useState<{ key: string; list: ScheduleVariable[] } | null>(null)
const key = event ?? 'cron'
useEffect(() => {
let alive = true
variablesOnce ??= api.schedules.variables().then((v) => (Array.isArray(v) ? v : []), (e) => {
variablesOnce = null
throw e
})
variablesOnce.then((v) => { if (alive) setVars(v) }, () => { if (alive) setVars([]) })
let once = variablesOnce.get(key)
if (!once) {
once = (event ? api.schedules.eventVariables(event) : api.schedules.variables())
.then((v) => (Array.isArray(v) ? v : []), (e) => {
variablesOnce.delete(key)
throw e
})
variablesOnce.set(key, once)
}
once.then((v) => { if (alive) setVars({ key, list: v }) }, () => { if (alive) setVars({ key, list: [] }) })
return () => { alive = false }
}, [])
return vars
}, [key]) // eslint-disable-line react-hooks/exhaustive-deps
return vars?.key === key ? vars.list : null
}
const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, error, onChange }: {
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, event = false, error, onChange }: {
workflow: Workflow | undefined
fields: FieldsState
inputs: Record<string, string>
@@ -503,6 +569,8 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
preview: PreviewState
/** 保存时服务端会不会核对输入(新建,或者改了工作流、运行类别、输入):会的话必填没填、字段对不上都要先处理 */
recheck: boolean
/** 事件触发:模板变量另按事件计算(说明不同) */
event?: boolean
error?: string
onChange: (inputs: Record<string, string>) => void
}) {
@@ -621,7 +689,7 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-inputs-title">
<div>
<h2 id="schedule-inputs-title" className="text-xs font-semibold">{T.inputs}</h2>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{T.inputsHint}</p>
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{event ? TT.inputsHint : T.inputsHint}</p>
{workflow && fields.status === 'ok' && (
<p className="mt-0.5 text-2xs text-faint" data-inputs-source="">
{fields.version != null ? T.inputsFromVersion(fields.version) : T.inputsFromDraft}
@@ -9,8 +9,8 @@ import type { Schedule } from '../../types'
import { ClassChip } from '../runs/parts'
import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail'
import {
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
targetText,
EnableSwitch, EventChip, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled,
targetKindOf, targetText,
} from './parts'
/**
@@ -173,6 +173,7 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
{workflow
? <ClassChip runClass={s.run_class} />
: <span className="chip shrink-0" data-schedule-target-kind={kind}>{T.targetKind[kind]}</span>}
{s.trigger_kind === 'event' && <EventChip />}
{!s.enabled && <span className="chip shrink-0" data-schedule-paused="">{T.pausedChip}</span>}
</div>
<div className="mt-0.5 truncate text-xs text-dim" title={`${target} · ${s.description_text}`} data-schedule-desc="">
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from 'react'
import clsx from 'clsx'
import { api } from '../../api/client'
import { Field, Notice, Skeleton, useRadioGroup } from '../../components/ui'
import { errorMessage } from '../../lib/errors'
import { timezoneOptions } from '../../lib/freq'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { ScheduleEventKind, ScheduleTriggerKind, ScheduleTriggerOptions } from '../../types'
import {
BACKFILL_ORIGIN, DEBOUNCE_OPTIONS, EVENT_KINDS, TRIGGER_KINDS, graceLabel, switchEvent, type TriggerForm,
} from './model'
import { isUnsupported } from './parts'
/**
* 数据事件触发的表单部件(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」(TriggerModePicker),
* 选了事件触发时用 EventSection 替换频率那一节——事件种类、对象、过滤、去抖、时区。
*
* 能选哪些对象、不能选的为什么不能选,都是服务端给的(GET /api/schedules/trigger-options):这里只照着画,不自己判「是不是
* 按期累积」「发没发布」;会不会串成环保存时由服务端核对,422 的说明落到这一节(error)。
*/
/** 新建时选触发方式;修改时只读地写出是哪一种(触发方式建好之后不能改) */
export function TriggerModePicker({ value, onChange, fixed }: {
value: ScheduleTriggerKind
onChange: (v: ScheduleTriggerKind) => void
/** 修改中的定时任务:只读 */
fixed?: boolean
}) {
const radio = useRadioGroup(TRIGGER_KINDS, value, onChange)
if (fixed) {
return <p className="text-2xs leading-relaxed text-faint" data-trigger-mode-fixed={value}>{TT.modeFixed(TT.mode[value])}</p>
}
return (
<div data-trigger-modes="">
<div className="label" id="schedule-trigger-mode">{TT.modeLabel}</div>
<div role="radiogroup" aria-labelledby="schedule-trigger-mode" className="flex flex-wrap gap-1">
{TRIGGER_KINDS.map((k) => (
<button key={k} type="button" {...radio(k)} onClick={() => onChange(k)} data-trigger-mode={k}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
value === k ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{TT.mode[k]}
</button>
))}
</div>
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.modeHint[value]}</p>
</div>
)
}
type OptionsState =
| { status: 'loading' }
| { status: 'ok'; data: ScheduleTriggerOptions }
| { status: 'error'; error: unknown; retry: () => void }
/** 可选的对象:各处共用一份,失败时清掉、下次再取 */
let optionsOnce: Promise<ScheduleTriggerOptions> | null = null
function useTriggerOptions(): OptionsState {
const [state, setState] = useState<OptionsState>({ status: 'loading' })
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let alive = true
setState({ status: 'loading' })
optionsOnce ??= api.schedules.triggerOptions().catch((e) => {
optionsOnce = null
throw e
})
optionsOnce.then(
(data) => { if (alive) setState({ status: 'ok', data }) },
(error) => { if (alive) setState({ status: 'error', error, retry: () => setAttempt((n) => n + 1) }) },
)
return () => { alive = false }
}, [attempt])
return state
}
export function EventSection({ value, onChange, timezone, setTimezone, savedName, error, debounceError }: {
value: TriggerForm
onChange: (t: TriggerForm) => void
timezone: string
setTimezone: (tz: string) => void
/** 修改时对象的名字(不在可选列表里时写它,标「已不存在」) */
savedName?: string | null
/** 这一节的问题(还没选对象)或保存被拒时服务端的说明(对象不合用、会循环触发) */
error?: string
debounceError?: string
}) {
const options = useTriggerOptions()
const radio = useRadioGroup(EVENT_KINDS, value.event, (event) => onChange(switchEvent(value, event)))
const zones = useMemo(() => timezoneOptions(timezone), [timezone])
const set = (patch: Partial<TriggerForm>) => onChange({ ...value, ...patch })
const kind = value.event
const label = TT.subject[kind]
const entry = options.status === 'ok' ? options.data.events.find((e) => e.event === kind) : undefined
const objects = entry?.objects ?? []
const listed = objects.some((o) => o.id === value.subjectId)
let picker
if (options.status === 'loading') picker = <Skeleton rows={1} height={30} />
else if (options.status === 'error') {
picker = (
<Notice tone="err">
{isUnsupported(options.error) ? TT.optionsUnsupported : `${TT.optionsFailed}:${errorMessage(options.error)}`}
{!isUnsupported(options.error) && (
<button type="button" className="btn btn-xs ml-2" onClick={options.retry}>{TT.retry}</button>
)}
</Notice>
)
} else {
picker = (
<select id="schedule-trigger-subject" className="field" value={value.subjectId} data-trigger-subject={kind}
aria-invalid={!!error || undefined}
aria-describedby={error ? 'schedule-trigger-subject-error' : undefined}
onChange={(e) => set({ subjectId: e.target.value })}>
<option value="">{objects.length ? TT.subjectPlaceholder(label) : TT.subjectEmpty[kind]}</option>
{value.subjectId && !listed && <option value={value.subjectId}>{TT.subjectGone(savedName || value.subjectId)}</option>}
{objects.map((o) => (
<option key={o.id} value={o.id} disabled={!o.ok && o.id !== value.subjectId} data-trigger-object-ok={String(o.ok)}>
{o.ok || !o.reason ? o.name : TT.subjectNotOk(o.name, o.reason)}
</option>
))}
</select>
)
}
return (
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-trigger-title" data-trigger-section="">
<h2 id="schedule-trigger-title" className="text-xs font-semibold">{TT.section}</h2>
<div>
<div className="label" id="schedule-trigger-event">{TT.eventLabel}</div>
<div role="radiogroup" aria-labelledby="schedule-trigger-event" className="flex flex-wrap gap-1">
{EVENT_KINDS.map((e: ScheduleEventKind) => (
<button key={e} type="button" {...radio(e)} onClick={() => onChange(switchEvent(value, e))} data-trigger-event={e}
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
kind === e ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
{TT.event[e]}
</button>
))}
</div>
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.eventHint[kind]}</p>
</div>
<Field label={label} htmlFor="schedule-trigger-subject" required>
{picker}
</Field>
{error && (
<div id="schedule-trigger-subject-error" className="-mt-1.5 text-2xs leading-relaxed text-[var(--err)]" role="alert"
data-trigger-error="">
{error}
</div>
)}
{kind === 'table.version' && (
<Field label={TT.periodLabel} htmlFor="schedule-trigger-period" hint={TT.periodHint}>
<select id="schedule-trigger-period" className="field w-auto max-w-full" value={value.period} data-trigger-period=""
aria-describedby="schedule-trigger-period-hint"
onChange={(e) => set({ period: e.target.value === 'new' ? 'new' : 'any' })}>
<option value="any">{TT.period.any}</option>
<option value="new">{TT.period.new}</option>
</select>
</Field>
)}
{kind === 'workflow.finished' && options.status === 'ok' && (
<div className="space-y-1">
<div className="flex flex-wrap gap-3">
<Field label={TT.appLabel} htmlFor="schedule-trigger-app" className="min-w-[12rem] flex-1">
<select id="schedule-trigger-app" className="field" value={value.appId} data-trigger-app=""
onChange={(e) => set({ appId: e.target.value })}>
<option value="">{TT.appAny}</option>
{value.appId && !options.data.apps.some((a) => a.id === value.appId) && (
<option value={value.appId}>{TT.subjectGone(value.appId)}</option>
)}
{options.data.apps.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</Field>
<Field label={TT.originLabel} htmlFor="schedule-trigger-origin" className="min-w-[12rem] flex-1">
<select id="schedule-trigger-origin" className="field" value={value.originKind} data-trigger-origin=""
onChange={(e) => set({ originKind: e.target.value })}>
<option value="">{TT.originAny}</option>
{options.data.origins.map((o) => <option key={o.kind} value={o.kind}>{o.label}</option>)}
</select>
</Field>
</div>
<p className="text-2xs leading-relaxed text-faint">{TT.filterHint}</p>
{/* 缺省不认的两种成功结束:途经驳回的、应用补算的(服务端 triggers.matches),要认的勾上 */}
<div className="space-y-1 pt-1">
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={value.includeRejected} data-trigger-include-rejected=""
onChange={(e) => set({ includeRejected: e.target.checked })} />
<span>{TT.includeRejected}<span className="block text-2xs leading-relaxed text-faint">{TT.includeRejectedHint}</span></span>
</label>
{value.originKind !== BACKFILL_ORIGIN && (
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={value.includeBackfill} data-trigger-include-backfill=""
onChange={(e) => set({ includeBackfill: e.target.checked })} />
<span>{TT.includeBackfill}<span className="block text-2xs leading-relaxed text-faint">{TT.includeBackfillHint}</span></span>
</label>
)}
</div>
<p className="text-2xs leading-relaxed text-faint" data-trigger-chain="">{TT.chainHint(options.data.max_depth)}</p>
</div>
)}
<Field label={TT.debounce} htmlFor="schedule-trigger-debounce" hint={TT.debounceHint} error={debounceError}>
<select id="schedule-trigger-debounce" className="field w-auto max-w-full" value={value.debounceS} data-trigger-debounce=""
aria-invalid={!!debounceError || undefined}
aria-describedby={debounceError ? 'schedule-trigger-debounce-error' : 'schedule-trigger-debounce-hint'}
onChange={(e) => set({ debounceS: Number(e.target.value) })}>
{[...new Set([...DEBOUNCE_OPTIONS, value.debounceS])].sort((a, b) => a - b).map((s) => (
<option key={s} value={s}>{TT.debounceOption(s ? graceLabel(s) : '')}</option>
))}
</select>
</Field>
<Field label={TT.timezone} htmlFor="schedule-trigger-timezone" hint={TT.timezoneHint}>
<select id="schedule-trigger-timezone" className="field" value={timezone} data-timezone=""
aria-describedby="schedule-trigger-timezone-hint" onChange={(e) => setTimezone(e.target.value)}>
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
</select>
</Field>
</section>
)
}
+98 -10
View File
@@ -4,9 +4,11 @@
* scripts/check-schedules.mjs 直接借 vite 转译后调用它们。
*/
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type {
Schedule, ScheduleCreate, ScheduleEvent, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate,
ScheduleTargetKind, ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion,
Schedule, ScheduleCreate, ScheduleEvent, ScheduleEventKind, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass,
ScheduleTargetCreate, ScheduleTargetKind, ScheduleTrigger, ScheduleTriggerKind, ScheduleWebhookFormat, ScheduleWebhookIn,
ScheduleWebhookTest, Workflow, WorkflowVersion,
} from '../../types'
export * from '../../lib/freq'
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq'
@@ -31,9 +33,74 @@ export interface WebhookForm {
events: ScheduleEvent[]
}
/**
* 事件触发的设置(docs/design/orchestration-round/00-总方案.md §4)。对象按事件种类:表格、数据源、上游工作流都记在 subjectId,
* 交上去时按种类写成 source_id / workflow_id。过滤只在对应的事件种类上交:期次给上传表格,应用、来源给上游工作流
*/
export interface TriggerForm {
event: ScheduleEventKind
subjectId: string
period: 'any' | 'new'
appId: string
originKind: string
/** 也认途经驳回的、也认应用补算的(只给上游工作流,缺省都不认) */
includeRejected: boolean
includeBackfill: boolean
debounceS: number
}
/** 应用补算的来源:选了「只认应用补算」时,「也认应用补算的」不再适用 */
export const BACKFILL_ORIGIN = 'app_backfill'
export const EVENT_KINDS: readonly ScheduleEventKind[] = ['table.version', 'workflow.finished', 'source.changed']
export const TRIGGER_KINDS: readonly ScheduleTriggerKind[] = ['cron', 'event']
/** 去抖的选项(秒):不去抖、1 分钟(缺省)、5 分钟、15 分钟、1 小时 */
export const DEBOUNCE_OPTIONS: readonly number[] = [0, 60, 300, 900, 3600]
export const DEFAULT_DEBOUNCE_S = 60
export function triggerFormOf(t: ScheduleTrigger | null | undefined): TriggerForm {
const event = t?.event && EVENT_KINDS.includes(t.event) ? t.event : 'table.version'
return {
event,
subjectId: (event === 'workflow.finished' ? t?.workflow_id : t?.source_id) ?? '',
period: t?.period === 'new' ? 'new' : 'any',
appId: t?.app_id ?? '',
originKind: t?.origin_kind ?? '',
includeRejected: t?.include_rejected === true,
includeBackfill: t?.include_backfill === true,
debounceS: typeof t?.debounce_s === 'number' ? t.debounce_s : DEFAULT_DEBOUNCE_S,
}
}
/** 交上去的触发条件:对象按种类写键,过滤只带对这种事件有意义、又选了的 */
export function triggerBody(t: TriggerForm): ScheduleTrigger {
const out: ScheduleTrigger = { event: t.event, debounce_s: t.debounceS }
if (t.event === 'workflow.finished') {
out.workflow_id = t.subjectId
if (t.appId) out.app_id = t.appId
if (t.originKind) out.origin_kind = t.originKind
if (t.includeRejected) out.include_rejected = true
if (t.includeBackfill && t.originKind !== BACKFILL_ORIGIN) out.include_backfill = true
} else {
out.source_id = t.subjectId
if (t.event === 'table.version' && t.period === 'new') out.period = 'new'
}
return out
}
/** 换了事件种类:对象、过滤都不再适用,清掉;去抖留着 */
export function switchEvent(t: TriggerForm, event: ScheduleEventKind): TriggerForm {
if (t.event === event) return t
return { event, subjectId: '', period: 'any', appId: '', originKind: '', includeRejected: false, includeBackfill: false,
debounceS: t.debounceS }
}
export interface ScheduleForm {
name: string
workflowId: string
/** 触发方式:只在新建时选,修改时跟着读到的定时任务(建好之后不能改) */
triggerKind: ScheduleTriggerKind
trigger: TriggerForm
freq: Freq
timezone: string
runClass: ScheduleRunClass
@@ -49,15 +116,19 @@ export interface ScheduleForm {
export function formOf(s: Schedule | null, timezone: string = browserTimezone()): ScheduleForm {
if (!s) {
return {
name: '', workflowId: '', freq: { ...DEFAULT_FREQ }, timezone, runClass: 'formal', inputs: {}, enabled: true,
misfireGraceS: DEFAULT_GRACE_S,
name: '', workflowId: '', triggerKind: 'cron', trigger: triggerFormOf(null), freq: { ...DEFAULT_FREQ }, timezone,
runClass: 'formal', inputs: {}, enabled: true, misfireGraceS: DEFAULT_GRACE_S,
webhook: { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] },
}
}
const event = s.trigger_kind === 'event'
return {
name: s.name,
workflowId: s.workflow_id,
freq: freqOf(s.cron),
triggerKind: event ? 'event' : 'cron',
trigger: triggerFormOf(event ? s.trigger : null),
// 事件触发的任务没有 cron(存空串):频率留缺省,不交也不比
freq: event ? { ...DEFAULT_FREQ } : freqOf(s.cron),
timezone: s.timezone,
runClass: s.run_class,
inputs: { ...(s.input_template ?? {}) },
@@ -79,10 +150,13 @@ const sortEvents = (events: ScheduleEvent[]) => SCHEDULE_EVENTS.filter((e) => ev
export function createBody(f: ScheduleForm): ScheduleCreate {
const w = f.webhook
const event = f.triggerKind === 'event'
return {
name: f.name.trim(),
workflow_id: f.workflowId,
cron: cronOf(f.freq),
// 事件触发:不写 cron(交空串),带触发方式和触发条件;cron 任务的请求体和以前一字不差
...(event ? { trigger_kind: 'event' as const, trigger: triggerBody(f.trigger) } : {}),
cron: event ? '' : cronOf(f.freq),
timezone: f.timezone,
run_class: f.runClass,
input_template: templateOf(f.inputs),
@@ -109,7 +183,9 @@ export function patchBody(f: ScheduleForm, initial: ScheduleForm, saved: Schedul
const out: Omit<SchedulePatch, 'version'> = {}
if (f.name.trim() !== initial.name.trim()) out.name = f.name.trim()
if (f.workflowId !== initial.workflowId) out.workflow_id = f.workflowId
if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq)
if (f.triggerKind === 'event') {
if (!sameJson(triggerBody(f.trigger), triggerBody(initial.trigger))) out.trigger = triggerBody(f.trigger)
} else if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq)
if (f.timezone !== initial.timezone) out.timezone = f.timezone
if (f.runClass !== initial.runClass) out.run_class = f.runClass
if (!sameJson(sortedEntries(templateOf(f.inputs)), sortedEntries(templateOf(initial.inputs)))) {
@@ -157,6 +233,8 @@ export function rebaseForm(cur: ScheduleForm, sent: ScheduleForm, saved: Schedul
return {
name: pick('name'),
workflowId: pick('workflowId'),
triggerKind: pick('triggerKind'),
trigger: pick('trigger'),
freq: pick('freq'),
timezone: pick('timezone'),
runClass: pick('runClass'),
@@ -279,6 +357,10 @@ export function serverFieldValue(field: string, f: ScheduleForm): string | null
case 'name': return f.name.trim()
case 'workflow_id': return f.workflowId
case 'cron': return cronOf(f.freq)
// 触发条件连带目标一起算:查环时换了目标,「会循环触发」也就不一定成立了
case 'trigger':
case 'debounce_s': return JSON.stringify([triggerBody(f.trigger), f.workflowId])
case 'trigger_kind': return f.triggerKind
case 'timezone': return f.timezone
case 'run_class': return JSON.stringify([f.runClass, f.workflowId])
case 'input_template': return JSON.stringify([sortedEntries(templateOf(f.inputs)), f.workflowId, f.runClass])
@@ -322,7 +404,8 @@ export function inputProblems(inputs: Record<string, string>, fields: readonly I
// 表单的问题
// ---------------------------------------------------------------------------
export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events'
export type FormField = 'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret'
| 'events'
/** 判表单问题时要的、表单之外的事 */
export interface FormContext {
@@ -345,8 +428,13 @@ export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormC
const out: Partial<Record<FormField, string>> = {}
if (!f.name.trim()) out.name = T.problem.name
if (!f.workflowId) out.workflow = T.problem.workflow
const freq = freqProblem(f.freq)
if (freq) out.freq = freq
if (f.triggerKind === 'event') {
// 事件触发没有频率:对象要选上(能不能选、会不会成环由服务端判,保存时 422 落到这一项)
if (!f.trigger.subjectId) out.trigger = TT.problemSubject(TT.subject[f.trigger.event])
} else {
const freq = freqProblem(f.freq)
if (freq) out.freq = freq
}
if (!f.timezone) out.timezone = T.problem.timezone
if (f.workflowId && (ctx.recheck ?? !saved)) {
if (f.runClass === 'formal' && ctx.published === null) out.runClass = T.problem.notPublished
+49 -6
View File
@@ -6,6 +6,7 @@ import { ApiError, api } from '../../api/client'
import { StatusPill, confirmDialog, toast } from '../../components/ui'
import { formatDateTime, parseServerTime } from '../../lib/format'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types'
import { browserTimezone, formatUntil, formatZoned, maintenanceSummary } from './model'
@@ -71,14 +72,44 @@ export function ZonedTime({ date, tz, now, relative = false, className }: {
)
}
/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */
/**
* 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜。事件触发的任务没有「到点」:写它现在在等什么——去抖中(几次事件、
* 什么时候合成一次触发)、有事件等待派发(调度没开时说不会派发)、等待事件(服务端 trigger_state)
*/
export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) {
if (!schedule.enabled) return <span className="text-faint">{T.nextPaused}</span>
if (schedule.trigger_kind === 'event') return <EventWait schedule={schedule} now={now} />
const d = parseServerTime(schedule.next_run_at)
if (!d) return <span className="text-faint">{T.nextNone}</span>
return <ZonedTime date={d} tz={schedule.timezone} now={now} relative className="text-dim" />
}
function EventWait({ schedule, now }: { schedule: Schedule; now: number }) {
const st = schedule.trigger_state
const due = parseServerTime(st?.due_at ?? null)
if (st && st.waiting > 0 && due) {
const when = formatZoned(due, schedule.timezone, 'short', new Date(now))
return <span className="text-dim" data-schedule-event-wait="waiting">{TT.waiting(st.waiting, when)}</span>
}
if (st && st.events > 0) {
return (
<span className={st.scheduler_on ? 'text-dim' : 'text-[var(--warn)]'} data-schedule-event-wait="pending">
{st.scheduler_on ? TT.pending(st.events) : TT.pendingOff(st.events)}
</span>
)
}
return (
<span className="text-faint" data-schedule-event-wait="idle" title={st && !st.scheduler_on ? TT.schedulerOff : undefined}>
{TT.nextEvent}
</span>
)
}
/** 列表、详情页头里「事件触发」的小标签:和运行类别的标签并排 */
export function EventChip() {
return <span className="chip shrink-0" title={TT.chipTitle} data-schedule-trigger-kind="event">{TT.chip}</span>
}
// -------------------------------------------------------------------------
// 触发
// -------------------------------------------------------------------------
@@ -349,13 +380,24 @@ export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: s
*
* 前面带和「定时」标记一样的日历图标:页头这一行都是灰字,不带记号看不出这一段能点。页头窄时这一行换行,链接至少留
* 几个字宽(以前这一行不换行,只有链接能缩,窄窗口里被挤成 0 宽,Tab 停在一个看不见的链接上),再长截断,全名在悬停里。
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样。
*
* 由数据事件触发的(运行来源里的 trigger,发起时写进、在封存范围内):整句写「由事件触发:<事件>(定时任务「X」)」,
* 事件的说法是服务端发起时写下的那一句,不在这里拼
*/
export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: string; startedBy?: string | null }) {
export function RunScheduleSource({ scheduleId, startedBy, trigger }: {
scheduleId: string; startedBy?: string | null
/** 运行来源里的 trigger(服务端 outbox.EventFire.origin);不是事件触发的为空 */
trigger?: { text?: unknown; sample?: unknown } | null
}) {
const names = useScheduleNames([scheduleId])
const recorded = startedBy?.startsWith(T.actorPrefix) ? startedBy.slice(T.actorPrefix.length).trim() : null
const auto = !startedBy || recorded != null
const name = names.get(scheduleId) || recorded || null
const eventText = typeof trigger?.text === 'string' && trigger.text ? trigger.text : null
const sample = !!eventText && trigger?.sample === true
const said = (gone: boolean) => (sample ? TT.runSourceSample(eventText, name) : gone ? TT.runSourceGone(eventText!, name)
: TT.runSource(eventText!, name))
const body = (text: string) => (
<>
<CalendarClock size={10} aria-hidden className="shrink-0" />
@@ -365,16 +407,17 @@ export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: strin
const source = scheduleGone(scheduleId) ? (
<span title={T.runSourceGoneTitle} data-run-schedule-gone={scheduleId}
className="inline-flex min-w-[7em] max-w-full items-center gap-1">
{body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
{body(eventText ? said(true) : auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
</span>
) : (
<Link
to={`/schedules/${encodeURIComponent(scheduleId)}`}
title={T.runSourceTitle(name)}
title={eventText ? said(false) : T.runSourceTitle(name)}
data-run-schedule-link={scheduleId}
data-run-schedule-event={eventText ? '' : undefined}
className="inline-flex min-w-[7em] max-w-full items-center gap-1 rounded-sm text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
{body(auto ? T.runSource(name) : T.runSourceManual(name))}
{body(eventText ? said(false) : auto ? T.runSource(name) : T.runSourceManual(name))}
</Link>
)
if (auto || !startedBy) return source
+108 -2
View File
@@ -3880,8 +3880,8 @@ export type ScheduleRunClass = 'formal' | 'exploratory'
export type ScheduleWebhookFormat = 'generic' | 'feishu' | 'wecom' | 'dingtalk'
/** 订阅的事件:运行结束、运行失败、停在人工审批上(只在真有待审批时发) */
export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval'
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发 */
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire'
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发、由数据事件触发(trigger_kind=event 的任务) */
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire' | 'event'
/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */
export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start'
/**
@@ -4074,6 +4074,112 @@ export interface ScheduleRunResult {
run_id: string | null
}
// ---------------------------------------------------------------------------
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4;服务端 scheduler/triggers.py、outbox.py):一个
// trigger_kind=event 的定时任务,目标只能是工作流。同名接口会合并,这里只往上面几个类型上补字段
// ---------------------------------------------------------------------------
/** 触发方式:按 cron 到点、由数据事件触发。老服务端不给,按 cron 看 */
export type ScheduleTriggerKind = 'cron' | 'event'
/** 事件种类:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化 */
export type ScheduleEventKind = 'table.version' | 'workflow.finished' | 'source.changed'
/** 触发条件(服务端规范化过的那一份)。对象键按事件种类:表格、数据源写 source_id,上游工作流写 workflow_id */
export interface ScheduleTrigger {
event: ScheduleEventKind
source_id?: string
workflow_id?: string
/** 只认新增的一期(不写即新增、替换都认),只给 table.version */
period?: 'new' | 'any'
/** 只认某个应用发起的、某种来源的运行,只给 workflow.finished */
app_id?: string
origin_kind?: string
/**
* 也认途经驳回的(上游走了某个审批的驳回出口、按驳回那条路跑完,状态仍是成功)、也认应用补算的;缺省都不认。只给
* workflow.finished,不写即 false
*/
include_rejected?: boolean
include_backfill?: boolean
/** 去抖:同一个任务这么多秒内的几次事件合成一次 */
debounce_s: number
/** 保存时记下的对象名(对象删掉之后界面写它);读出来才有,交上去不用带 */
subject_name?: string
}
/** 事件触发任务现在有几条事件在等:没派发的(调度关着时一直留着)、在去抖窗口里的、窗口结束的时刻、调度开没开 */
export interface ScheduleTriggerState {
events: number
waiting: number
due_at: string | null
scheduler_on: boolean
}
export interface Schedule {
trigger_kind?: ScheduleTriggerKind
/** 事件触发时的触发条件;cron 任务为 null */
trigger?: ScheduleTrigger | null
/** 给人看的触发说明(事件触发时同 description_text);cron 任务为 null */
trigger_text?: string | null
trigger_state?: ScheduleTriggerState | null
}
/** 事件触发的那一次触发带的事件说明(服务端 outbox.EventFire.brief,不含出口字段的值) */
export interface ScheduleFireEvent {
event: ScheduleEventKind
subject_id: string
subject_name: string
/** 一句说法:「上传表格「门店销售」出了新的一期(2026-09)」 */
text: string
/** 去抖合并了几次事件 */
merged: number
first_at: string | null
last_at: string | null
/** 这次运行在事件触发链上是第几层 */
depth: number
/** 立即运行时按示例事件发起的(不是真来了一次事件);real 为假时是示例值 */
sample: boolean
real: boolean
/** 几个过了补发宽限的去抖窗口合成一条跳过时才有:合了几个窗口、合掉的期次(上传表格);老服务端不给 */
windows?: number | null
periods?: string[] | null
}
export interface ScheduleFire {
/** 事件触发的那几次的事件说明;别的为 null,老服务端不给 */
event?: ScheduleFireEvent | null
}
export interface ScheduleCreate {
/** 不写即 cron(字段和以前一字不差) */
trigger_kind?: ScheduleTriggerKind
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
}
export interface SchedulePreviewRequest {
trigger_kind?: ScheduleTriggerKind
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
}
export interface SchedulePreview {
/** 事件触发时:按哪一次事件渲染的(real 为假时是示例值,对象还没发生过这种事件) */
event?: { text: string; real: boolean } | null
}
/** GET /api/schedules/trigger-options:每种事件能选的对象(ok 为假时不能选,reason 写为什么,由服务端判)、来源、应用 */
export interface ScheduleTriggerOptions {
events: {
event: ScheduleEventKind
label: string
subject_label: string
objects: { id: string; name: string; ok: boolean; reason: string | null }[]
}[]
origins: { kind: string; label: string }[]
apps: { id: string; name: string }[]
default_debounce_s: number
max_debounce_s: number
max_depth: number
}
/** GET /api/schedules/{id}/fires 的一页,新的在前;next_before 是下一页的游标,没有更多时为 null */
export interface ScheduleFirePage {
items: ScheduleFire[]
+340 -2
View File
@@ -13,6 +13,12 @@
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
//
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」,事件触发的没有频率、换成事件那一节
// (事件种类、对象、期次 / 应用 / 来源、去抖、时区),不能选的对象照服务端给的原因标出来、选不了;换事件种类时对象清掉;变量
// 按事件种类取({{ event.* }});预览按一次事件渲染输入、不列触发时间;请求体带 trigger_kind、trigger、cron 交空串。列表、
// 详情写触发说明和在等的事件(调度没开时照实说不会派发),修改时只读地写出触发方式、只交改了的触发条件;触发历史写「由事件
// 触发:…」和合并了几次、第几层、立即运行的示例;运行详情页头写「由事件触发:…(定时任务「X」)」。
//
// 浏览器的本机时区故意设成 America/Los_Angeles,和定时任务的时区(Asia/Shanghai、America/New_York)都不同:
// 两者相同时「按定时任务时区写」和「按本机写」在页面上看不出区别,时间相关的断言一律比对按定时任务时区写出的原文。
//
@@ -305,6 +311,56 @@ await section('纯函数:服务端会拒的组合、保存回来怎么接、
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
})
await section('纯函数:事件触发的表单 ⇄ 请求体', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const fresh = m.formOf(null, 'Asia/Shanghai')
check('新建缺省按 cron 触发,事件那一份缺省是上传表格、去抖 1 分钟', fresh.triggerKind === 'cron'
&& fresh.trigger.event === 'table.version' && fresh.trigger.debounceS === 60 && fresh.trigger.subjectId === '')
check('cron 任务的请求体一字不差:不带触发方式、触发条件', !('trigger_kind' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' }))
&& !('trigger' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' })))
const ev = { ...fresh, name: ' 门店月报 ', workflowId: 'wf-weekly', triggerKind: 'event',
trigger: { ...fresh.trigger, subjectId: 'ds-sales', period: 'new', appId: 'app-x', originKind: 'hook', debounceS: 300 },
inputs: { date: '{{ event.period }}' } }
const body = m.createBody(ev)
check('事件触发的请求体:trigger_kind、对象按种类写 source_id、只带这种事件的过滤、cron 交空串',
body.trigger_kind === 'event' && JSON.stringify(body.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300,
source_id: 'ds-sales', period: 'new' }) && body.cron === '' && body.name === '门店月报'
&& JSON.stringify(body.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
const wfForm = { event: 'workflow.finished', subjectId: 'wf-daily', period: 'new', appId: 'app-board', originKind: '',
includeRejected: false, includeBackfill: false, debounceS: 0 }
const wf = m.triggerBody(wfForm)
check('上游工作流:写 workflow_id,带应用、不带期次和空的来源,缺省不带「也认」两项', JSON.stringify(wf) === JSON.stringify({
event: 'workflow.finished', debounce_s: 0, workflow_id: 'wf-daily', app_id: 'app-board' }), JSON.stringify(wf))
const wide = m.triggerBody({ ...wfForm, includeRejected: true, includeBackfill: true })
check('勾了也认途经驳回的、应用补算的:各交 true', wide.include_rejected === true && wide.include_backfill === true, JSON.stringify(wide))
const onlyBackfill = m.triggerBody({ ...wfForm, originKind: 'app_backfill', includeBackfill: true })
check('只认应用补算的:不再另交「也认应用补算的」', onlyBackfill.origin_kind === 'app_backfill' && !('include_backfill' in onlyBackfill),
JSON.stringify(onlyBackfill))
check('表格的触发条件不带这两项', !('include_rejected' in m.triggerBody({ ...wfForm, event: 'table.version', includeRejected: true })))
const switched = m.switchEvent({ ...ev.trigger, includeRejected: true }, 'source.changed')
check('换事件种类:对象、过滤清掉,去抖留着', switched.subjectId === '' && switched.period === 'any' && switched.appId === ''
&& switched.originKind === '' && switched.includeRejected === false && switched.debounceS === 300 && m.switchEvent(ev.trigger, 'table.version') === ev.trigger)
check('问题:事件触发没选对象时说要选什么,不查频率', m.formProblems({ ...ev, trigger: switched, freq: { ...m.DEFAULT_FREQ, preset: 'weekly',
weekdays: [] } }, null).trigger === '请选择数据源' && !m.formProblems({ ...ev, freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] } },
null).freq)
// 夹具里的定时任务在后面才定义(这一段在顶层先跑),这里另写一份
const saved = { id: 's-event', name: '门店月报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '', timezone: 'Asia/Shanghai',
run_class: 'formal', input_template: { date: '{{ event.period }}' }, enabled: true, overlap: 'skip', misfire_grace_s: 3600,
webhook: null, next_run_at: null, last_fire: null, description_text: '', created_by: null, created_at: '', updated_at: '', version: 2,
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' } }
const base = m.formOf(saved)
check('读出来的表单:触发方式、对象、期次、去抖', base.triggerKind === 'event' && base.trigger.subjectId === 'ds-sales'
&& base.trigger.period === 'new' && base.trigger.debounceS === 300)
check('什么都没改:不发请求(不因为 cron 是空串算成改了频率)', m.patchBody(base, base, saved) === null)
const patch = m.patchBody({ ...base, trigger: { ...base.trigger, debounceS: 900 } }, base, saved)
check('只改去抖:只交触发条件(整份)', JSON.stringify(patch) === JSON.stringify({ trigger: { event: 'table.version', debounce_s: 900,
source_id: 'ds-sales', period: 'new' } }), JSON.stringify(patch))
check('服务端指 trigger 时:换了对象或目标就不再摆着那句', m.serverFieldValue('trigger', base)
!== m.serverFieldValue('trigger', { ...base, trigger: { ...base.trigger, subjectId: 'ds-other' } })
&& m.serverFieldValue('trigger', base) !== m.serverFieldValue('trigger', { ...base, workflowId: 'wf-stock' })
&& m.serverFieldValue('trigger', base) === m.serverFieldValue('trigger', { ...base, name: '别的名字' }))
})
/** 对比页写的默认基准来历(定时任务发起的):只挑比本次先发起、成功结束并封存的运行,服务端 api/compare.py 的 pick_base */
const REASON_SCHEDULE = '默认基准:同一定时任务上一次成功的运行'
@@ -559,6 +615,98 @@ const SUITES = [
{ id: 'suite-stock', name: '库存问法', description: '', archived: false },
]
// ---------------------------------------------------------------- 数据事件触发的夹具(orchestration-round §4)
/** 能选的对象(服务端 GET /api/schedules/trigger-options 的形状):不能选的带原因,界面照它画、不自己判 */
const ROSTER_REASON = '每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积'
const TRIGGER_OPTIONS = {
events: [
{ event: 'table.version', label: '上传表格出了新的一期', subject_label: '上传表格', objects: [
{ id: 'ds-sales', name: '门店销售', ok: true, reason: null },
{ id: 'ds-roster', name: '门店名单', ok: false, reason: ROSTER_REASON },
] },
{ event: 'workflow.finished', label: '上游工作流运行成功结束', subject_label: '工作流', objects: [
{ id: 'wf-daily', name: '门店日报', ok: true, reason: null },
{ id: 'wf-stock', name: '库存日报', ok: false, reason: '还没有发布版本:只有正式运行成功结束才算事件' },
] },
{ event: 'source.changed', label: '数据源有要处理的变化', subject_label: '数据源', objects: [
{ id: 'ds-shop', name: '门店库', ok: true, reason: null },
] },
],
origins: [{ kind: 'launcher', label: '手动正式运行' }, { kind: 'schedule', label: '定时任务' },
{ kind: 'app_backfill', label: '应用补算' }, { kind: 'hook', label: '入站钩子' }],
apps: [{ id: 'app-board', name: '门店看板' }],
default_debounce_s: 60, max_debounce_s: 86400, max_depth: 5,
}
/** 事件触发的变量清单(服务端按事件种类给:去掉上一次计划触发时间,接上 event.*) */
const EVENT_VARIABLES = {
'table.version': [
VARIABLES[0],
{ name: 'event.table', label: '上传表格的名字', example: '门店销售', note: '' },
{ name: 'event.period', label: '这一期(整月写 YYYY-MM,整周写 YYYY-Www,单日写 YYYY-MM-DD)', example: '2026-09', note: '' },
{ name: 'event.period_end', label: '这一期的最后一天(YYYY-MM-DD)', example: '2026-09-30', note: '' },
],
'workflow.finished': [
VARIABLES[0],
{ name: 'event.run_id', label: '上游那次运行的编号', example: 'run…', note: '' },
{ name: 'event.outputs.<字段名>', label: '上游运行的出口字段(写 event.outputs.<字段名>)', example: '{…}', note: '' },
],
'source.changed': [VARIABLES[0], { name: 'event.counts.new', label: '变化的条数', example: '{…}', note: '' }],
}
const SALES_EVENT = '上传表格「门店销售」出了新的一期(2026-09)'
const SALES_TRIGGER = '上传表格「门店销售」出了新的一期时触发(只认新增的一期;去抖 5 分钟)'
const SHOP_TRIGGER = '数据源「门店库」出现要处理的变化时触发(去抖 1 分钟)'
const EVENT_BRIEF = { event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, merged: 2,
first_at: iso(NOW - 25 * H), last_at: iso(NOW - 24 * H - 10 * 60e3), depth: 1, sample: false, real: true }
/** 事件触发的定时任务:去抖中(2 次事件在等),触发历史有事件、立即运行的示例、上一次没结束跳过的 */
const S_EVENT = {
...S1, id: 's-event', name: '门店月报 · 新一期', cron: '', next_run_at: null, webhook: null,
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' },
trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER, input_template: { date: '{{ event.period_end }}' },
trigger_state: { events: 0, waiting: 2, due_at: iso(NOW + 4 * 60e3), scheduler_on: true },
last_fire: fire('fe1', { kind: 'event', event: EVENT_BRIEF }), version: 2,
}
/** 调度没开:3 条事件留着、不会派发 */
const S_EVENT_OFF = {
...S_EVENT, id: 's-event-off', name: '变化处理', trigger: { event: 'source.changed', source_id: 'ds-shop', debounce_s: 60, subject_name: '门店库' },
trigger_text: SHOP_TRIGGER, description_text: SHOP_TRIGGER, trigger_state: { events: 3, waiting: 0, due_at: null, scheduler_on: false },
last_fire: null, version: 1,
}
function eventFires() {
const none = { run_id: null, run_status: null }
return [
fire('fe1', { kind: 'event', event: EVENT_BRIEF }),
fire('fe2', { ...none, kind: 'manual', status: 'failed_to_start', fire_at: iso(NOW - 30 * H), created_at: iso(NOW - 30 * H),
reason: '还没有发生过这种事件,输入模板里的 {{ event.* }} 没有可用的值,这次立即运行没有发起',
event: { ...EVENT_BRIEF, merged: 0, sample: true, real: false } }),
fire('fe3', { ...none, kind: 'event', status: 'skipped_overlap', fire_at: iso(NOW - 48 * H), created_at: iso(NOW - 48 * H),
reason: '上一次运行 #a1b2c3 仍在进行中,本次触发已跳过', event: { ...EVENT_BRIEF, merged: 1, depth: 2 } }),
// 调度停过:三期的事件都过了补发宽限,合成一条跳过(几个窗口、合掉的期次照实写)
fire('fe4', { ...none, kind: 'event', status: 'skipped_misfire', fire_at: iso(NOW - 72 * H), created_at: iso(NOW - 70 * H),
reason: '2026-10-14 09:00 至 2026-10-14 09:20 的 3 次事件(3 个去抖窗口):服务当时没有派发(调度未运行或已停止),已超出补发宽限(1 小时),没有发起;积压的事件不补',
event: { ...EVENT_BRIEF, merged: 3, windows: 3, periods: ['2026-07', '2026-08', '2026-09'] } }),
]
}
/** 由事件触发的那次运行:来源里记着 trigger(服务端发起时写下的那一句) */
const RUN_EVENT = { ...RUNS[0], id: 'run-event-1', schedule_id: 's-event', started_by: '定时任务:门店月报 · 新一期',
origin_kind: 'schedule', origin: { kind: 'schedule', schedule_id: 's-event', fire_kind: 'event',
trigger: { kind: 'event', event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, depth: 1 } } }
/** 有人署名点了立即运行:按最近一次事件发起(sample),不是事件触发的 */
const RUN_SAMPLE = { ...RUN_EVENT, id: 'run-event-2', started_by: '检查员甲',
origin: { ...RUN_EVENT.origin, fire_kind: 'manual', trigger: { ...RUN_EVENT.origin.trigger, sample: true, real: true } } }
/** 事件触发的预览:按一次事件渲染输入,不列触发时间;不合用的对象回 422(field=trigger) */
function eventPreviewReply(body) {
const t = body?.trigger ?? {}
if (t.source_id === 'ds-roster') return { status: 422, json: { detail: '「门店名单」不是按期累积的上传表格', field: 'trigger' } }
const tpl = body?.input_template ?? {}
const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v)
.replace(/\{\{\s*event\.period\s*\}\}/g, '2026-09').replace(/\{\{\s*event\.period_end\s*\}\}/g, '2026-09-30')]))
const desc = `上传表格「门店销售」出了新的一期时触发(${t.period === 'new' ? '只认新增的一期;' : ''}去抖 ${t.debounce_s === 300 ? '5 分钟' : '1 分钟'})`
return { json: { description_text: desc, next: [], inputs, event: { text: SALES_EVENT, real: true } } }
}
function freshState(patch = {}) {
return {
schedules: [structuredClone(S1), structuredClone(S2)],
@@ -598,7 +746,7 @@ const WEBHOOK_TEST_OK = { ok: true, http_status: 200, message: '请到接收端
/** 照约定改假状态:PATCH 核对版本、按地址省略 / 空串改 Webhook */
function applyPatch(s, body) {
const out = { ...s }
for (const k of ['name', 'workflow_id', 'cron', 'timezone', 'run_class', 'input_template', 'enabled', 'misfire_grace_s']) {
for (const k of ['name', 'workflow_id', 'cron', 'timezone', 'run_class', 'input_template', 'enabled', 'misfire_grace_s', 'trigger']) {
if (k in body) out[k] = body[k]
}
if ('webhook' in body) {
@@ -614,7 +762,7 @@ function applyPatch(s, body) {
}
out.version = s.version + 1
out.updated_at = iso(Date.now())
if (!out.enabled) out.next_run_at = null
if (!out.enabled || s.trigger_kind === 'event') out.next_run_at = null
else if (!s.enabled) out.next_run_at = iso(NOW + 3 * H + 5 * 60e3)
return out
}
@@ -635,7 +783,19 @@ async function reply(state, method, path, url, body) {
}
const m = path.match(/^\/schedules\/([^/]+)(\/.*)?$/)
if (method === 'GET' && path === '/schedules') return { json: { items: state.schedules } }
if (method === 'POST' && path === '/schedules/preview' && body?.trigger_kind === 'event') return eventPreviewReply(body)
if (method === 'POST' && path === '/schedules/preview') return previewReply(body, state)
if (method === 'GET' && path === '/schedules/trigger-options') return { json: TRIGGER_OPTIONS }
if (method === 'GET' && path === '/schedules/variables' && url.searchParams.get('trigger_kind') === 'event') {
return { json: EVENT_VARIABLES[url.searchParams.get('event')] ?? [] }
}
if (method === 'POST' && path === '/schedules' && body?.trigger_kind === 'event') {
const s = { ...S_EVENT, ...body, id: 's-new-event', workflow_name: WORKFLOWS.find((w) => w.id === body.workflow_id)?.name ?? '',
trigger: { ...body.trigger, subject_name: '门店销售' }, trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER,
trigger_state: { events: 0, waiting: 0, due_at: null, scheduler_on: true }, last_fire: null, version: 1, webhook: null }
state.schedules = [s, ...state.schedules]
return { status: 201, json: s }
}
if (method === 'GET' && path === '/schedules/variables') return { json: VARIABLES }
if (method === 'POST' && path === '/schedules' && body?.target_kind && body.target_kind !== 'workflow') {
const suite = SUITES.find((x) => x.id === body.target_id)
@@ -2116,6 +2276,184 @@ await section('记录页:定时标记', async () => {
check('没有页面报错', !errors.length, errors.join(' | '))
})
// ---------------------------------------------------------------- 数据事件触发(orchestration-round §4)
await section('事件触发:新建的请求体', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
const picker = page.locator('[data-workflow-picker] input')
await picker.click()
await picker.fill('周报')
await page.locator('[data-workflow-option="wf-weekly"]').click()
await page.locator('[data-field="name"]').fill('门店月报 · 新一期')
check('新建时有「定时 / 事件触发」两种,缺省定时', await page.locator('[data-trigger-mode]').count() === 2
&& await page.locator('[data-trigger-mode="cron"]').getAttribute('aria-checked') === 'true'
&& await page.locator('[data-freq-preset]').count() > 0)
await page.locator('[data-trigger-mode="event"]').click()
await page.locator('[data-trigger-section]').waitFor()
check('选了事件触发:频率那一节换成事件,三种事件、缺省上传表格', await page.locator('[data-freq-preset]').count() === 0
&& await page.locator('[data-trigger-event]').count() === 3
&& await page.locator('[data-trigger-event="table.version"]').getAttribute('aria-checked') === 'true')
const subject = page.locator('[data-trigger-subject="table.version"]')
await subject.waitFor()
const roster = subject.locator('option[value="ds-roster"]')
check('不能选的对象照服务端的原因标出来、选不了', await roster.isDisabled()
&& (await roster.innerText()).includes('没有「新的一期」'), await roster.innerText())
check('还没选对象:保存按钮禁用,说还需处理触发事件', await page.locator('[data-schedule-save]').isDisabled()
&& (await text(page, '[data-schedule-hint]')).includes('触发事件'), await text(page, '[data-schedule-hint]').catch(() => ''))
await subject.selectOption('ds-sales')
// 换事件种类:对象清掉,换成上游工作流的对象和过滤
await page.locator('[data-trigger-event="workflow.finished"]').click()
await page.locator('[data-trigger-subject="workflow.finished"]').waitFor()
check('换成上游工作流:对象清掉,多出只认应用、只认来源和触发链的说明',
await page.locator('[data-trigger-subject="workflow.finished"]').inputValue() === ''
&& await page.locator('[data-trigger-app]').count() === 1 && await page.locator('[data-trigger-origin]').count() === 1
&& (await text(page, '[data-trigger-chain]')).includes('最多 5 层'), await text(page, '[data-trigger-chain]').catch(() => ''))
await page.locator('[data-trigger-event="table.version"]').click()
await page.locator('[data-trigger-subject="table.version"]').selectOption('ds-sales')
await page.locator('[data-trigger-period]').selectOption('new')
await page.locator('[data-trigger-debounce]').selectOption('300')
await page.locator('[data-timezone]').selectOption('Asia/Shanghai')
check('事件触发时「错过触发时间」说的是派发晚了', (await text(page, '[data-schedule-form]')).includes('事件在宽限内派发照常发起'))
// 变量按事件种类取:event.period 在、上一次计划触发时间不在
await page.locator('[data-input-field="date"]').waitFor()
await page.locator('[data-input-vars="date"]').click()
await page.locator('[data-input-field="date"] [data-var="event.period"]').waitFor()
check('变量清单按事件种类取:有 event.period', await page.locator('[data-input-field="date"] [data-var="event.table"]').count() === 1)
check('……请求带 trigger_kind=event、event=table.version', sent.some((x) => x.key === 'GET /schedules/variables'
&& x.search.includes('trigger_kind=event') && x.search.includes('event=table.version')), sent.filter((x) => x.key.includes('variables'))
.map((x) => x.search).join(' | '))
await page.locator('[data-input-field="date"] [data-var="event.period"]').click()
check('点变量插入 {{ event.period }}', await page.locator('[data-input-field="date"] input').inputValue() === '{{ event.period }}')
const preview = await until(async () => (await page.locator('[data-schedule-preview][data-status="ok"][data-preview-mode="event"]').count())
&& (await page.locator('[data-preview-input="date"]').innerText().catch(() => '')).includes('2026-09'))
check('预览按一次事件渲染输入,不列触发时间', !!preview && await page.locator('[data-preview-time]').count() === 0
&& (await text(page, '[data-preview-event="real"]')).includes(SALES_EVENT)
&& (await text(page, '[data-preview-desc]')).includes('只认新增的一期'), await text(page, '[data-schedule-preview]').catch(() => ''))
const pv = writes(sent, /^POST \/schedules\/preview$/).at(-1)?.body
check('预览的请求体:trigger_kind、触发条件、cron 空串', pv?.trigger_kind === 'event' && pv?.cron === '' && pv?.trigger?.source_id === 'ds-sales'
&& !('n' in (pv ?? {})), JSON.stringify(pv))
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
const body = writes(sent, /^POST \/schedules$/)[0]?.body
check('POST 请求体:trigger_kind=event、触发条件、cron 空串', body?.trigger_kind === 'event' && body?.cron === ''
&& JSON.stringify(body?.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300, source_id: 'ds-sales', period: 'new' }),
JSON.stringify(body))
check('POST 请求体:工作流、名称、时区、输入模板', body?.workflow_id === 'wf-weekly' && body?.name === '门店月报 · 新一期'
&& body?.timezone === 'Asia/Shanghai' && JSON.stringify(body?.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
check('创建后进详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new-event'))
await page.locator('[data-trigger-mode-fixed="event"]').waitFor()
check('详情:触发方式只读,写明建好之后不能改', await page.locator('[data-trigger-modes]').count() === 0
&& (await text(page, '[data-trigger-mode-fixed]')).includes('建好之后不能改'))
await shot(page, 'event-new-dark')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('事件触发:上游工作流的请求体(途经驳回、应用补算)', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
await page.locator('[data-workflow-picker] input').click()
await page.locator('[data-workflow-option="wf-weekly"]').click()
await page.locator('[data-trigger-mode="event"]').click()
await page.locator('[data-trigger-event="workflow.finished"]').click()
await page.locator('[data-trigger-subject="workflow.finished"]').selectOption('wf-daily')
const rej = page.locator('[data-trigger-include-rejected]')
const back = page.locator('[data-trigger-include-backfill]')
check('上游工作流:有「也认途经驳回的」「也认应用补算的」,缺省都不勾', await rej.count() === 1 && await back.count() === 1
&& !(await rej.isChecked()) && !(await back.isChecked()))
check('……说明照实写不勾时这类运行不触发', (await text(page, '[data-trigger-section]')).includes('不勾时这类运行不触发'))
await page.locator('[data-trigger-origin]').selectOption('app_backfill')
check('……只认应用补算时「也认应用补算的」不再出现', await back.count() === 0)
await page.locator('[data-trigger-origin]').selectOption('')
await rej.check()
await page.locator('[data-input-field="date"] input').fill('{{ event.rejected }}')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
const body = writes(sent, /^POST \/schedules$/)[0]?.body
check('POST 请求体:勾了的「也认途经驳回的」交 true,没勾的不交', JSON.stringify(body?.trigger) === JSON.stringify({
event: 'workflow.finished', debounce_s: 60, workflow_id: 'wf-daily', include_rejected: true }), JSON.stringify(body?.trigger))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('事件触发:列表、详情与触发历史', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT), structuredClone(S_EVENT_OFF)], fires: eventFires() })
const list = await open('/schedules', { state })
await list.page.locator('[data-schedule-row="s-event"]').waitFor()
const row = list.page.locator('[data-schedule-row="s-event"]')
check('列表:事件触发的标「事件触发」,cron 的不标', await row.locator('[data-schedule-trigger-kind="event"]').count() === 1
&& await list.page.locator('[data-schedule-row="s-weekly"] [data-schedule-trigger-kind="event"]').count() === 0)
check('列表:频率那里写触发说明', (await row.locator('[data-schedule-desc]').innerText()).includes(SALES_TRIGGER))
const wait = await row.locator('[data-schedule-event-wait="waiting"]').innerText()
check('列表:去抖中写收到几次、什么时候合成一次触发', wait.includes('已收到 2 次事件') && wait.includes('合成一次触发'), wait)
const off = await list.page.locator('[data-schedule-row="s-event-off"] [data-schedule-event-wait="pending"]').innerText()
check('列表:调度没开时照实说事件在等、不会派发', off.includes('3 条事件') && off.includes('调度未开启'), off)
check('列表:上次触发链到那次运行', await row.locator('[data-schedule-last] a[href="/runs/run-fe1"]').count() === 1)
const { page, sent, errors } = await open('/schedules/s-event', { state })
await page.locator('[data-trigger-section]').waitFor()
check('详情:页头副标题是触发说明', (await page.locator('[data-schedules-page] header').innerText()).includes(SALES_TRIGGER))
check('详情:读出来的对象、期次、去抖', await page.locator('[data-trigger-subject="table.version"]').inputValue() === 'ds-sales'
&& await page.locator('[data-trigger-period]').inputValue() === 'new' && await page.locator('[data-trigger-debounce]').inputValue() === '300')
check('详情:没有频率、只读的触发方式', await page.locator('[data-freq-preset]').count() === 0
&& await page.locator('[data-trigger-mode-fixed="event"]').count() === 1)
await page.locator('[data-trigger-debounce]').selectOption('900')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH \/schedules\/s-event$/).length === 1)
const patch = writes(sent, /^PATCH \/schedules\/s-event$/)[0]?.body
check('只改去抖:PATCH 只带触发条件和版本,不带 cron', JSON.stringify(patch) === JSON.stringify({
trigger: { event: 'table.version', debounce_s: 900, source_id: 'ds-sales', period: 'new' }, version: 2 }), JSON.stringify(patch))
await saveDone(page)
await page.locator('#schedule-tab-history').click()
await page.locator('[data-fire-id="fe1"]').waitFor()
const fe1 = await text(page, '[data-fire-id="fe1"] [data-fire-event]')
check('触发历史:写「由事件触发:…」和合并了几次', fe1.includes(`由事件触发:${SALES_EVENT}`) && fe1.includes('合并了 2 次事件'), fe1)
check('……类型标「事件」', (await text(page, '[data-fire-id="fe1"] [data-fire-kind-chip]')).includes('事件'))
const fe2 = await text(page, '[data-fire-id="fe2"] [data-fire-event]')
check('……立即运行按示例值、没发起的照实写:写「按事件」,不说成由事件触发', fe2.includes(`按事件:${SALES_EVENT}`)
&& fe2.includes('立即运行,事件为示例值') && !fe2.includes('由事件触发')
&& (await text(page, '[data-fire-id="fe2"]')).includes('还没有发生过这种事件'), fe2)
check('……上一次没结束跳过的写第几层', (await text(page, '[data-fire-id="fe3"] [data-fire-event]')).includes('触发链第 2 层'))
const fe4 = await text(page, '[data-fire-id="fe4"] [data-fire-event]')
check('……过了宽限合成一条跳过的:写合并了几次、几个窗口、合掉的期次', fe4.includes('合并了 3 次事件') && fe4.includes('3 个去抖窗口')
&& fe4.includes('期次 2026-07、2026-08、2026-09'), fe4)
await shot(page, 'event-history-dark')
// 390px 窄屏、浅色:事件那一节(三种事件的按钮、对象下拉里带原因的长名字)不撑出横向滚动
const narrow = await open('/schedules/s-event', { state, width: 390, height: 844, theme: 'light' })
await narrow.page.locator('[data-trigger-section]').waitFor()
await narrow.page.locator('[data-trigger-event="workflow.finished"]').click()
await narrow.page.locator('[data-trigger-app]').waitFor()
await sleep(300)
check('390px 浅色:事件那一节不横向溢出,对象、去抖、保存在屏幕里', await overflow(narrow.page) <= 1
&& await inViewX(narrow.page, '[data-trigger-subject]') && await inViewX(narrow.page, '[data-trigger-debounce]')
&& await inViewX(narrow.page, '[data-schedule-save]'), `${await overflow(narrow.page)}px`)
await shot(narrow.page, 'event-390-light')
const all = [...errors, ...list.errors, ...narrow.errors]
check('没有页面报错', !all.length, all.join(' | '))
})
await section('事件触发:运行详情页头写「由事件触发」', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT)], runs: [...RUNS, RUN_EVENT, RUN_SAMPLE] })
const { page, errors } = await open('/runs/run-event-1', { state })
await page.locator('[data-run-detail="run-event-1"]').waitFor({ timeout: 10000 })
const link = page.locator('[data-run-detail] header [data-run-schedule-link]')
const said = await until(async () => {
const t = (await link.innerText().catch(() => '')).trim()
return t.includes('门店月报 · 新一期') ? t : null
})
check('页头写「由事件触发:<事件>(定时任务「X」)」,是到那个定时任务的链接',
said === `由事件触发:${SALES_EVENT}(定时任务「门店月报 · 新一期」)` && await link.getAttribute('href') === '/schedules/s-event'
&& await link.getAttribute('data-run-schedule-event') === '', said ?? (await link.innerText().catch(() => '没有链接')))
const manual = await open('/runs/run-event-2', { state })
await manual.page.locator('[data-run-detail="run-event-2"]').waitFor({ timeout: 10000 })
const mHead = manual.page.locator('[data-run-detail] header').first()
await until(async () => (await mHead.locator('[data-run-schedule-link]').innerText().catch(() => '')).includes('门店月报 · 新一期'))
const mText = oneLine([await mHead.innerText()])
check('立即运行按最近一次事件发起的:署名照写,写「立即运行…按事件」,不说成由事件触发', mText.includes('检查员甲 发起')
&& mText.includes(`立即运行定时任务「门店月报 · 新一期」,按事件:${SALES_EVENT}`) && !mText.includes('由事件触发'), mText.slice(0, 200))
check('没有页面报错', ![...errors, ...manual.errors].length, [...errors, ...manual.errors].join(' | '))
})
// ---------------------------------------------------------------- 窄屏、浅色
await section('窄屏 390px 与浅色', async () => {