mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:12:01 +00:00
286 lines
16 KiB
Python
286 lines
16 KiB
Python
"""按新口径补算(D-f;设计文档 §3.4 趋势那一行、§2.10 X3 的 app_backfill)。
|
||
|
||
钉住项升版时选了 recompute(按新口径重算),趋势只画新版本的数:升版之后才有的那一两期孤零零的,历史全空着。补算就是
|
||
把旧版本在同一来源上算过的那几期,按新版本重新发起一遍正式运行:
|
||
|
||
- 只对读定时任务或正式运行的视图(latest_sealed):同一来源才说得清「那几期」是哪几期;
|
||
- 只在这个视图的钉住项最近一次升版选了 recompute 时:选 dual、incomparable 的本来就照旧画旧版本的数;
|
||
- 只补没有外部副作用的工作流(_side_effect):钉作流程的、图里(含子工作流)有人工节点的、调用工具节点绑了写外部系统
|
||
能力的、按流程使用会出事(process.*:Agent 中途等审批、联网的代码节点)的一律拒。补算一次最多发起 SERIES_MAX 次正式
|
||
运行,对着历史输入逐个给人开待办、往外部系统重发写入,这些都收不回来,生效记录还会挡住以后删运行、删工作流;
|
||
- 取旧版本在同一来源(同一个定时任务,或 formal)上最近 N 期已封存的正式运行的输入(N 不超过视图上趋势的 last,
|
||
最多 SERIES_MAX)。旧版本自己补算过的那几期也算(trend_runs):连着升过两次版的,历史一样接得上;
|
||
- 新版本已经算过同一个输入的跳过:补算过的(没失败的),以及来源本身已经按新版本跑过的那一期;
|
||
- 用新版本逐个发起正式运行:来源 app_backfill、按钉住的 graph_hash 核对(expect_hash)、带 app_id / app_version,
|
||
origin 里记补的是哪个来源、哪一次、那一期的时间(period_at),趋势按它排(resolve.period_of)。
|
||
|
||
补算的运行和别的正式运行一样封存、一样能点开证据;它不是「最近一次」(resolve.sealed_runs 不取它),只是趋势上的点。
|
||
同一个视图的补算在进程内串行(_lock):锁里重新算一遍计划再发起,并发点两次、确认框弹着时别人先补过的,后来的那次
|
||
只会跳过。部署是单进程(调度器、运行管理都在进程里),进程内的锁就够。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.apps.pins import load_pins
|
||
from app.apps.resolve import (BACKFILL_ORIGIN, SERIES_MAX, input_key, period_of, pin_upgrade, sealed_runs,
|
||
trend_runs)
|
||
from app.apps.tree import components
|
||
from app.db.models import Run
|
||
from app.db.models_apps import AppVersion
|
||
|
||
#: 补算被拒的机器码
|
||
BACKFILL_REFUSED = "app_backfill_refused"
|
||
#: 趋势没写 last 时取几期(同 resolve._series)
|
||
SERIES_DEFAULT = 12
|
||
_LIVE = ("queued", "running", "interrupted", "succeeded")
|
||
|
||
|
||
class BackfillRefused(Exception):
|
||
def __init__(self, status_code: int, message: str, code: str | None = BACKFILL_REFUSED) -> None:
|
||
super().__init__(message)
|
||
self.status_code = status_code
|
||
self.message = message
|
||
self.code = code
|
||
|
||
|
||
@dataclass
|
||
class Plan:
|
||
view: str
|
||
alias: str
|
||
source: str
|
||
from_version: int
|
||
to_version: int
|
||
#: 这个视图上的趋势最多画几期
|
||
periods: int
|
||
#: 要发起的:旧版本那几期的运行,按输入期从早到晚
|
||
todo: list[Run] = field(default_factory=list)
|
||
skipped: int = 0
|
||
|
||
def out(self) -> dict[str, Any]:
|
||
return {"view": self.view, "alias": self.alias, "source": self.source, "from_version": self.from_version,
|
||
"to_version": self.to_version, "periods": self.periods, "will_launch": len(self.todo),
|
||
"will_skip": self.skipped}
|
||
|
||
|
||
def series_periods(spec: dict[str, Any], view_id: str) -> int:
|
||
"""这个视图上的趋势最多画几期:各趋势组件 last 的最大值(没写按 SERIES_DEFAULT),不超过 SERIES_MAX。没有趋势组件
|
||
返回 0。"""
|
||
wanted = 0
|
||
for comp, _, _ in components(spec):
|
||
bind = comp.get("bind") if isinstance(comp.get("bind"), dict) else None
|
||
if not bind or bind.get("view") != view_id or "series" not in bind:
|
||
continue
|
||
last = bind.get("last")
|
||
n = last if isinstance(last, int) and not isinstance(last, bool) and last > 0 else SERIES_DEFAULT
|
||
wanted = max(wanted, n)
|
||
return min(wanted, SERIES_MAX)
|
||
|
||
|
||
async def _upgrade(session: AsyncSession, app: Any, version: AppVersion, view_id: str
|
||
) -> tuple[dict[str, Any], Any, dict[str, Any], str] | None:
|
||
"""(视图来源, 新版本的钉住状态, 升版前的钉住项, 来源);这个视图不需要补算时返回 None。拒绝的情形抛 BackfillRefused。"""
|
||
spec = version.spec or {}
|
||
view = (spec.get("views") or {}).get(view_id)
|
||
src = (view or {}).get("source") if isinstance(view, dict) else None
|
||
if not isinstance(src, dict):
|
||
raise BackfillRefused(404, f"应用里没有视图「{view_id}」", "app_view_not_found")
|
||
if src.get("kind") != "latest_sealed":
|
||
raise BackfillRefused(409, "只有读定时任务或正式运行的视图能按新口径补算:按需运行、实例视图没有「历次」可补")
|
||
alias = str(src.get("pin") or "")
|
||
previous, disposition = await pin_upgrade(session, app.id, version, alias)
|
||
if previous is None or disposition != "recompute":
|
||
return None
|
||
state = (await load_pins(session, spec)).get(alias)
|
||
if state is None or not state.usable:
|
||
raise BackfillRefused(409, "视图固定的工作流版本已退役或不可用,应用需要升级后才能补算", "app_needs_upgrade")
|
||
if reason := await _side_effect(session, state):
|
||
raise BackfillRefused(409, f"「{state.label}」{reason},补算会对历史输入逐个重做这些事、无法撤回,这类工作流不能"
|
||
"按新口径补算")
|
||
return src, state, previous, str(src.get("from") or "formal")
|
||
|
||
|
||
async def _side_effect(session: AsyncSession, state: Any) -> str | None:
|
||
"""新版本的图补算时会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。
|
||
作为流程固定的一律算;别的按图判(graph_side_effect)。"""
|
||
if state.pin.get("use") == "process":
|
||
return "是作为流程固定的"
|
||
return await graph_side_effect(session, state.graph, level=state.level)
|
||
|
||
|
||
async def graph_side_effect(session: AsyncSession, graph_json: Any, *, level: str | None = None) -> str | None:
|
||
"""一张图替人重跑一次会不会碰到人或外部系统:会的交回一句原因(接在「「周报 v3」」后面),不会的返回 None。按新口径
|
||
补算(这里)和按口径正式出具(ledger/issue.py:自动出具、一键出具都不代人做这些事)共用这一个判断。
|
||
|
||
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
|
||
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
|
||
process.human_in_subgraph 才查得到)。level 是那一版发布时的级别(门禁按它查),没有按 published。"""
|
||
from app.engine.governance import publish_issues
|
||
from app.engine.lint_process import load_subgraphs, with_subgraphs
|
||
from app.engine.schema import GraphSpec, NodeType
|
||
|
||
try:
|
||
graph = GraphSpec.model_validate(graph_json)
|
||
except ValueError:
|
||
return "的图无法解析"
|
||
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
|
||
return f"里有人工节点「{'、'.join(humans[:3])}」"
|
||
if reason := await _external_write(session, graph):
|
||
return reason
|
||
with with_subgraphs(await load_subgraphs(session, graph)):
|
||
issues = publish_issues(graph, level=level or "published")
|
||
if process := [i for i in issues if (i.code or "").startswith("process.")]:
|
||
return f"按流程使用不安全:{process[0].message.rstrip('。')}"
|
||
return None
|
||
|
||
|
||
async def graph_external_write(session: AsyncSession, graph_json: Any) -> str | None:
|
||
"""一张图里有没有调用工具节点会写外部系统(graph_side_effect 里的那一条):有的交回一句原因,没有返回 None。
|
||
|
||
口径召回(ledger/recall)只用这一条:召回图是从口径卡往上派生的取数和口径卡,图里别处的人工节点、按流程使用的问题
|
||
碰不到它(细案做法 7 不重算的只有缺入口字段、数据部分不能复用、会写外部系统三种);替人整张图重跑的补算、按口径出具
|
||
仍然走 graph_side_effect 全套。"""
|
||
from app.engine.schema import GraphSpec
|
||
|
||
try:
|
||
graph = GraphSpec.model_validate(graph_json)
|
||
except ValueError:
|
||
return "的图无法解析"
|
||
return await _external_write(session, graph)
|
||
|
||
|
||
async def _external_write(session: AsyncSession, graph: Any) -> str | None:
|
||
from app.capabilities.registry import effective_effect, get_capability, writes_externally
|
||
from app.engine.schema import NodeType
|
||
|
||
for node in graph.nodes:
|
||
name = node.config.get("tool") if node.type == NodeType.TOOL else None
|
||
if not isinstance(name, str) or not name:
|
||
continue
|
||
cap = await get_capability(session, name)
|
||
if cap is not None and writes_externally(cap.name, effective_effect(cap), cap.endpoint_id):
|
||
return f"的「{node.title}」会写外部系统({name})"
|
||
return None
|
||
|
||
|
||
async def plan(session: AsyncSession, app: Any, version: AppVersion, view_id: str) -> Plan:
|
||
"""这个视图补算会发起几次、跳过几次(不发起任何运行)。"""
|
||
found = await _upgrade(session, app, version, view_id)
|
||
if found is None:
|
||
raise BackfillRefused(409, "这个视图固定的版本最近一次升版没有选「按新口径重算」,不需要补算")
|
||
src, state, previous, source = found
|
||
periods = series_periods(version.spec or {}, view_id)
|
||
if periods <= 0:
|
||
raise BackfillRefused(409, "这个视图上没有趋势组件,补算出来的历史没有地方展示")
|
||
out = Plan(view=view_id, alias=state.alias, source=source, from_version=int(previous.get("version")),
|
||
to_version=int(state.pin["version"]), periods=periods)
|
||
origin = src.get("from")
|
||
old_runs = await trend_runs(session, app.id, previous.get("workflow_id"), previous.get("version"),
|
||
previous.get("graph_hash"), origin, periods)
|
||
done = await _computed(session, app.id, state, origin, source)
|
||
for run in old_runs:
|
||
key = input_key(run.input)
|
||
if key in done:
|
||
out.skipped += 1
|
||
continue
|
||
done.add(key)
|
||
out.todo.append(run)
|
||
return out
|
||
|
||
|
||
async def _computed(session: AsyncSession, app_id: str, state: Any, origin: str | None, source: str) -> set[str]:
|
||
"""新版本已经算过(或正在算)的输入:这个应用为同一来源补算过、没失败的;来源本身按新版本跑过的。"""
|
||
pin = state.pin
|
||
keys = {input_key(r.input) for r in await sealed_runs(session, pin["workflow_id"], pin["version"],
|
||
pin.get("graph_hash"), origin, SERIES_MAX * 2)}
|
||
rows = (await session.execute(select(Run.input, Run.origin).where(
|
||
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.workflow_id == pin["workflow_id"],
|
||
Run.version == pin["version"], Run.version_hash == pin.get("graph_hash"), Run.status.in_(_LIVE)))).all()
|
||
keys |= {input_key(inputs) for inputs, meta in rows if (meta or {}).get("source") == source}
|
||
return keys
|
||
|
||
|
||
async def run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
|
||
actor: str | None) -> dict[str, Any]:
|
||
"""按计划逐个发起正式运行。返回 {launched, skipped, run_ids, ...plan}。
|
||
|
||
一次也没发起成(发起被拒,比如口径升版没处置)时原样抛 LaunchRefused;发起了几次之后被拒的,已经发起的照常算,
|
||
原因写进 refused 交回。"""
|
||
async with _lock(app.id, view_id):
|
||
return await _run_backfill(session, app, version, view_id, principal=principal, actor=actor)
|
||
|
||
|
||
#: (应用 id, 视图) → 补算锁。只增不减:一个应用的视图就那么几个
|
||
_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
|
||
|
||
|
||
def _lock(app_id: str, view_id: str) -> Any:
|
||
"""跨进程的锁(app/cluster/mutex):同一个视图的补算多进程时也只跑一份。"""
|
||
from app.cluster import mutex
|
||
|
||
return mutex.lock(f"app-backfill:{app_id}:{view_id}")
|
||
|
||
|
||
async def _run_backfill(session: AsyncSession, app: Any, version: AppVersion, view_id: str, *, principal: Any,
|
||
actor: str | None) -> dict[str, Any]:
|
||
from app.engine.launch import LaunchRefused, launch_run
|
||
|
||
# 计划在锁里算:前一次补算发起的运行已经提交,这里读得到、按「已经补算过」跳过
|
||
todo = await plan(session, app, version, view_id)
|
||
pin = (await load_pins(session, version.spec or {}))[todo.alias].pin
|
||
run_ids: list[str] = []
|
||
refused: str | None = None
|
||
for old in todo.todo:
|
||
period = period_of(old)
|
||
try:
|
||
run = await launch_run(
|
||
session, workflow_id=pin["workflow_id"], run_class="formal", version=pin["version"],
|
||
expect_hash=pin.get("graph_hash"), input_payload=dict(old.input or {}), actor=actor,
|
||
origin_kind=BACKFILL_ORIGIN, app_id=app.id, app_version=version.version,
|
||
origin={"view": view_id, "spec_hash": version.spec_hash, "source": todo.source,
|
||
"backfill_of": old.id, "from_version": todo.from_version,
|
||
"period_at": period.isoformat() if period else None,
|
||
"actor_verified": bool(getattr(principal, "verified", False)),
|
||
"auth_mode": getattr(principal, "mode", "off")})
|
||
except LaunchRefused as e:
|
||
if not run_ids:
|
||
raise
|
||
refused = e.message
|
||
break
|
||
run_ids.append(run.id)
|
||
from app.apps.audit import record
|
||
|
||
await record(session, action="app.backfill", subject_kind="app", subject_id=app.id, actor=actor,
|
||
data={**todo.out(), "launched": len(run_ids), "run_ids": run_ids, "app_version": version.version})
|
||
await session.commit()
|
||
return {**todo.out(), "launched": len(run_ids), "skipped": todo.skipped, "run_ids": run_ids,
|
||
**({"refused": refused} if refused else {})}
|
||
|
||
|
||
async def backfillable(session: AsyncSession, app: Any) -> list[dict[str, Any]]:
|
||
"""当前发布版里能按新口径补算的视图,各自会发起几次(编辑器、应用页上的「按新口径补算历史」按钮用)。只读。"""
|
||
if not app.published_version:
|
||
return []
|
||
version = (await session.execute(select(AppVersion).where(
|
||
AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none()
|
||
if version is None:
|
||
return []
|
||
out: list[dict[str, Any]] = []
|
||
for view_id, view in ((version.spec or {}).get("views") or {}).items():
|
||
source = (view or {}).get("source") if isinstance(view, dict) else None
|
||
if not isinstance(source, dict) or source.get("kind") != "latest_sealed":
|
||
continue
|
||
try:
|
||
found = await _upgrade(session, app, version, view_id)
|
||
if found is None or series_periods(version.spec or {}, view_id) <= 0:
|
||
continue
|
||
out.append((await plan(session, app, version, view_id)).out())
|
||
except BackfillRefused:
|
||
continue
|
||
return out
|