mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:31:59 +00:00
d57cdcaffc
以前这六类关卡被驳回一律抛 NodeRejected,运行收成 cancelled + end_reason=rejected,没有「驳回之后走另一条路」 (docs/design/orchestration-round/00-总方案.md §3)。现在画布上多一个可选的「驳回」出口(sourceHandle="rejected",和 分支、审批同一套连线机制): - engine/schema:GATE_TYPES(人工、调用工具、沙箱代码、双盲复算)、reject_targets(驳回出口连到哪几步)、approve_mode; 画布校验:别的节点接驳回出口报错(graph.reject_exit_invalid,条件分支的 case 标识可以叫 rejected,不算);审批同时设了 「驳回即终止运行」和驳回边的给一条提示(human.stop_on_reject_exit) - engine/compiler:接了驳回出口的关卡按出口路由(驳回出口 / 其余是普通出口,连线没写 sourceHandle 的照样是普通出口); NodeRejected 被接住时这一步正常结束:产出 NodeRejected.exit_payload()(rejected、decision、actor、note、kind、text, 审批另带 approved=false、「结果存为变量」照旧写 false),node.finished 带 outcome=rejected、哪种关卡、结论、谁、备注、 转到哪几步(to),trail 记 outcome=rejected;不重试、不被「出错时继续」接住,照旧 - engine/context:NodeRejected 加 stop(审批明说了驳回即终止的,驳回边照旧走不到)和 exit_payload;动作确认抛在发送之前, 走出口时什么都没发 - engine/runner:成功结束的运行途经驳回出口时,run.finished 带 rejected_via(在哪一步、谁、结论、转到哪),照实说「途经 驳回」;没有途经的终态事件一字不变 - engine/governance:发布检查各级都拦「驳回出口那条路走不到成果节点或结束」(reject_exit.no_end);退回上游再问一次的放过 - engine/layout:驳回出口排在普通出口下面 - apps/resolve:实例进度写「被驳回,转到「X」」(动作确认写「未确认(被驳回),转到…」,超时驳回写「超时驳回,转到…」); 实例状态(实例页头、列表、通知同一句)写「「x」被驳回,转到「y」」,走到另一个成果节点的照旧写那个成果节点;退回修改后 又批准了的按批准算;子工作流里的步骤不进实例进度 - 没接出口:行为一字不变(node.failed reason=rejected、运行 cancelled + rejected);审批没设驳回即终止、没接驳回边的老规矩 照旧。接了驳回边的审批,实例进度那一步的说法从「已驳回」改成「被驳回,转到「已退回」」(test_app_instance_page 跟着改) 另:engine/context 的 RunContext 加 prefix / global_id、事件经 stream_writer 发(子工作流把父运行的写入口交下来), 这一步本身不改变最外层的事件,下一个提交接上子工作流 测试:test_reject_exit(五类关卡接了出口各走一遍、批准走普通出口、超时驳回走出口、没接一字不变、驳回即终止优先、 重试和出错时继续不管驳回、退回修改再问一次、校验与发布检查、布局、实例进度);test_process_actions(动作确认走出口 不发送);test_dual_check_node(裁决走出口)
341 lines
20 KiB
Python
341 lines
20 KiB
Python
"""驳回出口(docs/design/orchestration-round/00-总方案.md §3)。
|
||
|
||
审批、补充输入、编辑稿件、工具调用审批、代码执行审批(动作确认、双盲裁决在 test_process_actions.py、test_dual_check_node.py
|
||
里,要假的外部系统、口径卡)在画布上多一个可选的「驳回」出口(sourceHandle="rejected"):
|
||
- 接了:驳回(含超时驳回)时这一步正常结束、产出 {rejected, decision, actor, note, kind, text},运行沿驳回那条边往下走;
|
||
node.finished 带 outcome=rejected(哪种关卡、结论、谁、备注、转到哪几步),运行照它的路径结束,终态事件照实带
|
||
rejected_via;批准了照旧走普通出口;
|
||
- 没接:行为一字不变(cancelled + end_reason=rejected,test_run_rejected.py 照过);审批没设驳回即终止的老规矩照旧;
|
||
- 「失败重试」「出错时继续」照旧不管驳回;
|
||
- 发布检查:驳回出口那条路走不到出口或结束(只能绕回去)的拦下;
|
||
- 实例进度写「被驳回,转到 X」。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import timedelta
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import Run, RunEvent, Task
|
||
from app.engine.runner import run_manager
|
||
|
||
|
||
@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}}
|
||
|
||
|
||
START = node("start", "input", fields=[{"name": "q"}])
|
||
DONE = node("done", "output", "办结", fields=[{"name": "结果", "value": "通过了"}])
|
||
BACK = node("back", "output", "退回申请人", fields=[{"name": "结果", "value": "退回:{{ nodes.gate.note }}"},
|
||
{"name": "谁", "value": "{{ nodes.gate.actor }}"}])
|
||
|
||
#: (关卡, 驳回之后的原话)
|
||
GATES = {
|
||
"tool": (node("gate", "tool", label="算一下", tool="calculator", args={"expression": "1+1"}, approval="always"),
|
||
"工具 calculator 的调用已被驳回:不对"),
|
||
"code": (node("gate", "code", label="跑代码", code="print(1)", language="python", approval="always"),
|
||
"代码执行已被驳回:不对"),
|
||
"approve": (node("gate", "human", label="把关", mode="approve", title="可以吗"), "人工驳回:不对"),
|
||
"edit": (node("gate", "human", label="改稿", mode="edit", title="改一下", draft="初稿"), "人工驳回:不对"),
|
||
"input": (node("gate", "human", label="补充", mode="input", title="补一下"), "人工驳回:不对"),
|
||
}
|
||
|
||
|
||
def gated(gate: dict, *, exit_to: dict | None = BACK, normal_handle: str | None = None) -> dict:
|
||
"""入口 → 关卡 → 办结;关卡的「驳回」出口接到 exit_to。审批模式的普通出口是 approved,别的是 out。"""
|
||
normal = normal_handle or ("approved" if gate["data"]["config"].get("mode") == "approve" else "out")
|
||
nodes = [START, gate, DONE] + ([exit_to] if exit_to else [])
|
||
edges = [{"source": "start", "target": "gate"}, {"source": "gate", "target": "done", "sourceHandle": normal}]
|
||
if exit_to:
|
||
edges.append({"source": "gate", "target": exit_to["id"], "sourceHandle": "rejected"})
|
||
return {"nodes": nodes, "edges": edges}
|
||
|
||
|
||
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 is not None 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 _events(run_id: str, kind: str | None = None) -> list[RunEvent]:
|
||
async with SessionLocal() as session:
|
||
query = select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq)
|
||
if kind:
|
||
query = query.where(RunEvent.type == kind)
|
||
return list((await session.execute(query)).scalars())
|
||
|
||
|
||
async def _answered(graph: dict, reply: dict) -> Run:
|
||
run = await run_manager.start(graph=graph, input_payload={"q": "x"})
|
||
await _wait(run.id, ("interrupted",))
|
||
await run_manager.wait_idle(run.id)
|
||
await run_manager.resume(run.id, reply, actor="张工")
|
||
return await _wait(run.id, ("cancelled", "failed", "succeeded"))
|
||
|
||
|
||
@pytest.mark.parametrize("kind", list(GATES))
|
||
async def test_a_rejection_takes_the_reject_exit_and_the_run_finishes_along_it(kind):
|
||
gate, said = GATES[kind]
|
||
final = await _answered(gated(gate), {"approved": False, "note": "不对"})
|
||
# 接了驳回出口:运行没有被驳回结束,照驳回那条路走完
|
||
assert (final.status, final.end_reason, final.error) == ("succeeded", None, None)
|
||
assert final.output == {"结果": "退回:不对", "谁": "张工"}
|
||
finished = {e.node_id: e for e in await _events(final.id, "node.finished")}
|
||
assert "back" in finished and "done" not in finished
|
||
assert not [e for e in await _events(final.id, "node.failed") if e.node_id == "gate"], "被驳回走出口不是失败"
|
||
data = finished["gate"].data
|
||
assert {k: data.get(k) for k in ("outcome", "kind", "decision", "actor", "note")} == {
|
||
"outcome": "rejected", "kind": kind, "decision": "reject", "actor": "张工", "note": "不对"}
|
||
assert data["to"] == [{"node_id": "back", "label": "退回申请人"}]
|
||
assert data["preview"]["__decision__"] == "rejected" and data["preview"]["rejected"] is True
|
||
# 这一步的产出:哪种关卡、结论、谁、备注、原话
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, final.id)
|
||
# 运行照路径结束,终态事件照实说途经驳回
|
||
[ended] = await _events(final.id, "run.finished")
|
||
assert ended.data["rejected_via"] == [{"node_id": "gate", "label": gate["data"]["label"], "kind": kind,
|
||
"decision": "reject", "actor": "张工", "note": "不对",
|
||
"to": [{"node_id": "back", "label": "退回申请人"}]}]
|
||
assert row.manifest_seq == ended.seq
|
||
from app.core.artifact_store import load
|
||
|
||
payload = load(data["artifact"])
|
||
assert {k: payload.get(k) for k in ("rejected", "decision", "actor", "note", "kind", "text")} == {
|
||
"rejected": True, "decision": "reject", "actor": "张工", "note": "不对", "kind": kind, "text": said}
|
||
|
||
|
||
@pytest.mark.parametrize("kind", list(GATES))
|
||
async def test_approving_takes_the_normal_exit(kind):
|
||
gate, _ = GATES[kind]
|
||
final = await _answered(gated(gate), {"approved": True, "value": "补好了"})
|
||
assert final.status == "succeeded", final.error
|
||
assert final.output == {"结果": "通过了"}
|
||
finished = {e.node_id: e for e in await _events(final.id, "node.finished")}
|
||
assert "done" in finished and "back" not in finished
|
||
assert "outcome" not in finished["gate"].data
|
||
[ended] = await _events(final.id, "run.finished")
|
||
assert "rejected_via" not in ended.data, "没有途经驳回的运行,终态事件和以前一字不差"
|
||
|
||
|
||
async def test_a_plain_out_handle_without_a_name_is_the_normal_exit_too():
|
||
"""普通出口的连线没写 sourceHandle(老图、助手建的图):照样是普通出口。"""
|
||
gate, _ = GATES["input"]
|
||
graph = gated(gate)
|
||
graph["edges"][1].pop("sourceHandle")
|
||
final = await _answered(graph, {"approved": True, "value": "补好了"})
|
||
assert final.status == "succeeded" and final.output == {"结果": "通过了"}
|
||
|
||
|
||
async def test_without_the_exit_nothing_changes():
|
||
"""没接驳回出口:照旧被驳回结束(cancelled + end_reason=rejected、node.failed reason=rejected)。"""
|
||
gate, said = GATES["edit"]
|
||
final = await _answered(gated(gate, exit_to=None), {"approved": False, "note": "不对"})
|
||
assert (final.status, final.end_reason, final.error) == ("cancelled", "rejected", said)
|
||
[failed] = [e for e in await _events(final.id, "node.failed") if e.node_id == "gate"]
|
||
assert failed.data["reason"] == "rejected"
|
||
[cancelled] = await _events(final.id, "run.cancelled")
|
||
assert "rejected_via" not in cancelled.data
|
||
|
||
|
||
async def test_an_approval_without_stop_on_reject_keeps_its_old_routing():
|
||
"""审批没设驳回即终止、也没接驳回边:照旧按 __decision__ 路由,驳回就结束在这里、运行成功,产出和以前一样。"""
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗", assign_to="ok")
|
||
final = await _answered(gated(gate, exit_to=None), {"approved": False, "note": "不对"})
|
||
assert final.status == "succeeded" and final.output in ({}, None)
|
||
finished = {e.node_id: e for e in await _events(final.id, "node.finished")}
|
||
assert finished["gate"].data["preview"] == {"approved": False, "note": "不对", "__decision__": "rejected"}
|
||
assert "outcome" not in finished["gate"].data and "done" not in finished
|
||
|
||
|
||
async def test_stop_on_reject_still_stops_even_with_a_reject_edge():
|
||
"""审批明说了「驳回即终止运行」:照旧收成被驳回,驳回边走不到(以前就是这样),画布校验另给一条提示。"""
|
||
from app.engine.schema import validate_graph
|
||
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗", stop_on_reject=True)
|
||
final = await _answered(gated(gate), {"approved": False, "note": "不对"})
|
||
assert (final.status, final.end_reason, final.error) == ("cancelled", "rejected", "人工驳回:不对")
|
||
assert not [e for e in await _events(final.id, "node.finished") if e.node_id in ("gate", "back")]
|
||
[hint] = [i for i in validate_graph(_spec(gated(gate))).issues if i.code == "human.stop_on_reject_exit"]
|
||
assert hint.level == "warning" and hint.node_id == "gate" and hint.field == "stop_on_reject"
|
||
|
||
|
||
async def test_an_approval_with_a_reject_edge_keeps_approved_false_and_the_variable():
|
||
"""接了驳回边的审批(不管设没设驳回即终止):产出照旧带 approved=false,「结果存为变量」照旧写 false。"""
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗", assign_to="ok")
|
||
back = node("back", "output", "退回申请人", fields=[{"name": "批了", "value": "{{ vars.ok }}"},
|
||
{"name": "approved", "value": "{{ nodes.gate.approved }}"}])
|
||
final = await _answered(gated(gate, exit_to=back), {"approved": False, "note": "不对"})
|
||
assert final.status == "succeeded" and final.output == {"批了": "False", "approved": "False"}
|
||
[gate_done] = [e for e in await _events(final.id, "node.finished") if e.node_id == "gate"]
|
||
assert gate_done.data["outcome"] == "rejected" and gate_done.data["kind"] == "approve"
|
||
|
||
|
||
async def test_a_timeout_rejection_takes_the_exit_too():
|
||
from app.process import tasks
|
||
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗", due="PT30M",
|
||
on_timeout={"action": "reject"})
|
||
run = await run_manager.start(graph=gated(gate), input_payload={"q": "x"})
|
||
await _wait(run.id, ("interrupted",))
|
||
await run_manager.wait_idle(run.id)
|
||
ticked = await tasks.tick(utcnow() + timedelta(hours=1))
|
||
async with SessionLocal() as session:
|
||
mine = (await session.execute(select(Task.id).where(Task.run_id == run.id))).scalar_one()
|
||
assert mine in ticked
|
||
final = await _wait(run.id, ("cancelled", "failed", "succeeded"))
|
||
assert final.status == "succeeded", final.error
|
||
[gate_done] = [e for e in await _events(final.id, "node.finished") if e.node_id == "gate"]
|
||
assert (gate_done.data["outcome"], gate_done.data["decision"], gate_done.data["actor"]) == (
|
||
"rejected", "expired_reject", None)
|
||
assert final.output["结果"].startswith("退回")
|
||
|
||
|
||
async def test_retries_and_continue_on_error_still_leave_rejections_alone():
|
||
"""「失败重试」「出错时继续」照旧不管驳回:接了出口的照出口走,没接的照旧被驳回结束(不重试、不吞掉)。"""
|
||
gate, _ = GATES["tool"]
|
||
patient = {**gate, "data": {**gate["data"], "config": {**gate["data"]["config"], "retries": 2,
|
||
"on_error": "continue"}}}
|
||
final = await _answered(gated(patient, exit_to=None), {"approved": False, "note": "不对"})
|
||
assert (final.status, final.end_reason) == ("cancelled", "rejected")
|
||
assert not [e for e in await _events(final.id, "log") if (e.data or {}).get("code") == "node_retry"]
|
||
final = await _answered(gated(patient), {"approved": False, "note": "不对"})
|
||
assert final.status == "succeeded" and final.output["结果"] == "退回:不对"
|
||
assert len(await _events(final.id, "human.requested")) == 1
|
||
|
||
|
||
async def test_the_reject_path_can_send_the_work_back_and_ask_again():
|
||
"""「退回修改」:驳回出口接回上游,改完再问一次。第二次批准了走普通出口;时间线上同一个关卡两次执行,第一次被驳回。"""
|
||
draft = node("draft", "transform", "起草", mode="template", template="稿子")
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗")
|
||
graph = {"nodes": [START, draft, gate, DONE],
|
||
"edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"},
|
||
{"source": "gate", "target": "done", "sourceHandle": "approved"},
|
||
{"source": "gate", "target": "draft", "sourceHandle": "rejected"}]}
|
||
run = await run_manager.start(graph=graph, input_payload={"q": "x"})
|
||
await _wait(run.id, ("interrupted",))
|
||
await run_manager.wait_idle(run.id)
|
||
await run_manager.resume(run.id, {"approved": False, "note": "重写"}, actor="张工")
|
||
await _wait(run.id, ("interrupted",))
|
||
await run_manager.wait_idle(run.id)
|
||
await run_manager.resume(run.id, {"approved": True}, actor="张工")
|
||
final = await _wait(run.id, ("cancelled", "failed", "succeeded"))
|
||
assert final.status == "succeeded" and final.output == {"结果": "通过了"}
|
||
outcomes = [e.data.get("outcome") for e in await _events(run.id, "node.finished") if e.node_id == "gate"]
|
||
assert outcomes == ["rejected", None]
|
||
[ended] = await _events(run.id, "run.finished")
|
||
assert [v["to"] for v in ended.data["rejected_via"]] == [[{"node_id": "draft", "label": "起草"}]]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 画布校验、发布检查
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _spec(graph: dict):
|
||
from app.engine.schema import GraphSpec
|
||
|
||
return GraphSpec.model_validate(graph)
|
||
|
||
|
||
def test_a_reject_exit_on_a_node_that_cannot_be_rejected_is_an_error():
|
||
from app.engine.schema import validate_graph
|
||
|
||
calc = node("gate", "transform", "整理", mode="template", template="x")
|
||
graph = gated(calc)
|
||
issues = [i for i in validate_graph(_spec(graph)).issues if i.code == "graph.reject_exit_invalid"]
|
||
assert [(i.level, i.node_id) for i in issues] == [("error", "gate")]
|
||
assert "驳回" in issues[0].message
|
||
assert not [i for i in validate_graph(_spec(gated(GATES["tool"][0]))).issues
|
||
if i.code == "graph.reject_exit_invalid"]
|
||
|
||
|
||
@pytest.mark.parametrize("level", ["published", "governed"])
|
||
def test_publishing_refuses_a_reject_path_that_never_reaches_an_end(level):
|
||
from app.engine.governance import lint_for_publish
|
||
|
||
# 驳回之后绕回关卡自己的上游,那里只有一条路又回到关卡:除了再驳回一次,走不到任何出口
|
||
loop_a = node("a", "transform", "甲", mode="template", template="x")
|
||
loop_b = node("b", "transform", "乙", mode="template", template="y")
|
||
gate = node("gate", "human", label="把关", mode="approve", title="可以吗")
|
||
graph = {"nodes": [START, gate, DONE, loop_a, loop_b],
|
||
"edges": [{"source": "start", "target": "gate"},
|
||
{"source": "gate", "target": "done", "sourceHandle": "approved"},
|
||
{"source": "gate", "target": "a", "sourceHandle": "rejected"},
|
||
{"source": "a", "target": "b"}, {"source": "b", "target": "a"}]}
|
||
issues = [i for i in lint_for_publish(_spec(graph), level=level).issues if i.code == "reject_exit.no_end"]
|
||
assert [(i.level, i.node_id) for i in issues] == [("error", "gate")]
|
||
assert "「把关」" in issues[0].message and "「甲」" in issues[0].message
|
||
# 走得到出口、走得到结束(没有下游的节点)、退回上游再问一次(重新问还能批准)的都放过
|
||
for exit_to, extra_edges in ((BACK, []), (node("note", "transform", "记一笔", mode="template", template="z"), []),):
|
||
ok = gated(gate, exit_to=exit_to)
|
||
ok["edges"] += extra_edges
|
||
assert not [i for i in lint_for_publish(_spec(ok), level=level).issues if i.code == "reject_exit.no_end"]
|
||
draft = node("draft", "transform", "起草", mode="template", template="稿子")
|
||
again = {"nodes": [START, draft, gate, DONE],
|
||
"edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"},
|
||
{"source": "gate", "target": "done", "sourceHandle": "approved"},
|
||
{"source": "gate", "target": "draft", "sourceHandle": "rejected"}]}
|
||
assert not [i for i in lint_for_publish(_spec(again), level=level).issues if i.code == "reject_exit.no_end"]
|
||
|
||
|
||
def test_layout_orders_the_reject_exit_below_the_normal_one():
|
||
from app.engine.layout import _handle_order
|
||
|
||
spec = _spec(gated(GATES["tool"][0]))
|
||
assert _handle_order(spec.node_map()["gate"], spec) == ["out", "rejected"]
|
||
approve = _spec(gated(GATES["approve"][0]))
|
||
assert _handle_order(approve.node_map()["gate"], approve) == ["approved", "rejected"]
|
||
plain = _spec(gated(GATES["tool"][0], exit_to=None))
|
||
assert _handle_order(plain.node_map()["gate"], plain) == ["out"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 应用:实例进度、实例状态
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_the_instance_says_rejected_and_where_it_went():
|
||
from types import SimpleNamespace
|
||
|
||
from app.apps.resolve import _timeline, _trails, instance_state
|
||
|
||
gate, _ = GATES["tool"]
|
||
|
||
async def said(final: Run) -> tuple[dict, dict]:
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, final.id)
|
||
steps = {s["node"]: s for s in (await _timeline(session, SimpleNamespace(instance=run)))["steps"]}
|
||
return steps, instance_state(run, (await _trails(session, [run]))[run.id])
|
||
|
||
# 驳回之后去通知一声就结束(只有一个成果节点):实例写谁被驳回、转到了哪一步,是人的决定,不画成成功
|
||
notify = node("back", "transform", "通知申请人", mode="template", template="退回了")
|
||
steps, state = await said(await _answered(gated(gate, exit_to=notify), {"approved": False, "note": "不对"}))
|
||
assert (steps["gate"]["state"], steps["gate"]["text"]) == ("rejected", "被驳回,转到「通知申请人」")
|
||
assert state == {"code": "cancelled", "text": "「算一下」被驳回,转到「通知申请人」"}
|
||
# 驳回出口走到另一个成果节点(流程的另一种结局):照旧写走到的那个成果节点,记成人的决定
|
||
steps, state = await said(await _answered(gated(gate), {"approved": False, "note": "不对"}))
|
||
assert (steps["gate"]["state"], steps["gate"]["text"]) == ("rejected", "被驳回,转到「退回申请人」")
|
||
assert state == {"code": "cancelled", "text": "退回申请人"}
|
||
# 批准了的照旧是办完
|
||
ok = await _answered(gated(gate), {"approved": True})
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, ok.id)
|
||
steps = {s["node"]: s for s in (await _timeline(session, SimpleNamespace(instance=run)))["steps"]}
|
||
state = instance_state(run, (await _trails(session, [run]))[run.id])
|
||
assert steps["gate"]["state"] == "done" and state == {"code": "succeeded", "text": "办结"}
|