mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
3e311ec551
整合(3c-integrate) - 公共层:跨天时长(formatSpan / formatOffset / formatLapse)、本机署名(lib/actor)、检索分类色令牌、 数据源目录(useDatasources)收进公共层;失败说明能一次列出多个写坏的自定义工具,查询超时指到数据源的查询时限 - 管理页收尾:设置页接入统一的离开前确认;检查脚本不再把沙箱里的真实值打进输出 - 画布 ↔ 右栏:点出具印章、点节点能跳到右栏对应位置;「定位」直接聚焦到出问题的字段 - 回答流 / 问数据 / 记录页:失败信息带「去哪修」且不再被翻译两遍;协作团队的结局按执行截取; 等审批时断流重连、而运行已在空档里跑完的,补全事件 扫尾(3c-sweep) - 术语统一:开始运行 / 继续运行、工作流最大步数;未保存的运行统一叫「未保存的工作流」(后端新运行同步) - 沙箱报错不再带 Python 异常类名;新增 check-tokens,未定义的 CSS 变量为 0,两个旧别名迁移后删除 检查脚本(3c-checks) - check-ui 跟上新界面(运行列表按 data 属性找行),加上令牌与选中态的回归断言,输出里的名字打码 - 新增 check-all:按顺序跑全部检查、汇总退出码,--repeat 列出偶发项 - 单节出错只记这一节失败、不再带崩整个脚本;外壳遥测浮层「重新检测」时焦点不再掉到 body 网页工具与 fake-ip - Clash / mihomo 一类代理开 TUN + fake-ip 时,查什么都先拿到 198.18 段的假地址,被当成内网拦截。 新增 AGENTLAB_HTTP_TOOL_FAKE_IP_RANGES(默认空):落在段里的解析结果改向公共 DNS(DoH)核实真实地址, 公网放行、内网照拦、核实不了按拦截处理。DoH 默认先问境外的,境内公共 DNS 会对被封锁的域名返回污染结果 - 列表型环境变量接受逗号分隔(原来注释说逗号分隔、实际只认 JSON 数组);域名解析改为异步,不再阻塞事件循环 验证(沙箱):tsc 干净,pytest 866 passed(两遍),检查脚本全绿。
370 lines
13 KiB
Python
370 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.errors import graph_error
|
||
from app.api.runs import actor_of
|
||
from app.core.artifact_store import graph_hash
|
||
from app.db.base import get_session
|
||
from app.db.models import Run, Workflow, WorkflowVersion
|
||
from app.engine.governance import lint_for_publish
|
||
from app.engine.schema import GraphSpec, validate_graph
|
||
|
||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||
|
||
_GONE = "这个工作流不存在,可能已经被删了"
|
||
|
||
|
||
class WorkflowIn(BaseModel):
|
||
name: str = Field(min_length=1, max_length=200)
|
||
description: str = ""
|
||
graph: dict[str, Any] = Field(default_factory=dict)
|
||
tags: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class WorkflowPatch(BaseModel):
|
||
name: str | None = None
|
||
description: str | None = None
|
||
graph: dict[str, Any] | None = None
|
||
tags: list[str] | None = None
|
||
note: str = ""
|
||
|
||
|
||
class WorkflowOut(BaseModel):
|
||
id: str
|
||
name: str
|
||
description: str
|
||
graph: dict[str, Any]
|
||
tags: list[str]
|
||
version: int
|
||
is_template: bool
|
||
status: str = "draft"
|
||
published_version: int | None = None
|
||
published_by: str | None = None
|
||
created_at: Any = None
|
||
updated_at: Any = None
|
||
run_count: int = 0
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@router.get("", response_model=list[WorkflowOut])
|
||
async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[WorkflowOut]:
|
||
rows = list(
|
||
(await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars()
|
||
)
|
||
counts = dict(
|
||
(await session.execute(select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id))).all()
|
||
)
|
||
out: list[WorkflowOut] = []
|
||
for row in rows:
|
||
item = WorkflowOut.model_validate(row)
|
||
item.run_count = counts.get(row.id, 0)
|
||
out.append(item)
|
||
return out
|
||
|
||
|
||
@router.post("", response_model=WorkflowOut, status_code=201)
|
||
async def create_workflow(
|
||
payload: WorkflowIn, session: AsyncSession = Depends(get_session)
|
||
) -> Workflow:
|
||
workflow = Workflow(
|
||
name=payload.name,
|
||
description=payload.description,
|
||
graph=payload.graph,
|
||
tags=payload.tags,
|
||
)
|
||
session.add(workflow)
|
||
await session.flush()
|
||
session.add(
|
||
WorkflowVersion(workflow_id=workflow.id, version=1, graph=payload.graph,
|
||
graph_hash=graph_hash(payload.graph), note="初始版本")
|
||
)
|
||
await session.commit()
|
||
await session.refresh(workflow)
|
||
return workflow
|
||
|
||
|
||
@router.get("/{workflow_id}", response_model=WorkflowOut)
|
||
async def get_workflow(
|
||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> Workflow:
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
return workflow
|
||
|
||
|
||
@router.patch("/{workflow_id}", response_model=WorkflowOut)
|
||
async def update_workflow(
|
||
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
|
||
) -> Workflow:
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
|
||
if payload.name is not None:
|
||
workflow.name = payload.name
|
||
if payload.description is not None:
|
||
workflow.description = payload.description
|
||
if payload.tags is not None:
|
||
workflow.tags = payload.tags
|
||
if payload.graph is not None and payload.graph != workflow.graph:
|
||
# 图有实际变化才留快照,避免改个名字就堆一堆版本
|
||
workflow.graph = payload.graph
|
||
workflow.version += 1
|
||
session.add(
|
||
WorkflowVersion(
|
||
workflow_id=workflow.id,
|
||
version=workflow.version,
|
||
graph=payload.graph,
|
||
graph_hash=graph_hash(payload.graph),
|
||
note=payload.note,
|
||
)
|
||
)
|
||
# status 说的是**当前画布**的状态,不是"这个工作流曾经发布过"。
|
||
# 改完图还挂着 governed,界面上就会显示一张从未过闸的图是受管模板——
|
||
# 已发布的那一版仍由 published_version 指着,formal 运行不受影响。
|
||
if workflow.status in ("published", "governed"):
|
||
workflow.status = "draft"
|
||
await session.commit()
|
||
await session.refresh(workflow)
|
||
return workflow
|
||
|
||
|
||
@router.delete("/{workflow_id}", status_code=204)
|
||
async def delete_workflow(
|
||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> None:
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
# 运行记录是 ON DELETE SET NULL:删掉工作流,已结束的运行留着、照样能核对封存;
|
||
# 还在跑的那次会失去归属,再也回不到它的画布,所以和删除运行记录一样先停再删
|
||
live = await session.scalar(
|
||
select(func.count()).select_from(Run).where(
|
||
Run.workflow_id == workflow_id, Run.status.in_(("queued", "running"))
|
||
)
|
||
)
|
||
if live:
|
||
raise HTTPException(409, f"这个工作流还有 {live} 次运行在进行,现在删除它们会失去归属。先停止运行,再删除")
|
||
await session.delete(workflow)
|
||
await session.commit()
|
||
|
||
|
||
@router.post("/{workflow_id}/duplicate", response_model=WorkflowOut, status_code=201)
|
||
async def duplicate_workflow(
|
||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> Workflow:
|
||
source = await session.get(Workflow, workflow_id)
|
||
if not source:
|
||
raise HTTPException(404, _GONE)
|
||
copy = Workflow(
|
||
name=f"{source.name} 副本",
|
||
description=source.description,
|
||
graph=source.graph,
|
||
tags=list(source.tags or []),
|
||
)
|
||
session.add(copy)
|
||
await session.commit()
|
||
await session.refresh(copy)
|
||
return copy
|
||
|
||
|
||
class VersionOut(BaseModel):
|
||
id: str
|
||
version: int
|
||
note: str
|
||
created_at: Any = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
@router.get("/{workflow_id}/versions", response_model=list[VersionOut])
|
||
async def list_versions(
|
||
workflow_id: str, session: AsyncSession = Depends(get_session)
|
||
) -> list[WorkflowVersion]:
|
||
rows = await session.execute(
|
||
select(WorkflowVersion)
|
||
.where(WorkflowVersion.workflow_id == workflow_id)
|
||
.order_by(WorkflowVersion.version.desc())
|
||
)
|
||
return list(rows.scalars())
|
||
|
||
|
||
class VersionDetailOut(VersionOut):
|
||
workflow_id: str
|
||
graph: dict[str, Any]
|
||
graph_hash: str | None = None
|
||
#: 这一版入口节点声明的字段。正式运行跑的是已发布的那一版,表单得照它来填——
|
||
#: 画布上改过入口字段时,拿当前画布的字段去跑已发布版本会传错参数
|
||
input_fields: list[dict[str, Any]] = Field(default_factory=list)
|
||
published: bool = False
|
||
|
||
|
||
@router.get("/{workflow_id}/versions/{version}", response_model=VersionDetailOut)
|
||
async def get_version(
|
||
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||
) -> VersionDetailOut:
|
||
"""取某一版的完整图。"""
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
snapshot = (
|
||
await session.execute(
|
||
select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == workflow_id,
|
||
WorkflowVersion.version == version,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not snapshot:
|
||
raise HTTPException(404, f"「{workflow.name}」没有 v{version} 这个版本")
|
||
|
||
graph = snapshot.graph or {}
|
||
fields: list[dict[str, Any]] = []
|
||
for node in graph.get("nodes") or []:
|
||
if node.get("type") == "input":
|
||
fields.extend(
|
||
f for f in ((node.get("data") or {}).get("config") or {}).get("fields") or []
|
||
if isinstance(f, dict)
|
||
)
|
||
return VersionDetailOut(
|
||
id=snapshot.id, version=snapshot.version, note=snapshot.note,
|
||
created_at=snapshot.created_at, workflow_id=workflow_id, graph=graph,
|
||
graph_hash=snapshot.graph_hash or graph_hash(graph), input_fields=fields,
|
||
published=workflow.published_version == snapshot.version,
|
||
)
|
||
|
||
|
||
@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut)
|
||
async def restore_version(
|
||
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
|
||
) -> Workflow:
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
snapshot = (
|
||
await session.execute(
|
||
select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == workflow_id,
|
||
WorkflowVersion.version == version,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not snapshot:
|
||
raise HTTPException(404, f"没有 v{version} 这个版本")
|
||
|
||
workflow.graph = snapshot.graph
|
||
workflow.version += 1
|
||
session.add(
|
||
WorkflowVersion(
|
||
workflow_id=workflow.id,
|
||
version=workflow.version,
|
||
graph=snapshot.graph,
|
||
graph_hash=graph_hash(snapshot.graph),
|
||
note=f"回滚到 v{version}",
|
||
)
|
||
)
|
||
await session.commit()
|
||
await session.refresh(workflow)
|
||
return workflow
|
||
|
||
|
||
class PublishIn(BaseModel):
|
||
level: str = "published" # published | governed
|
||
version: int | None = None # 默认发布当前最新版本
|
||
|
||
|
||
@router.post("/{workflow_id}/publish")
|
||
async def publish_workflow(
|
||
workflow_id: str,
|
||
payload: PublishIn,
|
||
session: AsyncSession = Depends(get_session),
|
||
x_actor: str | None = Header(default=None),
|
||
) -> dict[str, Any]:
|
||
"""把某个版本立为正式版本。
|
||
|
||
formal 运行只能从这里发布过的版本发起。governed 级别额外过治理 lint:
|
||
错误会挡住发布——这就是"必经检查点"落地的地方。
|
||
返回 200 + ok/issues 而不是抛错,让前端能把问题列表渲染出来。
|
||
"""
|
||
if payload.level not in ("published", "governed"):
|
||
raise HTTPException(400, "发布档位只能选「已发布」或「受管」")
|
||
workflow = await session.get(Workflow, workflow_id)
|
||
if not workflow:
|
||
raise HTTPException(404, _GONE)
|
||
|
||
version = payload.version or workflow.version
|
||
snapshot = (
|
||
await session.execute(
|
||
select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == workflow_id,
|
||
WorkflowVersion.version == version,
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not snapshot:
|
||
raise HTTPException(404, f"没有 v{version} 这个版本:画布上的改动还没保存,先保存一次再发布")
|
||
|
||
try:
|
||
spec = GraphSpec.model_validate(snapshot.graph)
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
|
||
|
||
issues = [i.model_dump() for i in validate_graph(spec).issues]
|
||
issues += [i.model_dump() for i in lint_for_publish(spec, level=payload.level).issues]
|
||
if any(i["level"] == "error" for i in issues):
|
||
return {"ok": False, "level": payload.level, "version": version, "issues": issues}
|
||
|
||
if not snapshot.graph_hash:
|
||
snapshot.graph_hash = graph_hash(snapshot.graph)
|
||
workflow.status = payload.level
|
||
workflow.published_version = version
|
||
workflow.published_by = actor_of(x_actor)
|
||
await session.commit()
|
||
return {
|
||
"ok": True,
|
||
"level": payload.level,
|
||
"version": version,
|
||
"graph_hash": snapshot.graph_hash,
|
||
"published_by": workflow.published_by,
|
||
"issues": issues, # 剩下的都是警告
|
||
}
|
||
|
||
|
||
class ValidateIn(BaseModel):
|
||
graph: dict[str, Any]
|
||
|
||
|
||
@router.post("/validate")
|
||
async def validate(payload: ValidateIn) -> dict[str, Any]:
|
||
"""画布上的实时校验。前端每次改动都会调它来标红。"""
|
||
try:
|
||
spec = GraphSpec.model_validate(payload.graph)
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
|
||
result = validate_graph(spec)
|
||
return result.model_dump()
|
||
|
||
|
||
@router.post("/variables")
|
||
async def variables(payload: ValidateIn) -> dict[str, Any]:
|
||
"""这张图里有哪些变量、谁产出、谁引用。
|
||
|
||
纯静态分析,不用跑图也不用有运行记录——用户在编排到一半时最需要它,
|
||
而那时候还没有任何一次运行可看。运行期的实际取值由前端从事件里补上。
|
||
"""
|
||
from app.engine.variables import analyze
|
||
|
||
try:
|
||
spec = GraphSpec.model_validate(payload.graph)
|
||
except Exception as e: # noqa: BLE001
|
||
return {"variables": [], "issues": [{"level": "error", "message": f"工作流的结构读不懂:{graph_error(e)}"}]}
|
||
return analyze(spec).model_dump()
|