mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
驳回出口(后端):审批、补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决接了「驳回」出口时沿它往下走
以前这六类关卡被驳回一律抛 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(裁决走出口)
This commit is contained in:
@@ -1503,6 +1503,9 @@ class _Trail:
|
||||
tasks: list[Any] = field(default_factory=list)
|
||||
actions: list[Any] = field(default_factory=list)
|
||||
events: list[tuple[str, str]] = field(default_factory=list)
|
||||
#: 走了「驳回」出口、而且最后一次执行就是走的驳回出口的关卡:节点 id → 那条 node.finished 的数据(outcome=rejected,
|
||||
#: 带 to:转到哪几步。engine/compiler._rejected_exit)。退回修改之后又批准了的不算:结局是批准
|
||||
rejected_out: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]:
|
||||
@@ -1528,9 +1531,46 @@ async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]:
|
||||
.order_by(RunEvent.seq))).all():
|
||||
if node_id:
|
||||
out[run_id].events.append((etype, node_id))
|
||||
await _rejected_exits(session, [r for r in runs if r.status == "succeeded"], out)
|
||||
return out
|
||||
|
||||
|
||||
async def _rejected_exits(session: AsyncSession, runs: list[Run], out: dict[str, _Trail]) -> None:
|
||||
"""成功结束、途经驳回出口的运行(run.finished 带 rejected_via,engine/runner._rejected_via):每个走过驳回出口的关卡,
|
||||
最后一次执行是不是走的驳回出口(退回修改之后又批准的,结局是批准)。只翻途经驳回的运行里那几个关卡的 node.finished。"""
|
||||
ids = [r.id for r in runs]
|
||||
if not ids:
|
||||
return
|
||||
gates: dict[str, set[str]] = {}
|
||||
for run_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.data).where(
|
||||
RunEvent.run_id.in_(ids), RunEvent.type == "run.finished"))).all():
|
||||
via = data.get("rejected_via") if isinstance(data, dict) else None
|
||||
names = {str(v.get("node_id")) for v in via or [] if isinstance(v, dict) and v.get("node_id")}
|
||||
if names:
|
||||
gates[run_id] = names
|
||||
if not gates:
|
||||
return
|
||||
wanted = sorted({n for names in gates.values() for n in names})
|
||||
for run_id, node_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.node_id, RunEvent.data).where(
|
||||
RunEvent.run_id.in_(list(gates)), RunEvent.type == "node.finished", RunEvent.node_id.in_(wanted))
|
||||
.order_by(RunEvent.seq))).all():
|
||||
if node_id not in gates[run_id]:
|
||||
continue
|
||||
if isinstance(data, dict) and data.get("outcome") == "rejected":
|
||||
out[run_id].rejected_out[node_id] = data
|
||||
else:
|
||||
out[run_id].rejected_out.pop(node_id, None)
|
||||
|
||||
|
||||
def rejected_exit_text(data: dict[str, Any]) -> str:
|
||||
"""走驳回出口的那一步怎么说:「被驳回,转到「通知申请人」」;动作确认写「未确认(被驳回),转到…」,超时驳回写「超时驳回,
|
||||
转到…」。转到哪几步取 node.finished 上记的 to(图上的名字)。"""
|
||||
head = "未确认(被驳回)" if data.get("kind") == "action_confirm" and data.get("decision") == "reject" \
|
||||
else "超时驳回" if data.get("decision") == "expired_reject" else "被驳回"
|
||||
to = [str(t.get("label") or t.get("node_id")) for t in data.get("to") or [] if isinstance(t, dict)]
|
||||
return f"{head},转到{'、'.join(f'「{t}」' for t in to)}" if to else head
|
||||
|
||||
|
||||
def _decision(trail: _Trail, node_id: str) -> tuple[str | None, str | None]:
|
||||
"""这个节点最近一条处理完的待办:(种类, 结论 approve / reject / expired_reject / submit)。"""
|
||||
done = [t for t in trail.tasks if t.node_id == node_id and t.status in ("resolving", "resolved")]
|
||||
@@ -1545,15 +1585,18 @@ _REJECTIONS = ("reject", "expired_reject")
|
||||
|
||||
|
||||
def rejected_outputs(graph: Any) -> dict[str, set[str]]:
|
||||
"""成果节点 → 从哪几个人工节点的「驳回」出口一路走到它(中间不经过别的人工节点:再遇到人工节点,结局由那一个人
|
||||
的决定说了算)。按图认,不按成果节点的标题猜。"""
|
||||
"""成果节点 → 从哪几个关卡(人工节点,以及接了「驳回」出口的调用工具、沙箱代码、双盲复算,engine/schema.GATE_TYPES)
|
||||
的「驳回」出口一路走到它(中间不经过别的人工节点:再遇到人工节点,结局由那一个人的决定说了算)。按图认,不按成果
|
||||
节点的标题猜。"""
|
||||
from app.apps.tree import node_map
|
||||
from app.engine.schema import GATE_TYPES
|
||||
|
||||
gate_types = {str(t) for t in GATE_TYPES}
|
||||
nodes = node_map(graph)
|
||||
edges = [e for e in (graph or {}).get("edges") or [] if isinstance(e, dict)]
|
||||
out: dict[str, set[str]] = {}
|
||||
for gate in (e for e in edges if e.get("sourceHandle") == REJECTED_HANDLE
|
||||
and (nodes.get(str(e.get("source"))) or {}).get("type") == "human"):
|
||||
and (nodes.get(str(e.get("source"))) or {}).get("type") in gate_types):
|
||||
seen: set[str] = set()
|
||||
todo = [str(gate.get("target"))]
|
||||
while todo:
|
||||
@@ -1594,7 +1637,8 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]:
|
||||
- 走完了:图里有几个成果节点(分支的几种结局,如「已通过」「已退回」)、恰好走到了其中一个的,写走到的那一个;只有
|
||||
一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局。走到的是
|
||||
否定的那种结局——这次运行里人工节点驳回了、从它的「驳回」出口一路走到的成果节点(rejected_outputs)——code 记
|
||||
cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题;
|
||||
cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题;别的走了关卡「驳回」出口、
|
||||
最后一次就是走的驳回出口的(_Trail.rejected_out),写「「x」被驳回,转到「y」」,code 同样记 cancelled;
|
||||
- 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……);
|
||||
- 被驳回(cancelled + end_reason=rejected,engine/context.NodeRejected):动作确认被驳回写「「付款」未确认(被驳回)」,
|
||||
审批设了驳回即终止、补充输入被驳回等写「「x」被驳回」,code 记 cancelled;手动停止的写「已取消」;
|
||||
@@ -1621,8 +1665,13 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]:
|
||||
reached = [nid for etype, nid in trail.events if etype == "node.finished" and nid in outputs]
|
||||
if len(outputs) > 1 and len(set(reached)) == 1:
|
||||
gates = rejected_outputs(run.graph).get(reached[0], set())
|
||||
negative = any(_decision(trail, gate)[1] in _REJECTIONS for gate in gates)
|
||||
# 驳回了:按待办的结论认(人工节点),或者这一步最后一次就是走的驳回出口(工具调用审批、代码执行审批没开待办)
|
||||
negative = any(_decision(trail, gate)[1] in _REJECTIONS or gate in trail.rejected_out for gate in gates)
|
||||
return {"code": "cancelled" if negative else "succeeded", "text": title(reached[0])}
|
||||
if trail.rejected_out:
|
||||
# 走了「驳回」出口(总方案 §3):运行照驳回那条路结束,照实说被驳回、转到了哪一步;是人的决定,不画成成功
|
||||
node_id, data = list(trail.rejected_out.items())[-1]
|
||||
return {"code": "cancelled", "text": f"「{title(node_id)}」{rejected_exit_text(data)}"}
|
||||
return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]}
|
||||
if run.status == "cancelled" and run.end_reason == "rejected":
|
||||
failed = [nid for etype, nid in trail.events if etype == "node.failed"]
|
||||
@@ -1745,8 +1794,11 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]:
|
||||
#: 被驳回的那一步:node.failed 上带着 reason=rejected 和结论(engine/compiler,总方案 X9)。工具调用审批、代码执行审批
|
||||
#: 这类驳回没开待办,以前只能落成「失败」;老运行的 node.failed 没有 reason,照旧按待办认
|
||||
rejected: dict[str, dict[str, Any]] = {}
|
||||
#: 走了「驳回」出口的那一步:node.finished 带 outcome=rejected(engine/compiler._rejected_exit),节点照常结束
|
||||
exits: dict[str, dict[str, Any]] = {}
|
||||
for etype, node_id, ts, data in rows:
|
||||
if not node_id:
|
||||
if not node_id or (node_id not in titles and "/" in node_id):
|
||||
# 子工作流里的那几步(节点 id 带「子工作流节点 id/」前缀):实例进度只说这张图上的步骤,子工作流算一步
|
||||
continue
|
||||
step = steps.setdefault(node_id, {"node": node_id, "title": titles.get(node_id, node_id), "state": "running",
|
||||
"started_at": datetime.fromtimestamp(ts, timezone.utc).isoformat()})
|
||||
@@ -1756,6 +1808,9 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]:
|
||||
rejected[node_id] = data
|
||||
elif etype == "node.started":
|
||||
rejected.pop(node_id, None)
|
||||
exits.pop(node_id, None)
|
||||
if etype == "node.finished" and isinstance(data, dict) and data.get("outcome") == "rejected":
|
||||
exits[node_id] = data
|
||||
for node_id, step in steps.items():
|
||||
text = None
|
||||
record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None)
|
||||
@@ -1764,6 +1819,8 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]:
|
||||
said = rejected.get(node_id) if step["state"] == "failed" else None
|
||||
if record is not None and record.state in ("sent", "unknown"):
|
||||
step["state"] = "unknown"
|
||||
elif step["state"] == "done" and node_id in exits:
|
||||
step["state"], text = "rejected", rejected_exit_text(exits[node_id])
|
||||
elif said is not None:
|
||||
step["state"] = "rejected"
|
||||
text = "未确认(被驳回)" if said.get("kind") == "action_confirm" and said.get("decision") == "reject" \
|
||||
|
||||
@@ -15,7 +15,10 @@ from app.engine.expressions import open_template_scope, template_scope
|
||||
from app.engine.nodes import (
|
||||
challenge, control, dual_check, human, io, knowledge, llm, merge, metrics, multi, report, tools,
|
||||
)
|
||||
from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges, innermost_loops
|
||||
from app.engine.schema import (
|
||||
GATE_TYPES, REJECT_HANDLE, GraphNode, GraphSpec, NodeType, approve_mode, back_edges, innermost_loops,
|
||||
reject_targets,
|
||||
)
|
||||
from app.engine.state import GraphState
|
||||
from app.process.actions import ActionPending
|
||||
|
||||
@@ -103,7 +106,7 @@ def _wrap(
|
||||
payload,
|
||||
kind="node_output",
|
||||
run_id=run_ctx.run_id,
|
||||
node_id=node.id,
|
||||
node_id=run_ctx.global_id(node.id),
|
||||
meta={"type": str(node.type), "attempt": attempt + 1},
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 工件写失败不该毁掉运行本身
|
||||
@@ -124,7 +127,7 @@ def _wrap(
|
||||
# 这一步新记进证据台账的条目(口径卡的指标集工件等)。事件在封存范围
|
||||
# 内,证据接口从这里出发找工件,不去读可以事后插行的 artifacts 表。
|
||||
# 没产出证据的节点不带这个键,事件和以前一字不差
|
||||
**({"evidence": updates["evidence"]} if updates.get("evidence") else {}),
|
||||
**_own_evidence(updates, node.id),
|
||||
)
|
||||
trail = {
|
||||
"node_id": node.id,
|
||||
@@ -169,6 +172,11 @@ def _wrap(
|
||||
break
|
||||
|
||||
elapsed = int((time.perf_counter() - started) * 1000)
|
||||
if isinstance(last_error, NodeRejected) and not last_error.stop \
|
||||
and (targets := reject_targets(run_ctx.spec, node)):
|
||||
# 接了「驳回」出口:驳回不让运行结束,这一步正常结束、沿驳回那条边往下走(总方案 §3)。没接的照旧往下收成
|
||||
# node.failed(reason=rejected)+ 运行 cancelled,一字不变
|
||||
return await _rejected_exit(node, ctx, last_error, targets, elapsed=elapsed, attempt=attempt + 1)
|
||||
message = _describe(last_error)
|
||||
# node_id 要带上:前端靠它把出错的节点从"转圈"收敛成"失败"。
|
||||
# error 是给人看的那句,原始异常放 detail,排查的人照样拿得到。
|
||||
@@ -196,6 +204,48 @@ def _wrap(
|
||||
return _entry
|
||||
|
||||
|
||||
def _own_evidence(updates: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||
"""node.finished 上带的台账条目:这一步自己新记的。子工作流节点交回父图的条目(节点 id 是「子工作流节点 id/…」,
|
||||
multi.run_subgraph)不带:子工作流里那几个节点自己的 node.finished 已经带着它们(节点 id 同样带前缀),再记一遍,
|
||||
证据接口会把查询认成子工作流节点自己查的。"""
|
||||
entries = [e for e in updates.get("evidence") or []
|
||||
if not (isinstance(e, dict) and str(e.get("node_id") or "").startswith(f"{node_id}/"))]
|
||||
return {"evidence": entries} if entries else {}
|
||||
|
||||
|
||||
async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, targets: list[str], *,
|
||||
elapsed: int, attempt: int) -> dict[str, Any]:
|
||||
"""被驳回、接了「驳回」出口:这一步正常结束(node.finished 带 outcome=rejected 和哪种关卡、结论、谁、备注、转到哪几步),
|
||||
产出 NodeRejected.exit_payload(),路由按它的 __decision__ 走驳回那条边。
|
||||
|
||||
动作确认被驳回时 NodeRejected 抛在发送之前,走到这里时什么都没发(照旧「未确认(被驳回)」)。trail 上这一步记
|
||||
outcome=rejected:运行收尾据此在 run.finished 上照实写「途经驳回」(runner._rejected_via)。审批模式写了「结果存为
|
||||
变量」的照旧写 false(以前接了驳回边的审批就是这么写的);别的关卡这一步没有产出值,变量不写。"""
|
||||
from app.core.artifact_store import put_json
|
||||
|
||||
run_ctx = ctx.run
|
||||
payload = error.exit_payload()
|
||||
nodes = run_ctx.spec.node_map()
|
||||
to = [{"node_id": run_ctx.global_id(t), "label": nodes[t].title if t in nodes else t} for t in dict.fromkeys(targets)]
|
||||
try:
|
||||
artifact_id: str | None = await put_json(payload, kind="node_output", run_id=run_ctx.run_id,
|
||||
node_id=run_ctx.global_id(node.id),
|
||||
meta={"type": str(node.type), "attempt": attempt, "outcome": "rejected"})
|
||||
except Exception: # noqa: BLE001 - 工件写失败不该毁掉运行本身
|
||||
artifact_id = None
|
||||
said = {"kind": error.kind, "decision": error.decision, "actor": error.actor, "note": error.note}
|
||||
ctx.emit(EventType.NODE_FINISHED, duration_ms=elapsed, attempt=attempt,
|
||||
preview=_preview({"nodes": {node.id: payload}}, node.id), artifact=artifact_id,
|
||||
outcome="rejected", **said, to=to)
|
||||
updates: dict[str, Any] = {"nodes": {node.id: payload}, "trail": [{
|
||||
"node_id": node.id, "type": str(node.type), "duration_ms": elapsed, "attempt": attempt, "ts": time.time(),
|
||||
"outcome": "rejected", "label": node.title, **said, "to": to}]}
|
||||
var_name = str(node.config.get("assign_to") or "").strip()
|
||||
if var_name and error.kind == "approve":
|
||||
updates["vars"] = {var_name: False}
|
||||
return updates
|
||||
|
||||
|
||||
def _describe(error: BaseException | None) -> str:
|
||||
"""错误消息。NodeError 的 message 本来就是写给人看的;别的异常翻成一句原因,
|
||||
不带类型名和内部符号——界面上出现过「_make_query_tool.<locals>._run() got an
|
||||
@@ -294,8 +344,13 @@ def _make_router(
|
||||
所以真正的判断逻辑放在节点执行阶段,这里只查表。
|
||||
"""
|
||||
mapping: dict[str, list[str]] = {}
|
||||
# 审批模式以外的关卡节点(接了驳回出口才走到这里)只有两条路:驳回出口,和其余的(普通出口 out)。正常结束时产出里
|
||||
# 没有 __decision__(调用工具、沙箱代码、双盲复算),或者是补充输入、编辑稿件写的 approved,都落到 default
|
||||
gate = node.type in GATE_TYPES and not approve_mode(spec, node)
|
||||
for edge in spec.outgoing(node.id):
|
||||
key = edge.sourceHandle or "default"
|
||||
if gate:
|
||||
key = REJECT_HANDLE if key == REJECT_HANDLE else "default"
|
||||
mapping.setdefault(key, []).append(edge.target)
|
||||
|
||||
default_targets = mapping.get("default") or []
|
||||
@@ -371,7 +426,7 @@ def compile_graph(spec: GraphSpec, run_ctx: RunContext) -> StateGraph:
|
||||
handles = {e.sourceHandle for e in outgoing if e.sourceHandle}
|
||||
needs_routing = node.type in ROUTING_TYPES or (
|
||||
node.type == NodeType.HUMAN and handles & {"approved", "rejected"}
|
||||
)
|
||||
) or bool(reject_targets(spec, node))
|
||||
|
||||
if needs_routing:
|
||||
route, path_map = _make_router(node, spec)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
from app.core.events import EventType
|
||||
from app.engine.schema import GraphNode, GraphSpec
|
||||
from app.engine.schema import REJECT_HANDLE, GraphNode, GraphSpec
|
||||
from app.engine.state import GraphState, template_context
|
||||
from app.engine.expressions import render_deep, render_template
|
||||
|
||||
@@ -34,6 +34,35 @@ class RunContext:
|
||||
agent_limits: dict[str, Any] | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def prefix(self) -> str:
|
||||
"""子工作流里的节点在这次运行里的 id 前缀(「子工作流节点 id/」,嵌套的一层层接上);最外层是空串。"""
|
||||
return str(self.extra.get(PREFIX_KEY) or "")
|
||||
|
||||
def global_id(self, node_id: str) -> str:
|
||||
"""这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。"""
|
||||
return f"{self.prefix}{node_id}"
|
||||
|
||||
|
||||
#: 子工作流里的节点 id 前缀(RunContext.extra 里的键,engine/nodes/multi.run_subgraph 放)
|
||||
PREFIX_KEY = "node_prefix"
|
||||
#: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给
|
||||
#: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都
|
||||
#: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发
|
||||
STREAM_KEY = "stream_writer"
|
||||
|
||||
|
||||
def stream_writer(run: RunContext) -> Any:
|
||||
"""往这次运行的事件流里写事件的入口:子工作流里是父运行交下来的那个,否则是 LangGraph 这一刻的。不在图执行上下文里
|
||||
(单元测试直接调执行器)时为 None。"""
|
||||
writer = run.extra.get(STREAM_KEY)
|
||||
if writer is not None:
|
||||
return writer
|
||||
try:
|
||||
return get_stream_writer()
|
||||
except Exception: # noqa: BLE001 - 不在图执行上下文里
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeContext:
|
||||
@@ -51,10 +80,13 @@ class NodeContext:
|
||||
|
||||
runner 在另一端消费,转成落库的 RunEvent 并广播给前端 —— 画布上的节点高亮、
|
||||
token 流、工具调用卡片全都来自这里。
|
||||
|
||||
子工作流里的节点经父运行交下来的写入口发(stream_writer),节点 id 带上前缀(RunContext.global_id):
|
||||
「子工作流节点 id/节点 id」,嵌套的一层层接上。事件里别的字段(台账条目、口径卡上记的输入)照旧写这张图里的 id,
|
||||
读的一方按事件的节点 id 认它在哪一层(api/evidence 的 _scoped)。
|
||||
"""
|
||||
try:
|
||||
writer = get_stream_writer()
|
||||
except Exception: # noqa: BLE001 - 不在图执行上下文里(比如单元测试)
|
||||
writer = stream_writer(self.run)
|
||||
if writer is None:
|
||||
return
|
||||
if str(event_type) == EventType.LLM_END and "unpriced" not in data:
|
||||
from app.providers.catalog import unpriced_call
|
||||
@@ -66,7 +98,7 @@ class NodeContext:
|
||||
{
|
||||
"__agentlab__": True,
|
||||
"type": str(event_type),
|
||||
"node_id": self.node.id,
|
||||
"node_id": self.run.global_id(self.node.id),
|
||||
"ts": time.time(),
|
||||
"data": data,
|
||||
}
|
||||
@@ -121,18 +153,36 @@ class NodeRejected(NodeError):
|
||||
|
||||
是 NodeError 的子类:节点这一步照旧记 node.failed(时间线、实例进度按它收尾);但不重试、不被 on_error=continue
|
||||
接住(engine/compiler._execute:那两项是给故障准备的,驳回被吞掉运行就以 succeeded 结束了)。原话(str)照旧写给人看,
|
||||
Run.error 和以前一字不差。kind 见 REJECTION_KINDS;decision 是 reject 或 expired_reject(同待办的结论)。"""
|
||||
Run.error 和以前一字不差。kind 见 REJECTION_KINDS;decision 是 reject 或 expired_reject(同待办的结论)。
|
||||
|
||||
节点在画布上接了「驳回」出口(REJECT_HANDLE,engine/schema.reject_targets)时,驳回不让运行结束:compiler 接住它,
|
||||
这一步正常结束、产出 exit_payload()、沿驳回那条边往下走(docs/design/orchestration-round/00-总方案.md §3)。stop 为真
|
||||
的除外:审批节点明说了「驳回即终止运行」,那条驳回边照旧走不到(以前就是这样),运行照旧收成被驳回。"""
|
||||
|
||||
def __init__(self, node_id: str, message: str, *, kind: str, decision: str = "reject",
|
||||
actor: str | None = None, note: str = "") -> None:
|
||||
actor: str | None = None, note: str = "", stop: bool = False) -> None:
|
||||
super().__init__(node_id, message)
|
||||
self.kind = kind
|
||||
self.decision = decision
|
||||
self.actor = actor
|
||||
self.note = note
|
||||
self.stop = stop
|
||||
|
||||
@classmethod
|
||||
def of(cls, ctx: Any, message: str, *, kind: str, decision: Any) -> NodeRejected:
|
||||
def of(cls, ctx: Any, message: str, *, kind: str, decision: Any, stop: bool = False) -> NodeRejected:
|
||||
"""按节点上下文和读出来的审批结论(engine/approval.Decision)造一个:谁驳回的取 ctx.actor()。"""
|
||||
return cls(ctx.node.id, message, kind=kind, decision="expired_reject" if decision.expired else "reject",
|
||||
actor=ctx.actor(), note=decision.note or "")
|
||||
actor=ctx.actor(), note=decision.note or "", stop=stop)
|
||||
|
||||
def exit_payload(self) -> dict[str, Any]:
|
||||
"""走驳回出口时这一步的产出:{rejected, decision, actor, note, kind, text},加路由用的 __decision__。
|
||||
|
||||
text 是原话(「「付款」未确认(被驳回),没有发送」),下游的「通知」「退回修改」写 {{ nodes.<id>.text }} 就有一句
|
||||
说得清的话;note 是驳回人写的备注。审批模式另带 approved=false:以前接了驳回边的审批节点产出就是
|
||||
{approved: false, note},下游模板读 approved 的照旧读得到。"""
|
||||
out: dict[str, Any] = {"rejected": True, "decision": self.decision, "actor": self.actor, "note": self.note,
|
||||
"kind": self.kind, "text": str(self), "__decision__": REJECT_HANDLE}
|
||||
if self.kind == "approve":
|
||||
out["approved"] = False
|
||||
return out
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
|
||||
"(那里会保存取数快照)", code="governed.code_network", node_id=node.id, field="network")
|
||||
|
||||
_lint_evidence(spec, flag, strict=strict)
|
||||
_lint_reject_exits(spec, flag)
|
||||
_lint_sql(spec, flag, checkers)
|
||||
_load_extra_lints()
|
||||
for check in EXTRA_LINTS:
|
||||
@@ -158,6 +159,41 @@ def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] |
|
||||
return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate]
|
||||
|
||||
|
||||
def _lint_reject_exits(spec: GraphSpec, flag: Callable[..., None]) -> None:
|
||||
"""「驳回」出口那条路要能走到成果节点或者结束(一个不再往下连的节点)(总方案 §3):走不到的,驳回之后只能一直绕下去,
|
||||
运行会在步数上限处中止。各级都报错——这不是受管才有的要求,任何一次驳回都会把运行拖到那里。
|
||||
|
||||
按图上的可达性认:从驳回出口连到的节点往下走,碰到成果节点或者没有出边的节点就算走得到。绕回关卡自己(「退回修改」:
|
||||
改完再问一次)算走得到:再问一次还能批准、走普通出口。受管级里驳回路径上的出具照常判档、契约照常核对,不另立规矩。"""
|
||||
from app.engine.schema import reject_targets
|
||||
|
||||
nodes = spec.node_map()
|
||||
for gate in spec.nodes:
|
||||
targets = [t for t in reject_targets(spec, gate) if t in nodes]
|
||||
if not targets or _reaches_an_end(spec, targets):
|
||||
continue
|
||||
flag(f"{_who(gate)}的「驳回」出口接到{'、'.join(f'「{nodes[t].title}」' for t in dict.fromkeys(targets))}之后,"
|
||||
"走不到任何成果节点或结束:驳回之后运行只能一直绕下去,会在步数上限处中止。请让驳回之后的那条路接到成果节点,"
|
||||
"或者在一个不再往下连的节点结束",
|
||||
code="reject_exit.no_end", node_id=gate.id, always=True)
|
||||
|
||||
|
||||
def _reaches_an_end(spec: GraphSpec, start: list[str]) -> bool:
|
||||
nodes = spec.node_map()
|
||||
seen: set[str] = set()
|
||||
todo = list(start)
|
||||
while todo:
|
||||
cur = todo.pop()
|
||||
if cur in seen or cur not in nodes:
|
||||
continue
|
||||
seen.add(cur)
|
||||
outgoing = [e.target for e in spec.outgoing(cur) if e.target in nodes]
|
||||
if nodes[cur].type == NodeType.OUTPUT or not outgoing:
|
||||
return True
|
||||
todo.extend(outgoing)
|
||||
return False
|
||||
|
||||
|
||||
def _lint_sql(spec: GraphSpec, flag: Callable[..., None], checkers: Mapping[str, Any] | None) -> None:
|
||||
"""调用工具节点里写死的 SQL 对照数据目录检查(data/sqlcheck.py)。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict, deque
|
||||
from statistics import median
|
||||
|
||||
from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges
|
||||
from app.engine.schema import REJECT_HANDLE, GraphNode, GraphSpec, NodeType, back_edges, reject_targets
|
||||
|
||||
#: 画布节点的宽度。前端 NodeCard 的 `width: 238` 是写死的,这里必须跟着它——
|
||||
#: 排版算出来的走廊是给连线用的,宽度估错了走廊就白留。
|
||||
@@ -108,11 +108,12 @@ def _ranks(
|
||||
return rank
|
||||
|
||||
|
||||
def _handle_order(node: GraphNode) -> list[str]:
|
||||
def _handle_order(node: GraphNode, spec: GraphSpec | None = None) -> list[str]:
|
||||
"""节点出口在画布上从上到下的顺序。分支的 yes/no 就该是这个顺序。
|
||||
|
||||
前端 `sourceHandles()` 决定出口怎么画,这里决定它们怎么排。两份必须一致,
|
||||
否则分支标签的上下关系会和连线对不上。
|
||||
否则分支标签的上下关系会和连线对不上。关卡节点接了「驳回」出口的(engine/schema.reject_targets),
|
||||
驳回排在普通出口下面;没接的只有一个出口,排不排都一样。
|
||||
"""
|
||||
cfg = node.config
|
||||
if node.type == NodeType.BRANCH:
|
||||
@@ -125,7 +126,9 @@ def _handle_order(node: GraphNode) -> list[str]:
|
||||
if node.type == NodeType.LOOP:
|
||||
return ["body", "done"]
|
||||
if node.type == NodeType.HUMAN and cfg.get("mode", "approve") == "approve":
|
||||
return ["approved", "rejected"]
|
||||
return ["approved", REJECT_HANDLE]
|
||||
if spec is not None and reject_targets(spec, node):
|
||||
return ["out", REJECT_HANDLE]
|
||||
return ["out"]
|
||||
|
||||
|
||||
@@ -225,7 +228,7 @@ def _apply_handle_order(
|
||||
outgoing[e.source].append(e)
|
||||
|
||||
for node in spec.nodes:
|
||||
handles = _handle_order(node)
|
||||
handles = _handle_order(node, spec)
|
||||
if len(handles) < 2 or node.id not in nodes:
|
||||
continue
|
||||
outs = outgoing.get(node.id, [])
|
||||
|
||||
@@ -1379,8 +1379,8 @@ def _merge(per_input: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]
|
||||
def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tuple[dict[str, str], str]:
|
||||
"""审核人的回复 → ({指标 id: a | b | neither}, 备注)。回复的 value 是 {choices, note};某个指标没选、选了认不出的
|
||||
不进 choices:照实记成「没有作出选择」,不替人说「两边都不对」,也不替人认可任何一边(缺口照样记、出具照样降档)。
|
||||
通用的输入卡送来的是一句话,所有指标都按没选记。明确驳回(approved 为否)的按人工驳回让节点失败,和人工审批节点的
|
||||
填写模式一样:这一步没有驳回的出口。"""
|
||||
通用的输入卡送来的是一句话,所有指标都按没选记。明确驳回(approved 为否)的抛 NodeRejected,和人工审批节点的
|
||||
填写模式一样:画布上接了「驳回」出口的沿它往下走(engine/compiler),没接的运行收成被驳回。"""
|
||||
decision = read_decision(response)
|
||||
if isinstance(response, dict) and decision.rejected:
|
||||
raise NodeRejected.of(ctx, f"人工驳回:{decision.note or '未说明原因'}", kind="dual_check", decision=decision)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.db.base import SessionLocal
|
||||
from app.engine.approval import read_decision
|
||||
from app.engine.context import NodeContext, NodeError, NodeRejected
|
||||
from app.engine.replay import ask, once
|
||||
from app.engine.schema import reject_targets
|
||||
from app.engine.state import GraphState, merge_usage, message_text, template_context
|
||||
from app.providers import catalog
|
||||
from app.providers.factory import ModelSpec, get_chat_model
|
||||
@@ -51,15 +52,20 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
||||
"note": decision.note,
|
||||
"__decision__": "approved" if approved else "rejected",
|
||||
}
|
||||
if not approved and ctx.cfg("stop_on_reject", False):
|
||||
raise NodeRejected.of(ctx, f"人工驳回:{result['note'] or '未说明原因'}", kind="approve", decision=decision)
|
||||
# 驳回即终止:照旧收成被驳回,接没接驳回边都一样(明说了终止,那条边以前就走不到;stop=True,compiler 不接)。
|
||||
# 画布上接了「驳回」出口的:同样交给 NodeRejected,由 compiler 接住、这一步正常结束、沿驳回边往下走,产出和别的
|
||||
# 关卡同一种形状(engine/schema.reject_targets)。两样都没有的照旧按 __decision__ 路由(没有驳回边就结束在这里)
|
||||
stop = bool(ctx.cfg("stop_on_reject", False))
|
||||
if not approved and (stop or reject_targets(ctx.run.spec, ctx.node)):
|
||||
raise NodeRejected.of(ctx, f"人工驳回:{result['note'] or '未说明原因'}", kind="approve", decision=decision,
|
||||
stop=stop)
|
||||
else: # edit / input
|
||||
# 驳回在这两种模式下以前是个摆设:__decision__ 硬编码成 approved,
|
||||
# approved 字段看都不看。界面上按钮在、toast 说"已驳回",运行却照常
|
||||
# 往下走——比没有这个按钮更糟。
|
||||
#
|
||||
# 这两种模式在图上只有一个 out 出口(没有 rejected 那条边),所以驳回
|
||||
# 的正确语义是终止运行,而不是当作通过继续。缺省仍是通过:只传 value
|
||||
# 这两种模式在图上本来只有一个 out 出口,所以驳回的语义是终止运行,而不是当作通过继续;
|
||||
# 画布上接了「驳回」出口的,compiler 接住 NodeRejected、沿驳回边往下走。缺省仍是通过:只传 value
|
||||
# 不带 approved 的老调用方行为不变。
|
||||
#
|
||||
# 只有结构体里明说了"不"才算驳回:这两种模式下裸字符串就是内容本身,
|
||||
|
||||
@@ -302,8 +302,9 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[
|
||||
await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note,
|
||||
actor=ctx.actor(), payload_hash=confirmed)
|
||||
if not decision.approved:
|
||||
# 驳回是人的决定,不是故障:运行到这里结束(没有可走的出口),收成 cancelled + end_reason=rejected
|
||||
# (NodeRejected),说法写成「未确认(被驳回)」,实例页、通知按同一个结论说(apps/resolve.instance_state)
|
||||
# 驳回是人的决定,不是故障:抛 NodeRejected,说法写成「未确认(被驳回)」,实例页、通知按同一个结论说
|
||||
# (apps/resolve.instance_state)。抛在发送之前:画布上接了「驳回」出口的沿它往下走(engine/compiler),什么都
|
||||
# 没发;没接的运行到这里结束,收成 cancelled + end_reason=rejected
|
||||
raise NodeRejected.of(ctx, f"「{ctx.node.title}」未确认(被驳回),没有发送"
|
||||
+ (f":{decision.note}" if decision.note else ""),
|
||||
kind="action_confirm", decision=decision)
|
||||
|
||||
@@ -1039,13 +1039,13 @@ class RunManager:
|
||||
# 切断的半截答案当成完整结论落了库
|
||||
clipped, truncated = _clip(output)
|
||||
data: dict[str, Any] = {"output": clipped, "usage": usage,
|
||||
"duration_ms": timing["active_ms"], "timing": timing}
|
||||
"duration_ms": timing["active_ms"], "timing": timing, **_rejected_via(state)}
|
||||
if truncated:
|
||||
data["output_truncated"] = True
|
||||
event = self._event(run_id, EventType.RUN_FINISHED, data=data)
|
||||
elif terminal is not None:
|
||||
kind, data = terminal
|
||||
event = self._event(run_id, kind, data={**data, "timing": timing})
|
||||
event = self._event(run_id, kind, data={**data, "timing": timing, **_rejected_via(state)})
|
||||
|
||||
if run:
|
||||
run.status = status
|
||||
@@ -1346,6 +1346,16 @@ def _trust_event(tool_trust: dict[str, str] | None) -> Any:
|
||||
return "formal" if tool_trust.get(FORMAL_MARK) else snapshot_levels(tool_trust)
|
||||
|
||||
|
||||
def _rejected_via(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""这次运行途经的驳回出口(engine/compiler._rejected_exit 记在 trail 上的 outcome=rejected):{rejected_via: [{node_id,
|
||||
label, kind, decision, actor, note, to}]},按先后。终态事件带上它,运行照它的路径结束(成功结束也一样)照实说「途经
|
||||
驳回」;没有途经的不带这个键,终态事件和以前一字不差。读的是收尾时的状态:节点出错、被驳回结束的那几种收尾拿不到
|
||||
状态(final_state 为空),不带;子工作流里的驳回出口不在这里(它的 trail 是子工作流自己的状态)。"""
|
||||
via = [{k: t.get(k) for k in ("node_id", "label", "kind", "decision", "actor", "note", "to")}
|
||||
for t in state.get("trail") or [] if isinstance(t, dict) and t.get("outcome") == "rejected"]
|
||||
return {"rejected_via": via} if via else {}
|
||||
|
||||
|
||||
def rejected_data(exc: NodeRejected, spec: GraphSpec) -> dict[str, Any]:
|
||||
"""被驳回的 run.cancelled 带什么:原因(rejected)、在哪一步(节点 id 和图上的名字)、哪种关卡、结论(驳回 / 超时
|
||||
驳回)、谁、备注。进封存:谁在哪一步驳回、为什么,事后查得到。"""
|
||||
|
||||
@@ -144,6 +144,37 @@ class GraphSpec(BaseModel):
|
||||
return [n for n in self.nodes if not self.outgoing(n.id)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 驳回出口(docs/design/orchestration-round/00-总方案.md §3)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: 会被人驳回、能接「驳回」出口的节点:人工节点(审批、补充输入、编辑稿件)、调用工具(工具调用审批、写入前的动作确认)、
|
||||
#: 沙箱代码(代码执行审批)、双盲复算(裁决)。和 context.REJECTION_KINDS 的六种关卡一一对得上
|
||||
GATE_TYPES = frozenset({NodeType.HUMAN, NodeType.TOOL, NodeType.CODE, NodeType.DUAL_CHECK})
|
||||
#: 「驳回」出口的 sourceHandle(同 context.REJECT_HANDLE)
|
||||
REJECT_HANDLE = "rejected"
|
||||
|
||||
|
||||
def approve_mode(spec: GraphSpec, node: GraphNode) -> bool:
|
||||
"""人工节点是不是审批模式(和 NodeContext.cfg 同一个规则:节点上没写取图级 defaults,都没写是审批)。审批模式本来就有
|
||||
「批准」「驳回」两个出口,按 __decision__ 路由;别的关卡只有一个普通出口,接了驳回出口才多一个。"""
|
||||
if node.type != NodeType.HUMAN:
|
||||
return False
|
||||
mode = node.config.get("mode")
|
||||
return (spec.defaults.get("mode") if mode in (None, "") else mode) in (None, "", "approve")
|
||||
|
||||
|
||||
def reject_targets(spec: GraphSpec, node: GraphNode) -> list[str]:
|
||||
"""这个关卡节点「驳回」出口连到的节点(按连线的先后);不是关卡、没接驳回出口的为空。
|
||||
|
||||
接了:驳回(含超时驳回)时这一步正常结束、沿这几条边往下走(engine/compiler);没接:驳回让运行结束(cancelled +
|
||||
end_reason=rejected),和以前一字不差。审批模式另有一条老规矩:没设「驳回即终止」时,驳回本来就按 __decision__ 走
|
||||
驳回边(没接就结束在这里、运行成功),那一条照旧。"""
|
||||
if node.type not in GATE_TYPES:
|
||||
return []
|
||||
return [e.target for e in spec.outgoing(node.id) if e.sourceHandle == REJECT_HANDLE]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 校验
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -869,6 +900,14 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
|
||||
result.add(f"连线的起点节点「{edge.source}」不存在", edge_id=edge.id)
|
||||
if edge.target not in nodes:
|
||||
result.add(f"连线的终点节点「{edge.target}」不存在", edge_id=edge.id)
|
||||
source = nodes.get(edge.source)
|
||||
if edge.sourceHandle == REJECT_HANDLE and source is not None \
|
||||
and source.type not in GATE_TYPES and source.type != NodeType.BRANCH:
|
||||
# 「驳回」出口只在会被人驳回的节点上有意义:别的节点上这条线不会被路由,每次都会走(条件分支的 case 标识
|
||||
# 可以叫 rejected,那是分支自己的出口)
|
||||
result.add(f"「{source.title}」不会被人驳回,不能接「驳回」出口:只有人工节点(审批、补充输入、编辑稿件)、"
|
||||
"调用工具(工具调用审批、动作确认)、沙箱代码(代码执行审批)、双盲复算能接。请把这条连线改接到"
|
||||
"节点的普通出口", node_id=source.id, edge_id=edge.id, code="graph.reject_exit_invalid")
|
||||
|
||||
if not spec.entry_nodes():
|
||||
result.add("找不到起始节点:每个节点都有上游连线,工作流中存在环路且没有起点")
|
||||
@@ -911,6 +950,11 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
|
||||
|
||||
for code, message, field in human_issues(node):
|
||||
result.add(message, node_id=node.id, field=field, code=code)
|
||||
if cfg.get("stop_on_reject") and approve_mode(spec, node) and reject_targets(spec, node):
|
||||
# 驳回即终止优先(以前就是这样):驳回出口那条线走不到。两样都写着,说明有一样不是作者的本意
|
||||
result.add("这个审批设了「驳回即终止运行」,「驳回」出口的连线不会走到:驳回时运行直接结束。"
|
||||
"要让驳回之后走那条路,请关掉「驳回即终止运行」", level="warning", node_id=node.id,
|
||||
field="stop_on_reject", code="human.stop_on_reject_exit")
|
||||
elif node.type == NodeType.SUBGRAPH:
|
||||
if not cfg.get("workflow_id"):
|
||||
result.add("「子工作流」节点还没有选择要嵌套的工作流", node_id=node.id,
|
||||
|
||||
@@ -191,11 +191,12 @@ async def test_the_instance_says_which_outcome_it_reached():
|
||||
await _wait(back, ("succeeded",))
|
||||
view = await _page(app_id, back, user("qianqi"))
|
||||
# 走了退回分支:运行是「已完成」,流程的结论是「已退回」。这是否定的结局(从「驳回」出口走到的成果节点),状态
|
||||
# 标记同「未确认(被驳回)」,不画成成功;时间线上驳回的那一步同样
|
||||
# 标记同「未确认(被驳回)」,不画成成功;时间线上驳回的那一步同样,照实写被驳回、转到了哪一步(驳回出口,
|
||||
# docs/design/orchestration-round/00-总方案.md §3)
|
||||
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", "已驳回")
|
||||
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
|
||||
|
||||
@@ -534,6 +534,27 @@ async def test_rejecting_the_approval_ends_the_run_as_rejected(monkeypatch, shop
|
||||
assert (ended.data["kind"], ended.data["note"]) == ("dual_check", "口径要重新对")
|
||||
|
||||
|
||||
async def test_rejecting_the_approval_with_a_reject_exit_goes_on_along_it(monkeypatch, shop, client):
|
||||
"""裁决接了「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):驳回时这一步正常结束、沿驳回边往下走,产出带
|
||||
哪种关卡、结论、备注;运行照它的路径结束。"""
|
||||
name, _ = shop
|
||||
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
|
||||
spec = graph(name, BY_ORDER_DATE)
|
||||
spec["nodes"].append(node("redo", "output", "重新核对", fields=[{"name": "结论", "value": "{{ nodes.dual.note }}"}]))
|
||||
spec["edges"].append({"source": "dual", "target": "redo", "sourceHandle": "rejected"})
|
||||
run = await start(spec)
|
||||
approval = await pending(run.id)
|
||||
res = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "口径要重新对"})
|
||||
assert res.status_code == 200, res.text
|
||||
done = await wait(run.id, ("succeeded", "failed", "cancelled"))
|
||||
assert (done.status, done.end_reason) == ("succeeded", None), done.error
|
||||
assert done.output == {"结论": "口径要重新对"}
|
||||
evs = await events(run.id)
|
||||
[finished] = [e for e in evs if e.type == "node.finished" and e.node_id == "dual"]
|
||||
assert (finished.data["outcome"], finished.data["kind"]) == ("rejected", "dual_check")
|
||||
assert not [e for e in evs if e.type == "node.finished" and e.node_id == "out"], "普通出口没有走"
|
||||
|
||||
|
||||
async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client):
|
||||
name, source_id = shop
|
||||
Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")})
|
||||
|
||||
@@ -473,6 +473,30 @@ async def test_a_rejected_confirmation_reads_as_a_decision_not_a_fault(erp, capa
|
||||
assert erp.requests == []
|
||||
|
||||
|
||||
async def test_a_rejected_confirmation_with_a_reject_exit_sends_nothing_and_goes_on(erp, capabilities):
|
||||
"""动作确认接了「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):驳回时照旧不发送写入、记「未确认(被驳回)」,
|
||||
这一步正常结束、沿驳回边往下走(这里是通知申请人),运行照它的路径结束;实例页写「未确认(被驳回),转到「通知申请人」」。"""
|
||||
capabilities(await _endpoint(test=True), lookup=False)
|
||||
graph = pay_graph()
|
||||
graph["nodes"].append(node("tell", "output", fields=[{"name": "结论", "value": "{{ nodes.pay.text }}"}]))
|
||||
graph["nodes"][-1]["data"]["label"] = "通知申请人"
|
||||
graph["edges"].append({"source": "pay", "target": "tell", "sourceHandle": "rejected"})
|
||||
run_id = await _formal(graph)
|
||||
task = await _task(run_id, "action_confirm")
|
||||
await tasks.respond(task.id, ANN, decision="reject", note="金额不对")
|
||||
final = await _wait(run_id, ("succeeded", "failed", "cancelled"))
|
||||
assert (final.status, final.end_reason) == ("succeeded", None), final.error
|
||||
assert final.output == {"结论": "「pay」未确认(被驳回),没有发送:金额不对"}
|
||||
assert erp.requests == [], "驳回之后走出口也不发送"
|
||||
assert await _records(run_id) == []
|
||||
[done] = [e for e in await _events(run_id, "node.finished") if e.node_id == "pay"]
|
||||
assert (done.data["outcome"], done.data["kind"], done.data["decision"]) == ("rejected", "action_confirm", "reject")
|
||||
assert not [e for e in await _events(run_id, "tool.start") if e.node_id == "pay"]
|
||||
view = await _instance_view(run_id)
|
||||
step = next(s for s in view["tl"]["steps"] if s["node"] == "pay")
|
||||
assert (step["state"], step["text"]) == ("rejected", "未确认(被驳回),转到「通知申请人」")
|
||||
|
||||
|
||||
async def test_unknown_then_reconciled_reads_as_waiting_then_a_footnote(erp, capabilities):
|
||||
capabilities(await _endpoint(test=True), lookup=True)
|
||||
erp.mode = "timeout_after_write"
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""驳回出口(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": "办结"}
|
||||
Reference in New Issue
Block a user