From d57cdcaffcf35f7fd80d959e42cf9f1f503eb745 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 15:56:20 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E3=80=81=E8=A1=A5=E5=85=85=E8=BE=93=E5=85=A5=E3=80=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E7=A8=BF=E4=BB=B6=E3=80=81=E5=8A=A8=E4=BD=9C=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E3=80=81=E4=BB=A3=E7=A0=81=E5=92=8C=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E3=80=81=E5=8F=8C=E7=9B=B2=E8=A3=81=E5=86=B3?= =?UTF-8?q?=E6=8E=A5=E4=BA=86=E3=80=8C=E9=A9=B3=E5=9B=9E=E3=80=8D=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=E6=97=B6=E6=B2=BF=E5=AE=83=E5=BE=80=E4=B8=8B=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前这六类关卡被驳回一律抛 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(裁决走出口) --- backend/app/apps/resolve.py | 69 ++++- backend/app/engine/compiler.py | 63 ++++- backend/app/engine/context.py | 68 ++++- backend/app/engine/governance.py | 36 +++ backend/app/engine/layout.py | 13 +- backend/app/engine/nodes/dual_check.py | 4 +- backend/app/engine/nodes/human.py | 14 +- backend/app/engine/nodes/tools.py | 5 +- backend/app/engine/runner.py | 14 +- backend/app/engine/schema.py | 44 +++ backend/tests/test_app_instance_page.py | 5 +- backend/tests/test_dual_check_node.py | 21 ++ backend/tests/test_process_actions.py | 24 ++ backend/tests/test_reject_exit.py | 340 ++++++++++++++++++++++++ 14 files changed, 684 insertions(+), 36 deletions(-) create mode 100644 backend/tests/test_reject_exit.py diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index ae394c3b..ea7abd3c 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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" \ diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index 9d6fbcdc..9e3a1f72 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -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.._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) diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index 7cc915ea..26dee96b 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -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..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 + diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 4f78c8f2..3f7c0c54 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -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)。 diff --git a/backend/app/engine/layout.py b/backend/app/engine/layout.py index c079ac99..813e47a0 100644 --- a/backend/app/engine/layout.py +++ b/backend/app/engine/layout.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, []) diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 58428f0f..b334e5cb 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -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) diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index 1e20e9b6..b112a39f 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -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 的老调用方行为不变。 # # 只有结构体里明说了"不"才算驳回:这两种模式下裸字符串就是内容本身, diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 129abc99..7c299fcb 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -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) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index c3acfd8c..27fea92e 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -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 和图上的名字)、哪种关卡、结论(驳回 / 超时 驳回)、谁、备注。进封存:谁在哪一步驳回、为什么,事后查得到。""" diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 8f6fca69..5f37fa06 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -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, diff --git a/backend/tests/test_app_instance_page.py b/backend/tests/test_app_instance_page.py index ce502afb..ff5c1622 100644 --- a/backend/tests/test_app_instance_page.py +++ b/backend/tests/test_app_instance_page.py @@ -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 diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 3ca9af15..67e08954 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -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")}) diff --git a/backend/tests/test_process_actions.py b/backend/tests/test_process_actions.py index b0d0e992..c093f98d 100644 --- a/backend/tests/test_process_actions.py +++ b/backend/tests/test_process_actions.py @@ -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" diff --git a/backend/tests/test_reject_exit.py b/backend/tests/test_reject_exit.py new file mode 100644 index 00000000..c6330d4f --- /dev/null +++ b/backend/tests/test_reject_exit.py @@ -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": "办结"} From 3998e821dcc24fafde8e644fb523fecb66f3a541 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 16:11:15 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E7=9A=84=E8=AF=81=E6=8D=AE=E4=BA=A4=E5=9B=9E=E7=88=B6=E5=9B=BE?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=AD=90=E5=9B=BE?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E6=AD=A5=E8=BF=9B=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E3=80=81=E5=8F=B0=E8=B4=A6=E5=92=8C=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=8D=A1=E4=BA=A4=E5=9B=9E=E3=80=81=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=B8=A6=E5=87=BA=E5=A4=84=E3=80=81=E7=88=B6?= =?UTF-8?q?=E5=9B=BE=E8=B7=A8=E5=AD=90=E5=9B=BE=E5=BE=80=E4=B8=8B=E8=BF=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调查时发现:run_subgraph 只设了节点 id 前缀(node_prefix)没用上,子图在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给父图的流——子图里每一步在运行的事件里一条都没有,时间线看不到、封存也不含它查过的数。「事件本来就在同一个 运行里」此前并不成立(docs/design/orchestration-round/00-总方案.md §2)。 - engine/context + nodes/multi:子图拿到父运行的写入口(STREAM_KEY)和前缀(PREFIX_KEY),子图里的节点经它发事件,节点 id 「子工作流节点 id/…」,嵌套的一层层接上;工件按这次运行里的 id 记。子图里报的错(含被驳回)交回时节点 id 换成这一层的、 名字接上子工作流(runner 的 run.failed / run.cancelled 写「子工作流 › 那一步」),不再和父图里同名的节点混在一起 - nodes/multi:子图的台账条目加前缀并进父图 evidence(父图报告的目录据此收进子图里的查询和口径卡);子图口径卡的产出以 「子工作流节点 id/卡」进父图 nodes;出口每个字段按出口模板认出处(模板整串就是一个取值链、渲染出来正是那个值的才认, 同口径卡认输入的那一套),出自子图运行输入的按子工作流节点的「输入」模板再往父图追一层,追不到的记 subgraph_input - engine/compiler:子工作流节点自己的 node.finished 不再重记交回来的台账条目(子图那几步自己的 node.finished 已经带着) - nodes/metrics:父图口径卡读 nodes.<子图>.output.<字段>(或 assign_to 变量的字段)时接上那个字段的出处,另记 subgraph、 field;经子图出口转抄子图口径卡的指标照样带上不完整、存疑;cell(nodes.<子图>.output.<原样交出的查询结果>, …) 按台账 认出子图里的那份快照 - engine/lineage:静态认法跨子图往下追(出口模板 → 子图里接着认;子图的运行输入按「输入」模板回父图认;最多 3 层), 和运行时同一套;whole_ref 是两边共用的「整串一个取值链」判断。lint_process.load_subgraphs 往下读嵌套的子工作流, subgraph_spec 给静态认法取图;没读好放进来的照旧停在子工作流节点 - nodes/report:报告收证据的范围加上上游子工作流交回来的(evidence_from 写了子工作流节点的一并算) - nodes/io:指标出具的缺口里,停在子图里的说停在那个子工作流(和静态检查同一句) - api/evidence:卡上记的节点 id 按卡所在那一层认(_scoped:子图里的卡写子图里的 id),转抄、输入、追不到的那几步一路给到 子图里的那一格;子图里的节点按 node.started 记的名字接成「子工作流 › 节点」(node_label、subgraph_label);双盲复算条目 里的卡 id 同样换成这次运行里的 - apps/resolve:实例进度不单列子图里的步骤 - 口径账本的 use_caliber 不改;比对读封存的查询,子图里的查询如今在封存范围里,照常认得出 测试:test_subgraph_evidence(子图每一步进运行、报错点名子图里那一步、实例进度、父图口径卡跨子图追到查询格并完整出具、 子图里追不到的父图降档、不是整串取值链的停在子工作流、运行输入往父图追、嵌套两层、静态检查与运行时一致(含两层和输入 模板)、父图报告引用子图里的查询和口径卡并一路点到那一格、封存与可验证报告、子图里的遮罩照父图的运行)。去掉「交写入口」 回跑,五条失败(快照不在封存范围里)。后端全量 14472 通过 --- backend/app/api/evidence.py | 80 ++++- backend/app/engine/lineage.py | 128 +++++++- backend/app/engine/lint_process.py | 64 ++-- backend/app/engine/nodes/io.py | 6 +- backend/app/engine/nodes/metrics.py | 42 ++- backend/app/engine/nodes/multi.py | 111 ++++++- backend/app/engine/nodes/report.py | 25 +- backend/app/engine/runner.py | 12 +- backend/tests/test_subgraph_evidence.py | 375 ++++++++++++++++++++++++ 9 files changed, 782 insertions(+), 61 deletions(-) create mode 100644 backend/tests/test_subgraph_evidence.py diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 552e7a18..6c42552c 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -209,6 +209,9 @@ class _Sealed: #: 按需裁判的判定(evidence.judged 事件),按先后:(seq, 节点, 载荷)。不管落在封存前后都收—— #: 失败后接着跑的运行会把早先追加的判定封进新的清单里,它仍然是按需裁判的批注 judged: list[tuple[int, str | None, dict[str, Any]]] = field(default_factory=list) + #: 节点 id → node.started 上记的名字、类型。子工作流里的节点(「子工作流节点 id/…」)不在这次运行的图上,名字从这里取 + labels: dict[str, str] = field(default_factory=dict) + types: dict[str, str] = field(default_factory=dict) @property def sealed(self) -> bool: @@ -273,6 +276,11 @@ async def _sealed(run_id: str) -> _Sealed: out.retrievals.setdefault(str(data["artifact"]), {"node_id": node_id, "source": data.get("collection")}) elif etype == "caliber.upgrade": out.upgrades[node_id] = data + elif etype == "node.started": + if isinstance(data.get("label"), str) and data["label"]: + out.labels.setdefault(node_id, data["label"]) + if isinstance(data.get("node_type"), str) and data["node_type"]: + out.types.setdefault(node_id, data["node_type"]) if etype != "node.finished": continue if data.get("artifact"): @@ -280,7 +288,9 @@ async def _sealed(run_id: str) -> _Sealed: for entry in data.get("evidence") or []: if isinstance(entry, dict) and entry.get("artifact"): out.ledger.add((node_id, entry["artifact"])) - out.entries.append({**entry, "node_id": node_id}) + # 条目里另记的节点 id(双盲复算复算的口径卡)是那个节点所在那一层图里的 id:换成这次运行里的 + out.entries.append({**entry, "node_id": node_id, + **({"card": _scoped(node_id, entry["card"])} if entry.get("card") else {})}) artifact = str(entry["artifact"]) if entry.get("kind") == "query": out.queries[artifact] = { @@ -975,20 +985,21 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s # 期 4:这个指标做没做过双盲复算、结果如何。没有双盲复算复核过这份指标集时没有这个键,老运行一字不差 if checked := _dual_check_of(sealed, str(entry.get("node_id") or ""), artifact, metric_id, metric): step["dual_check"] = checked + card_node = str(entry.get("node_id") or "") if metric is not None and metric.get("inputs"): # 输入追没追到原始数据(engine/lineage.py):和「有出处」(确定性复算一致)是两件事,面板分开说。追不到的照实说 # 出处追到哪个节点为止;卡上没有输入(常量)的没有可追的,不给这个键 - step["untraced"] = _untraced_steps(metric, sealed) + step["untraced"] = _untraced_steps(metric, sealed, card_node) catalog = doc.get("catalog") or {} inputs: list[dict[str, Any]] = [] #: 输入指到的查询快照 → 被引用的格;同一份快照只给一个查询步骤,格合在一起高亮 wanted: dict[str, list[tuple[Any, Any]]] = {} held, word = await _metric_masks(metric or {}, str(entry.get("node_id") or ""), artifact, sealed, masks, card=card) for item in (metric or {}).get("inputs") or []: - one = {"step": "input", **item} + one = {"step": "input", **item, **_scoped_ids(item, card_node, sealed)} if item.get("through"): # 穿过原样解析查询结果的整形节点按位置取的格:面板写明经过的是哪个节点(图里的名字) - one["through_label"] = _node_label(sealed, item["through"]) + one["through_label"] = _node_label(sealed, one["through"]) artifact_of = item.get("artifact") if artifact_of and item.get("via") in ("tool_cell", "agent_field"): one["query"] = _query_alias(catalog, artifact_of) @@ -999,7 +1010,8 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s one = _withheld(one, found) inputs.append(one) # 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):跨卡往下追,接上那个指标自己的一段出处,一直到查询格子 - upstream = await _upstream_steps(metric or {}, sealed, masks, catalog, wanted, {(artifact, str(metric_id))}) + upstream = await _upstream_steps(metric or {}, sealed, masks, catalog, wanted, {(artifact, str(metric_id))}, + card_node=card_node) if withheld := {path: _held_word(found) for path, found in held.items() if found.kind != "table"}: # 代入式里那几处同样写「已遮罩」(遮没遮核对不了的写「无法核对」):不然标签里遮着、代入式里亮着,同一个值换个 # 地方照样看得到。整份结果当输入的不换:代入式里本来就只留着它的路径 @@ -1015,7 +1027,7 @@ async def _metric_chain(seg: dict[str, Any], cite: dict[str, Any], entry: dict[s async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Masks", catalog: dict[str, Any], wanted: dict[str, list[tuple[Any, Any]]], seen: set[tuple[str, str]], - depth: int = 1) -> list[dict[str, Any]]: + depth: int = 1, *, card_node: str = "") -> list[dict[str, Any]]: """指标的输入里转抄另一张口径卡的指标的(nodes.<卡>.metrics[i].value,卡上记着 upstream),每个接一步 upstream:那张 卡的那个指标(名字、值、式子、代入式、核对状态,取值按同一套遮罩),它自己的输入(同指标步骤的输入;落在查询快照上的 并进 wanted,后面照常给查询步骤),追没追到原始数据。转抄的转抄一层层接着追(防环:同一个指标只接一次;最多 @@ -1033,7 +1045,8 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask if (artifact, metric_id) in seen: continue seen.add((artifact, metric_id)) - node = str(item.get("node_id") or "") + # 卡上记的是卡所在那一层图里的 id(子工作流里的卡转抄同一张子图里的卡;经子工作流出口转抄的已经是这一层的) + node = str(_scoped(card_node, item.get("node_id")) or "") card, hash_ok = _load_card(artifact) found = _metric_in(card, metric_id) step: dict[str, Any] = { @@ -1060,7 +1073,7 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask if found.get("incomplete") else {}), **({"sql_check_failed": True, "sql_check_reason": found.get("sql_check_reason") or ""} if found.get("sql_check_failed") else {}), - "untraced": _untraced_steps(found, sealed), + "untraced": _untraced_steps(found, sealed, node), }) if withheld := {path: _held_word(h) for path, h in held.items() if h.kind != "table"}: step["substituted"] = recompute.substituted_without(found, withheld) @@ -1068,7 +1081,7 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask for sub in found.get("inputs") or []: if not isinstance(sub, dict): continue - one = {"step": "input", **sub} + one = {"step": "input", **sub, **_scoped_ids(sub, node, sealed)} if sub.get("artifact") and sub.get("via") in SNAPSHOT_VIAS: one["query"] = _query_alias(catalog, sub["artifact"]) if cell := _input_cell(sub, catalog): @@ -1079,27 +1092,60 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask inputs.append(one) step["inputs"] = inputs out.append(step) - out += await _upstream_steps(found, sealed, masks, catalog, wanted, seen, depth + 1) + out += await _upstream_steps(found, sealed, masks, catalog, wanted, seen, depth + 1, card_node=node) + return out + + +def _scoped(card_node: Any, node_id: Any) -> Any: + """口径卡上记的节点 id(输入来自哪个节点、穿过的整形节点、转抄的卡)是卡所在那一层图里的 id:子工作流里的卡 + (节点 id「子工作流节点 id/卡」)记的是子图里的 id,换成这次运行里的(接上同一个前缀)。最外层的卡原样;经子工作流 + 出口接上的出处(multi.run_subgraph 记的)已经是卡这一层的 id,同样接上卡的前缀就对了。""" + card = str(card_node or "") + if not isinstance(node_id, str) or not node_id or "/" not in card: + return node_id + return f"{card.rsplit('/', 1)[0]}/{node_id}" + + +def _scoped_ids(item: dict[str, Any], card_node: str, sealed: _Sealed) -> dict[str, Any]: + """输入步骤上的节点 id 换成这次运行里的(_scoped);落在子工作流里的另给图上的名字(node_label、subgraph_label: + 「子工作流 › 节点」),画布、记录页认不出这个 id 时照它写。最外层的输入不多带键,和以前一字不差。""" + out: dict[str, Any] = {} + for key in ("node_id", "through", "subgraph"): + if isinstance(item.get(key), str) and item[key]: + out[key] = _scoped(card_node, item[key]) + if "/" in str(out.get("node_id") or ""): + out["node_label"] = _node_label(sealed, out["node_id"]) + if out.get("subgraph"): + out["subgraph_label"] = _node_label(sealed, out["subgraph"]) return out def _node_label(sealed: _Sealed, node_id: Any) -> str: - """封存的图里这个节点的名字,没有名字的写 id。""" + """封存的图里这个节点的名字,没有名字的写 id。子工作流里的节点(「子工作流节点 id/…」)不在图上,按 node.started 记的 + 名字一层层接上:「子工作流 › 节点」。""" node = next((n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id), {}) - return str((node.get("data") or {}).get("label") or node_id or "") + if node or not isinstance(node_id, str) or "/" not in node_id: + return str((node.get("data") or {}).get("label") or node_id or "") + parts = node_id.split("/") + names = [_node_label(sealed, parts[0])] + for i in range(2, len(parts) + 1): + here = "/".join(parts[:i]) + names.append(sealed.labels.get(here) or parts[i - 1]) + return " › ".join(names) -def _untraced_steps(metric: dict[str, Any], sealed: _Sealed) -> list[dict[str, Any]]: +def _untraced_steps(metric: dict[str, Any], sealed: _Sealed, card_node: str = "") -> list[dict[str, Any]]: """指标的输入里追不到原始数据的:[{path, node_id, label, type}],label 是封存的图里那个节点的名字、type 是节点类型 - (面板按它写「数据整形」「沙箱代码」)。认不出是哪个节点的,node_id、label 为 None。""" + (面板按它写「数据整形」「沙箱代码」)。认不出是哪个节点的,node_id、label 为 None。节点 id 按卡所在那一层认 + (_scoped)。""" nodes = {n.get("id"): n for n in (sealed.graph or {}).get("nodes") or [] if isinstance(n, dict)} out = [] for item in untraced(metric.get("inputs") or []): - node_id = item.get("node_id") + node_id = _scoped(card_node, item.get("node_id")) node = nodes.get(node_id) or {} out.append({"path": item.get("path"), "node_id": node_id, "label": _node_label(sealed, node_id) if node_id else None, - "type": node.get("type") or None}) + "type": node.get("type") or sealed.types.get(str(node_id or "")) or None}) return out @@ -1495,6 +1541,8 @@ class _InputMasks: return None, True def _output(self, node_id: Any) -> Any: + # 卡的取值链里写的是卡所在那一层图里的 id(子工作流里的卡写子图里的 id),换成这次运行里的再找 + node_id = _scoped(self._card, node_id) if self._given is not None: return self._given(node_id) if self._outputs is None: diff --git a/backend/app/engine/lineage.py b/backend/app/engine/lineage.py index a7eec4fd..93630579 100644 --- a/backend/app/engine/lineage.py +++ b/backend/app/engine/lineage.py @@ -13,6 +13,16 @@ - 应用检查(apps/lint)、受管级发布检查(governance):按 static_stops 静态地认(只有图、没有运行),追不到的给提示。 转抄同一张图里另一张口径卡的指标,和运行时一样跨卡按那个指标的式子接着认(最多 MAX_COPY_DEPTH 层);被抄的卡按 caliber_from 固定在别处的版本时,静态看不到那一版的图,照旧停在那张卡。 + +读子工作流出口的字段(nodes.<子图>.output.<字段>,docs/design/orchestration-round/00-总方案.md §2)跨子图往下追,三处同一套: +- 运行时:子图跑完时按出口模板认每个字段的出处(multi.run_subgraph 的 provenance:模板整串就是一个取值链的字段,在子图 + 的状态里按口径卡认输入的同一套认;出自子图运行输入的再按子工作流节点的「输入」模板往父图追一层),父图口径卡读这个 + 字段时接上它(metrics._subgraph_field),卡上记的就是子图里那一格的出处(via、artifact、locator、status),另记 + subgraph、field——traced / untraced 照常认;子图里追不到原始数据的,父图照样追不到、照样降档; +- 证据面板:卡上记的节点 id 按卡所在的那一层认(api/evidence 的 _scoped),一路给到子图里的那一格; +- 静态:按子工作流节点固定的那一版图(lint_process.subgraph_spec,调用方用 with_subgraphs 放进来)认出口字段的模板,接着 + 在子图里认;子图的运行输入按子工作流节点的「输入」模板回到父图认。最多 MAX_SUB_DEPTH 层(和运行时的嵌套上限一样), + 看不到子图的(没放进来、读不到)照旧停在子工作流节点。 """ from __future__ import annotations @@ -140,20 +150,52 @@ class Stop: node_id: str | None +#: 出口字段的模板整串就是一个取值链:{{ nodes.card.metrics[0].value }}(不带过滤器、不调函数、前后不带别的字) +_WHOLE_REF = re.compile(r"^\s*\{\{\s*((?:nodes|vars|input)\.[^{}|()]+?)\s*\}\}\s*$", re.S) + + +def whole_ref(template: Any) -> str | None: + """模板整串就是一个取值链时交回它(成果字段的值就是那个值本身,渲染只是转成文字);别的写法为 None:拼了别的字、 + 过了过滤器的值,对不上那一格的值。子工作流出口的出处,运行时(multi._output_provenance)和静态(_exit_template) + 都按这一个判断认。""" + m = _WHOLE_REF.match(template) if isinstance(template, str) else None + return m.group(1).strip() if m else None + + +#: 静态地跨子工作流往下追的层数上限:和运行时子工作流的嵌套上限(multi._MAX_DEPTH)一样 +MAX_SUB_DEPTH = 3 + + +@dataclass(frozen=True) +class _Scope: + """静态地认时所在的那一层图:节点 id → {type, title?, config}。是子工作流时记着父图那一层和它在父图里的节点 id(子图的 + 运行输入按那个子工作流节点的「输入」模板回到父图认)。""" + + nodes: Mapping[str, Mapping[str, Any]] + parent: "_Scope | None" = None + via: str | None = None + level: int = 0 + + def static_stops(nodes: Mapping[str, Mapping[str, Any]], expression: str, *, _depth: int = 0) -> list[Stop]: """静态地认一个指标表达式里追不到原始数据的输入。nodes 是节点 id → {type, config}(apps/tree.node_map 的形状)。 和运行时同一套认法,只是不看值:cell() 的第一个参数是查询结果(调用工具、合并查询的产出,或原样解析它的整形节点)、 按位置取的格穿过原样解析的整形节点、运行输入、开了按出处核对字段的 Agent 交来的字段,算追到了;转抄同一张图里 - 另一张口径卡的指标,按那个指标的式子接着认(它的输入都追到了才算);别的(表达式整形、沙箱代码、模型调用、转抄 - 固定在别处版本的口径卡……)停在写它的那个节点。表达式写错了的不在这里报(画布的校验报)。""" + 另一张口径卡的指标,按那个指标的式子接着认(它的输入都追到了才算);读子工作流出口的字段,按那一版子图的出口模板 + 接着认(_through_sub);别的(表达式整形、沙箱代码、模型调用、转抄固定在别处版本的口径卡、看不到的子图……)停在 + 写它的那个节点。表达式写错了的不在这里报(画布的校验报)。""" + return _stops(_Scope(nodes), expression, _depth) + + +def _stops(scope: _Scope, expression: str, depth: int) -> list[Stop]: try: tree = parse_expression(expression)[0] except ExpressionError: return [] out: list[Stop] = [] for path in leaf_refs(tree): - stop = _static_stop(nodes, path, _depth) + stop = _static_stop(scope, path, depth) if stop is not None: out.append(stop) return out @@ -179,16 +221,17 @@ def _copied_metric(nodes: Mapping[str, Mapping[str, Any]], path: str) -> tuple[s return writers[0], str(metrics[index]["expression"]) -def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int = 0) -> Stop | None: +def _static_stop(scope: _Scope, path: str, depth: int = 0) -> Stop | None: + nodes = scope.nodes type_of = lambda node_id: (nodes.get(node_id) or {}).get("type") # noqa: E731 if (copied := _copied_metric(nodes, path)) is not None: card_id, expression = copied - if depth < MAX_COPY_DEPTH and not static_stops(nodes, expression, _depth=depth + 1): + if depth < MAX_COPY_DEPTH and not _stops(scope, expression, depth + 1): return None return Stop(path, card_id) if (parts := cell_parts(path)) is not None: writers = _writers(nodes, parts[0]) - bad = [w for w in writers if not _table(nodes, w, type_of)] + bad = [w for w in writers if not _table(nodes, w, type_of) and not _sub_table(scope, w, parts[0])] return Stop(path, bad[0]) if bad or not writers else None if (at := row_cell(path)) is not None: writers = _writers(nodes, at.table) @@ -197,7 +240,7 @@ def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int = return None head = _REF_HEAD.match(path) if head and head.group(1) == "input": - return None + return None if _from_parent(scope, head.group(2), depth) else Stop(path, scope.via) writers = _writers(nodes, path) if not writers: return Stop(path, None) @@ -205,13 +248,84 @@ def _static_stop(nodes: Mapping[str, Mapping[str, Any]], path: str, depth: int = for writer in writers: node = nodes[writer] if node.get("type") == "input": + if head and head.group(1) == "vars" and not _from_parent(scope, head.group(2), depth): + return Stop(path, scope.via) continue if node.get("type") == "agent" and _cited(node, head.group(1) if head else "", field): continue + if node.get("type") == "subgraph" and head and _through_sub(scope, writer, head, depth): + continue return Stop(path, writer) return None +def _from_parent(scope: _Scope, name: str, depth: int) -> bool: + """运行输入的一个字段算不算原始数据:最外层的是(发起运行的人填的);子工作流的运行输入是父图按子工作流节点的「输入」 + 模板渲染出来的,那个模板整串就是一个取值链、在父图里追得到原始数据,才算(同运行时 multi._lifted)。""" + if scope.parent is None: + return True + templates = ((scope.parent.nodes.get(scope.via or "") or {}).get("config") or {}).get("input") + path = whole_ref(templates.get(name)) if isinstance(templates, dict) and name else None + return path is not None and _static_stop(scope.parent, path, depth + 1) is None + + +def _sub_scope(scope: _Scope, node_id: str) -> _Scope | None: + """子工作流节点 node_id 引用的那一版图(lint_process.subgraph_spec 读好放进来的),看不到、超过层数上限的为 None。""" + if scope.level >= MAX_SUB_DEPTH: + return None + from app.engine.lint_process import subgraph_spec + + sub = subgraph_spec((scope.nodes.get(node_id) or {}).get("config") or {}) + if sub is None: + return None + nodes = {n.id: {"type": str(n.type), "title": n.title, "config": n.config} for n in sub.nodes} + return _Scope(nodes, parent=scope, via=node_id, level=scope.level + 1) + + +def _exit_template(sub: _Scope, key: str) -> str | None: + """子图出口字段 key 的模板整串所写的取值链(whole_ref);没有这个字段、几个成果节点写法不一样、不是整串一个 + 取值链的为 None。""" + found = {whole_ref(f.get("value", "")) for node in sub.nodes.values() if node.get("type") == "output" + for f in (node.get("config") or {}).get("fields") or [] if isinstance(f, dict) and f.get("name") == key} + return found.pop() if len(found) == 1 else None + + +def _sub_key(head: re.Match[str], node: Mapping[str, Any]) -> str | None: + """nodes.<子图>.output.<字段> / vars.<子图的 assign_to>.<字段> 读的出口字段名;不是正好一个字段的为 None。""" + root, name, rest = head.group(1), head.group(2), head.group(3) + key = re.sub(r"\[\s*['\"]([^'\"\]]+)['\"]\s*\]", r".\1", rest).strip(".") + if root == "nodes": + if not key.startswith("output."): + return None + key = key[len("output."):] + elif root != "vars" or str((node.get("config") or {}).get("assign_to") or "").strip() != name: + return None + return key if key and "." not in key and "[" not in key else None + + +def _through_sub(scope: _Scope, writer: str, head: re.Match[str], depth: int) -> bool: + """读子工作流出口的一个字段:那个字段在子图里追得到原始数据(出口模板整串就是一个取值链,接着在子图里认)。""" + key = _sub_key(head, scope.nodes.get(writer) or {}) + sub = _sub_scope(scope, writer) if key else None + inner = _exit_template(sub, key) if sub is not None and key else None + return inner is not None and _static_stop(sub, inner, depth + 1) is None + + +def _sub_table(scope: _Scope, writer: str, source: str) -> bool: + """cell() 的第一个参数是子工作流出口原样交出的一份查询结果(出口字段的模板整串就是 {{ nodes.<查询节点> }})。""" + head = _REF_HEAD.match(source) + if not head or (scope.nodes.get(writer) or {}).get("type") != "subgraph": + return False + key = _sub_key(head, scope.nodes.get(writer) or {}) + sub = _sub_scope(scope, writer) if key else None + inner = _exit_template(sub, key) if sub is not None and key else None + if inner is None: + return False + writers = _writers(sub.nodes, inner) + type_of = lambda node_id: (sub.nodes.get(node_id) or {}).get("type") # noqa: E731 + return bool(writers) and all(_table(sub.nodes, w, type_of) or _sub_table(sub, w, inner) for w in writers) + + def _writers(nodes: Mapping[str, Mapping[str, Any]], source: str) -> list[str]: """取值链的根是谁写的:nodes.X 是 X;vars.X 是 assign_to 为 X 的节点(入口节点声明了字段 X 的也算)。""" head = _REF_HEAD.match(source) diff --git a/backend/app/engine/lint_process.py b/backend/app/engine/lint_process.py index e7ccac9d..58cb9f45 100644 --- a/backend/app/engine/lint_process.py +++ b/backend/app/engine/lint_process.py @@ -147,9 +147,17 @@ class Subgraphs(dict): self.pin_problems: list[Any] = [] +#: 嵌套的子工作流往下读几层:和运行时的嵌套上限(multi._MAX_DEPTH)一样,再深的运行时本来就拒 +_LOAD_DEPTH = 3 + + async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs: - """读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图)。读不到、不成形的跳过。 - 固定的版本能不能用也一并读好(Subgraphs.pin_problems)。""" + """读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图),子图里再嵌套的一层层往下读(最多 + _LOAD_DEPTH 层,同一张图只读一次,互相引用的不会转圈)。读不到、不成形的跳过。固定的版本能不能用也一并读好 + (Subgraphs.pin_problems,只看这张图自己的子工作流节点)。 + + 拿去用的:人工节点在子工作流里(_subgraph_humans)、口径卡的输入跨子工作流追到原始数据(engine/lineage 的静态认法, + subgraph_spec)。""" from sqlalchemy import select from app.apps.versions import subgraph_pin_problems @@ -157,24 +165,39 @@ async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs: out = Subgraphs() out.pin_problems = await subgraph_pin_problems(session, spec) - for node in spec.nodes: - if node.type != NodeType.SUBGRAPH: - continue - wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version") - version = int(pinned) if str(pinned or "").isdigit() else None - if not wid or (str(wid), version) in out: - continue - query = (select(WorkflowVersion.graph).where(WorkflowVersion.workflow_id == str(wid), - WorkflowVersion.version == version) - if version is not None else select(Workflow.graph).where(Workflow.id == str(wid))) - graph = (await session.execute(query)).scalar_one_or_none() - try: - out[(str(wid), version)] = GraphSpec.model_validate(graph) - except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时自己会说清楚 - continue + todo = [(spec, 0)] + while todo: + current, level = todo.pop(0) + for node in current.nodes: + if node.type != NodeType.SUBGRAPH: + continue + wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version") + version = int(pinned) if str(pinned or "").isdigit() else None + if not wid or (str(wid), version) in out: + continue + query = (select(WorkflowVersion.graph).where(WorkflowVersion.workflow_id == str(wid), + WorkflowVersion.version == version) + if version is not None else select(Workflow.graph).where(Workflow.id == str(wid))) + graph = (await session.execute(query)).scalar_one_or_none() + try: + out[(str(wid), version)] = GraphSpec.model_validate(graph) + except Exception: # noqa: BLE001 - 读不到、不成形:子工作流节点运行时自己会说清楚 + continue + if level + 1 < _LOAD_DEPTH: + todo.append((out[(str(wid), version)], level + 1)) return out +def subgraph_spec(config: Mapping[str, Any]) -> GraphSpec | None: + """子工作流节点(配置 config)引用的那一版图:load_subgraphs 读好、with_subgraphs 放进来的;没放进来、没读到的为 None。""" + graphs = _SUBGRAPHS.get() + if graphs is None: + return None + wid, pinned = config.get("workflow_id"), config.get("workflow_version") + version = int(pinned) if str(pinned or "").isdigit() else None + return graphs.get((str(wid or ""), version)) + + @contextlib.contextmanager def with_subgraphs(graphs: Mapping[tuple[str, int | None], GraphSpec]) -> Iterator[None]: token = _SUBGRAPHS.set(graphs) @@ -222,12 +245,7 @@ def _agent_tool_sets(node: GraphNode) -> list[tuple[str, Mapping[str, Any], list def _subgraph_humans(spec: GraphSpec, node: GraphNode) -> list[str] | None: - graphs = _SUBGRAPHS.get() - if graphs is None: - return None - wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version") - version = int(pinned) if str(pinned or "").isdigit() else None - sub = graphs.get((str(wid or ""), version)) + sub = subgraph_spec(node.config) if sub is None: return None return [n.title for n in sub.nodes if n.type == NodeType.HUMAN] diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index a1b956af..ff53d3ca 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -351,8 +351,10 @@ def _untraced_gaps(metrics: list[dict[str, Any]], ctx: NodeContext) -> list[str] stops = untraced(m.get("inputs") or []) if not stops: continue - where = "、".join(dict.fromkeys(f"{s.get('path')}(停在「{titles.get(s.get('node_id') or '', s.get('node_id') or '未知来源')}」)" - for s in stops[:3])) + # 停在子工作流里的(节点 id「子工作流节点 id/…」)说停在那个子工作流:这张图上看得到的是它,静态检查也这么说 + where = "、".join(dict.fromkeys( + f"{s.get('path')}(停在「{titles.get(at, at or '未知来源')}」)" + for s in stops[:3] for at in [str(s.get('node_id') or '').split('/', 1)[0]])) out.append(f"指标「{m.get('name') or m.get('id')}」的输入没追到原始数据:{where}") return out diff --git a/backend/app/engine/nodes/metrics.py b/backend/app/engine/nodes/metrics.py index 740d545b..85a9aa6d 100644 --- a/backend/app/engine/nodes/metrics.py +++ b/backend/app/engine/nodes/metrics.py @@ -624,6 +624,9 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, - vars.q.rows[0][0],q 由「只是把调用工具节点的结果原样按 JSON 解析」的整形节点写:同 cell(),via tool_cell, 另记 through(穿过的整形节点) - agent 开了 cite_fields 的字段:via agent_field,接上它的 data_evidence(eid、核对状态) + + 读子工作流出口的一个字段(nodes.<子图>.output.<字段>、vars.<子图的 assign_to>.<字段>):接上子图记下的那个字段的出处 + (_subgraph_field),via 等照子图里认出的写,另记 subgraph、field;认不出的照旧记 via subgraph,停在子工作流。 """ if (parts := cell_parts(path)) is not None: return _cell_input(path, value, parts, state, ctx, tctx or template_context(state)) @@ -644,6 +647,9 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, else: node_id = _writer_of(name, state, ctx) producer = nodes.get(node_id) if node_id else None + if producer is not None and str(producer.type) == "subgraph" and head \ + and (linked := _subgraph_field(path, value, root, field, producer.id, state)) is not None: + return linked entry: dict[str, Any] = { "path": path, "value": value if value is None or isinstance(value, (int, float, str, bool)) else _brief(value), @@ -667,6 +673,32 @@ def _input_of(path: str, value: Any, state: GraphState, ctx: NodeContext, return entry +def _subgraph_field(path: str, value: Any, root: str, field: str, node_id: str, + state: GraphState) -> dict[str, Any] | None: + """读子工作流出口的一个字段:接上子图记下的那个字段的出处(multi.run_subgraph 的 provenance,节点 id 已经是这一层图里 + 的「子工作流节点 id/…」)。只认整个字段(nodes.<子图>.output.<字段>、vars..<字段>,不再往里取),而且这次 + 读到的值就是子图交出的那个;别的为 None,照一般的来历记(via subgraph,停在子工作流)。 + + 接上的出处照子图里认出的写(via、node_id、artifact、locator、eid、status、upstream),path、value 换成这里读的,另记 + subgraph(经过的子工作流节点)、field(出口字段):engine/lineage.traced 照常认,证据面板照常一路给到子图里的那一格。""" + result = (state.get("nodes") or {}).get(node_id) + if not isinstance(result, dict) or not isinstance(result.get("provenance"), dict): + return None + key = _field_key(field) + if root == "nodes": + if not key.startswith("output."): + return None + key = key[len("output."):] + elif root != "vars": + return None + output = result.get("output") if isinstance(result.get("output"), dict) else {} + item = result["provenance"].get(key) + if not key or not isinstance(item, dict) or key not in output or output[key] != value: + return None + return {**item, "path": path, "value": value if value is None or isinstance(value, (int, float, str, bool)) + else _brief(value), "subgraph": node_id, "field": key} + + def _copied_card(path: str, tctx: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: """转抄另一张口径卡一个指标的输入(engine/lineage.metric_copy 认的写法):(那张卡这一次的产出, 那个指标)。不是这种 写法、产出不是指标集、下标越界的为 None。""" @@ -702,9 +734,17 @@ def _upstream(path: str, tctx: dict[str, Any]) -> dict[str, Any] | None: def _copied_metric(item: dict[str, Any], tctx: dict[str, Any]) -> dict[str, Any] | None: - """卡上的一个输入转抄了另一张口径卡的哪个指标(这一次的产出里那一项);不是转抄的为 None。""" + """卡上的一个输入转抄了另一张口径卡的哪个指标(这一次的产出里那一项);不是转抄的为 None。 + + 经子工作流出口转抄的(subgraph,子图出口字段就是子图里一张卡的指标):那张卡以「子工作流节点 id/卡」进了这一层的 + nodes(multi.run_subgraph),按 node_id 和 upstream 记的指标 id 找。""" if item.get("via") != "metric" or not isinstance(item.get("upstream"), dict): return None + if item.get("subgraph"): + card = (tctx.get("nodes") or {}).get(str(item.get("node_id") or "")) + metrics = card.get("metrics") if isinstance(card, dict) and card.get("kind") == "metric_set" else None + wanted = item["upstream"].get("metric") + return next((m for m in metrics or [] if isinstance(m, dict) and m.get("id") == wanted), None) found = _copied_card(str(item.get("path") or ""), tctx) return found[1] if found is not None else None diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index c83efada..80b20064 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -14,7 +14,8 @@ from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow -from app.engine.context import NodeContext, NodeError +from app.engine.context import PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer +from app.engine.lineage import whole_ref from app.engine.state import GraphState, message_text, template_context from app.engine.toolcalls import ( TOOL_MARKUP_NUDGE, @@ -616,8 +617,17 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: """嵌套执行另一个工作流。 - 子图共享同一个 run 的事件流(节点 id 会加前缀),所以在时间线上能看到它内部 - 每一步;但它有独立的状态,不会污染父图的变量池。 + 子图共享同一个 run 的事件流:节点 id 加前缀「子工作流节点 id/」(嵌套的一层层接上),事件经父运行交下来的写入口发 + (engine/context.STREAM_KEY;以前只设了前缀没用上,子图里每一步在运行的事件里一条都没有),时间线上看得到它内部每一步, + 封存也就覆盖了它查过的数。它有独立的状态,不会污染父图的变量池。 + + 证据交回父图(docs/design/orchestration-round/00-总方案.md §2),三样: + - 台账:子图记下的条目(查询快照、口径卡的指标集……)节点 id 加上前缀并进父图的 evidence,父图报告撰写的目录据此收进 + 子图里的查询和口径卡(report.report_catalog),cell(nodes.<子图>.output.<原样交出的查询结果>, …) 也按台账认得出快照; + - 口径卡:子图里口径卡的产出以「子工作流节点 id/卡」为 id 进父图的 nodes(目录要卡上的指标值;父图转抄它的指标时认得 + 出那张卡、带上不完整和存疑); + - 出处:出口的每个字段按出口模板认它来自哪里(_output_provenance),记在产出的 provenance 上;父图里读 + nodes.<子图>.output.<字段> 的口径卡据此接上子图里的那一格(metrics._subgraph_field),engine/lineage 同一套认法。 """ from app.engine.compiler import compile_graph # 延迟导入,避免循环依赖 from app.engine.schema import GraphSpec, loop_steps @@ -678,9 +688,10 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: approval_default=ctx.run.approval_default, tool_trust=ctx.run.tool_trust, agent_limits=ctx.run.agent_limits, - # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名) + # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context: + # 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀 extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")}, - "node_prefix": f"{ctx.node.id}/"}, + PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", STREAM_KEY: stream_writer(ctx.run)}, ) ctx.emit(EventType.LOG, level="info", @@ -711,6 +722,13 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: f"子工作流「{workflow.name}」已执行 {limit} 步仍未结束,已中止。可能存在没有轮数上限的环路," "请在子工作流中用「循环」节点限制轮数", ) from e + except NodeError as e: + # 子图里的节点报的错(含被驳回):节点 id 换成这一层图里的 id(「子工作流节点 id/…」),名字接上子工作流的名字。 + # 以前带的是子图里的 id,运行记下的出错节点会和父图里同名的节点混在一起 + titles = sub_spec.node_map() + inner = getattr(e, "label", None) or (titles[e.node_id].title if e.node_id in titles else e.node_id) + e.node_id, e.label = f"{ctx.node.id}/{e.node_id}", f"{ctx.node.title} › {inner}" + raise output = result_state.get("output") or {} result = { @@ -721,13 +739,94 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "pinned": bool(pinned), "text": json.dumps(output, ensure_ascii=False) if output else "", } + if provenance := _output_provenance(sub_spec, sub_run, result_state, output, state, ctx): + # 出口每个字段的出处(没有能认的字段时不带这个键,产出和以前一字不差) + result["provenance"] = provenance ctx.emit(EventType.LOG, level="info", message=f"子工作流「{workflow.name}」完成") + sub_nodes = result_state.get("nodes") or {} + cards = {f"{ctx.node.id}/{nid}": payload for nid, payload in sub_nodes.items() + if isinstance(payload, dict) and payload.get("kind") == "metric_set"} updates: dict[str, Any] = { - "nodes": {ctx.node.id: result}, + "nodes": {ctx.node.id: result, **cards}, "usage": result_state.get("usage") or {}, } + if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]: + updates["evidence"] = ledger var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: output} return updates + + +#: 台账条目里写着节点 id 的键:交回父图时一并加前缀(双盲复算的条目记着它复算的口径卡) +_LEDGER_NODE_KEYS = ("node_id", "card") + + +def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]: + """子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。""" + out = dict(entry) + for key in _LEDGER_NODE_KEYS: + if isinstance(out.get(key), str) and out[key]: + out[key] = f"{node_id}/{out[key]}" + return out + + +def _output_provenance(sub_spec: Any, sub_run: Any, sub_state: dict[str, Any], output: dict[str, Any], + state: GraphState, ctx: NodeContext) -> dict[str, dict[str, Any]]: + """子图出口每个字段的出处:{字段: 口径卡上一个输入的样子(via、node_id、artifact、locator、status、upstream……)}。 + + 认法和口径卡认它的输入同一个(metrics._input_of),在子图的状态里认:字段的模板整串就是一个取值链(whole_ref),而且 + 渲染出来的值正是那个值转成的文字,才算这个字段就是那个值;拼了别的字、过了过滤器的不认(不在结果里)。几个成果节点 + 写同一个字段时按最后跑的那个。认出来的节点 id 换成父图这一层的(加「子工作流节点 id/」前缀)。 + + 出自子图运行输入的(via input)再往父图追一层:子图的输入是父图按子工作流节点的「输入」模板渲染出来的,那个模板整串 + 也是一个取值链时,按父图的状态接着认(追到父图的运行输入、查询格……);否则记 via subgraph_input,停在子工作流的 + 入口——子图的运行输入对父图不是原始数据。""" + from app.engine.expressions import _stringify + from app.engine.nodes.metrics import _input_of, _value_of + from app.engine.schema import NodeType + + outputs = {n.id: n for n in sub_spec.nodes if n.type == NodeType.OUTPUT} + fields: dict[str, tuple[Any, str]] = {} + for step in sub_state.get("trail") or []: + exit_node = outputs.get(step.get("node_id")) if "error" not in step else None + for field in (exit_node.config.get("fields") or []) if exit_node is not None else []: + if isinstance(field, dict) and field.get("name"): + fields[str(field["name"])] = (exit_node, field.get("value", "")) + tctx = template_context(sub_state) + out: dict[str, dict[str, Any]] = {} + for name, (exit_node, template) in fields.items(): + path = whole_ref(template) + if path is None or name not in output: + continue + value = _value_of(path, tctx) + if _stringify(value) != output[name]: + continue + item = _input_of(path, value, sub_state, NodeContext(node=exit_node, run=sub_run), tctx) + out[name] = _lifted(item, state, ctx) + return out + + +def _lifted(item: dict[str, Any], state: GraphState, ctx: NodeContext) -> dict[str, Any]: + """子图里认出的一个出处换到父图这一层:节点 id 加前缀;出自子图运行输入的,按子工作流节点的「输入」模板往父图追。""" + from app.engine.expressions import _stringify + from app.engine.nodes.metrics import _REF_HEAD, _input_of, _value_of + + if item.get("via") == "input": + head = _REF_HEAD.match(str(item.get("path") or "")) + name = head.group(2) if head else "" + templates = ctx.cfg("input", {}) or {} + path = whole_ref(templates.get(name)) if isinstance(templates, dict) and name else None + if path is not None: + parent = template_context(state) + value = _value_of(path, parent) + if _stringify(value) == _stringify(item.get("value")): + return _input_of(path, value, state, ctx, parent) + return {"path": item.get("path"), "value": item.get("value"), "node_id": ctx.node.id, "via": "subgraph_input", + **({"field": name} if name else {}), "status": item.get("status") or "ok"} + out = dict(item) + for key in ("node_id", "through", "subgraph"): + if isinstance(out.get(key), str) and out[key]: + out[key] = f"{ctx.node.id}/{out[key]}" + return out diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index 5a33171f..b2596b6f 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -37,6 +37,7 @@ import asyncio import re import time from collections import Counter +from collections.abc import Iterable from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage @@ -140,16 +141,18 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[ if isinstance(sources, str) and sources != "ancestors": sources = [sources] if isinstance(sources, list): - # evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管 + # evidence_from 只管查询、检索这类证据(连同查询当时的表结构)从哪几个节点来;指标从哪来由 metrics_from 管。 + # 写了子工作流节点的,它交回来的(子图里的查询,节点 id「子工作流节点 id/…」)一并算 keep = {str(s) for s in sources} ledger = [e for e in ledger - if e.get("kind") not in ("query", "retrieval", "schema") or e.get("node_id") in keep] + if e.get("kind") not in ("query", "retrieval", "schema") or _outer(e.get("node_id")) in keep] + nodes = state.get("nodes") or {} catalog = build_catalog( - nodes=state.get("nodes") or {}, + nodes=nodes, ledger=ledger, inputs=state.get("input") or {}, metrics_from=_setting(spec, node, "metrics_from") or None, - allowed=_ancestors(spec, node.id), + allowed=with_subgraphs(_ancestors(spec, node.id), [*nodes, *(e.get("node_id") for e in ledger)]), ) if report_entities(spec, node) == "off": # 目录里没有表和字段,写作目录也就不列;compose / verify 另传 entities=False,解析不了的原因照实说 @@ -161,6 +164,20 @@ def report_catalog(state: GraphState, spec: GraphSpec, node: GraphNode) -> dict[ return catalog +def _outer(node_id: Any) -> str: + """节点 id 在这一层图上是哪个节点:子工作流交回来的「子工作流节点 id/…」是那个子工作流节点,别的原样。""" + return str(node_id or "").split("/", 1)[0] + + +def with_subgraphs(allowed: set[str], ids: Iterable[Any]) -> set[str]: + """报告收证据的范围(祖先节点)加上范围里的子工作流交回来的那些(multi.run_subgraph:台账条目、口径卡的节点 id 是 + 「子工作流节点 id/…」,嵌套的一层层接上):子工作流在报告上游,它里面查过的数、算过的口径卡也在上游 + (docs/design/orchestration-round/00-总方案.md §2)。""" + out = set(allowed) + out.update(i for i in ids if isinstance(i, str) and "/" in i and _outer(i) in allowed) + return out + + def _masked_word() -> str: """「已遮罩」:证据面板、推演同一个说法。api.evidence 导入了引擎里的模块,用到时再导入。""" from app.api.evidence import MASKED diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 27fea92e..e10d2d0a 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -1360,7 +1360,7 @@ def rejected_data(exc: NodeRejected, spec: GraphSpec) -> dict[str, Any]: """被驳回的 run.cancelled 带什么:原因(rejected)、在哪一步(节点 id 和图上的名字)、哪种关卡、结论(驳回 / 超时 驳回)、谁、备注。进封存:谁在哪一步驳回、为什么,事后查得到。""" node = spec.node_map().get(exc.node_id) - return {"reason": END_REJECTED, "node_id": exc.node_id, "label": node.title if node else exc.node_id, + return {"reason": END_REJECTED, "node_id": exc.node_id, "label": _label_of(exc, node), "kind": exc.kind, "decision": exc.decision, "actor": exc.actor, "note": exc.note} @@ -1390,10 +1390,18 @@ def _failure(exc: BaseException, spec: GraphSpec) -> tuple[str, tuple[EventType, data["detail"] = detail if node_id: node = spec.node_map().get(node_id) - data.update(node_id=node_id, label=node.title if node else node_id) + data.update(node_id=node_id, label=_label_of(exc, node)) return message, (EventType.RUN_FAILED, data) +def _label_of(exc: BaseException, node: Any) -> str: + """出错、被驳回的那一步在图上叫什么。子工作流里的那一步(节点 id「子工作流节点 id/…」)不在这张图上,名字由 + multi.run_subgraph 接好记在异常上(「子工作流 › 那一步」)。""" + if node is not None: + return node.title + return str(getattr(exc, "label", None) or getattr(exc, "node_id", "") or "") + + async def _approval_default(*, run_class: str) -> str: """工具审批策略的全局默认,取自设置里的「危险工具默认需要人工确认」。 diff --git a/backend/tests/test_subgraph_evidence.py b/backend/tests/test_subgraph_evidence.py new file mode 100644 index 00000000..572d1634 --- /dev/null +++ b/backend/tests/test_subgraph_evidence.py @@ -0,0 +1,375 @@ +"""子工作流的证据交回父图(docs/design/orchestration-round/00-总方案.md §2)。 + +以前子图只把出口的值交回父图:子图里的查询快照、口径卡留在子图自己的状态里,子图里每一步在运行的事件里一条都没有(只设了 +节点 id 前缀没用上,LangGraph 的 ainvoke 不把子图的 custom 事件交给父图的流),父图报告引用不到子图里的数,父图口径卡读 +子图出口的字段一律停在子工作流节点。现在: +- 子图里每一步进同一个运行的事件流,节点 id「子工作流节点 id/…」,封存覆盖它们; +- 子图的台账(查询快照、口径卡)交回父图:父图报告的目录收进它们,引用标记、证据面板一路点到子图里的那一格; +- 子图出口的每个字段带出处,父图口径卡读它时跨子图往下追:追到原始数据的完整出具,子图里追不到的父图照样降档; + 静态检查(发布前、应用检查)和运行时同一套认法;嵌套两层照样; +- 子图里的遮罩照父图的运行。 +数据是虚构店铺库(tests/ledger_samples.py)。 +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import RunEvent +from app.engine.lineage import traced, untraced +from app.engine.runner import run_manager +from tests import ledger_samples as S + +PAID = "SELECT SUM(amount) AS gmv FROM orders WHERE status = 'paid'" +GMV = {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "expression": "cell(nodes.fetch, 0, 'gmv')"} + + +@pytest.fixture(autouse=True) +async def engine_up(tmp_path): + await run_manager.setup() + await S.fresh_source(tmp_path / "shop.db") + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid: str, ntype: str, label: str | None = None, **config) -> dict: + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def metric(mid: str, expression: str, name: str = "") -> dict: + return {"id": mid, "name": name or mid, "unit": "元", "decimals": 2, "expression": expression} + + +def fetch_card_graph(*, card: dict | None = None, out: list[dict] | None = None, sql: str = PAID) -> dict: + """子工作流:取数 → 口径卡 → 出口(gmv 原样交出口径卡的指标,table 原样交出查询结果)。""" + return S.chain( + node("s", "input"), + node("fetch", "tool", tool=S.tool(), args={"sql": sql}), + card or node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[GMV]), + node("o", "output", fields=out or [{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"}, + {"name": "table", "value": "{{ nodes.fetch }}"}])) + + +async def publish(graph: dict, name: str = "子流程") -> str: + return await S.publish(name, graph=graph) + + +def parent(sub_id: str, *middle: dict, contract_from: str | None = "top", out: list[dict] | None = None, + sub_input: dict | None = None) -> dict: + return S.chain( + node("start", "input", fields=[{"name": "region"}]), + node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1, + **({"input": sub_input} if sub_input else {})), + *middle, + node("out", "output", fields=out or [{"name": "数", "value": "{{ nodes.top.metrics[0].value }}"}], + **({"contract": {"metrics_from": contract_from}} if contract_from else {}))) + + +async def run(graph: dict, inputs: dict | None = None): + started = await run_manager.start(graph=graph, input_payload=inputs or {}) + row = await S.wait(started.id) + assert row.status == "succeeded", row.error + return row + + +async def events(run_id: str) -> list[RunEvent]: + async with SessionLocal() as session: + return list((await session.execute(select(RunEvent).where(RunEvent.run_id == run_id) + .order_by(RunEvent.seq))).scalars()) + + +async def card(run_id: str, node_id: str) -> dict: + from app.api.evidence import _sealed, _sealed_cards + + sealed = await _sealed(run_id) + [found] = [c for n, _, c in _sealed_cards(sealed) if n == node_id] + return {m["id"]: m for m in found["metrics"]} + + +# -------------------------------------------------------------------------- +# 子图里每一步进同一个运行的事件流 +# -------------------------------------------------------------------------- + + +async def test_the_subgraph_steps_are_in_the_run_with_prefixed_ids(): + sub_id = await publish(fetch_card_graph()) + row = await run(parent(sub_id, contract_from=None, out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}])) + evs = await events(row.id) + started = [(e.node_id, e.data.get("label")) for e in evs if e.type == "node.started"] + assert [n for n, _ in started] == ["start", "sub", "sub/s", "sub/fetch", "sub/card", "sub/o", "out"] + assert dict(started)["sub/fetch"] == "fetch" + [end] = [e for e in evs if e.type == "tool.end" and e.node_id == "sub/fetch"] + assert end.data.get("query_artifact") + # 子图里的台账条目在子图那一步的 node.finished 上(节点 id 带前缀),子工作流节点自己的 node.finished 不再记一遍 + finished = {e.node_id: e for e in evs if e.type == "node.finished"} + assert [x["kind"] for x in finished["sub/fetch"].data["evidence"]][:1] == ["tool"] + assert "evidence" not in finished["sub"].data + # 封存覆盖它们 + assert row.manifest_seq == evs[-1].seq + assert row.output == {"数": "246.5"} + + +async def test_the_instance_timeline_counts_the_subgraph_as_one_step(): + """应用的实例进度只说这张图上的步骤:子图里的那几步(「sub/…」)不单列,子工作流算一步。""" + from types import SimpleNamespace + + from app.apps.resolve import _timeline + from app.db.models import Run + + sub_id = await publish(fetch_card_graph()) + row = await run(parent(sub_id, contract_from=None, out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}])) + async with SessionLocal() as session: + steps = (await _timeline(session, SimpleNamespace(instance=await session.get(Run, row.id))))["steps"] + assert [s["node"] for s in steps] == ["start", "sub", "out"] + + +async def test_an_error_inside_the_subgraph_names_the_step_inside_it(): + sub_id = await publish(fetch_card_graph(sql="SELECT nope FROM nowhere")) + started = await run_manager.start(graph=parent(sub_id, contract_from=None, + out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}]), + input_payload={}) + row = await S.wait(started.id) + assert row.status == "failed" and row.error_node_id == "sub/fetch" + [failed] = [e for e in await events(row.id) if e.type == "run.failed"] + assert (failed.data["node_id"], failed.data["label"]) == ("sub/fetch", "取数子流程 › fetch") + + +# -------------------------------------------------------------------------- +# 父图口径卡读子图出口的字段:跨子图往下追 +# -------------------------------------------------------------------------- + + +async def test_a_parent_card_reading_a_subgraph_field_traces_to_the_query_cell_and_issues_formally(client): + sub_id = await publish(fetch_card_graph()) + top = node("top", "metrics", caliber="父口径", caliber_version="v1", + metrics=[metric("gmv", "float(nodes.sub.output.gmv)"), metric("raw", "cell(nodes.sub.output.table, 0, 'gmv')")]) + row = await run(parent(sub_id, top)) + metrics = await card(row.id, "top") + [copy] = metrics["gmv"]["inputs"] + # 子图出口 gmv 原样交出子图口径卡的指标:接上那个指标自己的出处,一路追到取数查询 + assert (copy["via"], copy["node_id"], copy["subgraph"], copy["field"]) == ("metric", "sub/card", "sub", "gmv") + assert copy["path"] == "nodes.sub.output.gmv" and copy["value"] == "246.5" + assert copy["upstream"]["traced"] is True and traced(copy) + # 子图出口 table 原样交出查询结果:cell() 按台账认出是子图里的那份快照 + [cell] = metrics["raw"]["inputs"] + assert (cell["via"], cell["node_id"], cell["status"]) == ("tool_cell", "sub/fetch", "verified") + assert untraced(metrics["gmv"]["inputs"] + metrics["raw"]["inputs"]) == [] + assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"] + + # 证据面板:转抄的那一项后面接一步 upstream(子图里的卡),一直到子图里的取数查询 + body = (await client.get(f"/api/runs/{row.id}/evidence/metrics/top/gmv")).json() + chain = body["chain"] + assert chain[0]["untraced"] == [] + [inp] = [s for s in chain if s["step"] == "input"] + assert inp["subgraph"] == "sub" and inp["subgraph_label"] == "取数子流程" + assert inp["node_id"] == "sub/card" and inp["node_label"] == "取数子流程 › card" + [up] = [s for s in chain if s["step"] == "upstream"] + assert (up["node_id"], up["label"], up["sealed"], up["traced"]) == ("sub/card", "取数子流程 › card", True, True) + [read] = up["inputs"] + assert read["node_id"] == "sub/fetch" and read["node_label"] == "取数子流程 › fetch" + [query] = [s for s in chain if s["step"] == "query"] + assert query["artifact"] == read["artifact"] and query["node_id"] == "sub/fetch" and query["sealed"] is True + + +async def test_an_untraced_field_inside_the_subgraph_degrades_the_parent(): + inner = node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[metric("gmv", "vars.calc.v")]) + sub = S.chain(node("s", "input"), + node("calc", "transform", mode="json", template='{"v": 5}', assign_to="calc"), inner, + node("o", "output", fields=[{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"}])) + sub_id = await publish(sub) + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")]) + row = await run(parent(sub_id, top)) + [copy] = (await card(row.id, "top"))["gmv"]["inputs"] + assert copy["upstream"]["traced"] is False and not traced(copy) + issuance = row.output["_issuance"] + assert issuance["tier"] == "degraded" + assert any("指标「gmv」的输入没追到原始数据:nodes.sub.output.gmv(停在「取数子流程」)" in g + for g in issuance["gaps"]), issuance["gaps"] + + +async def test_a_field_that_is_not_a_whole_reference_stops_at_the_subgraph(): + sub_id = await publish(fetch_card_graph(out=[{"name": "gmv", "value": "合计 {{ nodes.card.metrics[0].value }}"}])) + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("n", "len(nodes.sub.output.gmv)")]) + row = await run(parent(sub_id, top)) + [item] = (await card(row.id, "top"))["n"]["inputs"] + assert (item["via"], item["node_id"]) == ("subgraph", "sub") and not traced(item) + assert row.output["_issuance"]["tier"] == "degraded" + + +async def test_the_subgraph_run_input_is_traced_back_through_the_parent_input_template(): + """子图出口交出它的运行输入:子图的输入是父图按「输入」模板渲染的。模板整串就是父图的运行输入时追到原始数据;拼了 + 别的字的停在子工作流的入口。""" + sub = S.chain(node("s", "input", fields=[{"name": "region"}]), + node("o", "output", fields=[{"name": "region", "value": "{{ input.region }}"}])) + sub_id = await publish(sub) + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("r", "nodes.sub.output.region")]) + row = await run(parent(sub_id, top, sub_input={"region": "{{ input.region }}"}), {"region": "east"}) + [item] = (await card(row.id, "top"))["r"]["inputs"] + assert (item["via"], item["path"], item["subgraph"]) == ("input", "nodes.sub.output.region", "sub") and traced(item) + assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"] + + row = await run(parent(sub_id, top, sub_input={"region": "{{ input.region }}区"}), {"region": "east"}) + [item] = (await card(row.id, "top"))["r"]["inputs"] + assert (item["via"], item["node_id"], item["field"]) == ("subgraph_input", "sub", "region") and not traced(item) + + +async def test_two_levels_of_nesting_trace_all_the_way_down(client): + inner_id = await publish(fetch_card_graph(), "内层") + middle = S.chain(node("m_in", "input"), + node("inner", "subgraph", "内层", workflow_id=inner_id, workflow_version=1), + node("m_out", "output", fields=[{"name": "gmv", "value": "{{ nodes.inner.output.gmv }}"}])) + middle_id = await publish(middle, "中层") + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")]) + row = await run(parent(middle_id, top)) + evs = await events(row.id) + assert "sub/inner/fetch" in {e.node_id for e in evs if e.type == "node.finished"} + [copy] = (await card(row.id, "top"))["gmv"]["inputs"] + assert (copy["via"], copy["node_id"], copy["subgraph"]) == ("metric", "sub/inner/card", "sub") + assert copy["upstream"]["traced"] is True + assert row.output["_issuance"]["tier"] == "formal", row.output["_issuance"]["gaps"] + chain = (await client.get(f"/api/runs/{row.id}/evidence/metrics/top/gmv")).json()["chain"] + [up] = [s for s in chain if s["step"] == "upstream"] + assert up["node_id"] == "sub/inner/card" and up["sealed"] is True and up["label"] == "取数子流程 › 内层 › card" + [query] = [s for s in chain if s["step"] == "query"] + assert query["node_id"] == "sub/inner/fetch" and query["sealed"] is True + + +# -------------------------------------------------------------------------- +# 静态检查:和运行时同一套认法 +# -------------------------------------------------------------------------- + + +async def _static(graph: dict) -> list[str]: + """受管级按指标出具的发布检查里「指标追不到原始数据」的那几条(governed.metric_untraced),子工作流读好放进来。""" + from app.engine.governance import lint_for_publish + from app.engine.lint_process import load_subgraphs, with_subgraphs + from app.engine.schema import GraphSpec + + spec = GraphSpec.model_validate(graph) + async with SessionLocal() as session: + graphs = await load_subgraphs(session, spec) + with with_subgraphs(graphs): + issues = lint_for_publish(spec, level="governed").issues + return [i.message for i in issues if i.code == "governed.metric_untraced"] + + +async def test_the_static_check_agrees_with_the_run(): + from app.engine.governance import lint_for_publish + from app.engine.schema import GraphSpec + + traced_sub = await publish(fetch_card_graph()) + inner = node("card", "metrics", caliber="子口径", caliber_version="v1", metrics=[metric("gmv", "vars.calc.v")]) + loose_sub = await publish(S.chain( + node("s", "input"), node("calc", "transform", mode="json", template='{"v": 5}', assign_to="calc"), inner, + node("o", "output", fields=[{"name": "gmv", "value": "{{ nodes.card.metrics[0].value }}"}])), "松的") + top = node("top", "metrics", caliber="父口径", caliber_version="v1", + metrics=[metric("gmv", "float(nodes.sub.output.gmv)"), metric("raw", "cell(nodes.sub.output.table, 0, 'gmv')")]) + good = parent(traced_sub, top) + bad = parent(loose_sub, node("top", "metrics", caliber="父口径", caliber_version="v1", + metrics=[metric("gmv", "float(nodes.sub.output.gmv)")])) + assert await _static(good) == [] + [said] = await _static(bad) + assert "停在「取数子流程」" in said + # 运行时同一个结论 + assert (await run(good)).output["_issuance"]["tier"] == "formal" + assert (await run(bad)).output["_issuance"]["tier"] == "degraded" + # 没把子工作流读好放进来(只看图本身的地方):看不到子图,照旧停在子工作流节点 + plain = [i for i in lint_for_publish(GraphSpec.model_validate(good), level="governed").issues + if i.code == "governed.metric_untraced"] + assert plain and "停在「取数子流程」" in plain[0].message + + +async def test_the_static_check_follows_two_levels_and_the_input_template(): + inner_id = await publish(fetch_card_graph(), "内层") + middle_id = await publish(S.chain( + node("m_in", "input"), node("inner", "subgraph", "内层", workflow_id=inner_id, workflow_version=1), + node("m_out", "output", fields=[{"name": "gmv", "value": "{{ nodes.inner.output.gmv }}"}])), "中层") + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("gmv", "float(nodes.sub.output.gmv)")]) + assert await _static(parent(middle_id, top)) == [] + + echo_id = await publish(S.chain(node("s", "input", fields=[{"name": "region"}]), + node("o", "output", fields=[{"name": "region", "value": "{{ input.region }}"}])), + "回声") + top = node("top", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("r", "nodes.sub.output.region")]) + assert await _static(parent(echo_id, top, sub_input={"region": "{{ input.region }}"})) == [] + assert await _static(parent(echo_id, top, sub_input={"region": "{{ input.region }}区"})) + + +# -------------------------------------------------------------------------- +# 父图报告直接引用子图里的查询和口径卡 +# -------------------------------------------------------------------------- + + +def report_parent(sub_id: str) -> dict: + return S.chain( + node("start", "input"), + node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1), + node("write", "report", instructions="写答案"), + node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write"})) + + +async def _segments(client: AsyncClient, run_id: str) -> dict[str, dict]: + body = (await client.get(f"/api/runs/{run_id}/evidence")).json() + assert body["mode"] == "cited", body + return body + + +async def test_the_parent_report_cites_the_subgraph_query_and_card(client, monkeypatch): + sub_id = await publish(fetch_card_graph()) + S.script(monkeypatch, "子流程里查到的销售额 [[v:Q1.r0.gmv]],口径算的是 [[m:gmv]]。") + started = await run_manager.start(graph=report_parent(sub_id), input_payload={}) + row = await S.wait(started.id) + assert row.status == "succeeded", row.error + body = await _segments(client, row.id) + items = {e["alias"]: e for e in body["evidence"]} + assert (items["Q1"]["node_id"], items["Q1"]["sealed"]) == ("sub/fetch", True) + assert (items["m:gmv"]["node_id"], items["m:gmv"]["sealed"]) == ("sub/card", True) + cited = {e["to"]: e["from"] for e in body["edges"] if e["rel"] == "cites"} + # 单元格引用一路点到子图里的那一格 + cell = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['Q1']}")).json()["chain"] + [query] = [s for s in cell if s["step"] == "query"] + assert query["node_id"] == "sub/fetch" and query["sealed"] is True and query["artifact"] == items["Q1"]["artifact"] + # 指标引用:子图里的卡、它的输入、子图里的查询 + chain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['m:gmv']}")).json()["chain"] + step = chain[0] + assert (step["step"], step["node_id"], step["sealed"], step["untraced"]) == ("metric", "sub/card", True, []) + [inp] = [s for s in chain if s["step"] == "input"] + assert (inp["node_id"], inp["node_label"], inp["via"]) == ("sub/fetch", "取数子流程 › fetch", "tool_cell") + [q] = [s for s in chain if s["step"] == "query"] + assert q["node_id"] == "sub/fetch" and q["sealed"] is True + # 封存核对通过,可验证报告照常(事件本来就在同一个运行里) + from app.engine.runner import verify_manifest + + assert (await verify_manifest(row.id))["ok"] is True + res = await client.get(f"/api/runs/{row.id}/report.html") + assert res.status_code == 200 and "246.5" in res.text + + +async def test_masks_inside_the_subgraph_follow_the_parent_run(client, monkeypatch, tmp_path): + """数据源遮了 phone:子图里查出来的号码在父图报告的证据面板里照样写「已遮罩」。""" + await S.set_source(tmp_path / "shop.db", options={"mask_columns": "phone"}) + amount = node("card", "metrics", caliber="子口径", caliber_version="v1", + metrics=[metric("gmv", "cell(nodes.fetch, 0, 'amount')")]) + sub_id = await publish(fetch_card_graph(card=amount, sql="SELECT phone, amount FROM orders WHERE id = 1")) + S.script(monkeypatch, "第一单的电话 [[v:Q1.r0.phone]]。") + started = await run_manager.start(graph=report_parent(sub_id), input_payload={}) + row = await S.wait(started.id) + assert row.status == "succeeded", row.error + body = await _segments(client, row.id) + cited = {e["to"]: e["from"] for e in body["edges"] if e["rel"] == "cites"} + chain = (await client.get(f"/api/runs/{row.id}/evidence/segments/{cited['Q1']}")).json()["chain"] + [query] = [s for s in chain if s["step"] == "query"] + assert query["node_id"] == "sub/fetch" + assert "13800000001" not in str(query), "子图里的遮罩列照父图的运行遮" + assert "已遮罩" in str(query) and query["sealed"] is True From 38addf8f5db26088adafffa26bbc9ff685b960aa Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 16:30:39 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=A4=9A=E4=B8=80=E4=B8=AA=E3=80=8C=E9=A9=B3=E5=9B=9E=E3=80=8D?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E3=80=81=E8=B5=B0=E4=BA=86=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E7=9A=84=E9=82=A3=E4=B8=80=E6=AD=A5=E7=94=BB?= =?UTF-8?q?=E3=80=8C=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D=E3=80=81=E5=AD=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8C=E7=9A=84=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=E5=92=8C=E8=AF=81=E6=8D=AE=E6=9C=89=E5=90=8D=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接后端两个提交(docs/design/orchestration-round/00-总方案.md §2、§3): - canvas/nodeDefs:会被人驳回的节点多一个「驳回」出口(id rejected,和后端 REJECT_HANDLE 同一个,中性色):人工节点三种 模式、双盲复算一律给;沙箱代码只在审批策略是每次审批时给;调用工具选了工具、审批策略不是从不审批时给(数据源的查询 只读,只在明说每次审批时给)。出口跟着配置算:改成不会被驳回时,studio 原有的出口同步把连在驳回出口上的线收掉;补充输入 改成审批时普通出口的线跟到「批准」 - run/trace:node.finished 带 outcome=rejected 时这一步收成 rejected(exited,记下转到哪几步),下一次执行开始时清掉; 子工作流里的步骤不另算一路并行 - run/derive:接了驳回出口的关卡按出口路由(驳回出口 / 其余是普通出口,同 compiler._make_router);走了驳回出口的算 「送出了数据」,下游照常排队、跑完,普通出口那一侧未到达;退回修改时两条出口都算走过;withSubSteps 把子工作流里的 步骤排在子工作流那一步后面(泳道、航迹的节点列表) - run/decode:走了驳回出口的那一步状态 rejected、副标题写谁驳回、备注、转到哪一步;终态写「完成(途经驳回)」和途经的 那几步(run.finished 的 rejected_via),没有途经的一字不变;子工作流里的步骤名接上子工作流的名字 - run/useNodeView:回放时「走了驳回出口」只说被驳回的那一刻 - canvas/NodeCard、pages/runs/TracePane:走了驳回出口的节点卡写「被驳回,转到「X」」,航迹节点卡写出口和转到哪,关键时刻 多一条「「审批」被驳回,转到「X」」、结局写「运行完成(途经驳回)」 - run/EvidencePanel、types:输入来源写明经哪个子工作流的哪个出口字段;子工作流里的节点(「sub/…」,画布上没有)照证据接口 给的名字写(node_label、subgraph_label),悬停写 id;停在子工作流入口的写「子工作流的运行输入」 - lib/terms:REJECTED_TEXT 加出口、走出口那一步、途经驳回的说法;EVIDENCE_TEXT 加经子工作流的说法 检查脚本:check-decode(驳回出口的步骤、航迹、走过的边、跑到一半、批准走普通出口;子工作流步骤名)、check-run-states (reject-exit:卡片状态与剪影、槽里写转到哪、驳回边走过批准边没走、运行成功)、check-studio(各类节点的出口、能连能画、 改配置收掉驳回线、改模式线跟过去)、check-evidence(subgraph:输入来源写明经哪个子工作流、子图节点写名字) --- frontend/src/canvas/NodeCard.tsx | 6 ++- frontend/src/canvas/RunTimeline.tsx | 5 +- frontend/src/canvas/nodeDefs.ts | 43 +++++++++++++--- frontend/src/lib/terms.ts | 23 +++++++++ frontend/src/pages/runs/TracePane.tsx | 28 +++++++--- frontend/src/run/EvidencePanel.tsx | 23 ++++++--- frontend/src/run/decode.ts | 21 ++++++-- frontend/src/run/derive.ts | 69 +++++++++++++++++++++---- frontend/src/run/trace.ts | 21 +++++++- frontend/src/run/useNodeView.ts | 2 + frontend/src/types.ts | 9 ++++ scripts/check-decode.mjs | 73 +++++++++++++++++++++++++++ scripts/check-evidence.mjs | 29 +++++++++++ scripts/check-run-states.mjs | 57 ++++++++++++++++++++- scripts/check-studio.mjs | 71 ++++++++++++++++++++++++++ 15 files changed, 440 insertions(+), 40 deletions(-) diff --git a/frontend/src/canvas/NodeCard.tsx b/frontend/src/canvas/NodeCard.tsx index 00747e0a..5798cc83 100644 --- a/frontend/src/canvas/NodeCard.tsx +++ b/frontend/src/canvas/NodeCard.tsx @@ -561,7 +561,11 @@ function readingOf({ state, view, type, config, skewMs, timed, fanIn, facts, sin } case 'rejected': // 被人驳回(node.failed 带 reason=rejected):人的决定,不是失败。状态字已经写了「被驳回」,槽里写原话的第一句 - // (「人工驳回:数字对不上」),整句放悬停;不按失败去归类、讲处理建议 + // (「人工驳回:数字对不上」),整句放悬停;不按失败去归类、讲处理建议。接了「驳回」出口的(node.finished 带 + // outcome=rejected,n.exited)这一步照常结束、运行接着走:槽里写转到了哪几步 + if (n?.exited) { + return { main: formatDuration(n.lastDurationMs ?? runtime?.durationMs), note: REJECTED_TEXT.exitCard(n.exitTo ?? []) } + } return { main: formatDuration(n?.lastDurationMs ?? runtime?.durationMs), note: error ? firstSentence(error) : REJECTED_TEXT.card, diff --git a/frontend/src/canvas/RunTimeline.tsx b/frontend/src/canvas/RunTimeline.tsx index 2b2a731c..776b8249 100644 --- a/frontend/src/canvas/RunTimeline.tsx +++ b/frontend/src/canvas/RunTimeline.tsx @@ -5,7 +5,7 @@ import { create } from 'zustand' import { StatusBadge } from '../components/ui' import { formatClock, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format' import { statusMeta } from '../lib/status' -import { topology, type GraphLike } from '../run/derive' +import { topology, withSubSteps, type GraphLike } from '../run/derive' import { isActivePhase, isTerminal, lastEventAt, liveAt, project, type NodeState, type NodeTrace, type Projection, type Segment, type Trace, @@ -339,7 +339,8 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map [n.id, n])) - const ids = [...topo.order, ...Object.keys(trace.nodes).filter((id) => !byId.has(id))] + // 子工作流里的步骤排在子工作流那一行下面(withSubSteps),老运行里改过图的照旧排在最后 + const ids = withSubSteps(topo.order, Object.keys(trace.nodes).filter((id) => !byId.has(id))) const failedLabel = trace.failedNodeId ? labels.get(trace.failedNodeId) ?? trace.failedNodeId : '' const rows: Row[] = [] let top = 0 diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts index 7b6e0d28..3f3d5939 100644 --- a/frontend/src/canvas/nodeDefs.ts +++ b/frontend/src/canvas/nodeDefs.ts @@ -4,7 +4,7 @@ import { Gauge, GitBranch, GitCompareArrows, Hand, Repeat, Search, ShieldAlert, Shuffle, Users, Wrench, } from 'lucide-react' import { - APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, UPGRADE_POLICY_LABEL, + APPROVAL_POLICY_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, MERGE_TEXT, NODE_TYPE_LABEL, REJECTED_TEXT, UPGRADE_POLICY_LABEL, } from '../lib/terms' import type { NodeType } from '../types' import { dualCheckFields, dualCheckSummary } from './DualCheckFields' @@ -323,7 +323,8 @@ export const NODE_DEFS: Record = { tool: { type: 'tool', label: NODE_TYPE_LABEL.tool, category: '执行', icon: Wrench, description: '直接调用一个指定工具', - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 出口由配置决定:会请人审批(工具调用审批、写入前的动作确认)时多一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, fields: [ { key: 'tool', label: '工具', type: 'tools', help: '只能选一个' }, { key: 'args', label: '参数', type: 'json', syntax: 'template', help: '值中可用 {{ }} 引用上游数据' }, @@ -354,7 +355,8 @@ export const NODE_DEFS: Record = { code: { type: 'code', label: NODE_TYPE_LABEL.code, category: '执行', icon: Code2, description: '在 microVM 或系统沙箱中执行代码', - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 审批策略设成每次审批时多一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, fields: [ { key: 'language', label: '语言', type: 'select', @@ -699,7 +701,8 @@ export const NODE_DEFS: Record = { dual_check: { type: 'dual_check', label: NODE_TYPE_LABEL.dual_check, category: '把关', icon: GitCompareArrows, description: DUAL_CHECK_TEXT.description, - hasTarget: true, sources: [{ id: 'out', label: '' }], + // 两路不一致时请人裁决,裁决可以驳回:普通出口之外有一个「驳回」出口,见 sourceHandles + hasTarget: true, sources: null, // 配置的键和缺省见 types.ts 的 DualCheckConfig(期 3 约定)。口径卡、指标、口径说明要看画布上别的节点, // 盲算模型是接入加模型,启用是三态,都交给 DualCheckFields 画;type 只管默认值和校验时当什么看。 // 盲算模型不放进高级选项:时间线上「模型相同」那一行的「打开设置」要定位到它,高级选项收着时定位不到 @@ -821,10 +824,38 @@ export function sourceHandles(type: NodeType, config: Record): Hand if (config.mode === 'approve') { return [ { id: 'approved', label: '批准', color: 'var(--ok)' }, - { id: 'rejected', label: '驳回', color: 'var(--err)' }, + REJECT_EXIT, ] } - return [{ id: 'out', label: '' }] + return [{ id: 'out', label: '' }, REJECT_EXIT] } + if (canReject(type, config)) return [{ id: 'out', label: '' }, REJECT_EXIT] return [{ id: 'out', label: '' }] } + +/** + * 「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):接了它,驳回(含超时驳回)时这一步正常结束、沿它往下走; + * 没接,驳回让运行结束。和后端 engine/schema.REJECT_HANDLE 同一个 id。颜色是中性的:驳回是人的决定,不是故障 + */ +export const REJECT_HANDLE = 'rejected' +const REJECT_EXIT: HandleDef = { id: REJECT_HANDLE, label: REJECTED_TEXT.exit, color: 'var(--text-dim)' } + +/** 数据源的查询、看表结构:只读,不会请人审批(除非节点上明说了每次都审批) */ +const READ_ONLY_TOOL = /^db_(?:query|schema)__/ + +/** + * 这个节点会不会被人驳回,画布上给不给「驳回」出口(后端认的六类关卡:engine/schema.GATE_TYPES)。人工节点、双盲复算一律 + * 给(审批、补充输入、编辑稿件、裁决都能驳回);沙箱代码只在审批策略是每次审批时问人;调用工具选了工具、审批策略不是 + * 从不审批时可能问人(工具调用审批、写入前的动作确认),数据源的查询只读,只在明说了每次审批时问。不给出口的节点上连着的 + * 驳回边,改配置时由 studio 收掉(出口跟着配置算) + */ +export function canReject(type: NodeType, config: Record): boolean { + if (type === 'human' || type === 'dual_check') return true + if (type === 'code') return config.approval === 'always' + if (type === 'tool') { + if (config.approval === 'always') return true + const tool = String(config.tool ?? '') + return config.approval !== 'never' && !!tool && !READ_ONLY_TOOL.test(tool) + } + return false +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8f01ebb6..2c5b1c90 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -313,6 +313,10 @@ export const EVIDENCE_TEXT = { originChipHint: '这个数由口径卡确定性算出、复算一致,但算式用到的输入没有一路追到查询快照', /** 按位置取的格穿过了原样解析查询结果的整形节点 */ through: (label: string) => `经「${label}」原样解析`, + /** 读子工作流出口的字段,出处接上子图里的那一格(docs/design/orchestration-round/00-总方案.md §2) */ + viaSubgraph: (label: string, field: string) => (field ? `经子工作流「${label}」的出口字段「${field}」` : `经子工作流「${label}」`), + /** 停在子工作流的入口:子图的运行输入是父图按「输入」模板拼出来的,不是原始数据 */ + subgraphInput: '子工作流的运行输入', /** * 转抄另一张口径卡的指标(对账卡、召回卡的「口径」那一项):出处跨卡往下追,接上那个指标自己的一段,一直到查询格子 */ @@ -5500,6 +5504,25 @@ export const REJECTED_TEXT = { }, /** 画布节点卡上被驳回的那一步:一句话,悬停写原话 */ card: '被驳回', + /** + * 「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):审批、补充输入、编辑稿件、动作确认、代码和工具审批、 + * 双盲裁决接了它时,驳回之后沿它往下走。画布上出口的字 + */ + exit: '驳回', + /** 节点卡、航迹上走了驳回出口的那一步:「被驳回,转到「通知申请人」」(转到哪几步取 node.finished 的 to) */ + exitCard: (to: string[]): string => (to.length ? `被驳回,转到${to.map((t) => `「${t}」`).join('、')}` : '被驳回,走驳回出口'), + /** 时间线、问数据过程里这一步的副标题:谁、备注,再接上转到哪几步 */ + exitStep: (d: { actor?: unknown; note?: unknown; decision?: unknown; to?: unknown }): string => { + const to = Array.isArray(d.to) ? d.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [] + return `${REJECTED_TEXT.step(d)},${to.length ? `转到${to.map((t) => `「${t}」`).join('、')}` : '走驳回出口'}` + }, + /** 运行照驳回那条路结束:终态事件的 rejected_via,一步一句「「审批」被驳回,转到「通知申请人」」 */ + via: (items: unknown): string => (Array.isArray(items) ? items : []) + .map((v: any) => `${typeof v?.label === 'string' && v.label ? `「${v.label}」` : '这一步'}${REJECTED_TEXT.exitCard( + Array.isArray(v?.to) ? v.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [])}`) + .join(';'), + /** 途经驳回的那一行的标题 */ + viaTitle: '途经驳回', } /** 来源标签:老运行没有来源时写「来源未记录」 */ diff --git a/frontend/src/pages/runs/TracePane.tsx b/frontend/src/pages/runs/TracePane.tsx index c165b23c..027074bf 100644 --- a/frontend/src/pages/runs/TracePane.tsx +++ b/frontend/src/pages/runs/TracePane.tsx @@ -12,7 +12,7 @@ import { NONE, formatCost, formatDateTime, formatNumber, formatOffset, formatSpan, formatTokens, shortId, } from '../../lib/format' import { COST_TEXT, REJECTED_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' -import { topology, type GraphLike } from '../../run/derive' +import { topology, withSubSteps, type GraphLike } from '../../run/derive' import { isActivePhase, isSettled, liveAt, project, type NodeState, type Projection, type RunPhase, type Trace, } from '../../run/trace' @@ -123,8 +123,8 @@ export function TracePane({ // 回放时的节点状态要按图再推一遍(排队、阻断、未到达),和画布卡片读同一份 const view = useMemo(() => (live ? trace : replayTraceCached(trace, replayAt!, g as GraphLike)), [live, trace, replayAt, g]) const order = useMemo(() => { - const ids = topology(g as GraphLike).order - return [...ids, ...Object.keys(trace.nodes).filter((id) => !ids.includes(id))] + // 子工作流里的步骤(「子工作流节点 id/…」)排在子工作流那一步后面 + return withSubSteps(topology(g as GraphLike).order, Object.keys(trace.nodes)) }, [g, trace.nodes]) const stateOf = (id: string): NodeState => view.nodes[id]?.state ?? 'idle' const facts = useMemo(() => factsOf(events), [events]) @@ -414,6 +414,10 @@ function momentsOf( } } else if (s.kind === 'retry') { out.push({ key: `${id}:t${i}`, at: s.start, status: 'running', text: `${name(id)}重试`, nodeId: id }) + } else if (s.kind === 'run' && s.status === 'rejected' && s.end != null && n.exited) { + // 被驳回、走了「驳回」出口:运行接着走,这一下是路上的一个时刻(没接出口的驳回由结局那一条说) + out.push({ key: `${id}:x${i}`, at: s.end, status: 'rejected', nodeId: id, + text: `${name(id)}${REJECTED_TEXT.exitCard(n.exitTo ?? [])}` }) } else if (s.kind === 'run' && s.status === 'failed' && s.end != null) { // 让整次运行失败的那一下由结局那一条说,这里只列失败了还接着走下去的 if (ended && trace.phase === 'failed' && id === trace.failedNodeId && i === lastFail) return @@ -447,16 +451,20 @@ function momentsOf( }) if (ended) { const failed = trace.phase === 'failed' ? trace.failedNodeId : undefined + // 照驳回出口那条路走完的:结局照实说途经驳回(和终态事件的 rejected_via 同一件事) + const via = order.filter((id) => trace.nodes[id]?.exited) const why = trace.phase === 'failed' ? explainRunError((failed && trace.nodes[failed]?.error) || error).title : code === 'rejected' ? error?.trim() || undefined : trace.phase === 'cancelled' ? cancelReason(error) ?? undefined - : trace.phase === 'suspended' ? error?.trim() || undefined : undefined + : trace.phase === 'suspended' ? error?.trim() || undefined + : via.length ? via.map((v) => `${name(v)}${REJECTED_TEXT.exitCard(trace.nodes[v]?.exitTo ?? [])}`).join(';') + : undefined out.push({ key: 'end', at: trace.endedAt!, end: true, // 挂起分两种(服务重启、可续跑),详情头已经分好了,照它的说 status: trace.phase === 'suspended' ? code : trace.phase, - text: trace.phase === 'succeeded' ? '运行完成' + text: trace.phase === 'succeeded' ? (via.length ? `运行完成(${REJECTED_TEXT.viaTitle})` : '运行完成') : failed ? `失败于${name(failed)}` : trace.phase === 'failed' ? '运行失败' : code === 'rejected' ? REJECTED_TEXT.title @@ -609,11 +617,15 @@ function NodeCard({ id, trace, proj, state, graph, facts, at, labelOf, href, gon const error = state === 'failed' && n?.error ? explainRunError(n.error) : null const rows: [string, ReactNode][] = [] if (n?.model) rows.push(['模型', {n.model}]) - if (n?.takenHandle && (state === 'done' || state === 'failed')) rows.push(['出口', {n.takenHandle}]) + if (n?.takenHandle && (state === 'done' || state === 'failed' || (state === 'rejected' && n.exited))) { + rows.push(['出口', {n.takenHandle}]) + } if (n?.reason) rows.push(['理由', n.reason]) if (state === 'skipped' && n?.skippedReason) rows.push(['跳过', n.skippedReason]) - // 被人驳回的那一步(node.failed 带 reason=rejected):写原话(谁驳回、为什么),不按失败去讲原因和处理建议 - if (state === 'rejected' && n?.error) rows.push([REJECTED_TEXT.title, n.error]) + // 被人驳回的那一步(node.failed 带 reason=rejected):写原话(谁驳回、为什么),不按失败去讲原因和处理建议。走了驳回 + // 出口的写转到了哪几步 + if (state === 'rejected' && n?.exited) rows.push([REJECTED_TEXT.title, REJECTED_TEXT.exitCard(n.exitTo ?? [])]) + else if (state === 'rejected' && n?.error) rows.push([REJECTED_TEXT.title, n.error]) // 卡片说的是游标那一刻所在的那一次执行(和画布卡片同一个划分):循环上一轮、接着跑之前 // 的事不挂到这一次干净的执行上;回放到更早的一次时,也不因为后面还有一件就看不到这一次的 const since = execStart(n, at, facts) diff --git a/frontend/src/run/EvidencePanel.tsx b/frontend/src/run/EvidencePanel.tsx index a1a27d27..bb33cc52 100644 --- a/frontend/src/run/EvidencePanel.tsx +++ b/frontend/src/run/EvidencePanel.tsx @@ -1013,7 +1013,7 @@ type InputLink = { index: number; alias: string; go: () => void; text?: string } * 面板里提到的节点。画布右栏里是按钮:点一下在画布上选中并对准它(画布上已经没有这个节点 * 时不给按钮,点了会落空);别处照旧写节点 id */ -function NodeChip({ id, prefix }: { id?: string | null; prefix?: string }) { +function NodeChip({ id, prefix, fallback }: { id?: string | null; prefix?: string; fallback?: string }) { const host = useEvidenceHost() if (!id) return null const label = host.nodeLabel?.(id) @@ -1025,7 +1025,8 @@ function NodeChip({ id, prefix }: { id?: string | null; prefix?: string }) { ) } - return {prefix}{id} + // 画布上没有这个节点(子工作流里的那一步「子工作流节点 id/…」):写证据接口给的名字,悬停写 id + return {prefix}{fallback || id} } /** @@ -1559,10 +1560,14 @@ function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) const masked = !missing && (maskedOf(input).length > 0 || !!unverified) // Agent 交来、被截断切开的数组字段:对它计数、求和只算到了取回的那部分 const cut = !missing && input.truncated === true - const node = input.node_id ? host.nodeLabel?.(input.node_id) || input.node_id : '' + const node = input.node_id ? host.nodeLabel?.(input.node_id) || input.node_label || input.node_id : '' const via = input.via ? VIA_TEXT[input.via] ?? nodeTypeLabel(input.via) : '' const through = throughText(input, host.nodeLabel) - const from = [node && `来自节点「${node}」`, via && `(${via})`, through && ` · ${through}`, + // 读子工作流出口的字段:写明经过哪个子工作流的哪个字段(出处接上的是子图里的那一格) + const sub = input.subgraph + ? EVIDENCE_TEXT.viaSubgraph(host.nodeLabel?.(input.subgraph) || input.subgraph_label || input.subgraph, input.field ?? '') + : '' + const from = [node && `来自节点「${node}」`, via && `(${via})`, sub && ` · ${sub}`, through && ` · ${through}`, input.role && ` · ${ROLE_TEXT[input.role] ?? input.role}`, cut && ` · ${EVIDENCE_TEXT.inputTruncated}`, masked && ` · ${unverified ? EVIDENCE_TEXT.unverifiedInput(unverified) : EVIDENCE_TEXT.maskedInput}`].filter(Boolean).join('') @@ -1597,7 +1602,9 @@ function InputChip({ input, link }: { input: EvidenceInput; link?: InputLink }) } /** 取值来源的方式:能追到快照的两种写中文,其余是产出节点的类型 */ -const VIA_TEXT: Record = { agent_field: 'Agent 字段', tool_cell: '单元格取数' } +const VIA_TEXT: Record = { + agent_field: 'Agent 字段', tool_cell: '单元格取数', subgraph_input: EVIDENCE_TEXT.subgraphInput, +} /** 按位置取的格穿过了原样解析查询结果的整形节点:「经「解析查询结果」原样解析」。cell() 取的格、别的输入没有 */ function throughText(input: EvidenceInput, nodeLabel?: (id: string) => string | undefined): string { @@ -1713,7 +1720,9 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp const alias = (link && !link.text ? link.alias : '') || inp.query || (typeof inp.cell === 'string' ? inp.cell.split('.')[0] : '') const where = [ - inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), + inp.subgraph + ? EVIDENCE_TEXT.viaSubgraph(host.nodeLabel?.(inp.subgraph) || inp.subgraph_label || inp.subgraph, inp.field ?? '') + : inp.field && (inp.via === 'agent_field' ? `字段 ${inp.field}` : inp.field), throughText(inp, host.nodeLabel), [alias, locatorText(inp.locator)].filter(Boolean).join(' · '), ].filter(Boolean).join(' · ') @@ -1732,7 +1741,7 @@ function SourcesPart({ inputs, linkOf }: { inputs: EvidenceInput[]; linkOf: (inp {(inp.node_id || where) && (
- {inp.node_id && } + {inp.node_id && } {inp.node_id && where && ' · '}{where}
)} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 002b6261..0ab3ccfd 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -1892,7 +1892,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt // 节点类型的中文名,至少能看出这步在干嘛 const label = String(d.label ?? '') const typeName = TYPE_LABEL[String(d.node_type ?? '')] - const title = (label && label !== nodeId ? label : typeName) || nodeId || '执行步骤' + // 子工作流里的一步(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md §2):前面接上 + // 子工作流那一步的名字,看得出这是哪个子工作流里的 + const parentTitle = nodeId?.includes('/') ? nodeSteps.get(nodeId.slice(0, nodeId.lastIndexOf('/')))?.title : undefined + const own = (label && label !== nodeId?.slice(nodeId.lastIndexOf('/') + 1) && label !== nodeId ? label : typeName) + || nodeId || '执行步骤' + const title = parentTitle ? `${parentTitle} › ${own}` : own const iteration = num(d.iteration) if (nodeId && !spans.has(nodeId)) { spans.set(nodeId, { start: event.ts ?? 0, end: event.ts ?? 0, ms: 0 }) @@ -1981,11 +1986,15 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt } closeLlm(nodeId, 'done', at) const step = nodeId ? nodeSteps.get(nodeId) : undefined + // 被驳回、走了「驳回」出口(node.finished 带 outcome=rejected):这一步照常结束、运行接着往下走,状态写「被驳回」, + // 副标题写谁、备注、转到哪几步;不画红、不当成出错 + const exited = d.outcome === 'rejected' if (step) { const ms = num(d.duration_ms) const cur = execOf(nodeId) - if (cur) { cur.status = 'done'; if (ms != null) cur.ms = ms } - step.status = 'done' + if (cur) { cur.status = exited ? 'rejected' : 'done'; if (ms != null) cur.ms = ms } + step.status = exited ? 'rejected' : 'done' + if (exited) { step.level = undefined; step.sub = REJECTED_TEXT.exitStep(d) } step.artifact = d.artifact || step.artifact const execs = step.execs ?? [] if (execs.length > 1) { @@ -2801,9 +2810,13 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt const timing = d.timing ?? {} const active = num(timing.active_ms) ?? num(d.duration_ms) const wait = num(timing.wait_ms) + // 途经驳回(终态事件带 rejected_via,docs/design/orchestration-round/00-总方案.md §3):照驳回那条路走完的,照实说 + // 在哪一步被驳回、转到了哪里。没有途经的和以前一字不差 + const via = REJECTED_TEXT.via(d.rejected_via) out.push({ id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done', - title: '完成', ms: active, + title: via ? `完成(${REJECTED_TEXT.viaTitle})` : '完成', ms: active, + ...(via ? { sub: via } : {}), // 等人的时间单独说:审批停了三分钟的运行,"执行 1.2 s"和"用了 3 分钟" // 都对,混成一个数就哪个都不对了 meta: [dur(active), wait ? `等待审批 ${formatDuration(wait)}` : ''].filter(Boolean).join(' · ') diff --git a/frontend/src/run/derive.ts b/frontend/src/run/derive.ts index 3d669e68..895fc828 100644 --- a/frontend/src/run/derive.ts +++ b/frontend/src/run/derive.ts @@ -121,19 +121,63 @@ export function topology(graph: GraphLike): Topology { return topo } +/** + * 拓扑序里插上图上没有的那几个节点:子工作流里的步骤(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md + * §2)排在它们的子工作流节点后面(嵌套的接在上一层后面),别的(老运行里改过图的)照旧排在最后。泳道、航迹的节点列表用它 + */ +export function withSubSteps(order: string[], ids: string[]): string[] { + const known = new Set(order) + const extra = ids.filter((id) => !known.has(id)) + if (!extra.length) return order + const under = new Map() + const rest: string[] = [] + for (const id of extra) { + const cut = id.lastIndexOf('/') + const parent = cut > 0 ? id.slice(0, cut) : '' + if (parent && (known.has(parent) || extra.includes(parent))) { + const list = under.get(parent) + if (list) list.push(id) + else under.set(parent, [id]) + } else rest.push(id) + } + const out: string[] = [] + const put = (id: string) => { + out.push(id) + for (const child of under.get(id) ?? []) put(child) + } + for (const id of order) put(id) + for (const id of rest) put(id) + return out +} + const continueOnError = (n: GraphLike['nodes'][number] | undefined): boolean => n?.data?.config?.on_error === 'continue' +/** 会被人驳回、能接「驳回」出口的节点(后端 engine/schema.GATE_TYPES)。出口 id 同 canvas/nodeDefs 的 REJECT_HANDLE */ +const GATE_TYPES = new Set(['human', 'tool', 'code', 'dual_check']) +const REJECT = 'rejected' + +/** + * 审批模式以外的关卡接了驳回出口:只有两条路,驳回出口和其余的(普通出口)。和 compiler.py 的 _make_router 一致: + * 驳回出口以外的出口一律当 default + */ +function gateRouted(node: GraphLike['nodes'][number] | undefined, outHandles: Set): boolean { + const type = node ? typeOf(node) : undefined + if (!type || !GATE_TYPES.has(type) || !outHandles.has(REJECT)) return false + return !(type === 'human' && (node?.data?.config?.mode ?? 'approve') === 'approve') +} + /** * 这个节点是不是按出口路由的。和 compiler.py 的 needs_routing 一致:分支、循环, - * 以及出口里有通过/驳回的人工审批。图里没带类型时,看它做过的决定能不能对上 + * 出口里有通过/驳回的人工审批,以及接了「驳回」出口的关卡。图里没带类型时,看它做过的决定能不能对上 * 某个出口。 */ function isRouting(node: GraphLike['nodes'][number] | undefined, outHandles: Set, n: NodeTrace | undefined): boolean { const type = node ? typeOf(node) : undefined if (type === 'branch' || type === 'loop') return true - if (type === 'human') return outHandles.has('approved') || outHandles.has('rejected') + if (type === 'human') return outHandles.has('approved') || outHandles.has(REJECT) + if (type && GATE_TYPES.has(type)) return outHandles.has(REJECT) if (type) return false const decision = n?.takenHandle return decision != null && (outHandles.has(decision) || outHandles.has('default')) @@ -166,22 +210,24 @@ function contextOf(graph: GraphLike): Ctx { return ctx } -/** 出口 handle 是否放行:decision 是节点做的某一次决定(undefined = 没做决定) */ +/** 出口 handle 是否放行:decision 是节点做的某一次决定(undefined = 没做决定)。gate:接了驳回出口的关卡(gateRouted) */ function handleOpen(e: GraphEdgeLike, routing: boolean, decision: string | undefined, - outHandles: Set): boolean { + outHandles: Set, gate = false): boolean { if (!routing) return true - const h = e.sourceHandle || 'default' + const raw = e.sourceHandle || 'default' + const h = gate && raw !== REJECT ? 'default' : raw // 路由节点没做出决定(被 skip_if 跳过):路由函数拿到 None,只走 default if (decision == null) return h === 'default' if (h === decision) return true return h === 'default' && !outHandles.has(decision) } -/** 源节点已经把数据送出来了:跑完、被跳过、容错失败,或者循环容器正放行 body */ +/** 源节点已经把数据送出来了:跑完、被跳过、容错失败、被驳回后走了驳回出口,或者循环容器正放行 body */ function sourcePassed(n: NodeTrace | undefined, node: GraphLike['nodes'][number] | undefined): boolean { if (!n) return false if (n.looping) return true return n.state === 'done' || n.state === 'skipped' + || (n.state === 'rejected' && n.exited === true) || (n.state === 'failed' && continueOnError(node)) } @@ -192,7 +238,7 @@ export function edgeFired(trace: Trace, graph: GraphLike, e: GraphEdgeLike): boo const node = ctx.byId.get(e.source) if (!sourcePassed(src, node)) return false const hs = ctx.handles.get(e.source) ?? new Set() - return handleOpen(e, isRouting(node, hs, src), src?.takenHandle, hs) + return handleOpen(e, isRouting(node, hs, src), src?.takenHandle, hs, gateRouted(node, hs)) } /** @@ -241,8 +287,13 @@ export function walkedEdges(trace: Trace, graph: GraphLike): Set { const hs = ctx.handles.get(e.source) ?? new Set() const node = ctx.byId.get(e.source) const routing = isRouting(node, hs, src) - const decisions = src.taken?.length ? src.taken : [undefined] - if (decisions.some((dec) => handleOpen(e, routing, dec, hs))) walked.add(edgeIdOf(e)) + const gate = gateRouted(node, hs) + // 接了驳回出口的关卡:有时走驳回出口(taken 里有 rejected),有时正常结束(没有决定,或者补充输入写的 approved)。 + // 「退回修改」里同一个关卡先被驳回、再被批准,两条出口都走过 + const decisions = src.taken?.length + ? (gate && src.count > src.taken.filter((t) => t === REJECT).length ? [...src.taken, undefined] : src.taken) + : [undefined] + if (decisions.some((dec) => handleOpen(e, routing, dec, hs, gate))) walked.add(edgeIdOf(e)) } return walked } diff --git a/frontend/src/run/trace.ts b/frontend/src/run/trace.ts index b9a29860..08315bb8 100644 --- a/frontend/src/run/trace.ts +++ b/frontend/src/run/trace.ts @@ -84,6 +84,13 @@ export interface NodeTrace { taken?: string[] /** 分支命中的理由 */ reason?: string + /** + * 被驳回、走了「驳回」出口(node.finished 带 outcome=rejected,docs/design/orchestration-round/00-总方案.md §3):这一步正常 + * 结束了、下游照常往下走,状态记 rejected(中性色,不是失败)。exitTo 是驳回出口转到的那几步的名字(node.finished 的 to)。 + * 下一次执行开始时清掉 + */ + exited?: boolean + exitTo?: string[] /** 用量随时间的累计,回放时按游标取那一刻的 tokens 和工具数。没调过模型和工具的节点没有 */ marks?: UsageMark[] } @@ -279,6 +286,8 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb function parallelOf(nodes: Record): number { let total = 0 for (const n of Object.values(nodes)) { + // 子工作流里的那几步(「子工作流节点 id/…」)在子工作流节点那一格里跑,不另算一路并行 + if (n.id.includes('/')) continue let inner = 0 let run = 0 for (const s of n.segments) { @@ -531,6 +540,8 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { n.endedAt = undefined n.lastDurationMs = undefined n.error = undefined + n.exited = undefined + n.exitTo = undefined if (n.toolsRunning) { n.toolsRunning = 0; mark(n) } if (num(d.iteration) != null) n.iteration = num(d.iteration) book().dispatchFrom[nodeId] = at @@ -572,8 +583,10 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (!nodeId) break const n = node(nodeId) const failed = type === 'node.failed' - // 被人驳回的那一步(服务端在 node.failed 上带 reason=rejected):这一段收成「被驳回」,不是失败 - const rejected = failed && d.reason === 'rejected' + // 被人驳回的那一步(服务端在 node.failed 上带 reason=rejected):这一段收成「被驳回」,不是失败。接了「驳回」出口的 + // 驳回是 node.finished 带 outcome=rejected:这一步正常结束、沿驳回出口往下走(exited),同样画「被驳回」 + const exited = !failed && d.outcome === 'rejected' + const rejected = (failed && d.reason === 'rejected') || exited // 节点跑完了,决定已经生效(兜底 human.resolved 没发出来的老运行) if (!failed && next.book.asking[nodeId]) delete book().asking[nodeId] const ms = num(d.duration_ms) @@ -604,6 +617,10 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { if (n.toolsRunning) { n.toolsRunning = 0; mark(n) } n.looping = looping n.state = rejected ? 'rejected' : failed ? 'failed' : looping ? 'running' : 'done' + if (exited) { + n.exited = true + n.exitTo = Array.isArray(d.to) ? d.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) : [] + } if (failed) { // 驳回的原话照样留着(卡片、航迹悬停写得出是谁驳回、为什么),但它不是「最近一次失败」:不拿它当失败的节点 n.error = String(d.error ?? '') diff --git a/frontend/src/run/useNodeView.ts b/frontend/src/run/useNodeView.ts index 7bfc74cc..e914feed 100644 --- a/frontend/src/run/useNodeView.ts +++ b/frontend/src/run/useNodeView.ts @@ -96,6 +96,8 @@ export function replayTraceCached(trace: Trace, at: number, graph: GraphLike): T nodes[id] = { ...n, state, count: p?.count ?? 0, iteration: p?.iteration, taken: decided, takenHandle: decided[decided.length - 1], looping: state === 'running' && looping, + // 走了驳回出口只说那一刻被驳回的那一次(游标停在它之前、它还在等人时不算):下游据此按驳回出口放行 + exited: state === 'rejected' ? n.exited : undefined, } } const out = applyDerived({ ...trace, phase: proj.phase, nodes }, graph) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cce85f52..580db5db 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2176,6 +2176,15 @@ export interface EvidenceInput { * 指标集工件、转抄了几层、那个指标的输入是不是都追到了原始数据(服务端 engine/lineage.metric_copy)。老运行、别的输入没有 */ upstream?: EvidenceUpstreamRef | null + /** + * 子工作流(docs/design/orchestration-round/00-总方案.md §2):读子工作流出口的字段、接上子图里那一格的出处时,经过的子工作流 + * 节点和出口字段(subgraph、field);node_id 落在子工作流里(「子工作流节点 id/…」)时证据接口另给图上的名字 + * (node_label、subgraph_label:「子工作流 › 节点」),画布、记录页认不出这个 id 时照它写。via 为 subgraph_input:停在 + * 子工作流的入口(子图的运行输入不是父图的原始数据) + */ + subgraph?: string + node_label?: string + subgraph_label?: string } /** 转抄的输入跨卡往下追的出处(EvidenceInput.upstream) */ diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 64aacf5c..43d202a9 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2959,5 +2959,78 @@ await section('被驳回的那一步:node.failed 带 reason=rejected 时画「 && old.nodes.out?.state === 'blocked', `${old.nodes.pay?.state} / ${old.failedNodeId} / ${old.nodes.out?.state}`) }) +await section('驳回出口:node.finished 带 outcome=rejected 时这一步画「被驳回」、写转到哪一步,下游照常往下走;终态写「途经驳回」(编排完善 §3)', async () => { + const trace = await import(await load('/src/run/trace.ts')) + const derive = await import(await load('/src/run/derive.ts')) + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const to = [{ node_id: 'tell', label: '通知申请人' }] + const events = [E(1, 'run.started', null, { nodes: 4 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'run.interrupted', 'pay', { payload: { node_id: 'pay' } }), + E(5, 'run.resumed', null, {}), E(6, 'node.started', 'pay', { label: '付款', resumed: true }), + E(7, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' }), + E(8, 'node.finished', 'pay', { duration_ms: 900, outcome: 'rejected', kind: 'action_confirm', decision: 'reject', + actor: '张工', note: '金额不对', to, + preview: { rejected: true, decision: 'reject', kind: 'action_confirm', __decision__: 'rejected', + text: '「付款」未确认(被驳回),没有发送:金额不对' } }), + E(9, 'node.started', 'tell', { label: '通知申请人' }), E(10, 'node.finished', 'tell', { duration_ms: 5 }), + E(11, 'run.finished', null, { output: {}, rejected_via: [{ node_id: 'pay', label: '付款', kind: 'action_confirm', + decision: 'reject', actor: '张工', note: '金额不对', to }] })] + const steps = flatten(mod.decodeRun(events)) + const pay = steps.find((s) => s.kind === 'node' && s.nodeId === 'pay') + check('这一步写「被驳回」:状态 rejected,不是 failed、不是 error', pay?.status === 'rejected' && pay?.level !== 'error', + `${pay?.status} / ${pay?.level}`) + check('副标题写谁驳回、备注、转到哪一步', pay?.sub === '张工 驳回:金额不对,转到「通知申请人」', pay?.sub) + check('这一次执行也记成被驳回', pay?.execs?.at(-1)?.status === 'rejected', pay?.execs?.at(-1)?.status) + const tell = steps.find((s) => s.kind === 'node' && s.nodeId === 'tell') + check('驳回出口那一步照常跑完', tell?.status === 'done', tell?.status) + const done = steps.find((s) => s.kind === 'lifecycle' && s.id === 'rd-11') + check('终态写「完成(途经驳回)」,副标题写在哪一步被驳回、转到哪', done?.title === '完成(途经驳回)' + && done?.sub === '「付款」被驳回,转到「通知申请人」' && done?.status === 'done', `${done?.title} / ${done?.sub}`) + const plain = flatten(mod.decodeRun([...events.slice(0, 10), E(11, 'run.finished', null, { output: {} })])) + .find((s) => s.kind === 'lifecycle' && s.id === 'rd-11') + check('没有途经驳回的终态和以前一字不差', plain?.title === '完成' && plain?.sub === undefined, `${plain?.title} / ${plain?.sub}`) + + // 航迹与图:关卡状态 rejected、走了驳回出口;普通出口那一步没走到(未到达),驳回出口的边走过了 + const graph = { nodes: [{ id: 'start', type: 'input' }, { id: 'pay', type: 'tool' }, { id: 'out', type: 'output' }, + { id: 'tell', type: 'output' }], + edges: [{ source: 'start', target: 'pay' }, { source: 'pay', target: 'out', sourceHandle: 'out' }, + { source: 'pay', target: 'tell', sourceHandle: 'rejected' }] } + const t = derive.applyDerived(events.reduce(trace.foldEvent, trace.emptyTrace()), graph) + check('航迹:关卡状态 rejected、走了驳回出口', t.nodes.pay?.state === 'rejected' && t.nodes.pay?.exited === true + && t.nodes.pay?.takenHandle === 'rejected', `${t.nodes.pay?.state} / ${t.nodes.pay?.exited} / ${t.nodes.pay?.takenHandle}`) + check('航迹:转到哪一步记着(卡片照它写)', t.nodes.pay?.exitTo?.join() === '通知申请人', t.nodes.pay?.exitTo?.join()) + check('航迹:不记成失败的节点', t.failedNodeId === undefined, t.failedNodeId) + check('航迹:普通出口那一步「未到达」,不是「被阻断」', t.nodes.out?.state === 'unreached', t.nodes.out?.state) + const walked = derive.walkedEdges(t, graph) + check('走过的边:驳回出口那条在,普通出口那条不在', walked.has('pay|tell|rejected') && !walked.has('pay|out|out'), + [...walked].join(' , ')) + // 跑到一半(驳回出口那一步还没开始):它在排队,普通出口那一步没有 + const mid = derive.applyDerived(events.slice(0, 8).reduce(trace.foldEvent, trace.emptyTrace()), graph) + // 没有事件、推导下来也还是 idle 的节点不进航迹(applyDerived 只补变了的) + check('跑到一半:驳回出口那一步排队,普通出口那一步没动', mid.nodes.tell?.state === 'queued' + && (mid.nodes.out?.state ?? 'idle') === 'idle', `${mid.nodes.tell?.state} / ${mid.nodes.out?.state ?? 'idle'}`) + // 批准了:正常结束(没有 outcome),走普通出口,驳回出口那一步未到达 + const ok = [...events.slice(0, 7), E(8, 'node.finished', 'pay', { duration_ms: 900, preview: { receipt: { id: 'R1' } } }), + E(9, 'node.started', 'out', { label: '成果' }), E(10, 'node.finished', 'out', {}), E(11, 'run.finished', null, { output: {} })] + const t2 = derive.applyDerived(ok.reduce(trace.foldEvent, trace.emptyTrace()), graph) + check('批准了:关卡完成、驳回出口那一步未到达', t2.nodes.pay?.state === 'done' && !t2.nodes.pay?.exited + && t2.nodes.tell?.state === 'unreached', `${t2.nodes.pay?.state} / ${t2.nodes.tell?.state}`) + const walked2 = derive.walkedEdges(t2, graph) + check('批准了:走过的是普通出口那条', walked2.has('pay|out|out') && !walked2.has('pay|tell|rejected'), [...walked2].join(' , ')) +}) + +await section('子工作流里的每一步进了同一个运行(节点 id「子工作流节点 id/…」):时间线的步骤名接上子工作流的名字(编排完善 §2)', async () => { + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const steps = flatten(mod.decodeRun([E(1, 'run.started', null, { nodes: 2 }), + E(2, 'node.started', 'sub', { label: '取数子流程', node_type: 'subgraph' }), + E(3, 'node.started', 'sub/fetch', { label: '查订单', node_type: 'tool' }), E(4, 'node.finished', 'sub/fetch', {}), + E(5, 'node.started', 'sub/o', { label: 'o', node_type: 'output' }), E(6, 'node.finished', 'sub/o', {}), + E(7, 'node.finished', 'sub', {}), E(8, 'run.finished', null, { output: {} })])) + const title = (id) => steps.find((s) => s.kind === 'node' && s.nodeId === id)?.title + check('子工作流里的一步:「取数子流程 › 查订单」', title('sub/fetch') === '取数子流程 › 查订单', title('sub/fetch')) + check('没起名字的(名字就是子图里的 id)退到节点类型', title('sub/o') === '取数子流程 › 成果', title('sub/o')) + check('子工作流节点自己照旧', title('sub') === '取数子流程', title('sub')) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-evidence.mjs b/scripts/check-evidence.mjs index 9c06ecbd..a84c534a 100644 --- a/scripts/check-evidence.mjs +++ b/scripts/check-evidence.mjs @@ -974,6 +974,35 @@ await section('sources', '输入来源:与快照一致、模型报 X 快照是 check('Esc 关掉面板,焦点回到原片段', await panel(page).count() === 0 && await active(page) === qseg('8.7%'), await active(page)) }) +await section('subgraph', '子工作流交回的证据:输入来源写明经哪个子工作流的哪个出口字段,子图里的节点写图上的名字(编排完善 §2)', async () => { + // 父图口径卡读子工作流出口的字段、出处接上子图里那一格时,证据接口在输入步骤上另给 subgraph / field、 + // node_label / subgraph_label(api/evidence 的 _scoped_ids)。画布、记录页认不出「sub/fetch」这种 id:照接口给的名字写 + const target = qseg('37.0元') + const { ctx, page: p, errors } = await probeQ((id, body) => { + if (id !== target) return body + for (const st of body.chain ?? []) { + if (st.step === 'input' && st.path === 'vars.kpi.gmv') { + Object.assign(st, { path: 'nodes.sub.output.gmv', node_id: 'sub/fetch', node_label: '取数子流程 › 查订单', + subgraph: 'sub', subgraph_label: '取数子流程', field: 'gmv' }) + } + } + return body + }) + try { + await openQ(p, '37.0元', { wait: '[data-ev-sources]' }) + const row = p.locator('[data-evidence-panel] [data-ev-source="nodes.sub.output.gmv"]') + const text = await row.innerText().catch(() => '') + check('写明经哪个子工作流的哪个出口字段', text.includes('经子工作流「取数子流程」的出口字段「gmv」'), text) + check('子图里的节点写图上的名字,不写裸 id', text.includes('取数子流程 › 查订单') && !text.includes('sub/fetch'), text) + check('核对状态照常(与快照一致)', text.includes('与快照一致'), text) + const chip = await row.locator('[data-ev-node]').first().getAttribute('title').catch(() => null) + check('悬停照样看得到 id', chip === 'sub/fetch', String(chip)) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + } finally { + await ctx.close() + } +}) + await section('table', '整表:每格是可点的片段,方向键按行列走,值和数同一套线型', async () => { const cells = page.locator(`${Q} table td [data-seg]`) check('整表每个数据格都是可点的片段(3 行 × 3 列)', await cells.count() === 9, String(await cells.count())) diff --git a/scripts/check-run-states.mjs b/scripts/check-run-states.mjs index 64afc46f..a6f04647 100644 --- a/scripts/check-run-states.mjs +++ b/scripts/check-run-states.mjs @@ -35,7 +35,7 @@ const SHOTS = process.env.RUN_STATES_SHOTS ?? '/tmp/agentlab-run-states' // 截图的像素密度:要看清卡片里 11px 的字时设成 2 const DSF = Number(process.env.RUN_STATES_DSF ?? 1) || 1 mkdirSync(SHOTS, { recursive: true }) -// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, issues, exits, reduced),调样式时省时间。 +// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, issues, exits, rejected, reject-exit, reduced),调样式时省时间。 // RUN_STATES_SKIP 反过来,跳过这几段:check-all 把这个检查拆成几份分到不同的道上,最后一份用它 // 兜住「其余全部」,以后新加的段不用改 check-all 也跑得到 const ONLY = (process.env.RUN_STATES_ONLY ?? '').split(',').filter(Boolean) @@ -1562,6 +1562,61 @@ await section('rejected', async () => { await browser.close() }) +// ---------------------------------------------------------------- 驳回出口 + +await section('reject-exit', async () => { + // 驳回出口(编排完善 §3):审批接了「驳回」出口,被驳回时这一步正常结束(node.finished 带 outcome=rejected),沿驳回 + // 那条边往下走。画布上它画「被驳回」(中性色、停止的剪影),槽里写转到哪一步;驳回出口的边走过了、下游照常跑完, + // 普通出口那一侧未到达;运行收成成功(途经驳回),不是取消 + console.log('\n=== 驳回出口 ===') + const EXIT_TAIL = [ + [3, 'human.resolved', 'human', { approved: false, note: '数字对不上', actor: '李工' }], + [0.05, 'run.resumed', null, { actor: '李工' }], + [0.05, 'node.started', 'human', { resumed: true }], + [0.1, 'node.finished', 'human', { duration_ms: 40, outcome: 'rejected', kind: 'approve', decision: 'reject', + actor: '李工', note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }], + preview: { rejected: true, approved: false, decision: 'reject', kind: 'approve', note: '数字对不上', + text: '人工驳回:数字对不上', __decision__: 'rejected' } }], + [0.1, 'node.started', 'fix', {}], + [0.1, 'node.finished', 'fix', { duration_ms: 5, preview: { 缺口清单: '退回:数字对不上' } }], + [0.05, 'run.finished', null, { output: { 缺口清单: '退回:数字对不上' }, + rejected_via: [{ node_id: 'human', label: '班长审批', kind: 'approve', decision: 'reject', actor: '李工', + note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }] }], + timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }], + ] + const EXITED = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, ...EXIT_TAIL]) + const { browser, page, errors } = await open({ theme: 'dark' }) + await setLod(page, 'full') + await feed(page, EXITED(Date.now())) + await page.waitForTimeout(400) + const all = await cards(page) + check('走驳回出口的那一步:卡片状态是 rejected', all.human?.state === 'rejected', all.human?.state) + check('……剪影是停止,不是失败的三角', all.human?.shape === 'stop', all.human?.shape) + check('……槽里写转到哪一步', /被驳回,转到「补数工单」/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text)) + check('……不写「失败」', !/失败/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text)) + check('驳回出口那一步照常跑完', all.fix?.state === 'done', all.fix?.state) + check('普通出口那一步「未到达」', all.out?.state === 'unreached', all.out?.state) + const edges = await page.evaluate(() => Object.fromEntries(window.__studio.getState().edges + .filter((e) => e.source === 'human').map((e) => { + const el = document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"]`) + return [e.sourceHandle, el ? [...el.classList].filter((c) => c.startsWith('sf-e-')).join(' ') : '没画出来'] + }))) + check('驳回出口的边走过了,批准那条没走', /sf-e-walked/.test(edges.rejected ?? '') && /sf-e-unwalked/.test(edges.approved ?? ''), + JSON.stringify(edges)) + const state = await page.evaluate(() => ({ phase: window.__studio.getState().runPhase, + failed: window.__studio.getState().trace.failedNodeId ?? null })) + check('运行收成成功(途经驳回),没有「失败的节点」', state.phase === 'succeeded' && state.failed === null, JSON.stringify(state)) + // 出口:驳回出口是中性色,写「驳回」 + const exit = await page.evaluate(() => { + const labels = [...document.querySelectorAll('.react-flow__node[data-id="human"] .nc-exit')].map((el) => el.textContent) + return labels + }) + check('审批的两个出口:批准、驳回', exit.join() === '批准,驳回', exit.join()) + shots.push(await shot(page, 'dark-reject-exit')) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + await browser.close() +}) + // ---------------------------------------------------------------- 关掉动效 await section('reduced', async () => { diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index 2e890c13..87a80fd4 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -3025,6 +3025,77 @@ await section('应用平台:人工节点的流程配置、入口字段的类 } }) +await section('驳回出口:会被人驳回的节点多一个「驳回」出口,能连、能显示;改配置不再会被驳回时连线一并收掉(编排完善 §3)', async () => { + FAKES['st-reject'] = wf('st-reject', { name: '__studio_check_reject__', version: 1, graph: { + nodes: [ + node('in', 'input', 0, 200, '入口', { fields: [{ name: 'q' }] }), + node('ask', 'human', 300, 60, '补充材料', { mode: 'input', title: '补一下' }), + node('ok', 'human', 300, 260, '主管审批', { mode: 'approve', title: '可以吗' }), + node('send', 'tool', 600, 60, '发通知', { tool: 'http_request', args: {}, approval: 'always' }), + node('query', 'tool', 600, 260, '查订单', { tool: 'db_query__shop', args: { sql: 'SELECT 1' } }), + node('run', 'code', 600, 460, '跑脚本', { code: 'print(1)', language: 'python', approval: 'always' }), + node('calc', 'code', 600, 660, '算一下', { code: 'print(2)', language: 'python' }), + node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }), + node('back', 'output', 900, 460, '退回', { fields: [{ name: '结果', value: '退回了' }] }), + ], + edges: [edge('in', 'ask'), edge('in', 'ok'), edge('ask', 'send', 'out'), edge('ok', 'query', 'approved'), + edge('send', 'out', 'out'), edge('query', 'out', 'out'), edge('ok', 'run', 'rejected'), edge('run', 'out', 'out'), + edge('calc', 'out', 'out')], + } }) + try { + const { ctx, page, errors } = await open({ path: '/studio/st-reject' }) + await page.waitForTimeout(500) + const exits = (id) => page.evaluate((x) => [...document.querySelectorAll( + `.react-flow__node[data-id="${x}"] .react-flow__handle.source`)].map((h) => h.getAttribute('data-handleid')), id) + const labels = (id) => page.evaluate((x) => [...document.querySelectorAll( + `.react-flow__node[data-id="${x}"] .nc-exit`)].map((el) => el.textContent), id) + check('补充输入:普通出口 + 驳回', (await exits('ask')).join() === 'out,rejected', (await exits('ask')).join()) + check('……出口上写「驳回」', (await labels('ask')).join() === '驳回', (await labels('ask')).join()) + check('审批:批准 + 驳回(同一个 id)', (await exits('ok')).join() === 'approved,rejected', (await exits('ok')).join()) + check('调用工具(每次审批):普通出口 + 驳回', (await exits('send')).join() === 'out,rejected', (await exits('send')).join()) + check('数据源查询(只读、不问人):只有普通出口', (await exits('query')).join() === 'out', (await exits('query')).join()) + check('沙箱代码(每次审批):普通出口 + 驳回', (await exits('run')).join() === 'out,rejected', (await exits('run')).join()) + check('沙箱代码(不审批):只有普通出口', (await exits('calc')).join() === 'out', (await exits('calc')).join()) + + // 连:从补充输入的驳回出口连到「退回」 + await S(page, () => window.__studio.getState().onConnect({ source: 'ask', target: 'back', sourceHandle: 'rejected', targetHandle: null })) + await page.waitForTimeout(300) + const wired = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'ask').map((e) => `${e.sourceHandle}->${e.target}`).sort()) + check('驳回出口连上了(普通出口那条照旧)', wired.join() === 'out->send,rejected->back', wired.join()) + const drawn = await page.evaluate(() => { + const e = window.__studio.getState().edges.find((x) => x.source === 'ask' && x.sourceHandle === 'rejected') + return !!e && !!document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"] .react-flow__edge-path`) + }) + check('……画得出来', drawn) + // 改配置:审批策略改成从不审批,工具不再会被驳回,驳回出口和连在上面的线一起收掉 + await S(page, () => window.__studio.getState().onConnect({ source: 'send', target: 'back', sourceHandle: 'rejected', targetHandle: null })) + await page.waitForTimeout(200) + await S(page, () => { + const n = window.__studio.getState().nodes.find((x) => x.id === 'send') + window.__studio.getState().updateNode('send', { config: { ...n.data.config, approval: 'never' } }) + }) + await page.waitForTimeout(300) + check('改成从不审批:驳回出口没了', (await exits('send')).join() === 'out', (await exits('send')).join()) + const left = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'send').map((e) => `${e.sourceHandle}->${e.target}`)) + check('……连在驳回出口上的线一并收掉,普通出口的线还在', left.join() === 'out->out', left.join()) + // 补充输入改成审批:普通出口的线跟到「批准」,驳回出口的线还在 + await S(page, () => { + const n = window.__studio.getState().nodes.find((x) => x.id === 'ask') + window.__studio.getState().updateNode('ask', { config: { ...n.data.config, mode: 'approve' } }) + }) + await page.waitForTimeout(300) + const moved = await S(page, () => window.__studio.getState().edges + .filter((e) => e.source === 'ask').map((e) => `${e.sourceHandle}->${e.target}`).sort()) + check('补充输入改成审批:普通出口的线跟到批准,驳回的线还在', moved.join() === 'approved->send,rejected->back', moved.join()) + check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | ')) + await ctx.close() + } finally { + delete FAKES['st-reject'] + } +}) + await section('受管工作流在画布上改过之后:页头、版本历史、选择器照当前发布版的级别写「受管」;和发布版内容相同时不写领先', async () => { // 画布上改过之后 status 回到草稿,当前发布版仍是受管级(published_level)。st-govs:撤回改动后保存出来的 v8, // 服务端按图哈希比出和 v6 相同(same_as_published);st-gova:真的改过、领先 2 版 From 32dbfcfb522707a56b241c286c4629e08a3ad211 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 18:15:37 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=AD=90=E5=9B=BE?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E6=8A=A5=E5=91=8A=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E5=88=A4=E3=80=8C=E7=96=91=E4=BC=BC=E8=A2=AB=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E3=80=8D=E3=80=81=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E8=A3=81=E5=86=B3=E7=85=A7=E6=A0=B7=E9=99=8D?= =?UTF-8?q?=E6=A1=A3=E3=80=81=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E4=B8=8E=E6=8E=A8=E6=BC=94=E6=8C=89=E5=8D=A1=E6=89=80=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=E5=B1=82=E6=89=BE=E4=BA=A7=E5=87=BA=E3=80=81=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E9=82=A3=E4=B8=80=E6=AD=A5=E5=86=99=E5=90=8D=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B1 子工作流里有报告撰写节点时:它的 report.checked 进了父运行(节点 id「sub/write」),报告文档却记子图里的 id「write」, 证据接口、可验证报告、质询拿两边一比判「疑似被修改」,评测读答案也说答案不能当证据。 - nodes/report:报告文档、产出工件记这次运行里的节点 id(ctx.run.global_id),和事件同一个;nodes/io 子图自己的出具契约 按同一个 id 核对,_issuance.report.node_id 同样写这次运行里的 id - api/evidence:_reports 默认只认父图上的报告(子图的报告是子工作流那一步内部的东西,父图的答案是子工作流出口的值), nested=True 连子图里的一起给;点名要子图报告的(?report=sub/write)照样点得开(_named_reports);按成果挑报告的兜底 不再挑中子图的报告。成果上没有 _issuance 时退回最后一条 issuance 事件,子图成果节点的那条不认 - bundle:出具档位(_tier)同样不认子图成果节点的 issuance,verifier.js 同一个认法;子图报告的文档在引用链上照常核对 - B2 双盲裁决被驳回、走驳回出口接到出具成果时:节点产出换成 exit_payload,出具缺口和 dual_check 台账条目跟着丢了,审核人驳回的 那组数照样完整出具(比选「两边都不对」还宽)。NodeRejected 加 exit_updates(节点在驳回之前已算出的产出字段、台账、用量), compiler._rejected_exit 并进去;双盲复算驳回时照记缺口「指标「X」两路结果不一致,审核人驳回了裁决」和一条结论为驳回的 dual_check。核对过别的关卡:人工节点、工具调用审批、动作确认、代码执行审批都在产出缺口、台账之前就被驳回,没有可丢的 - B4 子图里的卡转抄同一张子图里的卡、按位置取格:可验证报告(bundle/build._outputs_of)和推演(api/whatif._sources)按这次 运行里的 id 查产出,子图卡上记的是子图里的 id,查不到就整张遮住、推演不了;父图有同名节点时还拿父图那个当依据。改成按卡 所在那一层的 id 查(engine/lineage.in_scope / scoped_id,证据面板 _InputMasks 同一个认法),转抄的卡认不出时的兜底同样换层 - B6 子图里的一步出错、被驳回后 Run.error_node_id 是「sub/x」:实例页(apps/resolve)、通知(notify/service)照终态事件上记的 名字写「子工作流 › x」(run_node_label),不露出内部 id 测试:test_subgraph_evidence(子图含报告:证据图按父图、评测读答案不报被改、子图报告点得开、可验证报告核对通过且不写子图的 档位、子图自己的契约照常;子图卡在可验证报告里不被遮、推演还原得出、父图同名节点不当依据;子图那一步的名字)、 test_dual_check_node(驳回出口接到出具成果照样降档、台账照记、证据面板认得)。每条都去掉修复回跑过,确实失败 --- backend/app/api/evidence.py | 48 ++++--- backend/app/api/whatif.py | 19 ++- backend/app/apps/resolve.py | 20 ++- backend/app/bundle/build.py | 22 ++- backend/app/bundle/verifier.js | 5 +- backend/app/engine/compiler.py | 13 +- backend/app/engine/context.py | 3 + backend/app/engine/lineage.py | 26 ++++ backend/app/engine/nodes/dual_check.py | 24 +++- backend/app/engine/nodes/io.py | 6 +- backend/app/engine/nodes/report.py | 7 +- backend/app/notify/service.py | 8 +- backend/tests/test_dual_check_node.py | 34 +++++ backend/tests/test_subgraph_evidence.py | 178 ++++++++++++++++++++++++ 14 files changed, 368 insertions(+), 45 deletions(-) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 6c42552c..82f491a0 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -323,11 +323,17 @@ class _Report: claims: str | None = None -def _reports(sealed: _Sealed) -> list[_Report]: - """封存范围内每个报告节点最后一次的自查结果,按第一次出现的顺序。""" +def _reports(sealed: _Sealed, *, nested: bool = False) -> list[_Report]: + """封存范围内每个报告节点最后一次的自查结果,按第一次出现的顺序。 + + 默认只认这次运行的图上的报告节点:子工作流里的报告(节点 id「子工作流节点 id/…」,子图的事件和父运行在同一个流里) + 是子工作流那一步内部的东西,父图的答案是子工作流出口的值,不是那份报告本身——证据图、取答案的评测、按成果挑报告 + (_choose)都按父图自己的认。nested 为真时连子工作流里的一起给(按 ?report=sub/write 点开它们)。可验证报告另按全部 + report.checked 核对(bundle/build._report_events:自查过的文档一份都不能少)。""" latest: dict[str, dict[str, Any]] = {} for _, etype, node_id, data in sealed.events: - if etype == "report.checked" and node_id and isinstance(data, dict) and data.get("doc_artifact"): + if etype == "report.checked" and node_id and isinstance(data, dict) and data.get("doc_artifact") \ + and (nested or "/" not in node_id): latest[node_id] = data evidence = _output_evidence(sealed) out = [] @@ -579,11 +585,13 @@ def _metric_in(card: dict[str, Any] | None, metric_id: Any) -> dict[str, Any] | def _issuance_of(sealed: _Sealed) -> dict[str, Any] | None: - """成果上的 _issuance;成果里取不到时退回封存范围内最后一条 issuance 事件。""" + """成果上的 _issuance;成果里取不到时退回封存范围内最后一条 issuance 事件。子工作流里成果节点的出具判定(节点 id + 「子工作流节点 id/…」,子图的事件和父运行在同一个流里)说的是子工作流交出的那份,不是这次运行的,不认。""" output = sealed.payload_of(sealed.nodes_of("output")) or {} issuance = output.get("_issuance") if not isinstance(issuance, dict): - issuance = next((data for _, etype, _, data in reversed(sealed.events) if etype == "issuance"), None) + issuance = next((data for _, etype, node_id, data in reversed(sealed.events) + if etype == "issuance" and "/" not in str(node_id or "")), None) return issuance if isinstance(issuance, dict) else None @@ -873,7 +881,7 @@ async def evidence_metric(run_id: str, node: str, metric: str) -> dict[str, Any] def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str ) -> tuple[_Report, tuple[dict[str, Any], dict[str, Any], dict[str, Any]]]: """选报告、确认文档完好、找到片段:(报告, (块, 句子, 片段))。片段接口和推断来源接口共用同一套错误码。""" - reports = _reports(sealed) + reports = _named_reports(sealed, wanted) if not reports: raise CodedHTTPException(404, "本次运行没有封存的报告文档,没有可查看的片段", REPORT_NOT_FOUND) chosen = _choose(reports, wanted, sealed) @@ -887,6 +895,12 @@ def _open_segment(sealed: _Sealed, wanted: str | None, segment_id: str return chosen, found +def _named_reports(sealed: _Sealed, wanted: str | None) -> list[_Report]: + """点名要某一份报告时从哪些里挑:点的是子工作流里的那份(「sub/write」)连子工作流里的一起给,否则只给父图上的 + (_reports)。""" + return _reports(sealed, nested=bool(wanted) and "/" in str(wanted)) + + def _choose(reports: list[_Report], wanted: str | None, sealed: _Sealed) -> _Report: if wanted: chosen = next((r for r in reports if r.node_id == wanted), None) @@ -1097,13 +1111,10 @@ async def _upstream_steps(metric: dict[str, Any], sealed: _Sealed, masks: "_Mask def _scoped(card_node: Any, node_id: Any) -> Any: - """口径卡上记的节点 id(输入来自哪个节点、穿过的整形节点、转抄的卡)是卡所在那一层图里的 id:子工作流里的卡 - (节点 id「子工作流节点 id/卡」)记的是子图里的 id,换成这次运行里的(接上同一个前缀)。最外层的卡原样;经子工作流 - 出口接上的出处(multi.run_subgraph 记的)已经是卡这一层的 id,同样接上卡的前缀就对了。""" - card = str(card_node or "") - if not isinstance(node_id, str) or not node_id or "/" not in card: - return node_id - return f"{card.rsplit('/', 1)[0]}/{node_id}" + """口径卡上记的节点 id 换成这次运行里的(engine/lineage.scoped_id:子工作流里的卡记的是子图里的 id)。""" + from app.engine.lineage import scoped_id + + return scoped_id(card_node, node_id) def _scoped_ids(item: dict[str, Any], card_node: str, sealed: _Sealed) -> dict[str, Any]: @@ -1335,10 +1346,11 @@ class _CopyGraph: upstream: list[Any] = [] # 转抄的是哪张卡:按读到的产出上记着的指标集认(经数据整形赋给变量、转了一道的也认得出是哪张卡), # 认不出的按取值链上的那个节点 - owner = copied.node_id + # 取值链上写的是卡所在那一层图里的 id(_scoped) + owner = _scoped(node, copied.node_id) if copied.card is not None: up_art = str(copied.card.get("artifact") or "") - owner = self._owner(up_art) or copied.node_id + owner = self._owner(up_art) or owner for index in copied.picked: up_metric = copied.card["metrics"][index] if isinstance(up_metric, dict) and up_metric.get("inputs"): @@ -2943,7 +2955,7 @@ async def export_report_html(run_id: str, report: str | None = None) -> Response ) sealed = await _sealed(run_id) - reports = _reports(sealed) + reports = _named_reports(sealed, report) if not reports: raise CodedHTTPException(404, "本次运行没有报告文档,没有可导出的报告", REPORT_NOT_FOUND) chosen = _choose(reports, report, sealed) @@ -3896,7 +3908,7 @@ def _formal_message(doc: dict[str, Any] | None, claims: str | None, verdicts: li def _formal_refusal(sealed: _Sealed, wanted: str | None, units: list[str] | None = None) -> str: """按需裁判接口拒绝正式运行时的那句话,和片段接口说的一致:按请求要的那份报告(没指定取成果里标注的那一份)、 点的那几句结论句(不是结论句的不算)说。选不出报告、文档读不出来的,只说正式运行不能按需裁判(FORMAL_ONLY)。""" - reports = _reports(sealed) + reports = _named_reports(sealed, wanted) try: chosen = _choose(reports, wanted, sealed) if reports else None except CodedHTTPException: @@ -4070,7 +4082,7 @@ async def judge_units(run_id: str, payload: Any = Body(None), report: str | None raise CodedHTTPException(409, UNSEALED_JUDGE, JUDGE_UNSEALED) if not sealed.trusted: raise CodedHTTPException(409, BROKEN_JUDGE, SEAL_BROKEN_CODE) - reports = _reports(sealed) + reports = _named_reports(sealed, wanted) if not reports: raise CodedHTTPException(404, "这次运行没有封存的报告文档,没有可以判断的句子", REPORT_NOT_FOUND) chosen = _choose(reports, wanted, sealed) diff --git a/backend/app/api/whatif.py b/backend/app/api/whatif.py index a9275906..9ebcb51c 100644 --- a/backend/app/api/whatif.py +++ b/backend/app/api/whatif.py @@ -86,7 +86,7 @@ async def evidence_whatif(run_id: str, payload: Any = Body(None)) -> dict[str, A # 转抄上游口径卡的输入给不给值:和证据面板同一张指标依赖图(copied_inputs → recompute.masked_closure),在事件循环里 # 认好交进去,重算的线程里不再查库 copied = await copied_inputs(sealed, card_node, artifact, card) - sources = _sources(sealed, cards, started, asyncio.get_running_loop(), copied=copied) + sources = _sources(sealed, cards, started, asyncio.get_running_loop(), copied=copied, card_node=card_node) docs = [(r.node_id, r.doc) for r in reports if r.doc is not None and r.hash_ok is True and (report is None or r.node_id == report)] try: @@ -246,15 +246,22 @@ def _card_of(sealed: _Sealed, cards: list[tuple[str, str, dict[str, Any]]], node def _sources(sealed: _Sealed, cards: list[tuple[str, str, dict[str, Any]]], started: int, - loop: asyncio.AbstractEventLoop, *, copied: Mapping[int, Mapping[str, Any]] | None = None - ) -> recompute.Sources: + loop: asyncio.AbstractEventLoop, *, copied: Mapping[int, Mapping[str, Any]] | None = None, + card_node: str = "") -> recompute.Sources: """重算要用的东西(recompute.Sources):封存范围内的工件、口径卡那次执行之前的节点产出和跑完的时刻、快照交回的 时刻、用到哪份才算哪份的遮罩、转抄上游口径卡的输入给不给值(copied)。拿到的 Sources 要在工作线程里用(_Hidden - 在线程里等事件循环查库)。""" + 在线程里等事件循环查库)。 + + 节点产出、跑完的时刻按口径卡所在那一层的 id 交进去(engine/lineage.in_scope):子工作流里的卡(card_node「子工作流 + 节点 id/卡」)的取值链写的是子图里的 id,只看同一张子图里的节点,父图里同名的不算。""" + from app.engine.lineage import in_scope + loader = _loader(sealed, cards) hidden = _Hidden(sealed, loader, loop) - return recompute.Sources(load=loader, queries=tuple(sealed.queries), outputs=_outputs_before(sealed, started), - masked=hidden, returned=_returned(sealed), finished=_finished_before(sealed, started), + return recompute.Sources(load=loader, queries=tuple(sealed.queries), + outputs=in_scope(_outputs_before(sealed, started), card_node), + masked=hidden, returned=_returned(sealed), + finished=in_scope(_finished_before(sealed, started), card_node), secrets=hidden.secrets, copied=dict(copied or {})) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index ea7abd3c..c7b0ccb4 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -1506,6 +1506,9 @@ class _Trail: #: 走了「驳回」出口、而且最后一次执行就是走的驳回出口的关卡:节点 id → 那条 node.finished 的数据(outcome=rejected, #: 带 to:转到哪几步。engine/compiler._rejected_exit)。退回修改之后又批准了的不算:结局是批准 rejected_out: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 终态事件(run.failed / run.cancelled)上记的出错、被驳回的那一步:节点 id → 名字。子工作流里的那一步(「sub/x」) + #: 不在运行的图上,名字只有这里有(runner._label_of 接好的「子工作流 › x」) + end_labels: dict[str, str] = field(default_factory=dict) async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]: @@ -1532,9 +1535,24 @@ async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]: 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) + ended = [r.id for r in runs if r.status in ("failed", "cancelled")] + if ended: + for run_id, data in (await session.execute(select(RunEvent.run_id, RunEvent.data).where( + RunEvent.run_id.in_(ended), RunEvent.type.in_(("run.failed", "run.cancelled"))))).all(): + if isinstance(data, dict) and data.get("node_id") and isinstance(data.get("label"), str) and data["label"]: + out[run_id].end_labels[str(data["node_id"])] = data["label"] return out +def run_node_label(run: Run, trail: _Trail, node_id: str) -> str: + """运行里一个节点给人看的名字:运行的图上的标题;图上没有的(子工作流里的那一步「sub/x」)用终态事件上记的名字 + (「子工作流 › x」);都没有的照旧写 id。实例页、通知(notify/service)说同一个名字。""" + from app.apps.tree import node_map + + found = (node_map(run.graph).get(node_id) or {}).get("title") + return found or trail.end_labels.get(node_id) or node_id + + async def _rejected_exits(session: AsyncSession, runs: list[Run], out: dict[str, _Trail]) -> None: """成功结束、途经驳回出口的运行(run.finished 带 rejected_via,engine/runner._rejected_via):每个走过驳回出口的关卡, 最后一次执行是不是走的驳回出口(退回修改之后又批准的,结局是批准)。只翻途经驳回的运行里那几个关卡的 node.finished。""" @@ -1651,7 +1669,7 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: nodes = node_map(run.graph) def title(node_id: str) -> str: - return (nodes.get(node_id) or {}).get("title") or node_id + return (nodes.get(node_id) or {}).get("title") or trail.end_labels.get(node_id) or node_id if run.status == "interrupted": if any(a.state in ("sent", "unknown") for a in trail.actions): diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index b088980a..f742a610 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -58,6 +58,7 @@ from urllib.parse import quote from app.core.artifact_store import canonical_json, content_hash from app.core.events import EventType from app.engine.evidence import iter_units, query_entry_fields +from app.engine.lineage import in_scope, scoped_id from app.engine.masking import OPAQUE_SQL, SYSTEM_SCHEMAS, indirect_reads from app.engine.merge_query import MERGE_SOURCE from app.engine.recompute import ( @@ -300,10 +301,11 @@ class _Store: if copied is not None: upstream: list[Any] = [] # 转抄的是哪张卡:按读到的产出上记着的指标集认(经数据整形赋给变量的也认得出),认不出的按取值链上的节点 - owner = copied.node_id + # 取值链上写的是卡所在那一层图里的 id(engine/lineage.scoped_id) + owner = scoped_id(self._owner(art), copied.node_id) if copied.card is not None: up_card = str(copied.card.get("artifact") or "") - owner = self._owner(up_card) or copied.node_id + owner = self._owner(up_card) or owner for index in copied.picked: up_metric = copied.card["metrics"][index] if isinstance(up_metric, dict) and up_metric.get("inputs"): @@ -353,9 +355,13 @@ class _Store: def _outputs_of(self, card: str, events: Sequence[tuple[int, str, str | None, Any]]) -> Callable[[Any], Any]: """口径卡 card 那一次执行之前每个节点的产出(_card_outputs):节点 id → 读出来的内容,读不出来、和 id 对不上的是 - None。同一张卡只找一次。""" + None。同一张卡只找一次。 + + 卡上的取值链按卡所在那一层图里的 id 写(engine/lineage.in_scope):子工作流里的卡(台账记在「子工作流节点 id/卡」 + 上)只看同一张子图里的节点,按去掉前缀的 id 查,和证据面板 api/evidence._InputMasks._output 同一个认法。以前按这次 + 运行里的 id 查,子图里的卡转抄、按位置取的格一律查不到、当成核对不了整张遮住;父图里有同名的节点时还拿它当依据。""" if card not in self._card_outputs: - self._card_outputs[card] = _card_outputs(events, card) + self._card_outputs[card] = in_scope(_card_outputs(events, card), self._owner(card)) outputs = self._card_outputs[card] def output(node_id: Any) -> Any: @@ -1207,9 +1213,11 @@ def _summary(etype: str, data: Any) -> str: def _tier(events: Sequence[tuple[int, str, str | None, Any]]) -> dict[str, Any] | None: - """出具档位:封存范围内最后一条出具判定事件里的 tier,带上序号(浏览器拿那条已核对的事件对照)。""" - for seq, etype, _, data in reversed(events): - if etype == EventType.ISSUANCE and isinstance(data, dict) and isinstance(data.get("tier"), str): + """出具档位:封存范围内最后一条出具判定事件里的 tier,带上序号(浏览器拿那条已核对的事件对照)。子工作流里成果节点 + 的出具判定(节点 id「子工作流节点 id/…」)说的是子工作流交出的那份,不是这次运行的,不认(verifier.js 同一个认法)。""" + for seq, etype, node_id, data in reversed(events): + if etype == EventType.ISSUANCE and "/" not in str(node_id or "") and isinstance(data, dict) \ + and isinstance(data.get("tier"), str): return {"value": data["tier"], "seq": seq} return None diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index 24bf3a60..ffca5f15 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -1568,11 +1568,12 @@ } } - // 出具档位:封存范围内最后一条记下了档位的出具判定(和服务端 build._tier 同一个认法),还要是元信息指的那一条 + // 出具档位:封存范围内最后一条记下了档位的出具判定(和服务端 build._tier 同一个认法),还要是元信息指的那一条。 + // 子工作流里成果节点的出具判定(节点 id「子工作流节点 id/…」)说的是子工作流交出的那份,不是这次运行的,不认 const tierClaim = isObj(meta.tier) ? label(TIER_LABEL, meta.tier.value) : '—' let found = null for (const ev of [...order].reverse()) { - if (ev.type !== 'issuance') continue + if (ev.type !== 'issuance' || String(ev.node_id ?? '').includes('/')) continue if (typeof ev.canonical !== 'string') found = { result: FAIL, reason: T.tierAbsent(ev.seq) } else if (!out.checked.has(ev)) found = { result: NA, reason: T.tierUnchecked(ev.seq) } else if (typeof own(out.checked.get(ev), 'tier') === 'string') found = { ev, value: out.checked.get(ev).tier } diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index 9e3a1f72..bfc48df8 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -218,13 +218,17 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, """被驳回、接了「驳回」出口:这一步正常结束(node.finished 带 outcome=rejected 和哪种关卡、结论、谁、备注、转到哪几步), 产出 NodeRejected.exit_payload(),路由按它的 __decision__ 走驳回那条边。 + 节点在驳回之前已经算出的(NodeRejected.exit_updates)并进产出、台账和用量:双盲复算的裁决被驳回时,复算比出的不一致 + 照样记成出具缺口、台账照记一条结论为驳回的 dual_check,下游的出具不会把审核人驳回的那组数完整出具。 动作确认被驳回时 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() + extra = error.exit_updates or {} + # 节点在驳回之前已经算出的(NodeRejected.exit_updates:双盲复算的出具缺口、台账条目)照样交出;驳回的那几个键在上面 + payload = {**(extra.get("payload") or {}), **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: @@ -234,12 +238,17 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, except Exception: # noqa: BLE001 - 工件写失败不该毁掉运行本身 artifact_id = None said = {"kind": error.kind, "decision": error.decision, "actor": error.actor, "note": error.note} + ledger = [e for e in extra.get("evidence") or [] if isinstance(e, dict)] 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) + outcome="rejected", **said, to=to, **({"evidence": ledger} if ledger else {})) 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}]} + if ledger: + updates["evidence"] = ledger + if extra.get("usage"): + updates["usage"] = dict(extra["usage"]) var_name = str(node.config.get("assign_to") or "").strip() if var_name and error.kind == "approve": updates["vars"] = {var_name: False} diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index 26dee96b..fb85a203 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -167,6 +167,9 @@ class NodeRejected(NodeError): self.actor = actor self.note = note self.stop = stop + #: 走驳回出口时这一步另外交出的:{payload: 产出里的其余字段, evidence: 台账条目, usage: 用量}。节点在抛之前按需填: + #: 驳回之前已经算出、下游要据此判档的东西不能跟着驳回丢掉(双盲复算的出具缺口和台账条目)。没接出口时用不上 + self.exit_updates: dict[str, Any] | None = None @classmethod def of(cls, ctx: Any, message: str, *, kind: str, decision: Any, stop: bool = False) -> NodeRejected: diff --git a/backend/app/engine/lineage.py b/backend/app/engine/lineage.py index 93630579..e1e01b2c 100644 --- a/backend/app/engine/lineage.py +++ b/backend/app/engine/lineage.py @@ -137,6 +137,32 @@ def untraced(inputs: Iterable[Any]) -> list[dict[str, Any]]: if isinstance(item, dict) and item.get("value") is not None and not traced(item)] +# -------------------------------------------------------------------------- +# 卡上记的节点 id 在哪一层 +# -------------------------------------------------------------------------- + + +def scoped_id(card_node: Any, node_id: Any) -> Any: + """口径卡上记的节点 id(输入来自哪个节点、穿过的整形节点、转抄的卡、取值链里写的 nodes.X)是卡所在那一层图里的 id: + 子工作流里的卡(这次运行里的 id「子工作流节点 id/卡」)记的是子图里的 id,换成这次运行里的(接上同一个前缀)。最外层 + 的卡原样;经子工作流出口接上的出处(multi.run_subgraph 记的「子工作流节点 id/…」)已经是卡这一层的 id,同样接上卡的 + 前缀就对了。证据面板、可验证报告、推演都按这一个认。""" + card = str(card_node or "") + if not isinstance(node_id, str) or not node_id or "/" not in card: + return node_id + return f"{card.rsplit('/', 1)[0]}/{node_id}" + + +def in_scope(table: Mapping[str, Any], card_node: Any) -> dict[str, Any]: + """按这次运行里的节点 id 记的表(每个节点那一刻的产出、跑完的时刻)→ 按口径卡所在那一层的 id 记的:子工作流里的卡只看 + 同一张子图里的(去掉前缀;父图里同名的节点不算),最外层的卡原样。卡上的取值链按它这一层的 id 查(scoped_id 的反面)。""" + card = str(card_node or "") + if "/" not in card: + return dict(table) + prefix = card.rsplit("/", 1)[0] + "/" + return {key[len(prefix):]: value for key, value in table.items() if key.startswith(prefix)} + + # -------------------------------------------------------------------------- # 静态地认(只有图) # -------------------------------------------------------------------------- diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index b334e5cb..642cd847 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -86,6 +86,8 @@ from app.engine.tolerance import agree as _agree, delta_pct as _delta_pct #: 节点产出的状态:跳过、全部一致、核成了一部分(其余无法复算)、一个都没核成、人已裁决 SKIPPED, AGREED, PARTIAL, NONE_CHECKED, RESOLVED = "skipped", "agreed", "partial", "unchecked", "resolved" +#: 审核人驳回了裁决、接了驳回出口沿它往下走(docs/design/orchestration-round/00-总方案.md §3):这一步的结论 +REJECTED = "rejected" #: 单个指标的结果:一致、不一致、无法复算 SAME, DIFFERENT, UNCHECKED = "agreed", "disagreed", "unchecked" #: 审核人的三种选择:采用原流程、采用盲算、两边都不对 @@ -133,6 +135,7 @@ NO_SQL = "有一边没有可比较的 SQL,请对照原文" GAP_BLIND = "指标「{name}」两路结果不一致,审核人选择了盲算结果" GAP_NEITHER = "指标「{name}」两路结果不一致,审核人判断两边都不对" GAP_UNANSWERED = "指标「{name}」两路结果不一致,审核人没有作出选择" +GAP_REJECTED = "指标「{name}」两路结果不一致,审核人驳回了裁决" GAP_UNCHECKED = "指标「{name}」无法复算:{reason}" #: 运行时长上限将至(engine/deadline.py):没来得及开始的盲算、时长按剩余时间收紧后到了点的盲算,都按无法复算记 LATE = "本次运行即将达到 {seconds:g} 秒的时长上限,为保证运行按时完成,未进行盲算" @@ -222,7 +225,17 @@ async def run_dual_check(state: GraphState, ctx: NodeContext) -> dict[str, Any]: response = await ask(ctx, review["payload"], **review["payload"]) # 谁裁决的跟着事件走,和人工审批节点一样 await once(ctx, EventType.HUMAN_RESOLVED, response=response, actor=ctx.actor()) - choices, note = _choices(ctx, response, disagreed) + try: + choices, note = _choices(ctx, response, disagreed) + except NodeRejected as e: + # 审核人驳回了裁决。接了驳回出口时(engine/compiler._rejected_exit)这一步照常结束:两路不一致的那几个指标照样记成 + # 出具缺口,台账照记一条结论为驳回的 dual_check——驳回不是认可,下游的出具不能把这组数完整出具(不比选「两边 + # 都不对」宽)。没接出口的运行收成被驳回,这些用不上 + done = _finish(state, ctx, card_id, card, checked, status=REJECTED, gaps=_rejected_gaps(rows), choices={}, + note=e.note, artifact=review["card_artifact"]) + e.exit_updates = {"payload": done["nodes"][ctx.node.id], "evidence": done.get("evidence"), + "usage": done.get("usage")} + raise gaps = _gaps(rows, choices) await once(ctx, EventType.LOG, level="info", code="dual_check_resolved", choices=choices, gaps=gaps, message=_resolved_text(disagreed, choices)) @@ -1040,6 +1053,13 @@ def _compare(row: dict[str, Any], metric: dict[str, Any], results: dict[str, Any row["differing"] = differing or [i["key"] for i in row["inputs"]] +def _rejected_gaps(rows: list[dict[str, Any]]) -> list[str]: + """裁决被驳回时的出具缺口:不一致的写审核人驳回了裁决,无法复算的照旧写原因。""" + return [GAP_REJECTED.format(name=r["name"]) if r["status"] == DIFFERENT + else GAP_UNCHECKED.format(name=r["name"], reason=r.get("reason") or "原因不明") + for r in rows if r["status"] in (DIFFERENT, UNCHECKED)] + + def _unchecked_gaps(rows: list[dict[str, Any]]) -> list[str]: return [GAP_UNCHECKED.format(name=r["name"], reason=r.get("reason") or "原因不明") for r in rows if r["status"] == UNCHECKED] @@ -1474,4 +1494,4 @@ def _finish(state: GraphState, ctx: NodeContext, card_id: str, card: dict[str, A return updates -__all__ = ["AGREED", "CHOICES", "NONE_CHECKED", "PARTIAL", "RESOLVED", "SKIPPED", "run_dual_check"] +__all__ = ["AGREED", "CHOICES", "NONE_CHECKED", "PARTIAL", "REJECTED", "RESOLVED", "SKIPPED", "run_dual_check"] diff --git a/backend/app/engine/nodes/io.py b/backend/app/engine/nodes/io.py index ff53d3ca..c642a0fd 100644 --- a/backend/app/engine/nodes/io.py +++ b/backend/app/engine/nodes/io.py @@ -443,7 +443,8 @@ def _check_citations( if not isinstance(doc, dict): gaps.append(f"无法读取报告「{node.title}」的文档,未执行引用核对") return out - if doc.get("node_id") != report_from or doc.get("run_id") != ctx.run.run_id \ + # 文档记的是这次运行里的节点 id(子工作流里的报告带前缀,nodes/report 的 compose) + if doc.get("node_id") != ctx.run.global_id(str(report_from)) or doc.get("run_id") != ctx.run.run_id \ or doc.get("markdown") != payload.get("text"): gaps.append(f"报告「{node.title}」的文档与本次运行中的产出不一致,未执行引用核对") return out @@ -899,7 +900,8 @@ def _declare_citations( declared_at: Any, ctx: NodeContext, sql_checks: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """引用模式的 _issuance。旧字段一个不少(老前端照常显示),另加 mode、report、unresolved。""" - report = {"node_id": report_from, "doc_artifact": cited["doc_artifact"]} + # 节点 id 和报告文档、report.checked 事件上记的同一个(这次运行里的,子工作流里的带前缀) + report = {"node_id": ctx.run.global_id(report_from), "doc_artifact": cited["doc_artifact"]} issuance = { "mode": "citations", "tier": tier, diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index b2596b6f..ff5ed473 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -620,7 +620,7 @@ async def _store(doc: dict[str, Any], ctx: NodeContext) -> str | None: from app.core.artifact_store import put_json try: - return await put_json(doc, kind="report_doc", run_id=ctx.run.run_id, node_id=ctx.node.id, + return await put_json(doc, kind="report_doc", run_id=ctx.run.run_id, node_id=ctx.run.global_id(ctx.node.id), meta={"stats": doc.get("stats") or {}}) except Exception as e: # noqa: BLE001 - 工件写失败不毁掉运行,只是这份报告点不开证据 ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", @@ -710,7 +710,10 @@ async def run_report(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 第一稿、重写稿、裁判退回的改写稿都从这里过:整篇包在代码块里的先去掉外层围栏。文档的 source 记的是去掉 # 围栏之后的正文——markdown 由它渲染,出口契约复核取回的也是这份文档。引用了遮罩列的格子组装好之后再认 # (_MaskedCells):只读正文里引用到的那几份快照 - return await masked(compose_doc(_unfenced(raw), catalog, node_id=ctx.node.id, run_id=ctx.run.run_id, + # 文档记这次运行里的节点 id(子工作流里的报告是「子工作流节点 id/…」):和它的 report.checked 事件、产出工件同一个 + # id。证据接口、可验证报告、质询都拿文档上的 id 和事件上的比,对不上就判「疑似被修改」 + return await masked(compose_doc(_unfenced(raw), catalog, node_id=ctx.run.global_id(ctx.node.id), + run_id=ctx.run.run_id, allow_numbers=allow, cells_allowed=cells, entities=entities, absence=writer.trimmed)) diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index 462f4643..4c401397 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -436,7 +436,7 @@ async def notify_run(run_id: str, event: str) -> int: 发起人是认证过的身份(run.origin.actor_verified)时,他自己订阅了这件事的个人渠道也收得到(「我发起的实例完成 / 失败」, 04-对外集成与触达 B1)。以前这里给 _channels 的 users 是空的,个人渠道勾了这两项永远收不到。自报的署名不算:关登录 时谁都能填别人的名字。""" - from app.apps.resolve import _trails, instance_state + from app.apps.resolve import _trails, instance_state, run_node_label async with SessionLocal() as session: run = await session.get(Run, run_id) @@ -448,12 +448,14 @@ async def notify_run(run_id: str, event: str) -> int: return 0 base = await _base_url(session) where = await _where(session, run) - state = instance_state(run, (await _trails(session, [run]))[run.id]) + trail = (await _trails(session, [run]))[run.id] + state = instance_state(run, trail) finished = event == "run.finished" ok = state["code"] == "succeeded" lines = [("状态", state["text"]), ("实例", f"#{run_id[:6]}")] if not finished and run.error_node_id and state["code"] == "failed": - lines.append(("失败节点", f"「{_label(run.graph, run.error_node_id)}」")) + # 子工作流里的那一步(「sub/x」)图上没有:照终态事件上记的名字写,和实例页同一个(apps/resolve.run_node_label) + lines.append(("失败节点", f"「{run_node_label(run, trail, run.error_node_id)[:80]}」")) head = ("已完成" if ok else "已结束") if finished else ("已结束" if state["code"] == "cancelled" else "失败") message = {"event": event, "status": run.status, "ok": ok, "title": f"{where or '应用'}的实例{head}", "lines": lines, diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 67e08954..01b207b7 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -555,6 +555,40 @@ async def test_rejecting_the_approval_with_a_reject_exit_goes_on_along_it(monkey assert not [e for e in evs if e.type == "node.finished" and e.node_id == "out"], "普通出口没有走" +async def test_a_rejected_arbitration_through_the_exit_still_degrades_the_issuance(monkeypatch, shop, client): + """驳回出口直接接到出具的成果节点:审核人驳回了裁决,那组数不能照样完整出具。走出口时这一步的产出照样带出具缺口 + (「审核人驳回了裁决」),台账照记一条结论为驳回的 dual_check,证据面板的指标步骤据此认出复核过这份指标集; + 不比选「两边都不对」宽。""" + from app.api.evidence import _dual_check_of, _sealed + + name, _ = shop + Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) + spec = graph(name, BY_ORDER_DATE) + spec["edges"].append({"source": "dual", "target": "out", "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 == "succeeded", done.error + issuance = done.output["_issuance"] + assert issuance["tier"] == "degraded", issuance + assert "双盲复算「复核销售额」:指标「销售额」两路结果不一致,审核人驳回了裁决" in issuance["gaps"], issuance["gaps"] + 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"] == "rejected" + [entry] = [e for e in finished.data.get("evidence") or [] if e.get("kind") == "dual_check"] + assert (entry["status"], entry["card"]) == ("rejected", "card") and entry["metrics"][0]["status"] == "disagreed" + assert finished.data["preview"]["status"] == "rejected" and finished.data["preview"]["rejected"] is True + sealed = await _sealed(run.id) + [(_, artifact, _)] = [c for c in __import__("app.api.evidence", fromlist=["_sealed_cards"])._sealed_cards(sealed)] + checked = _dual_check_of(sealed, "card", artifact, "gmv") + assert checked is not None and checked["status"] == "disagreed" and "choice" not in checked, checked + # 对照:同一处选「两边都不对」也是降档 + other = await start(graph(name, BY_ORDER_DATE)) + assert (await decide(client, other.id, {"gmv": "neither"})).output["_issuance"]["tier"] == "degraded" + + async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client): name, source_id = shop Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) diff --git a/backend/tests/test_subgraph_evidence.py b/backend/tests/test_subgraph_evidence.py index 572d1634..f03dcc21 100644 --- a/backend/tests/test_subgraph_evidence.py +++ b/backend/tests/test_subgraph_evidence.py @@ -119,6 +119,49 @@ async def test_the_subgraph_steps_are_in_the_run_with_prefixed_ids(): assert row.output == {"数": "246.5"} +async def test_a_step_inside_the_subgraph_is_named_by_its_label_not_its_id(): + """子图里的一步被驳回、出错时,运行记下的出错节点是「sub/x」:实例页、通知照终态事件里的名字写「子工作流 › x」, + 不露出内部 id。""" + from app.apps.resolve import _trails, instance_state, run_node_label + from app.db.models import Run + + sub = S.chain(node("s", "input"), + node("gate", "tool", "算一下", tool="calculator", args={"expression": "1+1"}, approval="always"), + node("o", "output", fields=[{"name": "x", "value": "{{ nodes.gate }}"}])) + sub_id = await publish(sub) + graph = S.chain(node("start", "input"), node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", fields=[{"name": "x", "value": "{{ nodes.sub.output.x }}"}])) + started = await run_manager.start(graph=graph, input_payload={}) + for _ in range(200): + async with SessionLocal() as session: + if (await session.get(Run, started.id)).status == "interrupted" and not run_manager.is_active(started.id): + break + await __import__("asyncio").sleep(0.05) + await run_manager.resume(started.id, {"approved": False, "note": "不对"}, actor="张工") + for _ in range(200): + async with SessionLocal() as session: + row = await session.get(Run, started.id) + if row.status == "cancelled" and not run_manager.is_active(started.id): + break + await __import__("asyncio").sleep(0.05) + assert (row.status, row.end_reason, row.error_node_id) == ("cancelled", "rejected", "sub/gate") + async with SessionLocal() as session: + trail = (await _trails(session, [row]))[row.id] + assert instance_state(row, trail) == {"code": "cancelled", "text": "「取数子流程 › 算一下」被驳回"} + assert run_node_label(row, trail, "sub/gate") == "取数子流程 › 算一下" + + failing = await publish(fetch_card_graph(sql="SELECT nope FROM nowhere"), "出错的") + started = await run_manager.start(graph=parent(failing, contract_from=None, + out=[{"name": "数", "value": "{{ nodes.sub.output.gmv }}"}]), + input_payload={}) + row = await S.wait(started.id) + assert row.status == "failed" and row.error_node_id == "sub/fetch" + async with SessionLocal() as session: + trail = (await _trails(session, [row]))[row.id] + assert run_node_label(row, trail, row.error_node_id) == "取数子流程 › fetch" + assert run_node_label(row, trail, "sub") == "取数子流程" + + async def test_the_instance_timeline_counts_the_subgraph_as_one_step(): """应用的实例进度只说这张图上的步骤:子图里的那几步(「sub/…」)不单列,子工作流算一步。""" from types import SimpleNamespace @@ -356,6 +399,141 @@ async def test_the_parent_report_cites_the_subgraph_query_and_card(client, monke assert res.status_code == 200 and "246.5" in res.text +#: 载入可验证报告的核对脚本,交回引用链上每份报告文档的核对结果(同 tests/test_bundle_rejected.py 的做法) +VERIFY_CHAIN = r""" +const fs = require('fs') +const { createHash } = require('crypto') +const input = JSON.parse(fs.readFileSync(0, 'utf8')) +const mod = { exports: {} } +new Function('module', fs.readFileSync(process.argv[1], 'utf8'))(mod) +const digest = async (bytes) => createHash('sha256').update(bytes).digest('hex') +mod.exports.verify(input.data, digest, input.data.meta.manifest_hash).then((out) => { + process.stdout.write(JSON.stringify(out.chain.filter((c) => c.kind === 'report_doc') + .map((c) => ({ node: c.report && c.report.node_id, result: c.result, reason: c.reason || '' })))) +}, (e) => { console.error((e && e.stack) || String(e)); process.exit(1) }) +""" + + +async def test_a_report_inside_the_subgraph_is_not_tampered_and_not_the_parents_answer(client, monkeypatch): + """子图里有报告撰写节点:它的 report.checked 进了父运行(节点 id「sub/write」),报告文档记的也是这次运行里的 id, + 不被认成「疑似被修改」。父图自己没有报告:父图的答案是子图出口的值,不是子图那份报告——证据图、评测读答案都按父图 + 自己认(和子图事件进运行之前一样:没有报告),子图的报告按 ?report=sub/write 点得开,可验证报告里核对通过。""" + import json + import re + import shutil + import subprocess + from pathlib import Path + + import app.bundle + from app.api.evidence import _reports, _sealed + from app.eval.score import read_answer + + sub = S.chain(node("s", "input"), node("fetch", "tool", tool=S.tool(), args={"sql": PAID}), + node("write", "report", instructions="写答案"), + node("o", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}], + contract={"report_from": "write"})) + sub_id = await publish(sub) + graph = S.chain(node("start", "input"), node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.sub.output.答案 }}"}])) + S.script(monkeypatch, "子流程里查到的销售额 [[v:Q1.r0.gmv]]。") + started = await run_manager.start(graph=graph, input_payload={}) + row = await S.wait(started.id) + assert row.status == "succeeded", row.error + + body = (await client.get(f"/api/runs/{row.id}/evidence")).json() + # 父图没有报告、没有契约:照没有契约的答案按数值猜候选(legacy_text,写明是猜的)。子图查过的数如今在封存范围里, + # 猜得到它;不再把子图的报告当成父图的、更不说它被改过 + assert (body["mode"], body["reports"]) == ("legacy_text", []), body + [guessed] = [c for f in body["legacy"]["fields"] for seg in f["segments"] for c in seg.get("candidates") or []] + assert (guessed["node_id"], guessed["value"]) == ("sub/fetch", 246.5) + # 子图自己的出具契约照常核对它那份报告(文档上记的 id 和这一层的报告节点对得上) + from app.core.artifact_store import load + + inner_issuance = load((await _sealed(row.id)).outputs["sub"])["output"]["_issuance"] + assert inner_issuance["tier"] == "formal" and inner_issuance["report"]["node_id"] == "sub/write", inner_issuance + answer = await read_answer(row.id) + assert answer.problem is None and answer.report is None and "246.5" in row.output["答案"], answer + # 子图的报告本身:这次运行里的 id、哈希核对通过、点得开 + [inner] = _reports(await _sealed(row.id), nested=True) + assert (inner.node_id, inner.hash_ok, (inner.doc or {}).get("node_id")) == ("sub/write", True, "sub/write") + seg = next(s["id"] for b in inner.doc["blocks"] for u in b["units"] for s in u["segments"] + if (s.get("cite") or {}).get("kind") == "cell") + res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg}", params={"report": "sub/write"}) + assert res.status_code == 200, res.text + [query] = [st for st in res.json()["chain"] if st["step"] == "query"] + assert query["node_id"] == "sub/fetch" and query["sealed"] is True + # 可验证报告:子图那份报告的文档在引用链上核对通过(文档记的节点 id 和自查事件对得上) + node_bin = shutil.which("node") + if node_bin: + html = (await client.get(f"/api/runs/{row.id}/bundle.html")).text + data = json.loads(re.search(r'', html, re.S).group(1)) + verifier = Path(app.bundle.__file__).parent / "verifier.js" + done = subprocess.run([node_bin, "-e", VERIFY_CHAIN, str(verifier)], input=json.dumps({"data": data}), + capture_output=True, text=True, timeout=120) + assert done.returncode == 0, done.stderr[-2000:] + assert json.loads(done.stdout) == [{"node": "sub/write", "result": "pass", "reason": ""}] + # 这次运行没有出具判定(子图成果节点的那条是子图自己的):元信息不写档位 + assert data["meta"]["tier"] is None, data["meta"]["tier"] + + +@pytest.mark.parametrize("namesake", [False, True]) +async def test_the_bundle_and_whatif_read_a_subgraph_card_in_its_own_layer(client, monkeypatch, namesake): + """子图里一张卡转抄同一张子图里的另一张(card1 ← card0),父图报告引用 [[m:g1]],全程没设遮罩:卡上记的是子图里的 id + (nodes.card0…),可验证报告、推演都按卡所在的那一层找产出——找得到,不当成「核对不了」整张遮住;父图里另有一个同名的 + card0(namesake)时,也不拿它当依据(推演还原转抄来的值,还原成子图那张的 246.5,不是父图那张的 1)。""" + import json + import re + + # card0 另有一个按位置取的格(vars.rows.rows[0][0],经原样解析查询结果的 parse):可验证报告、推演都要顺着取值链从 + # parse 那一次的产出走 + sub = S.chain(node("s", "input"), node("fetch", "tool", tool=S.tool(), args={"sql": PAID}), + node("parse", "transform", mode="json", template="{{ nodes.fetch }}", assign_to="rows"), + node("card0", "metrics", caliber="子口径", caliber_version="v1", + metrics=[GMV, metric("first", "vars.rows.rows[0][0]"), + metric("cnt", "len(nodes.parse.rows)")]), + node("card1", "metrics", caliber="转抄", caliber_version="v1", + metrics=[metric("g1", "nodes.card0.metrics[0].value")]), + node("o", "output", fields=[{"name": "gmv", "value": "{{ nodes.card1.metrics[0].value }}"}])) + sub_id = await publish(sub) + parts = [node("start", "input")] + if namesake: + parts += [node("card0", "metrics", caliber="父口径", caliber_version="v1", metrics=[metric("one", "1")]), + node("parse", "transform", mode="json", template='{"rows": [[1], [2]]}', assign_to="other")] + parts += [node("sub", "subgraph", "取数子流程", workflow_id=sub_id, workflow_version=1), + node("write", "report", instructions="写答案"), + node("out", "output", fields=[{"name": "答案", "value": "{{ nodes.write.text }}"}])] + S.script(monkeypatch, "转抄的口径 [[m:g1]],第一行 [[m:first]]。") + started = await run_manager.start(graph=S.chain(*parts), input_payload={}) + row = await S.wait(started.id) + assert row.status == "succeeded", row.error + card1 = next(e["artifact"] for e in (await card_entries(row.id)) if e["node_id"] == "sub/card1") + html = (await client.get(f"/api/runs/{row.id}/bundle.html")).text + data = json.loads(re.search(r'', html, re.S).group(1)) + entry = next(a for a in data["artifacts"] if a["id"] == card1) + assert "masked" not in entry and entry.get("b64"), entry + # 推演:转抄的那个输入按子图里 card0 那次的产出还原,什么都不改时值不变、没有推演不了的 + res = await client.post(f"/api/runs/{row.id}/evidence/whatif", json={"card": "sub/card1"}) + assert res.status_code == 200, res.text + body = res.json() + assert body["unsupported"] == [], body["unsupported"] + [g1] = [m for m in body["metrics"] if m.get("id") == "g1"] + assert g1.get("changed") in (False, None) and g1.get("new") == g1.get("old") == 246.5, g1 + card0 = next(e["artifact"] for e in (await card_entries(row.id)) if e["node_id"] == "sub/card0") + entry = next(a for a in data["artifacts"] if a["id"] == card0) + assert "masked" not in entry and entry.get("b64"), entry + res = await client.post(f"/api/runs/{row.id}/evidence/whatif", json={"card": "sub/card0"}) + assert res.status_code == 200 and res.json()["unsupported"] == [], res.json() + # nodes.parse.rows 这种整份产出的输入从 parse 那一次的产出还原:子图里的 parse(1 行),不是父图里同名的那个(2 行) + [cnt] = [m for m in res.json()["metrics"] if m.get("id") == "cnt"] + assert cnt.get("new") == cnt.get("old") == 1, cnt + + +async def card_entries(run_id: str) -> list[dict]: + from app.api.evidence import _sealed + + return [e for e in (await _sealed(run_id)).entries if e.get("kind") == "metric_set"] + + async def test_masks_inside_the_subgraph_follow_the_parent_run(client, monkeypatch, tmp_path): """数据源遮了 phone:子图里查出来的号码在父图报告的证据面板里照样写「已遮罩」。""" await S.set_source(tmp_path / "shop.db", options={"mask_columns": "phone"}) From 3e6032bdcab6017d22a34866d7ada9ae317d6e84 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sun, 18 Oct 2026 18:18:53 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E3=80=81=E5=B9=B6=E8=A1=8C=E6=95=B0=E3=80=81?= =?UTF-8?q?=E3=80=8C=E5=BD=93=E5=89=8D=E5=9C=A8=E8=B7=91=E3=80=8D=E4=B8=8D?= =?UTF-8?q?=E6=8A=8A=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E5=8F=A6=E7=AE=97=EF=BC=9B=E5=AD=90=E5=9B=BE?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E6=AD=A5=E5=86=99=E3=80=8C=E5=AD=90=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=20=E2=80=BA=20=E9=82=A3=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B5 run/trace:子工作流里的步骤(isSubStep:节点 id「子工作流节点 id/…」)算在子工作流那一步里——完成数不另算(以前运行详情 会写「7/3」),没有 run.started 时的分母也不算它们;Telemetry 的「当前「X」等 N 个」同一个规矩,子工作流和它里面那一步不算成 两个同时在跑 - B6 run/trace.stepLabel:图上没有的子图步骤接上子工作流的名字(「取数子流程 › 查订单」,和服务端终态事件的 label 同一个拼法, 名字取 node.started 记的);运行胶囊(RunHud)、泳道(RunTimeline)的失败、阻断说法和泳道名、记录页的失败横幅与航迹 (RunDetailView.labelOf)都用它,不再写「失败于「sub/x」」 检查脚本:check-decode 加两段(子工作流步骤的完成数、并行数、当前在跑;出错在子图里那一步时的名字) --- frontend/src/canvas/RunTimeline.tsx | 10 +++++--- frontend/src/pages/runs/RunDetailView.tsx | 8 +++--- frontend/src/pages/runs/Telemetry.tsx | 5 ++-- frontend/src/run/RunHud.tsx | 5 ++-- frontend/src/run/trace.ts | 30 ++++++++++++++++++++--- scripts/check-decode.mjs | 30 +++++++++++++++++++++++ 6 files changed, 72 insertions(+), 16 deletions(-) diff --git a/frontend/src/canvas/RunTimeline.tsx b/frontend/src/canvas/RunTimeline.tsx index 776b8249..adfd77d8 100644 --- a/frontend/src/canvas/RunTimeline.tsx +++ b/frontend/src/canvas/RunTimeline.tsx @@ -7,7 +7,7 @@ import { formatClock, formatDuration, formatLapse, formatOffset, formatSpan, for import { statusMeta } from '../lib/status' import { topology, withSubSteps, type GraphLike } from '../run/derive' import { - isActivePhase, isTerminal, lastEventAt, liveAt, project, type NodeState, type NodeTrace, type Projection, + isActivePhase, isTerminal, lastEventAt, liveAt, project, stepLabel, type NodeState, type NodeTrace, type Projection, type Segment, type Trace, } from '../run/trace' import { projectCached } from '../run/useNodeView' @@ -341,7 +341,9 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map [n.id, n])) // 子工作流里的步骤排在子工作流那一行下面(withSubSteps),老运行里改过图的照旧排在最后 const ids = withSubSteps(topo.order, Object.keys(trace.nodes).filter((id) => !byId.has(id))) - const failedLabel = trace.failedNodeId ? labels.get(trace.failedNodeId) ?? trace.failedNodeId : '' + const own = (id: string) => labels.get(id) + // 子工作流里的那一步图上没有:接上子工作流的名字(stepLabel) + const failedLabel = trace.failedNodeId ? stepLabel(trace, trace.failedNodeId, own) : '' const rows: Row[] = [] let top = 0 for (const id of ids) { @@ -350,7 +352,7 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map onFocusNode?.(trace.failedNodeId!)} title="在画布上定位到失败的节点"> - 失败于「{labels.get(trace.failedNodeId) ?? trace.failedNodeId}」 + 失败于「{stepLabel(trace, trace.failedNodeId, (x) => labels.get(x))}」 )} {trace.phase === 'waiting' && trace.waitingNodeId && ( diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 1b363bd1..18ee841e 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -13,7 +13,7 @@ import { catalogDrift, decodePhase, decodeRun, exitLabels, schemaDrift, summariz import { sourceHandles } from '../../canvas/nodeDefs' import { EffectsPane, RunTaskCards, useRunTasks } from './RunTasks' import { OriginChip } from './RunList' -import { runStatusOf, type RunPhase } from '../../run/trace' +import { runStatusOf, stepLabel, type RunPhase } from '../../run/trace' import { useCatalog } from '../../store/catalog' import type { Approval, Run, RunEvent } from '../../types' import { CatalogDriftBanner, FailedBanner, FeedbackStrip, HeldBanner, WaitingBanner, type Feedback } from './Banners' @@ -161,9 +161,9 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const labelOf = useCallback((id?: string | null) => { if (!id) return undefined - const n = d.graph?.nodes.find((x) => x.id === id) - return n?.data?.label || id - }, [d.graph]) + // 子工作流里的那一步(出错、被驳回在它上面时运行记下的节点是「sub/x」)图上没有:接上子工作流的名字(stepLabel) + return stepLabel(d.trace, id, (x) => d.graph?.nodes.find((n) => n.id === x)?.data?.label || undefined) + }, [d.graph, d.trace]) if (!run) { if (d.state === 'error') { diff --git a/frontend/src/pages/runs/Telemetry.tsx b/frontend/src/pages/runs/Telemetry.tsx index bbbd93f6..d39beade 100644 --- a/frontend/src/pages/runs/Telemetry.tsx +++ b/frontend/src/pages/runs/Telemetry.tsx @@ -7,7 +7,7 @@ import { NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId, } from '../../lib/format' import { type StatusCode } from '../../lib/status' -import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' +import { isExecuting, isSubStep, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' import { useRunClock } from '../../run/useRunClock' import type { Approval, Run } from '../../types' import { BUNDLE_TEXT, COST_TEXT, REJECTED_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' @@ -96,7 +96,8 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf const unpriced = (settled || !streaming ? (isNum(usage.unpriced_calls) ? usage.unpriced_calls : undefined) : undefined) ?? trace.unpricedCalls - const running = Object.values(trace.nodes).filter(isExecuting) + // 子工作流里的那一步算在子工作流那一步上(isSubStep),不和它一起算成两个同时在跑 + const running = Object.values(trace.nodes).filter((n) => isExecuting(n) && !isSubStep(n.id)) const waitingId = trace.waitingNodeId ?? pending?.[0]?.node_id const waitedMs = trace.waitingSince != null ? liveAt(trace, now) - trace.waitingSince : ageMs(pending?.[0]?.created_at, now) diff --git a/frontend/src/run/RunHud.tsx b/frontend/src/run/RunHud.tsx index b90bbc43..e0c7e454 100644 --- a/frontend/src/run/RunHud.tsx +++ b/frontend/src/run/RunHud.tsx @@ -14,7 +14,7 @@ import { structureSig, useStudio } from '../store/studio' import type { RunEvent } from '../types' import { topology } from './derive' import { - isActivePhase, isSettled, isTerminal, liveAt, project, type NodeState, type RunPhase, type Trace, + isActivePhase, isSettled, isTerminal, liveAt, project, stepLabel, type NodeState, type RunPhase, type Trace, } from './trace' import { useRunClock } from './useRunClock' @@ -204,7 +204,8 @@ export function useRunGlance(active: boolean): RunGlance { const order = useMemo(() => topology({ nodes, edges }).order, [nodes, edges]) const labels = useMemo(() => new Map(nodes.map((n) => [n.id, n.data.label || n.id])), [nodes]) - const label = (id: string) => labels.get(id) ?? id + // 子工作流里的那一步(失败、被驳回在它上面)图上没有:接上子工作流的名字(stepLabel) + const label = (id: string) => stepLabel(trace, id, (x) => labels.get(x)) const live = isActivePhase(phaseLive) const at = replayAt ?? (live ? liveAt(trace, now) : lastStampOf(trace)) diff --git a/frontend/src/run/trace.ts b/frontend/src/run/trace.ts index 08315bb8..72ef85d0 100644 --- a/frontend/src/run/trace.ts +++ b/frontend/src/run/trace.ts @@ -91,6 +91,8 @@ export interface NodeTrace { */ exited?: boolean exitTo?: string[] + /** node.started 上记的名字。子工作流里的步骤不在画布的图上,名字从这里取(stepLabel) */ + label?: string /** 用量随时间的累计,回放时按游标取那一刻的 tokens 和工具数。没调过模型和工具的节点没有 */ marks?: UsageMark[] } @@ -277,6 +279,25 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb return -1 } +/** + * 子工作流里的一步(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md §2):它在子工作流节点那一步 + * 里面跑,节点计数、并行数、「当前在跑」都算在子工作流那一步上,不另算 + */ +export const isSubStep = (id: string): boolean => id.includes('/') + +/** + * 一个节点给人看的名字:own 是画布的图上的名字(找不到为 undefined)。子工作流里的步骤(isSubStep)图上没有,接上子工作流 + * 那一步的名字:「取数子流程 › 查订单」——和服务端终态事件的 label 同一个拼法(runner._label_of),失败横幅、泳道、 + * 运行胶囊不再写裸的「sub/fetch」。别的找不到的照旧写 id + */ +export function stepLabel(t: Trace, id: string, own: (id: string) => string | undefined): string { + const mine = own(id) + if (mine) return mine + if (!isSubStep(id)) return id + const cut = id.lastIndexOf('/') + return `${stepLabel(t, id.slice(0, cut), own)} › ${t.nodes[id]?.label || id.slice(cut + 1)}` +} + /** * 此刻同时在跑的数量。 * @@ -286,8 +307,7 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb function parallelOf(nodes: Record): number { let total = 0 for (const n of Object.values(nodes)) { - // 子工作流里的那几步(「子工作流节点 id/…」)在子工作流节点那一格里跑,不另算一路并行 - if (n.id.includes('/')) continue + if (isSubStep(n.id)) continue let inner = 0 let run = 0 for (const s of n.segments) { @@ -521,6 +541,7 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { case 'node.started': { if (!nodeId) break const n = node(nodeId) + if (typeof d.label === 'string' && d.label && n.label !== d.label) n.label = d.label const open = openIndex(n.segments, 'run') const lastSeg = n.segments[n.segments.length - 1] if (open >= 0) { @@ -1012,7 +1033,8 @@ export function project(t: Trace, at: number): Projection { toolsRunning: state === 'running' ? m?.[5] ?? 0 : 0, } } - if (view.state === 'done' || view.state === 'skipped') nodesDone += 1 + // 分母是工作流里的节点数:子工作流里的步骤算在子工作流那一步里(isSubStep),不另算完成数 + if ((view.state === 'done' || view.state === 'skipped') && !isSubStep(n.id)) nodesDone += 1 nodes[n.id] = view } @@ -1039,7 +1061,7 @@ export function project(t: Trace, at: number): Projection { return { phase, elapsedMs, activeMs, waitMs, nodesDone, - nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).length, + nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).filter((id) => !isSubStep(id)).length, parallelNow, ...usage, nodes, } } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 43d202a9..fd390a94 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -3030,6 +3030,36 @@ await section('子工作流里的每一步进了同一个运行(节点 id「 check('子工作流里的一步:「取数子流程 › 查订单」', title('sub/fetch') === '取数子流程 › 查订单', title('sub/fetch')) check('没起名字的(名字就是子图里的 id)退到节点类型', title('sub/o') === '取数子流程 › 成果', title('sub/o')) check('子工作流节点自己照旧', title('sub') === '取数子流程', title('sub')) + + // 航迹的计数:子工作流里的步骤算在子工作流那一步里,完成数的分母是工作流里的节点数(run.started 的 nodes) + const trace = await import(await load('/src/run/trace.ts')) + const evs = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'start', {}), E(3, 'node.finished', 'start', {}), + E(4, 'node.started', 'sub', { label: '取数子流程' }), + E(5, 'node.started', 'sub/fetch', { label: '查订单' }), E(6, 'node.finished', 'sub/fetch', {}), + E(7, 'node.started', 'sub/o', {})] + const t = evs.reduce(trace.foldEvent, trace.emptyTrace()) + const mid = trace.project(t, 7500) + check('跑到子工作流里:完成数只数工作流里的节点(1/2,不是 2/2)', mid.nodesDone === 1 && mid.nodesTotal === 2, + `${mid.nodesDone}/${mid.nodesTotal}`) + check('……子工作流和它里面那一步不算成两路并行', mid.parallelNow === 1, String(mid.parallelNow)) + check('……「当前在跑」只认子工作流那一步', Object.values(t.nodes).filter((n) => trace.isExecuting(n) && !trace.isSubStep(n.id)) + .map((n) => n.id).join() === 'sub') + const done = [...evs, E(8, 'node.finished', 'sub/o', {}), E(9, 'node.finished', 'sub', {}), + E(10, 'run.finished', null, { output: {} })].reduce(trace.foldEvent, trace.emptyTrace()) + const end = trace.project(done, 10000) + check('跑完:2/2,不是 4/2', end.nodesDone === 2 && end.nodesTotal === 2, `${end.nodesDone}/${end.nodesTotal}`) + + // 子工作流里的一步出错:运行记下的出错节点是「sub/fetch」,图上没有它。失败横幅、泳道、运行胶囊照「子工作流 › 那一步」写 + const failedRun = [...evs.slice(0, 5), E(6, 'node.failed', 'sub/fetch', { error: '查询失败' }), + E(7, 'node.failed', 'sub', { error: '查询失败' }), + E(8, 'run.failed', null, { error: '查询失败', node_id: 'sub/fetch', label: '取数子流程 › 查订单' })] + .reduce(trace.foldEvent, trace.emptyTrace()) + const own = (id) => ({ start: '入口', sub: '取数子流程' })[id] + check('出错节点记的是子图里那一步', failedRun.failedNodeId === 'sub/fetch', failedRun.failedNodeId) + check('名字接上子工作流:「取数子流程 › 查订单」,不写裸 id', trace.stepLabel(failedRun, 'sub/fetch', own) === '取数子流程 › 查订单', + trace.stepLabel(failedRun, 'sub/fetch', own)) + check('图上的节点照旧写图上的名字', trace.stepLabel(failedRun, 'sub', own) === '取数子流程') + check('没起过名字的子图步骤写子图里的 id', trace.stepLabel(t, 'sub/o', own) === '取数子流程 › o', trace.stepLabel(t, 'sub/o', own)) }) console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')