Files
AgentLab/backend/app/engine/lint_process.py
T
ThearchyHelios 3998e821dc 子工作流的证据交回父图(后端):子图每一步进同一个运行、台账和口径卡交回、出口字段带出处、父图跨子图往下追
调查时发现: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 通过
2026-10-18 16:11:15 +08:00

308 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""流程相关的检查(应用平台 §3.13):人工节点的新配置、按流程使用时会出事的接法。
两类问题分两处报,是因为门禁的 flag 只能在受管级别出错误:
- human.*:配置本身不成立(时限写不成时长、会签用在补填模式、升级没写岗位、表单不合目录)。运行时没法照它执行,
所以由 schema.validate_graph 报错误(human_issues),各级发布、发起运行都挡;publish_issues 本来就把 validate_graph
并进来,这里不再登记一遍,免得同一处问题说两遍。
- process.*:图本身能运行,按流程使用(应用钉住它、实例一等几天、中途升级)才出事。登记进 governance.EXTRA_LINTS,
发布工作流时一律警告;应用把它作为流程钉住时,应用检查(app/apps/lint.py)把它们升成 app.process_unsafe 报错。
子工作流里有没有人工节点要读库才知道,门禁本身不查库:调用方先用 load_subgraphs 读好、with_subgraphs 放进来,
和 SQL 检查器、模型接入表同一个做法。没放进来时这一条不查。
子工作流固定的版本是不是「已发布且未退役」(D-q)也要读库,随 load_subgraphs 一起读好(Subgraphs.pin_problems),由
lint_subgraph_pins 报:这一条各级都是错误——正式运行发起时按同一条规则拒(engine/launch.py),发布时放过去只会让之后的
每一次正式运行都在发起时才失败。
"""
from __future__ import annotations
import contextlib
import re
from collections.abc import Callable, Iterator, Mapping
from contextvars import ContextVar
from datetime import timedelta
from typing import Any
from app.engine.schema import GraphNode, GraphSpec, NodeType
#: ISO 8601 时长里认的部分:周、天、时、分、秒。不认年、月——「一个月」有几天说不准,时限按它算会差出几天
_DURATION = re.compile(
r"^P(?!$)(?:(?P<w>\d+)W)?(?:(?P<d>\d+)D)?(?:T(?=\d)(?:(?P<h>\d+)H)?(?:(?P<m>\d+)M)?(?:(?P<s>\d+(?:\.\d+)?)S)?)?$")
#: 人工节点 policy 的写法:any / all / {quorum: m}
POLICIES = ("any", "all")
#: on_timeout 的动作。没有「自动批准」
TIMEOUT_ACTIONS = ("remind", "escalate", "reject")
#: assignee 的种类
ASSIGNEE_KINDS = ("slot", "initiator", "expression", "hook")
_MODE_LABEL = {"approve": "审批", "input": "补充输入", "edit": "编辑稿件"}
def parse_duration(text: Any) -> timedelta | None:
"""ISO 8601 时长(PT4H、P2D、P1DT30M)→ timedelta;不认得、或者是零的返回 None。"""
if not isinstance(text, str):
return None
m = _DURATION.match(text.strip().upper())
if not m:
return None
parts = {k: float(v) if v else 0.0 for k, v in m.groupdict().items()}
delta = timedelta(weeks=parts["w"], days=parts["d"], hours=parts["h"], minutes=parts["m"], seconds=parts["s"])
return delta if delta.total_seconds() > 0 else None
def normalize_policy(value: Any) -> dict[str, Any] | None:
"""节点配置里的 policy → 待办上存的形状 {"kind": "any" | "all"} / {"kind": "quorum", "m": m};写法不对返回 None。"""
if value in (None, ""):
return {"kind": "any"}
if isinstance(value, str) and value in POLICIES:
return {"kind": value}
if isinstance(value, dict):
if value.get("kind") in POLICIES and set(value) <= {"kind"}:
return {"kind": value["kind"]}
m = value.get("quorum", value.get("m") if value.get("kind") == "quorum" else None)
if isinstance(m, int) and not isinstance(m, bool) and m >= 1:
return {"kind": "quorum", "m": m}
return None
def assignee_problem(value: Any) -> str | None:
"""assignee 的写法问题,没问题返回 None。人工节点和写入动作的调用工具节点共用。"""
if value in (None, "", {}):
return None
if not isinstance(value, dict) or value.get("kind") not in ASSIGNEE_KINDS:
return "「派给谁」只能是岗位、发起人、表达式或外部系统"
kind = value["kind"]
if kind == "slot" and not str(value.get("slot") or "").strip():
return "「派给谁」选了岗位,还没有填写岗位名"
if kind == "expression" and not str(value.get("expr") or "").strip():
return "「派给谁」选了表达式,还没有填写表达式"
if kind == "hook" and not str(value.get("correlation") or "").strip():
return "「派给谁」选了外部系统,还没有填写关联键模板"
return None
def human_issues(node: GraphNode) -> list[tuple[str, str, str]]:
"""人工节点新配置的问题:[(编号, 说明, 字段)]。schema.validate_graph 按错误报。
form 合不合目录用应用包的 validate_form_schema(表单只有一种说法,P5);只有写了 form 的节点才去问它。"""
cfg = node.config
out: list[tuple[str, str, str]] = []
mode = cfg.get("mode") or "approve"
if problem := assignee_problem(cfg.get("assignee")):
out.append(("human.assignee_invalid", problem, "assignee"))
policy = normalize_policy(cfg.get("policy"))
if policy is None:
out.append(("human.policy_invalid", "「会签方式」只能是任一人、全体同意或其中若干人同意", "policy"))
elif policy["kind"] != "any" and mode != "approve":
out.append(("human.policy_mode",
f"「会签方式」的全体同意、若干人同意只用于审批模式;当前是{_MODE_LABEL.get(mode, mode)}模式,"
"请改为任一人处理", "policy"))
due = cfg.get("due")
if due not in (None, "") and parse_duration(due) is None:
out.append(("human.due_invalid", f"「时限」需要写成 ISO 8601 时长,例如 PT4H(4 小时)、P2D(2 天),当前为「{due}」",
"due"))
timeout = cfg.get("on_timeout")
if timeout not in (None, "", {}):
action = timeout.get("action") if isinstance(timeout, dict) else None
if action not in TIMEOUT_ACTIONS:
out.append(("human.timeout_invalid", "「超时处理」只能是提醒、升级或驳回", "on_timeout"))
elif action == "escalate" and not str(timeout.get("to_slot") or "").strip():
out.append(("human.escalate_slot", "「超时处理」选了升级,还没有填写升级给哪个岗位", "on_timeout.to_slot"))
elif action == "remind" and timeout.get("every") not in (None, "") and parse_duration(timeout["every"]) is None:
out.append(("human.due_invalid", f"提醒间隔需要写成 ISO 8601 时长,例如 PT1H,当前为「{timeout['every']}」",
"on_timeout.every"))
if due in (None, ""):
out.append(("human.due_invalid", "设置了「超时处理」,还需要填写「时限」", "due"))
form = cfg.get("form")
if form not in (None, "", {}):
if not isinstance(form, dict):
out.append(("human.form_invalid", "「表单」必须是一个 JSON 对象", "form"))
else:
from app.apps.forms import validate_form_schema
if problems := validate_form_schema(form):
out.append(("human.form_invalid", f"「表单」不符合组件目录:{problems[0]}", "form"))
source = cfg.get("form_from")
if source not in (None, "") and not (isinstance(source, str) and re.fullmatch(r"(nodes|vars)\.[\w.\-]+", source.strip())):
out.append(("human.form_from_invalid", "「表单来自」要写成 nodes.某节点.字段 或 vars.变量名", "form_from"))
return out
# --------------------------------------------------------------------------
# process.*:登记进发布门禁
# --------------------------------------------------------------------------
#: 子工作流的图:(工作流 id, 固定的版本,没固定为 None) → GraphSpec。见 load_subgraphs
_SUBGRAPHS: ContextVar[Mapping[tuple[str, int | None], GraphSpec] | None] = ContextVar("process_subgraphs", default=None)
class Subgraphs(dict):
"""load_subgraphs 读好的东西:(工作流 id, 固定的版本,没固定为 None) → GraphSpec;pin_problems 另记固定的版本不是
「已发布且未退役」的子工作流节点(apps/versions.subgraph_pin_problems)。with_subgraphs 两样一起放进门禁。"""
def __init__(self) -> None:
super().__init__()
self.pin_problems: list[Any] = []
#: 嵌套的子工作流往下读几层:和运行时的嵌套上限(multi._MAX_DEPTH)一样,再深的运行时本来就拒
_LOAD_DEPTH = 3
async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs:
"""读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图),子图里再嵌套的一层层往下读(最多
_LOAD_DEPTH 层,同一张图只读一次,互相引用的不会转圈)。读不到、不成形的跳过。固定的版本能不能用也一并读好
(Subgraphs.pin_problems,只看这张图自己的子工作流节点)。
拿去用的:人工节点在子工作流里(_subgraph_humans)、口径卡的输入跨子工作流追到原始数据(engine/lineage 的静态认法,
subgraph_spec)。"""
from sqlalchemy import select
from app.apps.versions import subgraph_pin_problems
from app.db.models import Workflow, WorkflowVersion
out = Subgraphs()
out.pin_problems = await subgraph_pin_problems(session, spec)
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)
try:
yield
finally:
_SUBGRAPHS.reset(token)
def _who(node: GraphNode) -> str:
from app.engine.labels import type_label
return f"「{node.title}」({type_label(node.type)})"
def _approval_of(spec: GraphSpec, cfg: Mapping[str, Any]) -> str | None:
return cfg.get("approval") or spec.defaults.get("approval")
def _may_ask(name: str) -> bool:
"""这个工具在正式运行的信任快照下可能要人审批:MCP、自定义工具一律要(tools/trust.py 的 FORMAL_MARK),
内置工具看它是不是危险的。数据源查询看这一次的 SQL,静态说不准,不报。"""
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
from app.tools.registry import get_spec
if name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)) or "{{" in name:
return False
spec = get_spec(name)
return spec is None or bool(spec.dangerous)
def _agent_tool_sets(node: GraphNode) -> list[tuple[str, Mapping[str, Any], list[str]]]:
"""(给人看的位置, 审批配置所在的 dict, 工具名):Agent 自己,加上协作节点的各个成员。"""
cfg = node.config
out: list[tuple[str, Mapping[str, Any], list[str]]] = []
if node.type == NodeType.AGENT:
out.append(("", cfg, [t for t in cfg.get("tools") or [] if isinstance(t, str)]))
for member in cfg.get("agents") or []:
if isinstance(member, dict):
label = f"成员「{member.get('name') or '?'}」"
# 成员没写审批策略的跟随节点
approval_cfg = member if member.get("approval") else cfg
out.append((label, approval_cfg, [t for t in member.get("tools") or [] if isinstance(t, str)]))
return out
def _subgraph_humans(spec: GraphSpec, node: GraphNode) -> list[str] | None:
sub = subgraph_spec(node.config)
if sub is None:
return None
return [n.title for n in sub.nodes if n.type == NodeType.HUMAN]
def lint_process(spec: GraphSpec, flag: Callable[..., None], *, level: str, strict: bool) -> None:
"""process.* 一律警告(hard=False):发布工作流时只提醒,应用按流程钉住时由应用检查升成错误。"""
for node in spec.nodes:
cfg = node.config
who = _who(node)
if node.type in (NodeType.AGENT, NodeType.SUPERVISOR):
for label, approval_cfg, tools in _agent_tool_sets(node):
if _approval_of(spec, approval_cfg) == "never":
continue
asking = [t for t in tools if _may_ask(t)]
if asking:
flag(f"{who}{label}的工具「{'、'.join(asking[:3])}」在正式运行中可能需要人工审批,运行会停在 Agent 中途;"
"按流程使用时,系统升级后这种断点无法恢复。请把需要审批的调用移到「调用工具」节点,"
"或在能力登记里把只读的工具设为免审批",
code="process.wait_mid_agent", node_id=node.id, field="tools")
break
elif node.type == NodeType.SUBGRAPH:
humans = _subgraph_humans(spec, node)
if humans:
flag(f"{who}引用的工作流里有人工节点「{'、'.join(humans[:3])}」:子工作流不保存断点,"
"恢复时整个子工作流从头重新执行。按流程使用时请把人工节点放到外层工作流",
code="process.human_in_subgraph", node_id=node.id, field="workflow_id")
elif node.type == NodeType.CODE and cfg.get("network"):
flag(f"{who}开启了「允许联网」:按流程使用时,对外部系统的写入请放到「调用工具」节点,"
"那里先留生效记录再发送,结果未知时可以核实",
code="process.code_network", node_id=node.id, field="network")
def lint_subgraph_pins(spec: GraphSpec, flag: Callable[..., None], *, level: str, strict: bool) -> None:
"""子工作流固定的版本不是「已发布且未退役」的(D-q):各级都报错(always),机器码和发起时拒绝的一样。
load_subgraphs 没读好、没放进来时不查(门禁本身不查库)。"""
from app.apps.versions import pinned_upstream
graphs = _SUBGRAPHS.get()
nodes = spec.node_map()
for problem in getattr(graphs, "pin_problems", None) or []:
node = nodes.get(problem.node_id)
# 读好的结果对的是读的那张图;拿去查改过的图(自动修复的预览)时,节点改了固定的版本就不再算它
if node is None or pinned_upstream(node) is None \
or pinned_upstream(node)[1:] != (problem.workflow_id, problem.version):
continue
flag(f"{problem.who}固定的{problem.reason}:正式运行发起时会被拒绝。{problem.advice}",
code=problem.code, node_id=problem.node_id, field=problem.field, always=True)
def _register() -> None:
from app.engine.governance import EXTRA_LINTS
for check in (lint_process, lint_subgraph_pins):
if check not in EXTRA_LINTS:
EXTRA_LINTS.append(check)
_register()