mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:51:57 +00:00
4e3623885b
# Conflicts: # backend/app/api/schedules.py # backend/app/main.py
622 lines
34 KiB
Python
622 lines
34 KiB
Python
"""数据事件触发的规则(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]
|
||
|
||
async def edges_from(nodes: list[str]) -> dict[str, set[str]]:
|
||
"""这几个工作流跑完会触发哪些工作流:只读「上游工作流运行成功结束」、对象是它们的那几个任务(按触发条件里的事件种类和
|
||
对象筛,SQLAlchemy 的通用 JSON 访问),不把全部事件触发任务读出来。"""
|
||
rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind)
|
||
.where(Schedule.trigger_kind == "event",
|
||
Schedule.trigger["event"].as_string() == "workflow.finished",
|
||
Schedule.trigger["workflow_id"].as_string().in_(nodes)))).all()
|
||
out: dict[str, set[str]] = {}
|
||
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:
|
||
out.setdefault(subject_of(other), set()).add(workflow_id)
|
||
return out
|
||
|
||
# 从 B 出发找回 A 的路(广度优先,一层查一次,取最短的一条说给人听)
|
||
parent: dict[str, str | None] = {target: None}
|
||
frontier = [target]
|
||
while frontier:
|
||
edges = await edges_from(frontier)
|
||
nxt_level: list[str] = []
|
||
for node in frontier:
|
||
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)]
|
||
nxt_level.append(nxt)
|
||
frontier = nxt_level
|
||
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(), Run.id.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
|