Files
AgentLab/backend/app/apps/derive.py
T
ThearchyHelios e82d7fa55d 集成接口补齐错误码,开登录时收起 /docs、/redoc、/openapi.json(要搭建者登录)
外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人
敞开(中间件只拦 /api/ 开头的路径)。

错误码(detail 文字一字不改,老前端照常显示;码都登记在 api/coded.py):
- api/apps.py 13 处:应用不存在(app_not_found,同入口发起时的码;没有授权的人也回它)、版本、实例不存在,钩子没声明、
  没生成密钥,岗位绑定、个人应用草稿、授权、页面参数不合规;派生时工作流、版本不存在(apps/derive.py 的
  DeriveRefused 带上码);视图、入口、补算的拒绝原样带码,只有引擎不发起运行(LaunchRefused)没带码的几种补上
  run_launch_refused
- api/tasks.py 3 处:待办不存在、查看全部要搭建者(role_required);服务层没带码的拒绝按状态补上同一类的码
  (404 task_not_found、403 task_forbidden、409 task_closed、422 task_invalid),没改 process/tasks.py
- api/artifacts.py 5 处:没有权限、这类原文只给搭建者、不存在、内容和哈希对不上
- api/deps.py 的 _forbidden(run_forbidden)、运行和证据接口的角色拒绝(role_required)、个人运行只给拥有者
  (run_private)、问数据(chat_forbidden);ensure_role / 路由级依赖走 auth.require,抽出 auth.refusal 一处判断,
  没登录 401 auth_required、角色不够 403 role_required

/docs:开登录时中间件把 /docs(含子路径)、/redoc、/openapi.json 当成 /api 一样拦,和路由级依赖用同一个 auth.refusal:
匿名 401,不到搭建者 403。关登录照旧(test_provenance_contract.py 的断言不受影响)。

测试 tests/test_integration_errors.py:这五个模块里不许再直接抛 HTTPException(静态检查);应用、待办、工件、角色、运行
拒绝逐类核对 code 和原 detail;/docs 关登录照旧 200,开登录匿名 401、使用者 403、搭建者 200。去掉中间件的改动回跑,
开登录时匿名打开 /docs 是 200,测试失败。
2026-10-16 10:19:50 +08:00

