mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11: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 条失败。
289 lines
18 KiB
Python
289 lines
18 KiB
Python
"""流程应用的实例页和待办怎么说(apps/resolve.py 的流程组件、process/tasks.py 的待办描述)。
|
|
|
|
端到端验收(报销审批:财务会签、超时升级给总监)发现的几处「说得不对」:
|
|
- 发起人打开实例页,待办表单去取一条不是派给他的待办,红框写「待办不存在」:服务端解析时就判定读者是不是处理人,
|
|
不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取;
|
|
- 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项;
|
|
- 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」;
|
|
- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点;走到的是从人工节点「驳回」出口一路走到的
|
|
成果节点(否定的结局)时不画成成功的绿勾,时间线上「已驳回」那一步同样;
|
|
- 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列;
|
|
- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间;
|
|
- 停着等审批时印章那一块写「正在运行,完成后刷新页面」,和页头的「等待审批」「自动刷新」对不上:按在等的那一步说;
|
|
- 「会签进度 0/2」画了两遍(待办表单一遍、会签组件一遍):同一页上有绑同一条待办的会签组件时,服务端告诉待办表单不画;
|
|
- 等待名单、会签名单写的是用户名:服务端给显示名(people),界面写显示名、悬停给用户名。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.api.deps import Principal
|
|
from app.db.base import SessionLocal, utcnow
|
|
from app.db.models import App, AppVersion, Group, GroupMember, Run, Task, User
|
|
from app.engine.runner import run_manager
|
|
from app.process import tasks
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
def node(nid, ntype, label=None, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
|
|
|
|
|
def expense() -> dict:
|
|
nodes = [node("start", "input", "发起报销", fields=[{"name": "applicant"}, {"name": "amount", "type": "number"}]),
|
|
node("review", "human", "财务会签", mode="approve", title="报销审批:{{ input.applicant }}",
|
|
assignee={"kind": "slot", "slot": "finance"}, policy="all", due="PT1H",
|
|
on_timeout={"action": "escalate", "to_slot": "director"}),
|
|
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}
|
|
|
|
|
|
SPEC = {"format": "agentlab.app/1", "name": "报销", "views": {"inst": {"source": {"kind": "instance"}}},
|
|
"pages": [{"id": "instance", "title": "实例", "body": [
|
|
{"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"},
|
|
{"id": "form", "type": "task_form", "bind": {"task": "review"}},
|
|
{"id": "signs", "type": "signoffs", "bind": {"task": "review"}},
|
|
{"id": "mine", "type": "task_list", "props": {"scope": "mine"}},
|
|
{"id": "st", "type": "stamp", "bind": {"view": "inst"}}]}]}
|
|
|
|
|
|
def user(name: str) -> Principal:
|
|
return Principal(username=name, role="user", mode="local", verified=True)
|
|
|
|
|
|
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
|
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run_id)
|
|
if row and row.status in statuses and not run_manager.is_active(run_id):
|
|
return row
|
|
await asyncio.sleep(0.05)
|
|
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
|
|
|
|
|
|
async def _setup() -> tuple[str, str, str, str]:
|
|
"""一个应用:财务岗位绑一个组(两个人),总监岗位绑一个人。返回 (应用 id, 财务甲, 财务乙, 总监)。"""
|
|
u = uuid.uuid4().hex[:6]
|
|
a, b, boss, group = f"zhang{u}", f"wang{u}", f"zhao{u}", f"finance{u}"
|
|
async with SessionLocal() as session:
|
|
people = [User(username=n, role="user", display_name=f"财务{n[:1]}") for n in (a, b)]
|
|
session.add_all(people)
|
|
g = Group(name=group)
|
|
session.add(g)
|
|
await session.flush()
|
|
session.add_all([GroupMember(group_id=g.id, user_id=p.id) for p in people])
|
|
app = App(name=f"报销-{u}", published_version=1)
|
|
session.add(app)
|
|
await session.flush()
|
|
session.add(AppVersion(app_id=app.id, version=1, spec=SPEC, spec_hash="x" * 64, bindings={"slots": {
|
|
"finance": [{"kind": "group", "name": group}], "director": [{"kind": "user", "name": boss}]}}))
|
|
await session.commit()
|
|
return app.id, a, b, boss
|
|
|
|
|
|
async def _start(app_id: str, title: str, amount: float = 1280.5) -> str:
|
|
run = await run_manager.start(graph=expense(), input_payload={"applicant": "钱七", "amount": amount},
|
|
app_id=app_id, app_version=1, started_by="qianqi", run_class="formal",
|
|
origin_kind="app_entry", origin={"entry": "start", "title": title})
|
|
await _wait(run.id, ("interrupted",))
|
|
return run.id
|
|
|
|
|
|
async def _task(run_id: str) -> Task:
|
|
async with SessionLocal() as session:
|
|
return (await session.execute(select(Task).where(Task.run_id == run_id))).scalars().one()
|
|
|
|
|
|
async def _page(app_id: str, run_id: str, reader: Principal) -> dict:
|
|
from app.apps.resolve import resolve_page
|
|
|
|
async with SessionLocal() as session:
|
|
app = await session.get(App, app_id)
|
|
version = (await session.execute(select(AppVersion).where(AppVersion.app_id == app_id))).scalars().one()
|
|
run = await session.get(Run, run_id)
|
|
vm = await resolve_page(session, app, page_id="instance", principal=reader, role="use", instance=run,
|
|
version=version)
|
|
return {n["id"]: n.get("data") for n in vm["tree"]}
|
|
|
|
|
|
async def test_the_initiator_sees_the_step_not_a_missing_task():
|
|
app_id, a, b, _ = await _setup()
|
|
run_id = await _start(app_id, "报销审批·钱七")
|
|
task = await _task(run_id)
|
|
|
|
view = await _page(app_id, run_id, user("qianqi"))
|
|
form = view["form"]
|
|
# 发起人不是处理人:不让界面去取待办(取了是 404),给这一步的状态
|
|
assert form["handler"] is False
|
|
assert sorted((x["principal"], x["status"]) for x in form["assignments"]) == \
|
|
sorted([(a, "pending"), (b, "pending")])
|
|
assert form["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 0}
|
|
assert form["task"]["due_at"] and form["task"]["instance_title"] == "报销审批·钱七"
|
|
assert tasks.visible(await tasks.detail(task.id, user("qianqi")), user("qianqi")) is False
|
|
# 处理人:给待办卡(界面按 id 取完整的待办)
|
|
assert (await _page(app_id, run_id, user(a)))["form"]["handler"] is True
|
|
# 关登录时人人都算处理人(同待办服务)
|
|
assert (await _page(app_id, run_id, Principal(username=None, role="admin", mode="off")))["form"]["handler"] is True
|
|
hdr = view["hdr"]
|
|
assert (hdr["status_code"], hdr["status_text"]) == ("waiting", "等待审批")
|
|
assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "等待审批"
|
|
# 停着等审批:印章那一块按在等的那一步说,不写「正在运行,完成后刷新页面」(页头写着等待审批、自动刷新)
|
|
assert view["st"]["state"] == "running" and view["st"]["message"] == "等待审批,流程走完后自动显示"
|
|
# 同一页上有绑同一条待办的会签组件:待办表单不再画会签进度,会签组件照画
|
|
assert form["signoffs_on_page"] is True and "signoffs_on_page" not in view["signs"]
|
|
# 等待名单写显示名:服务端给用户名 → 显示名,界面悬停给用户名
|
|
assert form["people"] == {a: "财务z", b: "财务w"} and view["signs"]["people"] == form["people"]
|
|
detail = await tasks.detail(task.id, user(a))
|
|
assert detail["people"] == {a: "财务z", b: "财务w"}, "待办卡(待办服务)同一个口径"
|
|
|
|
|
|
async def test_signoffs_say_who_verified_and_when_and_the_voter_sees_their_own_answer():
|
|
app_id, a, b, _ = await _setup()
|
|
run_id = await _start(app_id, "报销审批·钱七")
|
|
task = await _task(run_id)
|
|
assert (await tasks.respond(task.id, user(a), decision="approve"))["resolved"] is False
|
|
|
|
signs = (await _page(app_id, run_id, user("qianqi")))["signs"]
|
|
done = next(x for x in signs["assignments"] if x["principal"] == a)
|
|
# 登录核实过的写「已登录核实」,不是「自报署名」;带处理时间
|
|
assert done["status"] == "responded" and done["verified"] is True and done["responded_at"]
|
|
assert done["decision"] == "approve" and done["source"] == "slot:finance"
|
|
assert signs["progress"]["agreed"] == 1 and signs["progress"]["need"] == 2
|
|
|
|
# 同意过的人再打开:不能再回复,带上他的回复(写「你已处理:同意」,不写「没有派给你」)
|
|
mine = await tasks.detail(task.id, user(a))
|
|
assert mine["can_respond"] is False and mine["my_response"]["decision"] == "approve"
|
|
assert mine["my_response"]["at"] and mine["my_response"]["verified"] is True
|
|
assert (await tasks.detail(task.id, user(b)))["my_response"] is None
|
|
assert (await _page(app_id, run_id, user(a)))["form"]["my_response"]["decision"] == "approve"
|
|
|
|
|
|
async def test_the_instance_says_which_outcome_it_reached():
|
|
app_id, a, b, _ = await _setup()
|
|
passed = await _start(app_id, "报销审批·钱七")
|
|
task = await _task(passed)
|
|
await tasks.respond(task.id, user(a), decision="approve")
|
|
await tasks.respond(task.id, user(b), decision="approve")
|
|
await _wait(passed, ("succeeded",))
|
|
view = await _page(app_id, passed, user("qianqi"))
|
|
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已通过")
|
|
review = next(s for s in view["tl"]["steps"] if s["node"] == "review")
|
|
assert (review["state"], review["text"]) == ("done", "已同意")
|
|
|
|
back = await _start(app_id, "报销审批·钱七")
|
|
await tasks.respond((await _task(back)).id, user(b), decision="reject", note="票据不全")
|
|
await _wait(back, ("succeeded",))
|
|
view = await _page(app_id, back, user("qianqi"))
|
|
# 走了退回分支:运行是「已完成」,流程的结论是「已退回」。这是否定的结局(从「驳回」出口走到的成果节点),状态
|
|
# 标记同「未确认(被驳回)」,不画成成功;时间线上驳回的那一步同样
|
|
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "已退回")
|
|
assert view["tl"]["status_code"] == "cancelled"
|
|
review = next(s for s in view["tl"]["steps"] if s["node"] == "review")
|
|
assert (review["state"], review["text"]) == ("rejected", "已驳回")
|
|
# 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 1
|
|
assert view["signs"]["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 1}
|
|
from app.apps.resolve import list_instances
|
|
|
|
async with SessionLocal() as session:
|
|
rows = {r["run_id"]: r for r in await list_instances(session, app_id, mine="qianqi")}
|
|
assert (rows[back]["status_text"], rows[passed]["status_text"]) == ("已退回", "已通过")
|
|
assert (rows[back]["status_code"], rows[passed]["status_code"]) == ("cancelled", "succeeded")
|
|
|
|
|
|
def test_a_negative_outcome_is_read_from_the_graph_and_this_runs_decision():
|
|
"""否定的结局按图认(人工节点「驳回」出口一路走到的成果节点,中间不经过别的人工节点),再看这次运行里那个人工节点
|
|
是不是真的驳回了:两条路汇到同一个成果节点时,批准走过来的不算否定。不按成果节点的标题猜。"""
|
|
from types import SimpleNamespace
|
|
|
|
from app.apps.resolve import _Trail, instance_state, rejected_outputs
|
|
|
|
graph = {"nodes": [node("start", "input", "发起"), node("review", "human", "财务会签", mode="approve"),
|
|
node("pay", "tool", "付款"), node("note", "tool", "记一笔"),
|
|
node("done", "output", "办结"), node("back", "output", "打回重填"),
|
|
node("again", "human", "复核", mode="approve"), node("ok", "output", "复核通过")],
|
|
"edges": [{"source": "start", "target": "review"},
|
|
{"source": "review", "target": "pay", "sourceHandle": "approved"},
|
|
{"source": "review", "target": "note", "sourceHandle": "rejected"},
|
|
{"source": "pay", "target": "done"}, {"source": "note", "target": "done"},
|
|
{"source": "note", "target": "back"},
|
|
{"source": "review", "target": "again", "sourceHandle": "rejected"},
|
|
{"source": "again", "target": "ok", "sourceHandle": "approved"}]}
|
|
# 「复核通过」在另一个人工节点之后:结局由复核的人说了算,不算 review 驳回的结局
|
|
assert rejected_outputs(graph) == {"done": {"review"}, "back": {"review"}}
|
|
|
|
def state(decision, reached):
|
|
task = SimpleNamespace(node_id="review", kind="approve", status="resolved", outcome={"decision": decision})
|
|
trail = _Trail(tasks=[task], events=[("node.finished", reached)])
|
|
return instance_state(SimpleNamespace(status="succeeded", graph=graph), trail)
|
|
|
|
assert state("reject", "back") == {"code": "cancelled", "text": "打回重填"}
|
|
assert state("expired_reject", "done") == {"code": "cancelled", "text": "办结"}
|
|
assert state("approve", "done") == {"code": "succeeded", "text": "办结"}, "批准走过来的同一个成果节点不算否定"
|
|
|
|
|
|
async def test_outputs_reached_in_parallel_are_not_one_outcome():
|
|
"""几个成果节点并行都走到了(「周报」「明细」一起出):那不是哪一种结局,写「已完成」,不写最后走到的那个节点的名字。"""
|
|
from app.apps.resolve import instance_rows
|
|
|
|
graph = {"nodes": [node("start", "input", "发起", fields=[{"name": "week"}]),
|
|
node("weekly", "output", "周报", fields=[{"name": "周", "value": "{{ input.week }}"}]),
|
|
node("detail", "output", "明细", fields=[{"name": "周", "value": "{{ input.week }}"}])],
|
|
"edges": [{"source": "start", "target": "weekly"}, {"source": "start", "target": "detail"}]}
|
|
run = await run_manager.start(graph=graph, input_payload={"week": "2026-W41"})
|
|
done = await _wait(run.id, ("succeeded", "failed"))
|
|
assert done.status == "succeeded", done.error
|
|
async with SessionLocal() as session:
|
|
[row] = await instance_rows(session, [await session.get(Run, run.id)])
|
|
assert (row["status_code"], row["status_text"]) == ("succeeded", "已完成")
|
|
|
|
|
|
async def test_after_escalation_the_rule_is_one_director_not_zero_of_three():
|
|
app_id, a, b, boss = await _setup()
|
|
run_id = await _start(app_id, "报销审批·钱七")
|
|
task = await _task(run_id)
|
|
await tasks.respond(task.id, user(a), decision="approve")
|
|
assert task.id in await tasks.tick(utcnow() + timedelta(hours=2))
|
|
detail = await tasks.detail(task.id, user(boss))
|
|
p = detail["progress"]
|
|
# 升级加派的那一份不算进会签人数:还是 1/2,另写升级给了谁、他一人说了算
|
|
assert (p["voters"], p["need"], p["agreed"]) == (2, 2, 1)
|
|
assert p["escalation"] == {"slot": "director", "assignees": [{"kind": "user", "name": boss}], "decided": None,
|
|
"by": None}
|
|
assert detail["can_respond"] is True
|
|
assert (await tasks.respond(task.id, user(boss), decision="approve"))["resolved"] is True
|
|
await _wait(run_id, ("succeeded",))
|
|
p = (await tasks.detail(task.id, user(boss)))["progress"]
|
|
assert (p["voters"], p["agreed"]) == (2, 1) and (p["escalation"]["decided"], p["escalation"]["by"]) == \
|
|
("approve", boss)
|
|
signs = (await _page(app_id, run_id, user("qianqi")))["signs"]
|
|
assert signs["progress"]["escalation"]["by"] == boss
|
|
|
|
|
|
async def test_tasks_with_the_same_title_carry_the_instance_and_when():
|
|
app_id, a, _, _ = await _setup()
|
|
first = await _start(app_id, "报销审批·钱七·第一笔")
|
|
second = await _start(app_id, "报销审批·钱七·第二笔", amount=300)
|
|
listed = [t for t in await tasks.list_tasks(user(a), app_id=app_id)]
|
|
assert [t["title"] for t in listed] == ["报销审批:钱七", "报销审批:钱七"]
|
|
assert {t["run_id"]: t["instance_title"] for t in listed} == {first: "报销审批·钱七·第一笔",
|
|
second: "报销审批·钱七·第二笔"}
|
|
rows = (await _page(app_id, first, user(a)))["mine"]["items"]
|
|
assert {r["instance_title"] for r in rows} == {"报销审批·钱七·第一笔", "报销审批·钱七·第二笔"}
|
|
assert all(r["created_at"] for r in rows)
|