mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:31:59 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
240 lines
13 KiB
Python
240 lines
13 KiB
Python
"""流程应用的实例页和待办怎么说(apps/resolve.py 的流程组件、process/tasks.py 的待办描述)。
|
|
|
|
端到端验收(报销审批:财务会签、超时升级给总监)发现的几处「说得不对」:
|
|
- 发起人打开实例页,待办表单去取一条不是派给他的待办,红框写「待办不存在」:服务端解析时就判定读者是不是处理人,
|
|
不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取;
|
|
- 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项;
|
|
- 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」;
|
|
- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点;
|
|
- 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列;
|
|
- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间。
|
|
"""
|
|
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": "报销", "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"}}]}]}
|
|
|
|
|
|
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") 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"] == "等待审批"
|
|
|
|
|
|
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"]) == ("succeeded", "已退回")
|
|
assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "已驳回"
|
|
# 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 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"]) == ("已退回", "已通过")
|
|
|
|
|
|
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 await tasks.tick(utcnow() + timedelta(hours=2)) >= 1
|
|
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)
|