mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:11:59 +00:00
合入 W1-c 数据事件触发:定时任务加「事件触发」(上传表格出新一期、上游工作流正式运行成功结束、数据源有要处理的变化),事件在发生处写进待派发事件、调度循环去抖派发;保存时与运行时防环;{{ event.* }} 模板变量;途经驳回、应用补算的缺省不认(可勾选);积压一拍处理完、过期的合成一条跳过;按期次分窗口;定时页可选「定时 / 事件触发」
This commit is contained in:
+304
-26
@@ -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}
|
||||
|
||||
@@ -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 "正式运行"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 只作显示:查询一律从快照解析
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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、验收副本)不管从哪条路径起服务,
|
||||
|
||||
@@ -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()):
|
||||
|
||||
@@ -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}
|
||||
@@ -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 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,389 @@
|
||||
"""事件触发的定时任务接口(api/schedules.py,docs/design/orchestration-round/00-总方案.md §4)。
|
||||
|
||||
- 建任务收 trigger_kind(cron 缺省,字段和行为一字不变)/ trigger:事件触发时 cron 存空串、下一次触发为空,目标只能是工作流;
|
||||
核对对象(表格在、是按期累积的上传表格;上游工作流在、已发布;数据源在;只认的应用在),输入模板按事件种类核对 {{ event.* }};
|
||||
不对回 422 {detail(中文), field}
|
||||
- 防环:workflow.finished 串成环(A→B→A、自己触发自己)拒收,说清是哪几个工作流;改触发条件、改目标同样查
|
||||
- 返回带 trigger_kind、trigger、trigger_text(给人看的触发说明,也写进 description_text)、trigger_state(几条事件在等、
|
||||
去抖到什么时候、调度开没开);触发历史里事件触发的那几次带 event(事件的说明)
|
||||
- 预览按一个示例事件渲染(上游最近一次成功的正式运行的出口字段……);变量清单按事件种类给 {{ event.* }};
|
||||
立即运行按示例事件发起,还没发生过这种事件、模板又用到 {{ event.* }} 的照实记未能发起
|
||||
- 触发方式建好之后不能改;Webhook 照旧可配
|
||||
|
||||
工作流、表格、数据源、应用都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import DataSource, Run, Schedule, TableRecipe, Workflow, WorkflowVersion
|
||||
from app.db.models_apps import App
|
||||
from app.db.models_triggers import TriggerDelivery, TriggerEvent
|
||||
from app.engine.launch import launch_run
|
||||
from app.engine.runner import run_manager
|
||||
from app.main import app
|
||||
|
||||
HOOK = "https://hooks.example.com/agentlab/inbox-7f2e"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def engine_up():
|
||||
await run_manager.setup()
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(TriggerDelivery))
|
||||
await session.execute(delete(TriggerEvent))
|
||||
await session.execute(delete(Schedule))
|
||||
await session.commit()
|
||||
yield
|
||||
await run_manager.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def node(nid, ntype, **config):
|
||||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||||
|
||||
|
||||
def graph(fields=(), value="完成") -> dict:
|
||||
return {"nodes": [node("start", "input", fields=list(fields)),
|
||||
node("out", "output", fields=[{"name": "r", "value": value}])],
|
||||
"edges": [{"source": "start", "target": "out"}]}
|
||||
|
||||
|
||||
async def workflow(name: str, *, published: bool = True, fields=(), value: str = "完成") -> tuple[str, str]:
|
||||
name = f"{name}-{uuid.uuid4().hex[:6]}"
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name=name, graph=graph(fields, value), status="published" if published else "draft",
|
||||
published_version=1 if published else None)
|
||||
session.add(wf)
|
||||
await session.flush()
|
||||
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph(fields, value)))
|
||||
await session.commit()
|
||||
return wf.id, name
|
||||
|
||||
|
||||
async def upload_table(name: str, *, mode: str = "accumulate") -> str:
|
||||
async with SessionLocal() as session:
|
||||
source = DataSource(name=f"{name}-{uuid.uuid4().hex[:6]}", kind="sqlite", origin="upload", readonly=True)
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
recipe = TableRecipe(source_id=source.id, seq=1, recipe={"mode": mode}, status="active")
|
||||
session.add(recipe)
|
||||
await session.flush()
|
||||
source.current_recipe_id = recipe.id
|
||||
await session.commit()
|
||||
return source.id
|
||||
|
||||
|
||||
async def manual_source(name: str = "门店库") -> str:
|
||||
async with SessionLocal() as session:
|
||||
source = DataSource(name=f"{name}-{uuid.uuid4().hex[:6]}", kind="sqlite", database=":memory:")
|
||||
session.add(source)
|
||||
await session.commit()
|
||||
return source.id
|
||||
|
||||
|
||||
def body(workflow_id: str, trigger: dict, **extra) -> dict:
|
||||
return {"name": "下游汇总", "workflow_id": workflow_id, "trigger_kind": "event", "trigger": trigger,
|
||||
"timezone": "Asia/Shanghai", **extra}
|
||||
|
||||
|
||||
async def settle(run_id: str) -> Run:
|
||||
for _ in range(400):
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Run, run_id)
|
||||
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
|
||||
await run_manager.wait_idle(run_id)
|
||||
return row
|
||||
await asyncio.sleep(0.03)
|
||||
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 建、读
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_table_version_schedule(client):
|
||||
table = await upload_table("门店销售")
|
||||
wf, _ = await workflow("月报", fields=[{"name": "month", "required": True}])
|
||||
r = await client.post("/api/schedules", json=body(wf, {"event": "table.version", "source_id": table,
|
||||
"period": "new"},
|
||||
input_template={"month": "{{ event.period }}"},
|
||||
webhook={"url": HOOK}))
|
||||
assert r.status_code == 201, r.text
|
||||
s = r.json()
|
||||
assert (s["trigger_kind"], s["cron"], s["next_run_at"], s["target_kind"]) == ("event", "", None, "workflow")
|
||||
assert {k: v for k, v in s["trigger"].items() if k != "subject_name"} == {
|
||||
"event": "table.version", "source_id": table, "period": "new", "debounce_s": 60}
|
||||
assert s["trigger"]["subject_name"].startswith("门店销售-") # 保存时记下的对象名
|
||||
assert s["trigger_text"].startswith("上传表格「门店销售-") and "(只认新增的一期;去抖 1 分钟)" in s["trigger_text"]
|
||||
assert s["description_text"] == s["trigger_text"]
|
||||
assert s["trigger_state"] == {"events": 0, "waiting": 0, "due_at": None, "scheduler_on": False}
|
||||
assert s["webhook"]["url_hint"].startswith("hooks.example.com")
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Schedule, s["id"])
|
||||
assert (row.trigger_kind, row.cron, row.next_run_at) == ("event", "", None)
|
||||
# 列表里同一份
|
||||
listed = {x["id"]: x for x in (await client.get("/api/schedules")).json()["items"]}
|
||||
assert listed[s["id"]]["trigger_text"] == s["trigger_text"]
|
||||
|
||||
|
||||
async def test_cron_schedules_are_unchanged_and_say_so(client):
|
||||
wf, _ = await workflow("周报", fields=[{"name": "stat_date", "required": True}])
|
||||
r = await client.post("/api/schedules", json={"name": "周一早报", "workflow_id": wf, "cron": "0 9 * * 1",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"input_template": {"stat_date": "{{ schedule.yesterday }}"}})
|
||||
assert r.status_code == 201, r.text
|
||||
s = r.json()
|
||||
assert (s["trigger_kind"], s["trigger"], s["trigger_text"], s["trigger_state"]) == ("cron", None, None, None)
|
||||
assert s["description_text"] == "每周一 09:00(Asia/Shanghai)" and s["next_run_at"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make, trigger, says", [
|
||||
("replace", {"event": "table.version"}, "不是按期累积的上传表格"),
|
||||
("manual", {"event": "table.version"}, "不是上传表格"),
|
||||
("gone", {"event": "table.version"}, "上传表格不存在"),
|
||||
("draft", {"event": "workflow.finished"}, "还没有发布版本"),
|
||||
("gone", {"event": "source.changed"}, "数据源不存在"),
|
||||
])
|
||||
async def test_trigger_objects_are_checked(client, make, trigger, says):
|
||||
target, _ = await workflow("汇总")
|
||||
if make == "replace":
|
||||
subject = await upload_table("门店销售", mode="replace")
|
||||
elif make == "manual":
|
||||
subject = await manual_source()
|
||||
elif make == "draft":
|
||||
subject, _ = await workflow("草稿日报", published=False)
|
||||
else:
|
||||
subject = "nope"
|
||||
key = "workflow_id" if trigger["event"] == "workflow.finished" else "source_id"
|
||||
r = await client.post("/api/schedules", json=body(target, {**trigger, key: subject}))
|
||||
assert r.status_code == 422 and r.json()["field"] == "trigger" and says in r.json()["detail"], r.text
|
||||
|
||||
|
||||
async def test_event_schedule_rules(client):
|
||||
src = await manual_source()
|
||||
wf, _ = await workflow("汇总", fields=[{"name": "n"}])
|
||||
trigger = {"event": "source.changed", "source_id": src}
|
||||
cases = [
|
||||
({"cron": "0 9 * * 1"}, "cron", "不写 cron"),
|
||||
({"target_kind": "eval_suite", "target_id": "suite-1", "workflow_id": ""}, "target_kind", "只能指向工作流"),
|
||||
({"input_template": {"n": "{{ event.period }}"}}, "input_template", "event.counts.new"),
|
||||
({"trigger_kind": "hourly"}, "trigger_kind", "定时或事件触发"),
|
||||
({"trigger": {**trigger, "debounce_s": 999999}}, "debounce_s", "去抖"),
|
||||
]
|
||||
for extra, field, says in cases:
|
||||
r = await client.post("/api/schedules", json={**body(wf, trigger), **extra})
|
||||
assert r.status_code == 422, (extra, r.text)
|
||||
assert r.json()["field"] == field and says in r.json()["detail"], (extra, r.json())
|
||||
|
||||
|
||||
async def test_unknown_app_filter_is_refused(client):
|
||||
a, _ = await workflow("日报")
|
||||
b, _ = await workflow("汇总")
|
||||
r = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
|
||||
"app_id": "nope"}))
|
||||
assert r.status_code == 422 and "只认的应用不存在" in r.json()["detail"]
|
||||
async with SessionLocal() as session:
|
||||
shop = App(name="门店看板")
|
||||
session.add(shop)
|
||||
await session.commit()
|
||||
app_id = shop.id
|
||||
r = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
|
||||
"app_id": app_id}))
|
||||
assert r.status_code == 201 and "只认应用「门店看板」发起的" in r.json()["trigger_text"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 防环
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cycles_are_refused_when_saving(client):
|
||||
a, a_name = await workflow("日报")
|
||||
b, b_name = await workflow("汇总")
|
||||
c, c_name = await workflow("全国")
|
||||
first = await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a}))
|
||||
assert first.status_code == 201
|
||||
second = await client.post("/api/schedules", json=body(c, {"event": "workflow.finished", "workflow_id": b}))
|
||||
assert second.status_code == 201
|
||||
loop = await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": c}))
|
||||
assert loop.status_code == 422 and loop.json()["field"] == "trigger"
|
||||
assert f"「{c_name}」→「{a_name}」→「{b_name}」→「{c_name}」" in loop.json()["detail"], loop.json()
|
||||
own = await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": a}))
|
||||
assert own.status_code == 422 and f"「{a_name}」→「{a_name}」" in own.json()["detail"]
|
||||
# 停用着的也算:启用不该绕过这道检查
|
||||
s2 = second.json()
|
||||
off = await client.patch(f"/api/schedules/{s2['id']}", json={"enabled": False, "version": s2["version"]})
|
||||
assert off.status_code == 200 and off.json()["next_run_at"] is None
|
||||
assert (await client.post("/api/schedules", json=body(a, {"event": "workflow.finished", "workflow_id": c}))
|
||||
).status_code == 422
|
||||
# 改目标、改触发条件造出环:同样拒
|
||||
s1 = first.json()
|
||||
moved = await client.patch(f"/api/schedules/{s1['id']}", json={
|
||||
"trigger": {"event": "workflow.finished", "workflow_id": c}, "version": s1["version"]})
|
||||
assert moved.status_code == 422 and "循环触发" in moved.json()["detail"]
|
||||
retarget = await client.patch(f"/api/schedules/{s2['id']}", json={"workflow_id": a, "version": off.json()["version"]})
|
||||
assert retarget.status_code == 422 and "循环触发" in retarget.json()["detail"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 改
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_patch_event_schedule(client):
|
||||
src = await manual_source()
|
||||
wf, _ = await workflow("汇总", fields=[{"name": "n"}])
|
||||
s = (await client.post("/api/schedules", json=body(wf, {"event": "source.changed", "source_id": src}))).json()
|
||||
r = await client.patch(f"/api/schedules/{s['id']}", json={"trigger_kind": "cron", "version": s["version"]})
|
||||
assert r.status_code == 422 and r.json()["field"] == "trigger_kind"
|
||||
r = await client.patch(f"/api/schedules/{s['id']}", json={"cron": "0 9 * * *", "version": s["version"]})
|
||||
assert r.status_code == 422 and r.json()["field"] == "cron"
|
||||
r = await client.patch(f"/api/schedules/{s['id']}", json={
|
||||
"trigger": {"event": "source.changed", "source_id": src, "debounce_s": 300},
|
||||
"input_template": {"n": "{{ event.counts.new }}"}, "version": s["version"]})
|
||||
assert r.status_code == 200, r.text
|
||||
out = r.json()
|
||||
assert out["trigger"]["debounce_s"] == 300 and "去抖 5 分钟" in out["trigger_text"]
|
||||
assert out["version"] == s["version"] + 1 and out["next_run_at"] is None and out["cron"] == ""
|
||||
# 整份表单带回来(cron 空串、触发条件没变)照常保存
|
||||
same = await client.patch(f"/api/schedules/{s['id']}", json={
|
||||
"name": "变化处理", "cron": "", "trigger": out["trigger"], "trigger_kind": "event", "version": out["version"]})
|
||||
assert same.status_code == 200 and same.json()["name"] == "变化处理"
|
||||
# 模板里换成别的事件的变量:拒
|
||||
bad = await client.patch(f"/api/schedules/{s['id']}", json={
|
||||
"input_template": {"n": "{{ event.outputs.r }}"}, "version": same.json()["version"]})
|
||||
assert bad.status_code == 422 and bad.json()["field"] == "input_template"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 预览、变量、立即运行、触发历史
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preview_and_run_now_with_a_sample_event(client):
|
||||
a, a_name = await workflow("日报", value="日报完成")
|
||||
b, _ = await workflow("汇总", fields=[{"name": "basis", "required": True}])
|
||||
trigger = {"event": "workflow.finished", "workflow_id": a}
|
||||
template = {"basis": "{{ event.outputs.r }} · {{ event.workflow }}"}
|
||||
# 上游还没成功过:示例值,照实说
|
||||
r = await client.post("/api/schedules/preview", json={"trigger_kind": "event", "trigger": trigger,
|
||||
"timezone": "Asia/Shanghai", "input_template": template,
|
||||
"workflow_id": b})
|
||||
assert r.status_code == 200, r.text
|
||||
p = r.json()
|
||||
assert p["next"] == [] and p["event"]["real"] is False and p["event"]["text"].startswith(f"工作流「{a_name}」")
|
||||
s = (await client.post("/api/schedules", json=body(b, trigger, input_template=template))).json()
|
||||
early = (await client.post(f"/api/schedules/{s['id']}/run")).json()
|
||||
assert early["run_id"] is None and early["fire"]["status"] == "failed_to_start"
|
||||
assert "还没有发生过这种事件" in early["fire"]["reason"] and early["fire"]["kind"] == "manual"
|
||||
# 上游成功过一次:预览、立即运行都按它
|
||||
async with SessionLocal() as session:
|
||||
up = await launch_run(session, workflow_id=a, run_class="formal", input_payload={}, actor="检查员",
|
||||
origin_kind="launcher")
|
||||
await settle(up.id)
|
||||
p = (await client.post("/api/schedules/preview", json={"trigger_kind": "event", "trigger": trigger,
|
||||
"timezone": "Asia/Shanghai", "input_template": template,
|
||||
"workflow_id": b})).json()
|
||||
assert p["inputs"] == {"basis": f"日报完成 · {a_name}"} and p["event"]["real"] is True
|
||||
assert p["description_text"].startswith(f"工作流「{a_name}」的正式运行成功结束时触发")
|
||||
await asyncio.sleep(1.05) # 立即运行按秒去重:上一次落在同一秒就交回那一条
|
||||
run = (await client.post(f"/api/schedules/{s['id']}/run")).json()
|
||||
assert run["run_id"] and run["fire"]["kind"] == "manual" and run["fire"]["event"]["sample"] is True
|
||||
done = await settle(run["run_id"])
|
||||
assert done.input == {"basis": f"日报完成 · {a_name}"}
|
||||
assert done.origin["trigger"]["sample"] is True and done.origin["trigger"]["event"] == "workflow.finished"
|
||||
assert done.origin["trigger"]["real"] is True and done.origin["trigger"]["chain"] == [up.id]
|
||||
# 应用页印章:立即运行按示例事件发起的不说「由事件触发」
|
||||
from app.apps import resolve
|
||||
|
||||
async with SessionLocal() as session:
|
||||
said = await resolve._from_text(session, resolve.Source(id="v", kind="latest_sealed", state="ok", run=done))
|
||||
assert said.startswith("定时任务「下游汇总」第 ")
|
||||
# 触发历史:事件的说明照写
|
||||
[newest, *_] = (await client.get(f"/api/schedules/{s['id']}/fires")).json()["items"]
|
||||
assert newest["event"]["text"].startswith(f"工作流「{a_name}」的正式运行成功结束(#{up.id[:6]})")
|
||||
|
||||
|
||||
async def test_variables_for_an_event(client):
|
||||
names = [v["name"] for v in (await client.get(
|
||||
"/api/schedules/variables", params={"trigger_kind": "event", "event": "workflow.finished"})).json()]
|
||||
assert "event.outputs.<字段名>" in names and "event.run_id" in names and "schedule.yesterday" in names
|
||||
assert "schedule.last_fire_time" not in names
|
||||
plain = [v["name"] for v in (await client.get("/api/schedules/variables")).json()]
|
||||
assert "schedule.last_fire_time" in plain and not any(n.startswith("event.") for n in plain)
|
||||
bad = await client.get("/api/schedules/variables", params={"trigger_kind": "event", "event": "nope"})
|
||||
assert bad.status_code == 422
|
||||
|
||||
|
||||
async def test_trigger_state_and_event_fires(client):
|
||||
"""调度关着:事件留在表里,接口照实说几条在等、调度没开;派发之后触发历史里是 kind=event、带事件的说明"""
|
||||
from app.scheduler import outbox
|
||||
from app.db.base import utcnow
|
||||
from datetime import timedelta
|
||||
|
||||
a, a_name = await workflow("日报")
|
||||
b, _ = await workflow("汇总")
|
||||
s = (await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a,
|
||||
"debounce_s": 0}))).json()
|
||||
async with SessionLocal() as session:
|
||||
up = await launch_run(session, workflow_id=a, run_class="formal", input_payload={}, actor="检查员",
|
||||
origin_kind="launcher")
|
||||
await settle(up.id)
|
||||
state = (await client.get(f"/api/schedules/{s['id']}")).json()["trigger_state"]
|
||||
assert state == {"events": 1, "waiting": 0, "due_at": None, "scheduler_on": False}
|
||||
[fire] = await outbox.dispatch_due(utcnow() + timedelta(seconds=1))
|
||||
await settle(fire.run_id)
|
||||
got = (await client.get(f"/api/schedules/{s['id']}")).json()
|
||||
assert got["trigger_state"]["events"] == 0 and got["last_fire"]["kind"] == "event"
|
||||
assert got["last_fire"]["event"]["text"] == f"工作流「{a_name}」的正式运行成功结束(#{up.id[:6]})"
|
||||
assert got["last_fire"]["inputs"] is None # 工作流目标的输入不在触发历史里列
|
||||
|
||||
|
||||
async def test_trigger_options(client):
|
||||
table = await upload_table("门店销售")
|
||||
flat = await upload_table("门店名单", mode="replace")
|
||||
wf, wf_name = await workflow("日报")
|
||||
draft, _ = await workflow("草稿", published=False)
|
||||
r = await client.get("/api/schedules/trigger-options")
|
||||
assert r.status_code == 200
|
||||
out = r.json()
|
||||
events = {e["event"]: e for e in out["events"]}
|
||||
assert list(events) == ["table.version", "workflow.finished", "source.changed"]
|
||||
tables = {o["id"]: o for o in events["table.version"]["objects"]}
|
||||
assert tables[table]["ok"] is True and tables[flat]["ok"] is False and "按期累积" in tables[flat]["reason"]
|
||||
flows = {o["id"]: o for o in events["workflow.finished"]["objects"]}
|
||||
assert flows[wf] == {"id": wf, "name": wf_name, "ok": True, "reason": None}
|
||||
assert flows[draft]["ok"] is False and "发布" in flows[draft]["reason"]
|
||||
assert "hook" in [o["kind"] for o in out["origins"]] and out["max_depth"] == 5
|
||||
assert out["default_debounce_s"] == 60
|
||||
|
||||
|
||||
async def test_include_rejected_and_backfill_through_the_api(client):
|
||||
"""复核 M1、C5:上游工作流的触发缺省不认途经驳回的、应用补算的,说法照实写;勾了「也认」的存下来、说法跟着变;
|
||||
别的事件带这两项拒收"""
|
||||
a, a_name = await workflow("日报")
|
||||
b, _ = await workflow("汇总", fields=[{"name": "rej"}])
|
||||
plain = (await client.post("/api/schedules", json=body(b, {"event": "workflow.finished", "workflow_id": a},
|
||||
input_template={"rej": "{{ event.rejected }}"}))).json()
|
||||
assert "途经驳回、应用补算的不算" in plain["trigger_text"] and "include_rejected" not in plain["trigger"]
|
||||
r = await client.patch(f"/api/schedules/{plain['id']}", json={
|
||||
"trigger": {"event": "workflow.finished", "workflow_id": a, "include_rejected": True}, "version": plain["version"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["trigger"]["include_rejected"] is True and "途经驳回的也算;应用补算的不算" in r.json()["trigger_text"]
|
||||
src = await upload_table("门店销售")
|
||||
bad = await client.post("/api/schedules", json=body(b, {"event": "table.version", "source_id": src,
|
||||
"include_backfill": True}))
|
||||
assert bad.status_code == 422 and bad.json()["field"] == "trigger"
|
||||
@@ -0,0 +1,277 @@
|
||||
"""数据事件触发复核意见的修正(docs/design/orchestration-round/00-总方案.md §4;scheduler/outbox.py、triggers.py)。
|
||||
|
||||
- M1 途经驳回:正式运行走驳回出口跑完(succeeded,run.finished 带 rejected_via)照发 workflow.finished,事件内容带 rejected_via、
|
||||
说法接「(途经驳回)」、模板有 {{ event.rejected }};触发条件缺省只认没途经驳回的,include_rejected 时也认;
|
||||
- C1 去抖积压:一拍处理到没有到点的窗口为止;已过宽限的几个窗口合成一条跳过(合并了几条、时间跨度),「调度未运行」只在
|
||||
派发确实晚到(派给任务的时刻离事件发生超过宽限)时说,否则照实说积压;积压清完之后新来的事件照常触发;
|
||||
- C3 source.changed 按任务补写:同一个任务只记一条(dedupe),任务结束后没记上的(进程死了、库被锁)在调度的一拍按 job_id 补写;
|
||||
- C4 不同期次的 table.version 不合进同一个窗口:各期有各自的触发;同一期的重传照旧合并;
|
||||
- C5 应用补算(app_backfill)发的 workflow.finished 缺省不认,include_backfill 或只认补算时才认;
|
||||
- C6 预览、立即运行的示例事件和真派发同一套出口字段过滤(「_」开头的不给模板)。
|
||||
|
||||
工作流、表格、数据源都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import DataSource, Run, Schedule
|
||||
from app.db.models_sources import IntrospectJob, SourceChange
|
||||
from app.db.models_triggers import TriggerDelivery, TriggerEvent
|
||||
from app.scheduler import outbox, triggers
|
||||
from tests.test_schedule_events import ( # noqa: F401 (engine_up:每个测试清掉定时任务和事件)
|
||||
deliveries, engine_up, event_schedule, events, fires, formal_run, later, put_event, settle, workflow,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# M1 途经驳回
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_rejected_via_is_carried_and_excluded_by_default():
|
||||
upstream = await workflow("门店日报")
|
||||
plain = await event_schedule(await workflow("区域汇总"),
|
||||
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0}, name="不认驳回")
|
||||
also = await event_schedule(await workflow("驳回复核", fields=[{"name": "rej"}]),
|
||||
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0,
|
||||
"include_rejected": True}, template={"rej": "{{ event.rejected }}"}, name="也认驳回")
|
||||
a = await formal_run(upstream)
|
||||
async with SessionLocal() as session:
|
||||
# runner 收尾时把这次运行途经的驳回交进来(W1-b 的 _rejected_via(state)):这里直接调,覆盖那一下
|
||||
await session.execute(TriggerEvent.__table__.delete().where(TriggerEvent.subject_id == upstream))
|
||||
outbox.stage_run_finished(session, await session.get(Run, a.id),
|
||||
rejected_via=[{"node_id": "gate", "label": "主管审核"}])
|
||||
await session.commit()
|
||||
[ev] = await events("workflow.finished", upstream)
|
||||
assert ev.payload["rejected"] is True and ev.payload["rejected_via"] == [{"node_id": "gate", "label": "主管审核"}]
|
||||
assert triggers.event_text(ev.event, ev.subject_name, ev.payload).endswith("(途经驳回)")
|
||||
[fire] = await outbox.dispatch_due(later())
|
||||
assert fire.schedule_id == also and fire.status == "started" and fire.inputs["inputs"] == {"rej": "true"}
|
||||
assert fire.inputs["event"]["text"].endswith("(途经驳回)")
|
||||
assert await fires(plain) == [] and await deliveries(plain) == []
|
||||
await settle(fire.run_id)
|
||||
|
||||
|
||||
async def test_plain_runs_say_not_rejected():
|
||||
upstream = await workflow("门店日报")
|
||||
sid = await event_schedule(await workflow("区域汇总", fields=[{"name": "rej"}]),
|
||||
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 0},
|
||||
template={"rej": "{{ event.rejected }}"})
|
||||
await formal_run(upstream) # runner 收尾没交 rejected_via:没途经驳回
|
||||
[ev] = await events("workflow.finished", upstream)
|
||||
assert ev.payload["rejected"] is False and ev.payload["rejected_via"] is None
|
||||
[fire] = await outbox.dispatch_due(later())
|
||||
assert fire.schedule_id == sid and fire.inputs["inputs"] == {"rej": "false"}
|
||||
assert "途经驳回" not in fire.inputs["event"]["text"]
|
||||
await settle(fire.run_id)
|
||||
|
||||
|
||||
def test_rejected_and_backfill_options_are_described():
|
||||
base = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf"})
|
||||
assert "(途经驳回、应用补算的不算;去抖 1 分钟)" in triggers.describe(base, "门店日报")
|
||||
both = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf", "include_rejected": True,
|
||||
"include_backfill": True})
|
||||
assert both["include_rejected"] is True and both["include_backfill"] is True
|
||||
assert "(途经驳回、应用补算的也算;去抖 1 分钟)" in triggers.describe(both, "门店日报")
|
||||
# 只认应用补算的:补算本来就要认,不再说「补算的不算」
|
||||
only = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf", "origin_kind": "app_backfill"})
|
||||
text = triggers.describe(only, "门店日报")
|
||||
assert "只认来源为「应用补算」的" in text and "应用补算的不算" not in text and "途经驳回的不算" in text
|
||||
# 别的事件不收这两项
|
||||
for key in ("include_rejected", "include_backfill"):
|
||||
try:
|
||||
triggers.normalize({"event": "table.version", "source_id": "x", key: True})
|
||||
except triggers.TriggerError as e:
|
||||
assert "上游工作流运行成功结束" in str(e)
|
||||
else:
|
||||
raise AssertionError(key)
|
||||
assert "event.rejected" in triggers.names("workflow.finished")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# C5 应用补算
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_backfill_runs_are_excluded_unless_asked():
|
||||
payload = {"origin_kind": "app_backfill", "rejected": False}
|
||||
base = triggers.normalize({"event": "workflow.finished", "workflow_id": "wf"})
|
||||
assert not triggers.matches(base, "workflow.finished", "wf", payload)
|
||||
assert triggers.matches(base, "workflow.finished", "wf", {**payload, "origin_kind": "launcher"})
|
||||
assert triggers.matches({**base, "include_backfill": True}, "workflow.finished", "wf", payload)
|
||||
assert triggers.matches({**base, "origin_kind": "app_backfill"}, "workflow.finished", "wf", payload)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# C6 示例事件的出口字段
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_sample_outputs_use_the_same_filter_as_dispatch():
|
||||
upstream = await workflow("门店日报")
|
||||
run = await formal_run(upstream)
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(Run.__table__.update().where(Run.id == run.id).values(
|
||||
output={"r": "日报完成", "_issuance": {"tier": "full"}}))
|
||||
await session.commit()
|
||||
got = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream})
|
||||
assert got.real and got.outputs == {"r": "日报完成"}
|
||||
assert triggers.outputs_of({"r": 1, "_x": 2}) == {"r": 1}
|
||||
|
||||
|
||||
async def test_sample_skips_rejected_and_backfill_runs_by_default():
|
||||
upstream = await workflow("门店日报")
|
||||
plain = await formal_run(upstream)
|
||||
backfill = await formal_run(upstream, origin_kind="app_backfill")
|
||||
async with SessionLocal() as session:
|
||||
got = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream})
|
||||
wide = await triggers.sample(session, {"event": "workflow.finished", "workflow_id": upstream,
|
||||
"include_backfill": True})
|
||||
assert got.payload["run_id"] == plain.id and wide.payload["run_id"] == backfill.id
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# C1 去抖积压
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _backlog(upstream: str, sid: str, *, n: int, gap_s: int, routed_late: bool) -> tuple:
|
||||
"""n 条相隔 gap_s 秒的事件,派给了任务、还没处理。routed_late:派给任务的时刻是现在(调度停过);否则发生后 1 秒就派了
|
||||
(调度在跑,只是没处理完)。"""
|
||||
t0 = utcnow().replace(microsecond=0) - timedelta(seconds=gap_s * (n - 1) + 61)
|
||||
ids = []
|
||||
async with SessionLocal() as session:
|
||||
for i in range(n):
|
||||
at = t0 + timedelta(seconds=gap_s * i)
|
||||
ev = TriggerEvent(event="workflow.finished", subject_id=upstream, subject_name="门店日报",
|
||||
payload={"workflow_id": upstream, "run_id": f"run-{i}", "rejected": False},
|
||||
occurred_at=at, created_at=at, status="routed", matched=1)
|
||||
session.add(ev)
|
||||
await session.flush()
|
||||
session.add(TriggerDelivery(schedule_id=sid, event_id=ev.id, occurred_at=at,
|
||||
created_at=utcnow() if routed_late else at + timedelta(seconds=1)))
|
||||
ids.append(ev.id)
|
||||
await session.commit()
|
||||
return t0, ids
|
||||
|
||||
|
||||
async def test_backlog_is_drained_in_one_tick_and_missed_windows_merge_into_one_skip():
|
||||
"""12 条相隔 5 分钟、去抖 60 秒、宽限 600 秒:一拍处理完。过了宽限的 10 个窗口合成一条跳过(合并 10 次、写时间跨度),
|
||||
宽限内的两个照常发起(第二个照重叠规则可能跳过);积压清完以后新来的事件照常触发"""
|
||||
upstream = await workflow("门店日报")
|
||||
sid = await event_schedule(await workflow("区域汇总", fields=[{"name": "run"}]),
|
||||
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}, grace=600,
|
||||
template={"run": "{{ event.run_id }}"})
|
||||
t0, ids = await _backlog(upstream, sid, n=12, gap_s=300, routed_late=True)
|
||||
done = await outbox.dispatch_due(utcnow())
|
||||
assert len(done) == 3, [(f.status, f.reason) for f in done]
|
||||
skip, *rest = done
|
||||
assert skip.status == "skipped_misfire" and skip.kind == "event"
|
||||
assert skip.inputs["event"]["merged"] == 10 and skip.inputs["event"]["windows"] == 10
|
||||
assert skip.inputs["event"]["event_ids"] == ids[:10]
|
||||
assert "服务当时没有派发(调度未运行或已停止)" in skip.reason and " 至 " in skip.reason and "10 次事件" in skip.reason
|
||||
assert [f.inputs["event"]["event_ids"] for f in rest] == [[ids[10]], [ids[11]]]
|
||||
assert rest[0].status == "started" and rest[1].status in ("started", "skipped_overlap")
|
||||
assert {d.status for d in await deliveries(sid)} == {"fired"}
|
||||
for f in rest:
|
||||
if f.run_id:
|
||||
await settle(f.run_id)
|
||||
# 积压清完:新来的一条照常触发
|
||||
new = await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "run-new"}, utcnow())
|
||||
[fresh] = await outbox.dispatch_due(later(61))
|
||||
assert fresh.status == "started" and fresh.inputs["event"]["event_ids"] == [new]
|
||||
await settle(fresh.run_id)
|
||||
|
||||
|
||||
async def test_backlog_while_scheduler_ran_says_backlog_not_scheduler_off():
|
||||
upstream = await workflow("门店日报")
|
||||
sid = await event_schedule(await workflow("区域汇总"),
|
||||
{"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}, grace=600)
|
||||
await _backlog(upstream, sid, n=4, gap_s=600, routed_late=False)
|
||||
skip, *rest = await outbox.dispatch_due(utcnow())
|
||||
assert skip.status == "skipped_misfire" and skip.inputs["event"]["merged"] == 3
|
||||
assert "调度未运行" not in skip.reason and "积压" in skip.reason, skip.reason
|
||||
for f in rest:
|
||||
if f.run_id:
|
||||
await settle(f.run_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# C4 不同期次各自一个窗口
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_different_periods_get_their_own_windows():
|
||||
table = uuid.uuid4().hex
|
||||
sid = await event_schedule(await workflow("月报", fields=[{"name": "month"}]),
|
||||
{"event": "table.version", "source_id": table, "debounce_s": 60},
|
||||
template={"month": "{{ event.period }}"})
|
||||
t0 = utcnow().replace(microsecond=0)
|
||||
payload = lambda p, s, e: {"table": "门店销售", "period": p, "period_start": s, "period_end": e, # noqa: E731
|
||||
"version": f"snap-{p}", "replaced": False}
|
||||
sep = await put_event("table.version", table, payload("2026-09", "2026-09-01", "2026-09-30"), t0, name="门店销售")
|
||||
oct_ = await put_event("table.version", table, payload("2026-10", "2026-10-01", "2026-10-31"),
|
||||
t0 + timedelta(seconds=30), name="门店销售")
|
||||
again = await put_event("table.version", table, {**payload("2026-09", "2026-09-01", "2026-09-30"), "replaced": True},
|
||||
t0 + timedelta(seconds=40), name="门店销售")
|
||||
[first] = await outbox.dispatch_due(t0 + timedelta(seconds=61))
|
||||
# 9 月一个窗口:9 月和 9 月的重传合在一起(内容取最新的那一条),10 月不合进来
|
||||
assert first.inputs["event"]["event_ids"] == [sep, again] and first.inputs["inputs"] == {"month": "2026-09"}
|
||||
await settle(first.run_id)
|
||||
[second] = await outbox.dispatch_due(t0 + timedelta(seconds=91))
|
||||
assert second.inputs["event"]["event_ids"] == [oct_] and second.inputs["inputs"] == {"month": "2026-10"}
|
||||
assert second.fire_at == t0 + timedelta(seconds=90) and second.status == "started"
|
||||
await settle(second.run_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# C3 source.changed 按任务补写
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _source_with_changes(job_status: str) -> tuple[str, str]:
|
||||
async with SessionLocal() as session:
|
||||
source = DataSource(name=f"门店库-{uuid.uuid4().hex[:6]}", kind="sqlite", database=":memory:")
|
||||
session.add(source)
|
||||
await session.flush()
|
||||
job = IntrospectJob(source_id=source.id, status=job_status,
|
||||
finished_at=utcnow() if job_status != "running" else None)
|
||||
session.add(job)
|
||||
await session.flush()
|
||||
for i in range(2):
|
||||
session.add(SourceChange(source_id=source.id, source_name=source.name, table_name="orders",
|
||||
column_name=f"c{i}", kind="column_removed", severity="action",
|
||||
detected_by={"job_kind": "introspect", "job_id": job.id},
|
||||
fingerprint=uuid.uuid4().hex, status="open"))
|
||||
await session.commit()
|
||||
return source.id, job.id
|
||||
|
||||
|
||||
async def test_source_changed_is_backfilled_once_per_job():
|
||||
"""任务结束了、变化记下了,那条事件却没记上(进程在中间死了):调度的一拍按 job_id 补写,只补一次;之后再走到
|
||||
notify_after_job 也不重复"""
|
||||
source_id, job_id = await _source_with_changes("succeeded")
|
||||
running_source, _ = await _source_with_changes("running")
|
||||
assert await events("source.changed", source_id) == []
|
||||
assert await outbox.backfill_source_changes(utcnow()) >= 1
|
||||
[ev] = await events("source.changed", source_id)
|
||||
assert ev.payload["job_id"] == job_id and ev.payload["counts"]["new"] == 2 and ev.dedupe_key == f"source.changed:{job_id}"
|
||||
# 还在进行的任务不补:它结束时自己会记
|
||||
assert await events("source.changed", running_source) == []
|
||||
await outbox.backfill_source_changes(utcnow())
|
||||
from app.data import source_changes
|
||||
|
||||
await source_changes.notify_after_job(source_id, job_id)
|
||||
assert len(await events("source.changed", source_id)) == 1
|
||||
|
||||
|
||||
async def test_dispatch_tick_runs_the_backfill():
|
||||
source_id, _ = await _source_with_changes("succeeded")
|
||||
outbox._backfilled_at = None # 刚起的服务:第一拍就补
|
||||
await outbox.dispatch_due(utcnow())
|
||||
assert len(await events("source.changed", source_id)) == 1
|
||||
@@ -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)
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* cron / timezone / input_template 哪一项(ApiError.field)。老服务端没有这些接口,回 404 且没有 code
|
||||
*/
|
||||
import type {
|
||||
Schedule, ScheduleCreate, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest, ScheduleRunResult,
|
||||
ScheduleTargetCreate, ScheduleVariable, ScheduleWebhookTest,
|
||||
Schedule, ScheduleCreate, ScheduleEventKind, ScheduleFirePage, SchedulePatch, SchedulePreview, SchedulePreviewRequest,
|
||||
ScheduleRunResult, ScheduleTargetCreate, ScheduleTriggerOptions, ScheduleVariable, ScheduleWebhookTest,
|
||||
} from '../types'
|
||||
import { del, get, patch, post, qs, request, type RequestOptions } from './http'
|
||||
|
||||
@@ -30,6 +30,14 @@ export const schedules = {
|
||||
request<SchedulePreview>('/schedules/preview', { method: 'POST', body: JSON.stringify(body), signal: opts?.signal }),
|
||||
/** 输入模板里能用的变量(schedule.yesterday 这类),按触发时间计算 */
|
||||
variables: (opts?: RequestOptions) => get<ScheduleVariable[]>('/schedules/variables', opts),
|
||||
/**
|
||||
* 事件触发任务的模板里能用的变量:schedule.*(上一次计划触发时间除外)加上这种事件的 event.*(docs/design/orchestration-round
|
||||
* §4)。老服务端不认这两个参数,照旧给 cron 的那一份
|
||||
*/
|
||||
eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) =>
|
||||
get<ScheduleVariable[]>(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts),
|
||||
/** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */
|
||||
triggerOptions: (opts?: RequestOptions) => get<ScheduleTriggerOptions>('/schedules/trigger-options', opts),
|
||||
/** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */
|
||||
run: (id: string) => post<ScheduleRunResult>(`${at(id)}/run`),
|
||||
/** 触发历史,新的在前。before 是上一页给的 next_before */
|
||||
|
||||
@@ -3995,11 +3995,13 @@ export const SCHEDULE_TEXT = {
|
||||
openRun: '查看运行',
|
||||
|
||||
// ---- 触发
|
||||
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发' } as Record<import('../types').ScheduleFireKind, string>,
|
||||
fireKind: { scheduled: '定时', manual: '手动', misfire: '补发', event: '事件' } as Record<
|
||||
import('../types').ScheduleFireKind, string>,
|
||||
fireKindHint: {
|
||||
scheduled: '到点按计划触发',
|
||||
manual: '手动「立即运行」',
|
||||
misfire: '错过触发时间后,在补发宽限内恢复时补发的一次',
|
||||
event: '由数据事件触发(去抖结束的时刻)',
|
||||
} as Record<import('../types').ScheduleFireKind, string>,
|
||||
/**
|
||||
* 错过、没有补发的(服务端记 kind=misfire、status=skipped_misfire):类型按「定时」写。没有补发的原因有两种(超出
|
||||
@@ -4235,9 +4237,10 @@ export const SCHEDULE_TEXT = {
|
||||
},
|
||||
/** 「还需处理」里各项的叫法 */
|
||||
problemField: {
|
||||
name: '名称', workflow: '工作流', freq: '频率', timezone: '时区', runClass: '运行类别', inputs: '输入', url: 'Webhook 地址',
|
||||
secret: '签名密钥', events: '通知事件',
|
||||
} as Record<'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events', string>,
|
||||
name: '名称', workflow: '工作流', freq: '频率', trigger: '触发事件', timezone: '时区', runClass: '运行类别', inputs: '输入',
|
||||
url: 'Webhook 地址', secret: '签名密钥', events: '通知事件',
|
||||
} as Record<'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events',
|
||||
string>,
|
||||
|
||||
// ---- 相对时间(将来)
|
||||
until: {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 数据事件触发的文案(docs/design/orchestration-round/00-总方案.md §4):定时任务页新建时选「定时 / 事件触发」、事件那一节、
|
||||
* 列表和详情里的等待状态、触发历史里的事件、运行详情页头的「由事件触发:…」。规矩同 terms.ts 开头的术语表:「运行」不写跑,
|
||||
* 「服务端」不写后端,全角标点。
|
||||
*
|
||||
* 能选什么、为什么不能选、触发说明(「上传表格「X」出了新的一期时触发(去抖 1 分钟)」)、一次事件的说法都由服务端给
|
||||
* (scheduler/triggers.py),这里只有叫法和句式。事件种类的叫法和服务端 triggers.EVENT_LABEL 逐字对照。
|
||||
*/
|
||||
import type { ScheduleEventKind, ScheduleTriggerKind } from '../types'
|
||||
|
||||
export const TRIGGER_TEXT = {
|
||||
// ---- 新建时选触发方式
|
||||
modeLabel: '怎么触发',
|
||||
mode: { cron: '定时', event: '事件触发' } as Record<ScheduleTriggerKind, string>,
|
||||
modeHint: {
|
||||
cron: '按设定的频率到点发起运行',
|
||||
event: '数据有变化时发起运行:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化',
|
||||
} as Record<ScheduleTriggerKind, string>,
|
||||
/** 修改时:触发方式建好之后不能改 */
|
||||
modeFixed: (label: string) => `触发方式:${label}。建好之后不能改,要换请新建一个定时任务`,
|
||||
|
||||
// ---- 事件那一节
|
||||
section: '触发事件',
|
||||
eventLabel: '事件',
|
||||
event: {
|
||||
'table.version': '上传表格出了新的一期',
|
||||
'workflow.finished': '上游工作流运行成功结束',
|
||||
'source.changed': '数据源有要处理的变化',
|
||||
} as Record<ScheduleEventKind, string>,
|
||||
eventHint: {
|
||||
'table.version': '按期累积的上传表格导入了新的一期并启用时触发;每次上传替换整张表的表格没有这个事件',
|
||||
'workflow.finished': '选中的工作流有一次正式运行成功结束时触发;输入模板可以引用它的出口字段',
|
||||
'source.changed': '数据源的探查、剖析新记下「要处理」级的变化时触发;同一次探查或剖析产生的一批只触发一次',
|
||||
} as Record<ScheduleEventKind, string>,
|
||||
subject: { 'table.version': '上传表格', 'workflow.finished': '上游工作流', 'source.changed': '数据源' } as Record<
|
||||
ScheduleEventKind, string>,
|
||||
subjectPlaceholder: (label: string) => `选择${label}`,
|
||||
subjectEmpty: {
|
||||
'table.version': '还没有上传表格',
|
||||
'workflow.finished': '还没有工作流',
|
||||
'source.changed': '还没有数据源',
|
||||
} as Record<ScheduleEventKind, string>,
|
||||
/** 不能选的对象:名字后面接服务端给的原因 */
|
||||
subjectNotOk: (name: string, reason: string) => `${name}(${reason})`,
|
||||
subjectGone: (name: string) => `${name}(已不存在)`,
|
||||
optionsLoading: '正在读取可选的对象…',
|
||||
optionsFailed: '无法读取可选的对象',
|
||||
optionsUnsupported: '服务端暂不支持事件触发,请升级服务端后再使用',
|
||||
retry: '重试',
|
||||
periodLabel: '期次',
|
||||
period: { any: '新增的一期和替换已有的一期都认', new: '只认新增的一期' } as Record<'any' | 'new', string>,
|
||||
periodHint: '替换已有的一期:重新上传了已经导入过的统计期(例如改正了数),选「只认新增的一期」时不触发',
|
||||
appLabel: '只认应用',
|
||||
appAny: '不限应用',
|
||||
originLabel: '只认来源',
|
||||
originAny: '不限来源',
|
||||
filterHint: '只在上游那次正式运行由这个应用、这种来源发起时触发',
|
||||
includeRejected: '也认途经驳回的',
|
||||
includeRejectedHint: '上游某个审批被驳回、按驳回那条路走完时,运行仍算成功结束。不勾时这类运行不触发;勾了以后,输入模板里的 {{ event.rejected }} 是 true',
|
||||
includeBackfill: '也认应用补算的',
|
||||
includeBackfillHint: '应用升版后按历次输入补算,会连着结束好几次正式运行,每次是某一期的历史输入。不勾时补算的运行不触发',
|
||||
debounce: '去抖',
|
||||
debounceHint: '这段时间内的几次事件合成一次触发,按最新的那一次渲染输入;上一次运行还没结束时仍按重叠策略跳过',
|
||||
debounceOption: (span: string) => (span ? `${span}内合成一次` : '不去抖,每次事件都触发'),
|
||||
timezone: '时区',
|
||||
timezoneHint: '输入模板里的日期变量、事件里的时间按这个时区写',
|
||||
chainHint: (n: number) => `上游工作流可以一个接一个地触发下游,最多 ${n} 层;串成环的设置保存时会被拒绝`,
|
||||
|
||||
// ---- 预览
|
||||
preview: '按一次事件预览',
|
||||
previewIdle: '选好事件和对象后在此预览渲染出的输入',
|
||||
previewLatest: '按这个对象最近一次的事件渲染',
|
||||
previewExample: '这个对象还没有发生过这种事件,下面是示例值;立即运行时用到事件变量的输入不会发起',
|
||||
previewInputs: '渲染出的输入',
|
||||
previewNoInputs: '没有填写输入,按工作流的默认值',
|
||||
|
||||
// ---- 列表、详情的等待状态
|
||||
chip: '事件触发',
|
||||
chipTitle: '由数据事件触发,不按时间',
|
||||
nextEvent: '等待事件',
|
||||
waiting: (n: number, when: string) => `去抖中:已收到 ${n} 次事件,将在 ${when} 合成一次触发`,
|
||||
pending: (n: number) => `有 ${n} 条事件等待派发`,
|
||||
pendingOff: (n: number) => `有 ${n} 条事件在等待,但定时任务调度未开启,不会派发`,
|
||||
schedulerOff: '定时任务调度未开启:事件会留着,不会派发',
|
||||
|
||||
// ---- 触发历史
|
||||
fireEvent: (text: string) => `由事件触发:${text}`,
|
||||
/** 立即运行按示例事件发起的那一次:不是事件触发的,写按的是哪一次事件 */
|
||||
fireSample: (text: string) => `按事件:${text}`,
|
||||
merged: (n: number) => `合并了 ${n} 次事件`,
|
||||
depth: (n: number) => `触发链第 ${n} 层`,
|
||||
sample: '立即运行,按最近一次事件',
|
||||
/** 几个过了补发宽限的去抖窗口合成一条跳过 */
|
||||
windows: (n: number) => `${n} 个去抖窗口`,
|
||||
periods: (list: string) => `期次 ${list}`,
|
||||
sampleExample: '立即运行,事件为示例值',
|
||||
|
||||
// ---- 运行详情页头
|
||||
runSource: (text: string, name?: string | null) => (name ? `由事件触发:${text}(定时任务「${name}」)` : `由事件触发:${text}`),
|
||||
runSourceGone: (text: string, name?: string | null) =>
|
||||
(name ? `由事件触发:${text}(定时任务「${name}」,已删除)` : `由事件触发:${text}(定时任务已删除)`),
|
||||
/** 立即运行按示例事件发起的运行:写成「来自定时任务」那一类,后面接按的是哪一次事件 */
|
||||
runSourceSample: (text: string, name?: string | null) =>
|
||||
(name ? `立即运行定时任务「${name}」,按事件:${text}` : `立即运行定时任务,按事件:${text}`),
|
||||
|
||||
// ---- 表单的问题
|
||||
problemSubject: (label: string) => `请选择${label}`,
|
||||
problemField: '触发事件',
|
||||
/** 事件触发时「错过触发时间」那一项的说明 */
|
||||
misfireHint: '调度停过(服务停机、调度未开启)时,事件在宽限内派发照常发起,超过则跳过',
|
||||
inputsHint: '可填固定值或模板,模板中的变量按触发时间和事件计算;留空的字段按工作流的默认值',
|
||||
}
|
||||
@@ -595,7 +595,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
|
||||
</time>
|
||||
<span>·</span>
|
||||
{/* 定时任务发起的:写成到那个定时任务的链接(键盘可达;记录页的「定时」标记只能鼠标点) */}
|
||||
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} /> : (
|
||||
{run.schedule_id ? <RunScheduleSource scheduleId={run.schedule_id} startedBy={run.started_by} trigger={run.origin?.trigger} /> : (
|
||||
<span title={run.started_by ? undefined : '发起时未设置署名'}>
|
||||
{run.started_by || '未署名'} 发起
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { api } from '../../api/client'
|
||||
import { EmptyState, ErrorState, Skeleton, Spinner, StatusPill, toast, useTicker } from '../../components/ui'
|
||||
import { formatDateTime, parseServerTime, shortId } from '../../lib/format'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import type { Schedule, ScheduleFire, ScheduleFireCompare } from '../../types'
|
||||
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
|
||||
import type { Schedule, ScheduleFire, ScheduleFireCompare, ScheduleFireEvent } from '../../types'
|
||||
import { comparePath, type CompareVia } from '../runs/compare/entry'
|
||||
import { maintenanceJobPath, maintenanceStepText } from './model'
|
||||
import { FireKindChip, FireStatusChip, ZonedTime, targetKindOf } from './parts'
|
||||
@@ -170,6 +171,7 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
|
||||
{maintenance && (jobs.length > 0 || skipped.length > 0) && (
|
||||
<MaintenanceFire sourceId={schedule.target_id ?? ''} jobs={jobs} skipped={skipped} />
|
||||
)}
|
||||
{f.event && <EventFire event={f.event} />}
|
||||
{reason && (
|
||||
<div className="mt-1 text-2xs leading-relaxed text-dim">
|
||||
<span className="text-faint">{T.reason}:</span>{reason}
|
||||
@@ -190,6 +192,26 @@ function FireRow({ fire: f, schedule, now }: { fire: ScheduleFire; schedule: Sch
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件触发的那一次(docs/design/orchestration-round/00-总方案.md §4):由什么事件触发(服务端写好的那一句)、去抖合并了几次、
|
||||
* 在触发链上第几层;立即运行时按示例事件发起的照实标出来(示例值的另说)
|
||||
*/
|
||||
function EventFire({ event }: { event: ScheduleFireEvent }) {
|
||||
const notes = [
|
||||
event.merged > 1 ? TT.merged(event.merged) : null,
|
||||
event.windows && event.windows > 1 ? TT.windows(event.windows) : null,
|
||||
event.periods && event.periods.length > 1 ? TT.periods(event.periods.join('、')) : null,
|
||||
event.depth > 1 ? TT.depth(event.depth) : null,
|
||||
event.sample ? (event.real ? TT.sample : TT.sampleExample) : null,
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<div className="mt-1 text-2xs leading-relaxed text-dim" data-fire-event={event.event}>
|
||||
{event.sample ? TT.fireSample(event.text) : TT.fireEvent(event.text)}
|
||||
{notes.length > 0 && <span className="text-faint">({notes.join(';')})</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源维护的一次触发建了什么(触发记录的 inputs,和数据源页「定时维护」的「最近一次」同一份、同一个写法):每个探查、剖析
|
||||
* 任务一行,链到能看它的地方——探查到数据源页那张卡片(探查的进度和结果都在卡片上),剖析到数据目录里那个任务的进度和结果;
|
||||
|
||||
@@ -9,13 +9,15 @@ import { errorMessage } from '../../lib/errors'
|
||||
import { parseServerTime } from '../../lib/format'
|
||||
import { useLeaveGuard } from '../../lib/leave'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
|
||||
import { useCatalog, useCatalogListState } from '../../store/catalog'
|
||||
import type { Schedule, SchedulePreview, ScheduleRunClass, ScheduleVariable, Workflow } from '../../types'
|
||||
import type { Schedule, ScheduleEventKind, SchedulePreview, ScheduleRunClass, ScheduleTrigger, ScheduleVariable, Workflow } from '../../types'
|
||||
import {
|
||||
GRACE_OPTIONS, createBody, cronOf, formOf, formProblems, formatZoned, freqProblem, graceLabel, graphFields, patchBody,
|
||||
rebaseForm, serverFieldValue, templateOf, type Freq, type FormField, type InputField, type ScheduleForm,
|
||||
rebaseForm, serverFieldValue, templateOf, triggerBody, type Freq, type FormField, type InputField, type ScheduleForm,
|
||||
} from './model'
|
||||
import { isUnsupported } from './parts'
|
||||
import { EventSection, TriggerModePicker } from './TriggerEditor'
|
||||
import { WebhookFields } from './WebhookFields'
|
||||
import { WorkflowPicker } from './WorkflowPicker'
|
||||
|
||||
@@ -23,6 +25,9 @@ import { WorkflowPicker } from './WorkflowPicker'
|
||||
* 新建、修改定时任务的表单。宽屏左边是表单、右边是接下来 5 次触发的预览(跟着滚动停在顶上);窄屏预览排在
|
||||
* 频率下面,改了频率当场看得到。
|
||||
*
|
||||
* 新建时先选「定时 / 事件触发」(docs/design/orchestration-round/00-总方案.md §4):事件触发的没有频率,那一节换成事件
|
||||
* (TriggerEditor 的 EventSection),预览按一次事件渲染输入(不列触发时间),变量多出 {{ event.* }}。触发方式建好之后不能改。
|
||||
*
|
||||
* schedule 为 null 是新建。修改时只交改了的字段,带上 version:详情页页头的开关改过之后,version 是开关交回的
|
||||
* 新版本,表单照常保存;别人在这期间改过就 409,表单不动,给「重新载入」。
|
||||
*/
|
||||
@@ -54,17 +59,20 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
|
||||
} | null>(null)
|
||||
|
||||
const workflow = workflows.find((w) => w.id === form.workflowId)
|
||||
const cron = cronOf(form.freq)
|
||||
const event = form.triggerKind === 'event'
|
||||
const cron = event ? '' : cronOf(form.freq)
|
||||
const template = useMemo(() => templateOf(form.inputs), [form.inputs])
|
||||
const preview = usePreview(cron, form.timezone, template, form.workflowId)
|
||||
const trigger = useMemo(() => (event ? triggerBody(form.trigger) : null), [event, form.trigger])
|
||||
const preview = usePreview(cron, form.timezone, template, form.workflowId, trigger)
|
||||
const fieldsOf = useInputFields(workflow, form.runClass)
|
||||
// 修改时工作流列表还没取回来:输入字段是「正在读取」,不是「请先选择工作流」
|
||||
const fields: FieldsState = !workflow && form.workflowId && workflowsState !== 'ok' ? { status: 'loading' } : fieldsOf
|
||||
const variables = useVariables()
|
||||
const variables = useVariables(event ? form.trigger.event : null)
|
||||
|
||||
const changes = base ? patchBody(form, initial, base) : null
|
||||
// 服务端保存时只在新建、或者改了工作流、运行类别、输入时才核对这三样:照着拦,不多拦也不少拦
|
||||
const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes))
|
||||
const recheck = !base || (!!changes && ('workflow_id' in changes || 'run_class' in changes || 'input_template' in changes
|
||||
|| 'trigger' in changes))
|
||||
const problems = formProblems(form, base, {
|
||||
published: workflow ? workflow.published_version ?? null : undefined,
|
||||
fields: fields.status === 'ok' ? fields.fields : undefined,
|
||||
@@ -75,6 +83,7 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
|
||||
/** 预览判定非法、并指明是这一项时,服务端的说明 */
|
||||
const previewSays = (field: string) => (preview.status === 'invalid' && preview.field === field ? preview.message : undefined)
|
||||
const previewBlocks = previewField === 'cron' || previewField === 'timezone' || previewField === 'input_template'
|
||||
|| previewField === 'trigger' || previewField === 'debounce_s'
|
||||
const missing = (Object.keys(problems) as FormField[]).map((k) => T.problemField[k])
|
||||
const hint = missing.length ? T.missing(missing.join('、')) : previewBlocks ? T.previewBlocks : ''
|
||||
const canSave = !saving && !missing.length && !previewBlocks && dirty
|
||||
@@ -178,16 +187,24 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
|
||||
<WorkflowField form={form} workflows={workflows} state={workflowsState} workflow={workflow} base={base}
|
||||
error={shown('workflow') ?? serverError(['workflow_id'])}
|
||||
onPick={pickWorkflow} onBlur={() => touch('workflow')} />
|
||||
<TriggerModePicker value={form.triggerKind} fixed={!!base} onChange={(triggerKind) => set({ triggerKind })} />
|
||||
</section>
|
||||
|
||||
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
|
||||
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
|
||||
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
|
||||
{event ? (
|
||||
<EventSection value={form.trigger} onChange={(t) => set({ trigger: t })} timezone={form.timezone}
|
||||
setTimezone={(timezone) => set({ timezone })} savedName={base?.trigger?.subject_name ?? null}
|
||||
error={previewSays('trigger') ?? serverError(['trigger', 'trigger_kind'])}
|
||||
debounceError={previewSays('debounce_s') ?? serverError(['debounce_s'])} />
|
||||
) : (
|
||||
<FreqPicker freq={form.freq} timezone={form.timezone} setFreq={setFreq} setTimezone={(timezone) => set({ timezone })}
|
||||
freqError={freqProblem(form.freq) ?? previewSays('cron') ?? serverError(['cron'])}
|
||||
tzError={previewSays('timezone') ?? serverError(['timezone'])} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
|
||||
<div className="lg:sticky lg:top-4">
|
||||
<PreviewBox preview={preview} />
|
||||
<PreviewBox preview={preview} event={event} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -196,13 +213,13 @@ export function ScheduleEditor({ schedule, version, onSaved, onReload }: {
|
||||
error={serverError(['run_class'])}
|
||||
onChange={(runClass) => { runClassTouched.current = true; set({ runClass }) }} />
|
||||
<InputsSection workflow={workflow} fields={fields} inputs={form.inputs} variables={variables}
|
||||
preview={preview} recheck={recheck}
|
||||
preview={preview} recheck={recheck} event={event}
|
||||
error={previewSays('input_template') ?? serverError(['input_template'])}
|
||||
onChange={(inputs) => set({ inputs })} />
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-overlap-title">
|
||||
<h2 id="schedule-overlap-title" className="text-xs font-semibold">{T.overlap}</h2>
|
||||
<p className="text-xs leading-relaxed text-dim">{T.overlapText}</p>
|
||||
<Field label={T.misfire} htmlFor="schedule-misfire" hint={T.misfireHint} error={misfireError}>
|
||||
<Field label={T.misfire} htmlFor="schedule-misfire" hint={event ? TT.misfireHint : T.misfireHint} error={misfireError}>
|
||||
<select id="schedule-misfire" className="field w-auto max-w-full" value={form.misfireGraceS}
|
||||
aria-invalid={!!misfireError || undefined}
|
||||
aria-describedby={misfireError ? 'schedule-misfire-error' : 'schedule-misfire-hint'}
|
||||
@@ -291,13 +308,18 @@ const PREVIEW_DEBOUNCE_MS = 400
|
||||
|
||||
/**
|
||||
* 接下来 5 次触发和渲染出的输入(服务端算,前端不自己解析 cron)。改动中保留上一次的结果并标「正在计算」;
|
||||
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题
|
||||
* 非法时清掉上一次的时间,只留服务端的说明,免得人照着旧的时间以为新的频率没问题。
|
||||
*
|
||||
* trigger 不为空是事件触发:交触发条件、不交 cron,服务端按一次示例事件渲染输入(没有触发时间);对象还没选时不问
|
||||
*/
|
||||
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string): PreviewState {
|
||||
export function usePreview(cron: string, timezone: string, template: Record<string, string>, workflowId: string,
|
||||
trigger: ScheduleTrigger | null = null): PreviewState {
|
||||
const [state, setState] = useState<PreviewState>({ status: 'idle' })
|
||||
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId])
|
||||
const key = JSON.stringify([cron, timezone, Object.entries(template).sort(([a], [b]) => a.localeCompare(b)), workflowId,
|
||||
trigger])
|
||||
useEffect(() => {
|
||||
if (!cron || !timezone) {
|
||||
const subject = trigger ? trigger.source_id ?? trigger.workflow_id : null
|
||||
if (trigger ? !subject || !timezone : !cron || !timezone) {
|
||||
setState({ status: 'idle' })
|
||||
return
|
||||
}
|
||||
@@ -306,7 +328,7 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
api.schedules.preview({
|
||||
cron, timezone, n: 5,
|
||||
...(trigger ? { trigger_kind: 'event' as const, trigger, cron: '' } : { cron, n: 5 }), timezone,
|
||||
...(Object.keys(template).length ? { input_template: template } : {}),
|
||||
...(workflowId ? { workflow_id: workflowId } : {}),
|
||||
}, { signal: ctrl.signal })
|
||||
@@ -324,22 +346,23 @@ export function usePreview(cron: string, timezone: string, template: Record<stri
|
||||
clearTimeout(timer)
|
||||
ctrl.abort()
|
||||
}
|
||||
// key 已经把四项都编进去了
|
||||
// key 已经把五项都编进去了
|
||||
}, [key])
|
||||
return state
|
||||
}
|
||||
|
||||
export function PreviewBox({ preview }: { preview: PreviewState }) {
|
||||
export function PreviewBox({ preview, event = false }: { preview: PreviewState; event?: boolean }) {
|
||||
const data = preview.status === 'ok' || preview.status === 'loading' ? preview.data : undefined
|
||||
const tz = preview.status === 'ok' || preview.status === 'loading' ? preview.timezone : undefined
|
||||
return (
|
||||
<section className="rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-preview-title" aria-live="polite"
|
||||
data-schedule-preview="" data-status={preview.status}>
|
||||
data-schedule-preview="" data-status={preview.status} data-preview-mode={event ? 'event' : 'cron'}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h2 id="schedule-preview-title" className="text-xs font-semibold">{T.preview}</h2>
|
||||
<h2 id="schedule-preview-title" className="text-xs font-semibold">{event ? TT.preview : T.preview}</h2>
|
||||
{preview.status === 'loading' && <Spinner size={11} />}
|
||||
</div>
|
||||
{data && (
|
||||
{data && event && <EventPreview data={data} loading={preview.status === 'loading'} />}
|
||||
{data && !event && (
|
||||
<>
|
||||
{data.description_text && <div className="mb-2 text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
|
||||
{data.next?.length ? (
|
||||
@@ -362,7 +385,7 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
|
||||
</>
|
||||
)}
|
||||
{!data && preview.status === 'loading' && <p className="text-xs text-faint">{T.previewLoading}</p>}
|
||||
{preview.status === 'idle' && <p className="text-xs text-faint">{T.previewIdle}</p>}
|
||||
{preview.status === 'idle' && <p className="text-xs text-faint">{event ? TT.previewIdle : T.previewIdle}</p>}
|
||||
{preview.status === 'invalid' && <p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">{preview.message}</p>}
|
||||
{preview.status === 'error' && (
|
||||
<p className="text-xs leading-relaxed text-[var(--err)]" data-preview-error="">
|
||||
@@ -373,6 +396,40 @@ export function PreviewBox({ preview }: { preview: PreviewState }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件触发的预览:触发说明、按哪一次事件渲染(对象最近一次的事件,没有时是示例值、照实说),渲染出的输入逐项列出。
|
||||
* 没有触发时间可列:事件什么时候来不由时间定
|
||||
*/
|
||||
function EventPreview({ data, loading }: { data: SchedulePreview; loading: boolean }) {
|
||||
const inputs = Object.entries(data.inputs ?? {})
|
||||
return (
|
||||
<div className={clsx('space-y-2', loading && 'opacity-60')}>
|
||||
{data.description_text && <div className="text-xs text-dim" data-preview-desc="">{data.description_text}</div>}
|
||||
{data.event && (
|
||||
<div className="text-2xs leading-relaxed" data-preview-event={data.event.real ? 'real' : 'example'}>
|
||||
<div className="text-fg">{data.event.text}</div>
|
||||
<div className={data.event.real ? 'text-faint' : 'text-[var(--warn)]'}>
|
||||
{data.event.real ? TT.previewLatest : TT.previewExample}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-2xs text-faint">{TT.previewInputs}</div>
|
||||
{inputs.length ? (
|
||||
<dl className="mt-0.5 space-y-0.5 text-2xs">
|
||||
{inputs.map(([k, v]) => (
|
||||
<div key={k} className="flex min-w-0 gap-1.5" data-preview-input={k}>
|
||||
<dt className="mono shrink-0 text-dim">{k}</dt>
|
||||
<dd className="mono min-w-0 break-all text-fg">{v === '' ? T.renderedEmpty : v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : <p className="text-2xs text-faint">{TT.previewNoInputs}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 运行类别
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -477,25 +534,34 @@ function useInputFields(workflow: Workflow | undefined, runClass: ScheduleRunCla
|
||||
return fetched?.key === key ? fetched.state : { status: 'loading' }
|
||||
}
|
||||
|
||||
/** 输入模板里能用的变量:服务端给,各处共用一份;取不到时为空表,变量按钮照样在,只是说没有可用的 */
|
||||
let variablesOnce: Promise<ScheduleVariable[]> | null = null
|
||||
function useVariables(): ScheduleVariable[] | null {
|
||||
const [vars, setVars] = useState<ScheduleVariable[] | null>(null)
|
||||
/**
|
||||
* 输入模板里能用的变量:服务端给,各处共用一份(按 cron 的一份,事件触发按事件种类各一份);取不到时为空表,变量按钮
|
||||
* 照样在,只是说没有可用的
|
||||
*/
|
||||
const variablesOnce = new Map<string, Promise<ScheduleVariable[]>>()
|
||||
function useVariables(event: ScheduleEventKind | null = null): ScheduleVariable[] | null {
|
||||
const [vars, setVars] = useState<{ key: string; list: ScheduleVariable[] } | null>(null)
|
||||
const key = event ?? 'cron'
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
variablesOnce ??= api.schedules.variables().then((v) => (Array.isArray(v) ? v : []), (e) => {
|
||||
variablesOnce = null
|
||||
throw e
|
||||
})
|
||||
variablesOnce.then((v) => { if (alive) setVars(v) }, () => { if (alive) setVars([]) })
|
||||
let once = variablesOnce.get(key)
|
||||
if (!once) {
|
||||
once = (event ? api.schedules.eventVariables(event) : api.schedules.variables())
|
||||
.then((v) => (Array.isArray(v) ? v : []), (e) => {
|
||||
variablesOnce.delete(key)
|
||||
throw e
|
||||
})
|
||||
variablesOnce.set(key, once)
|
||||
}
|
||||
once.then((v) => { if (alive) setVars({ key, list: v }) }, () => { if (alive) setVars({ key, list: [] }) })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
return vars
|
||||
}, [key]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return vars?.key === key ? vars.list : null
|
||||
}
|
||||
|
||||
const shownValue = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : JSON.stringify(v))
|
||||
|
||||
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, error, onChange }: {
|
||||
function InputsSection({ workflow, fields, inputs, variables, preview, recheck, event = false, error, onChange }: {
|
||||
workflow: Workflow | undefined
|
||||
fields: FieldsState
|
||||
inputs: Record<string, string>
|
||||
@@ -503,6 +569,8 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
|
||||
preview: PreviewState
|
||||
/** 保存时服务端会不会核对输入(新建,或者改了工作流、运行类别、输入):会的话必填没填、字段对不上都要先处理 */
|
||||
recheck: boolean
|
||||
/** 事件触发:模板变量另按事件计算(说明不同) */
|
||||
event?: boolean
|
||||
error?: string
|
||||
onChange: (inputs: Record<string, string>) => void
|
||||
}) {
|
||||
@@ -621,7 +689,7 @@ function InputsSection({ workflow, fields, inputs, variables, preview, recheck,
|
||||
<section className="space-y-2.5 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-inputs-title">
|
||||
<div>
|
||||
<h2 id="schedule-inputs-title" className="text-xs font-semibold">{T.inputs}</h2>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{T.inputsHint}</p>
|
||||
<p className="mt-0.5 text-2xs leading-relaxed text-faint">{event ? TT.inputsHint : T.inputsHint}</p>
|
||||
{workflow && fields.status === 'ok' && (
|
||||
<p className="mt-0.5 text-2xs text-faint" data-inputs-source="">
|
||||
{fields.version != null ? T.inputsFromVersion(fields.version) : T.inputsFromDraft}
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { Schedule } from '../../types'
|
||||
import { ClassChip } from '../runs/parts'
|
||||
import { ScheduleCreatePage, ScheduleDetailPage } from './ScheduleDetail'
|
||||
import {
|
||||
EnableSwitch, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled, targetKindOf,
|
||||
targetText,
|
||||
EnableSwitch, EventChip, LastFire, NextRun, isUnsupported, rememberScheduleNames, removeSchedule, runNow, setEnabled,
|
||||
targetKindOf, targetText,
|
||||
} from './parts'
|
||||
|
||||
/**
|
||||
@@ -173,6 +173,7 @@ function ScheduleRow({ s, now, busy, onToggle, onRun, onDelete }: {
|
||||
{workflow
|
||||
? <ClassChip runClass={s.run_class} />
|
||||
: <span className="chip shrink-0" data-schedule-target-kind={kind}>{T.targetKind[kind]}</span>}
|
||||
{s.trigger_kind === 'event' && <EventChip />}
|
||||
{!s.enabled && <span className="chip shrink-0" data-schedule-paused="">{T.pausedChip}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-dim" title={`${target} · ${s.description_text}`} data-schedule-desc="">
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../../api/client'
|
||||
import { Field, Notice, Skeleton, useRadioGroup } from '../../components/ui'
|
||||
import { errorMessage } from '../../lib/errors'
|
||||
import { timezoneOptions } from '../../lib/freq'
|
||||
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
|
||||
import type { ScheduleEventKind, ScheduleTriggerKind, ScheduleTriggerOptions } from '../../types'
|
||||
import {
|
||||
BACKFILL_ORIGIN, DEBOUNCE_OPTIONS, EVENT_KINDS, TRIGGER_KINDS, graceLabel, switchEvent, type TriggerForm,
|
||||
} from './model'
|
||||
import { isUnsupported } from './parts'
|
||||
|
||||
/**
|
||||
* 数据事件触发的表单部件(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」(TriggerModePicker),
|
||||
* 选了事件触发时用 EventSection 替换频率那一节——事件种类、对象、过滤、去抖、时区。
|
||||
*
|
||||
* 能选哪些对象、不能选的为什么不能选,都是服务端给的(GET /api/schedules/trigger-options):这里只照着画,不自己判「是不是
|
||||
* 按期累积」「发没发布」;会不会串成环保存时由服务端核对,422 的说明落到这一节(error)。
|
||||
*/
|
||||
|
||||
/** 新建时选触发方式;修改时只读地写出是哪一种(触发方式建好之后不能改) */
|
||||
export function TriggerModePicker({ value, onChange, fixed }: {
|
||||
value: ScheduleTriggerKind
|
||||
onChange: (v: ScheduleTriggerKind) => void
|
||||
/** 修改中的定时任务:只读 */
|
||||
fixed?: boolean
|
||||
}) {
|
||||
const radio = useRadioGroup(TRIGGER_KINDS, value, onChange)
|
||||
if (fixed) {
|
||||
return <p className="text-2xs leading-relaxed text-faint" data-trigger-mode-fixed={value}>{TT.modeFixed(TT.mode[value])}</p>
|
||||
}
|
||||
return (
|
||||
<div data-trigger-modes="">
|
||||
<div className="label" id="schedule-trigger-mode">{TT.modeLabel}</div>
|
||||
<div role="radiogroup" aria-labelledby="schedule-trigger-mode" className="flex flex-wrap gap-1">
|
||||
{TRIGGER_KINDS.map((k) => (
|
||||
<button key={k} type="button" {...radio(k)} onClick={() => onChange(k)} data-trigger-mode={k}
|
||||
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
value === k ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{TT.mode[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.modeHint[value]}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type OptionsState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ok'; data: ScheduleTriggerOptions }
|
||||
| { status: 'error'; error: unknown; retry: () => void }
|
||||
|
||||
/** 可选的对象:各处共用一份,失败时清掉、下次再取 */
|
||||
let optionsOnce: Promise<ScheduleTriggerOptions> | null = null
|
||||
function useTriggerOptions(): OptionsState {
|
||||
const [state, setState] = useState<OptionsState>({ status: 'loading' })
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setState({ status: 'loading' })
|
||||
optionsOnce ??= api.schedules.triggerOptions().catch((e) => {
|
||||
optionsOnce = null
|
||||
throw e
|
||||
})
|
||||
optionsOnce.then(
|
||||
(data) => { if (alive) setState({ status: 'ok', data }) },
|
||||
(error) => { if (alive) setState({ status: 'error', error, retry: () => setAttempt((n) => n + 1) }) },
|
||||
)
|
||||
return () => { alive = false }
|
||||
}, [attempt])
|
||||
return state
|
||||
}
|
||||
|
||||
export function EventSection({ value, onChange, timezone, setTimezone, savedName, error, debounceError }: {
|
||||
value: TriggerForm
|
||||
onChange: (t: TriggerForm) => void
|
||||
timezone: string
|
||||
setTimezone: (tz: string) => void
|
||||
/** 修改时对象的名字(不在可选列表里时写它,标「已不存在」) */
|
||||
savedName?: string | null
|
||||
/** 这一节的问题(还没选对象)或保存被拒时服务端的说明(对象不合用、会循环触发) */
|
||||
error?: string
|
||||
debounceError?: string
|
||||
}) {
|
||||
const options = useTriggerOptions()
|
||||
const radio = useRadioGroup(EVENT_KINDS, value.event, (event) => onChange(switchEvent(value, event)))
|
||||
const zones = useMemo(() => timezoneOptions(timezone), [timezone])
|
||||
const set = (patch: Partial<TriggerForm>) => onChange({ ...value, ...patch })
|
||||
const kind = value.event
|
||||
const label = TT.subject[kind]
|
||||
const entry = options.status === 'ok' ? options.data.events.find((e) => e.event === kind) : undefined
|
||||
const objects = entry?.objects ?? []
|
||||
const listed = objects.some((o) => o.id === value.subjectId)
|
||||
|
||||
let picker
|
||||
if (options.status === 'loading') picker = <Skeleton rows={1} height={30} />
|
||||
else if (options.status === 'error') {
|
||||
picker = (
|
||||
<Notice tone="err">
|
||||
{isUnsupported(options.error) ? TT.optionsUnsupported : `${TT.optionsFailed}:${errorMessage(options.error)}`}
|
||||
{!isUnsupported(options.error) && (
|
||||
<button type="button" className="btn btn-xs ml-2" onClick={options.retry}>{TT.retry}</button>
|
||||
)}
|
||||
</Notice>
|
||||
)
|
||||
} else {
|
||||
picker = (
|
||||
<select id="schedule-trigger-subject" className="field" value={value.subjectId} data-trigger-subject={kind}
|
||||
aria-invalid={!!error || undefined}
|
||||
aria-describedby={error ? 'schedule-trigger-subject-error' : undefined}
|
||||
onChange={(e) => set({ subjectId: e.target.value })}>
|
||||
<option value="">{objects.length ? TT.subjectPlaceholder(label) : TT.subjectEmpty[kind]}</option>
|
||||
{value.subjectId && !listed && <option value={value.subjectId}>{TT.subjectGone(savedName || value.subjectId)}</option>}
|
||||
{objects.map((o) => (
|
||||
<option key={o.id} value={o.id} disabled={!o.ok && o.id !== value.subjectId} data-trigger-object-ok={String(o.ok)}>
|
||||
{o.ok || !o.reason ? o.name : TT.subjectNotOk(o.name, o.reason)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-lg border bg-panel p-3.5" aria-labelledby="schedule-trigger-title" data-trigger-section="">
|
||||
<h2 id="schedule-trigger-title" className="text-xs font-semibold">{TT.section}</h2>
|
||||
<div>
|
||||
<div className="label" id="schedule-trigger-event">{TT.eventLabel}</div>
|
||||
<div role="radiogroup" aria-labelledby="schedule-trigger-event" className="flex flex-wrap gap-1">
|
||||
{EVENT_KINDS.map((e: ScheduleEventKind) => (
|
||||
<button key={e} type="button" {...radio(e)} onClick={() => onChange(switchEvent(value, e))} data-trigger-event={e}
|
||||
className={clsx('rounded-md border px-2.5 py-1 text-xs transition-colors',
|
||||
kind === e ? 'border-[var(--accent)] bg-accent-soft text-fg' : 'text-dim hover:bg-hover')}>
|
||||
{TT.event[e]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-2xs leading-relaxed text-faint">{TT.eventHint[kind]}</p>
|
||||
</div>
|
||||
|
||||
<Field label={label} htmlFor="schedule-trigger-subject" required>
|
||||
{picker}
|
||||
</Field>
|
||||
{error && (
|
||||
<div id="schedule-trigger-subject-error" className="-mt-1.5 text-2xs leading-relaxed text-[var(--err)]" role="alert"
|
||||
data-trigger-error="">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'table.version' && (
|
||||
<Field label={TT.periodLabel} htmlFor="schedule-trigger-period" hint={TT.periodHint}>
|
||||
<select id="schedule-trigger-period" className="field w-auto max-w-full" value={value.period} data-trigger-period=""
|
||||
aria-describedby="schedule-trigger-period-hint"
|
||||
onChange={(e) => set({ period: e.target.value === 'new' ? 'new' : 'any' })}>
|
||||
<option value="any">{TT.period.any}</option>
|
||||
<option value="new">{TT.period.new}</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{kind === 'workflow.finished' && options.status === 'ok' && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Field label={TT.appLabel} htmlFor="schedule-trigger-app" className="min-w-[12rem] flex-1">
|
||||
<select id="schedule-trigger-app" className="field" value={value.appId} data-trigger-app=""
|
||||
onChange={(e) => set({ appId: e.target.value })}>
|
||||
<option value="">{TT.appAny}</option>
|
||||
{value.appId && !options.data.apps.some((a) => a.id === value.appId) && (
|
||||
<option value={value.appId}>{TT.subjectGone(value.appId)}</option>
|
||||
)}
|
||||
{options.data.apps.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={TT.originLabel} htmlFor="schedule-trigger-origin" className="min-w-[12rem] flex-1">
|
||||
<select id="schedule-trigger-origin" className="field" value={value.originKind} data-trigger-origin=""
|
||||
onChange={(e) => set({ originKind: e.target.value })}>
|
||||
<option value="">{TT.originAny}</option>
|
||||
{options.data.origins.map((o) => <option key={o.kind} value={o.kind}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-faint">{TT.filterHint}</p>
|
||||
{/* 缺省不认的两种成功结束:途经驳回的、应用补算的(服务端 triggers.matches),要认的勾上 */}
|
||||
<div className="space-y-1 pt-1">
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={value.includeRejected} data-trigger-include-rejected=""
|
||||
onChange={(e) => set({ includeRejected: e.target.checked })} />
|
||||
<span>{TT.includeRejected}<span className="block text-2xs leading-relaxed text-faint">{TT.includeRejectedHint}</span></span>
|
||||
</label>
|
||||
{value.originKind !== BACKFILL_ORIGIN && (
|
||||
<label className="flex items-start gap-2 text-xs">
|
||||
<input type="checkbox" className="mt-0.5" checked={value.includeBackfill} data-trigger-include-backfill=""
|
||||
onChange={(e) => set({ includeBackfill: e.target.checked })} />
|
||||
<span>{TT.includeBackfill}<span className="block text-2xs leading-relaxed text-faint">{TT.includeBackfillHint}</span></span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-faint" data-trigger-chain="">{TT.chainHint(options.data.max_depth)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label={TT.debounce} htmlFor="schedule-trigger-debounce" hint={TT.debounceHint} error={debounceError}>
|
||||
<select id="schedule-trigger-debounce" className="field w-auto max-w-full" value={value.debounceS} data-trigger-debounce=""
|
||||
aria-invalid={!!debounceError || undefined}
|
||||
aria-describedby={debounceError ? 'schedule-trigger-debounce-error' : 'schedule-trigger-debounce-hint'}
|
||||
onChange={(e) => set({ debounceS: Number(e.target.value) })}>
|
||||
{[...new Set([...DEBOUNCE_OPTIONS, value.debounceS])].sort((a, b) => a - b).map((s) => (
|
||||
<option key={s} value={s}>{TT.debounceOption(s ? graceLabel(s) : '')}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={TT.timezone} htmlFor="schedule-trigger-timezone" hint={TT.timezoneHint}>
|
||||
<select id="schedule-trigger-timezone" className="field" value={timezone} data-timezone=""
|
||||
aria-describedby="schedule-trigger-timezone-hint" onChange={(e) => setTimezone(e.target.value)}>
|
||||
{zones.map((z) => <option key={z.tz} value={z.tz}>{z.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
* scripts/check-schedules.mjs 直接借 vite 转译后调用它们。
|
||||
*/
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
|
||||
import type {
|
||||
Schedule, ScheduleCreate, ScheduleEvent, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass, ScheduleTargetCreate,
|
||||
ScheduleTargetKind, ScheduleWebhookFormat, ScheduleWebhookIn, ScheduleWebhookTest, Workflow, WorkflowVersion,
|
||||
Schedule, ScheduleCreate, ScheduleEvent, ScheduleEventKind, ScheduleMaintenanceInputs, SchedulePatch, ScheduleRunClass,
|
||||
ScheduleTargetCreate, ScheduleTargetKind, ScheduleTrigger, ScheduleTriggerKind, ScheduleWebhookFormat, ScheduleWebhookIn,
|
||||
ScheduleWebhookTest, Workflow, WorkflowVersion,
|
||||
} from '../../types'
|
||||
export * from '../../lib/freq'
|
||||
import { DEFAULT_FREQ, DEFAULT_GRACE_S, browserTimezone, cronOf, freqOf, freqProblem, type Freq } from '../../lib/freq'
|
||||
@@ -31,9 +33,74 @@ export interface WebhookForm {
|
||||
events: ScheduleEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件触发的设置(docs/design/orchestration-round/00-总方案.md §4)。对象按事件种类:表格、数据源、上游工作流都记在 subjectId,
|
||||
* 交上去时按种类写成 source_id / workflow_id。过滤只在对应的事件种类上交:期次给上传表格,应用、来源给上游工作流
|
||||
*/
|
||||
export interface TriggerForm {
|
||||
event: ScheduleEventKind
|
||||
subjectId: string
|
||||
period: 'any' | 'new'
|
||||
appId: string
|
||||
originKind: string
|
||||
/** 也认途经驳回的、也认应用补算的(只给上游工作流,缺省都不认) */
|
||||
includeRejected: boolean
|
||||
includeBackfill: boolean
|
||||
debounceS: number
|
||||
}
|
||||
|
||||
/** 应用补算的来源:选了「只认应用补算」时,「也认应用补算的」不再适用 */
|
||||
export const BACKFILL_ORIGIN = 'app_backfill'
|
||||
|
||||
export const EVENT_KINDS: readonly ScheduleEventKind[] = ['table.version', 'workflow.finished', 'source.changed']
|
||||
export const TRIGGER_KINDS: readonly ScheduleTriggerKind[] = ['cron', 'event']
|
||||
/** 去抖的选项(秒):不去抖、1 分钟(缺省)、5 分钟、15 分钟、1 小时 */
|
||||
export const DEBOUNCE_OPTIONS: readonly number[] = [0, 60, 300, 900, 3600]
|
||||
export const DEFAULT_DEBOUNCE_S = 60
|
||||
|
||||
export function triggerFormOf(t: ScheduleTrigger | null | undefined): TriggerForm {
|
||||
const event = t?.event && EVENT_KINDS.includes(t.event) ? t.event : 'table.version'
|
||||
return {
|
||||
event,
|
||||
subjectId: (event === 'workflow.finished' ? t?.workflow_id : t?.source_id) ?? '',
|
||||
period: t?.period === 'new' ? 'new' : 'any',
|
||||
appId: t?.app_id ?? '',
|
||||
originKind: t?.origin_kind ?? '',
|
||||
includeRejected: t?.include_rejected === true,
|
||||
includeBackfill: t?.include_backfill === true,
|
||||
debounceS: typeof t?.debounce_s === 'number' ? t.debounce_s : DEFAULT_DEBOUNCE_S,
|
||||
}
|
||||
}
|
||||
|
||||
/** 交上去的触发条件:对象按种类写键,过滤只带对这种事件有意义、又选了的 */
|
||||
export function triggerBody(t: TriggerForm): ScheduleTrigger {
|
||||
const out: ScheduleTrigger = { event: t.event, debounce_s: t.debounceS }
|
||||
if (t.event === 'workflow.finished') {
|
||||
out.workflow_id = t.subjectId
|
||||
if (t.appId) out.app_id = t.appId
|
||||
if (t.originKind) out.origin_kind = t.originKind
|
||||
if (t.includeRejected) out.include_rejected = true
|
||||
if (t.includeBackfill && t.originKind !== BACKFILL_ORIGIN) out.include_backfill = true
|
||||
} else {
|
||||
out.source_id = t.subjectId
|
||||
if (t.event === 'table.version' && t.period === 'new') out.period = 'new'
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 换了事件种类:对象、过滤都不再适用,清掉;去抖留着 */
|
||||
export function switchEvent(t: TriggerForm, event: ScheduleEventKind): TriggerForm {
|
||||
if (t.event === event) return t
|
||||
return { event, subjectId: '', period: 'any', appId: '', originKind: '', includeRejected: false, includeBackfill: false,
|
||||
debounceS: t.debounceS }
|
||||
}
|
||||
|
||||
export interface ScheduleForm {
|
||||
name: string
|
||||
workflowId: string
|
||||
/** 触发方式:只在新建时选,修改时跟着读到的定时任务(建好之后不能改) */
|
||||
triggerKind: ScheduleTriggerKind
|
||||
trigger: TriggerForm
|
||||
freq: Freq
|
||||
timezone: string
|
||||
runClass: ScheduleRunClass
|
||||
@@ -49,15 +116,19 @@ export interface ScheduleForm {
|
||||
export function formOf(s: Schedule | null, timezone: string = browserTimezone()): ScheduleForm {
|
||||
if (!s) {
|
||||
return {
|
||||
name: '', workflowId: '', freq: { ...DEFAULT_FREQ }, timezone, runClass: 'formal', inputs: {}, enabled: true,
|
||||
misfireGraceS: DEFAULT_GRACE_S,
|
||||
name: '', workflowId: '', triggerKind: 'cron', trigger: triggerFormOf(null), freq: { ...DEFAULT_FREQ }, timezone,
|
||||
runClass: 'formal', inputs: {}, enabled: true, misfireGraceS: DEFAULT_GRACE_S,
|
||||
webhook: { on: false, format: 'generic', url: '', replaceUrl: false, secret: '', secretMode: 'keep', events: [...SCHEDULE_EVENTS] },
|
||||
}
|
||||
}
|
||||
const event = s.trigger_kind === 'event'
|
||||
return {
|
||||
name: s.name,
|
||||
workflowId: s.workflow_id,
|
||||
freq: freqOf(s.cron),
|
||||
triggerKind: event ? 'event' : 'cron',
|
||||
trigger: triggerFormOf(event ? s.trigger : null),
|
||||
// 事件触发的任务没有 cron(存空串):频率留缺省,不交也不比
|
||||
freq: event ? { ...DEFAULT_FREQ } : freqOf(s.cron),
|
||||
timezone: s.timezone,
|
||||
runClass: s.run_class,
|
||||
inputs: { ...(s.input_template ?? {}) },
|
||||
@@ -79,10 +150,13 @@ const sortEvents = (events: ScheduleEvent[]) => SCHEDULE_EVENTS.filter((e) => ev
|
||||
|
||||
export function createBody(f: ScheduleForm): ScheduleCreate {
|
||||
const w = f.webhook
|
||||
const event = f.triggerKind === 'event'
|
||||
return {
|
||||
name: f.name.trim(),
|
||||
workflow_id: f.workflowId,
|
||||
cron: cronOf(f.freq),
|
||||
// 事件触发:不写 cron(交空串),带触发方式和触发条件;cron 任务的请求体和以前一字不差
|
||||
...(event ? { trigger_kind: 'event' as const, trigger: triggerBody(f.trigger) } : {}),
|
||||
cron: event ? '' : cronOf(f.freq),
|
||||
timezone: f.timezone,
|
||||
run_class: f.runClass,
|
||||
input_template: templateOf(f.inputs),
|
||||
@@ -109,7 +183,9 @@ export function patchBody(f: ScheduleForm, initial: ScheduleForm, saved: Schedul
|
||||
const out: Omit<SchedulePatch, 'version'> = {}
|
||||
if (f.name.trim() !== initial.name.trim()) out.name = f.name.trim()
|
||||
if (f.workflowId !== initial.workflowId) out.workflow_id = f.workflowId
|
||||
if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq)
|
||||
if (f.triggerKind === 'event') {
|
||||
if (!sameJson(triggerBody(f.trigger), triggerBody(initial.trigger))) out.trigger = triggerBody(f.trigger)
|
||||
} else if (cronOf(f.freq) !== cronOf(initial.freq)) out.cron = cronOf(f.freq)
|
||||
if (f.timezone !== initial.timezone) out.timezone = f.timezone
|
||||
if (f.runClass !== initial.runClass) out.run_class = f.runClass
|
||||
if (!sameJson(sortedEntries(templateOf(f.inputs)), sortedEntries(templateOf(initial.inputs)))) {
|
||||
@@ -157,6 +233,8 @@ export function rebaseForm(cur: ScheduleForm, sent: ScheduleForm, saved: Schedul
|
||||
return {
|
||||
name: pick('name'),
|
||||
workflowId: pick('workflowId'),
|
||||
triggerKind: pick('triggerKind'),
|
||||
trigger: pick('trigger'),
|
||||
freq: pick('freq'),
|
||||
timezone: pick('timezone'),
|
||||
runClass: pick('runClass'),
|
||||
@@ -279,6 +357,10 @@ export function serverFieldValue(field: string, f: ScheduleForm): string | null
|
||||
case 'name': return f.name.trim()
|
||||
case 'workflow_id': return f.workflowId
|
||||
case 'cron': return cronOf(f.freq)
|
||||
// 触发条件连带目标一起算:查环时换了目标,「会循环触发」也就不一定成立了
|
||||
case 'trigger':
|
||||
case 'debounce_s': return JSON.stringify([triggerBody(f.trigger), f.workflowId])
|
||||
case 'trigger_kind': return f.triggerKind
|
||||
case 'timezone': return f.timezone
|
||||
case 'run_class': return JSON.stringify([f.runClass, f.workflowId])
|
||||
case 'input_template': return JSON.stringify([sortedEntries(templateOf(f.inputs)), f.workflowId, f.runClass])
|
||||
@@ -322,7 +404,8 @@ export function inputProblems(inputs: Record<string, string>, fields: readonly I
|
||||
// 表单的问题
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FormField = 'name' | 'workflow' | 'freq' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret' | 'events'
|
||||
export type FormField = 'name' | 'workflow' | 'freq' | 'trigger' | 'timezone' | 'runClass' | 'inputs' | 'url' | 'secret'
|
||||
| 'events'
|
||||
|
||||
/** 判表单问题时要的、表单之外的事 */
|
||||
export interface FormContext {
|
||||
@@ -345,8 +428,13 @@ export function formProblems(f: ScheduleForm, saved: Schedule | null, ctx: FormC
|
||||
const out: Partial<Record<FormField, string>> = {}
|
||||
if (!f.name.trim()) out.name = T.problem.name
|
||||
if (!f.workflowId) out.workflow = T.problem.workflow
|
||||
const freq = freqProblem(f.freq)
|
||||
if (freq) out.freq = freq
|
||||
if (f.triggerKind === 'event') {
|
||||
// 事件触发没有频率:对象要选上(能不能选、会不会成环由服务端判,保存时 422 落到这一项)
|
||||
if (!f.trigger.subjectId) out.trigger = TT.problemSubject(TT.subject[f.trigger.event])
|
||||
} else {
|
||||
const freq = freqProblem(f.freq)
|
||||
if (freq) out.freq = freq
|
||||
}
|
||||
if (!f.timezone) out.timezone = T.problem.timezone
|
||||
if (f.workflowId && (ctx.recheck ?? !saved)) {
|
||||
if (f.runClass === 'formal' && ctx.published === null) out.runClass = T.problem.notPublished
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiError, api } from '../../api/client'
|
||||
import { StatusPill, confirmDialog, toast } from '../../components/ui'
|
||||
import { formatDateTime, parseServerTime } from '../../lib/format'
|
||||
import { SCHEDULE_TEXT as T } from '../../lib/terms'
|
||||
import { TRIGGER_TEXT as TT } from '../../lib/termsTrigger'
|
||||
import type { Schedule, ScheduleFire, ScheduleFireStatus, ScheduleRunResult } from '../../types'
|
||||
import { browserTimezone, formatUntil, formatZoned, maintenanceSummary } from './model'
|
||||
|
||||
@@ -71,14 +72,44 @@ export function ZonedTime({ date, tz, now, relative = false, className }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜 */
|
||||
/**
|
||||
* 下次运行:暂停中、往后不再触发都照实说,不写「—」让人猜。事件触发的任务没有「到点」:写它现在在等什么——去抖中(几次事件、
|
||||
* 什么时候合成一次触发)、有事件等待派发(调度没开时说不会派发)、等待事件(服务端 trigger_state)
|
||||
*/
|
||||
export function NextRun({ schedule, now }: { schedule: Schedule; now: number }) {
|
||||
if (!schedule.enabled) return <span className="text-faint">{T.nextPaused}</span>
|
||||
if (schedule.trigger_kind === 'event') return <EventWait schedule={schedule} now={now} />
|
||||
const d = parseServerTime(schedule.next_run_at)
|
||||
if (!d) return <span className="text-faint">{T.nextNone}</span>
|
||||
return <ZonedTime date={d} tz={schedule.timezone} now={now} relative className="text-dim" />
|
||||
}
|
||||
|
||||
function EventWait({ schedule, now }: { schedule: Schedule; now: number }) {
|
||||
const st = schedule.trigger_state
|
||||
const due = parseServerTime(st?.due_at ?? null)
|
||||
if (st && st.waiting > 0 && due) {
|
||||
const when = formatZoned(due, schedule.timezone, 'short', new Date(now))
|
||||
return <span className="text-dim" data-schedule-event-wait="waiting">{TT.waiting(st.waiting, when)}</span>
|
||||
}
|
||||
if (st && st.events > 0) {
|
||||
return (
|
||||
<span className={st.scheduler_on ? 'text-dim' : 'text-[var(--warn)]'} data-schedule-event-wait="pending">
|
||||
{st.scheduler_on ? TT.pending(st.events) : TT.pendingOff(st.events)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-faint" data-schedule-event-wait="idle" title={st && !st.scheduler_on ? TT.schedulerOff : undefined}>
|
||||
{TT.nextEvent}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 列表、详情页头里「事件触发」的小标签:和运行类别的标签并排 */
|
||||
export function EventChip() {
|
||||
return <span className="chip shrink-0" title={TT.chipTitle} data-schedule-trigger-kind="event">{TT.chip}</span>
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 触发
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -349,13 +380,24 @@ export function ScheduleTag({ scheduleId, name }: { scheduleId: string; name?: s
|
||||
*
|
||||
* 前面带和「定时」标记一样的日历图标:页头这一行都是灰字,不带记号看不出这一段能点。页头窄时这一行换行,链接至少留
|
||||
* 几个字宽(以前这一行不换行,只有链接能缩,窄窗口里被挤成 0 宽,Tab 停在一个看不见的链接上),再长截断,全名在悬停里。
|
||||
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样
|
||||
* 定时任务已经删除的写成纯文本(链过去只有「不存在」页),同一行的「工作流已删除」也是这样。
|
||||
*
|
||||
* 由数据事件触发的(运行来源里的 trigger,发起时写进、在封存范围内):整句写「由事件触发:<事件>(定时任务「X」)」,
|
||||
* 事件的说法是服务端发起时写下的那一句,不在这里拼
|
||||
*/
|
||||
export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: string; startedBy?: string | null }) {
|
||||
export function RunScheduleSource({ scheduleId, startedBy, trigger }: {
|
||||
scheduleId: string; startedBy?: string | null
|
||||
/** 运行来源里的 trigger(服务端 outbox.EventFire.origin);不是事件触发的为空 */
|
||||
trigger?: { text?: unknown; sample?: unknown } | null
|
||||
}) {
|
||||
const names = useScheduleNames([scheduleId])
|
||||
const recorded = startedBy?.startsWith(T.actorPrefix) ? startedBy.slice(T.actorPrefix.length).trim() : null
|
||||
const auto = !startedBy || recorded != null
|
||||
const name = names.get(scheduleId) || recorded || null
|
||||
const eventText = typeof trigger?.text === 'string' && trigger.text ? trigger.text : null
|
||||
const sample = !!eventText && trigger?.sample === true
|
||||
const said = (gone: boolean) => (sample ? TT.runSourceSample(eventText, name) : gone ? TT.runSourceGone(eventText!, name)
|
||||
: TT.runSource(eventText!, name))
|
||||
const body = (text: string) => (
|
||||
<>
|
||||
<CalendarClock size={10} aria-hidden className="shrink-0" />
|
||||
@@ -365,16 +407,17 @@ export function RunScheduleSource({ scheduleId, startedBy }: { scheduleId: strin
|
||||
const source = scheduleGone(scheduleId) ? (
|
||||
<span title={T.runSourceGoneTitle} data-run-schedule-gone={scheduleId}
|
||||
className="inline-flex min-w-[7em] max-w-full items-center gap-1">
|
||||
{body(auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
|
||||
{body(eventText ? said(true) : auto ? T.runSourceGone(name) : T.runSourceManualGone(name))}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={`/schedules/${encodeURIComponent(scheduleId)}`}
|
||||
title={T.runSourceTitle(name)}
|
||||
title={eventText ? said(false) : T.runSourceTitle(name)}
|
||||
data-run-schedule-link={scheduleId}
|
||||
data-run-schedule-event={eventText ? '' : undefined}
|
||||
className="inline-flex min-w-[7em] max-w-full items-center gap-1 rounded-sm text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
{body(auto ? T.runSource(name) : T.runSourceManual(name))}
|
||||
{body(eventText ? said(false) : auto ? T.runSource(name) : T.runSourceManual(name))}
|
||||
</Link>
|
||||
)
|
||||
if (auto || !startedBy) return source
|
||||
|
||||
+108
-2
@@ -3880,8 +3880,8 @@ export type ScheduleRunClass = 'formal' | 'exploratory'
|
||||
export type ScheduleWebhookFormat = 'generic' | 'feishu' | 'wecom' | 'dingtalk'
|
||||
/** 订阅的事件:运行结束、运行失败、停在人工审批上(只在真有待审批时发) */
|
||||
export type ScheduleEvent = 'finished' | 'failed' | 'awaiting_approval'
|
||||
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发 */
|
||||
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire'
|
||||
/** 触发的来源:到点、手动「立即运行」、错过之后在宽限内补发、由数据事件触发(trigger_kind=event 的任务) */
|
||||
export type ScheduleFireKind = 'scheduled' | 'manual' | 'misfire' | 'event'
|
||||
/** 触发的结局:发起了运行、上一次还没结束而跳过、错过时间超出宽限而跳过、发起时被拒 */
|
||||
export type ScheduleFireStatus = 'started' | 'skipped_overlap' | 'skipped_misfire' | 'failed_to_start'
|
||||
/**
|
||||
@@ -4074,6 +4074,112 @@ export interface ScheduleRunResult {
|
||||
run_id: string | null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4;服务端 scheduler/triggers.py、outbox.py):一个
|
||||
// trigger_kind=event 的定时任务,目标只能是工作流。同名接口会合并,这里只往上面几个类型上补字段
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 触发方式:按 cron 到点、由数据事件触发。老服务端不给,按 cron 看 */
|
||||
export type ScheduleTriggerKind = 'cron' | 'event'
|
||||
/** 事件种类:上传表格出了新的一期、上游工作流运行成功结束、数据源有要处理的变化 */
|
||||
export type ScheduleEventKind = 'table.version' | 'workflow.finished' | 'source.changed'
|
||||
|
||||
/** 触发条件(服务端规范化过的那一份)。对象键按事件种类:表格、数据源写 source_id,上游工作流写 workflow_id */
|
||||
export interface ScheduleTrigger {
|
||||
event: ScheduleEventKind
|
||||
source_id?: string
|
||||
workflow_id?: string
|
||||
/** 只认新增的一期(不写即新增、替换都认),只给 table.version */
|
||||
period?: 'new' | 'any'
|
||||
/** 只认某个应用发起的、某种来源的运行,只给 workflow.finished */
|
||||
app_id?: string
|
||||
origin_kind?: string
|
||||
/**
|
||||
* 也认途经驳回的(上游走了某个审批的驳回出口、按驳回那条路跑完,状态仍是成功)、也认应用补算的;缺省都不认。只给
|
||||
* workflow.finished,不写即 false
|
||||
*/
|
||||
include_rejected?: boolean
|
||||
include_backfill?: boolean
|
||||
/** 去抖:同一个任务这么多秒内的几次事件合成一次 */
|
||||
debounce_s: number
|
||||
/** 保存时记下的对象名(对象删掉之后界面写它);读出来才有,交上去不用带 */
|
||||
subject_name?: string
|
||||
}
|
||||
|
||||
/** 事件触发任务现在有几条事件在等:没派发的(调度关着时一直留着)、在去抖窗口里的、窗口结束的时刻、调度开没开 */
|
||||
export interface ScheduleTriggerState {
|
||||
events: number
|
||||
waiting: number
|
||||
due_at: string | null
|
||||
scheduler_on: boolean
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
trigger_kind?: ScheduleTriggerKind
|
||||
/** 事件触发时的触发条件;cron 任务为 null */
|
||||
trigger?: ScheduleTrigger | null
|
||||
/** 给人看的触发说明(事件触发时同 description_text);cron 任务为 null */
|
||||
trigger_text?: string | null
|
||||
trigger_state?: ScheduleTriggerState | null
|
||||
}
|
||||
|
||||
/** 事件触发的那一次触发带的事件说明(服务端 outbox.EventFire.brief,不含出口字段的值) */
|
||||
export interface ScheduleFireEvent {
|
||||
event: ScheduleEventKind
|
||||
subject_id: string
|
||||
subject_name: string
|
||||
/** 一句说法:「上传表格「门店销售」出了新的一期(2026-09)」 */
|
||||
text: string
|
||||
/** 去抖合并了几次事件 */
|
||||
merged: number
|
||||
first_at: string | null
|
||||
last_at: string | null
|
||||
/** 这次运行在事件触发链上是第几层 */
|
||||
depth: number
|
||||
/** 立即运行时按示例事件发起的(不是真来了一次事件);real 为假时是示例值 */
|
||||
sample: boolean
|
||||
real: boolean
|
||||
/** 几个过了补发宽限的去抖窗口合成一条跳过时才有:合了几个窗口、合掉的期次(上传表格);老服务端不给 */
|
||||
windows?: number | null
|
||||
periods?: string[] | null
|
||||
}
|
||||
|
||||
export interface ScheduleFire {
|
||||
/** 事件触发的那几次的事件说明;别的为 null,老服务端不给 */
|
||||
event?: ScheduleFireEvent | null
|
||||
}
|
||||
|
||||
export interface ScheduleCreate {
|
||||
/** 不写即 cron(字段和以前一字不差) */
|
||||
trigger_kind?: ScheduleTriggerKind
|
||||
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
|
||||
}
|
||||
|
||||
export interface SchedulePreviewRequest {
|
||||
trigger_kind?: ScheduleTriggerKind
|
||||
trigger?: Omit<ScheduleTrigger, 'debounce_s'> & { debounce_s?: number }
|
||||
}
|
||||
|
||||
export interface SchedulePreview {
|
||||
/** 事件触发时:按哪一次事件渲染的(real 为假时是示例值,对象还没发生过这种事件) */
|
||||
event?: { text: string; real: boolean } | null
|
||||
}
|
||||
|
||||
/** GET /api/schedules/trigger-options:每种事件能选的对象(ok 为假时不能选,reason 写为什么,由服务端判)、来源、应用 */
|
||||
export interface ScheduleTriggerOptions {
|
||||
events: {
|
||||
event: ScheduleEventKind
|
||||
label: string
|
||||
subject_label: string
|
||||
objects: { id: string; name: string; ok: boolean; reason: string | null }[]
|
||||
}[]
|
||||
origins: { kind: string; label: string }[]
|
||||
apps: { id: string; name: string }[]
|
||||
default_debounce_s: number
|
||||
max_debounce_s: number
|
||||
max_depth: number
|
||||
}
|
||||
|
||||
/** GET /api/schedules/{id}/fires 的一页,新的在前;next_before 是下一页的游标,没有更多时为 null */
|
||||
export interface ScheduleFirePage {
|
||||
items: ScheduleFire[]
|
||||
|
||||
+340
-2
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user