diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index a7d08b7f..fe66843e 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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} diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index c7b0ccb4..9e01e068 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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 "正式运行" diff --git a/backend/app/data/source_changes.py b/backend/app/data/source_changes.py index 8edf71af..5b625baf 100644 --- a/backend/app/data/source_changes.py +++ b/backend/app/data/source_changes.py @@ -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 diff --git a/backend/app/data/table_versions.py b/backend/app/data/table_versions.py index 3302344b..ccf212cf 100644 --- a/backend/app/data/table_versions.py +++ b/backend/app/data/table_versions.py @@ -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 只作显示:查询一律从快照解析 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index a400acb3..860819a1 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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 diff --git a/backend/app/db/models_triggers.py b/backend/app/db/models_triggers.py new file mode 100644 index 00000000..368eb36b --- /dev/null +++ b/backend/app/db/models_triggers.py @@ -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) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index e10d2d0a..35e67e5e 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -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) diff --git a/backend/app/scheduler/__init__.py b/backend/app/scheduler/__init__.py index 57b7c9d7..d6a48183 100644 --- a/backend/app/scheduler/__init__.py +++ b/backend/app/scheduler/__init__.py @@ -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、验收副本)不管从哪条路径起服务, diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index 38baf29e..59948cb6 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -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()): diff --git a/backend/app/scheduler/outbox.py b/backend/app/scheduler/outbox.py new file mode 100644 index 00000000..366051a5 --- /dev/null +++ b/backend/app/scheduler/outbox.py @@ -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} diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py index 94435419..4b0aa041 100644 --- a/backend/app/scheduler/service.py +++ b/backend/app/scheduler/service.py @@ -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 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明 diff --git a/backend/app/scheduler/triggers.py b/backend/app/scheduler/triggers.py new file mode 100644 index 00000000..1a667cf3 --- /dev/null +++ b/backend/app/scheduler/triggers.py @@ -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 diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index 23ea36c6..90dac23f 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -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: diff --git a/backend/tests/test_schedule_events.py b/backend/tests/test_schedule_events.py new file mode 100644 index 00000000..81bb719d --- /dev/null +++ b/backend/tests/test_schedule_events.py @@ -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} diff --git a/backend/tests/test_schedule_events_api.py b/backend/tests/test_schedule_events_api.py new file mode 100644 index 00000000..e07d7561 --- /dev/null +++ b/backend/tests/test_schedule_events_api.py @@ -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" diff --git a/backend/tests/test_schedule_events_review.py b/backend/tests/test_schedule_events_review.py new file mode 100644 index 00000000..14eb4dc7 --- /dev/null +++ b/backend/tests/test_schedule_events_review.py @@ -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 diff --git a/backend/tests/test_schedules_api.py b/backend/tests/test_schedules_api.py index e3d5f753..5113d775 100644 --- a/backend/tests/test_schedules_api.py +++ b/backend/tests/test_schedules_api.py @@ -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) diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index e5e505cc..d92e3015 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -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('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }), /** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */ variables: (opts?: RequestOptions) => get('/schedules/variables', opts), + /** + * 事件触发任务的模板里能用的变量:schedule.*(上一次计划触发时间除外)加上这种事件的 event.*(docs/design/orchestration-round + * §4)。老服务端不认这两个参数,照旧给 cron 的那一份 + */ + eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) => + get(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts), + /** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */ + triggerOptions: (opts?: RequestOptions) => get('/schedules/trigger-options', opts), /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ run: (id: string) => post(`${at(id)}/run`), /** 触发历史,新的在前。before 是上一页给的 next_before */ diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2bbf91d7..4c4d5056 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3995,11 +3995,13 @@ export const SCHEDULE_TEXT = { openRun: '查看运行', // ---- 触发 - fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record, + fireKind: { scheduled: '定时', manual: '手动', misfire: '补发', event: '事件' } as Record< + import('../types').ScheduleFireKind, string>, fireKindHint: { scheduled: '到点按计划触发', manual: '手动「立即运行」', misfire: '错过触发时间后,在补发宽限内恢复时补发的一次', + event: '由数据事件触发(去抖结束的时刻)', } as Record, /** * 错过、没有补发的(服务端记 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: { diff --git a/frontend/src/lib/termsTrigger.ts b/frontend/src/lib/termsTrigger.ts new file mode 100644 index 00000000..2a94ad32 --- /dev/null +++ b/frontend/src/lib/termsTrigger.ts @@ -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, + modeHint: { + cron: '按设定的频率到点发起运行', + event: '数据有变化时发起运行:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化', + } as Record, + /** 修改时:触发方式建好之后不能改 */ + modeFixed: (label: string) => `触发方式:${label}。建好之后不能改,要换请新建一个定时任务`, + + // ---- 事件那一节 + section: '触发事件', + eventLabel: '事件', + event: { + 'table.version': '上传表格出了新的一期', + 'workflow.finished': '上游工作流运行成功结束', + 'source.changed': '数据源有要处理的变化', + } as Record, + eventHint: { + 'table.version': '按期累积的上传表格导入了新的一期并启用时触发;每次上传替换整张表的表格没有这个事件', + 'workflow.finished': '选中的工作流有一次正式运行成功结束时触发;输入模板可以引用它的出口字段', + 'source.changed': '数据源的探查、剖析新记下「要处理」级的变化时触发;同一次探查或剖析产生的一批只触发一次', + } as Record, + subject: { 'table.version': '上传表格', 'workflow.finished': '上游工作流', 'source.changed': '数据源' } as Record< + ScheduleEventKind, string>, + subjectPlaceholder: (label: string) => `选择${label}`, + subjectEmpty: { + 'table.version': '还没有上传表格', + 'workflow.finished': '还没有工作流', + 'source.changed': '还没有数据源', + } as Record, + /** 不能选的对象:名字后面接服务端给的原因 */ + 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: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值', +} diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 18ee841e..f6e79d55 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -595,7 +595,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { · {/* 定时任务发起的:写成到那个定时任务的链接(键盘可达;记录页的「定时」标记只能鼠标点) */} - {run.schedule_id ? : ( + {run.schedule_id ? : ( {run.started_by || '未署名'} 发起 diff --git a/frontend/src/pages/schedules/FireHistory.tsx b/frontend/src/pages/schedules/FireHistory.tsx index d6f8a67e..480e18bc 100644 --- a/frontend/src/pages/schedules/FireHistory.tsx +++ b/frontend/src/pages/schedules/FireHistory.tsx @@ -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) && ( )} + {f.event && } {reason && (
{T.reason}:{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 ( +
+ {event.sample ? TT.fireSample(event.text) : TT.fireEvent(event.text)} + {notes.length > 0 && ({notes.join(';')})} +
+ ) +} + /** * 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析 * 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果; diff --git a/frontend/src/pages/schedules/ScheduleEditor.tsx b/frontend/src/pages/schedules/ScheduleEditor.tsx index 26170977..60a61c7a 100644 --- a/frontend/src/pages/schedules/ScheduleEditor.tsx +++ b/frontend/src/pages/schedules/ScheduleEditor.tsx @@ -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 }: { touch('workflow')} /> + set({ triggerKind })} /> - set({ timezone })} - freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} - tzError={previewSays('timezone') ?? serverError(['timezone'])} /> + {event ? ( + 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'])} /> + ) : ( + set({ timezone })} + freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])} + tzError={previewSays('timezone') ?? serverError(['timezone'])} /> + )}
@@ -196,13 +213,13 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: { error={serverError(['run_class'])} onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} /> set({ inputs })} />

{T.overlap}

{T.overlapText}

- + set({ subjectId: e.target.value })}> + + {value.subjectId && !listed && } + {objects.map((o) => ( + + ))} + + ) + } + + return ( +
+

{TT.section}

+
+
{TT.eventLabel}
+
+ {EVENT_KINDS.map((e: ScheduleEventKind) => ( + + ))} +
+

{TT.eventHint[kind]}

+
+ + + {picker} + + {error && ( + + )} + + {kind === 'table.version' && ( + + + + )} + {kind === 'workflow.finished' && options.status === 'ok' && ( +
+
+ + + + + + +
+

{TT.filterHint}

+ {/* 缺省不认的两种成功结束:途经驳回的、应用补算的(服务端 triggers.matches),要认的勾上 */} +
+ + {value.originKind !== BACKFILL_ORIGIN && ( + + )} +
+

