mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:41:57 +00:00
定时任务后台:三张表、输入模板、调度循环和 Webhook 推送,设置加「通知」一组
- 表:schedules(Webhook 地址和签名密钥加密存放、next_run_at 存 UTC、version 乐观锁)、schedule_fires((任务,
fire_at, kind) 唯一)、webhook_deliveries;新表由 create_all 建,不用迁移
- 输入模板:{{ schedule.yesterday }} 这类变量按计划触发时间在任务时区算(不按「现在」:补发晚了、零点前后立即
运行,「昨天」都是那次触发的昨天),上一次计划触发时间按 cron 往前找,同一次触发永远渲染出同一份输入;未知
变量、未知过滤器、{{ }} 不成对都拦下,声明了入口字段的工作流还对照字段名和必填项
- 调度循环:每 15 秒一拍,条件 UPDATE 认领到点的任务并推进到下一次,两拍并发只有一拍认领得到,触发记录的唯一
约束是第二道保险;上一次运行没结束(含停在审批、服务重启挂起的)记跳过;错过的只看最近一次,在宽限内补一次、
超出记跳过,积压不补;发起走 launch_run,拒绝的原话记成未能发起,别的异常记通用说明、详情进日志;stop 等手上
这一拍收尾,开关关着时 start 不起循环
- Webhook:通用 JSON(可选 HMAC 签名头)、飞书文本、企业微信 markdown、钉钉 markdown,按各平台算法签名;运行
结束、失败、确有待审批时按订阅推一次,结束的等封存好再推,没订阅、没配 Webhook 的也记下已处理,之后配上不会
补推旧的;10 秒超时、失败重试 3 次(4xx 不重试),每次推送记一条记录;通知里没有报告数字和原始报错,日志和
错误里只有主机名(httpx 自己的请求日志也换成主机名)
- 设置 notify.public_base_url:通知里的运行链接 = 它 + /runs/{id},只收 http(s) 地址,末尾的 / 去掉
This commit is contained in:
@@ -562,6 +562,66 @@ async def verify_model_spec(session: AsyncSession, node_config: dict[str, Any] |
|
||||
return ModelSpec(provider=provider, model=model, max_tokens=JUDGE_MAX_TOKENS, thinking="off")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 通知:定时任务推送 Webhook 时拼运行链接用的对外访问地址
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 设置里 notify 这一组。public_base_url 是别人打开 AgentLab 用的地址(https://agentlab.example.com),通知里的
|
||||
#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接
|
||||
NOTIFY_GROUP = "notify"
|
||||
NOTIFY_DEFAULTS: dict[str, Any] = {"public_base_url": None}
|
||||
NOTIFY_SETTING_LABEL = {"public_base_url": "对外访问地址"}
|
||||
|
||||
|
||||
def _base_url_problem(value: Any) -> str | None:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
label = NOTIFY_SETTING_LABEL["public_base_url"]
|
||||
hint = (f"「{label}」需要是 http:// 或 https:// 开头的完整地址(例如 https://agentlab.example.com),"
|
||||
f"留空表示通知里不附运行链接,当前为「{value}」")
|
||||
if not isinstance(value, str):
|
||||
return hint
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
try:
|
||||
parts = urlsplit(value.strip())
|
||||
except ValueError:
|
||||
return hint
|
||||
if parts.scheme not in ("http", "https") or not parts.hostname or " " in value.strip():
|
||||
return hint
|
||||
if parts.query or parts.fragment:
|
||||
return f"「{label}」不能带 ? 或 # 之后的部分,运行链接会接在地址后面,当前为「{value}」"
|
||||
return None
|
||||
|
||||
|
||||
def notify_problem(value: Any) -> str | None:
|
||||
"""设置页写进来的 notify 这一组有什么问题,没问题返回 None。"""
|
||||
if not isinstance(value, dict):
|
||||
return f"「通知」设置格式有误,需要是一个对象,当前为「{value}」"
|
||||
for key, item in value.items():
|
||||
if key not in NOTIFY_DEFAULTS:
|
||||
return (f"「通知」设置中不支持「{key}」,可设置的项:"
|
||||
+ "、".join(f"「{NOTIFY_SETTING_LABEL[k]}」" for k in NOTIFY_DEFAULTS))
|
||||
if problem := _base_url_problem(item):
|
||||
return problem
|
||||
return None
|
||||
|
||||
|
||||
def notify_values(stored: Any) -> dict[str, Any]:
|
||||
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。"""
|
||||
stored = stored if isinstance(stored, dict) else {}
|
||||
url = stored.get("public_base_url")
|
||||
if not isinstance(url, str) or not url.strip() or _base_url_problem(url):
|
||||
return {"public_base_url": None}
|
||||
return {"public_base_url": url.strip().rstrip("/")}
|
||||
|
||||
|
||||
async def notify_settings(session: AsyncSession) -> dict[str, Any]:
|
||||
"""设置里的 notify 一组,没存过的补默认。定时任务推送 Webhook 时从这里读。"""
|
||||
row = await session.get(Setting, NOTIFY_GROUP)
|
||||
return notify_values(row.value if row else None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 用户设置
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -592,6 +652,8 @@ DEFAULT_SETTINGS: dict[str, Any] = {
|
||||
JUDGE_GROUP: dict(JUDGE_DEFAULTS),
|
||||
# 核查(双盲复算、反方审稿、审批质询):核查模型、三项的开关和上限、每日金额上限。和裁判一组一样由用户定
|
||||
VERIFY_GROUP: verify_values(None),
|
||||
# 通知:定时任务的 Webhook 里拼运行链接用的对外访问地址
|
||||
NOTIFY_GROUP: notify_values(None),
|
||||
}
|
||||
|
||||
|
||||
@@ -647,6 +709,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
|
||||
# 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
|
||||
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
|
||||
merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP))
|
||||
merged[NOTIFY_GROUP] = notify_values(stored.get(NOTIFY_GROUP))
|
||||
for key, value in stored.items():
|
||||
merged.setdefault(key, value)
|
||||
return merged
|
||||
@@ -667,6 +730,11 @@ async def put_settings(
|
||||
raise HTTPException(422, problem)
|
||||
if VERIFY_GROUP in payload.values and (problem := verify_problem(payload.values[VERIFY_GROUP])):
|
||||
raise HTTPException(422, problem)
|
||||
if NOTIFY_GROUP in payload.values:
|
||||
if problem := notify_problem(payload.values[NOTIFY_GROUP]):
|
||||
raise HTTPException(422, problem)
|
||||
# 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠
|
||||
payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP])
|
||||
for key, value in payload.values.items():
|
||||
if key in (TRUST_KEY, "limits", JUDGE_SPEND, VERIFY_SPEND):
|
||||
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
|
||||
|
||||
@@ -749,3 +749,97 @@ class ConversationTurn(Base, TimestampMixin):
|
||||
meta: Mapped[dict[str, Any] | None] = mapped_column(default=None)
|
||||
|
||||
conversation: Mapped[Conversation] = relationship(back_populates="turns")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 定时任务:按 cron 到点发起运行,结束、失败、停在审批时推送 Webhook(app/scheduler)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Schedule(Base, TimestampMixin):
|
||||
"""一个定时任务。webhook_url、webhook_secret 落库前经 app.core.crypto 加密,接口只返回主机和末 4 位。
|
||||
|
||||
不给 workflow_id 设外键:工作流删掉之后任务留着,到点照常发起、照实记一次未能发起(「工作流不存在」),
|
||||
订阅了失败通知的人会收到——悄悄跟着删掉,每周的报告就无声无息地没了。
|
||||
"""
|
||||
|
||||
__tablename__ = "schedules"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
name: Mapped[str] = mapped_column(String(200), default="")
|
||||
workflow_id: Mapped[str] = mapped_column(String(32), index=True)
|
||||
# 保存时的工作流名。工作流还在时接口给它现在的名字,删掉之后才用这一份
|
||||
workflow_name: Mapped[str] = mapped_column(String(200), default="")
|
||||
cron: Mapped[str] = mapped_column(String(200))
|
||||
timezone: Mapped[str] = mapped_column(String(64))
|
||||
# formal:每次都从当前发布版本发起正式运行;exploratory:用工作流现在的画布
|
||||
run_class: Mapped[str] = mapped_column(String(20), default="formal")
|
||||
# {输入字段名: 模板字符串},到点按触发时间渲染(scheduler/service.py)
|
||||
input_template: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
||||
enabled: Mapped[bool] = mapped_column(default=True)
|
||||
# 上一次运行还没结束时怎么办。目前只有 skip:记一次跳过
|
||||
overlap: Mapped[str] = mapped_column(String(20), default="skip")
|
||||
# 错过的触发(服务停机等)在这么多秒内补一次,超出就记跳过
|
||||
misfire_grace_s: Mapped[int] = mapped_column(Integer, default=3600)
|
||||
# generic | feishu | wecom | dingtalk。没配 Webhook 时 url 为空
|
||||
webhook_format: Mapped[str | None] = mapped_column(String(20), default=None)
|
||||
webhook_url: Mapped[str | None] = mapped_column(Text, default=None) # 密文
|
||||
webhook_secret: Mapped[str | None] = mapped_column(Text, default=None) # 密文
|
||||
webhook_events: Mapped[list[Any]] = mapped_column(default=list)
|
||||
# 下一次触发的时刻(UTC)。停用时为空;调度循环按它认领,认领时推进到再下一次
|
||||
next_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
|
||||
# 乐观锁:PATCH 带上读到的版本,对不上回 409。调度循环推进 next_run_at 不改它
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
|
||||
class ScheduleFire(Base):
|
||||
"""一次触发:到点、立即运行、或者错过的补跑 / 跳过。(schedule_id, fire_at, kind) 唯一:两拍撞在一起时,
|
||||
后插入的那条撞上约束就知道这一次已经有人处理了,不会发起第二次。"""
|
||||
|
||||
__tablename__ = "schedule_fires"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("schedule_id", "fire_at", "kind"),
|
||||
Index("ix_schedule_fires_schedule_created", "schedule_id", "created_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"))
|
||||
# 计划触发的时刻(UTC)。立即运行是点下去的那一秒;输入模板的日期变量都按它算
|
||||
fire_at: Mapped[datetime] = mapped_column(UTCDateTime)
|
||||
# scheduled | manual | misfire
|
||||
kind: Mapped[str] = mapped_column(String(20), default="scheduled")
|
||||
# started | skipped_overlap | skipped_misfire | failed_to_start
|
||||
status: Mapped[str] = mapped_column(String(30), default="started")
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
# 渲染后的输入,就是交给运行的那一份
|
||||
inputs: Mapped[dict[str, Any]] = mapped_column(default=dict)
|
||||
# 已经按哪个运行状态处理过通知(succeeded / failed / cancelled / interrupted / failed_to_start)。
|
||||
# 运行状态变了、和它不同才再看要不要推送,同一状态只处理一次
|
||||
notified_status: Mapped[str | None] = mapped_column(String(30), default=None)
|
||||
# 和同一任务上一次运行的比较结果(工件 id),后续的运行对比工作包写
|
||||
compare_artifact: Mapped[str | None] = mapped_column(String(64), default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
|
||||
|
||||
class WebhookDelivery(Base):
|
||||
"""一次 Webhook 推送(含重试)的结果。error 只写主机名和平台返回的错误码,不含地址、key、密钥。
|
||||
「测试 Webhook」也记一条:fire_id、run_id 为空,event 是 test。"""
|
||||
|
||||
__tablename__ = "webhook_deliveries"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
|
||||
schedule_id: Mapped[str] = mapped_column(ForeignKey("schedules.id", ondelete="CASCADE"), index=True)
|
||||
fire_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("schedule_fires.id", ondelete="CASCADE"), default=None, index=True
|
||||
)
|
||||
run_id: Mapped[str | None] = mapped_column(String(32), default=None)
|
||||
# finished | failed | awaiting_approval | test
|
||||
event: Mapped[str] = mapped_column(String(30))
|
||||
# ok | failed
|
||||
status: Mapped[str] = mapped_column(String(20))
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, default=None)
|
||||
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error: Mapped[str | None] = mapped_column(Text, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""定时任务的后台调度:到点按定时任务的设置发起工作流运行。
|
||||
"""定时任务的后台调度:到点按定时任务的设置发起工作流运行,结束、失败、停在审批时推送 Webhook。
|
||||
|
||||
目前 start / stop 都是空的,调度循环由定时任务的工作包补上。服务启动时(main.py 的 lifespan)
|
||||
只在 scheduler_enabled() 时调 start,关闭时总是调 stop——stop 要能在没 start 过时安全返回。
|
||||
- cron.py:五字段 cron 的解析、下次触发、中文描述
|
||||
- service.py:输入模板、和工作流入口字段的对照、发起一次触发(调度循环和「立即运行」共用)
|
||||
- loop.py:调度循环,条件 UPDATE 认领到点的任务
|
||||
- webhook.py:通知推送(通用 / 飞书 / 企业微信 / 钉钉)
|
||||
|
||||
服务启动时(main.py 的 lifespan)只在 scheduler_enabled() 时调 start,关闭时总是调 stop——stop 要能在没 start
|
||||
过时安全返回。start 自己也看一次开关:拷来的数据目录(检查脚本的 --stacks、验收副本)不管从哪条路径起服务,
|
||||
都不会替原库把定时任务再执行一遍。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.config import Settings, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def scheduler_enabled(config: Settings | None = None) -> bool:
|
||||
"""调度循环开不开:AGENTLAB_SCHEDULER,默认开,0 / false / off 关(见 core/config.py)。
|
||||
@@ -15,9 +25,25 @@ def scheduler_enabled(config: Settings | None = None) -> bool:
|
||||
return bool((config or settings).scheduler)
|
||||
|
||||
|
||||
async def start() -> None:
|
||||
"""起调度循环。调度循环在后续工作包里实现,现在什么都不做。"""
|
||||
async def start(*, interval: float | None = None, config: Settings | None = None) -> bool:
|
||||
"""起调度循环,返回起没起。开关关着时什么都不做;已经在跑时不起第二个。interval 只给测试用。"""
|
||||
if not scheduler_enabled(config):
|
||||
logger.info("定时任务调度已关闭(AGENTLAB_SCHEDULER),不会自动发起运行")
|
||||
return False
|
||||
from app.scheduler.loop import TICK_SECONDS, runner
|
||||
|
||||
await runner.start(interval if interval is not None else TICK_SECONDS)
|
||||
return True
|
||||
|
||||
|
||||
async def stop() -> None:
|
||||
"""停调度循环,等手上的那一轮收尾。现在什么都不做;没 start 过也要能安全调用。"""
|
||||
"""停调度循环,等手上的那一轮收尾。没 start 过也能安全调用。"""
|
||||
from app.scheduler.loop import runner
|
||||
|
||||
await runner.stop()
|
||||
|
||||
|
||||
def running() -> bool:
|
||||
from app.scheduler.loop import runner
|
||||
|
||||
return runner.running()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""调度循环:每一拍认领到点的定时任务、发起运行,再顺便查已发起的运行要不要推送通知。
|
||||
|
||||
认领靠条件 UPDATE:UPDATE schedules SET next_run_at = 下一次 WHERE id = … AND next_run_at = 旧值。两拍撞在
|
||||
一起(或者以后多开了一个进程),只有一拍改得动这一行,另一拍拿到 0 行就知道这一次已经有人处理了。触发记录
|
||||
(任务, fire_at, kind) 唯一是第二道保险。
|
||||
|
||||
错过的触发(服务停机、进程卡住):只看最近应触发的那一次——在宽限内补一次(kind=misfire),超出就记一条
|
||||
skipped_misfire;更早的积压只记一条跳过,不补。下一次一律推进到现在之后,不会醒来就把积压的一口气全发起。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import Schedule, ScheduleFire
|
||||
from app.scheduler import cron, service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 一拍的间隔(秒)。测试里 start(interval=…) 调小
|
||||
TICK_SECONDS = 15.0
|
||||
#: 晚了这么多秒以内都算准点(kind=scheduled),和补发宽限无关:拍与拍之间的十几秒不该算错过
|
||||
ON_TIME_S = 60
|
||||
#: stop 等手上那一拍收尾最多等多久,超过就取消
|
||||
STOP_GRACE_S = 10.0
|
||||
|
||||
|
||||
async def fire_due(now: datetime | None = None) -> list[ScheduleFire]:
|
||||
"""处理所有到点的任务(enabled 且 next_run_at <= now),返回这一拍记下的触发。一个任务出错不影响别的。"""
|
||||
now = now or utcnow()
|
||||
async with SessionLocal() as session:
|
||||
due = (await session.execute(
|
||||
select(Schedule.id, Schedule.next_run_at)
|
||||
.where(Schedule.enabled.is_(True), Schedule.next_run_at.is_not(None), Schedule.next_run_at <= now)
|
||||
.order_by(Schedule.next_run_at)
|
||||
)).all()
|
||||
out: list[ScheduleFire] = []
|
||||
for schedule_id, claimed_at in due:
|
||||
try:
|
||||
out.extend(await _due_one(schedule_id, claimed_at, now))
|
||||
except Exception: # noqa: BLE001 - 一个任务出错不能拖住这一拍的其他任务,也不能让循环停下
|
||||
logger.exception("定时任务 %s 这一拍处理失败", schedule_id)
|
||||
return out
|
||||
|
||||
|
||||
async def _due_one(schedule_id: str, claimed_at: datetime, now: datetime) -> list[ScheduleFire]:
|
||||
async with SessionLocal() as session:
|
||||
row = await session.get(Schedule, schedule_id)
|
||||
if row is None or not row.enabled or row.next_run_at != claimed_at:
|
||||
return []
|
||||
cron_expr, tz, grace = row.cron, row.timezone, max(int(row.misfire_grace_s or 0), 0)
|
||||
try:
|
||||
spec, zone = cron.parse(cron_expr), cron.zone(tz)
|
||||
# 最近应触发的那一次。库里的 next_run_at 理应就是一个触发时刻,万一不是(手改过库)就按它算
|
||||
latest = max(cron.last_due(spec, zone, now) or claimed_at, claimed_at)
|
||||
upcoming = cron.next_fire(spec, zone, now)
|
||||
except (cron.CronError, cron.TimezoneError) as e:
|
||||
# 存进库里之后才坏掉的表达式(手改过库、时区库升级删了时区):停止排期,在触发历史里照实说
|
||||
if not await _claim(schedule_id, claimed_at, None):
|
||||
return []
|
||||
logger.warning("定时任务 %s 的 cron 或时区无法解析,已停止排期:%s", schedule_id, e)
|
||||
fire = await service._record(schedule_id, claimed_at, "scheduled", "failed_to_start",
|
||||
f"cron 表达式或时区无法解析,已停止排期:{e}", {})
|
||||
return [fire] if fire else []
|
||||
if not await _claim(schedule_id, claimed_at, upcoming):
|
||||
return []
|
||||
|
||||
out: list[ScheduleFire | None] = []
|
||||
lateness = (now - latest).total_seconds()
|
||||
if lateness <= max(ON_TIME_S, grace):
|
||||
if latest > claimed_at:
|
||||
# 最近这一次照常(或补一次),更早的积压只记一条,不补
|
||||
last_missed = cron.prev_fire(spec, zone, latest) or claimed_at
|
||||
reason = f"服务未运行,错过了 {_span(claimed_at, last_missed, tz)} 的触发;积压的触发不补,只发起最近一次"
|
||||
out.append(await service.record_skip(schedule_id, claimed_at, reason))
|
||||
kind = "scheduled" if lateness <= ON_TIME_S else "misfire"
|
||||
out.append(await service.fire_once(schedule_id, latest, kind))
|
||||
else:
|
||||
reason = (f"服务未运行,错过了 {_span(claimed_at, latest, tz)} 的触发,"
|
||||
f"超出补发宽限({service.duration_text(grace)}),没有发起")
|
||||
out.append(await service.record_skip(schedule_id, latest, reason))
|
||||
return [fire for fire in out if fire is not None]
|
||||
|
||||
|
||||
async def _claim(schedule_id: str, claimed_at: datetime, upcoming: datetime | None) -> bool:
|
||||
"""把 next_run_at 从 claimed_at 推进到 upcoming。改动了一行才算认领到。
|
||||
|
||||
updated_at 照旧:它说的是任务最近一次被人修改,推进排期不算。version 也不动——乐观锁防的是两个人同时改设置,
|
||||
调度推进 next_run_at 不该让正在编辑的人保存时撞上 409。"""
|
||||
async with SessionLocal() as session:
|
||||
result = await session.execute(
|
||||
update(Schedule)
|
||||
.where(Schedule.id == schedule_id, Schedule.enabled.is_(True), Schedule.next_run_at == claimed_at)
|
||||
.values(next_run_at=upcoming, updated_at=Schedule.updated_at)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
def _span(first: datetime, last: datetime, tz: str) -> str:
|
||||
a, b = service.local_text(first, tz), service.local_text(last, tz)
|
||||
return a if a == b else f"{a} 至 {b}"
|
||||
|
||||
|
||||
async def tick(now: datetime | None = None) -> None:
|
||||
"""完整的一拍:发起到点的,再推送该推送的通知。测试和手动调用用;循环里两件事分开跑(见 _Runner._loop)。"""
|
||||
from app.scheduler import webhook
|
||||
|
||||
await fire_due(now)
|
||||
await webhook.notify_due(now)
|
||||
|
||||
|
||||
class _Runner:
|
||||
"""调度循环的任务。照 sandbox/manager.py 回收闲置会话的写法:一个 asyncio 任务,出错记日志接着下一拍。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.task: asyncio.Task[None] | None = None
|
||||
#: 通知推送另起任务:Webhook 超时加重试最长要几十秒,不能拖着下一拍的发起。同一时刻只跑一份
|
||||
self.notify: asyncio.Task[None] | None = None
|
||||
self._stopping: asyncio.Event | None = None
|
||||
|
||||
def running(self) -> bool:
|
||||
return self.task is not None and not self.task.done()
|
||||
|
||||
async def start(self, interval: float = TICK_SECONDS) -> None:
|
||||
if self.running():
|
||||
return
|
||||
self._stopping = asyncio.Event()
|
||||
self.task = asyncio.create_task(self._loop(interval, self._stopping), name="agentlab-scheduler")
|
||||
logger.info("定时任务调度已启动,每 %s 秒检查一次", interval)
|
||||
|
||||
async def _loop(self, interval: float, stopping: asyncio.Event) -> None:
|
||||
from app.scheduler import webhook
|
||||
|
||||
while not stopping.is_set():
|
||||
try:
|
||||
await fire_due()
|
||||
if self.notify is None or self.notify.done():
|
||||
self.notify = asyncio.create_task(webhook.notify_due(), name="agentlab-scheduler-notify")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - 这一拍出错不该让循环停下
|
||||
logger.exception("定时任务调度这一拍出错,下一拍照常进行")
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stopping.wait(), timeout=interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""让循环收完手上这一拍再停;等不到(STOP_GRACE_S)就取消。没 start 过直接返回。"""
|
||||
tasks = [t for t in (self.task, self.notify) if t is not None]
|
||||
self.task = self.notify = None
|
||||
if self._stopping is not None:
|
||||
self._stopping.set()
|
||||
for task in tasks:
|
||||
if task.done():
|
||||
continue
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=STOP_GRACE_S)
|
||||
except TimeoutError:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
except Exception: # noqa: BLE001 - 停的时候不再追究这一拍的错
|
||||
pass
|
||||
if tasks:
|
||||
logger.info("定时任务调度已停止")
|
||||
|
||||
|
||||
runner = _Runner()
|
||||
@@ -0,0 +1,318 @@
|
||||
"""定时任务的业务规则:输入模板、和工作流入口字段的对照、发起一次触发。
|
||||
|
||||
调度循环(loop.py)到点调 fire_once,接口的「立即运行」也调它:重叠跳过、渲染输入、记触发、发起运行这一套
|
||||
只有一份。发起本身走 engine/launch.py 的 launch_run,和 HTTP 发起是同一套规则——正式运行只能从当前发布版本
|
||||
发起,这一条不会在定时任务这边被绕过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import weakref
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import Run, Schedule, ScheduleFire
|
||||
from app.engine.expressions import _FILTERS, render_template
|
||||
from app.scheduler import cron
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: 变量清单的示例按这个时区算(GET /api/schedules/variables 没带时区时)
|
||||
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
||||
RUN_CLASSES = ("formal", "exploratory")
|
||||
#: 这几个状态的运行算「还没结束」:停在审批上、服务重启挂起的(interrupted)也算,它们随时会接着执行
|
||||
ACTIVE_RUN = ("queued", "running", "interrupted")
|
||||
|
||||
#: 模板里能用的变量:(变量名, 说明)。说明写在变量清单接口里,和示例一起给界面
|
||||
VARIABLES: tuple[tuple[str, str], ...] = (
|
||||
("today", "今天(YYYY-MM-DD)"),
|
||||
("yesterday", "昨天(YYYY-MM-DD)"),
|
||||
("this_week_start", "本周一(YYYY-MM-DD)"),
|
||||
("last_week_start", "上周一(YYYY-MM-DD)"),
|
||||
("last_week_end", "上周日(YYYY-MM-DD)"),
|
||||
("this_month_start", "本月 1 日(YYYY-MM-DD)"),
|
||||
("last_month_start", "上月 1 日(YYYY-MM-DD)"),
|
||||
("last_month_end", "上月最后一天(YYYY-MM-DD)"),
|
||||
("fire_time", "本次计划触发时间(带时区偏移的 ISO 时间)"),
|
||||
("last_fire_time", "上一次计划触发时间(带时区偏移的 ISO 时间)"),
|
||||
)
|
||||
_NAMES = tuple(f"schedule.{key}" for key, _ in VARIABLES)
|
||||
_TEMPLATE_RE = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
|
||||
|
||||
#: 触发记下来、运行还没发起时先写成这样:发起成功再改成 started。进程在这中间被强杀,留下的记录照实说没有发起
|
||||
INTERRUPTED_REASON = "服务在发起运行前停止,这次触发没有发起运行"
|
||||
#: launch_run 抛了 LaunchRefused 以外的异常(库出错之类)。详细信息只进日志
|
||||
GENERIC_REASON = "发起运行时出错,详细信息见服务日志"
|
||||
|
||||
|
||||
class TemplateError(ValueError):
|
||||
"""输入模板写错了。str() 是给人看的中文说明,指出是哪个输入。"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 模板变量
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def template_values(fire_at: datetime, tz: str, cron_expr: str | cron.CronSpec) -> dict[str, str]:
|
||||
"""按计划触发时间在任务时区算出的变量(键不带 schedule. 前缀)。日期写 YYYY-MM-DD,时间写带偏移的 ISO、只到秒。
|
||||
|
||||
按 fire_at 算而不是按「现在」:补发晚了一小时、立即运行在零点前后点下去,「昨天」都是那次触发的昨天。
|
||||
last_fire_time 是按 cron 往前找的上一次计划触发,不看历史记录:同一个 (cron, 时区, fire_at) 永远渲染出同一份输入。
|
||||
"""
|
||||
zone = cron.zone(tz)
|
||||
local = fire_at.astimezone(zone)
|
||||
today = local.date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
last_month_end = month_start - timedelta(days=1)
|
||||
previous = cron.prev_fire(cron_expr, zone, fire_at)
|
||||
return {
|
||||
"today": _day(today),
|
||||
"yesterday": _day(today - timedelta(days=1)),
|
||||
"this_week_start": _day(week_start),
|
||||
"last_week_start": _day(week_start - timedelta(days=7)),
|
||||
"last_week_end": _day(week_start - timedelta(days=1)),
|
||||
"this_month_start": _day(month_start),
|
||||
"last_month_start": _day(last_month_end.replace(day=1)),
|
||||
"last_month_end": _day(last_month_end),
|
||||
"fire_time": local.isoformat(timespec="seconds"),
|
||||
"last_fire_time": previous.astimezone(zone).isoformat(timespec="seconds") if previous else "",
|
||||
}
|
||||
|
||||
|
||||
def _day(value: date) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def render_inputs(template: dict[str, str], values: dict[str, str]) -> dict[str, str]:
|
||||
"""渲染输入模板。用项目通用的 {{ }} 渲染(engine/expressions.render_template),变量挂在 schedule 下。"""
|
||||
ctx = {"schedule": values}
|
||||
return {name: render_template(text, ctx) for name, text in template.items()}
|
||||
|
||||
|
||||
def check_template(template: Any) -> dict[str, str]:
|
||||
"""校验输入模板,原样返回(None 当空模板)。写错抛 TemplateError。
|
||||
|
||||
项目的模板渲染取不到值时渲染成空串、不报错——画布上的半成品图要能运行。定时任务没人盯着,拼错的变量名
|
||||
会让每一次运行都拿到空的日期,所以保存和预览时就要说出来。
|
||||
"""
|
||||
if template is None:
|
||||
return {}
|
||||
if not isinstance(template, dict):
|
||||
raise TemplateError("输入模板需要是「字段名:模板」的对象,例如 stat_date 对应 {{ schedule.yesterday }}")
|
||||
for name, text in template.items():
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise TemplateError("输入模板里有空的字段名,请填写工作流的输入字段名")
|
||||
if not isinstance(text, str):
|
||||
raise TemplateError(f"输入「{name}」的值需要是文本(可以带 {{{{ schedule.yesterday }}}} 这样的变量),当前为「{text}」")
|
||||
_check_text(name, text)
|
||||
return dict(template)
|
||||
|
||||
|
||||
def _check_text(name: str, text: str) -> 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:
|
||||
raise TemplateError(f"输入「{name}」的模板里「{path}」不是可用的变量,可用的变量:{'、'.join(_NAMES)}")
|
||||
for filt in filters:
|
||||
if filt not in _FILTERS:
|
||||
raise TemplateError(f"输入「{name}」的模板里「{filt}」不是可用的过滤器,可用的过滤器:{'、'.join(_FILTERS)}")
|
||||
rest = _TEMPLATE_RE.sub("", text)
|
||||
if "{{" in rest or "}}" in rest:
|
||||
raise TemplateError(f"输入「{name}」的模板里 {{{{ 和 }}}} 没有成对出现,请检查是否少写了一半")
|
||||
|
||||
|
||||
def variables(now: datetime | None = None, tz: str = DEFAULT_TIMEZONE) -> list[dict[str, str]]:
|
||||
"""变量清单:[{name, label, example}]。示例按 now(默认现在)这一刻触发算;上一次计划触发时间没有 cron
|
||||
可依,示例按每天同一时刻触发算。"""
|
||||
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]} for key, label in VARIABLES]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 和工作流入口字段对照
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def input_fields(graph: Any) -> list[dict[str, Any]]:
|
||||
"""工作流入口节点声明的字段 [{name, required, default}],没声明(或图不成形)是空列表。"""
|
||||
from app.engine.schema import GraphSpec, NodeType
|
||||
|
||||
try:
|
||||
spec = GraphSpec.model_validate(graph)
|
||||
except Exception: # noqa: BLE001 - 图不成形交给发起时报
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for node in spec.nodes:
|
||||
if node.type != NodeType.INPUT:
|
||||
continue
|
||||
for field in node.config.get("fields") or []:
|
||||
if isinstance(field, dict) and isinstance(field.get("name"), str) and field["name"].strip():
|
||||
out.append({"name": field["name"].strip(), "required": bool(field.get("required")),
|
||||
"default": field.get("default")})
|
||||
return out
|
||||
|
||||
|
||||
def check_fields(template: dict[str, str], fields: list[dict[str, Any]], workflow_name: str, *,
|
||||
require: bool = True) -> None:
|
||||
"""入口声明了字段时,模板里的字段名要在里面;require 时必填且没有默认值的字段要有模板。不对抛 TemplateError。
|
||||
|
||||
入口没声明字段的工作流,输入是开放的(运行时传什么键都收),不查。预览时用户还在填,只查多出来的名字。
|
||||
"""
|
||||
if not fields:
|
||||
return
|
||||
names = [f["name"] for f in fields]
|
||||
unknown = [name for name in template if name not in names]
|
||||
if unknown:
|
||||
raise TemplateError(f"输入「{unknown[0]}」不是工作流「{workflow_name}」入口声明的字段,可用的字段:{'、'.join(names)}")
|
||||
if not require:
|
||||
return
|
||||
missing = [f["name"] for f in fields
|
||||
if f["required"] and f["default"] in (None, "") and f["name"] not in template]
|
||||
if missing:
|
||||
raise TemplateError(f"工作流「{workflow_name}」的必填输入「{'、'.join(missing)}」没有填写,"
|
||||
"到点发起的运行会因缺少输入而失败")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发起一次触发
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 每个事件循环一把锁:发起(查重叠 → 记触发 → 发起运行 → 改记录)和通知扫描查库互斥。单进程部署,进程内
|
||||
#: 互斥就够;按事件循环分开是因为测试每个用例一个新循环,跨循环用同一把 asyncio.Lock 会报错
|
||||
_LOCKS: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def launch_lock() -> asyncio.Lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
lock = _LOCKS.get(loop)
|
||||
if lock is None:
|
||||
lock = _LOCKS[loop] = asyncio.Lock()
|
||||
return lock
|
||||
|
||||
|
||||
def actor_label(name: str) -> str:
|
||||
"""定时任务发起的运行记在谁名下。"""
|
||||
return f"定时任务:{name}"[:100]
|
||||
|
||||
|
||||
def next_run(cron_expr: str, tz: str, enabled: bool, now: datetime | None = None) -> datetime | None:
|
||||
"""下一次触发的时刻(UTC);停用的任务没有下一次。"""
|
||||
if not enabled:
|
||||
return None
|
||||
return cron.next_fire(cron_expr, tz, now or utcnow())
|
||||
|
||||
|
||||
async def fire_once(schedule_id: str, fire_at: datetime, kind: str, *, actor: str | None = None,
|
||||
) -> ScheduleFire | None:
|
||||
"""处理一次触发,返回记下的触发记录。同一 (任务, fire_at, kind) 已经有记录(两拍撞在一起、立即运行连点两下)
|
||||
时不再发起,返回已有的那一条;任务不存在返回 None。
|
||||
|
||||
上一次运行还没结束就记 skipped_overlap;输入渲染不了、launch_run 拒绝或出错记 failed_to_start(拒绝写原话,
|
||||
其他异常写通用说明、详细信息进日志);发起成功记 started 和运行 id。
|
||||
"""
|
||||
async with launch_lock():
|
||||
async with SessionLocal() as session:
|
||||
schedule = await session.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
return None
|
||||
name, workflow_id, run_class = schedule.name, schedule.workflow_id, schedule.run_class
|
||||
tz, cron_expr, template = schedule.timezone, schedule.cron, schedule.input_template
|
||||
try:
|
||||
inputs = render_inputs(check_template(template), template_values(fire_at, tz, cron_expr))
|
||||
except (TemplateError, cron.CronError, cron.TimezoneError) as e:
|
||||
return await _record(schedule_id, fire_at, kind, "failed_to_start", f"输入模板无法渲染:{e}", {})
|
||||
busy = await _active_run(schedule_id)
|
||||
if busy is not None:
|
||||
reason = f"上一次运行({busy[:8]})还没有结束,按设置跳过这次触发"
|
||||
return await _record(schedule_id, fire_at, kind, "skipped_overlap", reason, inputs)
|
||||
fire = await _record(schedule_id, fire_at, kind, "failed_to_start", INTERRUPTED_REASON, inputs, fresh=True)
|
||||
if fire is None:
|
||||
return await _existing(schedule_id, fire_at, kind)
|
||||
from app.engine.launch import LaunchRefused, launch_run
|
||||
|
||||
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)
|
||||
except LaunchRefused as e:
|
||||
return await _finish(fire.id, status="failed_to_start", reason=e.message)
|
||||
except Exception: # noqa: BLE001 - 发起失败不能让调度循环停下;详细信息进日志,记录上写通用说明
|
||||
logger.exception("定时任务「%s」(%s)发起运行失败", name, schedule_id)
|
||||
return await _finish(fire.id, status="failed_to_start", reason=GENERIC_REASON)
|
||||
return await _finish(fire.id, status="started", reason=None, run_id=run.id)
|
||||
|
||||
|
||||
async def record_skip(schedule_id: str, fire_at: datetime, reason: str) -> ScheduleFire | None:
|
||||
"""记一次错过、没有补发的触发(kind=misfire,status=skipped_misfire)。"""
|
||||
return await _record(schedule_id, fire_at, "misfire", "skipped_misfire", reason, {})
|
||||
|
||||
|
||||
async def _active_run(schedule_id: str) -> str | None:
|
||||
"""这个任务发起的、还没结束的运行 id(最近的那一个),没有返回 None。"""
|
||||
async with SessionLocal() as session:
|
||||
return await session.scalar(
|
||||
select(Run.id).where(Run.schedule_id == schedule_id, Run.status.in_(ACTIVE_RUN))
|
||||
.order_by(Run.created_at.desc()).limit(1))
|
||||
|
||||
|
||||
async def _record(schedule_id: str, fire_at: datetime, kind: str, status: str, reason: str | None,
|
||||
inputs: dict[str, Any], *, fresh: bool = False) -> ScheduleFire | None:
|
||||
"""插入一条触发记录。撞上唯一约束(同一次触发已经记过)或任务已被删除(外键)时:fresh 返回 None,否则返回
|
||||
已有的那一条(没有就是 None)。"""
|
||||
fire = ScheduleFire(schedule_id=schedule_id, fire_at=fire_at, kind=kind, status=status, reason=reason,
|
||||
inputs=inputs)
|
||||
async with SessionLocal() as session:
|
||||
session.add(fire)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
return None if fresh else await _existing(schedule_id, fire_at, kind)
|
||||
await session.refresh(fire)
|
||||
return fire
|
||||
|
||||
|
||||
async def _existing(schedule_id: str, fire_at: datetime, kind: str) -> ScheduleFire | None:
|
||||
async with SessionLocal() as session:
|
||||
return await session.scalar(select(ScheduleFire).where(
|
||||
ScheduleFire.schedule_id == schedule_id, ScheduleFire.fire_at == fire_at, ScheduleFire.kind == kind))
|
||||
|
||||
|
||||
async def _finish(fire_id: str, *, status: str, reason: str | None, run_id: str | None = None) -> ScheduleFire | None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(update(ScheduleFire).where(ScheduleFire.id == fire_id)
|
||||
.values(status=status, reason=reason, run_id=run_id))
|
||||
await session.commit()
|
||||
return await session.get(ScheduleFire, fire_id)
|
||||
|
||||
|
||||
def local_text(moment: datetime, tz: str) -> str:
|
||||
"""给人看的本地时间「2026-10-05 09:00」,写进跳过原因、通知里。"""
|
||||
return moment.astimezone(cron.zone(tz)).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def duration_text(seconds: int) -> str:
|
||||
"""补发宽限这类秒数的说法:3600 →「1 小时」,600 →「10 分钟」,90 →「90 秒」。"""
|
||||
if seconds and seconds % 3600 == 0:
|
||||
return f"{seconds // 3600} 小时"
|
||||
if seconds and seconds % 60 == 0:
|
||||
return f"{seconds // 60} 分钟"
|
||||
return f"{seconds} 秒"
|
||||
|
||||
|
||||
def as_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(timezone.utc) if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
@@ -0,0 +1,567 @@
|
||||
"""定时任务的通知:运行结束、失败、停在审批时按任务的设置推送 Webhook。
|
||||
|
||||
四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。配了签名密钥时:通用格式在请求头
|
||||
里带 HMAC-SHA256 签名,飞书、钉钉按各自平台的算法签(飞书放在请求体,钉钉放在地址上);企业微信的 key 本来就在
|
||||
地址里,没有另外的签名。
|
||||
|
||||
Webhook 地址本身就是凭证(企业微信的 key、钉钉的 access_token 都在地址里,飞书的在路径里),和签名密钥一样
|
||||
加密落库,日志、推送记录、接口返回里只出现主机名。httpx 每发一个请求都会在 INFO 级别记一行完整地址,这里装了
|
||||
一个过滤器把推送期间的那几行换成主机名。
|
||||
|
||||
通知里只有任务名、状态、出具档位、缺口条数、运行链接、封存哈希前 12 位和起止时间,不放报告里的具体数字,也不放
|
||||
失败时的原始报错——报错里可能带着查询到的数据,Webhook 推去的群不一定有权看。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextvars
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, quote_plus, urlsplit
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
|
||||
from app.core.crypto import decrypt
|
||||
from app.db.base import SessionLocal, utcnow
|
||||
from app.db.models import Approval, Run, RunEvent, Schedule, ScheduleFire, WebhookDelivery
|
||||
from app.engine.labels import ISSUANCE_LABEL
|
||||
from app.scheduler import service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FORMATS = ("generic", "feishu", "wecom", "dingtalk")
|
||||
EVENTS = ("finished", "failed", "awaiting_approval")
|
||||
#: 单次请求的超时(秒)
|
||||
TIMEOUT_S = 10.0
|
||||
#: 失败后再试 3 次,每次之前等这么久(秒)。4xx(429 除外)不重试:地址或参数错了,重试也一样
|
||||
RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
|
||||
#: 只看这么多天内的触发:更早的运行状态不会再变,不用每一拍都扫
|
||||
SCAN_DAYS = 30
|
||||
#: 运行结束后等封存(清单哈希)最多等多久。runner 先写终态再封存,两步之间被这一拍撞上就等下一拍
|
||||
SEAL_WAIT_S = 60
|
||||
#: 测试用:换成 httpx.MockTransport 接住请求
|
||||
_transport: httpx.AsyncBaseTransport | None = None
|
||||
|
||||
_HEADLINE = {"finished": "运行完成", "failed": "运行失败", "awaiting_approval": "等待审批", "test": "Webhook 测试"}
|
||||
#: 运行状态在通知里的叫法
|
||||
_STATUS_LABEL = {"succeeded": "已完成", "failed": "失败", "interrupted": "停在人工审批", "cancelled": "已取消",
|
||||
"failed_to_start": "未能发起"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 地址:校验、提示、脱敏
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_url(url: Any) -> str:
|
||||
"""校验 Webhook 地址,返回去掉首尾空白的地址。不对抛 ValueError(中文说明,不回显地址)。"""
|
||||
text = url.strip() if isinstance(url, str) else ""
|
||||
try:
|
||||
parts = urlsplit(text)
|
||||
except ValueError:
|
||||
parts = None
|
||||
if (not text or parts is None or parts.scheme not in ("http", "https") or not parts.hostname
|
||||
or any(c.isspace() for c in text)):
|
||||
raise ValueError("Webhook 地址需要是 http:// 或 https:// 开头的完整地址,例如飞书机器人的 "
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/…")
|
||||
if len(text) > 2000:
|
||||
raise ValueError("Webhook 地址过长(超过 2000 个字符),请检查是否粘贴了多余的内容")
|
||||
return text
|
||||
|
||||
|
||||
def host_of(url: str) -> str:
|
||||
"""主机名(带非默认端口)。日志、报错里只写它。"""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or "?"
|
||||
return f"{host}:{parts.port}" if parts.port else host
|
||||
except ValueError:
|
||||
return "?"
|
||||
|
||||
|
||||
def url_hint(url: str) -> str:
|
||||
"""给界面看的地址提示:只露主机和末 4 位(open.feishu.cn/…ab12)。"""
|
||||
return f"{host_of(url)}/…{url[-4:]}"
|
||||
|
||||
|
||||
def _sensitive(url: str, secret: str | None) -> list[str]:
|
||||
"""一条地址里不能出现在日志、报错里的片段:整条地址、查询参数的值(key、access_token)、路径里的长片段(飞书
|
||||
的令牌在路径里)、签名密钥。长的在前,免得先换掉短的、长的就认不出来了。"""
|
||||
out = {url, secret or ""}
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
out |= {value for _, value in parse_qsl(parts.query)}
|
||||
out |= {seg for seg in parts.path.split("/") if len(seg) >= 12}
|
||||
out.add(f"{parts.netloc}{parts.path}")
|
||||
except ValueError:
|
||||
pass
|
||||
return sorted((s for s in out if len(s) >= 4), key=len, reverse=True)
|
||||
|
||||
|
||||
def _scrub(text: str, url: str, secret: str | None) -> str:
|
||||
for piece in _sensitive(url, secret):
|
||||
text = text.replace(piece, "…")
|
||||
return text
|
||||
|
||||
|
||||
#: 正在推送哪条地址、用的哪个密钥。httpx 记请求日志时还在同一个任务里,过滤器据此只改推送的那几行
|
||||
_SENDING: contextvars.ContextVar[tuple[str, str | None] | None] = contextvars.ContextVar("webhook_sending", default=None)
|
||||
|
||||
|
||||
class _RedactWebhookUrls(logging.Filter):
|
||||
"""把 httpx 记下的推送请求的地址换成主机名。别的请求(模型接入、数据源工具)原样放过。"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
sending = _SENDING.get()
|
||||
if sending and isinstance(record.args, tuple):
|
||||
url, secret = sending
|
||||
record.args = tuple(
|
||||
url_hint(str(arg)) if isinstance(arg, httpx.URL)
|
||||
else _scrub(arg, url, secret) if isinstance(arg, str) else arg
|
||||
for arg in record.args)
|
||||
return True
|
||||
|
||||
|
||||
def _install_filter() -> None:
|
||||
target = logging.getLogger("httpx")
|
||||
if not any(isinstance(f, _RedactWebhookUrls) for f in target.filters):
|
||||
target.addFilter(_RedactWebhookUrls())
|
||||
|
||||
|
||||
_install_filter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 消息内容
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notice:
|
||||
"""一条通知要说的事。event 是 finished / failed / awaiting_approval / test;status 是运行状态,没能发起的是
|
||||
failed_to_start。没有的项为 None。"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 == "failed" and notice.status == "failed_to_start":
|
||||
headline = "未能发起运行"
|
||||
else:
|
||||
headline = _HEADLINE.get(notice.event, notice.event)
|
||||
title = f"定时任务「{notice.schedule_name}」{headline}"
|
||||
lines: list[tuple[str, str]] = []
|
||||
if notice.event == "test":
|
||||
lines.append(("说明", "这是一条测试消息,收到说明 Webhook 地址和签名配置正确"))
|
||||
return title, lines, None
|
||||
if notice.status:
|
||||
lines.append(("状态", _STATUS_LABEL.get(notice.status, notice.status)))
|
||||
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.pending_approvals:
|
||||
lines.append(("待审批", f"{notice.pending_approvals} 项,请到 AgentLab 处理"))
|
||||
if notice.status == "failed_to_start" and notice.reason:
|
||||
lines.append(("原因", notice.reason))
|
||||
elif notice.status == "failed":
|
||||
lines.append(("原因", "运行失败,详情请查看运行记录"))
|
||||
if notice.fire_at is not None:
|
||||
lines.append(("计划触发", f"{_local(notice.fire_at, notice.timezone, seconds=False)}({notice.timezone})"))
|
||||
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:
|
||||
lines.append(("运行编号", notice.run_id))
|
||||
return title, lines, notice.run_url
|
||||
|
||||
|
||||
def _text(notice: Notice) -> str:
|
||||
title, lines, link = message(notice)
|
||||
out = [f"【AgentLab】{title}", *(f"{k}:{v}" for k, v in lines)]
|
||||
if link:
|
||||
out.append(f"查看运行:{link}")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _wecom_markdown(notice: Notice) -> str:
|
||||
title, lines, link = message(notice)
|
||||
color = "info" if notice.event in ("finished", "test") else "warning"
|
||||
out = [f"**【AgentLab】{title}**"]
|
||||
for key, value in lines:
|
||||
shown = f'<font color="{color}">{value}</font>' if key == "状态" else value
|
||||
out.append(f"> {key}:{shown}")
|
||||
if link:
|
||||
out.append(f"[查看运行]({link})")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _dingtalk_markdown(notice: Notice) -> tuple[str, str]:
|
||||
title, lines, link = message(notice)
|
||||
out = [f"#### 【AgentLab】{title}", "", *(f"- {k}:{v}" for k, v in lines)]
|
||||
if link:
|
||||
out += ["", f"[查看运行]({link})"]
|
||||
return title, "\n".join(out)
|
||||
|
||||
|
||||
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 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()
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if fmt == "feishu":
|
||||
body: dict[str, Any] = {"msg_type": "text", "content": {"text": _text(notice)}}
|
||||
if secret:
|
||||
ts = str(int(now.timestamp()))
|
||||
# 飞书自定义机器人的签名:以「timestamp + 换行 + 密钥」为键,对空串做 HMAC-SHA256,再 base64
|
||||
key = f"{ts}\n{secret}".encode()
|
||||
body = {"timestamp": ts, "sign": base64.b64encode(hmac.new(key, b"", hashlib.sha256).digest()).decode(),
|
||||
**body}
|
||||
return url, headers, _json(body)
|
||||
if fmt == "wecom":
|
||||
return url, headers, _json({"msgtype": "markdown", "markdown": {"content": _wecom_markdown(notice)}})
|
||||
if fmt == "dingtalk":
|
||||
title, text = _dingtalk_markdown(notice)
|
||||
target = url
|
||||
if secret:
|
||||
# 钉钉自定义机器人的签名:毫秒时间戳,以密钥为键对「timestamp + 换行 + 密钥」做 HMAC-SHA256,base64 后
|
||||
# 再做 URL 编码,和 timestamp 一起加在地址上
|
||||
ts = str(int(now.timestamp() * 1000))
|
||||
digest = hmac.new(secret.encode(), f"{ts}\n{secret}".encode(), hashlib.sha256).digest()
|
||||
sign = quote_plus(base64.b64encode(digest).decode())
|
||||
target = f"{url}{'&' if '?' in url else '?'}timestamp={ts}&sign={sign}"
|
||||
return target, headers, _json({"msgtype": "markdown", "markdown": {"title": title, "text": text}})
|
||||
payload = _json(_generic(notice, now))
|
||||
if secret:
|
||||
# 通用格式:接收端按 HMAC-SHA256(密钥, 「时间戳.请求体」) 验签,时间戳防重放
|
||||
ts = str(int(now.timestamp()))
|
||||
mac = hmac.new(secret.encode(), f"{ts}.".encode() + payload, hashlib.sha256).hexdigest()
|
||||
headers |= {"X-AgentLab-Timestamp": ts, "X-AgentLab-Signature": f"sha256={mac}"}
|
||||
return url, headers, payload
|
||||
|
||||
|
||||
def _json(value: dict[str, Any]) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 推送
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Outcome:
|
||||
"""一次推送(含重试)的结果。error 是中文说明,只有主机名,不含地址和密钥。"""
|
||||
|
||||
ok: bool
|
||||
http_status: int | None
|
||||
attempts: int
|
||||
error: str | None = None
|
||||
#: 这一次失败值不值得再试(只在单次尝试的结果上有意义)
|
||||
retryable: bool = False
|
||||
|
||||
|
||||
def _client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=TIMEOUT_S, transport=_transport, follow_redirects=False)
|
||||
|
||||
|
||||
async def send(fmt: str, url: str, secret: str | None, notice: Notice, *, retry: bool = True) -> Outcome:
|
||||
"""推送一条通知。retry 时失败再试 3 次(退避),4xx(429 除外)不重试。失败只记主机名。"""
|
||||
host = host_of(url)
|
||||
delays = RETRY_DELAYS if retry else ()
|
||||
last = Outcome(False, None, 0, "没有发出请求")
|
||||
async with _client() as client:
|
||||
for attempt in range(len(delays) + 1):
|
||||
if attempt:
|
||||
await asyncio.sleep(delays[attempt - 1])
|
||||
target, headers, body = build_request(fmt, url, secret, notice)
|
||||
last = await _post_once(client, fmt, target, headers, body, url, secret)
|
||||
last.attempts = attempt + 1
|
||||
if last.ok:
|
||||
return last
|
||||
logger.warning("定时任务「%s」的 Webhook(%s)第 %d 次推送失败:%s",
|
||||
notice.schedule_name, host, attempt + 1, last.error)
|
||||
if not last.retryable:
|
||||
break
|
||||
return last
|
||||
|
||||
|
||||
async def _post_once(client: httpx.AsyncClient, fmt: str, target: str, headers: dict[str, str], body: bytes,
|
||||
url: str, secret: str | None) -> Outcome:
|
||||
host = host_of(url)
|
||||
token = _SENDING.set((url, secret))
|
||||
try:
|
||||
response = await client.post(target, content=body, headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return Outcome(False, None, 1, f"请求 {host} 超时({TIMEOUT_S:g} 秒)", retryable=True)
|
||||
except httpx.ConnectError:
|
||||
return Outcome(False, None, 1, f"无法连接 {host}", retryable=True)
|
||||
except httpx.HTTPError as e:
|
||||
return Outcome(False, None, 1, f"请求 {host} 失败({type(e).__name__})", retryable=True)
|
||||
finally:
|
||||
_SENDING.reset(token)
|
||||
status = response.status_code
|
||||
reply = _reply_json(response)
|
||||
if not 200 <= status < 300:
|
||||
detail = _platform_message(reply)
|
||||
text = f"{host} 返回 HTTP {status}" + (f":{detail}" if detail else "")
|
||||
return Outcome(False, status, 1, _scrub(text, url, secret)[:300], retryable=status == 429 or status >= 500)
|
||||
if problem := _platform_error(fmt, reply):
|
||||
return Outcome(False, status, 1, _scrub(f"{host} {problem}", url, secret)[:300], retryable=True)
|
||||
return Outcome(True, status, 1)
|
||||
|
||||
|
||||
def _reply_json(response: httpx.Response) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _platform_message(reply: dict[str, Any] | None) -> str:
|
||||
if not reply:
|
||||
return ""
|
||||
text = reply.get("errmsg") or reply.get("msg") or reply.get("StatusMessage") or ""
|
||||
return str(text)[:200]
|
||||
|
||||
|
||||
def _platform_error(fmt: str, reply: dict[str, Any] | None) -> str | None:
|
||||
"""HTTP 200 里的平台错误码:飞书 code(老接口 StatusCode),企业微信、钉钉 errcode。0 是成功。
|
||||
|
||||
说明里写主机名(调用方拼在前面),不写平台名:主机名已经说明是哪个平台,配错了格式时也不会张冠李戴。"""
|
||||
if not reply or fmt == "generic":
|
||||
return None
|
||||
code = reply.get("code", reply.get("StatusCode")) if fmt == "feishu" else reply.get("errcode")
|
||||
if code in (None, 0, "0"):
|
||||
return None
|
||||
detail = _platform_message(reply)
|
||||
return f"返回错误码 {code}" + (f":{detail}" if detail else "")
|
||||
|
||||
|
||||
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 是有人手动停的,不推
|
||||
_EVENT_OF = {"succeeded": "finished", "failed": "failed", "cancelled": None, "interrupted": "awaiting_approval"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Job:
|
||||
fmt: str
|
||||
url: str
|
||||
secret: str | None
|
||||
notice: Notice
|
||||
|
||||
|
||||
async def notify_due(now: datetime | None = None) -> int:
|
||||
"""查已发起的触发,运行状态有变化的按订阅推送一次。返回这一拍推送了几条(含失败的)。
|
||||
|
||||
处理过的状态记在 notified_status 上,用条件 UPDATE 认领(WHERE notified_status = 旧值):两拍撞在一起也只
|
||||
推一次。没订阅这个事件、任务没配 Webhook 的也记下已处理——之后再配上 Webhook,不会把旧的补推一遍。
|
||||
"""
|
||||
now = now or utcnow()
|
||||
jobs: list[_Job] = []
|
||||
# 和发起共用一把锁:发起时先记一条「未能发起」再发起运行、成功了才改成 started,扫描不能撞在这中间
|
||||
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(ScheduleFire.created_at >= now - timedelta(days=SCAN_DAYS), 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)
|
||||
)).all()
|
||||
base_url: str | None = None
|
||||
if rows:
|
||||
from app.api.settings import notify_settings
|
||||
|
||||
base_url = (await notify_settings(session))["public_base_url"]
|
||||
for fire, run in rows:
|
||||
state, event, pending = await _state_of(session, fire, run, now)
|
||||
if state is None or not await _mark(fire.id, fire.notified_status, state):
|
||||
continue
|
||||
schedule = await session.get(Schedule, fire.schedule_id)
|
||||
url = decrypt(schedule.webhook_url) if schedule and schedule.webhook_url else None
|
||||
if not (event and url and schedule.webhook_format in FORMATS
|
||||
and event in (schedule.webhook_events or [])):
|
||||
continue
|
||||
notice = await _notice(session, schedule, fire, run, event, base_url, pending)
|
||||
jobs.append(_Job(schedule.webhook_format, url, decrypt(schedule.webhook_secret), notice))
|
||||
if jobs:
|
||||
await asyncio.gather(*(_deliver(job) for job in jobs))
|
||||
return len(jobs)
|
||||
|
||||
|
||||
async def _state_of(session: Any, fire: ScheduleFire, run: Run | None,
|
||||
now: datetime) -> tuple[str | None, str | None, int]:
|
||||
"""(要记下的已处理状态, 要推送的事件, 待审批数)。还不能下结论(没封存、挂起但没有待审批)时状态为 None。"""
|
||||
if fire.status == "failed_to_start":
|
||||
return "failed_to_start", "failed", 0
|
||||
if run is None:
|
||||
return None, None, 0
|
||||
if run.status in ("succeeded", "failed", "cancelled"):
|
||||
finished = run.finished_at or now
|
||||
if not run.manifest_hash and (now - service.as_utc(finished)).total_seconds() < SEAL_WAIT_S:
|
||||
return None, None, 0
|
||||
return run.status, _EVENT_OF[run.status], 0
|
||||
if run.status == "interrupted":
|
||||
# 服务重启挂起的运行也是 interrupted,但没人要审批:确有待审批才算停在审批
|
||||
pending = await session.scalar(select(func.count()).select_from(Approval).where(
|
||||
Approval.run_id == run.id, Approval.status == "pending")) or 0
|
||||
return ("interrupted", "awaiting_approval", pending) if pending else (None, None, 0)
|
||||
return None, None, 0
|
||||
|
||||
|
||||
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, event: str,
|
||||
base_url: str | None, pending: int) -> Notice:
|
||||
tier, gaps = await _issuance_of(session, run) if run is not None else (None, None)
|
||||
return Notice(
|
||||
event=event, 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 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,
|
||||
)
|
||||
|
||||
|
||||
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}。"""
|
||||
async with SessionLocal() as session:
|
||||
schedule = await session.get(Schedule, schedule_id)
|
||||
if schedule is None:
|
||||
raise KeyError(schedule_id)
|
||||
url = decrypt(schedule.webhook_url) if schedule.webhook_url else None
|
||||
if not url or schedule.webhook_format not in FORMATS:
|
||||
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)
|
||||
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": f"测试消息已送达(HTTP {outcome.http_status})"}
|
||||
return {"ok": False, "http_status": outcome.http_status, "message": f"推送失败:{outcome.error}"}
|
||||
@@ -38,7 +38,7 @@ def test_unrecognized_value_fails_at_startup(monkeypatch):
|
||||
|
||||
|
||||
async def test_start_and_stop_are_safe_noops():
|
||||
"""调度循环还没实现:start / stop 什么都不做,没 start 过直接 stop 也不出错"""
|
||||
"""测试环境关着调度(conftest):start 不起循环,没 start 过直接 stop 也不出错。开着时的循环见 test_scheduler_loop"""
|
||||
await scheduler.stop()
|
||||
await scheduler.start()
|
||||
await scheduler.stop()
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""定时任务的输入模板(app/scheduler/service.py):日期变量按触发时间在任务时区算,模板写错就说出来。
|
||||
|
||||
- 变量都按 fire_at(计划触发时间)在任务时区算,不按「现在」:补跑晚了一小时,昨天还是计划那天的昨天
|
||||
- 跨月、跨年、跨时区(同一时刻在上海已经是周一,在纽约还是周日);周从周一算起
|
||||
- schedule.last_fire_time 是上一次计划触发的时间(按 cron 往前找),和本次一样写成带偏移的 ISO 时间
|
||||
- 模板写错(未知变量、未知过滤器、{{ }} 不成对、空的 {{ }}、值不是文本)抛 TemplateError,中文说明哪个输入
|
||||
- 声明了入口字段的工作流:模板里多出来的字段名、缺的必填输入(没有默认值)都拦下
|
||||
|
||||
夹具的工作流、字段名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scheduler import service
|
||||
from app.scheduler.service import TemplateError
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def values(fire_at: datetime, tz: str = "Asia/Shanghai", cron: str = "0 9 * * 1") -> dict[str, str]:
|
||||
return service.template_values(fire_at, tz, cron)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 变量按 fire_at 在任务时区算
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_monday_morning_in_shanghai():
|
||||
v = values(datetime(2026, 10, 5, 1, 0, tzinfo=UTC)) # 上海 2026-10-05(周一)09:00
|
||||
assert v == {
|
||||
"today": "2026-10-05",
|
||||
"yesterday": "2026-10-04",
|
||||
"this_week_start": "2026-10-05",
|
||||
"last_week_start": "2026-09-28",
|
||||
"last_week_end": "2026-10-04",
|
||||
"this_month_start": "2026-10-01",
|
||||
"last_month_start": "2026-09-01",
|
||||
"last_month_end": "2026-09-30",
|
||||
"fire_time": "2026-10-05T09:00:00+08:00",
|
||||
"last_fire_time": "2026-09-28T09:00:00+08:00",
|
||||
}
|
||||
|
||||
|
||||
def test_across_a_month_boundary():
|
||||
# 上海 2026-03-01(周日)00:30,UTC 还是 2 月 28 日
|
||||
v = values(datetime(2026, 2, 28, 16, 30, tzinfo=UTC), cron="30 0 * * *")
|
||||
assert (v["today"], v["yesterday"]) == ("2026-03-01", "2026-02-28")
|
||||
assert (v["this_week_start"], v["last_week_start"], v["last_week_end"]) == ("2026-02-23", "2026-02-16", "2026-02-22")
|
||||
assert (v["this_month_start"], v["last_month_start"], v["last_month_end"]) == ("2026-03-01", "2026-02-01", "2026-02-28")
|
||||
assert v["last_fire_time"] == "2026-02-28T00:30:00+08:00"
|
||||
|
||||
|
||||
def test_across_a_year_boundary():
|
||||
v = values(datetime(2027, 1, 1, 0, 0, tzinfo=UTC), cron="0 8 1 * *") # 上海 2027-01-01(周五)08:00
|
||||
assert (v["today"], v["yesterday"]) == ("2027-01-01", "2026-12-31")
|
||||
assert (v["this_week_start"], v["last_week_start"], v["last_week_end"]) == ("2026-12-28", "2026-12-21", "2026-12-27")
|
||||
assert (v["this_month_start"], v["last_month_start"], v["last_month_end"]) == ("2027-01-01", "2026-12-01", "2026-12-31")
|
||||
assert v["last_fire_time"] == "2026-12-01T08:00:00+08:00"
|
||||
|
||||
|
||||
def test_leap_february_ends_on_the_29th():
|
||||
v = values(datetime(2028, 3, 1, 1, 0, tzinfo=UTC), cron="0 9 1 * *")
|
||||
assert (v["last_month_start"], v["last_month_end"]) == ("2028-02-01", "2028-02-29")
|
||||
|
||||
|
||||
def test_same_moment_in_two_timezones():
|
||||
"""UTC 2026-10-04 20:00:上海已经是周一 04:00,纽约还是周日 16:00"""
|
||||
moment = datetime(2026, 10, 4, 20, 0, tzinfo=UTC)
|
||||
shanghai = values(moment, "Asia/Shanghai", "0 4 * * *")
|
||||
new_york = values(moment, "America/New_York", "0 16 * * *")
|
||||
assert (shanghai["today"], shanghai["this_week_start"]) == ("2026-10-05", "2026-10-05")
|
||||
assert (new_york["today"], new_york["this_week_start"]) == ("2026-10-04", "2026-09-28")
|
||||
assert shanghai["fire_time"] == "2026-10-05T04:00:00+08:00"
|
||||
assert new_york["fire_time"] == "2026-10-04T16:00:00-04:00"
|
||||
|
||||
|
||||
def test_fire_time_drops_sub_seconds():
|
||||
"""立即运行的触发时间是点下去的那一刻,写进输入时只到秒"""
|
||||
v = values(datetime(2026, 10, 5, 3, 4, 5, 678901, tzinfo=UTC))
|
||||
assert v["fire_time"] == "2026-10-05T11:04:05+08:00"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 渲染与校验
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_inputs():
|
||||
v = values(datetime(2026, 10, 5, 1, 0, tzinfo=UTC))
|
||||
out = service.render_inputs({
|
||||
"stat_date": "{{ schedule.yesterday }}",
|
||||
"week": "{{schedule.last_week_start}}~{{ schedule.last_week_end }}",
|
||||
"region": "华东",
|
||||
"tag": "{{ schedule.today | upper }}",
|
||||
}, v)
|
||||
assert out == {"stat_date": "2026-10-04", "week": "2026-09-28~2026-10-04", "region": "华东", "tag": "2026-10-05"}
|
||||
|
||||
|
||||
def test_valid_templates_pass():
|
||||
assert service.check_template({"a": "{{ schedule.today }}", "b": "固定值", "c": "{{ schedule.fire_time | trim }}"}) == {
|
||||
"a": "{{ schedule.today }}", "b": "固定值", "c": "{{ schedule.fire_time | trim }}"}
|
||||
assert service.check_template(None) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template, fragments", [
|
||||
({"stat_date": "{{ schedule.yesterdy }}"}, ["stat_date", "schedule.yesterdy", "schedule.yesterday"]),
|
||||
({"stat_date": "{{ input.day }}"}, ["stat_date", "input.day"]),
|
||||
({"stat_date": "{{ schedule.today"}, ["stat_date", "成对"]),
|
||||
({"stat_date": "schedule.today }}"}, ["stat_date", "成对"]),
|
||||
({"stat_date": "{{ }}"}, ["stat_date", "空的"]),
|
||||
({"stat_date": "{{ schedule.today | shout }}"}, ["stat_date", "shout", "过滤器"]),
|
||||
({"stat_date": 20261005}, ["stat_date", "文本"]),
|
||||
({"": "{{ schedule.today }}"}, ["字段名"]),
|
||||
(["{{ schedule.today }}"], ["输入模板"]),
|
||||
])
|
||||
def test_bad_templates_explain_in_chinese(template, fragments):
|
||||
with pytest.raises(TemplateError) as caught:
|
||||
service.check_template(template)
|
||||
for fragment in fragments:
|
||||
assert fragment in str(caught.value), str(caught.value)
|
||||
|
||||
|
||||
def test_variable_catalog():
|
||||
items = service.variables(datetime(2026, 10, 5, 1, 0, tzinfo=UTC), "Asia/Shanghai")
|
||||
names = [i["name"] for i in items]
|
||||
assert names == [f"schedule.{key}" for key in (
|
||||
"today", "yesterday", "this_week_start", "last_week_start", "last_week_end",
|
||||
"this_month_start", "last_month_start", "last_month_end", "fire_time", "last_fire_time")]
|
||||
by_name = {i["name"]: i for i in items}
|
||||
assert by_name["schedule.yesterday"] == {"name": "schedule.yesterday", "label": "昨天(YYYY-MM-DD)",
|
||||
"example": "2026-10-04"}
|
||||
assert all(i["label"] and i["example"] for i in items)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 和工作流入口字段对照
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def graph(*fields: dict) -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
|
||||
"data": {"label": "输入", "config": {"fields": list(fields)}}},
|
||||
{"id": "out", "type": "output", "position": {"x": 300, "y": 0},
|
||||
"data": {"label": "成果", "config": {"fields": [{"name": "r", "value": "完成"}]}}},
|
||||
],
|
||||
"edges": [{"source": "start", "target": "out"}],
|
||||
}
|
||||
|
||||
|
||||
def test_fields_of_a_graph():
|
||||
g = graph({"name": "stat_date", "required": True}, {"name": "region", "default": "华东"})
|
||||
assert [f["name"] for f in service.input_fields(g)] == ["stat_date", "region"]
|
||||
assert service.input_fields({"nodes": "坏掉的图"}) == []
|
||||
|
||||
|
||||
def test_unknown_field_names_are_refused():
|
||||
fields = service.input_fields(graph({"name": "stat_date"}, {"name": "region"}))
|
||||
with pytest.raises(TemplateError) as caught:
|
||||
service.check_fields({"stat_dat": "{{ schedule.yesterday }}"}, fields, "门店周报")
|
||||
assert "stat_dat" in str(caught.value) and "门店周报" in str(caught.value) and "stat_date" in str(caught.value)
|
||||
|
||||
|
||||
def test_missing_required_inputs_are_refused_only_when_asked():
|
||||
fields = service.input_fields(graph({"name": "stat_date", "required": True},
|
||||
{"name": "region", "required": True, "default": "华东"}))
|
||||
with pytest.raises(TemplateError) as caught:
|
||||
service.check_fields({}, fields, "门店周报")
|
||||
assert "stat_date" in str(caught.value) and "必填" in str(caught.value)
|
||||
# 预览时还在填,不查缺的;有默认值的必填项不算缺
|
||||
service.check_fields({}, fields, "门店周报", require=False)
|
||||
service.check_fields({"stat_date": "{{ schedule.yesterday }}"}, fields, "门店周报")
|
||||
|
||||
|
||||
def test_graph_without_declared_fields_accepts_any_name():
|
||||
"""入口没声明字段时输入是开放的:运行时传什么键都收"""
|
||||
service.check_fields({"anything": "x"}, service.input_fields(graph()), "门店周报")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""定时任务的调度循环(app/scheduler/loop.py、service.fire_once)。
|
||||
|
||||
- 到点只发起一次:条件 UPDATE 认领(WHERE next_run_at = 旧值)并推进到下一次,两拍并发也只有一拍认领得到;
|
||||
触发记录 (任务, fire_at, kind) 唯一,撞上约束的不再发起
|
||||
- 发起走 engine/launch.py 的 launch_run:记在「定时任务:<名字>」名下、带 schedule_id,输入按 fire_at 渲染;
|
||||
正式运行用当前发布版本,没有发布版本被拒,拒绝的原话记成 failed_to_start;别的异常记通用说明、详情进日志
|
||||
- 上一次的运行还没结束(排队、运行中、停在审批 / 挂起都算)记 skipped_overlap
|
||||
- 错过的触发:最近一次在宽限内补一次(kind=misfire),更早的积压只记一条跳过;最近一次也超出宽限就记一条
|
||||
skipped_misfire,推进到未来的下一次,不补积压
|
||||
- start / stop:起一个 asyncio 任务按拍执行,stop 干净地停下;AGENTLAB_SCHEDULER 关着时 start 不起循环
|
||||
|
||||
工作流、字段、任务名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app import scheduler
|
||||
from app.core.config import Settings
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Run, Schedule, ScheduleFire, Workflow, WorkflowVersion
|
||||
from app.engine.runner import run_manager
|
||||
from app.scheduler import loop, service
|
||||
|
||||
UTC = timezone.utc
|
||||
#: 上海 2026-10-05(周一)09:00
|
||||
NINE = datetime(2026, 10, 5, 1, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def engine_up():
|
||||
await run_manager.setup()
|
||||
async with SessionLocal() as session:
|
||||
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(text: str) -> dict:
|
||||
return {
|
||||
"nodes": [node("start", "input", fields=[{"name": "stat_date", "required": True}]),
|
||||
node("out", "output", fields=[{"name": "r", "value": f"{text} {{{{ input.stat_date }}}}"}])],
|
||||
"edges": [{"source": "start", "target": "out"}],
|
||||
}
|
||||
|
||||
|
||||
async def workflow(*, published: bool = True, canvas: str = "画布", snapshot: str = "发布版") -> str:
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name=f"门店周报-{uuid.uuid4().hex[:6]}", graph=graph(canvas),
|
||||
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(snapshot)))
|
||||
await session.commit()
|
||||
return wf.id
|
||||
|
||||
|
||||
async def schedule(workflow_id: str, *, cron: str = "0 9 * * *", next_run_at: datetime | None = NINE,
|
||||
run_class: str = "formal", grace: int = 3600, enabled: bool = True, name: str = "周一早报") -> str:
|
||||
async with SessionLocal() as session:
|
||||
row = Schedule(name=name, workflow_id=workflow_id, cron=cron, timezone="Asia/Shanghai", run_class=run_class,
|
||||
input_template={"stat_date": "{{ schedule.yesterday }}"}, enabled=enabled,
|
||||
misfire_grace_s=grace, next_run_at=next_run_at)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
return row.id
|
||||
|
||||
|
||||
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 runs_of(schedule_id: str) -> list[Run]:
|
||||
async with SessionLocal() as session:
|
||||
return list((await session.execute(select(Run).where(Run.schedule_id == schedule_id))).scalars())
|
||||
|
||||
|
||||
async def next_of(schedule_id: str) -> datetime | None:
|
||||
async with SessionLocal() as session:
|
||||
return (await session.get(Schedule, schedule_id)).next_run_at
|
||||
|
||||
|
||||
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_due_schedule_launches_once_and_moves_on():
|
||||
sid = await schedule(await workflow())
|
||||
await loop.fire_due(NINE + timedelta(seconds=10))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.kind, fire.status, fire.fire_at, fire.reason) == ("scheduled", "started", NINE, None)
|
||||
assert fire.inputs == {"stat_date": "2026-10-04"}
|
||||
run = await settle(fire.run_id)
|
||||
assert run.schedule_id == sid and run.run_class == "formal" and run.status == "succeeded"
|
||||
assert run.started_by == "定时任务:周一早报"
|
||||
assert run.input == {"stat_date": "2026-10-04"}
|
||||
# 正式运行用的是发布版本,不是画布
|
||||
assert run.version == 1 and run.output == {"r": "发布版 2026-10-04"}
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
# 同一拍再来一次:已经推进到明天,不再发起
|
||||
await loop.fire_due(NINE + timedelta(seconds=20))
|
||||
assert len(await fires(sid)) == 1 and len(await runs_of(sid)) == 1
|
||||
|
||||
|
||||
async def test_exploratory_schedule_uses_the_canvas():
|
||||
sid = await schedule(await workflow(published=False), run_class="exploratory")
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
run = await settle(fire.run_id)
|
||||
assert run.run_class == "exploratory" and run.output == {"r": "画布 2026-10-04"}
|
||||
|
||||
|
||||
async def test_two_concurrent_ticks_launch_only_once():
|
||||
sid = await schedule(await workflow())
|
||||
now = NINE + timedelta(seconds=3)
|
||||
await asyncio.gather(loop.fire_due(now), loop.fire_due(now), loop.fire_due(now))
|
||||
assert len(await fires(sid)) == 1
|
||||
[run] = await runs_of(sid)
|
||||
await settle(run.id)
|
||||
|
||||
|
||||
async def test_concurrent_ticks_with_different_clocks_claim_once():
|
||||
"""一拍准点、一拍晚了两分钟(会被当成补发、kind 不同、撞不上唯一约束):只有认领到的那一拍处理"""
|
||||
sid = await schedule(await workflow())
|
||||
await asyncio.gather(loop.fire_due(NINE + timedelta(seconds=3)), loop.fire_due(NINE + timedelta(minutes=2)))
|
||||
[fire] = await fires(sid)
|
||||
[run] = await runs_of(sid)
|
||||
assert fire.run_id == run.id
|
||||
await settle(run.id)
|
||||
|
||||
|
||||
async def test_a_fire_already_recorded_is_not_launched_again():
|
||||
"""认领之外的第二道保险:同一 (任务, fire_at, kind) 已有记录,fire_once 交回那一条,不再发起"""
|
||||
sid = await schedule(await workflow())
|
||||
first = await service.fire_once(sid, NINE, "scheduled")
|
||||
again = await service.fire_once(sid, NINE, "scheduled")
|
||||
assert again.id == first.id and len(await fires(sid)) == 1
|
||||
[run] = await runs_of(sid)
|
||||
await settle(run.id)
|
||||
|
||||
|
||||
async def test_disabled_schedule_is_not_fired():
|
||||
sid = await schedule(await workflow(), enabled=False)
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
assert await fires(sid) == [] and await runs_of(sid) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["queued", "running", "interrupted"])
|
||||
async def test_previous_run_still_going_means_skip(status):
|
||||
wf = await workflow()
|
||||
sid = await schedule(wf)
|
||||
async with SessionLocal() as session:
|
||||
busy = Run(workflow_id=wf, workflow_name="门店周报", status=status, schedule_id=sid, run_class="formal")
|
||||
session.add(busy)
|
||||
await session.commit()
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.status, fire.run_id) == ("skipped_overlap", None)
|
||||
assert busy.id[:8] in fire.reason and "跳过" in fire.reason
|
||||
assert [r.id for r in await runs_of(sid)] == [busy.id]
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 错过的触发
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_missed_within_grace_is_caught_up_once_with_inputs_of_its_own_day():
|
||||
"""服务停到 00:30(上海 10 月 6 日)才起来:补一次 9 点那一次,昨天还是 10 月 4 日"""
|
||||
sid = await schedule(await workflow(), grace=86400)
|
||||
late = NINE + timedelta(hours=15, minutes=30)
|
||||
await loop.fire_due(late)
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.kind, fire.status, fire.fire_at) == ("misfire", "started", NINE)
|
||||
assert fire.inputs == {"stat_date": "2026-10-04"}
|
||||
await settle(fire.run_id)
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
async def test_backlog_is_skipped_and_only_the_latest_is_caught_up():
|
||||
"""每小时一次,停了三个多小时:01–03 点的积压记一条跳过,04 点那次在宽限内补发"""
|
||||
sid = await schedule(await workflow(), cron="0 * * * *", grace=3600)
|
||||
await loop.fire_due(NINE + timedelta(hours=3, minutes=20))
|
||||
skipped, caught_up = await fires(sid)
|
||||
assert (skipped.kind, skipped.status, skipped.fire_at, skipped.run_id) == ("misfire", "skipped_misfire", NINE, None)
|
||||
assert "2026-10-05 09:00" in skipped.reason and "2026-10-05 11:00" in skipped.reason
|
||||
assert (caught_up.kind, caught_up.status, caught_up.fire_at) == ("misfire", "started", NINE + timedelta(hours=3))
|
||||
assert len(await runs_of(sid)) == 1
|
||||
await settle(caught_up.run_id)
|
||||
assert await next_of(sid) == NINE + timedelta(hours=4)
|
||||
|
||||
|
||||
async def test_missed_beyond_grace_is_skipped_without_launching():
|
||||
sid = await schedule(await workflow(), grace=600)
|
||||
await loop.fire_due(NINE + timedelta(hours=2))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.kind, fire.status, fire.fire_at, fire.run_id) == ("misfire", "skipped_misfire", NINE, None)
|
||||
assert "10 分钟" in fire.reason and "2026-10-05 09:00" in fire.reason
|
||||
assert await runs_of(sid) == []
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
async def test_days_of_downtime_leave_one_skip_record():
|
||||
sid = await schedule(await workflow(), grace=600, next_run_at=NINE - timedelta(days=4))
|
||||
await loop.fire_due(NINE + timedelta(hours=2))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.status, fire.fire_at) == ("skipped_misfire", NINE)
|
||||
assert "2026-10-01 09:00" in fire.reason and "2026-10-05 09:00" in fire.reason
|
||||
assert await runs_of(sid) == [] and await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
async def test_on_time_fire_ignores_a_small_grace():
|
||||
"""宽限写 0 也不影响准点的触发:拍与拍之间的十几秒不算错过"""
|
||||
sid = await schedule(await workflow(), grace=0)
|
||||
await loop.fire_due(NINE + timedelta(seconds=14))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.kind, fire.status) == ("scheduled", "started")
|
||||
await settle(fire.run_id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发起被拒、出错
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_formal_schedule_without_published_version_records_the_refusal():
|
||||
wf = await workflow(published=False)
|
||||
sid = await schedule(wf)
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
async with SessionLocal() as session:
|
||||
name = (await session.get(Workflow, wf)).name
|
||||
assert (fire.status, fire.run_id) == ("failed_to_start", None)
|
||||
assert fire.reason == f"「{name}」还没有发布版本,请先在编排页发布"
|
||||
assert await runs_of(sid) == []
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
async def test_deleted_workflow_records_the_refusal():
|
||||
sid = await schedule(f"gone{uuid.uuid4().hex[:8]}")
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.status, fire.reason) == ("failed_to_start", "工作流不存在,可能已被删除")
|
||||
|
||||
|
||||
async def test_unexpected_errors_record_a_generic_reason(monkeypatch, caplog):
|
||||
from app.engine import launch
|
||||
|
||||
async def explode(*args, **kwargs):
|
||||
raise RuntimeError("库连接断开:sqlite busy")
|
||||
|
||||
monkeypatch.setattr(launch, "launch_run", explode)
|
||||
sid = await schedule(await workflow())
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
assert (fire.status, fire.reason) == ("failed_to_start", service.GENERIC_REASON)
|
||||
assert "库连接断开" in caplog.text
|
||||
assert await next_of(sid) == NINE + timedelta(days=1)
|
||||
|
||||
|
||||
async def test_broken_cron_in_the_database_stops_the_schedule():
|
||||
sid = await schedule(await workflow(), cron="0 25 * * *")
|
||||
await loop.fire_due(NINE + timedelta(seconds=5))
|
||||
[fire] = await fires(sid)
|
||||
assert fire.status == "failed_to_start" and "「时」" in fire.reason
|
||||
assert await next_of(sid) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# start / stop
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_start_does_nothing_when_switched_off():
|
||||
"""测试环境 AGENTLAB_SCHEDULER=0(conftest):start 不起循环,stop 照样能调"""
|
||||
assert await scheduler.start() is False
|
||||
assert scheduler.running() is False
|
||||
await scheduler.stop()
|
||||
|
||||
|
||||
async def test_loop_ticks_until_stopped():
|
||||
sid = await schedule(await workflow(), next_run_at=datetime.now(UTC) - timedelta(seconds=1), cron="* * * * *")
|
||||
on = Settings(_env_file=None, scheduler=True)
|
||||
assert await scheduler.start(interval=0.05, config=on) is True
|
||||
assert scheduler.running()
|
||||
for _ in range(200):
|
||||
if await fires(sid):
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
await scheduler.stop()
|
||||
assert scheduler.running() is False
|
||||
[fire] = await fires(sid)
|
||||
assert fire.status == "started"
|
||||
await settle(fire.run_id)
|
||||
# 停下之后不再有拍:把下一次拨回过去也不会再发起
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(Schedule.__table__.update().where(Schedule.id == sid)
|
||||
.values(next_run_at=datetime.now(UTC) - timedelta(seconds=1)))
|
||||
await session.commit()
|
||||
await asyncio.sleep(0.2)
|
||||
async with SessionLocal() as session:
|
||||
assert await session.scalar(select(func.count()).select_from(ScheduleFire)
|
||||
.where(ScheduleFire.schedule_id == sid)) == 1
|
||||
|
||||
|
||||
async def test_start_twice_keeps_one_loop():
|
||||
on = Settings(_env_file=None, scheduler=True)
|
||||
await scheduler.start(interval=0.05, config=on)
|
||||
first = loop.runner.task
|
||||
await scheduler.start(interval=0.05, config=on)
|
||||
assert loop.runner.task is first
|
||||
await scheduler.stop()
|
||||
assert first.done()
|
||||
@@ -0,0 +1,399 @@
|
||||
"""定时任务的 Webhook(app/scheduler/webhook.py):运行结束、失败、停在审批时推送一次。
|
||||
|
||||
- 四种格式的请求体和签名:通用 JSON(配了密钥时 X-AgentLab-Signature = HMAC-SHA256(密钥, 时间戳.请求体))、
|
||||
飞书文本(timestamp + sign,sign 是以「时间戳\\n密钥」为键的 HMAC-SHA256)、企业微信 markdown(key 在地址里)、
|
||||
钉钉 markdown(地址上加 timestamp 和 sign)
|
||||
- 通知里有任务名、状态、出具档位、缺口条数、运行链接(对外访问地址 + /runs/{id})、封存哈希前 12 位、开始结束
|
||||
时间;不放报告里的具体数字,不放失败的原始报错
|
||||
- 每一拍查已发起的触发:运行状态变了、任务订阅了这个事件、这个状态还没处理过才推送;停在审批只在确有待审批时
|
||||
算;结束的运行等封存好再推;没订阅、没配 Webhook 的也记下已处理,之后再配上不会补发旧的
|
||||
- 失败重试 3 次(4xx 不重试),每次推送记一条 WebhookDelivery;日志和错误里只有主机名,没有地址、key、
|
||||
access_token、密钥(httpx 自己的请求日志也一样)
|
||||
|
||||
用 httpx.MockTransport 接住请求,不连外网。任务名、地址、密钥、数字都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import parse_qs, quote_plus, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.crypto import encrypt
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Approval, Run, Schedule, ScheduleFire, Setting, WebhookDelivery
|
||||
from app.scheduler import webhook
|
||||
|
||||
UTC = timezone.utc
|
||||
NOW = datetime(2026, 10, 5, 1, 2, 3, tzinfo=UTC)
|
||||
FEISHU = "https://open.feishu.cn/open-apis/bot/v2/hook/0f1e2d3c-aaaa-bbbb-cccc-9d8e7f6a5b4c"
|
||||
WECOM = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=6a7b8c9d-1111-2222-3333-444455556666"
|
||||
DINGTALK = "https://oapi.dingtalk.com/robot/send?access_token=f00dfacecafebabe0123456789abcdef"
|
||||
GENERIC = "https://hooks.example.com/agentlab/inbox-7f3a"
|
||||
SECRET = "SECdemo-not-a-real-secret-42"
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""MockTransport 的处理函数:记下每个请求,按 replies 依次回应(用完了就一直回最后一个)。"""
|
||||
|
||||
def __init__(self, *replies):
|
||||
self.requests: list[httpx.Request] = []
|
||||
self.replies = list(replies) or [httpx.Response(200, json={"code": 0, "errcode": 0})]
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
reply = self.replies[min(len(self.requests), len(self.replies)) - 1]
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
return reply
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(monkeypatch):
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(webhook, "_transport", httpx.MockTransport(rec))
|
||||
monkeypatch.setattr(webhook, "RETRY_DELAYS", (0, 0, 0))
|
||||
return rec
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean():
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Schedule))
|
||||
await session.execute(delete(Setting).where(Setting.key == "notify"))
|
||||
await session.commit()
|
||||
yield
|
||||
|
||||
|
||||
def notice(**over) -> webhook.Notice:
|
||||
base = dict(event="finished", schedule_id="s1", schedule_name="门店周报", timezone="Asia/Shanghai",
|
||||
fire_id="f1", fire_at=datetime(2026, 10, 5, 1, 0, tzinfo=UTC), fire_kind="scheduled",
|
||||
run_id="r0123456789abcdef", status="succeeded", tier="degraded", gaps=2,
|
||||
run_url="https://agentlab.example.com/runs/r0123456789abcdef",
|
||||
manifest_hash="a1b2c3d4e5f60718293a", started_at=datetime(2026, 10, 5, 1, 0, 3, tzinfo=UTC),
|
||||
finished_at=datetime(2026, 10, 5, 1, 1, 10, tzinfo=UTC), reason=None, pending_approvals=None)
|
||||
base.update(over)
|
||||
return webhook.Notice(**base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 请求体和签名
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generic_json_carries_the_agreed_fields():
|
||||
url, headers, body = webhook.build_request("generic", GENERIC, None, notice(), now=NOW)
|
||||
data = json.loads(body)
|
||||
assert url == GENERIC and headers["Content-Type"] == "application/json"
|
||||
assert {k: data[k] for k in ("event", "schedule_name", "run_id", "status", "tier", "tier_label", "gaps",
|
||||
"run_url", "manifest_hash")} == {
|
||||
"event": "finished", "schedule_name": "门店周报", "run_id": "r0123456789abcdef", "status": "succeeded",
|
||||
"tier": "degraded", "tier_label": "降档出具", "gaps": 2,
|
||||
"run_url": "https://agentlab.example.com/runs/r0123456789abcdef", "manifest_hash": "a1b2c3d4e5f6"}
|
||||
assert data["started_at"] == "2026-10-05T01:00:03+00:00" and data["finished_at"] == "2026-10-05T01:01:10+00:00"
|
||||
assert "X-AgentLab-Signature" not in headers
|
||||
|
||||
|
||||
def test_generic_signature_when_a_secret_is_set():
|
||||
_, headers, body = webhook.build_request("generic", GENERIC, SECRET, notice(), now=NOW)
|
||||
ts = headers["X-AgentLab-Timestamp"]
|
||||
assert ts == str(int(NOW.timestamp()))
|
||||
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
|
||||
assert headers["X-AgentLab-Signature"] == f"sha256={expected}"
|
||||
|
||||
|
||||
def test_feishu_text_and_signature():
|
||||
url, _, body = webhook.build_request("feishu", FEISHU, SECRET, notice(), now=NOW)
|
||||
data = json.loads(body)
|
||||
assert url == FEISHU and data["msg_type"] == "text"
|
||||
ts = data["timestamp"]
|
||||
assert ts == str(int(NOW.timestamp()))
|
||||
# 飞书的签名:以「timestamp + 换行 + 密钥」为键,对空串做 HMAC-SHA256,再 base64
|
||||
key = f"{ts}\n{SECRET}".encode()
|
||||
assert data["sign"] == base64.b64encode(hmac.new(key, b"", hashlib.sha256).digest()).decode()
|
||||
text = data["content"]["text"]
|
||||
for fragment in ("门店周报", "降档出具", "缺口 2 条", "a1b2c3d4e5f6", "/runs/r0123456789abcdef",
|
||||
"2026-10-05 09:00", "2026-10-05 09:00:03", "2026-10-05 09:01:10"):
|
||||
assert fragment in text, text
|
||||
|
||||
|
||||
def test_feishu_without_secret_has_no_sign():
|
||||
_, _, body = webhook.build_request("feishu", FEISHU, None, notice(), now=NOW)
|
||||
assert "sign" not in json.loads(body) and "timestamp" not in json.loads(body)
|
||||
|
||||
|
||||
def test_wecom_markdown_keeps_the_key_in_the_address():
|
||||
url, _, body = webhook.build_request("wecom", WECOM, None, notice(), now=NOW)
|
||||
data = json.loads(body)
|
||||
assert url == WECOM and data["msgtype"] == "markdown"
|
||||
assert "门店周报" in data["markdown"]["content"] and "降档出具" in data["markdown"]["content"]
|
||||
|
||||
|
||||
def test_dingtalk_markdown_and_signed_address():
|
||||
url, _, body = webhook.build_request("dingtalk", DINGTALK, SECRET, notice(), now=NOW)
|
||||
data = json.loads(body)
|
||||
assert data["msgtype"] == "markdown" and "门店周报" in data["markdown"]["title"]
|
||||
assert "降档出具" in data["markdown"]["text"]
|
||||
query = parse_qs(urlsplit(url).query)
|
||||
ts = str(int(NOW.timestamp() * 1000))
|
||||
assert query["access_token"] == ["f00dfacecafebabe0123456789abcdef"] and query["timestamp"] == [ts]
|
||||
sign = base64.b64encode(hmac.new(SECRET.encode(), f"{ts}\n{SECRET}".encode(), hashlib.sha256).digest()).decode()
|
||||
assert query["sign"] == [sign]
|
||||
assert f"sign={quote_plus(sign)}" in url
|
||||
|
||||
|
||||
def test_awaiting_approval_and_failed_to_start_texts():
|
||||
waiting = webhook.message(notice(event="awaiting_approval", status="interrupted", tier=None, gaps=None,
|
||||
pending_approvals=2, finished_at=None))
|
||||
assert "等待审批" in waiting[0] and any("2 项" in value for _, value in waiting[1])
|
||||
refused = webhook.message(notice(event="failed", status="failed_to_start", run_id=None, run_url=None,
|
||||
tier=None, gaps=None, manifest_hash=None, started_at=None, finished_at=None,
|
||||
reason="「门店周报」还没有发布版本,请先在编排页发布"))
|
||||
assert "未能发起" in refused[0] and any("还没有发布版本" in value for _, value in refused[1])
|
||||
|
||||
|
||||
def test_url_hint_shows_only_host_and_last_four():
|
||||
assert webhook.url_hint(FEISHU) == "open.feishu.cn/…5b4c"
|
||||
assert webhook.url_hint(WECOM) == "qyapi.weixin.qq.com/…6666"
|
||||
assert webhook.url_hint("http://127.0.0.1:8794/hook") == "127.0.0.1:8794/…hook"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["", "open.feishu.cn/hook", "ftp://x.example.com/a", "https://", "https://a b.com/x"])
|
||||
def test_bad_addresses_are_refused(url):
|
||||
with pytest.raises(ValueError) as caught:
|
||||
webhook.check_url(url)
|
||||
assert "Webhook 地址" in str(caught.value)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 推送与重试
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_success_on_first_try(recorder):
|
||||
outcome = await webhook.send("feishu", FEISHU, SECRET, notice())
|
||||
assert (outcome.ok, outcome.http_status, outcome.attempts, outcome.error) == (True, 200, 1, None)
|
||||
assert len(recorder.requests) == 1 and str(recorder.requests[0].url) == FEISHU
|
||||
|
||||
|
||||
async def test_server_errors_are_retried(recorder):
|
||||
recorder.replies = [httpx.Response(502), httpx.ReadTimeout("timed out"), httpx.Response(200, json={"code": 0})]
|
||||
outcome = await webhook.send("feishu", FEISHU, None, notice())
|
||||
assert (outcome.ok, outcome.attempts) == (True, 3)
|
||||
|
||||
|
||||
async def test_gives_up_after_three_retries(recorder):
|
||||
recorder.replies = [httpx.Response(500)]
|
||||
outcome = await webhook.send("generic", GENERIC, None, notice())
|
||||
assert (outcome.ok, outcome.http_status, outcome.attempts) == (False, 500, 4)
|
||||
assert "HTTP 500" in outcome.error
|
||||
|
||||
|
||||
async def test_client_errors_are_not_retried(recorder):
|
||||
recorder.replies = [httpx.Response(404)]
|
||||
outcome = await webhook.send("generic", GENERIC, None, notice())
|
||||
assert (outcome.ok, outcome.http_status, outcome.attempts) == (False, 404, 1)
|
||||
|
||||
|
||||
async def test_platform_error_code_counts_as_failure(recorder):
|
||||
recorder.replies = [httpx.Response(200, json={"code": 19021, "msg": "sign match fail or timestamp is not within one hour"})]
|
||||
outcome = await webhook.send("feishu", FEISHU, SECRET, notice())
|
||||
assert not outcome.ok and outcome.error.startswith("open.feishu.cn 返回错误码 19021:sign match fail")
|
||||
recorder.replies = [httpx.Response(200, json={"errcode": 310000, "errmsg": "sign not match"})]
|
||||
assert "310000" in (await webhook.send("dingtalk", DINGTALK, SECRET, notice())).error
|
||||
|
||||
|
||||
async def test_connection_errors_name_only_the_host(recorder):
|
||||
recorder.replies = [httpx.ConnectError(f"cannot reach {WECOM}")]
|
||||
outcome = await webhook.send("wecom", WECOM, None, notice())
|
||||
assert not outcome.ok and outcome.attempts == 4
|
||||
assert "qyapi.weixin.qq.com" in outcome.error and "6a7b8c9d" not in outcome.error
|
||||
|
||||
|
||||
async def test_logs_never_contain_the_address_key_or_secret(recorder, caplog):
|
||||
caplog.set_level(logging.DEBUG)
|
||||
recorder.replies = [httpx.Response(500, json={"errmsg": f"bad token {DINGTALK}"}), httpx.Response(200, json={"errcode": 0})]
|
||||
await webhook.send("dingtalk", DINGTALK, SECRET, notice())
|
||||
await webhook.send("feishu", FEISHU, SECRET, notice())
|
||||
text = caplog.text
|
||||
assert "oapi.dingtalk.com" in text and "open.feishu.cn" in text # 主机名照常记
|
||||
for leaked in (DINGTALK, FEISHU, "f00dfacecafebabe0123456789abcdef", "0f1e2d3c-aaaa", SECRET, "access_token="):
|
||||
assert leaked not in text, leaked
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 每一拍的通知扫描
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def make(*, run_status: str | None = "succeeded", events=("finished", "failed", "awaiting_approval"),
|
||||
fmt: str = "generic", url: str | None = GENERIC, secret: str | None = None, sealed: bool = True,
|
||||
fire_status: str = "started", output: dict | None = None, reason: str | None = None):
|
||||
"""建一个配了 Webhook 的任务、一次触发和它发起的运行(运行不真的执行,状态直接写库)。"""
|
||||
async with SessionLocal() as session:
|
||||
sched = Schedule(name="门店周报", workflow_id="wf-demo", cron="0 9 * * 1", timezone="Asia/Shanghai",
|
||||
webhook_format=fmt if url else None, webhook_url=encrypt(url) if url else None,
|
||||
webhook_secret=encrypt(secret) if secret else None, webhook_events=list(events))
|
||||
session.add(sched)
|
||||
await session.flush()
|
||||
run = None
|
||||
if run_status:
|
||||
now = datetime.now(UTC)
|
||||
run = Run(workflow_id=None, workflow_name="门店周报", status=run_status, schedule_id=sched.id,
|
||||
run_class="formal", started_at=now - timedelta(minutes=1),
|
||||
finished_at=now if run_status in ("succeeded", "failed", "cancelled") else None,
|
||||
manifest_hash=("c0ffee" * 10) if sealed else None,
|
||||
output=output if output is not None else {
|
||||
"report": "本周销售额 98765.43 元,同比增长 12.5%",
|
||||
"_issuance": {"tier": "formal", "gaps": []}})
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
fire = ScheduleFire(schedule_id=sched.id, fire_at=datetime.now(UTC).replace(microsecond=0),
|
||||
kind="scheduled", status=fire_status, run_id=run.id if run else None, reason=reason)
|
||||
session.add(fire)
|
||||
await session.commit()
|
||||
return sched.id, fire.id, run.id if run else None
|
||||
|
||||
|
||||
async def fire_row(fire_id: str) -> ScheduleFire:
|
||||
async with SessionLocal() as session:
|
||||
return await session.get(ScheduleFire, fire_id)
|
||||
|
||||
|
||||
async def deliveries(fire_id: str) -> list[WebhookDelivery]:
|
||||
async with SessionLocal() as session:
|
||||
return list((await session.execute(
|
||||
select(WebhookDelivery).where(WebhookDelivery.fire_id == fire_id).order_by(WebhookDelivery.created_at)
|
||||
)).scalars())
|
||||
|
||||
|
||||
async def set_run(run_id: str, **values) -> None:
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(Run.__table__.update().where(Run.id == run_id).values(**values))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_finished_run_is_pushed_once(recorder):
|
||||
async with SessionLocal() as session:
|
||||
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"}))
|
||||
await session.commit()
|
||||
_, fire_id, run_id = await make()
|
||||
assert await webhook.notify_due() == 1
|
||||
[req] = recorder.requests
|
||||
data = json.loads(req.content)
|
||||
assert (data["event"], data["status"], data["tier"], data["gaps"]) == ("finished", "succeeded", "formal", 0)
|
||||
assert data["run_url"] == f"https://agentlab.example.com/runs/{run_id}"
|
||||
assert data["manifest_hash"] == ("c0ffee" * 2)
|
||||
# 报告里的数字不进通知
|
||||
assert "98765.43" not in req.content.decode() and "12.5" not in req.content.decode()
|
||||
[delivery] = await deliveries(fire_id)
|
||||
assert (delivery.event, delivery.status, delivery.http_status, delivery.attempts, delivery.run_id) == (
|
||||
"finished", "ok", 200, 1, run_id)
|
||||
assert (await fire_row(fire_id)).notified_status == "succeeded"
|
||||
# 下一拍:同一状态不再推送
|
||||
assert await webhook.notify_due() == 0
|
||||
assert len(recorder.requests) == 1
|
||||
|
||||
|
||||
async def test_no_public_address_means_no_link(recorder):
|
||||
await make()
|
||||
await webhook.notify_due()
|
||||
assert json.loads(recorder.requests[0].content)["run_url"] is None
|
||||
|
||||
|
||||
async def test_failed_run_and_failed_to_start(recorder):
|
||||
_, failed_fire, _ = await make(run_status="failed", output={})
|
||||
_, refused_fire, _ = await make(run_status=None, fire_status="failed_to_start",
|
||||
reason="「门店周报」还没有发布版本,请先在编排页发布")
|
||||
assert await webhook.notify_due() == 2
|
||||
bodies = sorted((json.loads(r.content) for r in recorder.requests), key=lambda b: b["status"])
|
||||
assert [(b["event"], b["status"]) for b in bodies] == [("failed", "failed"), ("failed", "failed_to_start")]
|
||||
assert bodies[1]["run_id"] is None and "还没有发布版本" in bodies[1]["reason"]
|
||||
assert (await fire_row(failed_fire)).notified_status == "failed"
|
||||
assert (await fire_row(refused_fire)).notified_status == "failed_to_start"
|
||||
|
||||
|
||||
async def test_unsubscribed_event_is_marked_but_not_pushed(recorder):
|
||||
_, fire_id, _ = await make(events=("failed",))
|
||||
assert await webhook.notify_due() == 0
|
||||
assert recorder.requests == [] and await deliveries(fire_id) == []
|
||||
assert (await fire_row(fire_id)).notified_status == "succeeded"
|
||||
|
||||
|
||||
async def test_without_webhook_old_fires_are_not_pushed_later(recorder):
|
||||
sid, fire_id, _ = await make(url=None)
|
||||
await webhook.notify_due()
|
||||
assert (await fire_row(fire_id)).notified_status == "succeeded"
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(Schedule.__table__.update().where(Schedule.id == sid).values(
|
||||
webhook_format="generic", webhook_url=encrypt(GENERIC), webhook_events=["finished"]))
|
||||
await session.commit()
|
||||
assert await webhook.notify_due() == 0 and recorder.requests == []
|
||||
|
||||
|
||||
async def test_cancelled_run_is_not_pushed(recorder):
|
||||
_, fire_id, _ = await make(run_status="cancelled")
|
||||
await webhook.notify_due()
|
||||
assert recorder.requests == [] and (await fire_row(fire_id)).notified_status == "cancelled"
|
||||
|
||||
|
||||
async def test_waits_for_the_seal_before_pushing(recorder):
|
||||
_, fire_id, run_id = await make(sealed=False)
|
||||
assert await webhook.notify_due() == 0 and (await fire_row(fire_id)).notified_status is None
|
||||
await set_run(run_id, manifest_hash="beef" * 16)
|
||||
assert await webhook.notify_due() == 1
|
||||
assert json.loads(recorder.requests[0].content)["manifest_hash"] == "beef" * 3
|
||||
|
||||
|
||||
async def test_awaiting_approval_only_with_a_real_pending_approval(recorder):
|
||||
"""服务重启挂起的运行也是 interrupted,但没有待审批:不推。有了待审批推一次,批完跑完再推完成"""
|
||||
_, fire_id, run_id = await make(run_status="interrupted")
|
||||
assert await webhook.notify_due() == 0 and (await fire_row(fire_id)).notified_status is None
|
||||
async with SessionLocal() as session:
|
||||
session.add(Approval(run_id=run_id, node_id="gate", title="审核周报", status="pending"))
|
||||
await session.commit()
|
||||
assert await webhook.notify_due() == 1
|
||||
data = json.loads(recorder.requests[0].content)
|
||||
assert (data["event"], data["status"], data["pending_approvals"]) == ("awaiting_approval", "interrupted", 1)
|
||||
assert await webhook.notify_due() == 0
|
||||
await set_run(run_id, status="succeeded", finished_at=datetime.now(UTC), manifest_hash="ab" * 32)
|
||||
assert await webhook.notify_due() == 1
|
||||
assert [json.loads(r.content)["event"] for r in recorder.requests] == ["awaiting_approval", "finished"]
|
||||
assert [d.event for d in await deliveries(fire_id)] == ["awaiting_approval", "finished"]
|
||||
|
||||
|
||||
async def test_failed_delivery_is_recorded_without_secrets(recorder):
|
||||
recorder.replies = [httpx.Response(503, text=f"upstream {DINGTALK} down")]
|
||||
_, fire_id, _ = await make(fmt="dingtalk", url=DINGTALK, secret=SECRET)
|
||||
await webhook.notify_due()
|
||||
[delivery] = await deliveries(fire_id)
|
||||
assert (delivery.status, delivery.http_status, delivery.attempts) == ("failed", 503, 4)
|
||||
assert "HTTP 503" in delivery.error
|
||||
for leaked in (DINGTALK, "f00dfacecafebabe", SECRET):
|
||||
assert leaked not in delivery.error
|
||||
# 推送失败也算这个状态处理过了:不会每一拍都再试一遍
|
||||
assert (await fire_row(fire_id)).notified_status == "succeeded"
|
||||
|
||||
|
||||
async def test_test_message(recorder):
|
||||
sid, _, _ = await make(fmt="feishu", url=FEISHU, secret=SECRET, run_status=None, fire_status="skipped_overlap")
|
||||
result = await webhook.send_test(sid)
|
||||
assert result == {"ok": True, "http_status": 200, "message": "测试消息已送达(HTTP 200)"}
|
||||
data = json.loads(recorder.requests[0].content)
|
||||
assert "测试" in data["content"]["text"] and "门店周报" in data["content"]["text"] and data["sign"]
|
||||
async with SessionLocal() as session:
|
||||
[row] = (await session.execute(select(WebhookDelivery).where(WebhookDelivery.schedule_id == sid))).scalars()
|
||||
assert (row.event, row.status, row.fire_id, row.attempts) == ("test", "ok", None, 1)
|
||||
recorder.replies = [httpx.ConnectError("refused")]
|
||||
failed = await webhook.send_test(sid)
|
||||
assert failed["ok"] is False and failed["http_status"] is None and "open.feishu.cn" in failed["message"]
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址。
|
||||
|
||||
- 默认没填(null),通知里就不附运行链接
|
||||
- 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收
|
||||
- 写错整个请求回 422、detail 是中文,一项都不落库
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.api.settings import NOTIFY_GROUP, notify_settings, notify_values
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Setting
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def clean():
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Setting).where(Setting.key.in_([NOTIFY_GROUP, "run"])))
|
||||
await session.commit()
|
||||
yield
|
||||
|
||||
|
||||
def client() -> AsyncClient:
|
||||
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
||||
|
||||
|
||||
async def test_default_is_no_link():
|
||||
async with client() as c:
|
||||
body = (await c.get("/api/settings")).json()
|
||||
assert body["notify"] == {"public_base_url": None}
|
||||
async with SessionLocal() as session:
|
||||
assert await notify_settings(session) == {"public_base_url": None}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("given, stored", [
|
||||
("https://agentlab.example.com/", "https://agentlab.example.com"),
|
||||
(" http://10.20.30.40:8080 ", "http://10.20.30.40:8080"),
|
||||
("https://intra.example.com/agentlab/", "https://intra.example.com/agentlab"),
|
||||
("", None),
|
||||
(None, None),
|
||||
])
|
||||
async def test_saved_and_normalized(given, stored):
|
||||
async with client() as c:
|
||||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["notify"] == {"public_base_url": stored}
|
||||
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored}
|
||||
async with SessionLocal() as session:
|
||||
assert await notify_settings(session) == {"public_base_url": stored}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value, fragment", [
|
||||
({"public_base_url": "agentlab.example.com"}, "http:// 或 https://"),
|
||||
({"public_base_url": "ftp://files.example.com"}, "http:// 或 https://"),
|
||||
({"public_base_url": "https://"}, "http:// 或 https://"),
|
||||
({"public_base_url": 8080}, "http:// 或 https://"),
|
||||
({"public_base_url": "https://agentlab.example.com/?from=bot"}, "? 或 #"),
|
||||
({"public_url": "https://agentlab.example.com"}, "不支持「public_url」"),
|
||||
("https://agentlab.example.com", "格式有误"),
|
||||
])
|
||||
async def test_invalid_values_are_refused_in_chinese(value, fragment):
|
||||
async with client() as c:
|
||||
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: value, "run": {"stream_tokens": False}}})
|
||||
assert put.status_code == 422
|
||||
assert fragment in put.json()["detail"]
|
||||
assert "对外访问地址" in put.json()["detail"] or "通知" in put.json()["detail"]
|
||||
# 先查后写:同一请求里别的组也没有落库
|
||||
assert (await c.get("/api/settings")).json()["run"]["stream_tokens"] is True
|
||||
|
||||
|
||||
def test_broken_stored_value_reads_as_unset():
|
||||
assert notify_values({"public_base_url": "不是地址"}) == {"public_base_url": None}
|
||||
assert notify_values("坏掉的") == {"public_base_url": None}
|
||||
Reference in New Issue
Block a user