mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:31:59 +00:00
eddeded2a4
- 第一批(安全加固与生命周期止血):隔离测试宿主机密钥与 .env 泄漏;修复 SQLite 取消查询挂死、:memory: 连接池报错与 EngineCache 世代竞态;补齐运行与评测删除时的 Checkpointer 线程、Artifact 磁盘文件与无主附件回收;加固三套沙箱路径越界、进程组清理与 Seatbelt 数据目录隔离,封堵 SSRF IPv4-mapped IPv6 元数据绕过与响应体 OOM;修复推理模型多轮 reasoning_content、结构化输出 Token 漏计与缓存折扣计费 - 第二批(数据库/集群/问数提速与首屏分包):补齐核心表索引与 SQLite WAL 调优,列表接口按需 defer 大列,收敛启动扫描并打通跨进程 slot 槽位唤醒;优化跨源全局选表、哈希桶数字绑定、流式 CSV 导入及多方言分析算子;剥离首屏静态引入的 @xyflow/react 与全局样式(首屏主包降至 461.59 kB),补充流式 rAF 攒批、Store 精细订阅与表单无障碍绑定 - 第三批(架构演进与工程自动化闭环):记录库超挂接阈值历史版本自动增量归档至单库多表(解除 SQLite 10 库 ATTACH 硬上限);重构知识库为词面 BM25 与全集合归一化向量矩阵真·双路独立召回并支持游标分批重建;升级多方言感知 SQL 模板词法分析(E'...' / $$...$$ / q'[...]' / # / 嵌套注释)与 Decimal / bool / NaN 参数校验,消除 ledger 层对 api 路由层的反向依赖;拆分 3101 行 CaliberDrafts 页面为 7 个领域子模块,消除检查脚本本机路径硬编码并新增 GitHub Actions CI 门禁与三批回归测试套件
1084 lines
53 KiB
Python
1084 lines
53 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import json
|
||
import re
|
||
from collections.abc import Mapping
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import or_, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import defer
|
||
|
||
from app.api.coded import CodedHTTPException
|
||
# 「人」的唯一入口搬到了 api/deps.py(开登录时返回认证过的用户名)。别处照旧从这里导入 actor_of
|
||
from app.api.deps import actor_of, restricted # noqa: F401
|
||
from app.core.bus import bus
|
||
from app.db.base import SessionLocal, get_session
|
||
from app.db.models import Approval, Artifact, Run, RunEvent
|
||
from app.engine.inquiry import BUSY_CONTINUE, BUSY_DECIDE, INQUIRY_BUSY, inquiry_busy
|
||
# 发起规则和发起前检查绑定的工具搬到了 engine/launch.py。这几个名字以前从这里导入,照旧可用
|
||
from app.engine.launch import ( # noqa: F401
|
||
TOOL_MISSING, LaunchRefused, launch_run, missing_tools, node_setting, tool_bindings,
|
||
)
|
||
from app.engine.runner import run_manager
|
||
# 口径查询的真实工具名:投影里认「这个节点调的是口径查询」用它,别处不许另写一个字符串
|
||
from app.ledger.querycore import CALIBER_QUERY
|
||
|
||
router = APIRouter(prefix="/api/runs", tags=["runs"])
|
||
|
||
#: POST /api/runs 声明了客户端不能声明的来源(422)
|
||
ORIGIN_NOT_ALLOWED = "origin_not_allowed"
|
||
#: origin_ref(问数据的运行属于哪段对话、哪一轮)只在来源是问数据时收;对话不存在、这一轮不属于这段对话(422)
|
||
ORIGIN_REF_NOT_ALLOWED = "origin_ref_not_allowed"
|
||
ORIGIN_REF_INVALID = "origin_ref_invalid"
|
||
#: 删除被拒:这次运行写过外部系统(有演练以外的生效记录,409)
|
||
RUN_HAS_EFFECTS = "run_has_effects"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 普通使用者的投影(K14):读得到的运行图上、事件流里,口径查询那一处不留内部身份
|
||
#
|
||
# 规则本身在 auth/chat_policy(谁要按授权收窄)。这里只管**交出去的那一份**:开登录后 user 角色读自己这次运行(问数据的
|
||
# origin_kind=chat + 在工具边界遮过,api/deps.run_events_readable)时,事件、运行图里的口径查询工具**参数**(哪张卡、哪一版)、
|
||
# 工具结果(发布清单、selection、哈希、工件 id、参数材料)换成安全摘要——**存储一个字节都不动**,只在 API 这一层换一份。
|
||
# 给人看的东西一件不少:算成没算成、状态、指标值(本来就遮过)、运行进度、别的节点的事件原样保留(不因为这里有一条口径
|
||
# 查询就把普通聊天的事件清空)。builder 及以上读到的还是原件。
|
||
# --------------------------------------------------------------------------
|
||
|
||
#: 留在事件里的字段(按整个数据字典白名单):都是「到哪一步了、算成没算成、花了多久」
|
||
SAFE_EVENT_KEYS = ("tool", "label", "node_type", "duration_ms", "attempt", "outcome", "status", "timed_out",
|
||
"error", "ok", "truncated", "level", "code", "message", "to", "kind", "timing")
|
||
#: 留在工具结果摘要里的字段:结果本身(指标值、问题码)与它算成没算成
|
||
SAFE_RESULT_KEYS = ("ok", "status", "metrics", "problems", "truncated")
|
||
#: 运行图里口径查询节点的参数换成这一句(口径身份不给:那是内部的事)
|
||
CALIBER_ARGS_NOTE = {"caliber": "按一份有授权的口径取数(卡与版本不在运行记录里给出)"}
|
||
#: 转抄卡(metrics.caliber_from)的身份映射换成这一句:它指的是哪一份工作流的哪一版、节点怎么对应,都不出去
|
||
CALIBER_FROM_NOTE = {"caliber": "转抄自一份有授权的口径(工作流、版本与节点映射不在运行记录里给出)"}
|
||
#: 失败说法里的安全白话:口径那一档只给「没成功」+ 机器码(口径名、版本、card 都不带出来)
|
||
CALIBER_FAILED_TEXT = "按口径取数没有成功"
|
||
#: 提示词 / 说明里那份「取数写法」的键:口径身份那几个的值换成安全摘要(键、说明、inputs 的值都留着)
|
||
CALIBER_CALL_KEYS = ("workflow", "workflow_id", "workflow_version", "card", "card_id", "version", "node_id")
|
||
_CALL_VALUE = re.compile(r'("(?:' + "|".join(CALIBER_CALL_KEYS) + r')"\s*:\s*)"[^"]*"')
|
||
#: 失败事件(这几个类型才把 error / message 换成安全白话;log 的消息照旧给——它本来就没有口径身份)
|
||
FAILURE_EVENTS = ("node.failed", "tool.error", "run.failed")
|
||
|
||
|
||
def run_projection(graph: Any) -> _Projection | None:
|
||
"""这次请求要不要给运行图、事件做投影:普通使用者要(交回投影),builder 及以上不要(交回 None,照原样给)。"""
|
||
return _Projection(graph) if restricted() else None
|
||
|
||
|
||
class _Projection:
|
||
"""一次请求里这份投影:先把运行图上绑口径查询的节点认出来,之后按节点/工具名判每一条事件、每张图。"""
|
||
|
||
def __init__(self, graph: Any) -> None:
|
||
self.nodes = _caliber_nodes(graph)
|
||
|
||
def graph(self, graph: Any) -> Any:
|
||
"""运行图:口径查询节点的参数、提示词与转抄卡的身份映射换成安全摘要,别的节点一个字不动。"""
|
||
if not isinstance(graph, dict):
|
||
return graph or {}
|
||
nodes = []
|
||
for node in graph.get("nodes") or []:
|
||
config = _config_of(node)
|
||
if isinstance(node, dict) and isinstance(config, dict) and _touches_caliber(config):
|
||
node = {**node, "data": {**node["data"], "config": _safe_config(config)}}
|
||
nodes.append(node)
|
||
return {**graph, "nodes": nodes}
|
||
|
||
def event(self, event: Any) -> Any:
|
||
"""一条事件:口径查询那几处按白名单留字段(含工具结果的安全摘要、失败说法的安全白话),别的事件原样。
|
||
|
||
判「是口径那一处」的几种写法:事件的节点是口径节点、工具是口径查询、失败事件里另带的口径节点 id(`run.failed`
|
||
的节点写在 data 里,事件那一列是空的),或者这条失败说法本来就是口径那一档(认得出机器码/工具名)。"""
|
||
if not isinstance(event, dict):
|
||
return event
|
||
data = event.get("data")
|
||
if not isinstance(data, dict):
|
||
return event
|
||
node_id = str(event.get("node_id") or "")
|
||
failure = str(event.get("type") or "") in FAILURE_EVENTS
|
||
tool_name = str(data.get("tool") or "")
|
||
if node_id in self.nodes or tool_name in (CALIBER_QUERY, "ontology_neighbors", "source_health") \
|
||
or str(data.get("node_id") or "") in self.nodes \
|
||
or (failure and safe_failure(data.get("error")) is not None):
|
||
safe = {k: v for k, v in data.items() if k in SAFE_EVENT_KEYS}
|
||
if isinstance(data.get("health_artifact"), Mapping):
|
||
safe["health_artifact"] = {
|
||
k: v for k, v in data["health_artifact"].items()
|
||
if k in ("ref", "verdict", "phrase")
|
||
}
|
||
result = result_summary(data.get("artifact")) or safe_result(data.get("preview"))
|
||
if result is not None:
|
||
safe["result"] = result
|
||
if failure:
|
||
# 失败的那一条:口径名、版本、card 从说法里出去,机器码留着(人认得出是哪一档失败)
|
||
for key in ("error", "message"):
|
||
if key in safe:
|
||
safe[key] = safe_failure(safe[key])
|
||
return {**event, "data": safe}
|
||
if "caliber" in data:
|
||
# 补固定的那条日志:源名、版本号留下,口径的固定目标(哪一份口径要的这版数据)不留给使用者
|
||
return {**event, "data": {k: v for k, v in data.items()
|
||
if k not in ("caliber", "workflow_id", "version")}}
|
||
return event
|
||
|
||
def failure(self, text: Any) -> Any:
|
||
"""运行记录上那条失败说法:是口径那一档就换安全白话 + 机器码(`get_run` 的 error 用它)。"""
|
||
return safe_failure(text) or text
|
||
|
||
|
||
def _caliber_nodes(graph: Any) -> set[str]:
|
||
"""这张图里与口径有关的节点 id:绑着口径查询的(调用工具节点写死的那个,Agent / 协作成员绑的也算),以及转抄卡
|
||
(metrics.caliber_from)——两类都带着口径的身份,事件里按白名单给。"""
|
||
out: set[str] = set()
|
||
for node in (graph or {}).get("nodes") or []:
|
||
config = _config_of(node)
|
||
if isinstance(config, dict) and _touches_caliber(config):
|
||
out.add(str(node.get("id")))
|
||
return out
|
||
|
||
|
||
def _touches_caliber(config: Mapping[str, Any]) -> bool:
|
||
"""这个节点的配置里有没有口径的身份:绑了口径查询/本体邻居,或者是一张转抄卡。"""
|
||
bound = [t for t in (config.get("tools") if isinstance(config.get("tools"), list) else []) if isinstance(t, str)]
|
||
caliber_tools = {CALIBER_QUERY, "ontology_neighbors"}
|
||
if str(config.get("tool") or "") in caliber_tools or any(t in caliber_tools for t in bound):
|
||
return True
|
||
if config.get("tool") == "source_health" and isinstance(config.get("args"), Mapping) and config["args"].get("workflow"):
|
||
return True
|
||
return config.get("caliber_from") not in (None, "", {})
|
||
|
||
|
||
def _safe_config(config: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""与口径有关的内部写法换成安全摘要:工具参数、提示词里的取数写法、转抄卡的身份映射。
|
||
|
||
别的键(节点自己的说明、inputs 的值、工具清单)一个字不动——「使用者的图长什么样」照旧看得见,看不见的只是
|
||
「调的是哪一份工作流的哪一版」。"""
|
||
out = dict(config)
|
||
caliber_tools = {CALIBER_QUERY, "ontology_neighbors"}
|
||
if (str(out.get("tool") or "") in caliber_tools or (
|
||
out.get("tool") == "source_health" and isinstance(out.get("args"), Mapping) and out["args"].get("workflow")
|
||
)) and "args" in out:
|
||
out["args"] = dict(CALIBER_ARGS_NOTE)
|
||
bound = [t for t in (out.get("tools") if isinstance(out.get("tools"), list) else []) if isinstance(t, str)]
|
||
if str(out.get("tool") or "") in caliber_tools or any(t in caliber_tools for t in bound):
|
||
for key in ("prompt", "instructions"):
|
||
if key in out:
|
||
out[key] = safe_call_text(out[key])
|
||
if out.get("caliber_from") not in (None, "", {}):
|
||
out["caliber_from"] = dict(CALIBER_FROM_NOTE)
|
||
return out
|
||
|
||
|
||
def safe_call_text(text: Any) -> Any:
|
||
"""提示词 / 说明里那份「取数写法」:口径身份那几个键的**值**换成安全摘要,键名、说明、inputs 的值都留着。
|
||
|
||
模型是照 look 交回的那一行(`{"workflow": "<id>", "card": "<id>", ...}`)写进 agent 提示词的:整行照抄最省事,
|
||
所以这里按同一对键把它改掉——说明(「不要自己写取数 SQL」那几句)和要填的入口取值都还在,只是认不出是哪一份口径。"""
|
||
if not isinstance(text, str) or '"' not in text:
|
||
return text
|
||
return _CALL_VALUE.sub(lambda m: m.group(1) + '"(有授权的口径)"', text)
|
||
|
||
|
||
def safe_failure(text: Any) -> str | None:
|
||
"""一条失败说法的安全白话:只留机器码,口径名、版本、card 一个字不带出来;不是口径那一档返回 None(照原样给)。
|
||
|
||
认得出是口径那一档的两种写法:说法里有工具名(节点模板写的是「工具 caliber_query 执行失败:…」),或者有 querycore
|
||
起 Run 之前拒绝的那几个机器码(口径固定执行在起 Run 之前拒时,码就写在消息里)。"""
|
||
from app.ledger.querycore import REFUSAL_CODES
|
||
|
||
if not isinstance(text, str) or not text.strip():
|
||
return None
|
||
code = next((code for code in REFUSAL_CODES if code in text), None)
|
||
if code is None and CALIBER_QUERY not in text:
|
||
return None
|
||
return f"{CALIBER_FAILED_TEXT}({code})" if code else CALIBER_FAILED_TEXT
|
||
|
||
|
||
def _config_of(node: Any) -> Any:
|
||
if not isinstance(node, dict) or not isinstance(node.get("data"), dict):
|
||
return None
|
||
return node["data"].get("config")
|
||
|
||
|
||
def result_summary(artifact: Any) -> dict[str, Any] | None:
|
||
"""这次调用的**产出工件**里那份工具结果的安全摘要(本机文件,读不出来当没有)。
|
||
|
||
tool.end 的 preview 只留前 2000 个字符(一次口径查询交回的东西远超它),所以指标值、问题码从**工件本身**取:
|
||
值本来就在工具边界遮过,这里只按白名单挑「算成没算成、值是多少」,口径身份、清单、selection、哈希、工件 id 都不出去。"""
|
||
if not artifact:
|
||
return None
|
||
from app.core.artifact_store import load
|
||
|
||
try:
|
||
payload = load(str(artifact))
|
||
except (ValueError, OSError):
|
||
return None
|
||
if isinstance(payload, dict) and isinstance(payload.get("result"), dict):
|
||
payload = payload["result"] # 工具快照包着 result;节点产出就是结果本身
|
||
return _pick(payload)
|
||
|
||
|
||
def safe_result(preview: Any) -> dict[str, Any] | None:
|
||
"""那条 preview(工具交回的整份结果,可能被截断)里的安全摘要:读不成 JSON 就当没有,不猜。"""
|
||
if not isinstance(preview, str) or not preview.lstrip().startswith("{"):
|
||
return None
|
||
try:
|
||
payload = json.loads(preview)
|
||
except ValueError:
|
||
return None
|
||
return _pick(payload)
|
||
|
||
|
||
def _pick(payload: Any) -> dict[str, Any] | None:
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
out = {k: payload[k] for k in SAFE_RESULT_KEYS if payload.get(k) is not None}
|
||
return out if out.get("metrics") is not None or out.get("problems") is not None else None
|
||
|
||
|
||
|
||
|
||
class OriginRef(BaseModel):
|
||
"""问数据的运行属于哪段对话、哪一轮(next-round 02 做法 9)。服务端核对对话这个人用不用得了、这一轮是不是这段对话的,
|
||
写进 Run.origin 和封存的 run.origin 事件:运行和轮次之间从此有一条封存的链接(召回找答案的主人、统计按对话数重复都靠它),
|
||
不再只靠前端写的 meta.runId。"""
|
||
|
||
conversation_id: str = Field(min_length=1, max_length=32)
|
||
turn_id: str = Field(min_length=1, max_length=32)
|
||
|
||
|
||
class StartRunIn(BaseModel):
|
||
workflow_id: str | None = None
|
||
graph: dict[str, Any] | None = None # 直接跑一张未保存的图(画布上的"试运行")
|
||
input: dict[str, Any] = Field(default_factory=dict)
|
||
# 不传就取设置里的运行默认值(settings.resolve_run_scope)
|
||
memory_scope: str | None = None
|
||
collection: str | None = None
|
||
# formal:从已发布的不可变版本发起,可复现可出具;exploratory:随便玩
|
||
run_class: str = "exploratory"
|
||
version: int | None = None # formal 时可指定版本,默认用已发布版本
|
||
#: 运行来源(engine/origin.py):客户端只能声明 chat / canvas / launcher / api,不传按 api 记。
|
||
#: 应用、钩子、定时任务发起的来源只能由服务端写
|
||
origin: str | None = None
|
||
#: 来源是问数据时,这次运行属于哪段对话、哪一轮(OriginRef)。别的来源带它回 422
|
||
origin_ref: OriginRef | None = None
|
||
|
||
|
||
class RunOut(BaseModel):
|
||
id: str
|
||
workflow_id: str | None
|
||
workflow_name: str
|
||
status: str
|
||
input: dict[str, Any]
|
||
output: dict[str, Any]
|
||
error: str | None
|
||
#: 结束在 cancelled 的原因(docs/design/next-round/00-总方案.md X9):rejected 人工驳回、stopped 手动停止。
|
||
#: 其余终态、加这一列之前的运行为空。界面、通知、定时推送都读这一个字段,不再解析报错原文
|
||
end_reason: str | None = None
|
||
usage: dict[str, Any]
|
||
run_class: str | None = "exploratory"
|
||
version: int | None = None
|
||
version_hash: str | None = None
|
||
manifest_hash: str | None = None
|
||
#: 封存到了哪一条事件。为空 = 还没封存(没跑完或停在审批)
|
||
manifest_seq: int | None = None
|
||
started_by: str | None = None
|
||
#: 失败时能定位到的节点
|
||
error_node_id: str | None = None
|
||
#: 发起时实际生效的记忆域和知识库
|
||
memory_scope: str | None = None
|
||
collection: str | None = None
|
||
#: 发起它的定时任务。为空 = 手动发起
|
||
schedule_id: str | None = None
|
||
#: 运行来源(engine/origin.py 的 ORIGIN_KINDS)。为空是加这一列之前的运行:来源未记录
|
||
origin_kind: str | None = None
|
||
#: run.origin 事件的数据(kind、app_id、entry / view、hook、actor、actor_verified……),和封存范围里那一条是同一份
|
||
origin: dict[str, Any] | None = None
|
||
#: 应用发起的运行属于哪个应用版本
|
||
app_id: str | None = None
|
||
app_version: int | None = None
|
||
# 时间一律带时区(db.base.UTCDateTime 读出来就是 UTC aware)
|
||
created_at: Any = None
|
||
started_at: Any = None
|
||
finished_at: Any = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@router.post("", response_model=RunOut, status_code=201)
|
||
async def start_run(
|
||
payload: StartRunIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> Run:
|
||
"""发起规则全在 engine/launch.py(定时任务到点发起也用它),这里只把拒绝换回 HTTP 响应。"""
|
||
from app.api.deps import restricted
|
||
from app.engine.origin import CLIENT_ORIGINS
|
||
|
||
origin = payload.origin or "api"
|
||
if origin not in CLIENT_ORIGINS:
|
||
# 应用入口、钩子、定时任务的来源由服务端写:客户端能声明的话,谁都能把一次画布试跑说成应用发起的
|
||
raise CodedHTTPException(422, "运行来源只能是问数据、画布上的试运行、正式运行(手动)或接口,应用、外部系统和定时任务"
|
||
"发起的运行由服务端记录来源", ORIGIN_NOT_ALLOWED, field="origin")
|
||
if payload.origin_ref is not None and origin != "chat":
|
||
raise CodedHTTPException(422, "只有问数据的运行能写明属于哪段对话、哪一轮", ORIGIN_REF_NOT_ALLOWED,
|
||
field="origin_ref")
|
||
origin_data = await _origin_ref(session, payload.origin_ref) if payload.origin_ref is not None else None
|
||
options = None
|
||
actor = actor_of(x_actor)
|
||
memory_scope = payload.memory_scope
|
||
#: 这次要发起的图:使用者的那一档可能被 chat/launch 补上口径查询工具(见下面)
|
||
graph = payload.graph
|
||
if restricted():
|
||
# user 角色(管理员开了问数据)只能发起问数据当场搭的探索运行:已发布工作流的正式运行只经应用入口发起,别的来源
|
||
# 都是搭建者的事。图里只许读数据、整理、撰写这一类节点和只读工具(app/auth/chat_policy.py)。
|
||
# 查询结果在工具边界就遮,事件、回答里只有遮过的值——它读得到自己这次运行的事件(api/deps.py 的 _run_scope);
|
||
# 数据源查询一律只读,写语句不交给审批(Agent 的 approval 是它自己写在图里的)
|
||
if origin != "chat" or payload.run_class != "exploratory" or payload.workflow_id or payload.version is not None:
|
||
raise HTTPException(403, "没有权限:使用者只能发起问数据的运行,工作流的运行需要搭建者及以上的角色")
|
||
from app.auth.chat_policy import SCOPE_REFUSED
|
||
from app.chat.launch import ChatRunRefused, user_chat_run
|
||
|
||
# 白名单、记忆范围、遮罩与只读这几条和服务端跑的一轮问数据(chat/job.py)共用 chat/launch.user_chat_run;
|
||
# 它交回的图里可能已经补上了口径查询工具(有授权的口径那一份,见 chat/launch),发起用的是那一份
|
||
try:
|
||
memory_scope, options, graph = await user_chat_run(session, payload.graph, actor)
|
||
except ChatRunRefused as e:
|
||
raise HTTPException(403, str(e)) from e
|
||
if payload.memory_scope or payload.collection:
|
||
raise HTTPException(403, SCOPE_REFUSED)
|
||
try:
|
||
return await launch_run(
|
||
session,
|
||
workflow_id=payload.workflow_id,
|
||
graph=graph,
|
||
input_payload=payload.input,
|
||
memory_scope=memory_scope,
|
||
collection=payload.collection,
|
||
run_class=payload.run_class,
|
||
version=payload.version,
|
||
actor=actor,
|
||
origin_kind=origin,
|
||
origin=origin_data,
|
||
options=options,
|
||
)
|
||
except LaunchRefused as e:
|
||
raise _refused(e) from e
|
||
|
||
|
||
async def _origin_ref(session: AsyncSession, ref: OriginRef) -> dict[str, str]:
|
||
"""核对 origin_ref:对话这个人用不用得了(不能用的 403,规则同助手接口的 ensure_conversation_access)、对话在不在、这一轮
|
||
是不是这段对话的。核对过的才写进运行来源:客户端不能把一次运行挂到别人的对话、别的轮次上。"""
|
||
from app.api.deps import ensure_conversation_access
|
||
from app.db.models import ConversationTurn
|
||
|
||
conversation = await ensure_conversation_access(session, ref.conversation_id)
|
||
if conversation is None:
|
||
raise CodedHTTPException(422, "运行要挂的对话不存在,可能已被删除", ORIGIN_REF_INVALID, field="origin_ref")
|
||
turn = await session.get(ConversationTurn, ref.turn_id)
|
||
if turn is None or turn.conversation_id != conversation.id:
|
||
raise CodedHTTPException(422, "运行要挂的这一轮不属于这段对话", ORIGIN_REF_INVALID, field="origin_ref")
|
||
return {"conversation_id": conversation.id, "turn_id": turn.id}
|
||
|
||
|
||
def _refused(e: LaunchRefused) -> HTTPException:
|
||
"""发起被拒的 HTTP 响应,和规则搬去 engine/launch.py 之前逐字相同:带机器码的回 {detail, code},
|
||
不带的回 {detail}。"""
|
||
if e.code:
|
||
return CodedHTTPException(e.status_code, e.message, e.code)
|
||
return HTTPException(e.status_code, e.message)
|
||
|
||
|
||
#: workflow_id 取这个值时只要没挂在任何工作流上的运行:画布上没保存就跑的临时图,
|
||
#: 以及工作流被删掉之后留下来的运行(外键是 SET NULL)
|
||
UNSAVED = "__none__"
|
||
#: origin_kind 取这个值时只要来源未记录的运行(加来源这一列之前的老运行)
|
||
NO_ORIGIN = "none"
|
||
|
||
|
||
@router.get("", response_model=list[RunOut])
|
||
async def list_runs(
|
||
workflow_id: str | None = Query(default=None, description=f"{UNSAVED} 表示没保存的工作流"),
|
||
status: str | None = Query(default=None, description="逗号分隔,如 failed,cancelled"),
|
||
run_class: str | None = None,
|
||
q: str | None = Query(default=None, description="按工作流名模糊匹配"),
|
||
schedule_id: str | None = Query(default=None, description="只要这个定时任务发起的运行"),
|
||
origin_kind: str | None = Query(default=None, description=f"逗号分隔的运行来源;{NO_ORIGIN} 表示来源未记录的老运行"),
|
||
limit: int = Query(default=50, ge=1, le=200),
|
||
before: datetime | None = Query(default=None, description="翻页游标:只要 created_at 早于它的"),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[Run]:
|
||
"""运行列表,新的在前。
|
||
|
||
以前只有 limit:待审批的运行排在第 145 位,列表只取 100 条,点了徽标也找不到它。
|
||
筛选都走服务端,翻页用 created_at 做游标(before 传上一页最后一条的 created_at)。
|
||
别人的个人应用刷新、个人蓝图试运行不列(只给拥有者,auth/access.hidden_runs),管理员也一样。
|
||
没按来源筛时,系统派生的运行(评测、口径对账、口径召回:engine/origin.SYSTEM_ORIGINS)默认不列——几十道评测题的运行
|
||
会把真实用户的运行挤出第一页;按来源点名就列。没列出几次由 GET /api/runs/unlisted 照实说,界面写在列表底下。
|
||
"""
|
||
stmt = _filtered(select(Run).options(defer(Run.graph)), workflow_id=workflow_id, status=status, run_class=run_class, q=q,
|
||
schedule_id=schedule_id, origin_kind=origin_kind)
|
||
stmt = stmt.order_by(Run.created_at.desc(), Run.id.desc()).limit(limit)
|
||
if before is not None:
|
||
stmt = stmt.where(Run.created_at < before)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
@router.get("/unlisted")
|
||
async def unlisted_runs(
|
||
workflow_id: str | None = Query(default=None),
|
||
status: str | None = Query(default=None),
|
||
run_class: str | None = None,
|
||
q: str | None = Query(default=None),
|
||
schedule_id: str | None = Query(default=None),
|
||
origin_kind: str | None = Query(default=None),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> dict[str, Any]:
|
||
"""同样的筛选下,运行列表因为「系统派生的运行默认不列」而没列出的有几次:{total, by_origin: {来源: 次数}}。
|
||
|
||
按来源筛了的(origin_kind 给了)什么都不藏,total 为 0。别人的个人应用刷新、个人蓝图试运行不算在内:它们不是「没列出」,
|
||
是读不了。"""
|
||
from sqlalchemy import func
|
||
|
||
from app.engine.origin import SYSTEM_ORIGINS
|
||
|
||
if _csv(origin_kind):
|
||
return {"total": 0, "by_origin": {}}
|
||
stmt = _filtered(select(Run.origin_kind, func.count()), workflow_id=workflow_id, status=status, run_class=run_class,
|
||
q=q, schedule_id=schedule_id, origin_kind=",".join(SYSTEM_ORIGINS))
|
||
rows = (await session.execute(stmt.group_by(Run.origin_kind))).all()
|
||
by_origin = {kind: int(n) for kind, n in rows if kind and n}
|
||
return {"total": sum(by_origin.values()), "by_origin": by_origin}
|
||
|
||
|
||
def _filtered(stmt: Any, *, workflow_id: str | None, status: str | None, run_class: str | None, q: str | None,
|
||
schedule_id: str | None, origin_kind: str | None) -> Any:
|
||
"""运行列表的筛选条件(列表和「没列出几次」共用一份)。"""
|
||
from sqlalchemy import not_
|
||
|
||
from app.api.deps import current_principal
|
||
from app.auth.access import hidden_runs
|
||
from app.engine.origin import counted_runs
|
||
|
||
stmt = stmt.where(not_(hidden_runs(current_principal())))
|
||
if workflow_id == UNSAVED:
|
||
stmt = stmt.where(Run.workflow_id.is_(None))
|
||
elif workflow_id:
|
||
stmt = stmt.where(Run.workflow_id == workflow_id)
|
||
if statuses := _csv(status):
|
||
stmt = stmt.where(Run.status.in_(statuses))
|
||
if classes := _csv(run_class):
|
||
stmt = stmt.where(Run.run_class.in_(classes))
|
||
if q and q.strip():
|
||
# 不分大小写(db/portable.contains_ci):PostgreSQL 的文本列按字节排序(COLLATE "C"),列上的 ILIKE 只认 ASCII 的大小写
|
||
from app.db.portable import contains_ci
|
||
|
||
stmt = stmt.where(contains_ci(Run.workflow_name, q.strip()))
|
||
if schedule_id:
|
||
stmt = stmt.where(Run.schedule_id == schedule_id)
|
||
if origins := _csv(origin_kind):
|
||
kinds = [o for o in origins if o != NO_ORIGIN]
|
||
stmt = stmt.where(or_(Run.origin_kind.in_(kinds), *([Run.origin_kind.is_(None)] if NO_ORIGIN in origins else [])))
|
||
else:
|
||
# 没点名来源:只列计入统计的运行,系统派生的(评测、口径对账、召回)不列;来源为空的老运行照常列出
|
||
stmt = stmt.where(counted_runs())
|
||
return stmt
|
||
|
||
|
||
def _csv(value: str | None) -> list[str]:
|
||
return [v.strip() for v in (value or "").split(",") if v.strip()]
|
||
|
||
|
||
@router.get("/{run_id}", response_model=RunOut)
|
||
async def get_run(run_id: str, session: AsyncSession = Depends(get_session)) -> RunOut:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
out = RunOut.model_validate(run)
|
||
if (projection := run_projection(run.graph)) is not None:
|
||
# 失败说法里那一句(口径名、版本、card)不给使用者看:换成安全白话 + 机器码,别的失败原样
|
||
out.error = projection.failure(out.error)
|
||
if out.status == "failed" and not out.error_node_id:
|
||
# 加这一列之前失败的老运行:从事件里找最后一个失败的节点
|
||
out.error_node_id = (await session.execute(
|
||
select(RunEvent.node_id)
|
||
.where(RunEvent.run_id == run_id, RunEvent.type == "node.failed")
|
||
.order_by(RunEvent.seq.desc()).limit(1)
|
||
)).scalar_one_or_none()
|
||
return out
|
||
|
||
|
||
@router.get("/{run_id}/graph")
|
||
async def get_run_graph(run_id: str, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||
"""运行时的那张图(快照),不是工作流现在的样子。回放历史运行时画布照它画。
|
||
|
||
开登录后的使用者拿到的是一份**投影**(见 run_projection):图上口径查询节点的参数换成安全摘要,别的节点原样。"""
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
graph = run.graph or {}
|
||
projection = run_projection(graph)
|
||
if projection is not None:
|
||
graph = projection.graph(graph)
|
||
return {"graph": graph, "workflow_id": run.workflow_id, "version": run.version}
|
||
|
||
|
||
@router.get("/{run_id}/events")
|
||
async def get_events(
|
||
run_id: str,
|
||
after: int = 0,
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[dict[str, Any]]:
|
||
"""拉历史事件。刷新页面后重建时间线用这个,再接 WebSocket 续上实时流。
|
||
|
||
开登录后的使用者拿到的是一份**投影**(见 run_projection):口径查询那几处按白名单给,别的节点的事件原样。"""
|
||
run = await session.get(Run, run_id)
|
||
projection = run_projection(run.graph if run is not None else None)
|
||
rows = await session.execute(
|
||
select(RunEvent)
|
||
.where(RunEvent.run_id == run_id, RunEvent.seq > after)
|
||
.order_by(RunEvent.seq)
|
||
)
|
||
events = [
|
||
{
|
||
"id": e.id,
|
||
"run_id": e.run_id,
|
||
"seq": e.seq,
|
||
"type": e.type,
|
||
"node_id": e.node_id,
|
||
"ts": e.ts,
|
||
"data": e.data,
|
||
}
|
||
for e in rows.scalars()
|
||
]
|
||
return [projection.event(event) for event in events] if projection is not None else events
|
||
|
||
|
||
@router.get("/{run_id}/artifacts")
|
||
async def list_artifacts(
|
||
run_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> list[dict[str, Any]]:
|
||
"""这次运行产出的全部工件引用。审阅出具物时从这里下钻到完整证据。"""
|
||
rows = await session.execute(
|
||
select(Artifact).where(Artifact.run_id == run_id).order_by(Artifact.created_at, Artifact.id)
|
||
)
|
||
return [
|
||
{
|
||
"id": a.id,
|
||
"kind": a.kind,
|
||
"node_id": a.node_id,
|
||
"size": a.size,
|
||
"meta": a.meta,
|
||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||
}
|
||
for a in rows.scalars()
|
||
]
|
||
|
||
|
||
@router.post("/{run_id}/cancel")
|
||
async def cancel_run(run_id: str, x_actor: str | None = Header(default=None)) -> dict[str, Any]:
|
||
"""停止一次运行。status 是 stopping(正在执行的,取消已发出,稍后收到 run.cancelled)
|
||
或 cancelled(停在审批上、或服务重启挂起的,已经收成终态,待审批一并关闭)。"""
|
||
outcome = await run_manager.cancel(run_id, actor=actor_of(x_actor))
|
||
if outcome is None:
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
raise HTTPException(409, _cannot_cancel(run.status, run.end_reason))
|
||
return {"ok": True, "status": outcome}
|
||
|
||
|
||
def _cannot_cancel(status: str, end_reason: str | None = None) -> str:
|
||
if status == "cancelled" and end_reason == "rejected":
|
||
return "这次运行已被驳回结束,没有要停止的。"
|
||
return {
|
||
"succeeded": "这次运行已完成,没有要停止的。",
|
||
"failed": "这次运行已失败结束,没有要停止的;如需继续,请点「继续运行」。",
|
||
"cancelled": "这次运行已取消。",
|
||
}.get(status, "这次运行已不在执行中(可能刚结束,或服务重启过),无需停止。请刷新页面查看最新状态")
|
||
|
||
|
||
class ResumeIn(BaseModel):
|
||
response: Any = None
|
||
approval_id: str | None = None
|
||
|
||
|
||
def _missing(e: KeyError) -> HTTPException:
|
||
# str(KeyError) 会带上 repr 的引号,界面上就成了 "'运行记录不存在'"
|
||
return HTTPException(404, str(e.args[0]) if e.args else "运行记录不存在")
|
||
|
||
|
||
def _not_while_inquiring(run_id: str, detail: str) -> None:
|
||
"""这次运行正在回答审批质询(engine/inquiry.py 的进程内登记)时,审批回复和继续运行一律 409:问答要写进
|
||
运行停着的这一段,运行先往下走了,审批人看着回答再批的那份依据就落了空。"""
|
||
if inquiry_busy(run_id):
|
||
raise CodedHTTPException(409, detail, INQUIRY_BUSY)
|
||
|
||
|
||
@router.post("/{run_id}/resume", response_model=RunOut)
|
||
async def resume_run(
|
||
run_id: str, payload: ResumeIn, x_actor: str | None = Header(default=None)
|
||
) -> Any:
|
||
"""兼容垫片:回复审批改走待办服务(app/process/tasks.py 的 respond,按派给谁、会签汇总)。「始终允许」、改过的代码、
|
||
双盲裁决的选择原样交给节点。没有待审批的(服务重启挂起的运行从断点接着跑)照旧直接恢复。"""
|
||
_not_while_inquiring(run_id, BUSY_DECIDE)
|
||
target = payload.approval_id
|
||
if target is None:
|
||
async with SessionLocal() as session:
|
||
pending = list((await session.execute(select(Approval.id).where(
|
||
Approval.run_id == run_id, Approval.status == "pending"))).scalars())
|
||
# 只有一条时才敢认;多条时交给引擎说清楚「无法确定回复的是哪一条」
|
||
target = pending[0] if len(pending) == 1 else None
|
||
if target is not None:
|
||
async with SessionLocal() as session:
|
||
approval = await session.get(Approval, target)
|
||
if approval is not None and approval.run_id == run_id and approval.status == "pending":
|
||
return await _respond_to(target, payload.response, x_actor)
|
||
from app.engine.runner import ContinueRefused
|
||
|
||
try:
|
||
# approval_id 以前定义了却从没传下去——一次运行里有多条待审批时,
|
||
# 引擎无从知道这次回复的是哪一条
|
||
return await run_manager.resume(
|
||
run_id, payload.response,
|
||
approval_id=payload.approval_id,
|
||
actor=actor_of(x_actor),
|
||
)
|
||
except KeyError as e:
|
||
raise _missing(e) from e
|
||
except ContinueRefused as e:
|
||
# 被驳回的(run_rejected):和 /continue 同一个机器码
|
||
raise CodedHTTPException(409, str(e), e.code) from e
|
||
except ValueError as e:
|
||
raise HTTPException(409, str(e)) from e
|
||
|
||
|
||
async def _respond_to(approval_id: str, response: Any, x_actor: str | None) -> Any:
|
||
"""老接口的回复交给待办服务:只当一票、原样交给节点。待办还没有的(开待办时出过错)现在补一条。"""
|
||
from app.api.tasks import principal_of, refused
|
||
from app.process.tasks import RawResponse, TaskRefused, respond, task_for_approval
|
||
|
||
task_id = await task_for_approval(approval_id)
|
||
if task_id is None:
|
||
raise HTTPException(404, "审批记录不存在,所属的运行可能已被删除")
|
||
try:
|
||
out = await respond(task_id, principal_of(x_actor), decision=RawResponse(response))
|
||
except TaskRefused as e:
|
||
error = refused(e)
|
||
if isinstance(error, HTTPException):
|
||
raise error from e
|
||
return error
|
||
return out["run"]
|
||
|
||
|
||
class ContinueIn(BaseModel):
|
||
"""接着跑。graph 只能带改过配置的同一张图,结构必须一致。"""
|
||
|
||
graph: dict[str, Any] | None = None
|
||
|
||
|
||
@router.post("/{run_id}/continue", response_model=RunOut)
|
||
async def continue_run(
|
||
run_id: str, payload: ContinueIn, x_actor: str | None = Header(default=None)
|
||
) -> Run:
|
||
"""从失败的节点接着跑,前面跑过的不重来。
|
||
|
||
和 /resume 不是一回事:那条回复人工审批,这条是真的挂了(status=failed),
|
||
或者被服务重启打断、没有在等谁的 interrupted。挡住前者的原来只是 resume 里的
|
||
一行状态判定,而 checkpoint 一直都在——改个模型 id 就得把前面两分钟的取数
|
||
重跑一遍,纯属白等。
|
||
"""
|
||
_not_while_inquiring(run_id, BUSY_CONTINUE)
|
||
from app.engine.runner import ContinueRefused
|
||
|
||
try:
|
||
return await run_manager.continue_failed(
|
||
run_id, graph=payload.graph, actor=actor_of(x_actor),
|
||
)
|
||
except KeyError as e:
|
||
raise _missing(e) from e
|
||
except ContinueRefused as e:
|
||
# 正式运行带图(formal_graph_locked)、写入结果未知还没核实(action_unresolved)、被驳回了的(run_rejected):带机器码
|
||
raise CodedHTTPException(409, str(e), e.code) from e
|
||
except ValueError as e:
|
||
raise HTTPException(409, str(e)) from e
|
||
|
||
|
||
@router.get("/{run_id}/state")
|
||
async def get_state(run_id: str) -> dict[str, Any]:
|
||
"""读取 checkpoint 里的当前状态。调试中断中的 run 时很有用。"""
|
||
from app.engine.compiler import compile_graph
|
||
from app.engine.context import RunContext
|
||
from app.engine.schema import GraphSpec
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
app = compile_graph(spec, RunContext(run_id=run_id, thread_id=run_id, spec=spec)).compile(
|
||
checkpointer=run_manager.checkpointer
|
||
)
|
||
snapshot = await app.aget_state({"configurable": {"thread_id": run_id}})
|
||
values = dict(snapshot.values or {})
|
||
values.pop("messages", None) # 消息对象不好直接序列化,前端也用不到
|
||
return {
|
||
"values": _jsonable(values),
|
||
"next": list(snapshot.next or ()),
|
||
"interrupts": [
|
||
{"id": getattr(i, "id", None), "value": _jsonable(getattr(i, "value", None))}
|
||
for i in (snapshot.interrupts or ())
|
||
],
|
||
}
|
||
|
||
|
||
@router.get("/{run_id}/verify")
|
||
async def verify_run(run_id: str) -> dict[str, Any]:
|
||
"""核对事件流和封存时的清单哈希是否一致:事后被改过、删过、插过都会对不上。"""
|
||
from app.engine.runner import verify_manifest
|
||
|
||
try:
|
||
return await verify_manifest(run_id)
|
||
except KeyError as e:
|
||
raise _missing(e) from e
|
||
|
||
|
||
@router.get("/{run_id}/history")
|
||
async def get_history(run_id: str, limit: int = 50) -> list[dict[str, Any]]:
|
||
"""checkpoint 历史 —— 可以看到每一步之后的状态快照,用来做时间旅行式调试。"""
|
||
from app.engine.compiler import compile_graph
|
||
from app.engine.context import RunContext
|
||
from app.engine.schema import GraphSpec
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
|
||
spec = GraphSpec.model_validate(run.graph)
|
||
app = compile_graph(spec, RunContext(run_id=run_id, thread_id=run_id, spec=spec)).compile(
|
||
checkpointer=run_manager.checkpointer
|
||
)
|
||
out: list[dict[str, Any]] = []
|
||
async for snapshot in app.aget_state_history({"configurable": {"thread_id": run_id}}):
|
||
values = dict(snapshot.values or {})
|
||
values.pop("messages", None)
|
||
out.append(
|
||
{
|
||
"checkpoint_id": snapshot.config.get("configurable", {}).get("checkpoint_id"),
|
||
"next": list(snapshot.next or ()),
|
||
"created_at": snapshot.created_at,
|
||
"trail": _jsonable(values.get("trail") or [])[-1:],
|
||
"nodes_done": list((values.get("nodes") or {}).keys()),
|
||
}
|
||
)
|
||
if len(out) >= limit:
|
||
break
|
||
return out
|
||
|
||
|
||
@router.delete("/{run_id}", status_code=204)
|
||
async def delete_run(
|
||
run_id: str, force: bool = False, session: AsyncSession = Depends(get_session)
|
||
) -> None:
|
||
"""删除运行记录。运行记录是出具的追溯依据,三种情况要拦:
|
||
|
||
- 还在跑的:删了会留下没人管的执行任务,它还会往一条不存在的记录里写事件;
|
||
- 写过外部系统的(有演练以外的生效记录):生效记录随运行级联删除,删了就对不上外部系统里已经发生的事(P8),
|
||
停在「结果未知、等核实」上的连核实待办一起没了。force 也不放行;工作流的删除检查(F4)也就绕不过去;
|
||
- 封存过的正式运行:删掉之后清单再也无法核对。确实要删,带 force=true。
|
||
"""
|
||
from sqlalchemy import func
|
||
|
||
from app.db.models import ActionRecord
|
||
|
||
run = await session.get(Run, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
if run.status in ("queued", "running") or run_manager.is_active(run_id):
|
||
raise HTTPException(409, "这次运行仍在进行,现在删除会留下无人管理的后台任务。请先停止运行,再删除")
|
||
effects = await session.scalar(select(func.count(ActionRecord.id)).where(
|
||
ActionRecord.run_id == run_id, ActionRecord.state != "simulated"))
|
||
if effects:
|
||
raise CodedHTTPException(
|
||
409, f"这次运行有 {effects} 条写入外部系统的生效记录,是和外部系统对账的依据,不能删除(强制删除也不行)",
|
||
RUN_HAS_EFFECTS)
|
||
if run.run_class == "formal" and run.manifest_hash and not force:
|
||
raise HTTPException(
|
||
409,
|
||
"这是一次已封存的正式运行,是出具结果的追溯凭证。删除后其事件和工件将一并删除,"
|
||
"封存清单无法再核对。如仍要删除,请选择「强制删除」",
|
||
)
|
||
from sqlalchemy import delete as sql_delete
|
||
|
||
from app.core import artifact_store
|
||
from app.db.models import Artifact
|
||
from app.engine import checkpoints
|
||
from app.files import store as files_store
|
||
|
||
artifact_ids = list((await session.execute(select(Artifact.id).where(Artifact.run_id == run_id))).scalars())
|
||
if artifact_ids:
|
||
await session.execute(sql_delete(Artifact).where(Artifact.run_id == run_id)
|
||
.execution_options(synchronize_session=False))
|
||
released_shas = await files_store.release_runs(session, [run_id])
|
||
await session.delete(run)
|
||
await session.commit()
|
||
if artifact_ids:
|
||
await artifact_store.forget(artifact_ids)
|
||
if released_shas:
|
||
await files_store.drop_unreferenced(released_shas)
|
||
await checkpoints.delete_threads([run_id])
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 实时事件流
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
#: 不在进行中的状态:没有后续事件会来(interrupted 要等人回复,恢复会新开一段
|
||
#: 执行,客户端到时候重新接)
|
||
_SETTLED = ("succeeded", "failed", "cancelled", "interrupted")
|
||
|
||
|
||
@router.websocket("/{run_id}/stream")
|
||
async def stream_run(websocket: WebSocket, run_id: str) -> None:
|
||
"""把一次运行的事件推给前端。
|
||
|
||
先补历史再接实时,所以中途打开页面、或者刷新之后,时间线都是完整的。
|
||
运行一旦不在进行中,补完历史就发一条 stream.end(data.status 是当时的状态)
|
||
再关掉——interrupted 也发。以前停在审批或服务重启挂起的运行什么都不发,
|
||
客户端一直重连、一直以为它还在跑。
|
||
"""
|
||
await websocket.accept()
|
||
after = int(websocket.query_params.get("after", 0) or 0)
|
||
# 开登录后的使用者:实时流和历史回放是**同一条路**(下面两个都过同一个投影),见 run_projection
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
projection = run_projection(run.graph if run is not None else None)
|
||
|
||
# 先占订阅位,再读历史。反过来的话,两步之间图正好跑出来的事件既不在
|
||
# 历史里、也不在实时流里——永远丢了。实测就是这样:一次跑图的开头
|
||
# 两三条 node.started/node.finished 消失,界面上表现为某个节点永远
|
||
# 在转圈、它的子步骤跑到了顶层。
|
||
subscription = bus.attach(run_id)
|
||
try:
|
||
last_seq, run = await _replay(websocket, run_id, after, projection)
|
||
if run is not None and run.status in _SETTLED and run_manager.is_active(run_id):
|
||
# 状态已经落了、执行任务还在收尾(终态事件、封存):等它收完,把这期间
|
||
# 落库的事件补上,按最终状态收尾。否则会在 run.finished 之前就宣布结束
|
||
await run_manager.wait_idle(run_id)
|
||
last_seq, run = await _replay(websocket, run_id, last_seq, projection)
|
||
|
||
if run is None or run.status in _SETTLED:
|
||
await _end(websocket, run.status if run else None)
|
||
return
|
||
|
||
# 占位期间攒下的那几条历史里也有,按 seq 滤掉,否则前端同一步收两遍。多进程时这次运行可能在别的进程里执行:
|
||
# 跟随它(app/cluster/relay.py),本进程从库里按 seq 追、在本进程的总线上发布,这里收到的和在执行的进程上一样
|
||
from app.cluster.relay import relay
|
||
|
||
with relay.follow_run(run_id, last_seq):
|
||
pump = asyncio.create_task(_pump(websocket, subscription.stream(after=last_seq), run_id, projection))
|
||
try:
|
||
# 客户端断开时读操作会抛异常,用它来结束这条连接
|
||
while True:
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
pump.cancel()
|
||
with contextlib.suppress(asyncio.CancelledError):
|
||
await pump
|
||
except WebSocketDisconnect:
|
||
return
|
||
except Exception: # noqa: BLE001
|
||
with contextlib.suppress(Exception):
|
||
await websocket.close()
|
||
finally:
|
||
subscription.close()
|
||
|
||
|
||
async def _replay(websocket: WebSocket, run_id: str, after: int,
|
||
projection: _Projection | None = None) -> tuple[int, Run | None]:
|
||
"""把 seq > after 的历史事件发出去,顺带取回运行当前的状态。
|
||
|
||
projection 不为空(开登录后的使用者)时,事件先过一遍投影再发——历史回放和实时流走的是同一条路。"""
|
||
last_seq = after
|
||
async with SessionLocal() as session:
|
||
rows = await session.execute(
|
||
select(RunEvent)
|
||
.where(RunEvent.run_id == run_id, RunEvent.seq > after)
|
||
.order_by(RunEvent.seq)
|
||
)
|
||
for event in rows.scalars():
|
||
last_seq = max(last_seq, event.seq or 0)
|
||
wire = {
|
||
"seq": event.seq,
|
||
"type": event.type,
|
||
"node_id": event.node_id,
|
||
"ts": event.ts,
|
||
"data": event.data,
|
||
"replay": True,
|
||
}
|
||
await websocket.send_json(projection.event(wire) if projection is not None else wire)
|
||
run = await session.get(Run, run_id)
|
||
return last_seq, run
|
||
|
||
|
||
async def _end(websocket: WebSocket, status: str | None) -> None:
|
||
# 顶层的 status 留给老客户端;新口径读 data.status
|
||
await websocket.send_json({"type": "stream.end", "status": status, "data": {"status": status}})
|
||
await websocket.close()
|
||
|
||
|
||
async def _pump(websocket: WebSocket, stream: Any, run_id: str,
|
||
projection: _Projection | None = None) -> None:
|
||
try:
|
||
async for event in stream:
|
||
wire = event.to_wire()
|
||
await websocket.send_json(projection.event(wire) if projection is not None else wire)
|
||
# 实时流走完 = 运行到了终态,或者关停时挂起了:补一条结束标记再关,
|
||
# 客户端不用靠猜,也不会对着一条已经结束的运行无限重连
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
await _end(websocket, run.status if run else None)
|
||
except (WebSocketDisconnect, RuntimeError):
|
||
return
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 审批
|
||
# --------------------------------------------------------------------------
|
||
|
||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||
|
||
|
||
class ApprovalOut(BaseModel):
|
||
id: str
|
||
run_id: str
|
||
node_id: str
|
||
mode: str
|
||
title: str
|
||
payload: dict[str, Any]
|
||
status: str
|
||
response: dict[str, Any]
|
||
created_at: Any = None
|
||
#: 谁批的:设置里的署名(请求头 X-Actor)。没署名就是 null,由前端写「未署名」
|
||
resolved_by: str | None = None
|
||
resolved_at: Any = None
|
||
# 下面几项来自所属的运行。全局待办列表要拼得出一行像样的字:
|
||
# 哪个工作流、卡在哪个节点、运行现在什么状态
|
||
workflow_id: str | None = None
|
||
workflow_name: str | None = None
|
||
node_label: str | None = None
|
||
run_status: str | None = None
|
||
run_class: str | None = None
|
||
#: 这条审批对应的待办(app/process/tasks.py)。回复走 POST /api/tasks/{task_id}/respond;开待办时出过错、还没补上的
|
||
#: 为空,老接口 /decide 照旧能用(它会现补一条)
|
||
task_id: str | None = None
|
||
|
||
|
||
@approvals_router.get("", response_model=list[ApprovalOut])
|
||
async def list_approvals(
|
||
status: str = Query(default="pending", description="pending / resolved / all,可逗号分隔"),
|
||
run_id: str | None = None,
|
||
limit: int = Query(default=100, ge=1, le=500),
|
||
session: AsyncSession = Depends(get_session),
|
||
) -> list[ApprovalOut]:
|
||
from sqlalchemy import not_
|
||
|
||
from app.api.deps import current_principal
|
||
from app.auth.access import hidden_runs
|
||
from app.db.models import Task
|
||
|
||
stmt = (
|
||
select(Approval, Run, Task.id)
|
||
.join(Run, Run.id == Approval.run_id, isouter=True)
|
||
.join(Task, Task.approval_id == Approval.id, isouter=True)
|
||
# 别人的个人应用刷新、个人蓝图试运行上的审批不列(只给拥有者,同运行列表)
|
||
.where(or_(Run.id.is_(None), not_(hidden_runs(current_principal()))))
|
||
.order_by(Approval.created_at.desc(), Approval.id.desc())
|
||
.limit(limit)
|
||
)
|
||
if status != "all" and (statuses := _csv(status)):
|
||
stmt = stmt.where(Approval.status.in_(statuses))
|
||
if run_id:
|
||
stmt = stmt.where(Approval.run_id == run_id)
|
||
out: list[ApprovalOut] = []
|
||
for approval, run, task_id in (await session.execute(stmt)).all():
|
||
item = ApprovalOut.model_validate(approval, from_attributes=True)
|
||
item.task_id = task_id
|
||
if run is not None:
|
||
item.workflow_id = run.workflow_id
|
||
item.workflow_name = run.workflow_name
|
||
item.run_status = run.status
|
||
item.run_class = run.run_class
|
||
item.node_label = _node_label(run.graph, approval.node_id)
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
def _node_label(graph: dict[str, Any] | None, node_id: str) -> str | None:
|
||
for n in (graph or {}).get("nodes") or []:
|
||
if isinstance(n, dict) and n.get("id") == node_id:
|
||
return ((n.get("data") or {}).get("label")) or node_id
|
||
return None
|
||
|
||
|
||
class DecideIn(BaseModel):
|
||
approved: bool = True
|
||
note: str = ""
|
||
value: Any = None
|
||
args: dict[str, Any] | None = None
|
||
|
||
|
||
@approvals_router.post("/{approval_id}/decide", response_model=RunOut)
|
||
async def decide(
|
||
approval_id: str,
|
||
payload: DecideIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> Any:
|
||
"""兼容垫片:改走待办服务(app/process/tasks.py 的 respond)。请求体照旧,只当一票、原样交给节点。"""
|
||
from app.api.deps import PRIVATE_RUN, can_read_run, current_principal
|
||
|
||
approval = await session.get(Approval, approval_id)
|
||
if not approval:
|
||
raise HTTPException(404, "审批记录不存在,所属的运行可能已被删除")
|
||
if not await can_read_run(current_principal(), approval.run_id):
|
||
raise HTTPException(403, PRIVATE_RUN)
|
||
if approval.status != "pending":
|
||
raise HTTPException(409, "这条审批已处理过,请刷新页面查看最新状态")
|
||
# 在落签批人之前拦:拒掉的批准不该留下「谁试图批的」
|
||
_not_while_inquiring(approval.run_id, BUSY_DECIDE)
|
||
|
||
# 责任归属先落库再恢复执行——恢复失败也要留下"谁试图批的"
|
||
approval.resolved_by = actor_of(x_actor)
|
||
await session.commit()
|
||
|
||
response: dict[str, Any] = {"approved": payload.approved, "note": payload.note}
|
||
if payload.value is not None:
|
||
response["value"] = payload.value
|
||
if payload.args is not None:
|
||
response["args"] = payload.args
|
||
return await _respond_to(approval.id, response, x_actor)
|
||
|
||
|
||
def _jsonable(value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
return {str(k): _jsonable(v) for k, v in value.items()}
|
||
if isinstance(value, (list, tuple)):
|
||
return [_jsonable(v) for v in value]
|
||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||
return value
|
||
return str(value)
|