mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:41:59 +00:00
277982ead3
- 蓝图:bp_* 操作和逐卡白名单、蓝图检查、留痕、按蓝图判权限;五个场景模板加自由形(骨架、关节、应用骨架、定时任务),实例化与骨架比对;BlueprintApplier / TemplateApplier(只收模板声明过的关节)与自查;look 新类别口径卡;蓝图对话和填关节两个流式接口,不同组的关节并行填、出错的组串行重试;模板列表 - 试运行与发布单:四种起点(对话、模板、问数据的一轮、已发布版本);试运行是探索运行,草稿应用按这次运行解析、印章写试运行,流程类派给自己、不触发升级;发布单九步、预检只读、每步留痕、停下后接着做、核对卡片指纹和应用管理权、启动时整理中断的单;白话层覆盖全部问题编号 - 记录表与文件:平台托管只追加的记录表、按高水位固定数据、连接上的临时视图做行级可见、写入走动作(records_append)、表版本在发布时切换、草稿不影响线上、记录列表遮敏感列;文件按哈希存、两步上传、提交时绑定运行、下载按读权限、清理、file_text、按配方导入一期(table_import_period) - 个人应用:存成个人应用(问数据的约束)、刷新(遮罩、只读、每日上限)、只有拥有者看得见、提交审核与共享副本、开关下发到认人接口;页面编辑的候选;隐藏组件不下发;三条新检查
279 lines
16 KiB
Python
279 lines
16 KiB
Python
"""待办、实例、动作的通知:订阅、拼消息、投递记录(notify_channels / notify_deliveries)。
|
||
|
||
谁收得到:
|
||
- 应用版本 spec.notify 里订阅了这个事件的渠道([{channel: 渠道 id, events: [...]}]);
|
||
- 范围是 global、自己订阅了这个事件的渠道;
|
||
- 待办派给的人自己的渠道(范围是 user、owner_id 是他的用户 id;开了登录才有)。
|
||
同一个渠道只推一次。待办改派、升级、催办都是「有一条待办要你处理」,订阅了 task.opened 的渠道一并收到。
|
||
|
||
消息里只放标题、状态、链接(P4,沿用定时任务 Webhook 的规矩):不放报告里的数字,待办的标题用节点在图上的名字
|
||
(搭图时起的),不用按运行数据渲染出来的那一句。链接要设了对外访问地址(设置里的 notify)才有。
|
||
|
||
推送放在后台(spawn):接收端挂住时超时加重试要几十秒,不能拖着开待办、运行收尾。测试用 drain 等它们推完。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from collections.abc import Coroutine
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Task, TaskAssignment, User
|
||
from app.notify.core import Message, Outcome, host_of
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: 可以订阅的事件
|
||
EVENTS = ("task.opened", "run.finished", "run.failed", "action.unknown", "review.done")
|
||
EVENT_LABEL = {"task.opened": "有待办派给我", "run.finished": "实例完成", "run.failed": "实例失败",
|
||
"action.unknown": "写入结果未知",
|
||
# 个人应用提交审核之后,搭建者发布了或退回了(docs/design/normal-user-apps.md N7)
|
||
"review.done": "我提交审核的应用有结果"}
|
||
#: 这几种也是「有一条待办要你处理」,按 task.opened 的订阅推
|
||
_AS = {"task.reassigned": "task.opened", "task.escalated": "task.opened", "task.reminded": "task.opened"}
|
||
_TASK_HEAD = {"task.opened": "新的待办", "task.reassigned": "待办已改派给你", "task.escalated": "待办已超时,升级处理",
|
||
"task.reminded": "待办催办"}
|
||
_KIND_TEXT = {"approve": "审批", "input": "补充输入", "edit": "编辑稿件", "tool": "工具调用审批", "code": "代码执行审批",
|
||
"dual_check": "双盲复算裁决", "action_confirm": "确认写入外部系统", "reconcile": "核实写入结果",
|
||
"signal": "等外部系统回调", "review": "审核个人应用"}
|
||
_ENV_TEXT = {"prod": "生产环境", "test": "测试环境", "simulated": "演练"}
|
||
|
||
_background: set[asyncio.Task[Any]] = set()
|
||
|
||
|
||
def spawn(coro: Coroutine[Any, Any, Any]) -> None:
|
||
"""在后台推送。留着引用,免得任务还没跑完就被回收。"""
|
||
task = asyncio.create_task(coro)
|
||
_background.add(task)
|
||
task.add_done_callback(_background.discard)
|
||
|
||
|
||
async def drain() -> None:
|
||
"""等后台的推送都推完(测试、关停用)。"""
|
||
while _background:
|
||
await asyncio.gather(*list(_background), return_exceptions=True)
|
||
|
||
|
||
async def personal_url_problem(url: str) -> str | None:
|
||
"""个人渠道(scope=user,谁都能建)的地址过 SSRF 检查(tools/net.py,不开内网):内网、回环、元数据地址不发。
|
||
api/notify.py 保存时、send 发送前各查一次(域名可能事后改指向内网)。没问题为 None。
|
||
|
||
说法不带检查的细节(解析到了哪个地址、解析失败还是落在内网):那也是探测内网的信号。"""
|
||
from app.tools.net import UnsafeUrlError, assert_safe_url
|
||
|
||
try:
|
||
await assert_safe_url(url)
|
||
except UnsafeUrlError:
|
||
return "个人通知渠道只能发往公网地址(不能是内网、回环或云服务器的元数据地址,域名也要能解析),请改用公网的接收地址"
|
||
return None
|
||
|
||
|
||
def channel_out(row: NotifyChannel) -> dict[str, Any]:
|
||
"""渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。"""
|
||
from app.core.crypto import decrypt
|
||
|
||
url = decrypt(row.url) or ""
|
||
return {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
|
||
"url_host": host_of(url) if url else "", "signed": bool(row.secret), "events": list(row.events or []),
|
||
"enabled": row.enabled}
|
||
|
||
|
||
def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]:
|
||
"""通用格式的请求体。subject 只有编号,没有内容。"""
|
||
from app.notify.core import text_of
|
||
|
||
return {"source": "agentlab", "event": message.get("event"), "title": msg.title, "text": text_of(msg),
|
||
"status": message.get("status"), "subject": message.get("subject") or {},
|
||
"links": [{"label": label, "url": url} for label, url in msg.links], "sent_at": utcnow().isoformat()}
|
||
|
||
|
||
async def _base_url(session: Any) -> str | None:
|
||
from app.api.settings import notify_settings
|
||
|
||
return (await notify_settings(session))["public_base_url"]
|
||
|
||
|
||
async def _channels(session: Any, event: str, run: Run | None, users: list[str]) -> list[NotifyChannel]:
|
||
wanted = _AS.get(event, event)
|
||
found: dict[str, NotifyChannel] = {}
|
||
if run is not None and run.app_id and run.app_version is not None:
|
||
row = (await session.execute(select(AppVersion.spec, AppVersion.bindings).where(
|
||
AppVersion.app_id == run.app_id, AppVersion.version == run.app_version))).first()
|
||
spec, bindings = (row[0], row[1]) if row is not None else ({}, {})
|
||
# spec 里写的可以是渠道 id,也可以是别名:别名到这个部署的渠道 id 在 bindings.channels 里(spec 可以导出,
|
||
# 渠道是这个部署自己的,同岗位绑定)
|
||
aliases = (bindings or {}).get("channels") or {}
|
||
subscribed: list[str] = []
|
||
for sub in (spec or {}).get("notify") or []:
|
||
if isinstance(sub, dict) and wanted in (sub.get("events") or []):
|
||
target = aliases.get(str(sub.get("channel")), sub.get("channel"))
|
||
subscribed += [str(t) for t in (target if isinstance(target, list) else [target]) if t]
|
||
if subscribed:
|
||
for row in (await session.execute(select(NotifyChannel).where(
|
||
NotifyChannel.id.in_(subscribed), NotifyChannel.enabled.is_(True)))).scalars():
|
||
found[row.id] = row
|
||
for row in (await session.execute(select(NotifyChannel).where(
|
||
NotifyChannel.scope == "global", NotifyChannel.enabled.is_(True)))).scalars():
|
||
if wanted in (row.events or []):
|
||
found.setdefault(row.id, row)
|
||
if users:
|
||
owners = list((await session.execute(select(User.id).where(User.username.in_(users)))).scalars())
|
||
if owners:
|
||
for row in (await session.execute(select(NotifyChannel).where(
|
||
NotifyChannel.scope == "user", NotifyChannel.owner_id.in_(owners),
|
||
NotifyChannel.enabled.is_(True)))).scalars():
|
||
if wanted in (row.events or []):
|
||
found.setdefault(row.id, row)
|
||
return list(found.values())
|
||
|
||
|
||
async def _push(channels: list[NotifyChannel], message: dict[str, Any], *, subject_kind: str, subject_id: str,
|
||
event: str, run_id: str | None, task_id: str | None) -> int:
|
||
from app.notify import send
|
||
|
||
for channel in channels:
|
||
try:
|
||
outcome = await send(channel, message)
|
||
except Exception: # noqa: BLE001 - 拼消息、签名出错也不能让别的渠道跟着失败
|
||
logger.exception("通知渠道「%s」推送出错", channel.name)
|
||
outcome = Outcome(False, None, 0, "推送时出错,详细信息见服务日志")
|
||
async with SessionLocal() as session:
|
||
session.add(NotifyDelivery(channel_id=channel.id, subject_kind=subject_kind, subject_id=subject_id,
|
||
event=event, run_id=run_id, task_id=task_id,
|
||
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 的推送记录没能保存", channel.id)
|
||
return len(channels)
|
||
|
||
|
||
def _label(graph: dict[str, Any] | None, node_id: str | None) -> str:
|
||
for n in (graph or {}).get("nodes") or []:
|
||
if isinstance(n, dict) and n.get("id") == node_id:
|
||
return str(((n.get("data") or {}).get("label")) or node_id)[:80]
|
||
return str(node_id or "")
|
||
|
||
|
||
async def _where(session: Any, run: Run | None) -> str:
|
||
"""消息里「在哪」:应用名,没有应用的写工作流名。"""
|
||
if run is None:
|
||
return ""
|
||
if run.app_id:
|
||
name = await session.scalar(select(App.name).where(App.id == run.app_id))
|
||
if name:
|
||
return f"应用「{name}」"
|
||
return f"工作流「{run.workflow_name}」" if run.workflow_name else ""
|
||
|
||
|
||
async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
|
||
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。"""
|
||
async with SessionLocal() as session:
|
||
task = await session.get(Task, task_id)
|
||
if task is None:
|
||
return 0
|
||
run = await session.get(Run, task.run_id)
|
||
channels = await _channels(session, event, run, users)
|
||
if not channels:
|
||
return 0
|
||
base = await _base_url(session)
|
||
where = await _where(session, run)
|
||
pending = list((await session.execute(select(TaskAssignment).where(
|
||
TaskAssignment.task_id == task_id, TaskAssignment.status == "pending"))).scalars())
|
||
# 不挂在节点上的待办(个人应用的审核)用待办的标题:它按应用名起,不是按运行数据渲染的
|
||
node = _label(run.graph if run else None, task.node_id) if task.node_id else task.title[:80]
|
||
names = "、".join(dict.fromkeys(a.principal or "任何人" for a in pending)) or "任何人"
|
||
lines = [("状态", "待处理"), ("种类", _KIND_TEXT.get(task.kind, task.kind)), ("派给", names[:200])]
|
||
if where:
|
||
lines.insert(0, ("所在", where))
|
||
if task.due_at is not None:
|
||
lines.append(("时限", task.due_at.astimezone().strftime("%Y-%m-%d %H:%M")))
|
||
lines.append(("运行", f"#{task.run_id[:6]}"))
|
||
message = {"event": event, "status": "pending", "title": f"{_TASK_HEAD.get(event, '待办')}:{node}",
|
||
"lines": lines, "links": [("去处理", f"{base}/apps/inbox/{task_id}")] if base else [],
|
||
"subject": {"task_id": task_id, "run_id": task.run_id, "app_id": task.app_id}}
|
||
return await _push(channels, message, subject_kind="task", subject_id=task_id, event=event, run_id=task.run_id,
|
||
task_id=task_id)
|
||
|
||
|
||
async def notify_run(run_id: str, event: str) -> int:
|
||
"""应用实例结束、失败(run.finished / run.failed)。只推应用发起的运行。"""
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None or not run.app_id:
|
||
return 0
|
||
channels = await _channels(session, event, run, [])
|
||
if not channels:
|
||
return 0
|
||
base = await _base_url(session)
|
||
where = await _where(session, run)
|
||
finished = event == "run.finished"
|
||
lines = [("状态", "已完成" if finished else "失败"), ("实例", f"#{run_id[:6]}")]
|
||
if not finished and run.error_node_id:
|
||
lines.append(("失败节点", f"「{_label(run.graph, run.error_node_id)}」"))
|
||
message = {"event": event, "status": run.status, "ok": finished,
|
||
"title": f"{where or '应用'}的实例{'已完成' if finished else '失败'}", "lines": lines,
|
||
"links": [("查看实例", f"{base}/apps/{run.app_id}/i/{run_id}")] if base else [],
|
||
"subject": {"run_id": run_id, "app_id": run.app_id}}
|
||
return await _push(channels, message, subject_kind="run", subject_id=run_id, event=event, run_id=run_id,
|
||
task_id=None)
|
||
|
||
|
||
async def notify_review(task_id: str, decision: str, *, owner: str, name: str, app_id: str | None) -> int:
|
||
"""个人应用提交审核有了结果(review.done,N7):通知提交人——他自己订阅了这件事的个人渠道,加上订阅了它的全局渠道。
|
||
消息里只有应用名和结论,不放应用里的数(P4)。app_id 是链接过去的应用:发布了的是正式应用,退回的是他的个人应用。"""
|
||
event = "review.done"
|
||
async with SessionLocal() as session:
|
||
channels = await _channels(session, event, None, [owner])
|
||
if not channels:
|
||
return 0
|
||
base = await _base_url(session)
|
||
published = decision == "published"
|
||
message = {"event": event, "status": decision, "ok": published,
|
||
"title": f"提交审核的「{name[:80]}」{'已发布成正式应用' if published else '被退回'}",
|
||
"lines": [("结论", "已发布" if published else "已退回"), ("待办", f"#{task_id[:6]}")],
|
||
"links": [("去看看" if published else "查看我的应用", f"{base}/apps/{app_id}")] if base and app_id else [],
|
||
"subject": {"task_id": task_id, "app_id": app_id}}
|
||
return await _push(channels, message, subject_kind="task", subject_id=task_id, event=event, run_id=None,
|
||
task_id=task_id)
|
||
|
||
|
||
async def notify_action(action_id: str, event: str) -> int:
|
||
"""写入外部系统的结果未知(action.unknown)。有核实待办的链接到待办,没有的(系统按幂等键去查)链接到实例。"""
|
||
from app.db.models import ActionRecord
|
||
|
||
async with SessionLocal() as session:
|
||
rec = await session.get(ActionRecord, action_id)
|
||
if rec is None:
|
||
return 0
|
||
run = await session.get(Run, rec.run_id)
|
||
task_id = await session.scalar(select(Task.id).where(Task.correlation_key == f"action:{action_id}",
|
||
Task.kind == "reconcile"))
|
||
users = list((await session.execute(select(TaskAssignment.principal).where(
|
||
TaskAssignment.task_id == task_id, TaskAssignment.principal_kind == "user")))
|
||
.scalars()) if task_id else []
|
||
channels = await _channels(session, event, run, users)
|
||
if not channels:
|
||
return 0
|
||
base = await _base_url(session)
|
||
where = await _where(session, run)
|
||
lines = [("状态", "等待核实" if task_id else "系统正在按幂等键查询"), ("环境", _ENV_TEXT.get(rec.environment, rec.environment)),
|
||
("动作", f"#{action_id[:6]}"), ("运行", f"#{rec.run_id[:6]}")]
|
||
if where:
|
||
lines.insert(0, ("所在", where))
|
||
if task_id and base:
|
||
links = [("去核实", f"{base}/apps/inbox/{task_id}")]
|
||
elif base and run is not None and run.app_id:
|
||
links = [("查看实例", f"{base}/apps/{run.app_id}/i/{rec.run_id}")]
|
||
else:
|
||
links = [("查看运行", f"{base}/runs/{rec.run_id}")] if base else []
|
||
message = {"event": event, "status": "unknown", "title": f"写入外部系统的结果未知:{rec.capability}",
|
||
"lines": lines, "links": links,
|
||
"subject": {"action_id": action_id, "run_id": rec.run_id, "task_id": task_id}}
|
||
return await _push(channels, message, subject_kind="action", subject_id=action_id, event=event,
|
||
run_id=rec.run_id, task_id=task_id)
|