Files
AgentLab/backend/tests/test_run_launch.py
T
ThearchyHelios 2e7d6103ea 应用库可移植到 PostgreSQL:AGENTLAB_DATABASE_URL 设成 postgresql+asyncpg://… 时整个应用库用 PostgreSQL,断点用官方的 langgraph-checkpoint-postgres(同一个库,AGENTLAB_CHECKPOINT_URL 可另给);不设时照旧是数据目录里的 SQLite,行为不变
- 配置:database_url、checkpoint_url(启动时校验写法,只认 PostgreSQL)、连接池四项;启动日志写用的是哪种库(主机、库名、用户,不写密码)
- 引擎(db/base.make_engine):SQLite 照旧 WAL、外键、忙等;PostgreSQL 用连接池、探活、会话时区 UTC,装上方言调整(db/postgres.py):建表时 String(n) 是不限长的 VARCHAR(SQLite 从不查长度,导过来的存量也可能超长)、ORDER BY 里可空列的 NULL 位置照 SQLite 补写(升序在前、降序在后,窗口函数里同样);Integer 列在 PostgreSQL 上是 BIGINT(列挂到表上时换成 with_variant,绑定参数跟着列按 BIGINT 转换);JSON 列两边都存原文(PostgreSQL 的 json,不用 JSONB:JSONB 重排对象里的键,快照的列序、表的先后读回来就变了);JSON 里的 NaN 写成 null、NUL 去掉(PostgreSQL 不收)
- 迁移按方言:已有的列和索引用 inspector 认;PostgreSQL 上补列的类型取模型里这一列的、缺省值取迁移表里的(布尔 0/1 换成 false/true),ADD COLUMN IF NOT EXISTS;去重语句里的布尔按方言写;部分唯一索引补 postgresql_where;init_db 在 PostgreSQL 上拿咨询锁(几个进程同时启动不撞)
- 应用库上的 SQLite 专用写法改成按方言编译的表达式(db/portable.py):json_extract 的十几处(口径召回、出具、对账、个人应用、幂等键、访问控制、数据源变化通知、剖析任务、待办超时)换成 json_eq / json_flag / json_text,json_type 换成 json_is_string(两种库同一套带类型的比较);能力登记的 sqlite insert 换成按会话方言的 insert_ignore;能力使用次数的 json_each 按方言分支;附件「没绑记录」的条件不靠 json 的等号;按报错文字认唯一约束的地方换成 unique_violation(按表和列认,两种库的报错都认得)
- 断点(engine/checkpoints.py):PostgreSQL 时开 psycopg 连接池、在咨询锁里 setup;runner 只认 BaseCheckpointSaver,读断点仍只走 saver 的公开接口
- 依赖:新 extra postgres(asyncpg、psycopg[binary] 3、psycopg-pool、langgraph-checkpoint-postgres 3.1.2);langgraph-checkpoint 卡在 <4.3(断点格式就在这个包里,postgres 包新版要 4.3,不动已装的 4.2.0);锁文件补上新包
- 运行收尾:记 action.unknown、安排核实做完才把运行摘掉(以前先摘掉,等「不在跑了」再读事件的一方在库慢时读不到这一条)
- 测试:AGENTLAB_TEST_DATABASE_URL 时跑在 PostgreSQL 上(每个 xdist 进程建自己的库、按事件循环分的连接池 tests/pgpool.py),不设时盖掉环境里的数据库地址照旧用 SQLite;scripts/test-postgres.sh 起临时的 postgres:16 容器跑全量;sqlite_only 标记;另开连接改库的几个竞态测试、触发器测试改成两种库都能跑(tests/appdb.py);新增 test_db_portable 守着方言调整、JSON 键序、超长字符串、大整数、NULL 排序、时区、PostgreSQL 老库迁移
2026-10-19 09:52:09 +08:00

376 lines
17 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.
"""发起运行的规则搬到 engine/launch.py:HTTP 发起和定时任务到点发起共用同一个 launch_run。
- 每一种拒绝:POST /api/runs 的状态码、detail、code 先按搬家之前的原话写死(这一组先在旧代码上通过);
直接调 launch_run 抛 LaunchRefused,和 HTTP 的返回逐条一致
- 发起时定下的治理事件(受管级别那条日志、口径升版处置)在服务里写:不经 HTTP 发起的正式运行一样有;
它们和运行记录同一个事务提交,排在 run.started 前面、在封存范围里,写不进去就不建运行
- 旧的导入路径(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():
"""v1 是草稿快照(没有发布级别,也不是当前发布版)。应用平台之后正式运行接受「已发布且未退役」的版本,草稿照旧不行,
拒绝带上机器码 run_version_unpublished(F1)。"""
wf, _ = await workflow(published=2, versions=(1, 2))
return ({"run_class": "formal", "workflow_id": wf, "version": 1},
(409, "v1 没有发布过(当前发布版本为 v2)。正式运行只能使用已发布的版本;如需运行这一版,请先发布它。",
"run_version_unpublished"))
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()
# 两版都发布过(level 记着):口径卡固定的版本须「已发布且未退役」(D-q)
session.add(WorkflowVersion(workflow_id=lib.id, version=1, graph=library(), level="published"))
session.add(WorkflowVersion(workflow_id=lib.id, version=2, graph=library(version="v4"), level="published"))
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 for e in await _events(run.id, "log") if e.data.get("code") == "run_governance"]
assert len(notes) == 1 and notes[0].data["governed"] is False and "v1" in notes[0].data["message"]
[upgrade] = await _events(run.id, "caliber.upgrade")
assert upgrade.node_id == "card" and upgrade.data["policy"] == "dual" and upgrade.data["latest"] == 2
# 发起时就有的事件排在 run.started 前面,都在封存范围里
[started] = await _events(run.id, "run.started")
assert notes[0].seq < upgrade.seq < started.seq <= row.manifest_seq
async def test_launch_events_are_written_with_the_run_or_not_at_all():
"""发起时定下的事件和运行记录同一个事务提交:写不进去就不建运行,launch_run 抛出异常就是没有发起。以前运行
建好、开始执行之后才另外写,写失败时运行已经在执行,调用方(定时任务)只拿到一个异常,会把一次正在执行的
正式运行记成未能发起,封存范围里也少了受管级别那条日志。"""
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.db.base import engine
from app.engine.launch import launch_run
from tests.appdb import refuse_inserts
lib = await upstream_with_two_versions()
wf, _ = await workflow(weekly(lib, upgrade_policy="dual"), published=1, versions=(1,))
before = await _run_count()
# 口径升版处置那条写不进去。走异步引擎建删触发器:同步连接等写锁时会卡住事件循环,而持锁的正是这个循环里的写入
create, drop = refuse_inserts("run_events", "NEW.type = 'caliber.upgrade'", name="refuse_upgrade_note")
async with engine.begin() as conn:
for sql in create:
await conn.execute(text(sql))
try:
async with SessionLocal() as session:
with pytest.raises(IntegrityError):
await launch_run(session, workflow_id=wf, run_class="formal", actor="定时任务")
assert await _run_count() == before, "没写进治理事件,却留下了一条运行"
finally:
async with engine.begin() as conn:
for sql in drop:
await conn.execute(text(sql))
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)