Files
AgentLab/backend/app/apps/versions.py
T
ThearchyHelios 9d8b41430e 完善口径管理、主动调查与后台任务,修复通用问题
- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹
- 增加可执行指标约束、有预算的主动调查和过程评测
- 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复
- 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题
- 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理
- 纳入当前设计文档及部署配置调整

验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。
前端构建、文案检查及 git diff --check 通过。
全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
2026-11-06 17:14:45 +08:00

306 lines
16 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.
"""「已发布且未退役」的工作流版本:发起正式运行、口径升版判定、子图钉版本、应用钉版本都用这一个说法(P5,A1)。
以前三处各有各的判断:发起只认当前发布版(launch.py:92,F1),口径升版取 max(version) 把草稿也算进去(F3)。
工作流每保存一次就生成一个版本快照,没发布过的快照只是草稿:level 为空、也不是当前发布版。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import set_committed_value
from app.db.models import Workflow, WorkflowVersion
from app.db.models_apps import App, AppVersion
from app.db.models_process import ActionRecord
#: 退役被拒时的机器码
RETIRE_CURRENT = "version_retire_current"
RETIRE_UNPUBLISHED = "version_retire_unpublished"
RETIRE_ALREADY = "version_retire_already"
class WorkflowChanged(ValueError):
"""保存期间另一处已经提交了新版本,需要重新读取后再编辑。"""
async def save_graph(session: AsyncSession, workflow: Workflow, graph: dict[str, Any], *,
note: str | None = None) -> bool:
"""画布与口径草稿共用的保存:只新增快照,不改任何旧版本;由调用方提交事务。"""
from app.core.artifact_store import graph_hash
if graph == workflow.graph:
return False
previous = workflow.version
result = await session.execute(update(Workflow).where(
Workflow.id == workflow.id, Workflow.version == previous,
).values(graph=graph, version=previous + 1, status="draft").execution_options(synchronize_session=False))
if result.rowcount != 1:
raise WorkflowChanged("工作流已有新的修改,请刷新后再保存")
for key, value in (("graph", graph), ("version", previous + 1), ("status", "draft")):
set_committed_value(workflow, key, value)
session.add(WorkflowVersion(workflow_id=workflow.id, version=workflow.version, graph=graph,
graph_hash=graph_hash(graph), note=note or ""))
return True
class RetireRefused(Exception):
"""不退役。status_code / message / code 就是 HTTP 回的状态码、detail、机器码。"""
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
def is_published(version: Any, *, current: int | None = None) -> bool:
"""这一版能不能被使用:level 不为空且没退役。level 为空的老数据,只认它就是 current(当前发布版)的情形。
level 是发布时写在这一版上的(api/workflows.py 的 publish_workflow),记级别之前发布的老版本没有;那时只有
当前发布版能发起正式运行,照旧只认它。"""
if getattr(version, "retired_at", None) is not None:
return False
if getattr(version, "level", None):
return True
return current is not None and getattr(version, "version", None) == current
def _published_clause(current: int | None) -> Any:
"""is_published 的 SQL 写法:读不出整行(图很大)也能筛。"""
level_set = WorkflowVersion.level.is_not(None)
either = or_(level_set, WorkflowVersion.version == current) if current is not None else level_set
return and_(either, WorkflowVersion.retired_at.is_(None))
async def _current(session: AsyncSession, workflow_id: str) -> int | None:
return await session.scalar(select(Workflow.published_version).where(Workflow.id == workflow_id))
async def published_versions(session: AsyncSession, workflow_id: str) -> list[Any]:
"""这个工作流全部「已发布且未退役」的版本(WorkflowVersion),按版本号升序。"""
current = await _current(session, workflow_id)
rows = await session.execute(
select(WorkflowVersion).where(WorkflowVersion.workflow_id == workflow_id, _published_clause(current))
.order_by(WorkflowVersion.version))
return list(rows.scalars())
async def latest_published(session: AsyncSession, workflow_id: str) -> Any | None:
"""最新的「已发布且未退役」的版本;没有就是 None。口径升版的判定用它(替换 governance.py 的 func.max)。"""
current = await _current(session, workflow_id)
return (await session.execute(
select(WorkflowVersion).where(WorkflowVersion.workflow_id == workflow_id, _published_clause(current))
.order_by(WorkflowVersion.version.desc()).limit(1))).scalar_one_or_none()
async def version_row(session: AsyncSession, workflow_id: str, version: int) -> Any | None:
return (await session.execute(select(WorkflowVersion).where(
WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalar_one_or_none()
# --------------------------------------------------------------------------
# 子工作流固定的版本(D-q)
# --------------------------------------------------------------------------
#: 固定的上游版本不能用的机器码:子工作流节点(workflow_version)、口径卡转抄别处的口径(caliber_from)。发起正式运行
#: (engine/launch.py)、发布工作流与发布前检查(engine/lint_process.py 的 lint_subgraph_pins)、应用检查(apps/lint.py)
#: 都按 subgraph_pin_problems 判,机器码相同
SUBGRAPH_UNPUBLISHED = "subgraph_version_unpublished"
SUBGRAPH_RETIRED = "subgraph_version_retired"
CALIBER_UNPUBLISHED = "caliber_version_unpublished"
CALIBER_RETIRED = "caliber_version_retired"
@dataclass(frozen=True)
class SubgraphPinProblem:
node_id: str
#: 子工作流节点的标题
title: str
code: str
#: 节点固定的是哪个工作流的哪一版:门禁拿读好的结果去查另一张图(自动修复后的预览)时,按它核对节点没改过
workflow_id: str
version: int
#: 说到「「月报」v3 没有发布过」为止;前后半句(不能发起正式运行 / 正式运行发起时会被拒绝)由调用方接
reason: str
#: 怎么改
advice: str
#: 句子的主语:「子工作流「…」」或「口径卡「…」」
who: str = ""
#: 节点上哪个配置项固定了版本(问题定位到它)
field: str = "workflow_version"
def pinned_upstream(node: Any) -> tuple[str, str, int] | None:
"""节点固定的上游版本:(种类 subgraph / caliber, 工作流 id, 版本);没固定就是 None。子工作流写在 workflow_id /
workflow_version,口径卡转抄别处的口径写在 caliber_from 里。"""
from app.engine.schema import NodeType
cfg = node.config
if node.type == NodeType.SUBGRAPH:
wid, pinned = cfg.get("workflow_id"), cfg.get("workflow_version")
kind = "subgraph"
elif node.type == NodeType.METRICS and isinstance(cfg.get("caliber_from"), dict):
wid, pinned = cfg["caliber_from"].get("workflow_id"), cfg["caliber_from"].get("workflow_version")
kind = "caliber"
else:
return None
if not wid or not str(pinned or "").isdigit():
return None
return kind, str(wid), int(pinned)
async def subgraph_pin_problems(session: AsyncSession, spec: Any) -> list[SubgraphPinProblem]:
"""图里每个固定了上游版本的节点(子工作流、转抄别处口径的口径卡),固定的那一版必须「已发布且未退役」
(is_published);不是的逐个交回。
固定版本就是方法卡的机制:外层发布时认可的是「用它的 vN」。vN 只是一张保存出来的草稿快照、或者已经退役,正式运行
照样执行它、照样按它的口径算,就等于绕过了发布门禁、绕过了退役。发起时拦(launch.py),发布时也按同一条拦——不然钉在
草稿上的工作流照样发得出去,之后每一次正式运行(定时任务、应用入口、按需视图、补算)都在发起时才被拒。
没固定版本的跟随最新,那是受管级门禁管的事(governed.subgraph_unpinned),这里不管。"""
out: list[SubgraphPinProblem] = []
for node in spec.nodes:
pin = pinned_upstream(node)
if pin is None:
continue
kind, wid, version = pin
caliber = kind == "caliber"
who = f"口径卡「{node.title}」" if caliber else f"子工作流「{node.title}」"
field = "caliber_from" if caliber else "workflow_version"
unpublished, retired = (CALIBER_UNPUBLISHED, CALIBER_RETIRED) if caliber else (SUBGRAPH_UNPUBLISHED, SUBGRAPH_RETIRED)
workflow = await session.get(Workflow, wid)
row = await version_row(session, wid, version) if workflow is not None else None
if workflow is None or row is None:
out.append(SubgraphPinProblem(node.id, node.title, unpublished, wid, version,
f"工作流版本 v{version} 不存在", "请在节点中重新选择版本", who, field))
elif row.retired_at is not None:
out.append(SubgraphPinProblem(node.id, node.title, retired, wid, version,
f"「{workflow.name}」v{version} 已退役", "请把节点改为固定一个在用的发布版本",
who, field))
elif not is_published(row, current=workflow.published_version):
out.append(SubgraphPinProblem(node.id, node.title, unpublished, wid, version,
f"「{workflow.name}」v{version} 没有发布过",
"请先发布那个工作流,或把节点改为固定一个已发布的版本", who, field))
return out
async def backfill_legacy_levels(session: AsyncSession) -> int:
"""给记级别之前发布过的老版本补上 level(启动时跑一次,幂等)。
level 是后来才加的列:那之前发布过的版本都是空的,按 is_published 只认当前发布版——而子工作流、口径卡钉住的往往是
更早的某一版,新规则会把它们误判成「没发布过」。补的依据只有一条,有据可查:这一版发起过正式运行。老代码里正式运行
只能从当时的发布版发起,所以它一定发布过。级别记 published:当时是不是受管级查不出来,往低处记(应用的级别取最低,
只会少给信任,不会多给)。没有正式运行凭据的照旧为空,不猜。"""
from sqlalchemy import exists, update
from app.db.models import Run
evidence = exists().where(Run.workflow_id == WorkflowVersion.workflow_id, Run.version == WorkflowVersion.version,
Run.run_class == "formal")
result = await session.execute(update(WorkflowVersion)
.where(WorkflowVersion.level.is_(None), WorkflowVersion.retired_at.is_(None), evidence)
.values(level="published").execution_options(synchronize_session=False))
return result.rowcount or 0
# --------------------------------------------------------------------------
# 账本(L4):一个工作流版本被谁固定、生效了几次、正式运行了几次。退役确认、版本详情、删除约束、账本接口共用
# --------------------------------------------------------------------------
async def pinning_apps(session: AsyncSession, workflow_id: str, version: int | None = None) -> list[dict[str, Any]]:
"""当前发布版固定着这个工作流(version 给了就只看这一版)的应用:[{app_id, name, app_version, alias, version}]。
只看各应用当前的发布版:应用升过级、不再固定它的旧版本不算「正在用」。归档的应用不算。pins 存在 JSON 里,
应用的数量是人建的、不会很多,读出来逐个看。"""
rows = (await session.execute(
select(App.id, App.name, AppVersion.version, AppVersion.pins)
.join(AppVersion, and_(AppVersion.app_id == App.id, AppVersion.version == App.published_version))
.where(App.status != "archived"))).all()
out: list[dict[str, Any]] = []
for app_id, name, app_version, pins in rows:
for pin in pins or []:
if not isinstance(pin, dict) or pin.get("workflow_id") != workflow_id:
continue
if version is not None and pin.get("version") != version:
continue
out.append({"app_id": app_id, "name": name, "app_version": app_version, "alias": pin.get("alias"),
"version": pin.get("version")})
return out
async def effect_count(session: AsyncSession, workflow_id: str, version: int | None = None) -> int:
"""生效记录的条数(ActionRecord)。演练(simulated)不算生效:它没有发出去。"""
from sqlalchemy import func
stmt = select(func.count(ActionRecord.id)).where(ActionRecord.workflow_id == workflow_id,
ActionRecord.state != "simulated")
if version is not None:
stmt = stmt.where(ActionRecord.workflow_version == version)
return int(await session.scalar(stmt) or 0)
async def formal_run_count(session: AsyncSession, workflow_id: str, version: int) -> int:
from sqlalchemy import func
from app.db.models import Run
return int(await session.scalar(select(func.count(Run.id)).where(
Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal")) or 0)
async def usage(session: AsyncSession, workflow_id: str, version: int) -> dict[str, Any]:
"""{apps, effects, formal_runs}:版本详情、账本接口、退役确认给的同一份。"""
from app.apps.impact import dependents
return {"apps": await pinning_apps(session, workflow_id, version),
"effects": await effect_count(session, workflow_id, version),
"formal_runs": await formal_run_count(session, workflow_id, version),
**await dependents(session, workflow_id, version)}
# --------------------------------------------------------------------------
# 退役
# --------------------------------------------------------------------------
async def retire_version(session: AsyncSession, workflow_id: str, version: int, *, actor: str | None,
note: str | None) -> dict[str, Any]:
"""退役一个版本。当前发布版不能退(409)。返回 {retired, affected_apps: [...]};写审计。
退役之后:从它发起正式运行被拒(run_version_retired);固定着它的应用标「需升级」,入口拒绝发起新实例,视图保留
最后一次的数据并写明固定的版本已退役;进行中的实例照常走完——这里不碰任何运行。"""
from app.apps.audit import record
from app.db.base import utcnow
workflow = await session.get(Workflow, workflow_id)
if workflow is None:
raise RetireRefused(404, "工作流不存在,可能已被删除")
row = await version_row(session, workflow_id, version)
if row is None:
raise RetireRefused(404, f"「{workflow.name}」不存在 v{version} 版本")
if row.retired_at is not None:
raise RetireRefused(409, f"v{version} 已经退役", RETIRE_ALREADY)
if version == workflow.published_version:
raise RetireRefused(409, f"v{version} 是当前发布版本,不能退役。请先发布一个新版本,再退役这一版",
RETIRE_CURRENT)
if not is_published(row, current=workflow.published_version):
raise RetireRefused(409, f"v{version} 没有发布过,不需要退役", RETIRE_UNPUBLISHED)
from app.apps.impact import dependents
affected = await pinning_apps(session, workflow_id, version)
impact = await dependents(session, workflow_id, version)
row.retired_at = utcnow()
row.retired_by = actor
row.retire_note = (note or "").strip() or None
await record(session, action="workflow_version.retire", subject_kind="workflow_version",
subject_id=f"{workflow_id}:{version}", actor=actor,
data={"workflow_id": workflow_id, "workflow_name": workflow.name, "version": version,
"note": row.retire_note, "affected_apps": affected, "impact": impact})
await session.commit()
return {"retired": {"workflow_id": workflow_id, "version": version, "retired_at": row.retired_at,
"retired_by": actor, "retire_note": row.retire_note},
"affected_apps": affected, "impact": impact}