mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 22:01:58 +00:00
845809486a
为什么:动作确认、工具 / 代码审批、审批驳回即终止、填写 / 编辑被驳回、双盲裁决的驳回以前抛普通 NodeError,运行记成 failed。 通知推「实例失败」,定时推「运行失败」,发布单把驳回过的试运行说成「试运行失败了」并挡住发布,记录页还给「继续运行」 (接着跑只会读到同一个驳回答复、再停一次)。人的决定被说成故障,每个消费方各写一份映射,已经漏了四处(05-已知遗留 (a)、总方案 X9)。 做了什么: - engine/context 新增 NodeRejected(NodeError),带 node_id、kind、decision(reject / expired_reject)、actor、note;六处驳回改抛它, 原话一字不变。待办超时驳回的答复带 expired,Decision.expired 据此记成 expired_reject。 - runner._drive 在通用 except 之前单独接住:status=cancelled、end_reason=rejected、error 写原话、error_node 写那一步, run.cancelled 带 reason/node_id/label/kind/decision/actor/note;关审批、回收沙箱、关流、封存走取消的那一套。手动停止 (执行中取消、放弃断点上的运行)写 end_reason=stopped,run.cancelled 带 reason=stopped。 - 继续运行:被驳回的 409 code=run_rejected「被驳回的运行不能接着跑:改好内容后重新发起」;存量 failed+驳回(失败那一步上 最近一条处理完的待办是驳回,和实例页同一份判断 resolve.legacy_rejected)也拒。RunOut 带 end_reason。 - apps/resolve.instance_state:cancelled+rejected 和存量 failed+驳回说同一句(「「付款」未确认(被驳回)」「「x」被驳回」)。 - notify_run:状态那一句取自 instance_state,被驳回的走「实例结束」、ok=False,走到「已退回」成果的不再写「已完成」; 手动停止照旧不推。顺带修 04-对外集成与触达 B1:发起人是认证身份(run.origin.actor_verified)时 users 传 [started_by], 个人渠道订阅了实例完成 / 失败终于收得到;自报署名不算。 - 定时 webhook:cancelled+rejected 按「运行结束」推「未出具(被驳回)」、写驳回的那一步,不写备注和原话、不画成功色; 手动停止仍不推。 - 发布单:试运行被驳回不算 plan.trial_failed,记 warning plan.trial_rejected「最近一次试运行在「x」被驳回、没有走完」(带白话)。 - (h) 重启时排队中的运行改成 failed 时补发 run.failed(同一句原因),时间线里有了失败原因这一行;不写结束时间、不封存,同以前。 - (f) tasks.tick / actions.tick 交回这一拍处理过的 id 列表(调度循环只记条数),测试改成只断言自己造的待办;先加了预置 一条无关到期待办的 fixture,旧的按条数断言当场失败(3 == 1),改完通过。 已封存的老运行不改写。测试:新增 test_run_rejected(五种关卡参数化、超时驳回、继续运行拒绝含存量、手动停止、补发 run.failed、 instance_state 新老形状同一句)、test_notify_run(驳回走实例结束、手动停止不推、已退回不写已完成、个人渠道 B1 两条); webhook、发布单、动作确认、双盲、审批的老断言跟上。去掉 runner 里接 NodeRejected 的分支回跑,新测试 8 条失败。
200 lines
9.1 KiB
Python
200 lines
9.1 KiB
Python
"""应用实例结束的通知(notify/service.notify_run):说法取自实例页,个人渠道按「我发起的实例」推。
|
|
|
|
- 被驳回的实例(cancelled + end_reason=rejected)走「实例结束」,状态写实例页那一句(「「review」被驳回」),不推
|
|
「实例失败」(05-已知遗留 (a)、总方案 X6);
|
|
- 走到「已退回」那种结局的运行,实例页写成果节点的标题、记成人的决定,推送以前却写「已完成」:现在两边一致;
|
|
- 个人渠道勾了「实例完成 / 失败」以前永远收不到(_channels 拿到的 users 是空的):发起人是认证过的身份时,他自己的
|
|
个人渠道收得到;自报的署名不算(04-对外集成与触达 B1)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.core.crypto import encrypt
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, User
|
|
from app.engine.runner import run_manager
|
|
from app.notify import core, service
|
|
|
|
URL = "https://hooks.example.com/agentlab/run-3b8e1f"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await service.drain()
|
|
await run_manager.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
def received(monkeypatch):
|
|
got: list[httpx.Request] = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
got.append(request)
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
|
|
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
|
|
# 个人渠道发送前要解析域名、核对不是内网:测试里不连外网
|
|
async def public(url: str) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(service, "personal_url_problem", public)
|
|
return got
|
|
|
|
|
|
def node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
|
|
|
|
|
def flow(**human):
|
|
nodes = [node("start", "input", fields=[{"name": "q"}]),
|
|
node("review", "human", mode="approve", title="审一下", **human),
|
|
node("ok", "output", fields=[{"name": "结论", "value": "通过"}]),
|
|
node("no", "output", fields=[{"name": "结论", "value": "退回"}])]
|
|
edges = [{"source": "start", "target": "review"},
|
|
{"source": "review", "target": "ok", "sourceHandle": "approved"},
|
|
{"source": "review", "target": "no", "sourceHandle": "rejected"}]
|
|
return {"nodes": nodes, "edges": edges}
|
|
|
|
|
|
async def _app(events: list[str]) -> tuple[str, str]:
|
|
async with SessionLocal() as session:
|
|
channel = NotifyChannel(name=f"群-{uuid.uuid4().hex[:6]}", format="generic", url=encrypt(URL), events=[])
|
|
session.add(channel)
|
|
app = App(name=f"报销-{uuid.uuid4().hex[:6]}", published_version=1)
|
|
session.add(app)
|
|
await session.flush()
|
|
session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={},
|
|
spec={"notify": [{"channel": channel.id, "events": events}]}))
|
|
await session.commit()
|
|
return app.id, channel.id
|
|
|
|
|
|
async def _wait(run_id: str, statuses: tuple[str, ...]) -> Run:
|
|
for _ in range(400):
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run_id)
|
|
if row is not None and row.status in statuses:
|
|
return row
|
|
await asyncio.sleep(0.05)
|
|
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
|
|
|
|
|
|
async def _decide(run_id: str, approved: bool) -> Run:
|
|
"""经待办服务回复(同界面上点同意 / 驳回):实例页认结局靠待办的结论。"""
|
|
from app.api.deps import Principal
|
|
from app.db.models import Task
|
|
from app.process import tasks
|
|
|
|
await _wait(run_id, ("interrupted",))
|
|
await run_manager.wait_idle(run_id)
|
|
for _ in range(100):
|
|
async with SessionLocal() as session:
|
|
task = (await session.execute(select(Task).where(Task.run_id == run_id, Task.status == "pending")))\
|
|
.scalars().first()
|
|
if task is not None:
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
await tasks.respond(task.id, Principal(username=None, role="admin", mode="off"),
|
|
decision="approve" if approved else "reject", note="金额 45678 不对")
|
|
final = await _wait(run_id, ("succeeded", "failed", "cancelled"))
|
|
await run_manager.wait_idle(run_id)
|
|
await service.drain()
|
|
return final
|
|
|
|
|
|
async def _deliveries(channel_id: str) -> list[tuple[str, str]]:
|
|
async with SessionLocal() as session:
|
|
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.channel_id == channel_id))).scalars()
|
|
return [(r.event, r.status) for r in rows]
|
|
|
|
|
|
async def test_a_rejected_instance_is_pushed_as_finished_in_the_instance_page_words(received):
|
|
app_id, channel_id = await _app(["run.finished", "run.failed"])
|
|
run = await run_manager.start(graph=flow(stop_on_reject=True), input_payload={"q": "x"}, app_id=app_id,
|
|
app_version=1)
|
|
final = await _decide(run.id, approved=False)
|
|
assert (final.status, final.end_reason) == ("cancelled", "rejected")
|
|
assert await _deliveries(channel_id) == [("run.finished", "ok")], "驳回不进「实例失败」"
|
|
[request] = received
|
|
body = json.loads(request.content)
|
|
assert body["event"] == "run.finished" and body["title"].endswith("的实例已结束")
|
|
assert "状态:「review」被驳回" in body["text"] and "失败" not in body["text"]
|
|
assert "45678" not in request.content.decode(), "驳回的备注不进消息(可能带着数)"
|
|
|
|
|
|
async def test_a_manual_stop_is_not_pushed(received):
|
|
app_id, channel_id = await _app(["run.finished", "run.failed"])
|
|
run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1)
|
|
await _wait(run.id, ("interrupted",))
|
|
await run_manager.wait_idle(run.id)
|
|
assert await run_manager.cancel(run.id) == "cancelled"
|
|
await service.drain()
|
|
assert await _deliveries(channel_id) == [] and received == []
|
|
|
|
|
|
async def test_reaching_the_returned_outcome_no_longer_says_completed(received):
|
|
app_id, channel_id = await _app(["run.finished"])
|
|
run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1)
|
|
final = await _decide(run.id, approved=False)
|
|
assert final.status == "succeeded" and final.output.get("结论") == "退回"
|
|
[request] = received
|
|
body = json.loads(request.content)
|
|
# 实例页写走到的成果节点(no),记成人的决定:推送也这么写,不再说「已完成」
|
|
assert "状态:no" in body["text"] and "已完成" not in body["title"] and body["title"].endswith("的实例已结束")
|
|
|
|
|
|
async def _personal(username: str, events: list[str]) -> str:
|
|
async with SessionLocal() as session:
|
|
user = (await session.execute(select(User).where(User.username == username))).scalar_one_or_none()
|
|
if user is None:
|
|
user = User(username=username, role="user")
|
|
session.add(user)
|
|
await session.flush()
|
|
channel = NotifyChannel(name="我的", scope="user", owner_id=user.id, format="generic", url=encrypt(URL),
|
|
events=events)
|
|
session.add(channel)
|
|
await session.commit()
|
|
return channel.id
|
|
|
|
|
|
async def _finished_instance(app_id: str, *, started_by: str, verified: bool) -> str:
|
|
async with SessionLocal() as session:
|
|
run = Run(workflow_name="报销", status="succeeded", graph=flow(), input={}, app_id=app_id, app_version=1,
|
|
started_by=started_by, origin_kind="app_entry",
|
|
origin={"kind": "app_entry", "actor": started_by, "actor_verified": verified})
|
|
session.add(run)
|
|
await session.commit()
|
|
return run.id
|
|
|
|
|
|
async def test_the_initiator_s_personal_channel_receives_my_instance_finished(received):
|
|
"""B1:个人渠道订阅了 run.finished,发起人自己发起的实例结束了收得到;应用自己没订阅也一样。"""
|
|
app_id, _ = await _app([])
|
|
name = f"uma{uuid.uuid4().hex[:6]}"
|
|
mine = await _personal(name, ["run.finished"])
|
|
run_id = await _finished_instance(app_id, started_by=name, verified=True)
|
|
assert await service.notify_run(run_id, "run.finished") == 1
|
|
assert await _deliveries(mine) == [("run.finished", "ok")]
|
|
body = json.loads(received[0].content)
|
|
assert body["title"].endswith("的实例已完成") and "状态:已完成" in body["text"]
|
|
|
|
|
|
async def test_a_self_declared_initiator_does_not_reach_a_personal_channel(received):
|
|
"""关登录时署名是自己填的,谁都能填别人的名字:不按它推给那个人的个人渠道。"""
|
|
app_id, _ = await _app([])
|
|
name = f"vic{uuid.uuid4().hex[:6]}"
|
|
mine = await _personal(name, ["run.finished"])
|
|
run_id = await _finished_instance(app_id, started_by=name, verified=False)
|
|
assert await service.notify_run(run_id, "run.finished") == 0
|
|
assert await _deliveries(mine) == [] and received == []
|