发起运行的规则抽成 engine/launch.py 的 launch_run:以前全写在 api/runs.py 的 start_run 里、只认 HTTP 请求,后面的定时任务要用同一套规则发起正式运行,另抄一份迟早走样。正式运行只能从当前发布版本发起、口径升版未声明策略就拦、绑定的工具不存在就拦、发起后记受管级别那条日志和目录变化、口径升版处置,全部搬进服务;拒绝时抛 LaunchRefused(status_code, message, code),start_run 只剩一层包装,把它换回和以前逐字相同的响应(带机器码的仍回 {detail, code})。正式运行的快照图不成形,以前由 main.py 的 ValueError 兜底回 400,现在在服务里换成同样的 400 拒绝,调度器也拿得到明确的拒绝。node_setting、tool_bindings、missing_tools 跟着搬过去,runs.py 再导出,旧的导入路径照旧可用;runner 固定数据版本取绑定时改从 engine/launch 导入,不再依赖接口层。新测试先把 HTTP 的 17 种拒绝(含工具缺失的数据、工具、混合、超过 8 处四种说法)按原话写死、在旧代码上通过,再逐条比对直接调 launch_run 的结果;另测服务发起的正式运行照样写治理事件

This commit is contained in:
ThearchyHelios
2026-10-05 00:03:24 +08:00
parent 6041073ef2
commit 7820556ab6
4 changed files with 636 additions and 222 deletions
+23 -220
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
from datetime import datetime
from typing import Any
from urllib.parse import unquote
@@ -12,24 +11,17 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select as _select
from app.api.coded import CodedHTTPException
from app.core.bus import bus
from app.core import artifact_store
from app.core.events import EventType
from app.data.catalog_impact import catalog_drift
from app.db.base import SessionLocal, get_session
from app.db.models import Approval, Artifact, Run, RunEvent, Workflow, WorkflowVersion
from app.engine.governance import governance_note, unresolved_caliber_upgrades
from app.db.models import Approval, Artifact, Run, RunEvent
# 发起规则和发起前检查绑定的工具搬到了 engine/launch.py。这几个名字以前从这里导入,照旧可用
from app.engine.launch import ( # noqa: F401
TOOL_MISSING, LaunchRefused, launch_run, missing_tools, node_setting, tool_bindings,
)
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, type_label
router = APIRouter(prefix="/api/runs", tags=["runs"])
logger = logging.getLogger(__name__)
#: 发起时发现绑定的工具在本机不存在(见 missing_tools)
TOOL_MISSING = "run_tool_missing"
def actor_of(header: str | None) -> str | None:
@@ -90,218 +82,29 @@ async def start_run(
session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None),
) -> Run:
actor = actor_of(x_actor)
# 没挂在工作流上(画布没保存就跑、问数据当场搭的)。老数据里是「临时图」,前端两种都认
name = "未保存的工作流"
version: int | None = None
version_hash: str | None = None
governed = False
if payload.run_class == "formal":
# 正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本。
# 传裸 graph、或工作流还没发布过,都进不了 formal。
if not payload.workflow_id:
raise HTTPException(400, "正式运行需要从已保存的工作流发起:请求中没有指定工作流")
if payload.graph is not None:
raise HTTPException(400, "正式运行只能使用已发布的版本,画布上未发布的修改不会生效。请先保存并发布")
workflow = await session.get(Workflow, payload.workflow_id)
if not workflow:
raise HTTPException(404, "工作流不存在,可能已被删除")
# 只看 published_version:status 反映的是当前画布(改过图就退回 draft),
# 而 formal 跑的是已发布的那一版,两者本来就可以不一致
if not workflow.published_version:
raise HTTPException(409, f"「{workflow.name}」还没有发布版本,请先在编排页发布")
version = payload.version or workflow.published_version
# "不可变"和"过过闸"是两件事。快照确实不可变,但 PATCH 保存出来的
# 草稿版本同样有快照——只认 version 存不存在的话,工作流只要发布过
# 任意一个版本,之后所有草稿都能以 formal 身份启动,发布期的
# validate_graph + lint_for_publish 全部绕过。
if version != workflow.published_version:
raise HTTPException(
409,
f"v{version} 不是当前发布版本(v{workflow.published_version})。"
"正式运行只能使用当前发布版本;如需运行这一版,请先发布它。",
)
snapshot = (
await session.execute(
_select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow.id,
WorkflowVersion.version == version,
)
)
).scalar_one_or_none()
if not snapshot:
raise HTTPException(404, f"版本 v{version} 不存在")
graph = snapshot.graph
version_hash = snapshot.graph_hash or artifact_store.graph_hash(graph)
name = workflow.name
# 按这一版发布时记下的级别;记级别之前发布的老版本没有,照旧看工作流现在的 status
governed = (snapshot.level == "governed") if snapshot.level else workflow.status == "governed"
# 口径升版是被迫处置的事件:钉住的方法卡有新版本而未声明策略,拒绝启动
spec = GraphSpec.model_validate(graph)
errors, upgrade_events = await unresolved_caliber_upgrades(session, spec)
if errors:
raise HTTPException(409, ";".join(errors))
# 数据目录和口径卡不同:目录是给人和助手看的说明,不是运行的输入。发布之后变了只提醒、不拦——
# 拦下来等于让一次文字修订卡住正式出具
catalog_changes = await catalog_drift(session, snapshot.catalog_versions)
else:
graph = payload.graph
upgrade_events = []
catalog_changes = []
if payload.workflow_id:
workflow = await session.get(Workflow, payload.workflow_id)
if not workflow:
raise HTTPException(404, "工作流不存在,可能已被删除")
graph = graph or workflow.graph
name = workflow.name
if not graph:
raise HTTPException(400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容")
await _refuse_missing_tools(session, graph)
from app.api.settings import resolve_run_scope
memory_scope, collection = await resolve_run_scope(
session, payload.memory_scope, payload.collection
)
"""发起规则全在 engine/launch.py(定时任务到点发起也用它),这里只把拒绝换回 HTTP 响应。"""
try:
run = await run_manager.start(
graph=graph,
input_payload=payload.input,
return await launch_run(
session,
workflow_id=payload.workflow_id,
workflow_name=name,
memory_scope=memory_scope,
collection=collection,
run_class="formal" if payload.run_class == "formal" else "exploratory",
version=version,
version_hash=version_hash,
started_by=actor,
graph=payload.graph,
input_payload=payload.input,
memory_scope=payload.memory_scope,
collection=payload.collection,
run_class=payload.run_class,
version=payload.version,
actor=actor_of(x_actor),
)
except ValueError as e:
raise HTTPException(400, str(e)) from e
if payload.run_class == "formal":
# 按不按受管出具在发起这一刻定下来、记进封存范围:status 反映的是当前画布,中途改图、
# 审批停下再恢复时再去读它,报告节点和出口复核可能各读到不同的值
await run_manager.note(run.id, "log", **governance_note(
governed=governed, workflow_name=name, version=version))
if catalog_changes:
# 落在封存范围内:之后回看这次正式出具,能知道当时的目录和发布时不一样、差在哪几张表。
# 每张表带 impact:direct 写死的 SQL 用到的,possible Agent 可能查询的(界面分开说)
await run_manager.note(run.id, EventType.CATALOG_DRIFT, version=version, count=len(catalog_changes),
tables=catalog_changes)
# 已声明策略的升版记入事件流,出具物上能看到"这期换口径了、怎么处置的"
for ev in upgrade_events:
payload_ev = dict(ev)
node = payload_ev.pop("node_id", None)
await run_manager.note(run.id, "caliber.upgrade", node_id=node, **payload_ev)
return run
except LaunchRefused as e:
raise _refused(e) from e
# --------------------------------------------------------------------------
# 发起前检查绑定的工具
# --------------------------------------------------------------------------
def node_setting(spec: GraphSpec, node: Any, key: str) -> Any:
"""节点配置的实际取值,和运行时 NodeContext.cfg 同一个规则:节点上没写(None、空串)就取图级 defaults。"""
value = node.config.get(key)
return spec.defaults.get(key) if value in (None, "") else value
def tool_bindings(spec: GraphSpec) -> list[tuple[str, str]]:
"""图里每一处绑定的工具:(谁绑的, 工具名)。调用工具节点的 tool、Agent 的 tools、协作成员的 tools。
取值和运行时一致(node_setting):Agent 节点上没写 tools、协作节点上没写成员,运行时用的是
图级 defaults 里的,这里也算它们——不然发起前检查放过了缺的工具,发起时固定数据版本也漏了源。
模板拼出来的名字({{ vars.tool }})也在内:发起前检查(_bindings)跳过它们,发起时固定数据
版本(engine/runner.py)要知道有没有这种名字——有的话说不准会用到哪个数据源。
"""
out: list[tuple[str, str]] = []
for node in spec.nodes:
who = f"「{node.title}」({type_label(node.type)})"
if node.type == NodeType.TOOL:
names = [node_setting(spec, node, "tool")]
out.extend((who, n) for n in names if isinstance(n, str) and n.strip())
elif node.type == NodeType.AGENT:
out.extend((who, n) for n in node_setting(spec, node, "tools") or []
if isinstance(n, str) and n.strip())
elif node.type == NodeType.SUPERVISOR:
for member in node_setting(spec, node, "agents") or []:
if not isinstance(member, dict):
continue
label = f"「{node.title}」的成员「{member.get('name') or '?'}」"
out.extend((label, n) for n in member.get("tools") or [] if isinstance(n, str) and n.strip())
return [(who, name.strip()) for who, name in out]
def _bindings(spec: GraphSpec) -> list[tuple[str, str]]:
"""发起前要检查的绑定。模板拼出来的工具名({{ vars.tool }})发起时还不知道是谁,交给运行时。"""
return [(who, name) for who, name in tool_bindings(spec) if "{{" not in name]
async def missing_tools(session: AsyncSession, spec: GraphSpec) -> list[tuple[str, str, str]]:
"""本机不存在的绑定:(谁绑的, 工具名, 去哪接入:data / tools)。
解析和运行时 build_tools 一致:内置(静态注册)、mcp:<服务>/<工具>、db_query__ / db_schema__
<数据源>、其余按自定义工具。数据源和自定义工具只认启用着的——停用的运行时同样拿不到。
MCP 只看服务登记着、启用着没有:连不连得上、工具还在不在,要连一次才知道,发起时不去等它,
上次没连上或者工具清单里没有的只在日志里提一句。
"""
from app.db.models import CustomTool, DataSource, McpServer
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
from app.tools.registry import all_specs
bindings = _bindings(spec)
if not bindings:
return []
builtin = all_specs()
sources = {n for (n,) in await session.execute(select(DataSource.name).where(DataSource.enabled.is_(True)))}
custom = {n for (n,) in await session.execute(select(CustomTool.name).where(CustomTool.enabled.is_(True)))}
servers = {r.name: r for r in (await session.execute(select(McpServer))).scalars()}
missing: list[tuple[str, str, str]] = []
for who, name in bindings:
if name.startswith("mcp:"):
server, _, tool = name[4:].partition("/")
row = servers.get(server)
if row is None or not row.enabled:
missing.append((who, name, "tools"))
elif row.status == "error":
logger.warning("%s 绑的 %s:MCP 服务 %s 上次没连上,运行时可能拿不到这个工具", who, name, server)
elif tool not in ("", "*") and row.tools_cache and tool not in row.tools_cache:
logger.warning("%s 绑的 %s:MCP 服务 %s 上次交回的工具清单里没有 %s", who, name, server, tool)
elif name in builtin:
continue
elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)):
source = name[len(QUERY_PREFIX):] if name.startswith(QUERY_PREFIX) else name[len(SCHEMA_PREFIX):]
if source not in sources:
missing.append((who, name, "data"))
elif name not in custom:
missing.append((who, name, "tools"))
return missing
async def _refuse_missing_tools(session: AsyncSession, graph: dict[str, Any]) -> None:
"""绑定的工具在本机不存在就不发起:以前要跑到那一步才失败,前面的步骤白跑、钱白花。"""
try:
spec = GraphSpec.model_validate(graph)
except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报
return
missing = await missing_tools(session, spec)
if not missing:
return
lines = ";".join(f"{who}绑定的 {name}" for who, name, _ in missing[:8])
more = f"等 {len(missing)} 处" if len(missing) > 8 else ""
places = {where for _, _, where in missing}
if places == {"data"}:
fix = "请到「数据」页接入,或在节点中重新选择"
elif places == {"tools"}:
fix = "请到「工具」页接入,或在节点中重新选择"
else:
fix = "数据源工具请到「数据」页接入,其他工具请到「工具」页接入,或在节点中重新选择"
raise CodedHTTPException(422, f"绑定的工具不存在:{lines}{more}。{fix}", TOOL_MISSING)
def _refused(e: LaunchRefused) -> HTTPException:
"""发起被拒的 HTTP 响应,和规则搬去 engine/launch.py 之前逐字相同:带机器码的回 {detail, code},
不带的回 {detail}。"""
if e.code:
return CodedHTTPException(e.status_code, e.message, e.code)
return HTTPException(e.status_code, e.message)
#: workflow_id 取这个值时只要没挂在任何工作流上的运行:画布上没保存就跑的临时图,
+273
View File
@@ -0,0 +1,273 @@
"""发起一次运行:HTTP 发起(api/runs.py 的 start_run)和定时任务到点发起共用的那套规则。
以前这些规则全写在 start_run 里、只认 HTTP 请求:定时任务要发起正式运行,要么另抄一份(两份规则迟早
走样,正式运行「只能从当前发布版本发起」这条最要紧的规则就可能在一边被绕过),要么假装成一次 HTTP 请求。
现在两边都调 launch_run。拒绝时抛 LaunchRefused:start_run 把它换回以前的 HTTP 响应(状态码、detail、
code 逐字不变),调度器据此记一次未能发起。
发起前检查绑定的工具(missing_tools),以及它和 runner 固定数据版本共用的取绑定的办法(tool_bindings、
node_setting)也放在这里:runner 以前要从接口层导入它们。
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import artifact_store
from app.core.events import EventType
from app.data.catalog_impact import catalog_drift
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.governance import governance_note, unresolved_caliber_upgrades
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, NodeType, type_label
logger = logging.getLogger(__name__)
#: 发起时发现绑定的工具在本机不存在(见 missing_tools)
TOOL_MISSING = "run_tool_missing"
class LaunchRefused(Exception):
"""不发起这次运行。status_code 和 message 就是 HTTP 发起时回的状态码和 detail;code 是给程序认的机器码,
没有的是 None(HTTP 响应里也就没有 code 字段)。"""
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
async def launch_run(
session: AsyncSession,
*,
workflow_id: str | None = None,
graph: dict[str, Any] | None = None,
input_payload: dict[str, Any] | None = None,
memory_scope: str | None = None,
collection: str | None = None,
run_class: str = "exploratory",
version: int | None = None,
actor: str | None,
) -> Run:
"""发起一次运行,返回刚建好的运行记录(执行已经交给 run_manager)。不发起就抛 LaunchRefused。
参数和 POST /api/runs 的请求体一一对应(input 叫 input_payload);actor 是记在运行上的发起人。
"""
# 没挂在工作流上(画布没保存就发起、问数据当场搭的)。老数据里是「临时图」,前端两种都认
name = "未保存的工作流"
run_version: int | None = None
version_hash: str | None = None
governed = False
formal = run_class == "formal"
if formal:
# 正式运行的全部语义就这一条规则:必须引用一个不可变的已发布版本。
# 传裸 graph、或工作流还没发布过,都进不了 formal。
if not workflow_id:
raise LaunchRefused(400, "正式运行需要从已保存的工作流发起:请求中没有指定工作流")
if graph is not None:
raise LaunchRefused(400, "正式运行只能使用已发布的版本,画布上未发布的修改不会生效。请先保存并发布")
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise LaunchRefused(404, "工作流不存在,可能已被删除")
# 只看 published_version:status 反映的是当前画布(改过图就退回 draft),
# 而 formal 用的是已发布的那一版,两者本来就可以不一致
if not workflow.published_version:
raise LaunchRefused(409, f"「{workflow.name}」还没有发布版本,请先在编排页发布")
run_version = version or workflow.published_version
# "不可变"和"过过闸"是两件事。快照确实不可变,但 PATCH 保存出来的
# 草稿版本同样有快照——只认 version 存不存在的话,工作流只要发布过
# 任意一个版本,之后所有草稿都能以 formal 身份启动,发布期的
# validate_graph + lint_for_publish 全部绕过。
if run_version != workflow.published_version:
raise LaunchRefused(
409,
f"v{run_version} 不是当前发布版本(v{workflow.published_version})。"
"正式运行只能使用当前发布版本;如需运行这一版,请先发布它。",
)
snapshot = (
await session.execute(
select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow.id,
WorkflowVersion.version == run_version,
)
)
).scalar_one_or_none()
if not snapshot:
raise LaunchRefused(404, f"版本 v{run_version} 不存在")
graph = snapshot.graph
version_hash = snapshot.graph_hash or artifact_store.graph_hash(graph)
name = workflow.name
# 按这一版发布时记下的级别;记级别之前发布的老版本没有,照旧看工作流现在的 status
governed = (snapshot.level == "governed") if snapshot.level else workflow.status == "governed"
try:
spec = GraphSpec.model_validate(graph)
except ValueError as e:
# 快照里的图不成形。以前这里没接,由 main.py 的 ValueError 兜底回 400、detail 是校验原话;
# 原样换成拒绝,HTTP 响应不变,调度器也拿得到一个明确的拒绝
raise LaunchRefused(400, str(e)) from e
# 口径升版是被迫处置的事件:引用的方法卡有新版本而未声明策略,拒绝启动
errors, upgrade_events = await unresolved_caliber_upgrades(session, spec)
if errors:
raise LaunchRefused(409, ";".join(errors))
# 数据目录和口径卡不同:目录是给人和助手看的说明,不是运行的输入。发布之后变了只提醒、不拦——
# 拦下来等于让一次文字修订卡住正式出具
catalog_changes = await catalog_drift(session, snapshot.catalog_versions)
else:
upgrade_events = []
catalog_changes = []
if workflow_id:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise LaunchRefused(404, "工作流不存在,可能已被删除")
graph = graph or workflow.graph
name = workflow.name
if not graph:
raise LaunchRefused(400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容")
await _refuse_missing_tools(session, graph)
from app.api.settings import resolve_run_scope
memory_scope, collection = await resolve_run_scope(session, memory_scope, collection)
try:
run = await run_manager.start(
graph=graph,
input_payload=input_payload if input_payload is not None else {},
workflow_id=workflow_id,
workflow_name=name,
memory_scope=memory_scope,
collection=collection,
run_class="formal" if formal else "exploratory",
version=run_version,
version_hash=version_hash,
started_by=actor,
)
except ValueError as e:
raise LaunchRefused(400, str(e)) from e
if formal:
# 按不按受管出具在发起这一刻定下来、记进封存范围:status 反映的是当前画布,中途改图、
# 审批停下再恢复时再去读它,报告节点和出口复核可能各读到不同的值
await run_manager.note(run.id, "log", **governance_note(
governed=governed, workflow_name=name, version=run_version))
if catalog_changes:
# 落在封存范围内:之后回看这次正式出具,能知道当时的目录和发布时不一样、差在哪几张表。
# 每张表带 impact:direct 写死的 SQL 用到的,possible Agent 可能查询的(界面分开说)
await run_manager.note(run.id, EventType.CATALOG_DRIFT, version=run_version, count=len(catalog_changes),
tables=catalog_changes)
# 已声明策略的升版记入事件流,出具物上能看到"这期换口径了、怎么处置的"
for ev in upgrade_events:
payload_ev = dict(ev)
node = payload_ev.pop("node_id", None)
await run_manager.note(run.id, "caliber.upgrade", node_id=node, **payload_ev)
return run
# --------------------------------------------------------------------------
# 发起前检查绑定的工具
# --------------------------------------------------------------------------
def node_setting(spec: GraphSpec, node: Any, key: str) -> Any:
"""节点配置的实际取值,和运行时 NodeContext.cfg 同一个规则:节点上没写(None、空串)就取图级 defaults。"""
value = node.config.get(key)
return spec.defaults.get(key) if value in (None, "") else value
def tool_bindings(spec: GraphSpec) -> list[tuple[str, str]]:
"""图里每一处绑定的工具:(谁绑的, 工具名)。调用工具节点的 tool、Agent 的 tools、协作成员的 tools。
取值和运行时一致(node_setting):Agent 节点上没写 tools、协作节点上没写成员,运行时用的是
图级 defaults 里的,这里也算它们——不然发起前检查放过了缺的工具,发起时固定数据版本也漏了源。
模板拼出来的名字({{ vars.tool }})也在内:发起前检查(_bindings)跳过它们,发起时固定数据
版本(engine/runner.py)要知道有没有这种名字——有的话说不准会用到哪个数据源。
"""
out: list[tuple[str, str]] = []
for node in spec.nodes:
who = f"「{node.title}」({type_label(node.type)})"
if node.type == NodeType.TOOL:
names = [node_setting(spec, node, "tool")]
out.extend((who, n) for n in names if isinstance(n, str) and n.strip())
elif node.type == NodeType.AGENT:
out.extend((who, n) for n in node_setting(spec, node, "tools") or []
if isinstance(n, str) and n.strip())
elif node.type == NodeType.SUPERVISOR:
for member in node_setting(spec, node, "agents") or []:
if not isinstance(member, dict):
continue
label = f"「{node.title}」的成员「{member.get('name') or '?'}」"
out.extend((label, n) for n in member.get("tools") or [] if isinstance(n, str) and n.strip())
return [(who, name.strip()) for who, name in out]
def _bindings(spec: GraphSpec) -> list[tuple[str, str]]:
"""发起前要检查的绑定。模板拼出来的工具名({{ vars.tool }})发起时还不知道是谁,交给运行时。"""
return [(who, name) for who, name in tool_bindings(spec) if "{{" not in name]
async def missing_tools(session: AsyncSession, spec: GraphSpec) -> list[tuple[str, str, str]]:
"""本机不存在的绑定:(谁绑的, 工具名, 去哪接入:data / tools)。
解析和运行时 build_tools 一致:内置(静态注册)、mcp:<服务>/<工具>、db_query__ / db_schema__
<数据源>、其余按自定义工具。数据源和自定义工具只认启用着的——停用的运行时同样拿不到。
MCP 只看服务登记着、启用着没有:连不连得上、工具还在不在,要连一次才知道,发起时不去等它,
上次没连上或者工具清单里没有的只在日志里提一句。
"""
from app.db.models import CustomTool, DataSource, McpServer
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
from app.tools.registry import all_specs
bindings = _bindings(spec)
if not bindings:
return []
builtin = all_specs()
sources = {n for (n,) in await session.execute(select(DataSource.name).where(DataSource.enabled.is_(True)))}
custom = {n for (n,) in await session.execute(select(CustomTool.name).where(CustomTool.enabled.is_(True)))}
servers = {r.name: r for r in (await session.execute(select(McpServer))).scalars()}
missing: list[tuple[str, str, str]] = []
for who, name in bindings:
if name.startswith("mcp:"):
server, _, tool = name[4:].partition("/")
row = servers.get(server)
if row is None or not row.enabled:
missing.append((who, name, "tools"))
elif row.status == "error":
logger.warning("%s 绑的 %s:MCP 服务 %s 上次没连上,运行时可能拿不到这个工具", who, name, server)
elif tool not in ("", "*") and row.tools_cache and tool not in row.tools_cache:
logger.warning("%s 绑的 %s:MCP 服务 %s 上次交回的工具清单里没有 %s", who, name, server, tool)
elif name in builtin:
continue
elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)):
source = name[len(QUERY_PREFIX):] if name.startswith(QUERY_PREFIX) else name[len(SCHEMA_PREFIX):]
if source not in sources:
missing.append((who, name, "data"))
elif name not in custom:
missing.append((who, name, "tools"))
return missing
async def _refuse_missing_tools(session: AsyncSession, graph: dict[str, Any]) -> None:
"""绑定的工具在本机不存在就不发起:以前要执行到那一步才失败,前面的步骤白做、钱白花。"""
try:
spec = GraphSpec.model_validate(graph)
except Exception: # noqa: BLE001 - 图本身不成形交给 run_manager.start 报
return
missing = await missing_tools(session, spec)
if not missing:
return
lines = ";".join(f"{who}绑定的 {name}" for who, name, _ in missing[:8])
more = f"等 {len(missing)} 处" if len(missing) > 8 else ""
places = {where for _, _, where in missing}
if places == {"data"}:
fix = "请到「数据」页接入,或在节点中重新选择"
elif places == {"tools"}:
fix = "请到「工具」页接入,或在节点中重新选择"
else:
fix = "数据源工具请到「数据」页接入,其他工具请到「工具」页接入,或在节点中重新选择"
raise LaunchRefused(422, f"绑定的工具不存在:{lines}{more}。{fix}", TOOL_MISSING)
+3 -2
View File
@@ -1062,11 +1062,12 @@ async def _data_versions(session: Any, spec: GraphSpec) -> dict[str, Any]:
async def _bound_sources(session: Any, spec: GraphSpec) -> tuple[set[str], bool]:
"""图里(含子工作流)绑定的数据源工具指向的源名,以及有没有带模板、发起时定不下来的工具名。
绑定的取法和发起前检查同一个(api/runs.py 的 tool_bindings),子工作流照运行时的取法
绑定的取法和发起前检查同一个(engine/launch.py 的 tool_bindings),子工作流照运行时的取法
读出来接着找:固定了版本的读那一版,没固定的读工作流现在的图。读不到、图不成形的跳过——
那个节点运行时自己会报错。同一个工作流(同一版)只读一次,互相引用也不会转圈。
"""
from app.api.runs import node_setting, tool_bindings
# 在函数里导入:launch 在模块级导入了本模块(它要 run_manager)
from app.engine.launch import node_setting, tool_bindings
from app.db.models import WorkflowVersion
from app.tools.datasource import source_of_tool
+337
View File
@@ -0,0 +1,337 @@
"""发起运行的规则搬到 engine/launch.py:HTTP 发起和定时任务到点发起共用同一个 launch_run。
- 每一种拒绝:POST /api/runs 的状态码、detail、code 先按搬家之前的原话写死(这一组先在旧代码上通过);
直接调 launch_run 抛 LaunchRefused,和 HTTP 的返回逐条一致
- 发起之后的治理事件(受管级别那条日志、口径升版处置)在服务里写:不经 HTTP 发起的正式运行一样有
- 旧的导入路径(app.api.runs 的 missing_tools、node_setting、tool_bindings、TOOL_MISSING)照旧可用;
runner 不再从接口层导入
"""
from __future__ import annotations
import asyncio
import inspect
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec
from app.main import app
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
SIMPLE = chain(node("start", "input"), node("out", "output", fields=[{"name": "r", "value": "完成"}]))
def _tag() -> str:
return uuid.uuid4().hex[:8]
async def workflow(graph=SIMPLE, *, published=None, versions=(), snapshot=None, name=None) -> tuple[str, str]:
"""建一个工作流。published 是当前发布版本号,versions 是要建快照的版本号,snapshot 是快照里的图(默认同 graph)。"""
name = name or f"发起测试·周报-{_tag()}"
async with SessionLocal() as session:
wf = Workflow(name=name, graph=graph, status="published" if published else "draft",
published_version=published, version=max(versions, default=1))
session.add(wf)
await session.flush()
for v in versions:
session.add(WorkflowVersion(workflow_id=wf.id, version=v,
graph=snapshot if snapshot is not None else graph))
await session.commit()
return wf.id, name
def tool_graph(*tools: tuple[str, str]) -> dict:
"""每个 (节点名, 工具名) 一个调用工具节点。"""
middle = [node(f"t{i}", "tool", label, tool=tool, args={}) for i, (label, tool) in enumerate(tools)]
return chain(node("start", "input"), *middle, node("out", "output", fields=[{"name": "r", "value": "完成"}]))
# --------------------------------------------------------------------------
# 每一种拒绝:(请求体, (状态码, detail, code))。detail 是 None 时只比两边一致,另由 check 查要点
# --------------------------------------------------------------------------
async def case_formal_without_workflow():
return {"run_class": "formal"}, (400, "正式运行需要从已保存的工作流发起:请求中没有指定工作流", None)
async def case_formal_with_canvas_graph():
wf, _ = await workflow(published=1, versions=(1,))
return ({"run_class": "formal", "workflow_id": wf, "graph": SIMPLE},
(400, "正式运行只能使用已发布的版本,画布上未发布的修改不会生效。请先保存并发布", None))
async def case_formal_workflow_gone():
return {"run_class": "formal", "workflow_id": f"gone{_tag()}"}, (404, "工作流不存在,可能已被删除", None)
async def case_formal_never_published():
wf, name = await workflow()
return {"run_class": "formal", "workflow_id": wf}, (409, f"「{name}」还没有发布版本,请先在编排页发布", None)
async def case_formal_not_the_published_version():
wf, _ = await workflow(published=2, versions=(1, 2))
return ({"run_class": "formal", "workflow_id": wf, "version": 1},
(409, "v1 不是当前发布版本(v2)。正式运行只能使用当前发布版本;如需运行这一版,请先发布它。", None))
async def case_formal_snapshot_missing():
wf, _ = await workflow(published=3, versions=(1, 2))
return {"run_class": "formal", "workflow_id": wf}, (404, "版本 v3 不存在", None)
async def case_formal_snapshot_malformed():
"""快照里的图不成形:以前由 main.py 的 ValueError 兜底回 400,detail 是校验的原话。"""
broken = {"nodes": "坏掉的图"}
wf, _ = await workflow(published=1, versions=(1,), snapshot=broken)
with pytest.raises(ValueError) as caught:
GraphSpec.model_validate(broken)
return {"run_class": "formal", "workflow_id": wf}, (400, str(caught.value), None)
def library(version="v3"):
return chain(node("start", "input"),
node("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version=version,
metrics=[{"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.kpi.gmv"}]),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.caliber.text }}"}]))
def weekly(lib_id: str, **card) -> dict:
return chain(node("start", "input"),
node("fetch", "transform", mode="expression", expression="{'gmv': 4321.5}", assign_to="kpi"),
node("card", "metrics", "本地口径卡",
caliber_from={"workflow_id": lib_id, "workflow_version": 1, "node_id": "caliber"}, **card),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]))
async def upstream_with_two_versions() -> str:
async with SessionLocal() as session:
lib = Workflow(name=f"口径库-{_tag()}", graph=library(), status="published", published_version=2, version=2)
session.add(lib)
await session.flush()
session.add(WorkflowVersion(workflow_id=lib.id, version=1, graph=library()))
session.add(WorkflowVersion(workflow_id=lib.id, version=2, graph=library(version="v4")))
await session.commit()
return lib.id
async def case_formal_caliber_upgrade_undeclared():
lib = await upstream_with_two_versions()
graph = weekly(lib)
wf, _ = await workflow(graph, published=1, versions=(1,))
return {"run_class": "formal", "workflow_id": wf}, (409, None, None)
def check_formal_caliber_upgrade_undeclared(detail: str) -> None:
assert "「本地口径卡」" in detail and "v1" in detail and "v2" in detail and "「上游发布新版本时」" in detail
async def case_saved_workflow_gone():
return {"workflow_id": f"gone{_tag()}"}, (404, "工作流不存在,可能已被删除", None)
async def case_nothing_to_run():
return {}, (400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容", None)
async def case_saved_workflow_is_empty():
wf, _ = await workflow({})
return {"workflow_id": wf}, (400, "没有要运行的内容:请求中既没有工作流 ID,也没有工作流内容", None)
async def case_missing_datasource_tool():
source = f"gone{_tag()}"
return ({"graph": tool_graph(("取数", f"db_query__{source}"))},
(422, f"绑定的工具不存在:「取数」(调用工具)绑定的 db_query__{source}。请到「数据」页接入,或在节点中重新选择",
"run_tool_missing"))
async def case_missing_custom_tool():
tool = f"ghost_{_tag()}"
return ({"graph": tool_graph(("查编码", tool))},
(422, f"绑定的工具不存在:「查编码」(调用工具)绑定的 {tool}。请到「工具」页接入,或在节点中重新选择",
"run_tool_missing"))
async def case_missing_tools_of_both_kinds():
source, tool = f"gone{_tag()}", f"ghost_{_tag()}"
return ({"graph": tool_graph(("取数", f"db_schema__{source}"), ("查编码", tool))},
(422, f"绑定的工具不存在:「取数」(调用工具)绑定的 db_schema__{source};「查编码」(调用工具)绑定的 {tool}。"
"数据源工具请到「数据」页接入,其他工具请到「工具」页接入,或在节点中重新选择", "run_tool_missing"))
async def case_more_than_eight_missing_tools():
tag = _tag()
tools = [(f"第{i}步", f"ghost_{tag}_{i}") for i in range(10)]
listed = ";".join(f"「{label}」(调用工具)绑定的 {tool}" for label, tool in tools[:8])
return ({"graph": tool_graph(*tools)},
(422, f"绑定的工具不存在:{listed}等 10 处。请到「工具」页接入,或在节点中重新选择", "run_tool_missing"))
async def case_formal_missing_tool():
source = f"gone{_tag()}"
wf, _ = await workflow(tool_graph(("取数", f"db_query__{source}")), published=1, versions=(1,))
return ({"run_class": "formal", "workflow_id": wf},
(422, f"绑定的工具不存在:「取数」(调用工具)绑定的 db_query__{source}。请到「数据」页接入,或在节点中重新选择",
"run_tool_missing"))
async def case_graph_fails_validation():
return ({"graph": {"nodes": [], "edges": []}},
(400, "工作流未通过校验,请先在画布上修正标红的节点再运行:画布上还没有节点,请先从节点库拖入一个节点", None))
#: 「不存在」的工作流、数据源、自定义工具都带随机后缀,库里不会恰好有同名的
CASES = {name[len("case_"):]: fn for name, fn in dict(globals()).items() if name.startswith("case_")}
def _body(status: int, detail: str, code: str | None) -> dict:
return {"detail": detail, **({"code": code} if code else {})}
async def _run_count() -> int:
async with SessionLocal() as session:
return len((await session.execute(select(Run.id))).all())
@pytest.mark.parametrize("case", list(CASES))
async def test_http_refusals_are_unchanged(client, case):
"""HTTP 这一侧逐字写死:搬家前后都得是这些状态码和原话。"""
body, (status, detail, code) = await CASES[case]()
before = await _run_count()
res = await client.post("/api/runs", json=body)
assert res.status_code == status, res.text
got = res.json()
if detail is None:
globals()[f"check_{case}"](got["detail"])
assert set(got) == ({"detail", "code"} if code else {"detail"}), got
else:
assert got == _body(status, detail, code)
assert await _run_count() == before, "被拒的请求不该留下运行记录"
def _launch_kwargs(body: dict) -> dict:
"""请求体换成 launch_run 的参数:input 对应 input_payload,其余同名。"""
out = {k: v for k, v in body.items() if k != "input"}
if "input" in body:
out["input_payload"] = body["input"]
return out
@pytest.mark.parametrize("case", list(CASES))
async def test_launch_run_refuses_exactly_like_http(client, case):
from app.engine.launch import LaunchRefused, launch_run
body, (status, detail, code) = await CASES[case]()
before = await _run_count()
async with SessionLocal() as session:
with pytest.raises(LaunchRefused) as caught:
await launch_run(session, actor="定时任务", **_launch_kwargs(body))
refused = caught.value
assert (refused.status_code, refused.code) == (status, code)
if detail is not None:
assert refused.message == detail
res = await client.post("/api/runs", json=body)
assert res.status_code == refused.status_code
assert res.json() == _body(refused.status_code, refused.message, refused.code)
assert await _run_count() == before
# --------------------------------------------------------------------------
# 发起成功:参数照转、治理事件在服务里写
# --------------------------------------------------------------------------
async def _settle(run_id: str) -> Run:
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 没有结束:{row.status}")
async def _events(run_id: str, etype: str) -> list[RunEvent]:
async with SessionLocal() as session:
return list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype).order_by(RunEvent.seq)
)).scalars())
async def test_a_formal_launch_records_the_governance_notes():
"""不经 HTTP 发起的正式运行(定时任务)也记下按什么级别出具、口径升版怎么处置的。"""
from app.engine.launch import launch_run
lib = await upstream_with_two_versions()
wf, name = await workflow(weekly(lib, upgrade_policy="dual"), published=1, versions=(1,))
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=wf, run_class="formal", input_payload={"week": "第 40 周"},
actor="定时任务")
assert (run.run_class, run.version, run.workflow_name, run.started_by) == ("formal", 1, name, "定时任务")
assert run.version_hash and run.input == {"week": "第 40 周"}
row = await _settle(run.id)
assert row.status == "succeeded", row.error
notes = [e.data for e in await _events(run.id, "log") if e.data.get("code") == "run_governance"]
assert len(notes) == 1 and notes[0]["governed"] is False and "v1" in notes[0]["message"]
[upgrade] = await _events(run.id, "caliber.upgrade")
assert upgrade.node_id == "card" and upgrade.data["policy"] == "dual" and upgrade.data["latest"] == 2
async def test_an_exploratory_launch_takes_the_saved_graph_and_the_run_defaults():
from app.engine.launch import launch_run
wf, name = await workflow()
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=wf, actor=None)
assert (run.run_class, run.workflow_id, run.workflow_name, run.version) == ("exploratory", wf, name, None)
assert (run.memory_scope, run.collection) == ("default", "default") and run.input == {}
row = await _settle(run.id)
assert row.status == "succeeded", row.error
assert [e for e in await _events(run.id, "log") if e.data.get("code") == "run_governance"] == []
# --------------------------------------------------------------------------
# 旧的导入路径
# --------------------------------------------------------------------------
def test_the_old_import_paths_still_work():
from app.api import runs as runs_api
from app.engine import launch
for name in ("missing_tools", "node_setting", "tool_bindings", "TOOL_MISSING"):
assert getattr(runs_api, name) is getattr(launch, name), name
def test_the_runner_no_longer_imports_from_the_api_layer():
from app.engine import runner
assert "app.api.runs" not in inspect.getsource(runner)