Files
AgentLab/backend/app/scheduler/webhook.py
T

664 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""定时任务的通知:运行结束、失败、停在审批(或因服务重启挂起、写入外部系统的结果未知)时按任务的设置推送 Webhook。
格式、签名、发送、脱敏抽到了 app/notify/core.py,待办、实例、动作的通知(app/notify/service.py)共用同一份。这里留
定时任务自己的部分:消息内容、每一拍的通知扫描、推送记录(webhook_deliveries,不迁移,F14)。原来在这里的名字照原名
再导出,调用方和测试不用改;_transport、RETRY_DELAYS 仍是这个模块自己的(测试照旧替换它们),send 每次调用时读。
通知里只有任务名、状态、出具档位、缺口条数、运行链接、封存哈希(界面上叫清单哈希)前 12 位和起止时间,失败时
另写失败节点的名字和下一步(不许诺能继续运行);被人驳回的那一期按「运行结束」推「未出具(被驳回)」、写驳回的那一步,
手动停止的不推;运行成功结束后和同一任务上一次成功的运行的对比(auto_compare)只写
各类指标的个数和对比页链接。不放报告里的具体数字,也不放失败时的原始报错——报错里可能带着查询到的数据,Webhook
推去的群不一定有权看。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from sqlalchemy import and_, func, or_, select, update
from app.core.crypto import decrypt
from app.core.events import EventType
from app.db.base import SessionLocal, utcnow
from app.db.models import ActionRecord, Approval, Run, RunEvent, Schedule, ScheduleFire, WebhookDelivery
from app.engine.labels import ISSUANCE_LABEL
from app.notify import core
# 抽到 app/notify/core.py 的名字照原名再导出
from app.notify.core import ( # noqa: F401
_BOTS, _PLATFORM_HOSTS, _SENDING, FORMAT_LABEL, FORMATS, TIMEOUT_S, Outcome, _platform_error,
_platform_message, _RedactWebhookUrls, _reply_json, _sensitive, bot_format, check_url, format_problem, host_of,
url_hint,
)
from app.notify.core import scrub as _scrub # noqa: F401
from app.scheduler import auto_compare, service
logger = logging.getLogger(__name__)
EVENTS = ("finished", "failed", "awaiting_approval")
#: 事件在报错里的叫法,和界面上的选项一致
EVENT_LABEL = {"finished": "运行结束", "failed": "运行失败", "awaiting_approval": "等待审批"}
#: 失败后再试 3 次,每次之前等这么久(秒)。测试里换成 (0, 0, 0)
RETRY_DELAYS: tuple[float, ...] = core.RETRY_DELAYS
#: 只看这么多天内的触发:更早结束的运行状态不会再变,不用每一拍都扫。例外见 notify_due:停在审批上、有待审批的不受
#: 这个限制,运行记录这些天内有过变化的也照扫——审批可以等上一个多月,批完接着执行、停到下一个审批点或跑完都要推
SCAN_DAYS = 30
#: 运行结束后等封存(清单哈希)最多等多久。runner 先写终态再封存,两步之间被这一拍撞上就等下一拍
SEAL_WAIT_S = 60
#: 测试用:换成 httpx.MockTransport 接住请求
_transport: httpx.AsyncBaseTransport | None = None
_HEADLINE = {"finished": "运行完成", "failed": "运行失败", "awaiting_approval": "等待审批"}
#: 运行状态在通知里的叫法,和界面一致。interrupted 分两种,见 _status_label
_STATUS_LABEL = {"succeeded": "已完成", "failed": "失败", "cancelled": "已取消", "failed_to_start": "未能发起"}
#: 挂起的运行要人去处理:不处理,这个任务之后的触发都按「上一次还没结束」跳过
SUSPENDED_REASON = "运行停在断点上、没有待审批,通常是服务重启所致"
SUSPENDED_ACTION = "请在 AgentLab 的「记录」中继续运行或放弃这次运行;它结束之前,这个定时任务之后的触发都会跳过"
#: 写外部系统的结果未知、挂起等核实的运行(app/process/actions.py):核实之后系统自动接着跑,不用人去点继续运行
ACTION_PENDING_REASON = "运行中有一次写入外部系统的结果未知,已挂起等待核实"
ACTION_PENDING_ACTION = ("请在 AgentLab 的「应用」中处理核实待办(登记了查询能力的由系统自动核实);核实之后运行会自动继续,"
"它结束之前,这个定时任务之后的触发都会跳过")
#: 失败的运行怎么处理:不许诺「修正后可继续运行」。开始执行过的也可能没有断点(执行第一个节点前就失败)或已不兼容
#: (系统升级),能不能继续以记录页的提示为准。「记录」是导航里那一页的名字,和挂起那句一致
FAILED_ACTION = "请在 AgentLab 的「记录」中查看失败原因"
#: 从没开始执行就失败的(服务重启时还在排队,被改成失败、没有开始时间):没有可继续的进度。不照抄运行记录上的报错——
#: 通知一律不放原始报错,这一句是按「没有开始时间」认出来的,自己写
NOT_STARTED_REASON = "运行在开始执行前就已中止(通常是服务重启时仍在排队),没有可继续的进度"
#: 失败的运行不挡之后的触发(挂起的才挡):任务还在排期上时补这一句,免得人以为这个定时任务就此停了
NEXT_AS_USUAL = "下一次触发会照常发起"
#: 从没开始执行、任务又不在排期上(停用了):原因刚写过,记录页上也没有更多可看的,下一步是重新发起。「立即运行」
#: 是定时任务页上那个按钮的名字,停用的任务也能点
RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点击「立即运行」"
#: 被人驳回的那一期(cancelled + end_reason=rejected,next-round X9):是人的决定,不是故障。按「运行结束」推(订阅
#: 「运行失败」的人盯的是故障),标题、状态照实写被驳回;驳回的备注不写(可能带着数),驳回的是哪一步写节点名
REJECTED_HEADLINE = "未出具(被驳回)"
REJECTED_STATUS = "被驳回"
REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看"
#: 照驳回那条路成功结束的(驳回出口):标题、状态后面接这一句
VIA_REJECTED = "(途经驳回)"
# --------------------------------------------------------------------------
# 消息内容
# --------------------------------------------------------------------------
@dataclass
class Notice:
"""一条通知要说的事。event 是 finished / failed / awaiting_approval / test;status 是运行状态,没能发起的是
failed_to_start。没有的项为 None。
suspended:运行停在断点上、没有待审批(通常是服务重启挂起的),按 failed 事件推。error_node:失败节点的名字。
next_fire:任务启用着、往后还会触发(失败通知据此补「下一次触发会照常发起」)。
compare:和同一定时任务上一次成功的运行的对比摘要(auto_compare.compare_fire 的返回:基准是哪一次、只有个数的
summary、基准是不是紧挨着的上一次、基准开始的时间),compare_url:对比页的链接。
fmt、signed 只给测试消息用:说明里只说这条消息证明得了的事。"""
event: str
schedule_id: str
schedule_name: str
timezone: str
fire_id: str | None = None
fire_at: datetime | None = None
fire_kind: str | None = None
run_id: str | None = None
status: str | None = None
tier: str | None = None
gaps: int | None = None
run_url: str | None = None
manifest_hash: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
reason: str | None = None
pending_approvals: int | None = None
suspended: bool = False
#: 挂起的原因是写外部系统的结果未知(等核实,核实后自动接着跑),不是服务重启。只在 suspended 时有意义
action_pending: bool = False
error_node: str | None = None
#: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步
end_reason: str | None = None
#: 照驳回那条路成功结束的运行途经的驳回出口(run.finished 的 rejected_via,engine/runner._rejected_via):标题、状态写
#: 途经驳回,另起一行写谁被驳回、转到哪一步(同实例页 apps/resolve.rejected_exit_text)。没途经驳回的为 None
rejected_via: list[dict[str, Any]] | 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
signed: bool = False
@property
def rejected(self) -> bool:
return self.status == "cancelled" and self.end_reason == "rejected"
@property
def via_rejection(self) -> bool:
"""成功结束、途经驳回出口(审批接了驳回出口、按驳回那条路走完):状态是 succeeded,但不能只说「运行完成」。"""
return self.status == "succeeded" and bool(self.rejected_via)
@property
def never_started(self) -> bool:
"""失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。"""
return self.status == "failed" and self.run_id is not None and self.started_at is None
def _local(moment: datetime | None, tz: str, *, seconds: bool = True) -> str | None:
if moment is None:
return None
try:
from app.scheduler import cron
local = moment.astimezone(cron.zone(tz))
except ValueError:
local = moment.astimezone(timezone.utc)
return local.strftime("%Y-%m-%d %H:%M:%S" if seconds else "%Y-%m-%d %H:%M")
def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
"""(标题, [(项, 内容)…], 运行链接)。三种文本格式都从这一份排出来。"""
if notice.event == "test":
return f"定时任务「{notice.schedule_name}」Webhook 测试", [("说明", _test_text(notice))], None
if notice.status == "failed_to_start":
headline = "未能发起运行"
elif notice.suspended:
headline = "运行已挂起"
elif notice.rejected:
headline = REJECTED_HEADLINE
else:
headline = _HEADLINE.get(notice.event, notice.event)
if notice.via_rejection:
headline += VIA_REJECTED
title = f"定时任务「{notice.schedule_name}」{headline}"
lines: list[tuple[str, str]] = []
if notice.status:
lines.append(("状态", _status_label(notice)))
if notice.via_rejection:
from app.apps.resolve import rejected_exit_text
# 一个关卡一句:「「把关」被驳回,转到「退回申请人」」(名字是搭图时起的,不带查询到的数据)
lines.append(("驳回", ";".join(f"「{str(v.get('label') or v.get('node_id') or '')[:80]}」{rejected_exit_text(v)}"
for v in notice.rejected_via or [] if isinstance(v, dict))))
if notice.tier:
gaps = f"(缺口 {notice.gaps} 条)" if notice.gaps is not None else ""
lines.append(("出具档位", f"{ISSUANCE_LABEL.get(notice.tier, notice.tier)}{gaps}"))
if notice.compare:
# 只给各类指标的个数:具体的数在对比页上,跟着运行的权限走,不随消息推进群里
lines.append((_compare_head(notice), auto_compare.summary_text(notice.compare.get("summary") or {})))
if notice.pending_approvals:
lines.append(("待审批", f"{notice.pending_approvals} 项,请到 AgentLab 处理;运行结束之前,"
"这个定时任务之后的触发都会跳过"))
if notice.status == "failed_to_start" and notice.reason:
lines.append(("原因", notice.reason))
elif notice.suspended and notice.action_pending:
lines += [("原因", ACTION_PENDING_REASON), ("处理", ACTION_PENDING_ACTION)]
elif notice.suspended:
lines += [("原因", SUSPENDED_REASON), ("处理", SUSPENDED_ACTION)]
elif notice.rejected:
if notice.error_node:
lines.append(("驳回节点", f"「{notice.error_node}」"))
lines.append(("处理", f"{REJECTED_ACTION};{NEXT_AS_USUAL}" if notice.next_fire else REJECTED_ACTION))
elif notice.status == "failed":
# 不写原始报错(可能带着查询到的数据),写失败节点的名字和下一步
if notice.error_node:
lines.append(("失败节点", f"「{notice.error_node}」"))
if notice.never_started:
# 原因刚写过,也没有断点可续:不再让人去查看原因,说下一次照常,不在排期上的说怎么重新发起
lines += [("原因", NOT_STARTED_REASON), ("处理", NEXT_AS_USUAL if notice.next_fire else RERUN_ACTION)]
else:
lines.append(("处理", f"{FAILED_ACTION};{NEXT_AS_USUAL}" if notice.next_fire else FAILED_ACTION))
if notice.fire_at is not None:
when = f"{_local(notice.fire_at, notice.timezone, seconds=False)}({notice.timezone})"
# 立即运行不写「计划触发」:群里的人会以为报告按时出了
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:
lines.append(("计划触发", when))
if notice.started_at is not None:
lines.append(("开始", _local(notice.started_at, notice.timezone) or ""))
if notice.finished_at is not None:
lines.append(("结束", _local(notice.finished_at, notice.timezone) or ""))
if notice.manifest_hash:
lines.append(("清单哈希", notice.manifest_hash[:12]))
if notice.run_id:
# 和界面上的短编号一样写(#前 6 位);完整 id 在运行链接里
lines.append(("运行", f"#{notice.run_id[:6]}"))
return title, lines, notice.run_url
def _compare_head(notice: Notice) -> str:
"""对比那一行的项名。基准是紧挨着的上一次时写「与上一次相比」;不是(上一次失败了、取消了)时写明比的是哪一次:
短编号(同界面,#前 6 位)和开始的日期(按定时任务的时区)。"""
compare = notice.compare or {}
if compare.get("previous", True):
return "与上一次相比"
when = _date_of(compare.get("base_started_at"), notice.timezone)
return f"与上一次成功的运行(#{str(compare.get('base_run_id') or '')[:6]}{f',{when}' if when else ''})相比"
def _date_of(text: Any, tz: str) -> str | None:
"""ISO 时间(对比结果里运行的开始时间)按定时任务的时区写成日期,读不出来的返回 None。"""
try:
moment = datetime.fromisoformat(str(text))
except ValueError:
return None
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return (_local(moment, tz, seconds=False) or "")[:10] or None
def _status_label(notice: Notice) -> str:
if notice.status == "interrupted":
if notice.pending_approvals:
return "等待审批"
return "已挂起,等待核实" if notice.action_pending else "已挂起,可继续运行"
if notice.rejected:
return REJECTED_STATUS
if notice.via_rejection:
return f"{_STATUS_LABEL['succeeded']}{VIA_REJECTED}"
return _STATUS_LABEL.get(notice.status or "", notice.status or "")
def _test_text(notice: Notice) -> str:
"""测试消息的说明只说收到它证明得了的事:飞书、钉钉的机器人开了签名校验时,验签不过就不收;通用格式的接收端验没
验签这边不知道;企业微信不签名,填了密钥也不提。"""
if notice.signed and notice.fmt in ("feishu", "dingtalk"):
return "这是一条测试消息。收到即表示 Webhook 地址可用;机器人开启了签名校验的,签名密钥也配置正确"
if notice.signed:
return "这是一条测试消息。收到即表示 Webhook 地址可用;请求头带有签名,接收端可据此核对签名密钥"
return "这是一条测试消息。收到即表示 Webhook 地址配置正确"
def _links(notice: Notice) -> list[tuple[str, str]]:
"""消息末尾的链接:运行,有对比时再加对比页。没设对外访问地址时都没有。"""
return [(label, url) for label, url in (("查看运行", notice.run_url), ("查看对比", notice.compare_url)) if url]
def _message(notice: Notice) -> core.Message:
title, lines, _ = message(notice)
# 被驳回的那一期按 finished 推,但没出具:不画成成功的颜色
return core.Message(title=title, lines=lines, links=_links(notice),
ok=notice.event in ("finished", "test") and not notice.rejected)
def _text(notice: Notice) -> str:
return core.text_of(_message(notice))
def _wecom_markdown(notice: Notice) -> str:
return core.wecom_markdown(_message(notice))
def _dingtalk_markdown(notice: Notice) -> tuple[str, str]:
return core.dingtalk_markdown(_message(notice))
def _iso(moment: datetime | None) -> str | None:
return service.as_utc(moment).isoformat() if moment is not None else None
def _generic(notice: Notice, now: datetime) -> dict[str, Any]:
title, _, _ = message(notice)
return {
"source": "agentlab",
"event": notice.event,
"title": title,
"text": _text(notice),
"schedule_id": notice.schedule_id,
"schedule_name": notice.schedule_name,
"fire_id": notice.fire_id,
"fire_at": _iso(notice.fire_at),
"fire_kind": notice.fire_kind,
"run_id": notice.run_id,
"status": notice.status,
"tier": notice.tier,
"tier_label": ISSUANCE_LABEL.get(notice.tier) if notice.tier else None,
"gaps": notice.gaps,
"pending_approvals": notice.pending_approvals,
"run_url": notice.run_url,
"manifest_hash": notice.manifest_hash[:12] if notice.manifest_hash else None,
"started_at": _iso(notice.started_at),
"finished_at": _iso(notice.finished_at),
"reason": (notice.reason if notice.status == "failed_to_start"
else f"{ACTION_PENDING_REASON}。{ACTION_PENDING_ACTION}" if notice.suspended and notice.action_pending
else f"{SUSPENDED_REASON}。{SUSPENDED_ACTION}" if notice.suspended
else NOT_STARTED_REASON if notice.never_started else None),
"error_node": notice.error_node,
# 对比只给基准是哪一次(是不是紧挨着的上一次)、各类指标的个数和对比页链接
"compare": ({"base_run_id": notice.compare.get("base_run_id"),
"summary": dict(notice.compare.get("summary") or {}),
"previous": notice.compare.get("previous", True) is not False, "url": notice.compare_url}
if notice.compare else None),
"sent_at": _iso(now),
}
def build_request(fmt: str, url: str, secret: str | None, notice: Notice, *,
now: datetime | None = None) -> tuple[str, dict[str, str], bytes]:
"""(请求地址, 请求头, 请求体)。签名里的时间戳按 now 取,每次重试重新算:飞书要求时间戳在一小时内。"""
now = now or utcnow()
return core.build_request(fmt, url, secret, _message(notice), generic=_generic(notice, now), now=now)
# --------------------------------------------------------------------------
# 推送
# --------------------------------------------------------------------------
async def send(fmt: str, url: str, secret: str | None, notice: Notice, *, retry: bool = True) -> Outcome:
"""推送一条通知。retry 时失败再试 3 次(退避),4xx(429 除外)不重试。失败只记主机名。
_transport、RETRY_DELAYS 在调用时从这个模块读:测试替换的是它们。"""
return await core.post(fmt, url, secret, lambda: build_request(fmt, url, secret, notice),
label=f"定时任务「{notice.schedule_name}」的 Webhook", retry=retry, transport=_transport,
delays=RETRY_DELAYS)
async def _record(schedule_id: str, fire_id: str | None, run_id: str | None, event: str, outcome: Outcome) -> None:
async with SessionLocal() as session:
session.add(WebhookDelivery(schedule_id=schedule_id, fire_id=fire_id, run_id=run_id, event=event,
status="ok" if outcome.ok else "failed", http_status=outcome.http_status,
attempts=outcome.attempts, error=outcome.error))
try:
await session.commit()
except Exception: # noqa: BLE001 - 任务刚被删掉(外键)之类:推送记录记不下不影响别的
logger.warning("定时任务 %s 的推送记录没能保存", schedule_id)
# --------------------------------------------------------------------------
# 每一拍的通知扫描
# --------------------------------------------------------------------------
#: 运行到了这些状态才可能要推送
_WATCHED = ("succeeded", "failed", "cancelled", "interrupted")
#: 结束的运行 → 事件。cancelled 是有人手动停的,不推;被驳回的(end_reason=rejected)按 finished 推,见 _event_of。
#: interrupted 分停在审批和挂起两种,见 _state_of
_EVENT_OF = {"succeeded": "finished", "failed": "failed", "cancelled": None}
def _event_of(run: Run) -> str | None:
if run.status == "cancelled" and run.end_reason == "rejected":
return "finished"
return _EVENT_OF[run.status]
@dataclass
class _Job:
fmt: str
url: str
secret: str | None
notice: Notice
@dataclass
class _Due:
"""挑出来的一次状态变化接下来要做的事:运行成功结束时和上一次成功的运行比一次(compare_run),按条件 UPDATE
认领(把 notified_status 从 old 改成 state),认领到了、订阅了这个事件时推一条(job)。都在锁外做:对比要读快照、
重算,推送要等接收端,都不能挡着发起。pushed:认领到了、发出了推送(含失败的)。"""
fire_id: str
old: str | None
state: str
compare_run: str | None
job: _Job | None
pushed: bool = False
async def notify_due(now: datetime | None = None) -> int:
"""查已发起的触发,运行状态有变化的按订阅推送一次。返回这一拍推送了几条(含失败的)。
处理过的状态记在 notified_status 上,用条件 UPDATE 认领(WHERE notified_status = 旧值):两拍撞在一起也只
推一次。没订阅这个事件、任务没配 Webhook 的也记下已处理——之后再配上 Webhook,不会把旧的补推一遍。
停在断点上的运行每一拍都要重新看(见 _state_of):它的已处理状态带着第几段执行,和运行状态本身对不上。
运行成功结束、封存完的那一次,认领之前先和同一定时任务上一次成功的运行比一次(auto_compare),结束的通知带上计数
摘要和对比页链接。没配 Webhook 的也比:触发历史里要用。对比在锁外、认领之前做,最多等 auto_compare.WAIT_S 秒,
过了就不带对比推送:一次慢的对比最多让这一拍晚这么久,不会把之后所有的通知一直压着,也不拖着发起用的锁。比到一半
扫描被取消(停机只等通知扫描一个宽限),这次触发还没认领,下次照常比、照常推——以前先认领、后比,取消之后这条
通知再也不会推。比不了、出错不影响推送。
"""
now = now or utcnow()
dues: list[_Due] = []
base_url: str | None = None
cutoff = now - timedelta(days=SCAN_DAYS)
# 扫描窗口按触发记录的创建时间算,两种例外:运行记录窗口内有过变化(运行状态一变 updated_at 就跟着变),以及
# 停在审批上、有待审批的——以前只看触发记录,创建 30 天以后才停到审批上的运行收不到「等待审批」
waiting = select(Approval.id).where(Approval.run_id == Run.id, Approval.status == "pending").exists()
# 和发起共用一把锁:发起时先记一条「未能发起」再发起运行、成功了才改成 started,扫描不能撞在这中间。锁里只挑出
# 状态有变化的、拼好要推的消息;认领在锁外按条件 UPDATE 做,挑出来的是锁里看到的状态,和别的扫描撞上也只有一个
# 认领得到
async with service.launch_lock():
async with SessionLocal() as session:
rows = (await session.execute(
select(ScheduleFire, Run)
.outerjoin(Run, Run.id == ScheduleFire.run_id)
.where(or_(ScheduleFire.created_at >= cutoff, Run.updated_at >= cutoff,
and_(Run.status == "interrupted", waiting)), or_(
and_(ScheduleFire.status == "failed_to_start", ScheduleFire.notified_status.is_(None)),
and_(ScheduleFire.status == "started", Run.status.in_(_WATCHED),
or_(ScheduleFire.notified_status.is_(None), ScheduleFire.notified_status != Run.status)),
))
.order_by(ScheduleFire.created_at, ScheduleFire.id)
)).all()
if rows:
# 链接里的地址:全站同一个对外访问地址(环境变量优先,其次设置,core/public_url.py)
from app.core import public_url
base_url = await public_url.refresh(session)
for fire, run in rows:
seen = await _state_of(session, fire, run, now)
if seen.state is None or seen.state == fire.notified_status:
continue
schedule = await session.get(Schedule, fire.schedule_id)
url = decrypt(schedule.webhook_url) if schedule and schedule.webhook_url else None
job = None
if seen.event and url and schedule.webhook_format in FORMATS \
and seen.event in (schedule.webhook_events or []):
notice = await _notice(session, schedule, fire, run, seen, base_url)
job = _Job(schedule.webhook_format, url, decrypt(schedule.webhook_secret), notice)
# 只比成功结束、封存完的(见 auto_compare):失败、取消的指标不全,等不来新封存的比了也是旧的
compare_run = run.id if run is not None and run.status == "succeeded" and seen.sealed else None
dues.append(_Due(fire.id, fire.notified_status, seen.state, compare_run, job))
if dues:
await asyncio.gather(*(_follow_up(due, base_url) for due in dues))
return sum(1 for due in dues if due.pushed)
async def _follow_up(due: _Due, base_url: str | None) -> None:
"""先比(需要的话),再认领,认领到了再推:对比摘要要写进这条通知;认领放在对比之后,比到一半被取消时这次触发
还没认领。对比自己兜住了超时和异常,出什么事都不挡推送。"""
brief = await auto_compare.compare_fire(due.fire_id, due.compare_run) if due.compare_run else None
if not await _mark(due.fire_id, due.old, due.state) or due.job is None:
return
if brief is not None:
due.job.notice.compare = brief
if base_url:
due.job.notice.compare_url = auto_compare.page_url(base_url, due.compare_run, brief["base_run_id"])
due.pushed = True
await _deliver(due.job)
@dataclass
class _Seen:
"""_state_of 的结论。state:要记下的已处理状态,还不能下结论时为 None;event:要推送的事件;pending:待审批数;
sealed:终态的运行封存完了(封存盖住了这一次的结束事件,见 auto_compare.sealed_now),只有这时才比、通知里才写
清单哈希。"""
state: str | None
event: str | None = None
pending: int = 0
sealed: bool = False
action_pending: bool = False
async def _state_of(session: Any, fire: ScheduleFire, run: Run | None, now: datetime) -> _Seen:
"""这次触发现在该记成什么状态、推什么。还不能下结论(没封存、审批刚批完还没接着执行)时状态为 None。
停在断点上(interrupted)的分两种,各推一次:
- 有待审批:停在审批,推 awaiting_approval。批完接着执行、又停在下一个审批点时要再推,所以已处理状态里带着这是
第几段执行(每段执行从一条 run.started 开始),同一个审批点不重复推。
- 没有待审批:通常是服务重启挂起的。它不会自己接着执行,这个任务之后的触发都会按「上一次还没结束」跳过,
不说一声任务就此静默停摆——按 failed 推一次,写明要去继续运行或放弃。
其中写外部系统的结果未知、挂起等核实的(动作挂起,app/process/actions.py)另记一种状态:处理办法不一样(去核实,
核实后自动接着跑,点继续运行会被拒),说明也不一样。
审批批完到运行改回 running 之间,审批是 answered、还没有 resolved:那不是挂起,等下一拍。
"""
if fire.status == "failed_to_start":
return _Seen("failed_to_start", "failed")
if run is None:
return _Seen(None)
if run.status in ("succeeded", "failed", "cancelled"):
# runner 先写终态和结束时间、再另起会话封存,两步之间被这一拍撞上就等。失败后接着跑完的运行这时还带着失败时
# 的清单哈希,不能只看有没有哈希,要看封存盖没盖住这一次的结束事件。没有结束时间的(服务重启时还在排队、被改
# 成 failed 的)永远不会封存:按它最后一次更新的时间等,以前按「现在」等,每一拍都等、永远不推。等过时限仍没
# 封存完的照常推,只是不比、不写清单哈希
sealed = await auto_compare.sealed_now(session, run)
since = run.finished_at or run.updated_at or fire.created_at
if not sealed and (now - service.as_utc(since)).total_seconds() < SEAL_WAIT_S:
return _Seen(None)
return _Seen(run.status, _event_of(run), sealed=sealed)
if run.status != "interrupted":
return _Seen(None)
counts = dict((await session.execute(
select(Approval.status, func.count()).where(
Approval.run_id == run.id, Approval.status.in_(("pending", "answered")))
.group_by(Approval.status))).all())
segment = await session.scalar(select(func.count()).select_from(RunEvent).where(
RunEvent.run_id == run.id, RunEvent.type == EventType.RUN_STARTED.value)) or 0
if counts.get("pending"):
return _Seen(f"interrupted:approval:{segment}", "awaiting_approval", counts["pending"])
if counts.get("answered"):
return _Seen(None)
if await session.scalar(select(func.count()).select_from(ActionRecord).where(
ActionRecord.run_id == run.id, ActionRecord.state.in_(("sent", "unknown")))):
return _Seen(f"interrupted:action:{segment}", "failed", action_pending=True)
return _Seen(f"interrupted:held:{segment}", "failed")
async def _mark(fire_id: str, old: str | None, state: str) -> bool:
cond = ScheduleFire.notified_status.is_(None) if old is None else ScheduleFire.notified_status == old
async with SessionLocal() as session:
result = await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id, cond)
.values(notified_status=state))
await session.commit()
return result.rowcount == 1
async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run | None, seen: _Seen,
base_url: str | None) -> Notice:
tier, gaps = await _issuance_of(session, run) if run is not None else (None, None)
pending = seen.pending
return Notice(
event=seen.event or "", schedule_id=schedule.id, schedule_name=schedule.name, timezone=schedule.timezone,
fire_id=fire.id, fire_at=fire.fire_at, fire_kind=fire.kind,
run_id=run.id if run else None,
status=run.status if run else fire.status,
tier=tier, gaps=gaps,
run_url=f"{base_url}/runs/{run.id}" if base_url and run else None,
# 只写这一次结束时封的:停在审批上、封存还没补上的运行,身上可能还留着上一次失败时的清单哈希
manifest_hash=run.manifest_hash if run is not None and seen.sealed else None,
started_at=run.started_at if run else None,
finished_at=run.finished_at if run else None,
reason=fire.reason if fire.status == "failed_to_start" else None,
pending_approvals=pending or None,
suspended=run is not None and run.status == "interrupted" and not pending,
action_pending=seen.action_pending,
error_node=_failed_node(run, await _end_label(session, 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,
rejected_via=await _rejected_via_of(session, run) if run is not None and run.status == "succeeded" else None,
# 事件触发的任务没有下一次「到点」,启用着就是下一次事件来了照常发起
next_fire=bool(schedule.enabled and (schedule.next_run_at is not None or schedule.trigger_kind == "event")),
trigger_text=_trigger_text(fire),
)
async def _rejected_via_of(session: Any, run: Run) -> list[dict[str, Any]] | None:
"""这次运行途经的驳回出口:最后一条 run.finished 上的 rejected_via(子工作流里的那几步名字写「子工作流 › x」),没有为 None。"""
data = await session.scalar(select(RunEvent.data).where(RunEvent.run_id == run.id, RunEvent.type == "run.finished")
.order_by(RunEvent.seq.desc()).limit(1))
via = data.get("rejected_via") if isinstance(data, dict) else None
if not isinstance(via, list):
return None
return [v for v in via if isinstance(v, dict)] or None
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, end_label: str | None = None) -> str | None:
"""失败节点在图上的名字(没有名字的给节点 id)。节点名是搭图时起的,不带查询到的数据。图上找不到的(子工作流里的那一步,
id 是「sub/x」)用终态事件上记的名字(「子工作流 › x」,apps/resolve.end_label),和实例页、通知同一个说法。"""
if not run.error_node_id:
return None
for node in (run.graph or {}).get("nodes") or []:
if isinstance(node, dict) and node.get("id") == run.error_node_id:
label = (node.get("data") or {}).get("label") if isinstance(node.get("data"), dict) else None
return str(label or run.error_node_id)[:80]
return end_label[:80] if end_label else None
async def _end_label(session: Any, run: Run | None) -> str | None:
if run is None:
return None
from app.apps.resolve import end_label
return await end_label(session, run)
async def _issuance_of(session: Any, run: Run) -> tuple[str | None, int | None]:
"""出具档位和缺口条数:成果上的 _issuance,取不到时退回最后一条 issuance 事件(同 api/evidence.py 的读法)。"""
issuance = (run.output or {}).get("_issuance") if isinstance(run.output, dict) else None
if not isinstance(issuance, dict):
issuance = await session.scalar(select(RunEvent.data).where(
RunEvent.run_id == run.id, RunEvent.type == "issuance").order_by(RunEvent.seq.desc()).limit(1))
if not isinstance(issuance, dict) or not issuance.get("tier"):
return None, None
gaps = issuance.get("gaps")
return str(issuance["tier"]), len(gaps) if isinstance(gaps, list) else None
async def _deliver(job: _Job) -> None:
try:
outcome = await send(job.fmt, job.url, job.secret, job.notice)
except Exception: # noqa: BLE001 - 拼消息、签名出错也不能让这一拍的其他推送跟着失败
logger.exception("定时任务「%s」的 Webhook(%s)推送出错", job.notice.schedule_name, host_of(job.url))
outcome = Outcome(False, None, 0, "推送时出错,详细信息见服务日志")
await _record(job.notice.schedule_id, job.notice.fire_id, job.notice.run_id, job.notice.event, outcome)
async def send_test(schedule_id: str) -> dict[str, Any]:
"""按任务存的 Webhook 发一条测试消息(不重试),记一条 event=test 的推送记录。任务不存在抛 KeyError,
没配 Webhook、地址解不开抛 ValueError。
返回 {ok, http_status, message}。message 是 ok、http_status 之外的补充说明:失败时是原因(只有主机名),送达时
提醒去接收端看一眼。界面在「测试消息已送达(HTTP 200)」「测试消息发送失败」后面接它,这里不再重复那半句。"""
async with SessionLocal() as session:
schedule = await session.get(Schedule, schedule_id)
if schedule is None:
raise KeyError(schedule_id)
if not schedule.webhook_url or schedule.webhook_format not in FORMATS:
raise ValueError("这个定时任务没有配置 Webhook,请先填写地址并保存")
url = decrypt(schedule.webhook_url)
if not url:
raise ValueError("Webhook 地址无法解密(加密密钥可能已更换),请重新填写地址并保存")
fmt, secret = schedule.webhook_format, decrypt(schedule.webhook_secret)
notice = Notice(event="test", schedule_id=schedule.id, schedule_name=schedule.name,
timezone=schedule.timezone, fmt=fmt, signed=bool(secret) and fmt != "wecom")
outcome = await send(fmt, url, secret, notice, retry=False)
await _record(schedule_id, None, None, "test", outcome)
if outcome.ok:
return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"}
return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"}