{TT.chainHint(options.data.max_depth)}

+
+ )} + + + + + + + + +
+ ) +} diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts index 6d06a478..8e922f84 100644 --- a/frontend/src/pages/schedules/model.ts +++ b/frontend/src/pages/schedules/model.ts @@ -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 = {} 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, 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> = {} 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 diff --git a/frontend/src/pages/schedules/parts.tsx b/frontend/src/pages/schedules/parts.tsx index 7b6e48a4..d6c17ff3 100644 --- a/frontend/src/pages/schedules/parts.tsx +++ b/frontend/src/pages/schedules/parts.tsx @@ -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 {T.nextPaused} + if (schedule.trigger_kind === 'event') return const d = parseServerTime(schedule.next_run_at) if (!d) return {T.nextNone} return } +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 {TT.waiting(st.waiting, when)} + } + if (st && st.events > 0) { + return ( + + {st.scheduler_on ? TT.pending(st.events) : TT.pendingOff(st.events)} + + ) + } + return ( + + {TT.nextEvent} + + ) +} + +/** 列表、详情页头里「事件触发」的小标签:和运行类别的标签并排 */ +export function EventChip() { + return {TT.chip} +} + // ------------------------------------------------------------------------- // 触发 // ------------------------------------------------------------------------- @@ -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) => ( <> @@ -365,16 +407,17 @@ export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: strin const source = scheduleGone(scheduleId) ? ( - {body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))} + {body(eventText ? said(true) : auto ? T.runSourceGone(name) : T.runSourceManualGone(name))} ) : ( - {body(auto ? T.runSource(name) : T.runSourceManual(name))} + {body(eventText ? said(false) : auto ? T.runSource(name) : T.runSourceManual(name))} ) if (auto || !startedBy) return source diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bc7fc16b..0a2f9290 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 & { debounce_s?: number } +} + +export interface SchedulePreviewRequest { + trigger_kind?: ScheduleTriggerKind + trigger?: Omit & { 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[] diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 8af36ea7..b125ed57 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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 () => {