mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:01:57 +00:00
315 lines
16 KiB
Python
315 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from typing import Any, AsyncIterator
|
||
|
||
from sqlalchemy import JSON, DateTime, MetaData, event
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||
from sqlalchemy.types import TypeDecorator
|
||
|
||
from app.core.config import settings
|
||
|
||
NAMING_CONVENTION = {
|
||
"ix": "ix_%(column_0_label)s",
|
||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
||
"pk": "pk_%(table_name)s",
|
||
}
|
||
|
||
|
||
def utcnow() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def new_id() -> str:
|
||
return uuid.uuid4().hex
|
||
|
||
|
||
class UTCDateTime(TypeDecorator):
|
||
"""存 UTC、读出来也是带时区的 UTC。
|
||
|
||
SQLite 没有时区类型:DateTime(timezone=True) 写进去的只是一串不带偏移的
|
||
字面时间,读回来是 naive 的。API 于是吐出 '2026-09-26T01:11:19',浏览器把
|
||
不带偏移的 ISO 串当本地时间解析,运行列表整体慢了 8 小时。在这一层补上 UTC,
|
||
pydantic 序列化自然带 +00:00,所有接口一次修好。
|
||
|
||
写入前先换算成 UTC 再去掉时区:SQLite 方言格式化时直接丢弃 tzinfo,
|
||
一个 +08:00 的时间原样写进去,读回来就差出 8 小时。
|
||
"""
|
||
|
||
impl = DateTime
|
||
cache_ok = True
|
||
|
||
def process_bind_param(self, value: datetime | None, dialect: Any) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is not None:
|
||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return value
|
||
|
||
def process_result_value(self, value: datetime | None, dialect: Any) -> datetime | None:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is None:
|
||
return value.replace(tzinfo=timezone.utc)
|
||
return value.astimezone(timezone.utc)
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||
|
||
# 图定义、节点配置、事件负载都是任意形状的 JSON,统一映射到 SQLite 的 JSON 列
|
||
type_annotation_map = {dict[str, Any]: JSON, list[Any]: JSON, datetime: UTCDateTime}
|
||
|
||
|
||
class TimestampMixin:
|
||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
|
||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow)
|
||
|
||
|
||
def _stop_aiosqlite_once() -> None:
|
||
"""aiosqlite 连接的工作线程只停一次;之后再要求停的,拿到的是同一个结果。
|
||
|
||
aiosqlite 0.22 的 stop() 每调一次都往工作线程队列里放一个"关闭并退出线程"
|
||
的请求,而线程处理完第一个就退出了——之后放进去的请求没人处理,等它的一方
|
||
永远挂着。close() 的 finally 里会调 stop(),SQLAlchemy 的强制 terminate 也会
|
||
直接调;两个 close 并发、或者 close 撞上强制 terminate,就会卡死。实测同一
|
||
连接并发 close 两次,10 次卡 10 次。
|
||
|
||
在这里触发它的路径:并行的两条支路一条失败,LangGraph 取消另一条,而那一条
|
||
正在写工件。取消打进进行中的数据库操作,SQLAlchemy 在两条路径上各 terminate
|
||
一次同一条连接——节点任务取消不完,整个运行卡在 running,占着的并发名额也不
|
||
还。大约五次里有一次。连接池和 checkpointer 都走 aiosqlite,补在这一层都管得到。
|
||
"""
|
||
import weakref
|
||
|
||
import aiosqlite
|
||
|
||
original = aiosqlite.Connection.stop
|
||
if getattr(original, "_agentlab_once", False):
|
||
return
|
||
stopping: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary()
|
||
|
||
def stop(self): # type: ignore[no-untyped-def]
|
||
if self not in stopping:
|
||
stopping[self] = original(self)
|
||
return stopping[self]
|
||
|
||
stop._agentlab_once = True # type: ignore[attr-defined]
|
||
aiosqlite.Connection.stop = stop # type: ignore[method-assign]
|
||
|
||
|
||
_stop_aiosqlite_once()
|
||
# pool_pre_ping:取连接前先探一下活。取消异常打进 SQLAlchemy 的清理流程时
|
||
# (_close_connection 记完日志会把 CancelledError 原样抛出,跳过了把连接记录
|
||
# 清空的那一行),已经关掉的连接会留在池里,下一个取到它的人拿到的是
|
||
# "no active connection"。探活失败会被当成断开,换一条新的——SQLite 本地一次
|
||
# SELECT 1 的代价,换池子不被污染。
|
||
engine = create_async_engine(settings.db_url, echo=False, future=True, pool_pre_ping=True)
|
||
|
||
|
||
@event.listens_for(engine.sync_engine, "connect")
|
||
def _set_sqlite_pragma(dbapi_conn, _record): # pragma: no cover - 驱动回调
|
||
"""WAL + 外键约束。WAL 让读写不互相阻塞,事件写入和前端轮询能并行。"""
|
||
cur = dbapi_conn.cursor()
|
||
cur.execute("PRAGMA journal_mode=WAL")
|
||
cur.execute("PRAGMA foreign_keys=ON")
|
||
cur.execute("PRAGMA busy_timeout=5000")
|
||
cur.close()
|
||
|
||
|
||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||
|
||
|
||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||
"""FastAPI 依赖注入用的会话。"""
|
||
async with SessionLocal() as session:
|
||
yield session
|
||
|
||
|
||
# 轻量迁移:create_all 只建新表,不给已有表加列。SQLite 的 ADD COLUMN
|
||
# 便宜且带默认值回填,够用;等真需要改列类型再考虑 alembic。
|
||
_COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [
|
||
("runs", "run_class", "TEXT DEFAULT 'exploratory'"),
|
||
("runs", "version", "INTEGER"),
|
||
("runs", "version_hash", "VARCHAR(64)"),
|
||
("runs", "manifest_hash", "VARCHAR(64)"),
|
||
("runs", "manifest_seq", "INTEGER"),
|
||
("runs", "started_by", "VARCHAR(100)"),
|
||
# 续跑、恢复要沿用第一次发起时的记忆域、知识库和审批默认值;以前只在第一段
|
||
# _drive 里传过一次,恢复后回落成 "default",同一次运行前后两段查的不是同一个库
|
||
("runs", "memory_scope", "VARCHAR(100)"),
|
||
("runs", "collection", "VARCHAR(100)"),
|
||
("runs", "approval_default", "VARCHAR(20)"),
|
||
("runs", "tool_trust", "JSON"),
|
||
("runs", "agent_limits", "JSON"),
|
||
("runs", "error_node_id", "VARCHAR(64)"),
|
||
("workflows", "status", "TEXT DEFAULT 'draft'"),
|
||
("workflows", "published_version", "INTEGER"),
|
||
("workflows", "published_by", "VARCHAR(100)"),
|
||
("workflow_versions", "graph_hash", "VARCHAR(64)"),
|
||
# 存量版本回填成 NULL:不知道当时按哪一档发布的,正式运行照旧看工作流的 status
|
||
("workflow_versions", "level", "VARCHAR(20)"),
|
||
# 发布时的数据目录版本(阶段 4A)。存量版本回填成 NULL:不知道当时按哪一版目录发布的,正式运行不提醒
|
||
("workflow_versions", "catalog_versions", "JSON"),
|
||
("approvals", "resolved_by", "VARCHAR(100)"),
|
||
# 存量向量没有这两列,回填成空串 / 0 —— 正好表示"不知道是谁建的",
|
||
# 检索侧会把它们当成对不上当前 embedder 处理
|
||
("chunks", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
||
("chunks", "embed_dim", "INTEGER DEFAULT 0"),
|
||
("memories", "embed_model", "VARCHAR(100) DEFAULT ''"),
|
||
("memories", "embed_dim", "INTEGER DEFAULT 0"),
|
||
("chunks", "token_len", "INTEGER DEFAULT 0"),
|
||
("documents", "status", "TEXT DEFAULT 'ready'"),
|
||
("documents", "error", "TEXT DEFAULT ''"),
|
||
# 存量轮次没有复核结论。NULL 正好表示"这一轮没复核过",和"复核过、没问题"
|
||
# (存 verdict=ok)是两回事,前端要分得开
|
||
("conversation_turns", "review", "JSON"),
|
||
("conversation_turns", "meta", "JSON"),
|
||
("data_sources", "last_check", "JSON"),
|
||
("mcp_servers", "last_check", "JSON"),
|
||
# 存量的源一律先记成 manual;老的上传源由 table_versions.startup 按路径认出来,
|
||
# 补上 v0 版本后再改成 upload
|
||
("data_sources", "origin", "TEXT DEFAULT 'manual'"),
|
||
("data_sources", "current_snapshot_id", "VARCHAR(64)"),
|
||
# 存量运行没有钉过版本,NULL 正好表示「升级前发起的」
|
||
("runs", "data_versions", "JSON"),
|
||
# 按配方导入(期 2)。新表 table_recipes、import_stagings 由 create_all 建;这些是已有表的新列,
|
||
# 存量行一律 NULL:存量的源都是简单导入或手工源,存量的导入记录都不是按配方导入的
|
||
("data_sources", "current_recipe_id", "VARCHAR(32)"),
|
||
("table_imports", "recipe_id", "VARCHAR(32)"),
|
||
("table_imports", "period_start", "VARCHAR(10)"),
|
||
("table_imports", "period_end", "VARCHAR(10)"),
|
||
("table_imports", "context", "JSON"),
|
||
("table_imports", "checks", "JSON"),
|
||
("table_imports", "overrides", "JSON"),
|
||
("table_imports", "waivers", "JSON"),
|
||
("table_imports", "confirmations", "JSON"),
|
||
("table_imports", "manifest_artifact", "VARCHAR(64)"),
|
||
("table_imports", "signed_by", "VARCHAR(100)"),
|
||
("table_imports", "staging_id", "VARCHAR(32)"),
|
||
# 期 3:按期累积、版本页(P3-SPEC 9.6)。新表 snapshot_activations 由 create_all 建;存量行一律 NULL,
|
||
# 含义是「期 3 之前的」:快照的 mode 按 replace、recipe_id 取最后一期导入的、activated_at 按 created_at
|
||
("source_snapshots", "mode", "VARCHAR(20)"),
|
||
("source_snapshots", "recipe_id", "VARCHAR(32)"),
|
||
("source_snapshots", "activated_at", "DATETIME"),
|
||
("source_snapshots", "manifest_artifact", "VARCHAR(64)"),
|
||
("table_imports", "revoked", "JSON"),
|
||
("import_stagings", "edits", "JSON"),
|
||
("import_stagings", "answers_dropped", "JSON"),
|
||
("import_stagings", "redraft_sha256", "VARCHAR(64)"),
|
||
# 运行选项(发起时的快照,整次运行沿用)和发起它的定时任务。存量运行回填 {} 和 NULL:
|
||
# 没有选项,也不是定时任务发起的
|
||
("runs", "options", "JSON DEFAULT '{}'"),
|
||
("runs", "schedule_id", "VARCHAR(32)"),
|
||
# 数据目录起草任务(验收后修正):超时、没写出来的调用次数,当前的列数上限,提醒;逐表的模型留空的列。
|
||
# 存量任务回填 0 和 NULL:升级前没记这些
|
||
("catalog_draft_jobs", "timeouts", "INTEGER DEFAULT 0"),
|
||
("catalog_draft_jobs", "failed_calls", "INTEGER DEFAULT 0"),
|
||
("catalog_draft_jobs", "part_columns", "INTEGER"),
|
||
("catalog_draft_jobs", "notice", "TEXT"),
|
||
("catalog_draft_tasks", "blank_columns", "INTEGER DEFAULT 0"),
|
||
("catalog_draft_tasks", "blank_sample", "JSON"),
|
||
# 数据目录闭环第一步:宽表逐段落盘的段数、块进度。存量任务回填 0 和 NULL:升级前没记这些
|
||
("catalog_draft_tasks", "parts_written", "INTEGER DEFAULT 0"),
|
||
("catalog_draft_tasks", "chunks_total", "INTEGER"),
|
||
("catalog_draft_tasks", "chunks_done", "INTEGER"),
|
||
# 复核后:逐表的日志(模型提议的有效记录条件没有收下的原因)。存量任务回填 NULL,接口给空列表
|
||
("catalog_draft_tasks", "log", "JSON"),
|
||
# 应用平台(docs/design/app-platform-and-look.md §3.1.2)。新表由 create_all 建;这些是已有表的新列,存量一律 NULL:
|
||
# 运行的来源未记录、版本的发布人和退役没记过、MCP 没有认证头、对话不属于任何应用
|
||
("runs", "origin_kind", "VARCHAR(20)"),
|
||
("runs", "origin", "JSON"),
|
||
("runs", "app_id", "VARCHAR(32)"),
|
||
("runs", "app_version", "INTEGER"),
|
||
("workflow_versions", "published_at", "DATETIME"),
|
||
("workflow_versions", "published_by", "VARCHAR(100)"),
|
||
("workflow_versions", "retired_at", "DATETIME"),
|
||
("workflow_versions", "retired_by", "VARCHAR(100)"),
|
||
("workflow_versions", "retire_note", "TEXT"),
|
||
("workflow_versions", "capability_versions", "JSON"),
|
||
("workflows", "origin", "JSON"),
|
||
("mcp_servers", "headers", "JSON DEFAULT '{}'"),
|
||
("mcp_servers", "endpoint_id", "VARCHAR(32)"),
|
||
("conversations", "app_id", "VARCHAR(32)"),
|
||
("conversations", "created_by", "VARCHAR(100)"),
|
||
# 普通用户建应用(docs/design/normal-user-apps.md §3.1):个人应用(scope=personal、owner)和出自哪个蓝图
|
||
("apps", "scope", "VARCHAR(20) DEFAULT 'shared'"),
|
||
("apps", "owner", "VARCHAR(100)"),
|
||
("apps", "blueprint_id", "VARCHAR(32)"),
|
||
# 下一轮(docs/design/next-round/00-总方案.md X4、X9):运行为什么结束在 cancelled;定时的目标种类和 id;
|
||
# 定时触发建的那次评测。存量行:end_reason 为空,target_kind 是 workflow(target_id 由 scheduler 按 workflow_id 读)
|
||
("runs", "end_reason", "VARCHAR(20)"),
|
||
("schedules", "target_kind", "VARCHAR(20) DEFAULT 'workflow'"),
|
||
("schedules", "target_id", "VARCHAR(32)"),
|
||
("schedule_fires", "eval_run_id", "VARCHAR(32)"),
|
||
# 第 2 波:服务账号(users.kind);剖析上传表格源时的快照
|
||
("users", "kind", "VARCHAR(20) DEFAULT 'person'"),
|
||
("catalog_profile_tasks", "snapshot_id", "VARCHAR(32)"),
|
||
# 第 3 波:发布时这一版引用的表的结构版本
|
||
("workflow_versions", "schema_versions", "JSON"),
|
||
]
|
||
|
||
#: 迁移补上的列要建的索引:(索引名, 表, 列或多列)。create_all 不碰已有的表,表上新列的索引也就不会建。
|
||
#: 索引名照 NAMING_CONVENTION 取(多列的照模型 __table_args__ 里写的名字),和新库 create_all 建出来的同名,
|
||
#: IF NOT EXISTS 才不会重复建
|
||
_INDEX_MIGRATIONS: list[tuple[str, str, str | tuple[str, ...]]] = [
|
||
# 定时任务的触发历史按它找这个任务发起的运行
|
||
("ix_runs_schedule_id", "runs", "schedule_id"),
|
||
# 应用平台:按来源、按应用筛运行;按工作流、版本、状态找钉住版本最近一次封存的运行
|
||
("ix_runs_origin_kind", "runs", "origin_kind"),
|
||
("ix_runs_app_id", "runs", "app_id"),
|
||
("ix_runs_wf_ver_status", "runs", ("workflow_id", "version", "status")),
|
||
("ix_conversations_app_id", "conversations", "app_id"),
|
||
("ix_conversations_created_by", "conversations", "created_by"),
|
||
# 口径账本按种类反查工件(kind=caliber_match,next-round 02 做法 2)
|
||
("ix_artifacts_kind", "artifacts", "kind"),
|
||
]
|
||
|
||
|
||
#: 迁移补建的唯一索引:(索引名, 表, 列, 部分索引的条件, 建之前的去重语句)。只在索引还不存在时先去重再建——存量库里可能
|
||
#: 已经有并发留下的重复行,直接建会失败。去重的规矩写在各自的语句里:多出的钩子行删掉(只留最早的那一行,它的密钥照旧
|
||
#: 能用),多出的助手对话归档(不删,历史还在)
|
||
_UNIQUE_INDEX_MIGRATIONS: list[tuple[str, str, tuple[str, ...], str | None, str]] = [
|
||
("ux_app_hooks_app_name", "app_hooks", ("app_id", "name"), None,
|
||
"DELETE FROM app_hooks WHERE id NOT IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER "
|
||
"(PARTITION BY app_id, name ORDER BY created_at, id) AS rn FROM app_hooks) WHERE rn = 1)"),
|
||
("ux_conversations_app_owner", "conversations", ("app_id", "created_by"), "kind = 'app' AND archived = 0",
|
||
"UPDATE conversations SET archived = 1 WHERE kind = 'app' AND archived = 0 AND created_by IS NOT NULL "
|
||
"AND id NOT IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY app_id, created_by "
|
||
"ORDER BY last_active_at DESC, id) AS rn FROM conversations WHERE kind = 'app' AND archived = 0 "
|
||
"AND created_by IS NOT NULL) WHERE rn = 1)"),
|
||
]
|
||
|
||
|
||
def _migrate(conn) -> None: # pragma: no cover - 同步回调
|
||
from sqlalchemy import text
|
||
|
||
for table, column, ddl in _COLUMN_MIGRATIONS:
|
||
cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||
if cols and column not in cols:
|
||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
|
||
for name, table, columns in _INDEX_MIGRATIONS:
|
||
wanted = (columns,) if isinstance(columns, str) else columns
|
||
cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||
if all(c in cols for c in wanted):
|
||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {name} ON {table} ({', '.join(wanted)})"))
|
||
for name, table, columns, where, dedupe in _UNIQUE_INDEX_MIGRATIONS:
|
||
cols = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
|
||
exists = conn.execute(text("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = :n"), {"n": name}).first()
|
||
if exists or not all(c in cols for c in columns):
|
||
continue
|
||
conn.execute(text(dedupe))
|
||
conn.execute(text(f"CREATE UNIQUE INDEX {name} ON {table} ({', '.join(columns)})"
|
||
+ (f" WHERE {where}" if where else "")))
|
||
|
||
|
||
async def init_db() -> None:
|
||
from app.db import models # noqa: F401 确保模型已注册到 metadata
|
||
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
await conn.run_sync(_migrate)
|