mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
2e7d6103ea
- 配置: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 老库迁移
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""运行选项和来源:Run.options 是发起时定下、整次运行沿用的选项快照,Run.schedule_id 是发起它的定时任务。
|
|
|
|
- 旧库升级时补这两列和 schedule_id 的索引;存量运行的选项读出来是 {},来源为空
|
|
- RunManager.start 收下两者并落库;选项非空时写进 run.started(进封存范围),为空时 run.started 和以前
|
|
逐字相同;恢复、继续运行沿用发起时的选项,节点从 RunContext.extra 读得到同一份
|
|
- 接口带出 schedule_id,运行列表能按定时任务筛;launch_run 原样往下交
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import create_engine, select, text
|
|
|
|
from app.db.base import SessionLocal, engine as app_engine
|
|
from app.db.models import Run, RunEvent
|
|
from app.engine import compiler
|
|
from app.engine.runner import run_manager
|
|
from app.engine.schema import NodeType
|
|
from app.main import app
|
|
|
|
#: 加 options 之前 run.started 就有的键:选项为空时一个不多、一个不少
|
|
LEGACY_STARTED_KEYS = {"nodes", "resumed", "memory_scope", "collection", "approval_default", "tool_trust",
|
|
"agent_limits", "data_versions", "replay_protocol"}
|
|
OPTIONS = {"verify": {"dual_check": True, "max_queries": 6}, "note": "第 40 周例行核查"}
|
|
|
|
|
|
@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, **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:])]}
|
|
|
|
|
|
SIMPLE = chain(node("start", "input"), node("out", "output", fields=[{"name": "r", "value": "完成"}]))
|
|
GATED = chain(node("start", "input"),
|
|
node("before", "transform", mode="template", template="前"),
|
|
node("gate", "human", mode="approve", title="放行吗"),
|
|
node("after", "transform", mode="template", template="后"),
|
|
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.after }}"}]))
|
|
|
|
|
|
async def _settle(run_id: str, statuses=("succeeded", "failed", "cancelled")) -> Run:
|
|
for _ in range(400):
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run_id)
|
|
if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
|
|
await run_manager.wait_idle(run_id)
|
|
return row
|
|
await asyncio.sleep(0.03)
|
|
raise AssertionError(f"运行 {run_id} 停在 {row.status}")
|
|
|
|
|
|
async def _started(run_id: str) -> list[dict]:
|
|
async with SessionLocal() as session:
|
|
return [e.data for e in (await session.execute(
|
|
select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == "run.started").order_by(RunEvent.seq)
|
|
)).scalars()]
|
|
|
|
|
|
def _schedule() -> str:
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 旧库升级
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_old_databases_get_both_columns_and_the_index():
|
|
from app.db.base import _migrate
|
|
|
|
db = create_engine("sqlite://")
|
|
with db.begin() as conn:
|
|
conn.execute(text("CREATE TABLE runs (id VARCHAR(32) PRIMARY KEY, workflow_name VARCHAR(200), "
|
|
"status VARCHAR(20), last_seq INTEGER)"))
|
|
conn.execute(text("INSERT INTO runs (id, workflow_name, status, last_seq) VALUES ('r1', '周报', 'succeeded', 9)"))
|
|
_migrate(conn)
|
|
_migrate(conn) # 再执行一次不出错:列和索引都已经在了
|
|
cols = {row[1] for row in conn.execute(text("PRAGMA table_info(runs)"))}
|
|
assert {"options", "schedule_id"} <= cols
|
|
assert conn.execute(text("SELECT options, schedule_id FROM runs WHERE id = 'r1'")).one() == ("{}", None)
|
|
indexes = {row[1]: row for row in conn.execute(text("PRAGMA index_list(runs)"))}
|
|
assert "ix_runs_schedule_id" in indexes
|
|
assert [r[2] for r in conn.execute(text("PRAGMA index_info(ix_runs_schedule_id)"))] == ["schedule_id"]
|
|
|
|
|
|
def test_a_missing_table_is_left_alone():
|
|
"""还没有 runs 表的库(create_all 之前不会这样,这里只确认迁移不替它建索引、不报错)。"""
|
|
from app.db.base import _migrate
|
|
|
|
db = create_engine("sqlite://")
|
|
with db.begin() as conn:
|
|
_migrate(conn)
|
|
assert conn.execute(text("SELECT name FROM sqlite_master WHERE name = 'ix_runs_schedule_id'")).first() is None
|
|
|
|
|
|
async def test_new_databases_index_schedule_id():
|
|
from sqlalchemy import inspect
|
|
|
|
async with app_engine.connect() as conn:
|
|
indexes = await conn.run_sync(lambda sync: inspect(sync).get_indexes("runs"))
|
|
assert {ix["name"]: ix["column_names"] for ix in indexes}.get("ix_runs_schedule_id") == ["schedule_id"]
|
|
|
|
|
|
async def test_a_migrated_row_reads_back_empty_options():
|
|
"""迁移回填的是字符串 '{}':经 ORM 读出来是空 dict,不是字符串,也不是 None。"""
|
|
async with SessionLocal() as session:
|
|
run = Run(workflow_name="迁移回填", status="succeeded", graph={}, input={})
|
|
session.add(run)
|
|
await session.commit()
|
|
run_id = run.id
|
|
async with app_engine.begin() as conn:
|
|
await conn.execute(text("UPDATE runs SET options = '{}' WHERE id = :id"), {"id": run_id})
|
|
async with SessionLocal() as session:
|
|
row = await session.get(Run, run_id)
|
|
assert row.options == {} and row.schedule_id is None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 发起:落库、run.started、沿用
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_options_and_schedule_are_stored_and_sealed():
|
|
sid = _schedule()
|
|
run = await run_manager.start(graph=SIMPLE, input_payload={}, options=OPTIONS, schedule_id=sid)
|
|
assert (run.options, run.schedule_id) == (OPTIONS, sid)
|
|
row = await _settle(run.id)
|
|
assert row.status == "succeeded", row.error
|
|
assert (row.options, row.schedule_id) == (OPTIONS, sid)
|
|
[started] = await _started(run.id)
|
|
assert started["options"] == OPTIONS
|
|
assert set(started) == LEGACY_STARTED_KEYS | {"options"}
|
|
|
|
|
|
@pytest.mark.parametrize("options", [None, {}])
|
|
async def test_without_options_run_started_is_unchanged(options):
|
|
run = await run_manager.start(graph=SIMPLE, input_payload={}, options=options)
|
|
row = await _settle(run.id)
|
|
assert row.options == {} and row.schedule_id is None
|
|
[started] = await _started(run.id)
|
|
assert set(started) == LEGACY_STARTED_KEYS, "选项为空时 run.started 多出了键"
|
|
|
|
|
|
async def test_the_options_belong_to_the_run_not_to_the_callers_dict():
|
|
mine = {"verify": {"dual_check": True}}
|
|
run = await run_manager.start(graph=SIMPLE, input_payload={}, options=mine)
|
|
mine["verify"]["dual_check"] = False
|
|
mine["late"] = 1
|
|
await _settle(run.id)
|
|
async with SessionLocal() as session:
|
|
assert (await session.get(Run, run.id)).options == {"verify": {"dual_check": True}}
|
|
|
|
|
|
@pytest.fixture
|
|
def seen_options(monkeypatch):
|
|
"""记下每次执行「数据整形」节点时它从运行上下文里读到的选项。"""
|
|
from app.engine.runner import RUN_OPTIONS_KEY
|
|
|
|
seen: list = []
|
|
original = compiler.RUNNERS[NodeType.TRANSFORM]
|
|
|
|
async def spy(state, ctx):
|
|
seen.append(ctx.run.extra.get(RUN_OPTIONS_KEY))
|
|
return await original(state, ctx)
|
|
|
|
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, spy)
|
|
return seen
|
|
|
|
|
|
async def test_resume_keeps_the_options_the_run_started_with(seen_options):
|
|
run = await run_manager.start(graph=GATED, input_payload={}, options=OPTIONS)
|
|
assert (await _settle(run.id, ("interrupted",))).status == "interrupted"
|
|
await run_manager.resume(run.id, {"approved": True})
|
|
row = await _settle(run.id)
|
|
assert row.status == "succeeded", row.error
|
|
assert seen_options == [OPTIONS, OPTIONS], "恢复之后节点读到的选项变了"
|
|
first, second = await _started(run.id)
|
|
assert first["options"] == second["options"] == OPTIONS and second["resumed"] is True
|
|
|
|
|
|
async def test_continue_keeps_the_options_too(seen_options, monkeypatch):
|
|
original = compiler.RUNNERS[NodeType.TRANSFORM]
|
|
calls = {"n": 0}
|
|
|
|
async def flaky(state, ctx):
|
|
calls["n"] += 1
|
|
if ctx.node.id == "after" and calls["n"] <= 2:
|
|
raise RuntimeError("临时故障")
|
|
return await original(state, ctx)
|
|
|
|
monkeypatch.setitem(compiler.RUNNERS, NodeType.TRANSFORM, flaky)
|
|
graph = chain(node("start", "input"),
|
|
node("before", "transform", mode="template", template="前"),
|
|
node("after", "transform", mode="template", template="后"),
|
|
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.after }}"}]))
|
|
run = await run_manager.start(graph=graph, input_payload={}, options=OPTIONS)
|
|
assert (await _settle(run.id)).status == "failed"
|
|
await run_manager.continue_failed(run.id)
|
|
row = await _settle(run.id)
|
|
assert row.status == "succeeded", row.error
|
|
assert [s.get("options") for s in await _started(run.id)] == [OPTIONS, OPTIONS]
|
|
|
|
|
|
async def test_nodes_read_an_empty_dict_when_there_are_no_options(seen_options):
|
|
run = await run_manager.start(graph=GATED, input_payload={})
|
|
await _settle(run.id, ("interrupted",))
|
|
assert seen_options == [{}]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 接口:带出来源、按定时任务筛、launch_run 照转
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_the_api_shows_and_filters_by_schedule(client):
|
|
sid, other = _schedule(), _schedule()
|
|
mine = [await run_manager.start(graph=SIMPLE, input_payload={}, schedule_id=sid) for _ in range(2)]
|
|
theirs = await run_manager.start(graph=SIMPLE, input_payload={}, schedule_id=other)
|
|
manual = await run_manager.start(graph=SIMPLE, input_payload={})
|
|
for run in (*mine, theirs, manual):
|
|
await _settle(run.id)
|
|
|
|
got = (await client.get(f"/api/runs/{mine[0].id}")).json()
|
|
assert got["schedule_id"] == sid
|
|
assert (await client.get(f"/api/runs/{manual.id}")).json()["schedule_id"] is None
|
|
|
|
listed = (await client.get("/api/runs", params={"schedule_id": sid})).json()
|
|
assert sorted(r["id"] for r in listed) == sorted(r.id for r in mine)
|
|
assert all(r["schedule_id"] == sid for r in listed)
|
|
# 不带这个参数时照旧全列
|
|
everything = {r["id"] for r in (await client.get("/api/runs", params={"limit": 200})).json()}
|
|
assert {theirs.id, manual.id} <= everything
|
|
|
|
|
|
async def test_launch_run_passes_both_through():
|
|
from app.engine.launch import launch_run
|
|
|
|
sid = _schedule()
|
|
async with SessionLocal() as session:
|
|
run = await launch_run(session, graph=SIMPLE, actor="定时任务", schedule_id=sid, options=OPTIONS)
|
|
row = await _settle(run.id)
|
|
assert (row.schedule_id, row.options, row.started_by) == (sid, OPTIONS, "定时任务")
|
|
[started] = await _started(run.id)
|
|
assert started["options"] == OPTIONS
|
|
|
|
|
|
async def test_an_http_launch_has_no_schedule_and_no_options(client):
|
|
res = await client.post("/api/runs", json={"graph": SIMPLE})
|
|
assert res.status_code == 201, res.text
|
|
assert res.json()["schedule_id"] is None
|
|
row = await _settle(res.json()["id"])
|
|
assert row.options == {}
|