Files
AgentLab/backend/tests/test_run_data_versions.py
T
ThearchyHelios 2cdbf9a733 Excel 导入改造期 3:网格交互、自助修复、按期累积——每月的报表可以累积成一份跨月的表,漂移了点修复按钮就能接着导入,网格上框一块就能写进配方,版本页可以回滚、移除一期、清除原件
按期累积(新 recipe_accumulate;table_versions 扩展)
- 按统计期出的交叉表报表首次导入时默认「按期累积」,由人确认;一期一个构建,两期及以上物化成一份新的 SQLite 快照,不用视图
- 统计期完全相同要勾「替换该期」,部分重叠拒收,中间有空缺要确认;物化前逐期核对库哈希和行号,并集上另加行数守恒、主键唯一、日期落在所属统计期三条核对
- 配方演进分三档:新增列(早期各期为空值)、退役的列和表保留旧值(只在这次才退役时要勾)、不兼容变化只能显式「重新开始累积」
- 快照 id 在累积时含目标配方和模式;同样的输入物化两次库哈希相同,同一文件换名再传直接复用
- 说明按期合并,任何一期没核对过就不写「已核对」;各期维度取值不同、退役列、合计表都有提示,经过数字检查

修复按钮(新 recipe_fixes;执行器给问题带结构化参数)
- 加入标签、移除标签、声明为合计行、分段标题改名、编辑恒等式成员、按锚点忽略行/列/区域外文字、声明占位符、声明隐藏行处理、工作表改名
- 服务端算补丁,先预览新旧配方对照、再应用,可以撤销;补丁封闭,不引入正则、自由文本常量和写死坐标;修复进确认清单
- 只改分段标题、新标题仍含原来选的词时沿用已确认的常量,不算破坏性变更;修复后只查改动过的段的认领,没改的关系交给试运行核对

框选转锚点(新 recipe_select)
- 网格上框选(鼠标、键盘、区域输入),按八种用途换算成按文字认的锚点,立刻干跑比对;配方里不存坐标,泛化不了时列出原因
- 整张表挪位后同一配方照样认得;框进合计行、夹着小计、表下说明都有明确的处理

确认与对照(recipe_confirm、recipe_diff、新 recipe_compare)
- 改配方后确认清单只列有变化的项(同时按本期数据比较),不用每修一次重勾十来项;新旧配方对照界面;按规则重新起草并把名字对齐到现行配方
- 改配方后已体现在配方里的回答保留,矛盾的列出来请人重答
- 「排除的行」写进回执和导入清单:隐藏行、跳过的空行、按配方忽略的行、停止后的文字行、只核对不另存的合计行

版本页(新 source_versions、api/source_versions;前端 VersionsDialog)
- 快照与导入记录列表、启用旧版本(可写理由)、查看清单、移除一期、作废接受、清除原件;写操作带界面看到的当前版本,并发改动回 409,不静默丢期
- 除当前版本和被运行引用的版本外,再保留最近 3 个版本供回滚;遮罩列丢失要明确确认

期 1、期 2 遗留
- 重传撞上被改过的构建文件:能用本次上传恢复就隔离旧文件再恢复,否则回 409,不再是 500
- 跳过空行的列表不再把表下隔一行的说明读成数据;不像说明的单格行要人确认;受影响的配方构建换 id
- 版本存储加进程级文件锁:第二个进程拿不到锁时不清理、不回收,写操作回 503(部署本来就是单进程)
2026-10-02 07:38:44 +08:00