229 lines
13 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.
"""从工作流版本确定性派生应用(A8):不靠模型,助手失败了也有可用的应用(P3)。
入口字段 → 入口表单;人工节点 → 待办;图里引用到的岗位(派给谁、超时升级给谁、动作确认派给谁,tree.slot_refs)→
岗位,留空等人绑定;口径卡 → 指标块;报告 → 报告块;取数节点 → 表格;写入的调用工具节点 → 动作确认待办 + 动作状态。
看板(use=view)的首页看「最近封存运行」,视图优先绑定已有的定时任务(同一工作流、正式运行、启用着的):数从定时任务的
封存运行来,打开页面不发起运行。流程(use=process)的首页只放发起和我发起的:每件事的结果在它自己的实例页看,首页没有
「最近一次」可看——派生出来只会挂一块永远空着的印章。
"""
from __future__ import annotations
import re
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.apps.forms import input_schema
from app.apps.spec import APP_FORMAT, parse_spec
from app.apps.tree import input_fields, node_map, slot_refs
PIN = "main"
VIEW = "latest"
INSTANCE_VIEW = "inst"
ENTRY = "start"
RECONCILER = "reconciler"
#: 派生不了时的机器码:工作流、版本不存在(404)
WORKFLOW_GONE = "workflow_not_found"
VERSION_GONE = "workflow_version_not_found"
class DeriveRefused(Exception):
"""派生不了。code 是给程序认的机器码,接口照它回。"""
def __init__(self, status_code: int, message: str, code: str) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
def _safe(text: str) -> str:
return re.sub(r"[^A-Za-z0-9_-]", "_", text)
async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, name: str | None = None) -> dict[str, Any]:
"""返回一份 agentlab.app/1 spec(未发布的草稿)。"""
from app.apps.pins import PinState
from app.apps.versions import version_row
from app.db.models import Schedule, Workflow
workflow = await session.get(Workflow, workflow_id)
if workflow is None:
raise DeriveRefused(404, "工作流不存在,可能已被删除", WORKFLOW_GONE)
row = await version_row(session, workflow_id, version)
if row is None:
raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本", VERSION_GONE)
state = PinState(alias=PIN, pin={"workflow_id": workflow_id, "version": version}, workflow=workflow, row=row)
schedule = (await session.execute(select(Schedule).where(
Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True))
.order_by(Schedule.created_at))).scalars().first()
return await _derive(session, row.graph or {}, title=workflow.name, name=name,
description=workflow.description or "",
pin={"alias": PIN, "workflow_id": workflow_id, "version": version, "graph_hash": state.hash,
"level": state.level},
schedule_id=schedule.id if schedule else None)
async def derive_from_graph(session: AsyncSession, graph: dict[str, Any], *, name: str, description: str = "",
workflow_id: str | None = None, blueprint_id: str | None = None,
schedule_id: str | None = None) -> dict[str, Any]:
"""从一张还没发布的图派生应用草稿(docs/design/normal-user-apps.md N4:蓝图的试运行、发布单建应用之前)。派生规则和
derive_app 同一份,只是钉住项没有版本号(version 为空)、graph_hash 取这张图的哈希。挂在工作流上的写 workflow_id,
还没有工作流的(蓝图刚实例化出来的图、个人应用)写 blueprint。schedule_id 给了,看板的视图绑这个定时任务。
这样的草稿发布不了(应用检查报 app.pin_unpublished):发布单在工作流发布之后把钉住项换成那一版再建应用。"""
from app.core.artifact_store import graph_hash
pin: dict[str, Any] = {"alias": PIN, "version": None, "graph_hash": graph_hash(graph), "level": None}
pin.update({"workflow_id": workflow_id} if workflow_id else {"blueprint": blueprint_id})
return await _derive(session, graph, title=name, name=name, description=description, pin=pin,
schedule_id=schedule_id)
async def _derive(session: AsyncSession, graph: dict[str, Any], *, title: str, name: str | None, description: str,
pin: dict[str, Any], schedule_id: str | None) -> dict[str, Any]:
"""派生规则本身:title 是工作流(或蓝图)的名字,写进首页标题、入口的说法;pin 是钉住项(还缺 use),
schedule_id 是看板的视图要绑的定时任务(没有就看正式运行)。"""
from app.apps.pins import write_nodes
nodes = node_map(graph)
fields = input_fields(graph)
humans = {nid: n for nid, n in nodes.items() if n["type"] == "human"}
# 写外部系统的调用工具节点:和运行时同一个认法(pins.write_nodes),只有它们开发送确认卡、结果未知时要人核实
caps = await write_nodes(session, graph)
writes = {nid: nodes[nid] for nid in caps}
lookups = all(getattr(cap, "lookup", None) for cap in caps.values())
process = bool(humans or writes)
spec: dict[str, Any] = {
"format": APP_FORMAT, "name": name or title, "description": description,
"icon": "app",
"pins": [{**pin, "use": "process" if process else "view"}],
"slots": {}, "entries": [], "forms": {}, "task_ui": {}, "views": {}, "pages": [], "hooks": [], "notify": [],
}
for ref in slot_refs(graph, writes):
spec["slots"].setdefault(ref["slot"], {"label": ref["slot"]})
for nid, node in humans.items():
spec["task_ui"][nid] = {"title": node["title"]}
if writes and not lookups:
spec["slots"][RECONCILER] = {"label": "动作核实"}
if fields:
spec["forms"][ENTRY] = input_schema(fields)
names = list(spec["forms"][ENTRY]["properties"])
first = next((n for n in names if n in spec["forms"][ENTRY]["required"]), names[0] if names else None)
spec["entries"].append({
"id": ENTRY, "label": f"发起{title}" if process else "按条件运行", "pin": PIN,
"form": {"$ref": f"#/forms/{ENTRY}"}, "map": {n: f"form.{n}" for n in names},
**({"instance_title": {"pattern": f"{title}·{{{first}}}", "fields": [first]}}
if first and re.fullmatch(r"[A-Za-z0-9_㐀-鿿]+", first) else {}),
})
if not process:
spec["views"][VIEW] = {"source": {"kind": "latest_sealed", "pin": PIN,
"from": f"schedule:{schedule_id}" if schedule_id else "formal"}}
if spec["entries"]:
spec["views"][INSTANCE_VIEW] = {"source": {"kind": "instance"}}
spec["pages"].append({"id": "home", "title": title,
"body": ([] if process else _results(nodes, VIEW, "home")) + _entries(spec)})
if spec["entries"]:
spec["pages"].append({"id": "instance", "title": "实例", "body": [
{"id": "inst_header", "type": "instance_header"},
{"id": "inst_timeline", "type": "instance_timeline"},
*[c for nid in humans for c in (
{"id": f"task_{_safe(nid)}", "type": "task_form", "bind": {"task": nid}},
{"id": f"signoffs_{_safe(nid)}", "type": "signoffs", "bind": {"task": nid}})],
*[{"id": f"action_{_safe(nid)}", "type": "action_status", "props": {"label": node["title"][:200]},
"bind": {"action": nid}} for nid, node in writes.items()],
*_results(nodes, INSTANCE_VIEW, "inst"),
]})
# 形状对不上是派生规则的错,不是用户的:这里直接让它报出来
return parse_spec(spec).model_dump(mode="json", by_alias=True)
def _results(nodes: dict[str, dict[str, Any]], view: str, prefix: str) -> list[dict[str, Any]]:
"""看结果的那几块:印章、口径卡的指标(分栏)、报告、取数节点的表格。每一块怎么绑定见 _metric_block 等,页面编辑的
「加一张数据卡片」(data_candidates)用的是同一份规则。"""
body: list[dict[str, Any]] = [{"id": f"{prefix}_stamp", "type": "stamp", "bind": {"view": view}}]
for nid, node in nodes.items():
metrics = _card_metrics(node)
if not metrics:
continue
body.append({"id": f"{prefix}_kpis_{_safe(nid)}", "type": "section", "props": {"title": node["title"][:200]},
"children": [{"id": f"{prefix}_cols_{_safe(nid)}", "type": "columns",
"props": {"count": min(len(metrics), 4)},
"children": [_metric_block(nid, m, view, prefix) for m in metrics]}]})
for nid, node in nodes.items():
if node["type"] == "report":
body.append(_report_block(nid, node, view, prefix))
for nid, node in nodes.items():
if _query_node(node):
body.append(_table_block(nid, node, view, prefix))
return body
def _card_metrics(node: dict[str, Any]) -> list[dict[str, Any]]:
"""口径卡上声明了 id 的指标;不是口径卡的是空的。"""
if node["type"] != "metrics":
return []
return [m for m in node["config"].get("metrics") or [] if isinstance(m, dict) and m.get("id")]
def _query_node(node: dict[str, Any]) -> bool:
"""取数节点:调用工具节点绑的是数据源查询。"""
return node["type"] == "tool" and str(node["config"].get("tool") or "").startswith("db_query__")
def _metric_block(nid: str, metric: dict[str, Any], view: str, prefix: str) -> dict[str, Any]:
return {"id": f"{prefix}_m_{_safe(nid)}_{_safe(str(metric['id']))}", "type": "metric",
"props": {"label": str(metric.get("name") or metric["id"])[:200],
**({"unit": str(metric["unit"])[:200]} if metric.get("unit") else {})},
"bind": {"view": view, "metric": f"{nid}.{metric['id']}"}}
def _series_block(nid: str, metric: dict[str, Any], view: str, prefix: str) -> dict[str, Any]:
return {"id": f"{prefix}_s_{_safe(nid)}_{_safe(str(metric['id']))}", "type": "series",
"props": {"label": str(metric.get("name") or metric["id"])[:200]},
"bind": {"view": view, "series": f"{nid}.{metric['id']}"}}
def _report_block(nid: str, node: dict[str, Any], view: str, prefix: str) -> dict[str, Any]:
return {"id": f"{prefix}_report_{_safe(nid)}", "type": "report", "props": {"title": node["title"][:200]},
"bind": {"view": view, "report": nid}}
def _table_block(nid: str, node: dict[str, Any], view: str, prefix: str) -> dict[str, Any]:
return {"id": f"{prefix}_table_{_safe(nid)}", "type": "table", "props": {"title": node["title"][:200]},
"bind": {"view": view, "query": {"node": nid}}}
def data_candidates(nodes: dict[str, dict[str, Any]], view: str, prefix: str) -> list[dict[str, Any]]:
"""页面编辑「加一张数据卡片」能加的(docs/design/normal-user-apps.md N6):[{kind, label, group, component}],
component 可以直接交给 app_add。规则和派生的结果块(_results)同一份——口径卡的每个指标、报告、取数节点的表格——
另加每个指标的趋势(派生不放趋势:新应用只有一期数)。group 是出自哪个节点(口径卡、报告、取数的标题)。"""
out: list[dict[str, Any]] = []
for nid, node in nodes.items():
for metric in _card_metrics(node):
name = str(metric.get("name") or metric["id"])[:200]
out.append({"kind": "metric", "label": name, "group": node["title"][:200],
"component": _metric_block(nid, metric, view, prefix)})
out.append({"kind": "series", "label": f"{name}的趋势", "group": node["title"][:200],
"component": _series_block(nid, metric, view, prefix)})
for nid, node in nodes.items():
if node["type"] == "report":
out.append({"kind": "report", "label": node["title"][:200], "group": node["title"][:200],
"component": _report_block(nid, node, view, prefix)})
for nid, node in nodes.items():
if _query_node(node):
out.append({"kind": "table", "label": node["title"][:200], "group": node["title"][:200],
"component": _table_block(nid, node, view, prefix)})
return out
def _entries(spec: dict[str, Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for entry in spec["entries"]:
out.append({"id": f"btn_{_safe(entry['id'])}", "type": "entry_button", "props": {"entry": entry["id"]}})
out.append({"id": f"list_{_safe(entry['id'])}", "type": "instance_list",
"props": {"entry": entry["id"], "who": "mine"}})
return out