897 lines
44 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.
"""运行固定上传表格的数据版本(期 1 WP-D)。
上传源每次重传是一个新快照。运行发起时把这张图用到的上传源的当前快照记进 runs.data_versions,
之后这次运行里的一切——工具描述、db_schema、冻结的表结构、查询结果——都出自那一版:
- 运行中途重传:审批恢复、失败后继续运行,查到的仍是发起时那一版;
- 固定的快照被回收、文件被删:工具明确报错,绝不改用当前版本;
- 只固定图里(含子工作流、图级 defaults 绑的)实际用到的上传源;工具名带模板时全部固定;手工源不进来;
- 子工作流沿用父运行固定的版本;构造工具时没拿到版本(漏传)的上传源报错,不改用当前版本;
- 发起时没算到的源,第一次用到时补固定、记进运行,并在事件流里留一条日志;
- 调用工具节点遇到数据本身用不了时判失败,报错照交原因,不让人改 SQL;
- 查询快照记下 data_version(快照 id),手工源不记;工具库试用(不在运行里)用当前版本。
夹具:表格在测试里用 openpyxl 现造,标签是假名,数字手写。
"""
from __future__ import annotations
import asyncio
import io
import json
import os
import sqlite3
import stat
import uuid
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from sqlalchemy import select
from app.api.runs import missing_tools
from app.core import artifact_store
from app.core.config import settings
from app.core.events import EventType
from app.data import table_versions
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent, SourceSnapshot, Workflow
from app.engine.context import RunContext
from app.engine.runner import _data_versions, run_manager
from app.engine.schema import GraphSpec
from app.tools.datasource import (
DATA_UNAVAILABLE,
LATE_PIN_CODE,
RUN_VERSIONS_KEY,
build_datasource_tools,
run_versions,
)
from app.tools.registry import ToolContext, Versions
SUM = 'SELECT SUM("数量") AS total FROM "明细"'
@pytest.fixture(autouse=True)
def store(monkeypatch, tmp_path):
data = tmp_path / "data"
(data / "uploads").mkdir(parents=True)
monkeypatch.setattr(settings, "data_dir", data)
yield data
@pytest.fixture
async def engine_up(store):
# 依赖 store:checkpoint 库跟着 data_dir 走,要先换好目录再建
await run_manager.setup()
yield
await run_manager.shutdown()
# --------------------------------------------------------------------------
# 夹具:表格、数据源
# --------------------------------------------------------------------------
def book(sheets: dict[str, list[list]]) -> bytes:
from openpyxl import Workbook
wb = Workbook()
wb.remove(wb.active)
for title, rows in sheets.items():
ws = wb.create_sheet(title)
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
#: 第一版:一张「明细」,数量合计 3
V1 = {"明细": [["区域", "数量"], ["分区甲", 1], ["分区乙", 2]]}
#: 第二版:「明细」合计 100,另多一张「新增」。表名、数字都和第一版不同,串了版本一眼看得出
V2 = {"明细": [["区域", "数量"], ["分区甲", 60], ["分区乙", 40]], "新增": [["时段", "客流"], ["上午", 7]]}
def uniq(prefix: str = "dv") -> str:
return f"{prefix}_{uuid.uuid4().hex[:10]}"
async def publish(name: str, sheets: dict[str, list[list]]) -> tuple[str, str]:
"""发布一版上传表格,返回 (源 id, 快照 id)。源不存在就新建。"""
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
result = await table_versions.publish_upload(session, row, book(sheets), "客流.xlsx",
header_row=1, mixed="reject", raw_mode=False)
return row.id, result.snapshot_id
async def manual_source(tmp_path, total: int) -> tuple[str, str, str]:
"""一个手工登记的 SQLite 源(文件在上传目录外)。返回 (源 id, 源名, 文件路径)。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript('CREATE TABLE "明细" ("区域" TEXT, "数量" INTEGER);')
db.execute('INSERT INTO "明细" VALUES (?, ?)', ("分区甲", total))
db.commit()
db.close()
name = uniq("manual")
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
await session.commit()
return row.id, name, str(path)
async def keep(source_id: str, name: str, snapshot: str) -> str:
"""登记一条引用这个快照的中断运行,让之后重传触发的回收不删它。返回运行 id。"""
async with SessionLocal() as session:
run = Run(workflow_name="保留旧版本", status="interrupted",
data_versions={source_id: {"snapshot": snapshot, "name": name}})
session.add(run)
await session.commit()
return run.id
async def tools_for(names: list[str], *, data_versions=Versions.CURRENT, run_id: str = "", emit=None) -> dict:
"""建数据源工具。缺省按不在运行里(工具库试用)建,用当前版本。"""
async with SessionLocal() as session:
built = await build_datasource_tools(
names, ToolContext(run_id=run_id, node_id="n", data_versions=data_versions, emit=emit), session)
return {t.name: t for t in built}
def total_of(text: str) -> object:
return json.loads(text)["rows"][0][0]
def pinned_of(pins: dict, sid: str) -> str | None:
return (pins.get(sid) or {}).get("snapshot")
# --------------------------------------------------------------------------
# 跑图的工具
# --------------------------------------------------------------------------
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": 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:])]}
async def wait(run_id: str, statuses=("interrupted", "failed", "succeeded"), timeout=30.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 and row.status in statuses and run_manager._tasks.get(run_id) is None:
return row
await asyncio.sleep(0.05)
raise AssertionError(f"等超时了:{run_id}")
async def events_of(run_id: str, kind: str, node_id: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
query = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind)
if node_id is not None:
query = query.where(RunEvent.node_id == node_id)
return list((await session.execute(query.order_by(RunEvent.seq))).scalars())
async def started_events(run_id: str) -> list[RunEvent]:
return await events_of(run_id, "run.started")
async def late_pin_events(run_id: str) -> list[RunEvent]:
return [e for e in await events_of(run_id, "log") if (e.data or {}).get("code") == LATE_PIN_CODE]
def loop_graph(first: str, second: str, condition: str) -> dict:
"""t1 查 first → 循环(条件执行时才报错的话停在这里)→ t2 查 second → 输出两次的合计。"""
return {
"nodes": [
node("start", "input"),
node("t1", "tool", tool=f"db_query__{first}", args={"sql": SUM}),
node("lp", "loop", mode="while", condition=condition, max_iterations=1),
node("body", "transform", mode="template", template="转一圈"),
node("t2", "tool", tool=f"db_query__{second}", args={"sql": SUM}),
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
{"name": "t2", "value": "{{ nodes.t2 }}"}]),
],
"edges": [
{"source": "start", "target": "t1"}, {"source": "t1", "target": "lp"},
{"source": "lp", "target": "body", "sourceHandle": "body"}, {"source": "body", "target": "lp"},
{"source": "lp", "target": "t2", "sourceHandle": "done"}, {"source": "t2", "target": "out"},
],
}
#: 过得了静态检查、执行时才报错的循环条件(指数超限):运行停在循环节点上,失败
FAILS_AT_RUNTIME = "2 ** 100000 > 1"
def output_total(run: Run, field: str) -> object:
value = (run.output or {})[field]
return total_of(value if isinstance(value, str) else json.dumps(value))
def scripted_agent(monkeypatch, source: str) -> dict:
"""Agent 先列表结构、再查合计、然后收尾;协作成员(只绑了查询工具)直接查合计。
记下各自看到的工具描述(按 (谁, 工具名))和工具结果(按调用 id)。
"""
from app.providers import mock_model
seen: dict = {"descriptions": {}, "results": {}}
routes = {"n": 0}
def _decide(self, messages):
if self.response_format:
if "assignments" not in json.dumps(self.response_format, ensure_ascii=False):
return AIMessage(content="{}")
# 调度者:第一轮派给成员,第二轮收工
routes["n"] += 1
plan = ({"assignments": [{"agent": "analyst", "instruction": "查合计"}]} if routes["n"] == 1
else {"assignments": [], "done": True})
return AIMessage(content=json.dumps(plan, ensure_ascii=False))
if not self.tools:
return AIMessage(content="收尾")
names = {t["name"] for t in self.tools}
who = "agent" if f"db_schema__{source}" in names else "member"
for t in self.tools:
seen["descriptions"][(who, t["name"])] = t["description"]
done = set()
for m in messages:
if isinstance(m, ToolMessage):
seen["results"][m.tool_call_id] = str(m.content)
done.add(m.tool_call_id)
plan = [(f"{who}_schema", f"db_schema__{source}", {}), (f"{who}_sum", f"db_query__{source}", {"sql": SUM})]
for call_id, name, args in plan:
if name in names and call_id not in done:
return AIMessage(content="查一下。", tool_calls=[{"name": name, "args": args, "id": call_id}])
return AIMessage(content="查完了。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return seen
def json_in(text: str) -> dict:
"""工具结果交给模型时前面可能标了证据编号:从第一个「{」起读。"""
return json.loads(text[text.index("{"):])
# --------------------------------------------------------------------------
# 发起时固定哪些源
# --------------------------------------------------------------------------
async def test_start_pins_only_the_upload_sources_the_graph_uses(engine_up, monkeypatch, tmp_path):
used, unused = uniq(), uniq()
used_id, used_snap = await publish(used, V1)
unused_id, _ = await publish(unused, V1)
manual_id, manual, _ = await manual_source(tmp_path, 5)
scripted_agent(monkeypatch, used)
graph = chain(
node("start", "input"),
node("t1", "tool", tool=f"db_query__{used}", args={"sql": SUM}),
node("bot", "agent", prompt="查一下", tools=[f"db_schema__{used}", f"db_query__{manual}"], max_steps=3),
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"}]),
)
run = await run_manager.start(graph=graph, input_payload={})
pinned = {used_id: {"snapshot": used_snap, "name": used}}
assert run.data_versions == pinned
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert done.data_versions == pinned
assert unused_id not in done.data_versions and manual_id not in done.data_versions
# run.started 记下固定的版本:封存清单里查得到这次运行用的是哪一版
assert (await started_events(run.id))[0].data["data_versions"] == pinned
async def test_subgraph_sources_are_pinned_and_templated_names_pin_every_upload_source(tmp_path):
top, inner, other, off = uniq(), uniq(), uniq(), uniq()
top_id, top_snap = await publish(top, V1)
inner_id, inner_snap = await publish(inner, V1)
other_id, _ = await publish(other, V1)
off_id, _ = await publish(off, V1)
_, manual, _ = await manual_source(tmp_path, 5)
async with SessionLocal() as session:
(await session.get(DataSource, off_id)).enabled = False
sub = Workflow(name="子流程", graph=chain(
node("s", "input"), node("q", "tool", tool=f"db_query__{inner}", args={"sql": SUM}), node("o", "output")))
session.add(sub)
await session.commit()
sub_id = sub.id
graph = chain(
node("start", "input"),
node("t", "tool", tool=f"db_query__{top}", args={"sql": SUM}),
node("sub", "subgraph", workflow_id=sub_id),
node("m", "tool", tool=f"db_query__{manual}", args={"sql": SUM}),
node("out", "output"),
)
async with SessionLocal() as session:
pinned = await _data_versions(session, GraphSpec.model_validate(graph))
# 子工作流里绑的源也固定;没用到的、手工的不固定
assert pinned == {top_id: {"snapshot": top_snap, "name": top},
inner_id: {"snapshot": inner_snap, "name": inner}}
# 工具名带模板:发起时说不准会用哪个源,所有启用着的上传源都固定上,停用的、手工的不算
graph["nodes"][1]["data"]["config"]["tool"] = "db_query__{{ vars.which }}"
async with SessionLocal() as session:
everything = await _data_versions(session, GraphSpec.model_validate(graph))
uploads = {r.id: r.current_snapshot_id for r in (await session.execute(select(DataSource).where(
DataSource.origin == "upload", DataSource.enabled.is_(True),
DataSource.current_snapshot_id.is_not(None)))).scalars()}
assert {sid: v["snapshot"] for sid, v in everything.items()} == uploads
assert {top_id, inner_id, other_id} <= set(everything) and off_id not in everything
# 一个上传源都没用到:空 dict(不是 None,None 留给升级前发起的运行)
async with SessionLocal() as session:
assert await _data_versions(session, GraphSpec.model_validate(
chain(node("start", "input"), node("out", "output")))) == {}
# --------------------------------------------------------------------------
# 运行中途重传:恢复、继续运行都还是发起时那一版
# --------------------------------------------------------------------------
async def test_reupload_while_waiting_for_approval_does_not_change_the_run(engine_up, monkeypatch):
src = uniq()
sid, s1 = await publish(src, V1)
seen = scripted_agent(monkeypatch, src)
graph = chain(
node("start", "input"),
node("t1", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
node("gate", "human", mode="approve", title="看一眼"),
node("bot", "agent", prompt="查合计", tools=[f"db_schema__{src}", f"db_query__{src}"], max_steps=4),
node("team", "supervisor", goal="查合计", max_rounds=2,
agents=[{"name": "analyst", "system": "你是分析员", "tools": [f"db_query__{src}"], "max_steps": 3}]),
node("t2", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"},
{"name": "t2", "value": "{{ nodes.t2 }}"}]),
)
run = await run_manager.start(graph=graph, input_payload={})
assert (await wait(run.id)).status == "interrupted"
# 停在审批上时重传:当前版本变成 S2(合计 100,多一张「新增」)
_, s2 = await publish(src, V2)
assert s2 != s1
await run_manager.resume(run.id, {"approved": True})
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert output_total(done, "t1") == 3
assert output_total(done, "t2") == 3, "恢复之后查到了重传的新版本"
# 恢复后才建的 Agent 和协作成员的工具:描述、表结构、查询都还是 S1
for who in ("agent", "member"):
desc = seen["descriptions"][(who, f"db_query__{src}")]
assert "明细" in desc and "新增" not in desc, who
assert "共 1 张表" in seen["results"]["agent_schema"] and "新增" not in seen["results"]["agent_schema"]
assert json_in(seen["results"]["agent_sum"])["rows"] == [[3]]
assert json_in(seen["results"]["member_sum"])["rows"] == [[3]]
# 运行记录和两段 run.started 记的都是 S1
assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
assert [e.data["data_versions"] for e in await started_events(run.id)] == [
{sid: {"snapshot": s1, "name": src}}] * 2
async def test_continue_after_failure_keeps_the_version(engine_up):
src = uniq()
sid, s1 = await publish(src, V1)
run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
assert (await wait(run.id, ("failed", "succeeded"))).status == "failed"
await publish(src, V2)
await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert output_total(done, "t1") == 3
assert output_total(done, "t2") == 3, "继续运行时改用了重传的新版本"
assert [e.data["data_versions"] for e in await started_events(run.id)] == [
{sid: {"snapshot": s1, "name": src}}] * 2
# --------------------------------------------------------------------------
# 工具层:按固定的版本生成、固定的版本没了就报错
# --------------------------------------------------------------------------
async def test_tools_follow_the_pinned_snapshot_and_record_data_version():
src = uniq()
sid, s1 = await publish(src, V1)
await keep(sid, src, s1)
_, s2 = await publish(src, V2)
pins = {sid: {"snapshot": s1, "name": src}}
try:
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"], data_versions=pins)
query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
assert "明细" in query.description and "新增" not in query.description
listed = await schema.coroutine()
assert "共 1 张表" in listed and "新增" not in listed
assert "数量" in await schema.coroutine(table="明细")
text = await query.coroutine(sql=SUM)
payload = json.loads(text)
assert total_of(text) == 3
# data_version 只进查询快照,不进交给模型的结果
assert "data_version" not in payload
assert artifact_store.load(payload["artifact"])["data_version"] == s1
# 冻结的表结构是 S1 的那份,不是数据源上现在的 S2
assert set(artifact_store.load(payload["schema_artifact"])["tables"]) == {"明细"}
# 不在运行里(工具库试用、data_versions 为 None)用当前版本
current = (await tools_for([f"db_query__{src}"]))[f"db_query__{src}"]
assert "新增" in current.description
text = await current.coroutine(sql=SUM)
assert total_of(text) == 100
assert artifact_store.load(json.loads(text)["artifact"])["data_version"] == s2
finally:
await engines.invalidate(sid)
async def test_pinned_snapshot_collected_by_gc_gives_a_clear_error_and_never_reads_current(monkeypatch):
# 期 3 起回收另外保留最近 SNAPSHOT_KEEP 个快照;调成 0(期 1 的规则),没人引用的 S1 才会在重传后被回收
monkeypatch.setattr(table_versions, "SNAPSHOT_KEEP", 0)
src = uniq()
sid, s1 = await publish(src, V1)
# 没有运行引用 S1:重传后的回收把它标成已回收、删掉文件
await publish(src, V2)
async with SessionLocal() as session:
snap = await session.get(SourceSnapshot, s1)
assert snap.retired_at is not None and not os.path.exists(snap.db_path)
try:
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
data_versions={sid: {"snapshot": s1, "name": src}})
query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
# 描述不列表名:表名是当前版本的
assert "明细" not in query.description and "新增" not in query.description
assert "本次运行固定的数据版本已不存在" in query.description
text = await query.coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
assert "不会改用当前版本" in text and "100" not in text
# Agent 里这句话作为工具结果交回(tool.end),运行面板按「查询失败」开头把那一步标成失败
assert text.startswith("查询失败")
listed = await schema.coroutine()
assert "本次运行固定的数据版本已不存在" in listed and "新增" not in listed
# 版本根本不属于这个源(记录被改坏、别的源的快照)也一样
other = uniq()
_, foreign = await publish(other, V2)
tools = await tools_for([f"db_query__{src}"], data_versions={sid: {"snapshot": foreign, "name": src}})
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
finally:
await engines.invalidate(sid)
async def test_snapshot_file_deleted_after_tools_were_built():
"""工具建好之后快照文件被删:缓存的连接还开着已删的文件,也不能拿它接着查。"""
src = uniq()
sid, s1 = await publish(src, V1)
await keep(sid, src, s1)
await publish(src, V2)
try:
tools = await tools_for([f"db_query__{src}", f"db_schema__{src}"],
data_versions={sid: {"snapshot": s1, "name": src}})
query = tools[f"db_query__{src}"]
assert total_of(await query.coroutine(sql=SUM)) == 3 # 连接已建好、进了缓存
async with SessionLocal() as session:
path = (await session.get(SourceSnapshot, s1)).db_path
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
os.unlink(path)
text = await query.coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在") and "100" not in text
assert (await tools[f"db_schema__{src}"].coroutine()).startswith(
f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
finally:
await engines.invalidate(sid)
async def test_source_first_used_mid_run_is_pinned_and_recorded():
"""发起时没算到的源(继续运行时改绑、子工作流中途改过):第一次用到时固定,记进运行。"""
src = uniq()
sid, s1 = await publish(src, V1)
async with SessionLocal() as session:
run = Run(workflow_name="中途补固定", status="running", data_versions={})
session.add(run)
await session.commit()
run_id = run.id
pins: dict = {}
seen: list = []
emit = lambda kind, **data: seen.append((kind, data)) # noqa: E731 - 和 NodeContext.emit 一样是同步的
try:
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
assert pins == {sid: {"snapshot": s1, "name": src, "late": True}}
async with SessionLocal() as session:
assert (await session.get(Run, run_id)).data_versions == pins
# 补固定这件事本身进事件流:当次这一段的 run.started 里没有这个源
[(kind, data)] = seen
assert kind == EventType.LOG and data["code"] == LATE_PIN_CODE
assert (data["source_id"], data["snapshot"], data["late"]) == (sid, s1, True)
assert src in data["message"]
# 之后重传:同一段执行里(同一个 dict)、恢复后(从运行记录读回)都还是 S1
await publish(src, V2)
assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
again = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
assert len(seen) == 1, "已经固定过的源不该再发一遍"
assert total_of(await again[f"db_query__{src}"].coroutine(sql=SUM)) == 3
async with SessionLocal() as session:
stored = dict((await session.get(Run, run_id)).data_versions)
resumed = await tools_for([f"db_query__{src}"], data_versions=stored, run_id=run_id)
assert total_of(await resumed[f"db_query__{src}"].coroutine(sql=SUM)) == 3
finally:
await engines.invalidate(sid)
async def test_late_pin_takes_what_the_run_record_already_has():
"""运行记录里已经有这个源(上一段执行补固定的),内存里还没有:认运行记录的,不取当前版本。"""
src = uniq()
sid, s1 = await publish(src, V1)
async with SessionLocal() as session:
run = Run(workflow_name="已记过", status="running",
data_versions={sid: {"snapshot": s1, "name": src, "late": True}})
session.add(run)
await session.commit()
run_id = run.id
await publish(src, V2)
pins: dict = {}
seen: list = []
async def emit(kind, **data): # 协程函数也认
seen.append((kind, data))
try:
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id, emit=emit)
assert pinned_of(pins, sid) == s1
assert seen == [], "上一段执行补固定时已经发过事件"
assert total_of(await tools[f"db_query__{src}"].coroutine(sql=SUM)) == 3
finally:
await engines.invalidate(sid)
async def test_manual_sources_are_not_versioned(tmp_path):
sid, name, path = await manual_source(tmp_path, 5)
pins: dict = {}
try:
tools = await tools_for([f"db_query__{name}"], data_versions=pins)
text = await tools[f"db_query__{name}"].coroutine(sql=SUM)
assert total_of(text) == 5
assert "data_version" not in artifact_store.load(json.loads(text)["artifact"])
assert pins == {}, "手工源不该进 data_versions"
# 手工源查的就是那个文件此刻的内容
db = sqlite3.connect(path)
db.execute('UPDATE "明细" SET "数量" = 9')
db.commit()
db.close()
assert total_of(await tools[f"db_query__{name}"].coroutine(sql=SUM)) == 9
finally:
await engines.invalidate(sid)
# --------------------------------------------------------------------------
# 子工作流、漏传版本、调用工具节点的报错、图级 defaults、补固定的事件
# --------------------------------------------------------------------------
async def test_subgraph_resumed_after_approval_reads_the_pinned_version(engine_up):
"""子工作流的工具经它自己的 RunContext 拿版本:父运行停在审批时重传,恢复后子工作流查的仍是 S1。"""
src = uniq()
sid, s1 = await publish(src, V1)
async with SessionLocal() as session:
sub = Workflow(name="子流程取数", graph=chain(
node("s", "input"),
node("q", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
node("o", "output", fields=[{"name": "t", "value": "{{ nodes.q }}"}])))
session.add(sub)
await session.commit()
sub_id = sub.id
graph = chain(
node("start", "input"),
node("gate", "human", mode="approve", title="看一眼"),
node("sub", "subgraph", workflow_id=sub_id),
node("out", "output", fields=[{"name": "sub", "value": "{{ nodes.sub.output.t }}"}]),
)
try:
run = await run_manager.start(graph=graph, input_payload={})
assert run.data_versions == {sid: {"snapshot": s1, "name": src}}
assert (await wait(run.id)).status == "interrupted"
_, s2 = await publish(src, V2)
assert s2 != s1
await run_manager.resume(run.id, {"approved": True})
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert output_total(done, "sub") == 3, "子工作流恢复后查到了重传的新版本"
payload = json.loads((done.output or {})["sub"])
assert artifact_store.load(payload["artifact"])["data_version"] == s1
# 拿到的是发起时固定的那份,不是第一次用到时才补的
assert done.data_versions == {sid: {"snapshot": s1, "name": src}}
assert await late_pin_events(run.id) == []
finally:
await engines.invalidate(sid)
async def test_tools_built_without_versions_refuse_upload_sources(tmp_path):
"""构造 ToolContext 时没给版本(在运行里漏传了):上传源报错,不当成「不在运行里」改用当前版本。"""
src = uniq()
sid, _ = await publish(src, V1)
spec = GraphSpec.model_validate(chain(node("start", "input"), node("out", "output")))
# 运行上下文里没有这个键(不是 runner 建的,或往下传时丢了):UNSET,不是 None
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec)) is Versions.UNSET
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: None})) is None
pins: dict = {}
assert run_versions(RunContext(run_id="r", thread_id="r", spec=spec, extra={RUN_VERSIONS_KEY: pins})) is pins
assert ToolContext().data_versions is Versions.UNSET
manual_id, manual, _ = await manual_source(tmp_path, 5)
try:
async with SessionLocal() as session:
built = {t.name: t for t in await build_datasource_tools(
[f"db_query__{src}", f"db_schema__{src}", f"db_query__{manual}"],
ToolContext(run_id="r", node_id="n"), session)}
query, schema = built[f"db_query__{src}"], built[f"db_schema__{src}"]
assert "没有拿到本次运行固定的数据版本" in query.description and "明细" not in query.description
text = await query.coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本") and "rows" not in text
assert (await schema.coroutine()).startswith(f"{DATA_UNAVAILABLE}没有拿到本次运行固定的数据版本")
# 手工源不分版本,照常查
assert total_of(await built[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
finally:
await engines.invalidate(sid)
await engines.invalidate(manual_id)
@pytest.mark.parametrize("prefix", ["db_query__", "db_schema__"])
async def test_tool_node_fails_with_the_reason_when_its_pinned_snapshot_is_gone(engine_up, prefix):
"""停在审批时重传、再删掉 S1 的文件:恢复后调用工具节点判失败,报错照交原因,不让人改 SQL。"""
src = uniq()
sid, s1 = await publish(src, V1)
tool = f"{prefix}{src}"
graph = chain(
node("start", "input"),
node("gate", "human", mode="approve", title="看一眼"),
node("t", "tool", tool=tool, args={"sql": SUM} if prefix == "db_query__" else {}),
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]),
)
try:
run = await run_manager.start(graph=graph, input_payload={})
assert (await wait(run.id)).status == "interrupted"
await publish(src, V2) # 这次运行引用着 S1,回收不删它
async with SessionLocal() as session:
path = (await session.get(SourceSnapshot, s1)).db_path
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
os.unlink(path)
await run_manager.resume(run.id, {"approved": True})
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "failed"
error = done.error or ""
assert f"工具 {tool} 无法使用:本次运行固定的数据版本已不存在(数据源「{src}」)" in error, error
assert "请重新发起运行" in error
# 改 SQL 没用,不能这么指;原话的开头也不能和外面那层叠成两遍
assert "SQL" not in error and "查询失败" not in error and error.count(DATA_UNAVAILABLE) == 0, error
[err] = await events_of(run.id, "tool.error", "t")
assert err.data["error"].startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据版本已不存在")
assert not await events_of(run.id, "tool.end", "t")
finally:
await engines.invalidate(sid)
async def test_tools_bound_through_graph_defaults_are_pinned_at_start():
"""Agent 节点没写 tools、协作节点没写成员时运行时取图级 defaults,发起时固定版本也按它算。"""
agent_src, member_src = uniq(), uniq()
agent_id, agent_snap = await publish(agent_src, V1)
member_id, member_snap = await publish(member_src, V1)
graph = chain(node("start", "input"), node("bot", "agent", prompt="查一下"),
node("team", "supervisor", goal="查合计"), node("out", "output"))
graph["defaults"] = {"tools": [f"db_query__{agent_src}"],
"agents": [{"name": "analyst", "tools": [f"db_schema__{member_src}"]}]}
async with SessionLocal() as session:
assert await _data_versions(session, GraphSpec.model_validate(graph)) == {
agent_id: {"snapshot": agent_snap, "name": agent_src},
member_id: {"snapshot": member_snap, "name": member_src}}
# 发起前检查是同一个取法:defaults 里绑的工具不存在,发起前就拦下
ghost = uniq("ghost")
broken = {**graph, "defaults": {"tools": [f"db_query__{ghost}"]}}
missing = await missing_tools(session, GraphSpec.model_validate(broken))
assert [(name, where) for _, name, where in missing] == [(f"db_query__{ghost}", "data")]
# 节点上写了(哪怕是空列表)就不看 defaults:和 NodeContext.cfg 一样
graph["nodes"][1]["data"]["config"]["tools"] = []
assert set(await _data_versions(session, GraphSpec.model_validate(graph))) == {member_id}
async def test_source_first_used_after_continue_is_pinned_and_leaves_an_event(engine_up):
"""继续运行时改绑了发起时没用到的源:第一次用到时补固定,记进运行,事件流里留一条日志。"""
first, later = uniq(), uniq()
first_id, first_snap = await publish(first, V1)
later_id, later_snap = await publish(later, V1)
try:
run = await run_manager.start(graph=loop_graph(first, first, FAILS_AT_RUNTIME), input_payload={})
failed = await wait(run.id, ("failed", "succeeded"))
assert failed.status == "failed"
assert failed.data_versions == {first_id: {"snapshot": first_snap, "name": first}}
await run_manager.continue_failed(run.id, graph=loop_graph(first, later, "false"))
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert done.data_versions[later_id] == {"snapshot": later_snap, "name": later, "late": True}
# 这一段的 run.started 里没有它(发起这一段时还没用到):补固定的那条日志是事件流里唯一的记录
assert later_id not in (await started_events(run.id))[-1].data["data_versions"]
[event] = await late_pin_events(run.id)
assert event.node_id == "t2"
assert (event.data["source_id"], event.data["snapshot"], event.data["late"]) == (later_id, later_snap, True)
finally:
await engines.invalidate(first_id)
await engines.invalidate(later_id)
async def test_continue_after_the_pinned_source_is_deleted_and_recreated_under_the_same_name(engine_up):
"""固定的源被删、又用同一个名字新建:续跑报错,不改查同名的新源,也不把新源当成中途新建的补固定。"""
from httpx import ASGITransport, AsyncClient
from app.main import app
src = uniq()
a_id, s1 = await publish(src, V1)
b_id = None
try:
run = await run_manager.start(graph=loop_graph(src, src, FAILS_AT_RUNTIME), input_payload={})
failed = await wait(run.id, ("failed", "succeeded"))
assert failed.status == "failed"
assert failed.data_versions == {a_id: {"snapshot": s1, "name": src}}
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
assert (await c.delete(f"/api/datasources/{a_id}")).status_code == 204
b_id, _ = await publish(src, V2)
assert b_id != a_id
await run_manager.continue_failed(run.id, graph=loop_graph(src, src, "false"))
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "failed", f"续跑改查了同名重建的新源:{done.output}"
error = done.error or ""
assert f"本次运行固定的数据源「{src}」已被删除或重建,不会改用同名的新数据源" in error, error
assert "SQL" not in error and "100" not in error
# 新源没有被补固定进运行记录,也没有补固定的日志
assert set(done.data_versions) == {a_id}
assert not await late_pin_events(run.id)
finally:
await engines.invalidate(a_id)
if b_id:
await engines.invalidate(b_id)
async def test_same_name_manual_source_does_not_stand_in_for_a_pinned_upload_source(tmp_path):
"""同名重建成手工源也拦住:手工源不分版本,但运行固定的是另一个源,不能拿它顶上。"""
_, manual, _ = await manual_source(tmp_path, 5)
pins = {"gone-source-id": {"snapshot": "0" * 64, "name": manual}}
tools = await tools_for([f"db_query__{manual}", f"db_schema__{manual}"], data_versions=pins, run_id="r")
text = await tools[f"db_query__{manual}"].coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}本次运行固定的数据源「{manual}」已被删除或重建"), text
assert "5" not in text.replace(manual, "")
assert (await tools[f"db_schema__{manual}"].coroutine()).startswith(DATA_UNAVAILABLE)
# 运行里没固定过这个名字的手工源照常查
tools = await tools_for([f"db_query__{manual}"], data_versions={}, run_id="r")
assert total_of(await tools[f"db_query__{manual}"].coroutine(sql=SUM)) == 5
def _tamper(path: str) -> None:
"""改掉库文件末尾一个字节(发布后是 0444,先放开写)。"""
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
with open(path, "r+b") as f:
f.seek(-1, os.SEEK_END)
last = f.read(1)
f.seek(-1, os.SEEK_END)
f.write(bytes([last[0] ^ 0xFF]))
async def test_tampered_snapshot_is_reported_as_data_unavailable_not_as_a_sql_error(engine_up):
"""快照文件被改过(哈希对不上):工具按「数据不可用」交回,调用工具节点不让人去改 SQL。"""
src = uniq()
sid, s1 = await publish(src, V1)
graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
try:
async with SessionLocal() as session:
path = (await session.get(SourceSnapshot, s1)).db_path
await engines.invalidate(sid) # 下次查询新建引擎,建之前核对哈希
_tamper(path)
for pins in (Versions.CURRENT, {sid: {"snapshot": s1, "name": src}}):
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id="r")
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
assert text.startswith(f"{DATA_UNAVAILABLE}数据源「{src}」的数据文件与登记的版本不一致"), text
assert "SQL" not in text and "请联系管理员" in text
run = await run_manager.start(graph=graph, input_payload={})
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "failed"
error = done.error or ""
assert error.startswith(f"工具 db_query__{src} 无法使用:数据源「{src}」的数据文件与登记的版本不一致"), error
assert "SQL" not in error and "查询失败" not in error, error
finally:
await engines.invalidate(sid)
async def test_reupload_while_a_run_is_being_started_does_not_collect_its_snapshot(engine_up, monkeypatch):
"""发起运行读完指针、运行记录还没提交时有人重传:发布后的回收看不到这条运行,以前会把刚固定的快照删掉,
这次运行只能报「固定的数据版本已不存在」。现在读指针到提交持着版本存储的锁,回收等它提交之后才开始。"""
from app.engine import runner
src = uniq()
sid, s1 = await publish(src, V1)
real = runner._data_versions
pending: list[asyncio.Task] = []
async def racing(session, spec):
pins = await real(session, spec)
# 读完指针的这一刻有人重传。给它足够的时间做完(没有锁的话它会做完,连同回收)
task = asyncio.create_task(publish(src, V2))
pending.append(task)
await asyncio.wait({task}, timeout=0.5)
return pins
monkeypatch.setattr(runner, "_data_versions", racing)
graph = chain(node("start", "input"), node("t", "tool", tool=f"db_query__{src}", args={"sql": SUM}),
node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}]))
try:
run = await run_manager.start(graph=graph, input_payload={})
_, s2 = await pending[0]
assert run.data_versions == {sid: {"snapshot": s1, "name": src}} and s2 != s1
async with SessionLocal() as session:
snap = await session.get(SourceSnapshot, s1)
assert snap.retired_at is None and os.path.exists(snap.db_path), "回收删掉了刚固定的快照"
done = await wait(run.id, ("succeeded", "failed"))
assert done.status == "succeeded", done.error
assert output_total(done, "t") == 3
finally:
await engines.invalidate(sid)
async def test_late_pin_reads_the_pointer_again_under_the_lock(monkeypatch):
"""补固定时数据源记录是建工具前读的:那之后重传过、旧快照已回收,不能拿旧指针去固定。
在版本存储的锁里重新读指针,固定的是此刻的当前版本,而且回收不会在写进运行记录之前删掉它。"""
from app.tools import datasource
src = uniq()
sid, s1 = await publish(src, V1)
async with SessionLocal() as session:
run = Run(workflow_name="补固定撞上重传", status="running", data_versions={})
session.add(run)
await session.commit()
run_id = run.id
real = datasource._late_lock
reuploaded: list[str] = []
class Racing:
"""进补固定的锁之前,先让一次重传整个做完(发布、切指针、回收掉没人引用的 S1)。"""
async def __aenter__(self):
reuploaded.append((await publish(src, V2))[1])
self.lock = real()
await self.lock.acquire()
async def __aexit__(self, *exc):
self.lock.release()
monkeypatch.setattr(datasource, "_late_lock", lambda: Racing())
pins: dict = {}
try:
tools = await tools_for([f"db_query__{src}"], data_versions=pins, run_id=run_id)
[s2] = reuploaded
assert pinned_of(pins, sid) == s2, "补固定用了建工具前读到的旧指针"
text = await tools[f"db_query__{src}"].coroutine(sql=SUM)
assert not text.startswith(DATA_UNAVAILABLE), text
assert total_of(text) == 100
async with SessionLocal() as session:
assert pinned_of((await session.get(Run, run_id)).data_versions, sid) == s2
finally:
await engines.invalidate(sid)