应用平台集成(后端应用与流程):身份断言 sub 为发起人、确认人进 act;应用助手对话(取或建、带历史)与助手润色只能改呈现;按新口径补算(只对无副作用的视图、加锁、同一输入只补一次);钩子按名字关联、入站按发布版声明认;应用详情只对 manage 给草稿与岗位绑定;删工作流的阻挡算上已退役版本;待办恢复失败退回待处理;调用工具节点作证核对快照归属;子图固定版本须已发布且未退役(发起与发布检查都查);视图里端点快照按能力的遮罩字段遮(含统一复核后的修复)

This commit is contained in:
ThearchyHelios
2026-10-14 21:55:51 +08:00
parent acb9ae1774
commit d67349562a
30 changed files with 2318 additions and 215 deletions
+188 -79
View File
@@ -7,9 +7,9 @@
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import secrets
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query
@@ -115,11 +115,38 @@ async def _summary(session: AsyncSession, app: App, principal: Principal, role:
"updated_at": app.updated_at.isoformat() if app.updated_at else None}
async def _detail(session: AsyncSession, app: App, principal: Principal, role: str | None) -> dict[str, Any]:
def _published_outline(version: AppVersion | None) -> dict[str, Any] | None:
"""当前发布版里使用面要的那一点:有哪些页面、哪些入口(名字和标题)。页面内容走页面解析,表单走页面的 forms。"""
if version is None:
return None
spec = version.spec or {}
return {"version": version.version,
"pages": [{"id": p.get("id"), "title": p.get("title") or ""} for p in spec.get("pages") or []
if isinstance(p, dict) and p.get("id")],
"entries": [{"id": e.get("id"), "label": e.get("label") or ""} for e in spec.get("entries") or []
if isinstance(e, dict) and e.get("id")]}
async def _detail(session: AsyncSession, app: App, principal: Principal, role: str | None, *,
with_backfill: bool = False) -> dict[str, Any]:
"""应用详情。草稿和草稿里的岗位绑定只给 manage(D-m):草稿是还没发布、没过检查的东西,岗位绑定是这个部署里具体
的人和组;use / observe 只拿概况和当前发布版的页面、入口目录(published)。
with_backfill:另给管理者列出能按新口径补算的视图、各会发起几次(D-f)。要逐期查封存,只在打开详情时算;保存草稿、
归档这些改动的回包里不算(编辑器存一次草稿就算一遍太浪费,补算只看发布版,草稿改了也不变)。"""
from app.apps.spec import empty_spec
return {**await _summary(session, app, principal, role), "draft": app.draft or empty_spec(app.name),
"draft_bindings": {"slots": {}, **(app.draft_bindings or {})}, "created_by": app.created_by}
out = {**await _summary(session, app, principal, role),
"published": _published_outline(await _previous(session, app))}
if not at_least(role, "manage"):
return out
out.update(draft=app.draft or empty_spec(app.name), draft_bindings={"slots": {}, **(app.draft_bindings or {})},
created_by=app.created_by)
if with_backfill:
from app.apps.backfill import backfillable
out["backfill"] = await backfillable(session, app)
return out
# --------------------------------------------------------------------------
@@ -191,7 +218,7 @@ async def get_app(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
principal = _principal(x_actor)
app, role = await _app(session, app_id, principal, "use")
return await _detail(session, app, principal, role)
return await _detail(session, app, principal, role, with_backfill=True)
class AppPatch(BaseModel):
@@ -253,6 +280,49 @@ async def archive_app(app_id: str, session: AsyncSession = Depends(get_session),
return await _detail(session, app, principal, role)
#: (应用 id, 人) → 取或建助手对话的锁。只增不减:编辑器打开过的应用 × 人
_ASSISTANT_LOCKS: dict[tuple[str, str | None], asyncio.Lock] = {}
def _assistant_lock(app_id: str, who: str | None) -> asyncio.Lock:
return _ASSISTANT_LOCKS.setdefault((app_id, who), asyncio.Lock())
@router.get("/{app_id}/assistant-conversation")
async def assistant_conversation(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""应用编辑器里助手的那段对话:没有就建(D-e)。kind=app、归这个应用、建立人是现在这个人——对话按人隔离,同一个应用
几个人轮流改,各自的历史不串。编辑器每轮落一条 turn(问题、说明、meta.looks),润色接口按 conversation_id 带历史。"""
from app.api.conversations import _detail
from app.db.models import Conversation, ConversationTurn
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
who = actor_of(x_actor)
mine = Conversation.created_by == who if who else Conversation.created_by.is_(None)
lookup = (select(Conversation).where(Conversation.kind == "app", Conversation.app_id == app.id,
Conversation.archived.is_(False), mine)
.order_by(Conversation.last_active_at.desc()).limit(1))
# 取或建在进程里按 (应用, 人) 串行:编辑器开发模式下 effect 执行两次、同一个人开两个标签页,都会同时来取。库里没有
# 唯一约束,建完再查一遍:别处(另一个进程)抢先建了的,删掉刚建的这段(还没有轮次),用那一段——历史才不会被拆开
async with _assistant_lock(app.id, who):
row = await session.scalar(lookup)
if row is None:
row = Conversation(kind="app", app_id=app.id, created_by=who, title=f"「{app.name}」的助手")
session.add(row)
await session.flush()
other = await session.scalar(lookup.where(Conversation.id != row.id))
if other is not None:
await session.delete(row)
row = other
await session.commit()
await session.refresh(row)
turns = list((await session.execute(select(ConversationTurn).where(
ConversationTurn.conversation_id == row.id).order_by(ConversationTurn.seq))).scalars())
# 形状同 GET /api/conversations/{id}(ConversationDetail):编辑器拿它渲染历史、接着落轮次
return {**_detail(row, turns).model_dump(mode="json"), "turn_count": len(turns),
"last_question": turns[-1].question if turns else ""}
# --------------------------------------------------------------------------
# 检查与发布
# --------------------------------------------------------------------------
@@ -420,111 +490,109 @@ async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Dep
# --------------------------------------------------------------------------
# 入站钩子的管理(收请求在 /api/hooks/{hook_id},BE-2)
# 入站钩子的管理(收请求在 /api/hooks/{hook_id},process/hooks.py)
#
# 按名字认(D-g):spec.hooks 声明 {name, kind, entry},app_hooks 按 (应用, 名字) 一行,只存密钥和启用与否。钩子做
# 什么以当前发布版的声明为准:改草稿不改正在用的钩子,发布版里没声明的拒收。密钥只在生成、轮换时交回一次
# --------------------------------------------------------------------------
def _hook_out(h: AppHook, secret: str | None = None) -> dict[str, Any]:
return {"id": h.id, "name": h.name, "kind": h.kind, "entry_id": h.entry_id, "enabled": h.enabled,
"created_at": h.created_at.isoformat() if h.created_at else None, **({"secret": secret} if secret else {})}
class HookIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
kind: str
entry_id: str | None = None
class HookPatch(BaseModel):
enabled: bool | None = None
entry_id: str | None = None
enabled: bool
async def _hook_state(session: AsyncSession, app: App) -> tuple[dict[str, Any], dict[str, Any], dict[str, AppHook]]:
"""(草稿里的声明, 当前发布版里的声明, 已有的行),都按名字。"""
from app.process.hooks import declarations
version = await _previous(session, app)
rows: dict[str, AppHook] = {}
for row in (await session.execute(select(AppHook).where(AppHook.app_id == app.id)
.order_by(AppHook.created_at))).scalars():
rows.setdefault(row.name, row)
return declarations(app.draft or {}), declarations(version.spec if version is not None else {}), rows
def _hook_item(name: str, draft: dict[str, Any], published: dict[str, Any], rows: dict[str, AppHook],
secret: str | None = None) -> dict[str, Any]:
from app.process.hooks import hook_out
return hook_out(rows.get(name), name=name, declared=draft.get(name) or published.get(name),
in_draft=name in draft, in_published=name in published, secret=secret)
@router.get("/{app_id}/hooks")
async def list_hooks(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""草稿里声明的 ∪ 已经有行的:每个名字一行,标出密钥生成过没有、当前发布版里有没有(没有的收不到请求)。"""
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
rows = (await session.execute(select(AppHook).where(AppHook.app_id == app.id).order_by(AppHook.created_at))).scalars()
return {"items": [_hook_out(h) for h in rows]}
draft, published, rows = await _hook_state(session, app)
names = list(dict.fromkeys([*draft, *published, *rows]))
return {"items": [_hook_item(n, draft, published, rows) for n in names]}
@router.post("/{app_id}/hooks", status_code=201)
async def create_hook(app_id: str, payload: HookIn, session: AsyncSession = Depends(get_session),
@router.post("/{app_id}/hooks/{name}/rotate")
async def rotate_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""建一个入站钩子。签名密钥只在这里交回一次(库里存密文),之后要换就轮换。"""
"""生成或轮换这个名字的签名密钥,密钥只在这里交回一次(库里存密文)。名字要在草稿或当前发布版里声明过。"""
from app.apps.audit import record
from app.core.crypto import encrypt
from app.process import hooks
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
if payload.kind not in ("start", "signal"):
raise HTTPException(422, "钩子只能是发起实例或回传信号两种")
if payload.kind == "start" and not payload.entry_id:
raise HTTPException(422, "发起实例的钩子需要指定入口")
secret = secrets.token_urlsafe(32)
hook = AppHook(app_id=app.id, name=payload.name, kind=payload.kind, entry_id=payload.entry_id,
secret=encrypt(secret) or "")
session.add(hook)
await session.flush()
await record(session, action="app.hook_create", subject_kind="app_hook", subject_id=hook.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": hook.name, "kind": hook.kind,
"entry_id": hook.entry_id})
await session.commit()
await session.refresh(hook)
return _hook_out(hook, secret)
draft, published, rows = await _hook_state(session, app)
declared = draft.get(name) or published.get(name)
if declared is None:
raise HTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它")
# 从读行锁到提交:两位管理者同时点「生成密钥」,后到的那次读得到前一次插入的行,只轮换它(见 process/hooks.py)
async with hooks.hook_lock(app.id, name):
row, secret, created = await hooks.rotate_hook(session, app.id, name, kind=declared["kind"],
entry=declared.get("entry"))
await record(session, action="app.hook_create" if created else "app.hook_rotate", subject_kind="app_hook",
subject_id=row.id, actor=actor_of(x_actor),
data={"app_id": app.id, "name": name, "kind": declared["kind"], "entry": declared.get("entry")})
await session.commit()
await session.refresh(row)
return _hook_item(name, draft, published, {**rows, name: row}, secret)
async def _hook(session: AsyncSession, app: App, hook_id: str) -> AppHook:
hook = await session.get(AppHook, hook_id)
if hook is None or hook.app_id != app.id:
raise HTTPException(404, "钩子不存在")
return hook
async def _hook_named(session: AsyncSession, app: App, name: str) -> AppHook:
from app.process.hooks import hook_row
row = await hook_row(session, app.id, name)
if row is None:
raise HTTPException(404, f"钩子「{name}」还没有生成密钥")
return row
@router.patch("/{app_id}/hooks/{hook_id}")
async def update_hook(app_id: str, hook_id: str, payload: HookPatch, session: AsyncSession = Depends(get_session),
@router.patch("/{app_id}/hooks/{name}")
async def update_hook(app_id: str, name: str, payload: HookPatch, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""启用、停用。停用的钩子收到请求一律拒收,密钥留着,重新启用就能接着用。"""
from app.apps.audit import record
from app.process.hooks import set_enabled
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
hook = await _hook(session, app, hook_id)
if payload.enabled is not None:
hook.enabled = payload.enabled
if payload.entry_id is not None:
hook.entry_id = payload.entry_id or None
await record(session, action="app.hook_update", subject_kind="app_hook", subject_id=hook.id,
actor=actor_of(x_actor), data=payload.model_dump(exclude_none=True))
row = await _hook_named(session, app, name)
await set_enabled(session, app.id, name, payload.enabled)
await record(session, action="app.hook_update", subject_kind="app_hook", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": name, "enabled": payload.enabled})
await session.commit()
await session.refresh(hook)
return _hook_out(hook)
draft, published, rows = await _hook_state(session, app)
return _hook_item(name, draft, published, rows)
@router.post("/{app_id}/hooks/{hook_id}/rotate")
async def rotate_hook(app_id: str, hook_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
from app.apps.audit import record
from app.core.crypto import encrypt
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
hook = await _hook(session, app, hook_id)
secret = secrets.token_urlsafe(32)
hook.secret = encrypt(secret) or ""
await record(session, action="app.hook_rotate", subject_kind="app_hook", subject_id=hook.id,
actor=actor_of(x_actor), data={"app_id": app.id})
await session.commit()
await session.refresh(hook)
return _hook_out(hook, secret)
@router.delete("/{app_id}/hooks/{hook_id}", status_code=204)
async def delete_hook(app_id: str, hook_id: str, session: AsyncSession = Depends(get_session),
@router.delete("/{app_id}/hooks/{name}", status_code=204)
async def delete_hook(app_id: str, name: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> None:
"""删掉这个名字的密钥和去重记录。声明留在草稿里(要去掉在编辑器里删);再生成就是一把新密钥、一个新地址。"""
from app.apps.audit import record
from app.process import hooks
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
hook = await _hook(session, app, hook_id)
await record(session, action="app.hook_delete", subject_kind="app_hook", subject_id=hook.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": hook.name})
await session.delete(hook)
row = await _hook_named(session, app, name)
await record(session, action="app.hook_delete", subject_kind="app_hook", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "name": name})
await hooks.delete_hook(session, app.id, name)
await session.commit()
@@ -670,3 +738,44 @@ async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: A
except (ViewRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "cached": cached}
# --------------------------------------------------------------------------
# 按新口径补算(D-f)
# --------------------------------------------------------------------------
@router.get("/{app_id}/views/{view_id}/backfill")
async def backfill_plan(app_id: str, view_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""补算会发起几次、跳过几次(不发起运行):按钮上写明会发起几次运行用。"""
from app.apps.backfill import BackfillRefused, plan
app, _ = await _app(session, app_id, _principal(x_actor), "manage")
version = await _current_version(session, app)
try:
return (await plan(session, app, version, view_id)).out()
except BackfillRefused as e:
raise _view_refused(e) from e
@router.post("/{app_id}/views/{view_id}/backfill")
async def backfill(app_id: str, view_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""按新口径补算:这个视图的钉住项最近一次升版选了 recompute 时,取旧版本在同一来源上最近几期的输入,用新版本逐个
发起正式运行(来源 app_backfill);新版本已经算过同一个输入的跳过。返回发起了几次、跳过几次。"""
from app.apps.backfill import BackfillRefused, run_backfill
from app.apps.resolve import clear_cache
from app.engine.launch import LaunchRefused
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "manage")
version = await _current_version(session, app)
try:
out = await run_backfill(session, app, version, view_id, principal=principal, actor=actor_of(x_actor))
except BackfillRefused as e:
raise _view_refused(e) from e
except LaunchRefused as e:
raise _view_refused(e) from e
clear_cache()
return out
+104 -14
View File
@@ -74,7 +74,7 @@ def _scope_note(rows: list[Any], scoped: bool) -> str:
"db_schema__ 工具;前几轮用过别的库,这一轮也不要沿用")
def _tool_catalog(rows: list[Any]) -> str:
def _tool_catalog(rows: list[Any], *, builtins: frozenset[str] | None = None) -> str:
"""给助手看的工具清单。
all_specs() 只有静态注册的内置工具,数据源工具是运行时按库动态生成的
@@ -82,10 +82,13 @@ def _tool_catalog(rows: list[Any]) -> str:
自己写 SQL 的代码节点——接了数据库等于白接。
不列表名(look 文档 D3):以前每个库列前 12 个表名,和这一轮要做什么无关,闲聊也带着;表数写一句,字段要用时 look。
builtins 给了的话内置工具只列这几个:使用者的问数据(_CHAT_USER_PROMPT)只能用只读的那几个,列出来用不了的工具,
模型照样会绑。
"""
lines = [
f"- {name}({spec.category}):{spec.description}"
for name, spec in sorted(all_specs().items())
for name, spec in sorted(all_specs().items()) if builtins is None or name in builtins
]
from app.data import introspect as _introspect
@@ -429,6 +432,14 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str:
return f"请设计一个工作流:{payload.instruction}"
async def _ensure_conversation_access(session: AsyncSession, conversation_id: str | None) -> Any | None:
"""请求带的 conversation_id 这个人能不能用(对话按人隔离,D-d):规则只有 api/deps.ensure_conversation_access 一处。
能用交回对话;没给、不存在交回 None(当没有历史);不能用 403——别人的问答不能经「历史」拼进这一轮的提示。"""
from app.api.deps import ensure_conversation_access
return await ensure_conversation_access(session, conversation_id)
async def _previous_graph(
session: AsyncSession, conversation_id: str | None
) -> dict[str, Any] | None:
@@ -741,24 +752,67 @@ GRAPH_SCHEMA: dict[str, Any] = {
}
def _graph_system(sources: list[Any], scope: set[str] | None, datasources: str, looks: Any) -> str:
def _graph_system(sources: list[Any], scope: set[str] | None, datasources: str, looks: Any, *,
chat_user: bool = False) -> str:
"""画布、问数据(流式和非流式)共用的 system:节点手册、工具清单、数据源概览、结构要求、输出协议、查看的用法。
这一份一直用到自查修正结束(接着写、修正轮都不换),模型前后看到的是同一套说法。"""
这一份一直用到自查修正结束(接着写、修正轮都不换),模型前后看到的是同一套说法。
chat_user:开登录后 user 角色的问数据(D-c)。内置工具只列只读的那几个,结构要求后面再加一段使用者的约束。"""
from app.auth.chat_policy import CHAT_BUILTIN_TOOLS
return (
"你是一个 agent 工作流编排专家。根据用户需求,以操作流的方式逐步搭出一张可执行的工作流图。\n\n"
f"{NODE_REFERENCE}\n\n可用工具:\n{_tool_catalog(sources)}"
f"{NODE_REFERENCE}\n\n可用工具:\n{_tool_catalog(sources, builtins=CHAT_BUILTIN_TOOLS if chat_user else None)}"
f"{datasources}{_scope_note(sources, scope is not None)}\n\n"
"结构要求:\n"
"1. 必须有且只有一个 input 节点和至少一个 output 节点\n"
"2. 节点 id 用简短英文小写下划线;label 用中文,一眼看懂\n"
"3. 用 assign_to 传结果,下游 {{ vars.变量名 }} 引用\n"
"4. 只能用上面列出的工具名\n"
"5. 能用 3 个节点解决就别堆 8 个\n\n"
"5. 能用 3 个节点解决就别堆 8 个"
f"{_CHAT_USER_PROMPT if chat_user else ''}\n\n"
f"{_STREAM_PROTOCOL}"
+ (f"\n\n{looks.guide()}\n{copilot_context.LOOK_WHEN_PROMPT}" if looks.kinds else "")
)
def _chat_user_kinds() -> str:
from app.auth.chat_policy import CHAT_NODE_TYPES
return "、".join(sorted(CHAT_NODE_TYPES - {"merge"}))
#: 开登录后 user 角色在问数据里生成的图(D-c)要守的约束,接在结构要求后面(_graph_system 的 chat_user)。发起时按
#: app/auth/chat_policy.py 收窄,节点手册里取数的标准写法(tool 节点查库、merge 按键合并)搭出来的图发起时一律被拒,所以先
#: 说给模型听。规则本身只在 chat_policy 一处:节点类型从那里取,自查也按它判(_chat_user_issues),这里只是把它讲成模型
#: 照着搭得出来的写法
_CHAT_USER_PROMPT = (
"\n\n这位用户是「使用者」:这张图以他的身份运行,只能读数据、整理、撰写。下面几条优先于上面节点手册和搭图规则里的"
"写法:\n"
f"1. 节点只用 {_chat_user_kinds()}。不要用 tool、code、merge、supervisor、subgraph、loop、human、memory、validate 等"
"其他节点(merge 的输入只能是 tool 节点,所以同样用不了)\n"
"2. 取数只用 agent:tools 里绑定 db_query__<数据源> 和 db_schema__<数据源>。数字要进口径卡、报告时,给这个 agent 配 "
"output_schema 和 cite_fields: true,口径卡取 vars.<agent 的 assign_to>.字段。数据在两个库时,让一个 agent 绑两个库的"
"查询工具,各自聚合后在 output_schema 里一起交回\n"
"3. 查询一律只读,不要写改数据的 SQL\n"
"4. 任何节点、图级 defaults 里都不要写 memory_scope、collection:记忆范围和知识库由平台定"
)
def _chat_user_issues(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""这张图使用者在问数据里能不能发起(app/auth/chat_policy.py,和 POST /api/runs 同一个判断):不能的话是一条挡运行的
问题,交回模型按 _CHAT_USER_PROMPT 改;改不好照实列在交付的问题里,不自动运行。"""
from app.auth.chat_policy import chat_graph_problem
problem = chat_graph_problem({"nodes": list(nodes.values()), "edges": edges})
if not problem:
return []
return [{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": "chat.not_allowed",
"message": problem,
"for_model": f"{problem}。按 system 里「使用者」那一段的约束改:取数改成 agent 绑定数据源查询工具,"
"去掉不允许的节点、工具和记忆范围 / 知识库配置"}]
def _graph_request(payload: GenerateIn) -> str:
"""这一轮的请求:改现有图时附上原图、只要改动操作;新建时按入口包好(_user_message)。"""
if payload.base_graph and payload.base_graph.get("nodes"):
@@ -791,6 +845,7 @@ async def generate(
"""
from app.data.sqlcheck import load_checkers
await _ensure_conversation_access(session, payload.conversation_id)
sources = await _sources(session, payload.datasource_ids)
scope = {r.name for r in sources} if payload.datasource_ids else None
# 先拿模型:没配好就直接 400,不白读一遍目录
@@ -1020,10 +1075,12 @@ def _blocking_issues(
nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], scope: set[str] | None = None,
*, sources: list[Any] | None = None, baseline: list[dict[str, Any]] | None = None,
level: str | None = None, defaults: dict[str, Any] | None = None,
checkers: dict[str, Any] | None = None,
checkers: dict[str, Any] | None = None, chat_user: bool = False,
) -> list[dict[str, Any]] | None:
"""按运行时同一套校验,挑出会挡住运行的问题。图本身不成形时返回 None。
chat_user:图要以开登录后 user 角色的身份在问数据里发起(D-c),使用者不能用的节点、工具、配置也挡(_chat_user_issues)。
限定了数据源时,用了范围外的库也算:用户点了「只查这个库」,拿别的库的数
回答他,比跑不起来更糟——答案看起来是对的。
@@ -1051,7 +1108,8 @@ def _blocking_issues(
authored = authored_issues(spec, sources or [], baseline, checkers=None if level else checkers)
out = ([i.model_dump() for i in validate_graph(spec).issues if i.level == "error"]
+ _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope")
+ [i for i in authored if i["level"] == "error"])
+ [i for i in authored if i["level"] == "error"]
+ (_chat_user_issues(nodes, edges) if chat_user else []))
if level:
from app.engine.governance import lint_for_publish
@@ -1630,6 +1688,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
from app.data.sqlcheck import load_checkers
from app.api.deps import restricted
# 带了对话的,先核对这个人能不能用它(D-d):历史、上一轮的图都从它来
await _ensure_conversation_access(session, payload.conversation_id)
# 开登录后的 user 角色(D-c):图只能用 chat_policy 放行的节点和工具,提示词先说、自查按同一个判断挡
chat_user = restricted()
sources = await _sources(session, payload.datasource_ids)
scope = {r.name for r in sources} if payload.datasource_ids else None
# 基于数据目录的 SQL 检查器:在开流之前读好目录(流里不再碰这个会话),自查和收尾共用
@@ -1653,11 +1717,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
looks = await _look_service(session, payload, sources, graph=payload.base_graph if (
payload.base_graph or {}).get("nodes") else prev_graph)
# 这一份 system 一直用到最后:接着写、自查修正都沿用,不换
system = _graph_system(sources, scope, await copilot_context.section(looks), looks)
system = _graph_system(sources, scope, await copilot_context.section(looks), looks, chat_user=chat_user)
applier = GraphApplier(nodes, edges)
started = time.monotonic()
gen = Generation(model, system=system, applier=applier, looks=looks, started=started,
checker=GraphChecker(applier, scope=scope, sources=sources, baseline=baseline, checkers=checkers))
checker=GraphChecker(applier, scope=scope, sources=sources, baseline=baseline, checkers=checkers,
chat_user=chat_user))
async def event_stream():
yield _sse({"op": "model", "model": model_id})
@@ -1712,7 +1777,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
# 循环 / 分支条件里套了 {{ }}),前面的步骤白跑,报错还停在半路
async for op in _with_heartbeat(gen.repair(), phase="repairing"):
yield _sse(op)
remaining = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline, checkers=checkers)
remaining = _blocking_issues(nodes, edges, scope, sources=sources, baseline=baseline, checkers=checkers,
chat_user=chat_user)
# 工具绑定变化不挡运行,但要在自查这一步就说出来:工具被改没了的图照样
# 能跑,只是跑出来的是模型「假设」查过库的答案
changes = tool_changes(baseline, list(nodes.values()))
@@ -1743,6 +1809,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
authored = authored_issues(spec, sources, baseline, checkers=checkers)
issues += [{**i, "level": "warning"} for i in authored if i["code"] == "sql_unknown_column"]
issues += [i for i in authored if i["code"] in CHECK_CODES]
# 使用者发起不了的图(D-c):自查没改好的照实列出,界面据此不运行、说清原因
issues += _chat_user_issues(nodes, edges) if chat_user else []
# 跳过的节点要说出来,不能安静地少一步。code 给前端认:这一类要单独
# 提示「少了一步」,不能和普通校验警告混在一起
issues += [
@@ -2201,7 +2269,8 @@ APP_SYSTEM_PROMPT = """\
文字组件里不要写数字,也不要拼算式——数字只来自绑定,界面层不做任何计算
- 可见性表达式(visible)只能用 run、input、metric、instance、task、user、view 这几个根名字,只能比较、取值、in 和
and / or / not,不能写算术
- 不改钉住的版本(pins),不改入口表单的数据约束(类型、必填、枚举、取值范围),只改外观
- 只改呈现:不改钉住的版本(pins)、入口(entries)、视图(views)、岗位(slots),不改表单的数据约束(字段、类型、
必填、枚举、取值范围);表单只改已有表单的 title、description、x-order、x-widget、x-enum-labels。越界的操作不会被采纳
- 岗位(slots)绑定到哪些人或组只能由人来做:岗位留着,不要替人绑定;done 的说明里提醒还有哪些岗位等着绑定
- 能按常理定的直接改,必须由用户拿主意的才 ask"""
@@ -2215,7 +2284,7 @@ APP_PROTOCOL_PROMPT = """\
{"op":"app_remove","id":"组件 id"}
{"op":"app_move","id":"组件 id","parent":"组件 id 或页面 id","index":0}
{"op":"app_page","page":{"id":"…","title":"…"}} ← 新增页面,或改已有页面的标题
{"op":"app_set","path":"entries|views|forms|slots|task_ui","key":"…","value":{}}
{"op":"app_set","path":"forms|task_ui","key":"…","value":{}} ← forms:已有表单的整份新写法,只改外观键;task_ui:按人工节点改待办外观
{"op":"done","explanation":"两三句话说明改了什么,还有哪些岗位等着绑定"} ← 最后一行
- 组件 id 用简短英文小写下划线,同一个应用里不重复;已有的组件用 app_update 改,不要删了重建
- 这句话不需要改应用(只是问个问题)时,第一行也是最后一行输出 {"op":"reply","text":"直接回答的内容"}
@@ -2278,6 +2347,18 @@ async def _derived_drafts(session: AsyncSession, pins: list[dict[str, Any]]) ->
return APP_DERIVED_PROMPT.format(drafts="\n".join(drafts)) if drafts else ""
async def _require_app_manage(session: AsyncSession, app: Any) -> None:
"""应用润色要这个应用的「管理」权限(同编辑草稿,§3.12):搭建者不是创建者、也没有管理授权的,只能看不能改。"""
from app.api.deps import current_principal
from app.apps.access import app_role, at_least
role = await app_role(session, current_principal(), app)
if role is None:
raise HTTPException(404, "应用不存在,可能已被删除")
if not at_least(role, "manage"):
raise HTTPException(403, "需要这个应用的「管理」权限")
@router.post("/app-stream")
async def app_stream(payload: AppStreamIn, session: AsyncSession = Depends(get_session)):
"""应用润色:SSE 推送,形状同 /generate-stream。
@@ -2299,6 +2380,12 @@ async def app_stream(payload: AppStreamIn, session: AsyncSession = Depends(get_s
row = await session.get(App, payload.app_id) if payload.app_id else None
if payload.app_id and row is None:
raise HTTPException(404, "应用不存在,可能已被删除")
if row is not None:
await _require_app_manage(session, row)
conversation = await _ensure_conversation_access(session, payload.conversation_id)
if conversation is not None and (conversation.kind != "app" or row is None or conversation.app_id != row.id):
# 应用助手的历史只取这个应用自己的那段对话:拿问数据、画布、别的应用的对话来拼,模型看到的「上一轮」是别处的事
raise HTTPException(409, "这段对话不是这个应用的助手对话,请从应用编辑器里重新打开助手")
spec = copy.deepcopy(payload.spec if payload.spec is not None else (row.draft if row is not None else None))
if not spec:
raise HTTPException(400, "没有可润色的应用草稿。请先从工作流版本生成草稿,再请助手润色")
@@ -2352,7 +2439,8 @@ async def app_stream(payload: AppStreamIn, session: AsyncSession = Depends(get_s
remaining = await checker.issues()
yield _sse({"op": "check", "status": "failed" if remaining else "passed", "repaired": gen.repaired,
**({"issues": remaining} if remaining else {})})
yield _sse({"op": "final", "spec": applier.spec, "issues": await checker.lint(),
# 越界被拒的操作照实列给人看(警告级),排在应用检查的问题前面
yield _sse({"op": "final", "spec": applier.spec, "issues": [*applier.refusals(), *await checker.lint()],
"explanation": applier.explanation})
except CopilotIncomplete as e:
yield _sse({"op": "error", "message": f"助手本轮未完成:{e}", "hint": e.hint, "detail": e.detail})
@@ -2394,6 +2482,8 @@ async def extract_template(
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
from app.core.artifact_store import graph_hash
# 对话只是记进来源:不能用的不让记(D-d),不存在的照旧记下 id
await _ensure_conversation_access(session, payload.conversation_id)
run = await session.get(Run, payload.run_id)
if not run:
raise HTTPException(404, "运行记录不存在,可能已被删除")
+36 -4
View File
@@ -365,8 +365,10 @@ class GraphChecker:
def __init__(self, applier: GraphApplier, *, scope: set[str] | None = None, sources: list[Any] | None = None,
baseline: list[dict[str, Any]] | None = None, level: str | None = None,
defaults: dict[str, Any] | None = None, checkers: dict[str, Any] | None = None,
known: set[Any] | None = None) -> None:
known: set[Any] | None = None, chat_user: bool = False) -> None:
self.applier = applier
#: 图要以开登录后 user 角色的身份在问数据里发起(D-c):使用者不能用的节点、工具也算挡路
self.chat_user = chat_user
self.scope = scope
self.sources = sources
self.baseline = baseline
@@ -380,7 +382,7 @@ class GraphChecker:
return _blocking_issues(self.applier.nodes, self.applier.edges, self.scope, sources=self.sources,
baseline=self.baseline, level=self.level, defaults=self.defaults,
checkers=self.checkers)
checkers=self.checkers, chat_user=self.chat_user)
async def issues(self) -> list[dict[str, Any]]:
errors = self.blocking()
@@ -414,7 +416,7 @@ REPAIR_LOOKS_PROMPT = "\n\n这一轮查看过的内容(和起草时看到的
APP_HUMAN_ONLY = frozenset({
"app.pin_unpublished", "app.pin_retired", "app.pin_hash", "app.upgrade_policy", "app.slot_unbound",
"app.reconciler_unbound", "app.acl_empty", "app.notify_channel_missing", "app.process_unsafe",
"app.view_source_empty",
"app.view_source_empty", "subgraph_version_unpublished", "subgraph_version_retired",
})
@@ -440,6 +442,10 @@ class AppApplier:
"""应用的落地:app/apps/ops.apply_app_op 改 spec(原地改),外加接着写时的去重。
plan、done 记下说明;reply 记下(这句话不需要改应用);图操作、目录修改提案在这里一概不收。
润色只改呈现(D-h,app/apps/ops.polish_problem):组件操作、页面、待办外观(task_ui)、表单的外观键照收;改钉住的
版本、入口、视图、岗位、表单的数据约束一律拒(Applied(False, False, problem))。拒掉的原因记在 refused 里:自查时
交回模型(AppChecker.issues),交付时列给人看(refusals),不悄悄吞掉。
"""
def __init__(self, spec: dict[str, Any]) -> None:
@@ -448,6 +454,9 @@ class AppApplier:
self.plan: dict[str, Any] | None = None
self.replied: dict[str, Any] | None = None
self.explanation = ""
#: 越界被拒的操作:[{op, path, message}],按到来的顺序
self.refused: list[dict[str, Any]] = []
self._reported = 0
self._landed: list[str] = []
self._continuing = False
self._repairing = False
@@ -472,6 +481,10 @@ class AppApplier:
return Applied(False, False)
if self._continuing and self._repeats(op):
return Applied(False, False)
if problem := app_ops.polish_problem(self.spec, op):
path = f"{op.get('path')}.{op.get('key')}" if op.get("key") else str(op.get("path") or "")
self.refused.append({"op": kind, "path": path, "message": problem})
return Applied(False, False, problem)
applied = app_ops.apply_app_op(self.spec, op)
if applied.changed:
self.effective.append(op)
@@ -500,6 +513,21 @@ class AppApplier:
def repairing(self) -> None:
self._repairing = True
def unreported(self) -> list[dict[str, Any]]:
"""还没交回过模型的越界操作,取走即算交回过:同一条不在后面几轮自查里反复交回。"""
out = self.refused[self._reported:]
self._reported = len(self.refused)
return out
def refusals(self) -> list[dict[str, Any]]:
"""交付时列给人看的:形状同应用检查的问题(警告级),说明助手想改什么、为什么没改。"""
return [{"message": f"助手想改的这一处没有采纳:{r['message']}", "level": "warning", "code": POLISH_REFUSED,
"path": r["path"] or None} for r in self.refused]
#: 助手润色越界被拒(D-h)。不是应用检查的编号:不进发布检查,只在润色交付时列出来
POLISH_REFUSED = "app.polish_refused"
class AppChecker:
"""应用的自查:app/apps/lint.lint_app 的 error,去掉只有人能处理的(APP_HUMAN_ONLY),交回模型改。"""
@@ -517,7 +545,11 @@ class AppChecker:
return list(await app_lint.lint_app(session, self.applier.spec, self.bindings))
async def issues(self) -> list[dict[str, Any]]:
return [i for i in await self.lint() if i.get("level") == "error" and i.get("code") not in APP_HUMAN_ONLY]
"""交回模型改的:应用检查的 error(去掉只有人能处理的),加上这之前越界被拒、还没交回过的操作。"""
refused = [{"message": r["message"], "level": "error", "code": POLISH_REFUSED, "path": r["path"] or None}
for r in self.applier.unreported()]
return [*refused,
*(i for i in await self.lint() if i.get("level") == "error" and i.get("code") not in APP_HUMAN_ONLY)]
def repair_request(self, issues: list[dict[str, Any]], looks_text: str) -> str:
lines = "\n".join(f"- {i.get('path') or ''} {i.get('message') or ''}".strip() for i in issues)
+35 -13
View File
@@ -15,6 +15,7 @@ from app.data.catalog_impact import catalog_drift, catalog_versions_for, recorde
from app.db.base import get_session
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers, quiet_unset_model
from app.engine.schema import GraphSpec, NodeType, validate_graph
@@ -192,13 +193,24 @@ async def delete_workflow(
async def _delete_blockers(session: AsyncSession, workflow_id: str) -> list[str]:
from app.apps.versions import effect_count, pinning_apps, published_versions
"""拒绝删除的原因:应用固定着;某个版本有生效记录(演练不算)。
生效记录按全部版本数,不只看在用的发布版:退役只是不再接受新的运行,退役之前写进外部系统的那些事照样发生过,
版本删掉了,生效记录就对不上它当时的承诺(D-n)。"""
from app.apps.versions import pinning_apps
from app.db.models import ActionRecord
reasons = [f"应用「{a['name']}」v{a['app_version']} 固定了 v{a['version']}"
for a in await pinning_apps(session, workflow_id)]
for row in await published_versions(session, workflow_id):
if count := await effect_count(session, workflow_id, row.version):
reasons.append(f"v{row.version} 有 {count} 条生效记录")
rows = (await session.execute(
select(ActionRecord.workflow_version, func.count(ActionRecord.id))
.where(ActionRecord.workflow_id == workflow_id, ActionRecord.state != "simulated")
.group_by(ActionRecord.workflow_version).order_by(ActionRecord.workflow_version))).all()
retired = set((await session.execute(select(WorkflowVersion.version).where(
WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.retired_at.is_not(None)))).scalars())
for version, count in rows:
which = f"v{version}" if version is not None else "未记版本的运行"
reasons.append(f"{which}{'(已退役)' if version in retired else ''} 有 {count} 条生效记录")
return reasons
@@ -424,8 +436,10 @@ async def publish_workflow(
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
checkers = await _sql_checkers(session, spec)
issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers,
providers=await _providers(session, spec))]
providers = await _providers(session, spec)
with with_subgraphs(await load_subgraphs(session, spec)):
issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers,
providers=providers)]
if any(i["level"] == "error" for i in issues):
return {"ok": False, "level": payload.level, "version": version, "issues": issues}
@@ -553,8 +567,11 @@ async def publish_check(
except Exception as e: # noqa: BLE001
return _unreadable(payload.level, e)
versions, cards = await _gate_context(session, spec)
return check(spec, level=payload.level, versions=versions, cards=cards, checkers=await _sql_checkers(session, spec),
providers=await _providers(session, spec))
checkers = await _sql_checkers(session, spec)
providers = await _providers(session, spec)
with with_subgraphs(await load_subgraphs(session, spec)):
return check(spec, level=payload.level, versions=versions, cards=cards, checkers=checkers,
providers=providers)
@router.post("/{workflow_id}/autofix")
@@ -580,8 +597,11 @@ async def autofix(
versions, cards = await _gate_context(session, spec)
checkers = await _sql_checkers(session, spec)
providers = await _providers(session, spec)
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards,
checkers=checkers, providers=providers)
# 和发布、发布前检查同一套口径:子工作流先读好,process.human_in_subgraph 才查得到
subgraphs = await load_subgraphs(session, spec)
with with_subgraphs(subgraphs):
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards,
checkers=checkers, providers=providers)
out["assist"] = None
# 人在选项里选了「交给 Copilot」(handoff),和点「交给 Copilot」是一回事
if payload.assist or out["handoff"]:
@@ -607,9 +627,11 @@ async def autofix(
out["ops"] += helped["ops"]
out["applied"].append("assist")
fixed_spec = GraphSpec.model_validate(fixed)
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards,
checkers=await _sql_checkers(session, fixed_spec),
providers=await _providers(session, fixed_spec))
fixed_checkers = await _sql_checkers(session, fixed_spec)
fixed_providers = await _providers(session, fixed_spec)
with with_subgraphs(await load_subgraphs(session, fixed_spec)):
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards,
checkers=fixed_checkers, providers=fixed_providers)
out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"])
elif helped["reason"]:
out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]})
+252
View File
@@ -0,0 +1,252 @@
"""按新口径补算(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。
判断沿用应用检查对流程钉住项的那一套(apps/lint._lint_process):人工节点、写外部系统的能力
(capabilities.registry.writes_externally,和运行时、发布检查同一个判断)、process.* 问题(子工作流先读好,
process.human_in_subgraph 才查得到)。"""
from app.capabilities.registry import effective_effect, get_capability, writes_externally
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec, NodeType
if state.pin.get("use") == "process":
return "是作为流程固定的"
try:
graph = GraphSpec.model_validate(state.graph)
except ValueError:
return "的图无法解析"
if humans := [n.title for n in graph.nodes if n.type == NodeType.HUMAN]:
return f"里有人工节点「{'、'.join(humans[:3])}」"
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})"
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=state.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 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) -> asyncio.Lock:
return _LOCKS.setdefault((app_id, view_id), asyncio.Lock())
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
+27 -1
View File
@@ -94,6 +94,7 @@ async def lint_app(session: AsyncSession, spec: dict[str, Any], bindings: dict[s
level = _level(pins)
_lint_unique(spec, out)
_lint_pins(spec, pins, out)
await _lint_subgraph_pins(session, spec, pins, out)
_lint_upgrade(spec, previous, dispositions or {}, out)
views = spec.get("views") or {}
await _lint_views(session, spec, pins, out)
@@ -147,6 +148,27 @@ def _lint_pins(spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None
out.error("app.pin_hash", f"引用的「{state.label}」的内容指纹和应用里记的不一致,请重新选择这一版", at)
async def _lint_subgraph_pins(session: AsyncSession, spec: dict[str, Any], pins: dict[str, Any], out: _Issues) -> None:
"""钉住的版本里,子工作流固定的版本不是「已发布且未退役」的(D-q,apps/versions.subgraph_pin_problems):各种用法都会
发起这一版的正式运行(入口、流程、按需视图、给最新封存视图供数的定时任务、补算),每一次都会在发起时被拒,所以报错。
钉住项本身不能用的(上面 _lint_pins 报过)不再往里看。"""
from app.apps.versions import subgraph_pin_problems
from app.engine.schema import GraphSpec
for i, pin in enumerate(spec.get("pins") or []):
state = pins.get(pin.get("alias"))
if state is None or state.row is None or not state.published:
continue
try:
graph = GraphSpec.model_validate(state.graph)
except ValueError:
continue
for problem in await subgraph_pin_problems(session, graph):
out.error(problem.code, f"「{state.label}」里的子工作流「{problem.title}」固定的{problem.reason}:这一版的正式运行"
"发起时会被拒绝。请在那个工作流里改好并发布新版本,再把这里的引用换成新版本", f"pins[{i}]",
node_id=problem.node_id)
def _lint_upgrade(spec: dict[str, Any], previous: Any | None, dispositions: dict[str, str], out: _Issues) -> None:
from app.engine.governance import UPGRADE_POLICY_LABELS
@@ -419,6 +441,7 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[
"""流程钉住项:岗位都绑了人、写入动作有人核实、图本身经得起升级(process.* 升成 app.process_unsafe)。"""
from app.capabilities.registry import effective_effect, get_capability
from app.engine.governance import publish_issues
from app.engine.lint_process import load_subgraphs, with_subgraphs
from app.engine.schema import GraphSpec
process = [s for s in pins.values() if s.pin.get("use") == "process" and s.row is not None]
@@ -453,7 +476,10 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[
graph = GraphSpec.model_validate(state.graph)
except ValueError:
continue
for issue in publish_issues(graph, level=state.level or "published"):
# 子工作流里有没有人工节点要读库才知道(门禁本身不查库):先读好放进来,process.human_in_subgraph 才真正生效
with with_subgraphs(await load_subgraphs(session, graph)):
issues = publish_issues(graph, level=state.level or "published")
for issue in issues:
if (issue.code or "").startswith("process."):
out.error("app.process_unsafe", f"「{state.label}」作为流程不安全:{issue.message}",
f"pins[{_pin_index(spec, state.alias)}]", node_id=issue.node_id)
+55
View File
@@ -27,6 +27,12 @@ from app.apps.tree import components
OPS = ("app_add", "app_update", "app_remove", "app_move", "app_page", "app_set")
_SET_PATHS = ("entries", "views", "forms", "slots", "task_ui")
#: 助手润色能用的操作(D-h):只改呈现。app_set 另外只放行 task_ui,和 forms 里只动外观键的改动(polish_problem)
POLISH_OPS = ("app_add", "app_update", "app_remove", "app_move", "app_page")
#: 表单里的外观键:顶层和每个字段上都只能改这几个,别的(type、required、enum、取值范围、format……)是数据约束
FORM_LOOK_KEYS = frozenset({"title", "description", "x-order", "x-widget", "x-enum-labels"})
_SET_LABEL = {"entries": "入口", "views": "视图", "slots": "岗位", "pins": "固定的版本"}
def _refuse(problem: str) -> Applied:
return Applied(False, False, problem)
@@ -248,3 +254,52 @@ def _first(e: ValidationError) -> str:
err = e.errors()[0] if e.errors() else {}
where = ".".join(str(x) for x in err.get("loc") or ())
return f"{where} {err.get('msg', '')}".strip()
# --------------------------------------------------------------------------
# 助手润色的边界(D-h)
# --------------------------------------------------------------------------
def polish_problem(spec: dict[str, Any], op: dict[str, Any]) -> str | None:
"""助手润色时这个操作有没有越界:越界返回交回模型的一句话,没越界返回 None。
润色只改呈现(布局、文案、控件,A8 第 2 条)。钉住的版本、入口、视图、岗位决定的是这个应用发起什么、读哪次运行、
派给谁,表单的数据约束决定收什么值——这些要人在编辑器里改、发布时过检查,助手改了会悄悄换掉应用的承诺。
编辑器自己调 apply_app_op 不受这一层限制。"""
kind = op.get("op") if isinstance(op, dict) else None
if kind != "app_set":
return None
path, key = op.get("path"), op.get("key")
if path == "task_ui":
return None
if path == "forms":
return _form_look_problem(spec, key, op.get("value"))
what = _SET_LABEL.get(str(path), str(path))
return (f"润色只改呈现,不能改{what}:{what}决定应用发起什么、读哪次运行、派给谁,要由人在编辑器里改。"
"请只用组件操作、待办外观(task_ui)和表单的外观键")
def _looks_stripped(form: Any) -> Any:
"""去掉外观键之后的表单:两份表单去掉外观键后一样,就只差在外观上。"""
if not isinstance(form, dict):
return form
out = {k: v for k, v in form.items() if k not in FORM_LOOK_KEYS}
props = form.get("properties")
if isinstance(props, dict):
out["properties"] = {name: ({k: v for k, v in field.items() if k not in FORM_LOOK_KEYS}
if isinstance(field, dict) else field) for name, field in props.items()}
return out
def _form_look_problem(spec: dict[str, Any], key: Any, value: Any) -> str | None:
forms = spec.get("forms") if isinstance(spec.get("forms"), dict) else {}
if not isinstance(key, str) or key not in forms:
return f"润色不能新增表单「{key}」:表单决定入口收什么值,要由人在编辑器里加"
if value is None:
return f"润色不能删掉表单「{key}」"
if _looks_stripped(forms[key]) != _looks_stripped(value):
return (f"表单「{key}」只能改外观({'、'.join(sorted(FORM_LOOK_KEYS))}),不能改字段、类型、必填、可选值、"
"取值范围这些数据约束")
return None
+126 -24
View File
@@ -32,12 +32,9 @@ from app.apps.spec import APP_ROOTS, VIEW_FORMAT
from app.apps.tree import bind_kind, page_of, resolve_form
from app.core.artifact_store import canonical_json
from app.db.base import utcnow
from app.db.models import Run, RunEvent, Schedule, ScheduleFire, Setting
from app.db.models import Run, RunEvent, Schedule, ScheduleFire
from app.db.models_apps import AppViewCache, AppVersion
#: 设置里应用这一组(api/settings.py 的 get_settings 原样合并没登记过的组):每个应用每天最多发起几次按需运行
APPS_SETTING_KEY = "apps"
DAILY_LIMIT_DEFAULT = 50
#: 趋势最多取几次封存运行
SERIES_MAX = 24
#: 表格最多下发几行(页面上只排序、不汇总;完整快照走证据接口)
@@ -45,6 +42,9 @@ TABLE_ROWS_MAX = 200
#: 进程内的页面缓存
PAGE_CACHE_SIZE = 256
#: 按新口径补算的运行来源(engine/origin.py)
BACKFILL_ORIGIN = "app_backfill"
VIEW_EMPTY = "view.source_empty"
VIEW_REFUSED = "app_view_refused"
DAILY_LIMIT = "app_view_daily_limit"
@@ -83,13 +83,18 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
n: int) -> list[Run]:
"""钉住版本(同一 graph_hash)最近 n 次成功且已封存的正式运行,新的在前。
sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。"""
sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。
按新口径补算的运行(app_backfill)不算:它们是现在才算的历史期,「最近一次」不能取到它们;趋势另经 trend_runs
按它们对应的输入期排进去。"""
from sqlalchemy import or_
from app.scheduler.auto_compare import sealed_now
if not workflow_id or not isinstance(version, int) or not graph_hash:
return []
query = select(Run).where(Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal",
Run.status == "succeeded", Run.version_hash == graph_hash)
Run.status == "succeeded", Run.version_hash == graph_hash,
or_(Run.origin_kind.is_(None), Run.origin_kind != BACKFILL_ORIGIN))
if isinstance(origin, str) and origin.startswith("schedule:"):
query = query.where(Run.schedule_id == origin[len("schedule:"):])
rows = list((await session.execute(query.order_by(Run.finished_at.desc(), Run.created_at.desc())
@@ -103,6 +108,59 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
return out
def input_key(inputs: Any) -> str:
"""一次运行的输入的指纹:补算时认「同一个输入算过没有」,趋势里认补算和正常运行是不是同一期。"""
return hashlib.sha256(canonical_json(inputs if isinstance(inputs, dict) else {}).encode()).hexdigest()
def _aware(moment: datetime | None) -> datetime | None:
if moment is None:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def period_of(run: Run) -> datetime | None:
"""这次运行对应的输入期,趋势按它排:正常运行就是它结束的时间;补算的运行是现在才算的,按它补的那一次原来的时间
(origin.period_at,补算发起时记下)。"""
if run.origin_kind == BACKFILL_ORIGIN:
raw = (run.origin or {}).get("period_at")
if isinstance(raw, str):
try:
return _aware(datetime.fromisoformat(raw))
except ValueError:
pass
return _aware(run.finished_at or run.created_at)
async def trend_runs(session: AsyncSession, app_id: str | None, workflow_id: Any, version: Any, graph_hash: Any,
origin: str | None, n: int) -> list[Run]:
"""趋势要画的那几次:同一来源最近 n 期,按输入期从早到晚。
来源本身的封存运行(sealed_runs),加上这个应用为同一来源按新口径补算的运行(app_backfill,origin.source 记着补的
是哪个来源)——补算的是新版本的点,按它补的那一期排进去(period_of)。同一个输入既有正常运行又有补算的,取正常
运行:补算只是替缺的那几期补上。"""
from app.scheduler.auto_compare import sealed_now
own = await sealed_runs(session, workflow_id, version, graph_hash, origin, n)
backfilled: list[Run] = []
if app_id and workflow_id and isinstance(version, int) and graph_hash:
rows = (await session.execute(select(Run).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.workflow_id == workflow_id,
Run.version == version, Run.version_hash == graph_hash, Run.run_class == "formal",
Run.status == "succeeded").order_by(Run.created_at.desc()).limit(max(n, 1) * 3 + 5))).scalars()
seen = {input_key(r.input) for r in own}
for run in rows:
key = input_key(run.input)
if (run.origin or {}).get("source") != (origin or "formal") or key in seen:
continue
if await sealed_now(session, run):
seen.add(key)
backfilled.append(run)
floor = datetime.min.replace(tzinfo=timezone.utc)
merged = sorted([*own, *backfilled], key=lambda r: (period_of(r) or floor, _aware(r.created_at) or floor))
return merged[-n:] if n > 0 else []
@dataclass
class Source:
id: str
@@ -254,9 +312,11 @@ async def cache_key(session: AsyncSession, state: PinState, inputs: dict[str, An
async def daily_limit(session: AsyncSession) -> int:
row = await session.get(Setting, APPS_SETTING_KEY)
value = ((row.value if row else None) or {}).get("on_demand_daily_limit")
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else DAILY_LIMIT_DEFAULT
"""每个应用每天最多发起几次按需运行:设置里的 apps.on_demand_daily_limit(D-j,默认值和取值范围只在 api/settings.py
一处定,设置页改的就是它)。"""
from app.api.settings import apps_settings
return int((await apps_settings(session))["on_demand_daily_limit"])
async def _cached(session: AsyncSession, key: str) -> Run | None:
@@ -373,6 +433,12 @@ async def _mask_fingerprint(session: AsyncSession) -> str:
return hashlib.sha256(canonical_json({"sources": sources, "capabilities": caps}).encode()).hexdigest()
async def _backfilled(session: AsyncSession, app_id: str) -> int:
"""这个应用补算完成了几次:进页面缓存的键。补算的运行不是「最近一次」,来源取的运行不变,趋势却多了点,键得跟着变。"""
return int(await session.scalar(select(func.count(Run.id)).where(
Run.app_id == app_id, Run.origin_kind == BACKFILL_ORIGIN, Run.status == "succeeded")) or 0)
async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, principal: Principal, role: str | None,
instance: Run | None = None, params: dict[str, Any] | None = None, draft: bool = False,
version: AppVersion | None = None, actor: str | None = None) -> dict[str, Any]:
@@ -400,7 +466,8 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
tuple((v, s.run.id if s.run else None, s.state) for v, s in sorted(ctx.sources.items())),
tuple((a, p.workflow.published_version if p.workflow else None, p.retired)
for a, p in sorted(ctx.pins.items())),
role, principal.username, principal.role, await _mask_fingerprint(session))
role, principal.username, principal.role, await _mask_fingerprint(session),
await _backfilled(session, app.id))
cacheable = (not draft and instance is None and all(s.state in ("ok", "empty") for s in ctx.sources.values())
and not _dynamic(page))
if cacheable and key in _PAGE_CACHE:
@@ -719,16 +786,18 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r
segments: list[tuple[int, str, list[Run]]] = []
mode = "single"
previous, disposition = await _upgrade_of(session, ctx, pin.alias)
app_id = ctx.app.id if ctx.app is not None else None
if previous is not None and disposition in ("dual", "incomparable"):
mode = disposition
old_runs = await sealed_runs(session, previous.get("workflow_id"), previous.get("version"),
previous.get("graph_hash"), origin, n)
segments.append((int(previous.get("version")), str(previous.get("graph_hash")), list(reversed(old_runs))))
old_runs = await trend_runs(session, app_id, previous.get("workflow_id"), previous.get("version"),
previous.get("graph_hash"), origin, n)
segments.append((int(previous.get("version")), str(previous.get("graph_hash")), old_runs))
elif disposition == "recompute":
mode = "recompute"
runs = await sealed_runs(session, pin.pin.get("workflow_id"), pin.pin.get("version"), pin.pin.get("graph_hash"),
origin, n)
segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), list(reversed(runs))))
# 新版本这一段带上按新口径补算的那几期(trend_runs):recompute 升版之后补算过,趋势就按新口径从头画
runs = await trend_runs(session, app_id, pin.pin.get("workflow_id"), pin.pin.get("version"),
pin.pin.get("graph_hash"), origin, n)
segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), runs))
series: list[dict[str, Any]] = []
unit = ""
for version, _, rows in segments:
@@ -739,8 +808,10 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r
metric, text, eid, held = await _metric_value(point_source, node_id, metric_id, None)
if metric is not None:
unit = unit or str(metric.get("unit") or "")
points.append({"run_id": run.id, "as_of": run.finished_at.isoformat() if run.finished_at else None,
period = period_of(run)
points.append({"run_id": run.id, "as_of": period.isoformat() if period else None,
"value": None if held or metric is None else metric.get("value"), "text": text,
**({"backfill": True} if run.origin_kind == BACKFILL_ORIGIN else {}),
"evidence": _evidence(point_source, "metric", node=node_id, metric=metric_id, eid=eid)
if metric is not None else None})
series.append({"version": version, "label": f"v{version}", "points": points})
@@ -758,14 +829,23 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r
async def _upgrade_of(session: AsyncSession, ctx: _Page, alias: str) -> tuple[dict[str, Any] | None, str | None]:
"""这个钉住项最近一次升版:(升版前的钉住项, 当时选的处置)。从当前应用版本往回找第一次换了工作流版本的地方——之后
又发过几版(改布局、改文案)不影响趋势按那次处置来画。没升过版的是 (None, None)。"""
if ctx.version is None or ctx.version.version <= 1:
if ctx.version is None:
return None, None
return await pin_upgrade(session, ctx.app.id, ctx.version, alias)
async def pin_upgrade(session: AsyncSession, app_id: str, version: AppVersion,
alias: str) -> tuple[dict[str, Any] | None, str | None]:
"""这个钉住项最近一次升版:(升版前的钉住项, 当时选的处置)。从这个应用版本往回找第一次换了工作流版本的地方——之后
又发过几版(改布局、改文案)不影响趋势按那次处置来画、也不影响按新口径补算。没升过版的是 (None, None)。"""
if version.version <= 1:
return None, None
rows = list((await session.execute(select(AppVersion).where(
AppVersion.app_id == ctx.app.id, AppVersion.version <= ctx.version.version)
AppVersion.app_id == app_id, AppVersion.version <= version.version)
.order_by(AppVersion.version.desc()))).scalars())
rows[0] = ctx.version
if not rows:
return None, None
rows[0] = version
for newer, older in zip(rows, rows[1:]):
new = next((p for p in newer.pins or [] if p.get("alias") == alias), None)
old = next((p for p in older.pins or [] if p.get("alias") == alias), None)
@@ -787,16 +867,38 @@ def _query_artifact(source: Source, node_id: str, exec_no: Any = None) -> str |
async def _snapshot(source: Source, artifact: str) -> tuple[dict[str, Any] | None, set[str]]:
"""查询快照和要遮的列(小写):查询当时记下的加数据源现在设的,按 SQL 认出改了名字的也遮(同证据面板)。"""
from app.api.evidence import _fetch
"""查询快照和要遮的列(小写):查询当时记下的加数据源现在设的,按 SQL 认出改了名字的也遮(同证据面板)。端点快照
按列名遮(见下)。"""
from app.api.evidence import _fetch, _recorded_masks
snap, ok = _fetch(artifact)
if ok is not True or not isinstance(snap, dict) or not isinstance(snap.get("rows"), list):
return None, set()
if snap.get("capability"):
# 端点快照:列是能力登记的表格映射排出来的,没有 SQL 改名这回事,按列名遮就是准的——落快照时记下的,加上能力
# 现在的遮罩字段。查询快照那一套(按 SQL 认改了名字的遮罩列)在这里认不出出处,会把整张表都遮掉
return snap, _recorded_masks(snap) | await _capability_masks(str(snap["capability"]))
sets = await source.masks.sets(artifact, snap.get("source"), snap)
return snap, set(sets.hidden)
async def _capability_masks(name: str) -> set[str]:
"""端点快照(capabilities/endpoints.py 落的 endpoint_snapshot)按能力现在的遮罩字段要遮的列(小写)。
快照里的 mask_columns 是落快照那一刻的遮罩(调用方另外并进来);管理员事后给能力加了遮罩字段,来源运行没换,
视图也得跟着遮(P4,同数据源事后加遮罩列)。遮罩字段是 JSON 指针,按能力登记的表格映射认它落在哪几列
(masked_columns_of,落快照时记 mask_columns 用的同一个函数)。能力删掉了的只能按快照记下的遮。"""
from app.capabilities.endpoints import masked_columns_of
from app.capabilities.registry import get_capability, masked_paths
from app.db.base import SessionLocal
async with SessionLocal() as session:
cap = await get_capability(session, name)
if cap is None:
return set()
return {c.lower() for c in masked_columns_of(cap.tabular or {}, masked_paths(cap))}
async def _cell(ctx: _Page, source: Source, cid: str, ref: Any, conv: Any) -> dict[str, Any]:
from app.api.evidence import MASKED
from app.engine.evidence import MISSING, RenderError, cell_eid, render_cell
+58
View File
@@ -5,6 +5,7 @@
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy import and_, or_, select
@@ -75,6 +76,63 @@ async def version_row(session: AsyncSession, workflow_id: str, version: int) ->
WorkflowVersion.workflow_id == workflow_id, WorkflowVersion.version == version))).scalar_one_or_none()
# --------------------------------------------------------------------------
# 子工作流固定的版本(D-q)
# --------------------------------------------------------------------------
#: 子工作流节点固定的版本不能用的机器码。发起正式运行(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"
@dataclass(frozen=True)
class SubgraphPinProblem:
node_id: str
#: 子工作流节点的标题
title: str
code: str
#: 节点固定的是哪个工作流的哪一版:门禁拿读好的结果去查另一张图(自动修复后的预览)时,按它核对节点没改过
workflow_id: str
version: int
#: 说到「「月报」v3 没有发布过」为止;前后半句(不能发起正式运行 / 正式运行发起时会被拒绝)由调用方接
reason: str
#: 怎么改
advice: str
async def subgraph_pin_problems(session: AsyncSession, spec: Any) -> list[SubgraphPinProblem]:
"""图里每个固定了版本的子工作流节点,固定的那一版必须「已发布且未退役」(is_published);不是的逐个交回。
子工作流固定版本就是方法卡的机制:外层发布时认可的是「用它的 vN」。vN 只是一张保存出来的草稿快照、或者已经退役,
正式运行照样执行它,就等于绕过了发布门禁、绕过了退役。发起时拦(launch.py),发布时也按同一条拦——不然钉在草稿上的
工作流照样发得出去,之后每一次正式运行(定时任务、应用入口、按需视图、补算)都在发起时才被拒。
没固定版本的子工作流跟随最新,那是受管级门禁管的事(governed.subgraph_unpinned),这里不管。"""
from app.engine.schema import NodeType
out: list[SubgraphPinProblem] = []
for node in spec.nodes:
if node.type != NodeType.SUBGRAPH:
continue
wid, pinned = node.config.get("workflow_id"), node.config.get("workflow_version")
if not wid or not str(pinned or "").isdigit():
continue
version = int(pinned)
workflow = await session.get(Workflow, str(wid))
row = await version_row(session, str(wid), version) if workflow is not None else None
if workflow is None or row is None:
out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_UNPUBLISHED, str(wid), version,
f"工作流版本 v{version} 不存在", "请在节点中重新选择版本"))
elif row.retired_at is not None:
out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_RETIRED, str(wid), version,
f"「{workflow.name}」v{version} 已退役", "请把节点改为固定一个在用的发布版本"))
elif not is_published(row, current=workflow.published_version):
out.append(SubgraphPinProblem(node.id, node.title, SUBGRAPH_UNPUBLISHED, str(wid), version,
f"「{workflow.name}」v{version} 没有发布过",
"请先发布那个工作流,或把节点改为固定一个已发布的版本"))
return out
# --------------------------------------------------------------------------
# 账本(L4):一个工作流版本被谁固定、生效了几次、正式运行了几次。退役确认、版本详情、删除约束、账本接口共用
# --------------------------------------------------------------------------
+17 -4
View File
@@ -237,11 +237,23 @@ async def preview_request(session: AsyncSession, capability: Any, args: dict[str
return {**req, "endpoint": endpoint.name, "environment": environment}
def on_behalf_parts(on_behalf: Any) -> tuple[str | None, str | None]:
"""on_behalf 的两种写法 → (发起人, 确认人)。
- 字符串(老写法,工具库试用、agent 里的读取):就是发起人,没有确认人;
- {"sub": 发起人, "act": 确认人}:调用工具节点的写入。sub 一律是运行的发起人,确认动作的人放在 act(D-b)。"""
if isinstance(on_behalf, dict):
sub, act = on_behalf.get("sub"), on_behalf.get("act")
return (str(sub) if sub else None), (str(act) if act else None)
return (str(on_behalf) if on_behalf else None), None
async def endpoint_request(session: AsyncSession, capability: Any, args: dict[str, Any], *, environment: str,
idem_key: str | None = None, on_behalf: str | None = None,
idem_key: str | None = None, on_behalf: str | dict[str, Any] | None = None,
run_id: str | None = None, node_id: str | None = None) -> EndpointResult:
"""平台执行器(registry.endpoint_request 的实现,签名同附录 C)。environment 由调用方按运行类别定(pick_environment);
传 test 而端点没有测试环境时演练。超时、连接中断交 unknown=True:对方可能已经处理了,写入要转核实,不能重发。"""
传 test 而端点没有测试环境时演练。超时、连接中断交 unknown=True:对方可能已经处理了,写入要转核实,不能重发。
on_behalf 是发起人(字符串),或者 {"sub": 发起人, "act": 确认人}(on_behalf_parts)。"""
try:
cap, tool, endpoint = await _resolve(session, capability)
envs = endpoint.environments or {}
@@ -271,9 +283,10 @@ async def endpoint_request(session: AsyncSession, capability: Any, args: dict[st
from app.auth import current_mode
subject, actor = on_behalf_parts(on_behalf)
# 开了登录,发起人就是认证过的用户名;关登录时是自报的署名,断言里照实写 verified=false
headers[ASSERTION_HEADER] = assertion(endpoint.name, on_behalf,
verified=bool(on_behalf) and current_mode() != "off")
headers[ASSERTION_HEADER] = assertion(endpoint.name, subject, actor=actor,
verified=bool(subject) and current_mode() != "off")
try:
async with _client(timeout=endpoint.timeout_s or settings.http_tool_timeout, follow_redirects=False,
trust_env=False, verify=env.get("verify_tls", True) is not False) as client:
+13 -1
View File
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core import artifact_store
from app.core.events import EventType
from app.data.catalog_impact import catalog_drift
from app.apps.versions import is_published
from app.apps.versions import is_published, subgraph_pin_problems
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.governance import governance_note, unresolved_caliber_upgrades
from app.engine.runner import run_manager
@@ -36,6 +36,8 @@ VERSION_RETIRED = "run_version_retired"
VERSION_HASH_MISMATCH = "run_version_hash_mismatch"
#: 发布之后这一版用到的能力的治理版本变了:只提醒、不拦(同数据目录漂移)
CAPABILITY_DRIFT = "capability_drift"
#: 正式运行里子工作流节点固定的版本没有发布过、已经退役(D-q)的机器码,和发布门禁、应用检查同一套:见 apps/versions.py
#: 的 SUBGRAPH_UNPUBLISHED / SUBGRAPH_RETIRED 和 subgraph_pin_problems
class LaunchRefused(Exception):
@@ -156,6 +158,8 @@ async def launch_run(
errors, upgrade_events = await unresolved_caliber_upgrades(session, spec)
if errors:
raise LaunchRefused(409, ";".join(errors))
# 处置声明了,固定的那一版本身还得在用(D-q):两条都拦,先说升版是沿用以前的提示顺序
await _refuse_unpublished_subgraphs(session, spec)
# 数据目录和口径卡不同:目录是给人和助手看的说明,不是运行的输入。发布之后变了只提醒、不拦——
# 拦下来等于让一次文字修订卡住正式出具
catalog_changes = await catalog_drift(session, snapshot.catalog_versions)
@@ -413,6 +417,14 @@ async def missing_tools(session: AsyncSession, spec: GraphSpec) -> list[tuple[st
return missing
async def _refuse_unpublished_subgraphs(session: AsyncSession, spec: GraphSpec) -> None:
"""正式运行里,每个固定了版本的子工作流节点,固定的那一版必须「已发布且未退役」(D-q)。规则在
apps/versions.subgraph_pin_problems,发布门禁按同一条拦;这里是发起这一刻的那一道(发布之后那一版才退役的)。"""
for problem in await subgraph_pin_problems(session, spec):
raise LaunchRefused(409, f"子工作流「{problem.title}」固定的{problem.reason},不能发起正式运行。"
f"{problem.advice},再发布这个工作流", problem.code)
async def _refuse_missing_tools(session: AsyncSession, graph: dict[str, Any]) -> None:
"""绑定的工具在本机不存在就不发起:以前要执行到那一步才失败,前面的步骤白做、钱白花。"""
try:
+37 -5
View File
@@ -9,6 +9,10 @@
子工作流里有没有人工节点要读库才知道,门禁本身不查库:调用方先用 load_subgraphs 读好、with_subgraphs 放进来,
和 SQL 检查器、模型接入表同一个做法。没放进来时这一条不查。
子工作流固定的版本是不是「已发布且未退役」(D-q)也要读库,随 load_subgraphs 一起读好(Subgraphs.pin_problems),由
lint_subgraph_pins 报:这一条各级都是错误——正式运行发起时按同一条规则拒(engine/launch.py),发布时放过去只会让之后的
每一次正式运行都在发起时才失败。
"""
from __future__ import annotations
@@ -134,13 +138,25 @@ def human_issues(node: GraphNode) -> list[tuple[str, str, str]]:
_SUBGRAPHS: ContextVar[Mapping[tuple[str, int | None], GraphSpec] | None] = ContextVar("process_subgraphs", default=None)
async def load_subgraphs(session: Any, spec: GraphSpec) -> dict[tuple[str, int | None], GraphSpec]:
"""读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图)。读不到、不成形的跳过。"""
class Subgraphs(dict):
"""load_subgraphs 读好的东西:(工作流 id, 固定的版本,没固定为 None) → GraphSpec;pin_problems 另记固定的版本不是
「已发布且未退役」的子工作流节点(apps/versions.subgraph_pin_problems)。with_subgraphs 两样一起放进门禁。"""
def __init__(self) -> None:
super().__init__()
self.pin_problems: list[Any] = []
async def load_subgraphs(session: Any, spec: GraphSpec) -> Subgraphs:
"""读出图里子工作流节点引用的图(固定了版本的读那一版,没固定的读工作流现在的图)。读不到、不成形的跳过。
固定的版本能不能用也一并读好(Subgraphs.pin_problems)。"""
from sqlalchemy import select
from app.apps.versions import subgraph_pin_problems
from app.db.models import Workflow, WorkflowVersion
out: dict[tuple[str, int | None], GraphSpec] = {}
out = Subgraphs()
out.pin_problems = await subgraph_pin_problems(session, spec)
for node in spec.nodes:
if node.type != NodeType.SUBGRAPH:
continue
@@ -245,11 +261,27 @@ def lint_process(spec: GraphSpec, flag: Callable[..., None], *, level: str, stri
code="process.code_network", node_id=node.id, field="network")
def lint_subgraph_pins(spec: GraphSpec, flag: Callable[..., None], *, level: str, strict: bool) -> None:
"""子工作流固定的版本不是「已发布且未退役」的(D-q):各级都报错(always),机器码和发起时拒绝的一样。
load_subgraphs 没读好、没放进来时不查(门禁本身不查库)。"""
graphs = _SUBGRAPHS.get()
nodes = spec.node_map()
for problem in getattr(graphs, "pin_problems", None) or []:
node = nodes.get(problem.node_id)
# 读好的结果对的是读的那张图;拿去查改过的图(自动修复的预览)时,节点改了固定的版本就不再算它
if node is None or str(node.config.get("workflow_id") or "") != problem.workflow_id \
or str(node.config.get("workflow_version") or "") != str(problem.version):
continue
flag(f"子工作流「{problem.title}」固定的{problem.reason}:正式运行发起时会被拒绝。{problem.advice}",
code=problem.code, node_id=problem.node_id, field="workflow_version", always=True)
def _register() -> None:
from app.engine.governance import EXTRA_LINTS
if lint_process not in EXTRA_LINTS:
EXTRA_LINTS.append(lint_process)
for check in (lint_process, lint_subgraph_pins):
if check not in EXTRA_LINTS:
EXTRA_LINTS.append(check)
_register()
+2 -1
View File
@@ -48,7 +48,7 @@ from app.providers.factory import (
get_chat_model,
)
from app.engine.sql_problems import event_checks
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_versions
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_versions
from app.tools.registry import (
ToolArgsError,
ToolBuildError,
@@ -337,6 +337,7 @@ async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]:
collection=ctx.cfg("collection") or ctx.run.collection,
data_versions=run_versions(ctx.run),
mask_results=run_masks_results(ctx.run),
force_readonly=run_reads_only(ctx.run),
# 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
emit=ctx.emit,
)
+2 -1
View File
@@ -28,7 +28,7 @@ from app.engine.toolcalls import (
from app.providers import catalog
from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model
from app.engine.sql_problems import event_checks
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_versions
from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_versions
from app.tools.registry import (
ToolArgsError,
ToolContext,
@@ -315,6 +315,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
collection=ctx.run.collection,
data_versions=run_versions(ctx.run),
mask_results=run_masks_results(ctx.run),
force_readonly=run_reads_only(ctx.run),
# 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
emit=ctx.emit,
)
+26 -3
View File
@@ -19,8 +19,8 @@ from app.engine.toolcalls import ToolTimeout, limit_fields, limit_of, run_bounde
from app.sandbox.base import SandboxLimits
from app.sandbox.manager import sandbox_manager
from app.engine.sql_problems import event_checks
from app.capabilities.registry import is_platform_query
from app.tools.datasource import DATA_UNAVAILABLE, SCHEMA_PREFIX, run_masks_results, run_versions
from app.capabilities.registry import is_platform_query, snapshot_owned
from app.tools.datasource import DATA_UNAVAILABLE, SCHEMA_PREFIX, run_masks_results, run_reads_only, run_versions
from app.tools.registry import (
ToolArgsError,
ToolBuildError,
@@ -49,6 +49,7 @@ def _tool_ctx(ctx: NodeContext) -> ToolContext:
collection=ctx.cfg("collection") or ctx.run.collection,
data_versions=run_versions(ctx.run),
mask_results=run_masks_results(ctx.run),
force_readonly=run_reads_only(ctx.run),
# 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late)
emit=ctx.emit,
)
@@ -193,6 +194,10 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
# 只认平台执行的查询(数据源查询、登记里声明了 tabular 的端点读取,capabilities.registry.is_platform_query):
# 别的工具(MCP、自定义)拼出同样形状的 JSON,里面的工件 id 不是我们落的。按身份认,不看结果长得像不像(F11)
query = query_entry_fields(result) if evidence_on and is_platform_query(name) else None
# 端点读取另核一道(同 nodes/llm.py):名字对了,交回的工件还得是这次运行落下的端点快照。数据源查询的快照由数据层
# 自己落,snapshot_owned 对它们直接放行
if query and not await snapshot_owned(query["artifact"], ctx.run.run_id, name):
query = None
schema = query.get("schema_artifact") if query else None
# 这次查询的 SQL 检查结果单独带上:preview 只有前 2000 个字符,checks 排在 rows 后面,通常被截掉
checks = event_checks(result) if is_platform_query(name) else []
@@ -271,6 +276,7 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[
unattended = governance.get("unattended_write", getattr(capability, "unattended_write", False))
confirm = (governance.get("approval") or getattr(capability, "approval", "inherit")) == "confirm" \
or not (ctx.approval_mode() == "never" and unattended)
confirmer: str | None = None
if confirm:
title = f"确认发送:{getattr(capability, 'title', '') or name}"
card = {"kind": "action_confirm", "node_id": ctx.node.id, "mode": "approve", "title": title, "tool": name,
@@ -289,14 +295,31 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[
# 动作确认不接受改参数:要改在上游的表单里改。确认之后上游的数据变了,看过的就不是要发的,拒发
raise NodeError(ctx.node.id, f"「{name}」确认时看到的内容和现在要发送的不一致(内容指纹对不上),"
"已拒绝发送。请重新发起运行,确认新的内容")
confirmer = ctx.actor() or await _confirmed_by(ctx.run.run_id, ctx.node.id)
# 身份断言(D-b):sub 一律是运行的发起人——这件事是替他办的;确认的人放在 act。以前恢复时写的是确认人,
# 外部系统会把审批人当成申请人;无人值守的写入没有人确认,不写 act
on_behalf = {"sub": prep.run.get("started_by"), "act": confirmer}
try:
result = await actions.send(prep, capability, args, run_id=ctx.run.run_id, node_id=ctx.node.id,
exec_no=exec_no, emit=ctx.emit, on_behalf=ctx.actor() or prep.run.get("started_by"))
exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf)
except actions.ActionFailed as e:
raise NodeError(ctx.node.id, str(e)) from e
return _write_updates(ctx, result)
async def _confirmed_by(run_id: str, node_id: str) -> str | None:
"""确认这次发送的人:节点重新执行(结果未知核实后接着跑、重启后恢复)时 ctx.actor() 已经不是确认的那次恢复,
按待办上记的处理人认(会签时是凑齐的那一票)。"""
from sqlalchemy import select
from app.db.models import Task
async with SessionLocal() as session:
return await session.scalar(select(Task.resolved_by).where(
Task.run_id == run_id, Task.node_id == node_id, Task.kind == "action_confirm",
Task.status == "resolved").order_by(Task.created_at.desc()).limit(1))
def _write_updates(ctx: NodeContext, result: dict[str, Any]) -> dict[str, Any]:
updates: dict[str, Any] = {"nodes": {ctx.node.id: result}}
if var_name := ctx.cfg("assign_to", ""):
+7 -2
View File
@@ -160,8 +160,10 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a
async def send(prep: Prepared, capability: Any, args: dict[str, Any], *, run_id: str, node_id: str, exec_no: int,
emit: Callable[..., None], on_behalf: str | None = None) -> dict[str, Any]:
"""先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。"""
emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None) -> dict[str, Any]:
"""先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。
on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。"""
from app.capabilities.registry import endpoint_request, masked_paths
name = capability.name
@@ -422,6 +424,9 @@ async def _continue(run_id: str, actor: str | None) -> Any:
except (ValueError, KeyError) as e:
logger.warning("动作核实之后没能接着运行 %s:%s", run_id, e)
return None
except Exception: # noqa: BLE001 - 核实本身已经落库:接着跑不成不该让核实报错,记日志,人可以在记录页接着运行
logger.exception("动作核实之后接着运行 %s 出错", run_id)
return None
async def tick(now: datetime | None = None) -> int:
+113 -36
View File
@@ -15,11 +15,21 @@ Idempotency-Key 去重。start 按入口发起实例;signal 按 correlation
signal 的请求体:{"correlation": 关联键, "decision"?: "approve" | "reject", "note"?: …, 其余字段原样交给节点}。关联键
也可以放在 X-AgentLab-Correlation 头里。
钩子的增删放在 /api/apps/{id}/hooks(应用包的接口),这里给 create_hook / list_hooks / delete_hook 三个函数;
密钥只在新建时交回一次,库里存密文。
钩子按名字认(D-g):应用 spec 的 hooks 里声明 {name, kind, entry},app_hooks 按 (应用, 名字) 一行,只存密钥和启用
与否。请求到来时按应用**当前发布版**的 spec 认这个名字的 kind 和 entry——不另存一份入口:钩子做什么跟着发布走,
改草稿不改正在用的钩子,发布版里没声明(或者行被停用)的一律拒收。
钩子的管理放在 /api/apps/{id}/hooks(应用包的接口),这里给 hook_row / rotate_hook / set_enabled / delete_hook 几个
函数;密钥只在生成、轮换时交回一次,库里存密文。
一个名字只认一行(最早的那一行,hook_row)。库里还没有 (app_id, name) 的唯一约束,靠三道守:同一进程里生成、轮换按名字
串行(hook_lock);插入后再查一遍,前面已经有行的删掉自己、改去轮换那一行(rotate_hook);万一还是多出一行(多进程各自
插入),收请求时只认最早那一行(receive),停用按名字改全部行(set_enabled)——不然停用管不到另一行,用另一把密钥签名
的请求照样被接受。
"""
from __future__ import annotations
import asyncio
import hmac
import json
import logging
@@ -99,12 +109,17 @@ async def receive(hook_id: str, headers: Any, body: bytes) -> dict[str, Any]:
async with SessionLocal() as session:
hook = await session.get(AppHook, hook_id)
if hook is None or not hook.enabled:
# 同名多出来的行(见模块说明)不认:界面、停用、轮换都只管最早那一行
if hook is None or not hook.enabled or (await hook_row(session, hook.app_id, hook.name)).id != hook.id:
raise HookRejected("钩子不存在或已停用", status_code=404)
secret = decrypt(hook.secret) or ""
declared = await published_declaration(session, hook.app_id, hook.name)
if not secret:
raise HookRejected("钩子的密钥无法解密(加密密钥可能已更换),请在应用里重新生成钩子", status_code=409)
verify(secret, headers, body)
# 验过签名才说「发布版里没有它」:没验签的请求不该探出应用里声明了哪些钩子
if declared is None:
raise HookRejected("应用当前的发布版本里没有声明这个钩子,不接收请求", status_code=404)
idem_key = _header(headers, IDEMPOTENCY_HEADER)[:200]
if not idem_key:
raise HookRejected(f"请求缺少 {IDEMPOTENCY_HEADER} 请求头:同一件事重发时要带同一个键", status_code=400)
@@ -122,17 +137,44 @@ async def receive(hook_id: str, headers: Any, body: bytes) -> dict[str, Any]:
previous, reclaimed = await _claim(hook.id, idem_key)
if previous is not None:
return previous
request = HookRequest(hook_id=hook.id, kind=hook.kind, idem_key=idem_key, payload=payload)
kind = str(declared.get("kind"))
request = HookRequest(hook_id=hook.id, kind=kind, idem_key=idem_key, payload=payload)
try:
if hook.kind == "start":
out = await _start(hook, request, reclaimed=reclaimed)
if kind == "start":
out = await _start(hook, request, declared.get("entry"), reclaimed=reclaimed)
else:
out = await _signal(hook, request, _header(headers, CORRELATION_HEADER), reclaimed=reclaimed)
except Exception as e:
await _finish(hook.id, idem_key, status="rejected", detail=str(e)[:500])
raise
await _finish(hook.id, idem_key, status="accepted", run_id=out.get("run_id"), task_id=out.get("task_id"))
return {"status": "accepted", "kind": hook.kind, **out}
return {"status": "accepted", "kind": kind, **out}
def declarations(spec: Any) -> dict[str, dict[str, Any]]:
"""spec.hooks 里的声明:{名字: {name, kind, entry}}。形状不对的跳过(应用检查另外报);同名的取第一个。"""
out: dict[str, dict[str, Any]] = {}
hooks = spec.get("hooks") if isinstance(spec, dict) else None
for item in hooks if isinstance(hooks, list) else []:
if not isinstance(item, dict) or item.get("kind") not in KINDS:
continue
name = str(item.get("name") or "").strip()
if name and name not in out:
out[name] = {"name": name, "kind": item["kind"],
"entry": item.get("entry") if item["kind"] == "start" else None}
return out
async def published_declaration(session: AsyncSession, app_id: str, name: str) -> dict[str, Any] | None:
"""应用当前发布版里这个名字的钩子声明;应用没发布、归档了、发布版里没有这个名字的返回 None。"""
from app.db.models import App, AppVersion
app = await session.get(App, app_id)
if app is None or app.status == "archived" or not app.published_version:
return None
spec = await session.scalar(select(AppVersion.spec).where(AppVersion.app_id == app_id,
AppVersion.version == app.published_version))
return declarations(spec).get(name)
async def _bind_signature(hook_id: str, ts: str, signature: str, idem_key: str) -> None:
@@ -205,15 +247,15 @@ def _caller(hook: AppHook) -> Any:
mode=auth_mode())
async def _start(hook: AppHook, request: HookRequest, *, reclaimed: bool = False) -> dict[str, Any]:
async def _start(hook: AppHook, request: HookRequest, entry: Any, *, reclaimed: bool = False) -> dict[str, Any]:
from app.apps.entries import start_entry
if not hook.entry_id:
raise HookRejected("这个钩子没有指定入口,无法发起", status_code=409)
if not entry:
raise HookRejected("这个钩子在发布版里没有指定入口,无法发起", status_code=409)
if reclaimed and (run_id := await _started_before(hook, request.idem_key)):
# 上一次处理到一半服务停了:实例其实已经发起,交回它,不再发起一个
return {"run_id": run_id}
run = await start_entry(hook.app_id, hook.entry_id, request.payload, _caller(hook), origin_kind="hook",
run = await start_entry(hook.app_id, str(entry), request.payload, _caller(hook), origin_kind="hook",
hook=hook.name, hook_key=request.idem_key)
return {"run_id": run.id}
@@ -259,43 +301,78 @@ async def _signal(hook: AppHook, request: HookRequest, header_key: str, *, recla
# --------------------------------------------------------------------------
# 钩子的增删(给 /api/apps/{id}/hooks 用)
# 钩子的管理(给 /api/apps/{id}/hooks 用):按名字,一个名字一行
# --------------------------------------------------------------------------
def hook_out(row: AppHook) -> dict[str, Any]:
return {"id": row.id, "app_id": row.app_id, "name": row.name, "kind": row.kind, "entry_id": row.entry_id,
"enabled": row.enabled, "url_path": f"/api/hooks/{row.id}",
"created_at": row.created_at.isoformat() if row.created_at else None}
def hook_out(row: AppHook | None, *, name: str, declared: dict[str, Any] | None, in_draft: bool,
in_published: bool, secret: str | None = None) -> dict[str, Any]:
"""接口里的一行:声明(草稿优先,没有再看发布版)加上库里的那一行(密钥生成过没有、启用与否、收请求的地址)。"""
decl = declared or {}
return {"id": row.id if row is not None else None, "name": name,
"kind": decl.get("kind") or (row.kind if row is not None else None),
"entry_id": decl.get("entry") if decl else (row.entry_id if row is not None else None),
"enabled": bool(row.enabled) if row is not None else False,
"has_secret": row is not None and bool(row.secret),
"in_draft": in_draft, "in_published": in_published,
"url_path": f"/api/hooks/{row.id}" if row is not None else None,
"created_at": row.created_at.isoformat() if row is not None and row.created_at else None,
**({"secret": secret} if secret else {})}
async def create_hook(session: AsyncSession, app_id: str, *, name: str, kind: str,
entry_id: str | None = None) -> tuple[AppHook, str]:
"""新建一个钩子,返回 (钩子, 密钥明文)。密钥只在这一次交回,库里存密文。调用方提交。"""
async def hook_row(session: AsyncSession, app_id: str, name: str) -> AppHook | None:
"""(应用, 名字) 的那一行。库里没有唯一约束,万一多出一行,认最早的那一行(之后的轮换、停用、删除、收请求都按它)。"""
return await session.scalar(select(AppHook).where(AppHook.app_id == app_id, AppHook.name == name)
.order_by(AppHook.created_at, AppHook.id).limit(1))
#: (应用 id, 名字) → 生成、轮换的锁(见模块说明)。只增不减:一个应用的钩子就那么几个
_LOCKS: dict[tuple[str, str], asyncio.Lock] = {}
def hook_lock(app_id: str, name: str) -> asyncio.Lock:
"""同一个名字的生成、轮换在进程里串行:调用方从读行一直锁到提交(api/apps.py 的 rotate_hook)。"""
return _LOCKS.setdefault((app_id, name), asyncio.Lock())
async def rotate_hook(session: AsyncSession, app_id: str, name: str, *, kind: str,
entry: str | None = None) -> tuple[AppHook, str, bool]:
"""生成或轮换这个名字的密钥,返回 (行, 密钥明文, 是不是新生成的)。密钥只在这一次交回,库里存密文。
kind / entry 只是照声明记在行上给人看;收请求时以当前发布版的声明为准(published_declaration)。调用方提交。
新插入一行之后再查一遍:别人抢先插了(并发生成,hook_lock 管不到的另一个进程),删掉自己这行、改去轮换最早那一行,
交回的仍是这一次的密钥——和两个人先后点「轮换」一样,后点的那把生效。"""
from app.core.crypto import encrypt
if kind not in KINDS:
raise ValueError("钩子只能是发起实例或回调待办两种")
if kind == "start" and not entry_id:
raise ValueError("发起实例的钩子需要指定入口")
secret = secrets.token_urlsafe(32)
row = AppHook(app_id=app_id, name=name.strip()[:100] or kind, kind=kind,
entry_id=entry_id if kind == "start" else None, secret=encrypt(secret), enabled=True)
session.add(row)
row = await hook_row(session, app_id, name)
created = row is None
if row is None:
row = AppHook(app_id=app_id, name=name[:100], kind=kind, enabled=True, secret="")
session.add(row)
await session.flush()
first = await hook_row(session, app_id, name)
if first is not None and first.id != row.id:
await session.delete(row)
row, created = first, False
row.kind, row.entry_id = kind, (entry if kind == "start" else None)
row.secret = encrypt(secret) or ""
await session.flush()
return row, secret
return row, secret, created
async def list_hooks(session: AsyncSession, app_id: str) -> list[dict[str, Any]]:
rows = (await session.execute(select(AppHook).where(AppHook.app_id == app_id)
.order_by(AppHook.created_at))).scalars()
return [hook_out(r) for r in rows]
async def set_enabled(session: AsyncSession, app_id: str, name: str, enabled: bool) -> None:
"""启用、停用这个名字的钩子:同名的行一起改(见模块说明),停用管得到每一把密钥。调用方提交。"""
await session.execute(update(AppHook).where(AppHook.app_id == app_id, AppHook.name == name)
.values(enabled=enabled).execution_options(synchronize_session="fetch"))
async def delete_hook(session: AsyncSession, app_id: str, hook_id: str) -> bool:
"""删掉一个钩子(去重记录随外键一起删)。不是这个应用的、不存在的返回 False。调用方提交。"""
row = await session.get(AppHook, hook_id)
if row is None or row.app_id != app_id:
return False
await session.delete(row)
return True
async def delete_hook(session: AsyncSession, app_id: str, name: str) -> bool:
"""删掉这个名字的钩子行(密钥、去重记录随外键一起删);没有返回 False。声明留在 spec 里,由人在编辑器里删。调用方提交。"""
rows = list((await session.execute(select(AppHook).where(AppHook.app_id == app_id, AppHook.name == name)))
.scalars())
for row in rows:
await session.delete(row)
return bool(rows)
+23 -5
View File
@@ -418,6 +418,16 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s
except (ValueError, KeyError) as e:
await _unclaim(task_id, mine.id)
raise TaskRefused(str(e.args[0]) if e.args else str(e)) from e
except Exception as e: # noqa: BLE001 - 判定之后恢复不成:不管是什么错,待办都不能卡在 resolving
# 以前只接 ValueError / KeyError:别的异常(库被锁、检查点读不出……)直接冒上去,待办停在 resolving,再也没人
# 能回复,只有重启时 startup 才收拾。这里一律退回待处理、这一票撤回,原因写进日志、交回调用方
logger.exception("待办 %s 判定后恢复运行 %s 出错", task_id, run_id)
closed = await _unclaim(task_id, mine.id)
from app.core.errors import describe_exception
after = "运行已经结束,这条待办随之关闭" if closed else "你的回复没有生效,待办已退回待处理,请稍后重试"
raise TaskRefused(f"恢复运行时出错:{describe_exception(e)}。{after}", status_code=500,
code="task_resume_failed") from e
await _settle(task_id)
await record(run_id, EventType.TASK_RESOLVED, node_id=node_id, task_id=task_id, decision=decided,
actor=principal.username, signoffs=_signoffs(rows))
@@ -469,13 +479,13 @@ async def close_reconcile(session: AsyncSession, action_id: str, principal: Any,
return task.id
async def _unclaim(task_id: str, assignment_id: str) -> None:
"""恢复没成:运行已经结束的,待办随之关闭;还停着的,退回 pending,这一票撤回、可以再回复。"""
async def _unclaim(task_id: str, assignment_id: str) -> bool:
"""恢复没成:运行已经结束的,待办随之关闭;还停着的,退回 pending,这一票撤回、可以再回复。返回是不是关闭了。"""
async with SessionLocal() as session:
task = await session.get(Task, task_id)
status = await session.scalar(select(Run.status).where(Run.id == task.run_id)) if task else None
if task is None:
return
return True
if status in _TERMINAL_RUN or status is None:
task.status = "cancelled"
task.resolved_at = utcnow()
@@ -488,8 +498,9 @@ async def _unclaim(task_id: str, assignment_id: str) -> None:
.values(status="pending", response=None, responded_by=None, responded_at=None,
verified=False))
await session.commit()
logger.warning("待办 %s 判定后恢复运行没有成功,已%s", task_id,
"随运行关闭" if status in _TERMINAL_RUN else "退回待处理")
closed = status in _TERMINAL_RUN or status is None
logger.warning("待办 %s 判定后恢复运行没有成功,已%s", task_id, "随运行关闭" if closed else "退回待处理")
return closed
async def _settle(task_id: str) -> None:
@@ -675,6 +686,13 @@ async def _on_timeout(task_id: str, now: datetime) -> int:
resolved_at=utcnow()))
await session.commit()
return 1
except Exception: # noqa: BLE001 - 同 respond:别的错不能让待办卡在 resolving,退回待处理,下一拍再驳回
logger.exception("待办 %s 超时驳回时恢复运行出错,退回待处理,下一拍再试", task_id)
async with SessionLocal() as session:
await session.execute(update(Task).where(Task.id == task_id, Task.status == "resolving")
.values(status="pending", outcome=None))
await session.commit()
return 0
await _settle(task_id)
await record(run_id, EventType.TASK_RESOLVED, node_id=node_id, task_id=task_id, decision="expired_reject",
actor=None)
+18 -2
View File
@@ -98,10 +98,26 @@ def source_of_tool(name: str) -> str | None:
MASK_RESULTS_OPTION = "mask_results"
#: Run.options 里的键:这次运行的数据源查询一律只读,可写源上的写语句也由守卫拒掉(_make_query_tool 的 force_readonly)。
#: 和 MASK_RESULTS_OPTION 一起由 api/runs.py 给开登录后 user 角色的问数据运行打上:只读不能交给审批——Agent 的
#: approval 写成 never、或者管理员给这个工具设过「始终允许」,写语句就不问人直接执行了。另记一个键而不是拿遮罩开关
#: 顺带:遮不遮和能不能写是两件事,各自说清
READ_ONLY_OPTION = "read_only_data"
def _run_option(run_ctx: Any, key: str) -> bool:
options = (getattr(run_ctx, "extra", None) or {}).get("run_options") or {}
return bool(options.get(key))
def run_masks_results(run_ctx: Any) -> bool:
"""这次运行的查询结果要不要在工具边界就遮(见 MASK_RESULTS_OPTION)。构造 ToolContext 时传进去。"""
options = (getattr(run_ctx, "extra", None) or {}).get("run_options") or {}
return bool(options.get(MASK_RESULTS_OPTION))
return _run_option(run_ctx, MASK_RESULTS_OPTION)
def run_reads_only(run_ctx: Any) -> bool:
"""这次运行的数据源查询是不是一律只读(见 READ_ONLY_OPTION)。构造 ToolContext 时传进去。"""
return _run_option(run_ctx, READ_ONLY_OPTION)
def run_versions(run_ctx: Any) -> dict[str, Any] | Versions | None:
+144
View File
@@ -0,0 +1,144 @@
"""身份断言的 sub 与 act(D-b;设计文档 A6 第 3 条)。
sub 一律是运行的发起人(Run.started_by):这件事是替他办的;确认这次发送的人放在 act 声明(RFC 8693:
{"act": {"sub": 确认人}})。没有确认人就不写 act。on_behalf 的老写法(字符串,就是发起人)照旧认。
"""
from __future__ import annotations
import asyncio
import json
import uuid
import httpx
import jwt
import pytest
from sqlalchemy import delete, select
from app.api.deps import Principal
from app.auth import signing
from app.capabilities import endpoints as ep
from app.db.base import SessionLocal
from app.db.models import Capability, CustomTool, Endpoint, Run, Task
from app.engine.runner import run_manager
from app.process import tasks
from tests.fakes.fake_erp import PAY, FakeErp, endpoint_row, install
def _claims(token: str, audience: str = "erp") -> dict:
key = jwt.PyJWK(signing.jwks()["keys"][0]).key
return jwt.decode(token, key, algorithms=["EdDSA"], audience=audience, issuer="agentlab")
def test_sub_is_the_initiator_and_act_is_the_confirmer():
both = _claims(signing.assertion("erp", "alice", verified=True, actor="bob"))
assert both["sub"] == "alice" and both["act"] == {"sub": "bob"} and both["verified"] is True
alone = _claims(signing.assertion("erp", "alice", verified=False))
assert alone["sub"] == "alice" and "act" not in alone
service = _claims(signing.assertion("erp", None, verified=False))
assert "sub" not in service and "act" not in service
assert ep.on_behalf_parts("alice") == ("alice", None)
assert ep.on_behalf_parts({"sub": "alice", "act": "bob"}) == ("alice", "bob")
assert ep.on_behalf_parts({"sub": None, "act": None}) == (None, None)
assert ep.on_behalf_parts(None) == (None, None)
async def test_the_platform_executor_signs_both(monkeypatch):
sent: list[httpx.Request] = []
real = httpx.AsyncClient
def client(**kwargs):
return real(transport=httpx.MockTransport(lambda r: (sent.append(r), httpx.Response(201, json={}))[1]),
**kwargs)
monkeypatch.setattr(ep, "_client", client)
name = f"erp_pay_{uuid.uuid4().hex[:6]}"
aud = f"erp-{uuid.uuid4().hex[:6]}"
async with SessionLocal() as s:
endpoint = Endpoint(name=aud, environments={"prod": {"base_url": "http://127.0.0.1:9", "headers": {},
"allow_intranet": True}})
s.add(endpoint)
await s.flush()
s.add(CustomTool(name=name, kind="http", parameters={"type": "object", "properties": {}},
config={"endpoint": endpoint.id, "method": "POST", "path": "/payments"}))
cap = Capability(name=name, effect="write", credential_mode="service_on_behalf")
s.add(cap)
await s.commit()
endpoint_id = endpoint.id
try:
async with SessionLocal() as s:
cap = await s.get(Capability, name)
out = await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:1",
on_behalf={"sub": "alice", "act": "bob"})
assert out.status == 201, out.error
claims = _claims(sent[-1].headers["x-agentlab-assertion"], aud)
assert claims["sub"] == "alice" and claims["act"] == {"sub": "bob"}
await ep.endpoint_request(s, cap, {"amount": 1}, environment="prod", idem_key="k:2", on_behalf="alice")
claims = _claims(sent[-1].headers["x-agentlab-assertion"], aud)
assert claims["sub"] == "alice" and "act" not in claims, "老写法:字符串就是发起人"
finally:
async with SessionLocal() as s:
await s.execute(delete(CustomTool).where(CustomTool.name == name))
await s.execute(delete(Capability).where(Capability.name == name))
await s.execute(delete(Endpoint).where(Endpoint.id == endpoint_id))
await s.commit()
# --------------------------------------------------------------------------
# 调用工具节点:发起人和确认人分开交给执行器
# --------------------------------------------------------------------------
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses and not run_manager.is_active(run_id):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def test_the_call_tool_node_passes_initiator_and_confirmer(engine_up, monkeypatch):
from app.capabilities import registry
erp = FakeErp()
install(monkeypatch, erp, await endpoint_row(test=True), lookup=False)
seen: list = []
fake = registry.endpoint_request
async def recording(session, capability, args, **kw):
seen.append(kw.get("on_behalf"))
return await fake(session, capability, args, **kw)
monkeypatch.setattr(registry, "endpoint_request", recording)
graph = {"nodes": [node("start", "input", fields=[{"name": "amount"}]),
node("pay", "tool", tool=PAY, args={"amount": "{{ input.amount }}", "card": "6222"}),
node("out", "output", fields=[{"name": "单号", "value": "{{ nodes.pay.receipt.id }}"}])],
"edges": [{"source": "start", "target": "pay"}, {"source": "pay", "target": "out"}]}
run = await run_manager.start(graph=graph, input_payload={"amount": 5}, run_class="formal", started_by="alice")
await _wait(run.id, ("interrupted",))
for _ in range(100):
async with SessionLocal() as session:
task = (await session.execute(select(Task).where(Task.run_id == run.id, Task.kind == "action_confirm",
Task.status == "pending"))).scalars().first()
if task is not None:
break
await asyncio.sleep(0.05)
bob = Principal(username="bob", role="admin", mode="off")
card = (await tasks.detail(task.id, bob))["payload"]
await tasks.respond(task.id, bob, decision="approve", payload_hash=card["payload_hash"])
final = await _wait(run.id, ("succeeded", "failed"))
assert final.status == "succeeded", final.error
assert seen == [{"sub": "alice", "act": "bob"}], json.dumps(seen, ensure_ascii=False)
+211
View File
@@ -0,0 +1,211 @@
"""应用详情按授权给(D-m),应用编辑器的助手对话(D-e)。
- GET /api/apps/{id}:只有 manage 拿得到草稿和草稿里的岗位绑定;use / observe 只拿概况和当前发布版的页面、入口目录;
- GET /api/apps/{id}/assistant-conversation:manage 才能用,取或建这个人在这个应用上的助手对话(kind=app、app_id、
created_by),同一个人再取是同一段,换个人是另一段;
- /api/copilot/app-stream:要这个应用的 manage;带的对话必须是这个应用的助手对话;历史按对话拼进提示(同问数据)。
"""
from __future__ import annotations
import json
import uuid
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessageChunk
from app.api import apps as apps_api
from app.api.deps import Principal
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal, utcnow
from app.db.models import Conversation, ConversationTurn, Workflow, WorkflowVersion
from app.db.models_apps import AppGrant
from app.main import app
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
GRAPH = {"nodes": [node("start", "input", "开始", fields=[{"name": "amount", "type": "number"}]),
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ input.amount }}"}])],
"edges": [{"source": "start", "target": "out"}]}
async def published_app(client) -> str:
async with SessionLocal() as session:
wf = Workflow(name=f"报销-{uuid.uuid4().hex[:6]}", graph=GRAPH, status="published", published_version=1)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=GRAPH, graph_hash=graph_hash(GRAPH),
level="published", published_at=utcnow()))
await session.commit()
wf_id = wf.id
spec = {"format": "agentlab.app/1", "name": "报销",
"pins": [{"alias": "main", "workflow_id": wf_id, "version": 1, "graph_hash": graph_hash(GRAPH),
"use": "entry"}],
"entries": [{"id": "apply", "label": "发起报销", "pin": "main", "form": {"$ref": "#/forms/apply"},
"map": {"amount": "form.amount"}}],
"forms": {"apply": {"type": "object", "properties": {"amount": {"type": "number", "title": "金额"}}}},
"pages": [{"id": "home", "title": "首页", "body": [
{"id": "new", "type": "entry_button", "props": {"entry": "apply"}}]}]}
res = await client.post("/api/apps", json={"name": "报销", "spec": spec}, headers={"X-Actor": quote("建立人")})
app_id = res.json()["id"]
assert (await client.post(f"/api/apps/{app_id}/publish", json={})).json()["ok"]
return app_id
def as_user(monkeypatch, name: str = "carol", role: str = "user") -> Principal:
who = Principal(username=name, role=role, verified=True, mode="local")
monkeypatch.setattr(apps_api, "current_principal", lambda: who)
return who
async def grant(app_id: str, who: str, role: str) -> None:
async with SessionLocal() as session:
session.add(AppGrant(app_id=app_id, principal_kind="user", principal=who, role=role))
await session.commit()
# --------------------------------------------------------------------------
# D-m
# --------------------------------------------------------------------------
async def test_only_managers_get_the_draft(client, monkeypatch):
app_id = await published_app(client)
managed = (await client.get(f"/api/apps/{app_id}")).json()
assert managed["my_role"] == "manage" and managed["draft"]["entries"][0]["id"] == "apply"
assert managed["draft_bindings"] == {"slots": {}} and managed["created_by"] == "建立人"
outline = {"version": 1, "pages": [{"id": "home", "title": "首页"}], "entries": [{"id": "apply", "label": "发起报销"}]}
assert managed["published"] == outline
as_user(monkeypatch)
await grant(app_id, "carol", "observe")
seen = (await client.get(f"/api/apps/{app_id}")).json()
assert seen["my_role"] == "observe" and seen["published"] == outline
for key in ("draft", "draft_bindings", "created_by", "backfill"):
assert key not in seen, key
# --------------------------------------------------------------------------
# D-e
# --------------------------------------------------------------------------
async def test_assistant_conversation_is_per_app_and_per_person(client, monkeypatch):
app_id = await published_app(client)
first = (await client.get(f"/api/apps/{app_id}/assistant-conversation", headers={"X-Actor": quote("张三")})).json()
assert (first["kind"], first["app_id"], first["created_by"], first["turns"]) == ("app", app_id, "张三", [])
again = (await client.get(f"/api/apps/{app_id}/assistant-conversation", headers={"X-Actor": quote("张三")})).json()
assert again["id"] == first["id"]
other = (await client.get(f"/api/apps/{app_id}/assistant-conversation", headers={"X-Actor": quote("李四")})).json()
assert other["id"] != first["id"] and other["created_by"] == "李四"
as_user(monkeypatch)
await grant(app_id, "carol", "use")
assert (await client.get(f"/api/apps/{app_id}/assistant-conversation")).status_code == 403
async def test_concurrent_requests_share_one_assistant_conversation(client):
"""编辑器开发模式下 effect 执行两次、同一个人开两个标签页:同时来取的只建一段,历史不被拆开。"""
import asyncio
from sqlalchemy import select
app_id = await published_app(client)
headers = {"X-Actor": quote("王五")}
replies = await asyncio.gather(*(client.get(f"/api/apps/{app_id}/assistant-conversation", headers=headers)
for _ in range(3)))
assert len({r.json()["id"] for r in replies}) == 1
async with SessionLocal() as session:
rows = (await session.execute(select(Conversation.id).where(
Conversation.kind == "app", Conversation.app_id == app_id, Conversation.created_by == "王五"))).all()
assert len(rows) == 1
class Script:
"""按行吐操作流的假模型,记下每次收到的消息。"""
def __init__(self, *ops: dict) -> None:
self.lines = [json.dumps(o, ensure_ascii=False) for o in ops]
self.calls: list[list] = []
async def astream(self, messages):
self.calls.append(list(messages))
for line in self.lines:
yield AIMessageChunk(content=line + "\n")
async def _stream(client, monkeypatch, body: dict, model: Script | None = None) -> tuple[int, list[dict]]:
import app.api.copilot as copilot
from app.apps import lint
async def _model(*_a, **_k):
return model, "mock-fast"
async def _lint(session, spec, bindings, **_):
return []
monkeypatch.setattr(copilot, "get_chat_model", _model)
monkeypatch.setattr(lint, "lint_app", _lint)
events: list[dict] = []
async with client.stream("POST", "/api/copilot/app-stream", json=body) as r:
if r.status_code != 200:
return r.status_code, [json.loads(await r.aread())]
async for line in r.aiter_lines():
if line.startswith("data: "):
events.append(json.loads(line[6:]))
return 200, events
async def test_app_stream_reads_the_apps_own_conversation(client, monkeypatch):
app_id = await published_app(client)
other_app = await published_app(client)
conv = (await client.get(f"/api/apps/{app_id}/assistant-conversation")).json()
async with SessionLocal() as session:
session.add(ConversationTurn(conversation_id=conv["id"], seq=1, question="按钮文字写成我要报销",
explanation="改了发起按钮的文字", status="done"))
chat = Conversation(kind="chat", title="问数据")
session.add(chat)
await session.commit()
chat_id = chat.id
foreign = (await client.get(f"/api/apps/{other_app}/assistant-conversation")).json()
status, body = await _stream(client, monkeypatch, {"instruction": "改标题", "app_id": app_id,
"conversation_id": chat_id})
assert status == 409 and "不是这个应用的助手对话" in body[0]["detail"]
status, _ = await _stream(client, monkeypatch, {"instruction": "改标题", "app_id": app_id,
"conversation_id": foreign["id"]})
assert status == 409
model = Script({"op": "plan", "summary": "改首页标题"}, {"op": "app_page", "page": {"id": "home", "title": "报销"}},
{"op": "done", "explanation": "首页标题改成了报销"})
status, events = await _stream(client, monkeypatch, {"instruction": "首页标题写成报销", "app_id": app_id,
"conversation_id": conv["id"]}, model)
assert status == 200 and events[-1]["op"] == "final", events
human = model.calls[0][1][1]
assert "这次对话之前聊过的" in human and "按钮文字写成我要报销" in human and "改了发起按钮的文字" in human
assert events[-1]["spec"]["pages"][0]["title"] == "报销"
async def test_app_stream_needs_manage(client, monkeypatch):
app_id = await published_app(client)
import app.api.deps as deps
bob = Principal(username="bob", role="builder", verified=True, mode="local")
monkeypatch.setattr(deps, "current_principal", lambda: bob)
status, body = await _stream(client, monkeypatch, {"instruction": "改标题", "app_id": app_id})
assert status == 403 and "管理" in body[0]["detail"]
# 有 manage 授权的搭建者能用
await grant(app_id, "bob", "manage")
model = Script({"op": "reply", "text": "好的"})
status, events = await _stream(client, monkeypatch, {"instruction": "你好", "app_id": app_id}, model)
assert status == 200 and any(e["op"] == "reply" for e in events)
+226
View File
@@ -0,0 +1,226 @@
"""按新口径补算(D-f;设计文档 §3.4 趋势那一行、X3 的 app_backfill)。
- 只有视图的钉住项最近一次升版选了 recompute 才补算;取旧版本在同一来源上最近 N 期(N 不超过趋势的 last)已封存正式
运行的输入,用新版本逐个发起正式运行(来源 app_backfill,带 app_id / app_version,按钉住的 graph_hash 核对);
- 新版本已经算过同一个输入的跳过(补算过的、来源本身按新版本跑过的),再点一次不重复发起;
- 趋势把补算的运行算作新版本的点,按它补的那一期排;「最近一次」不取补算的运行;
- 选了别的处置、视图不存在的拒绝;详情里给管理者列出能补算的视图和会发起几次;
- 有外部副作用的工作流(人工节点、写外部系统、钉作流程)不补算,详情里也不列;
- 同一个视图并发点两次,同一个输入只发起一次。
"""
from __future__ import annotations
import asyncio
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal, utcnow
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.runner import run_manager
from app.main import app
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def kpi(gmv: int) -> dict:
nodes = [
node("start", "input", "开始", fields=[{"name": "week", "required": True}]),
node("fetch", "transform", "取数", mode="expression", expression=f"{{'gmv': {gmv}}}", assign_to="kpi"),
node("card", "metrics", "口径卡", caliber="周报口径", caliber_version="v1",
metrics=[{"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.kpi.gmv"}]),
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.card.text }}"}]),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def workflow(v2: dict | None = None) -> tuple[str, str, str]:
"""v1、v2 两个发布版,口径不同(gmv 100 → 200)。返回 (工作流, v1 哈希, v2 哈希)。"""
v1, v2 = kpi(100), v2 or kpi(200)
async with SessionLocal() as session:
wf = Workflow(name=f"周报-{uuid.uuid4().hex[:6]}", graph=v2, status="published", published_version=2, version=2)
session.add(wf)
await session.flush()
for number, graph in ((1, v1), (2, v2)):
session.add(WorkflowVersion(workflow_id=wf.id, version=number, graph=graph, graph_hash=graph_hash(graph),
level="published", published_at=utcnow()))
await session.commit()
return wf.id, graph_hash(v1), graph_hash(v2)
async def wait(run_id: str) -> Run:
for _ in range(600):
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None:
return row
await asyncio.sleep(0.03)
raise AssertionError(f"运行 {run_id} 没有结束:{row.status}")
async def formal(wf: str, version: int, week: str) -> Run:
from app.engine.launch import launch_run
async with SessionLocal() as session:
run = await launch_run(session, workflow_id=wf, run_class="formal", version=version,
input_payload={"week": week}, actor="周报编辑", origin_kind="launcher")
row = await wait(run.id)
assert row.status == "succeeded", row.error
return row
def spec(wf: str, version: int, hash_: str) -> dict:
return {
"format": "agentlab.app/1", "name": "周报看板",
"pins": [{"alias": "main", "workflow_id": wf, "version": version, "graph_hash": hash_, "use": "view"}],
"views": {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}},
"pages": [{"id": "home", "title": "首页", "body": [
{"id": "trend", "type": "series", "props": {"label": "销售额趋势"},
"bind": {"view": "latest", "series": "card.gmv", "last": 6}},
{"id": "gmv", "type": "metric", "props": {"label": "销售额"}, "bind": {"view": "latest", "metric": "card.gmv"}},
]}],
}
async def upgraded_app(client, wf: str, h1: str, h2: str, disposition: str) -> str:
res = await client.post("/api/apps", json={"name": "周报看板", "spec": spec(wf, 1, h1)})
assert res.status_code == 201, res.text
app_id = res.json()["id"]
assert (await client.post(f"/api/apps/{app_id}/publish", json={})).json()["ok"]
draft = (await client.get(f"/api/apps/{app_id}")).json()["draft"]
draft["pins"][0].update(version=2, graph_hash=h2)
assert (await client.patch(f"/api/apps/{app_id}", json={"draft": draft})).status_code == 200
out = (await client.post(f"/api/apps/{app_id}/publish", json={"dispositions": {"main": disposition}})).json()
assert out["ok"] and out["version"] == 2, out
return app_id
def find(vm, cid):
return next(n for n in vm["tree"] if n["id"] == cid)
async def test_backfill_recomputes_the_old_periods_under_the_new_version(client):
wf, h1, h2 = await workflow()
old = [await formal(wf, 1, w) for w in ("W1", "W2", "W3")]
app_id = await upgraded_app(client, wf, h1, h2, "recompute")
current = await formal(wf, 2, "W3") # 升版之后新版本已经跑过 W3:这一期不补
plan = (await client.get(f"/api/apps/{app_id}/views/latest/backfill")).json()
assert plan == {"view": "latest", "alias": "main", "source": "formal", "from_version": 1, "to_version": 2,
"periods": 6, "will_launch": 2, "will_skip": 1}
detail = (await client.get(f"/api/apps/{app_id}")).json()
assert detail["backfill"] == [plan], "管理者在详情里看得到能补算的视图和会发起几次"
out = (await client.post(f"/api/apps/{app_id}/views/latest/backfill")).json()
assert (out["launched"], out["skipped"]) == (2, 1) and len(out["run_ids"]) == 2
runs = [await wait(r) for r in out["run_ids"]]
assert all(r.status == "succeeded" for r in runs), [r.error for r in runs]
assert [(r.origin_kind, r.app_id, r.app_version, r.version, r.version_hash, r.run_class) for r in runs] == \
[("app_backfill", app_id, 2, 2, h2, "formal")] * 2
assert [r.input for r in runs] == [{"week": "W1"}, {"week": "W2"}]
assert [r.origin["backfill_of"] for r in runs] == [old[0].id, old[1].id]
assert runs[0].origin["source"] == "formal" and runs[0].origin["period_at"]
again = (await client.post(f"/api/apps/{app_id}/views/latest/backfill")).json()
assert (again["launched"], again["skipped"]) == (0, 3), "同一个输入不再补第二次"
vm = (await client.get(f"/api/apps/{app_id}/pages/home")).json()
assert vm["sources"]["latest"]["run_id"] == current.id, "补算的运行不是「最近一次」"
trend = find(vm, "trend")["data"]
assert trend["mode"] == "recompute"
[segment] = trend["series"]
assert segment["version"] == 2
assert [p["run_id"] for p in segment["points"]] == [runs[0].id, runs[1].id, current.id], "按补的那一期排"
assert [p.get("backfill", False) for p in segment["points"]] == [True, True, False]
assert [p["value"] for p in segment["points"]] == [200, 200, 200]
assert segment["points"][0]["as_of"].startswith(old[0].finished_at.isoformat()[:19])
async def test_backfill_needs_recompute_and_a_trend(client):
wf, h1, h2 = await workflow()
await formal(wf, 1, "W1")
app_id = await upgraded_app(client, wf, h1, h2, "incomparable")
refused = await client.post(f"/api/apps/{app_id}/views/latest/backfill")
assert refused.status_code == 409 and refused.json()["code"] == "app_backfill_refused"
assert "按新口径重算" in refused.json()["detail"]
assert (await client.get(f"/api/apps/{app_id}")).json()["backfill"] == []
gone = await client.get(f"/api/apps/{app_id}/views/nope/backfill")
assert gone.status_code == 404 and gone.json()["code"] == "app_view_not_found"
async with SessionLocal() as session:
launched = (await session.execute(select(Run.id).where(Run.app_id == app_id))).scalars().all()
assert launched == []
async def test_backfill_refuses_workflows_with_side_effects(client):
"""新版本里有人工节点:补算会对历史输入逐个给人开待办,收不回来。拒绝、详情里不列、一次也不发起。"""
v2 = kpi(200)
v2["nodes"].insert(3, node("ok", "human", "经理确认", mode="approve", title="确认周报"))
v2["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(v2["nodes"], v2["nodes"][1:])]
wf, h1, h2 = await workflow(v2)
await formal(wf, 1, "W1")
app_id = await upgraded_app(client, wf, h1, h2, "recompute")
for res in (await client.get(f"/api/apps/{app_id}/views/latest/backfill"),
await client.post(f"/api/apps/{app_id}/views/latest/backfill")):
assert res.status_code == 409 and res.json()["code"] == "app_backfill_refused"
assert "人工节点「经理确认」" in res.json()["detail"] and "不能按新口径补算" in res.json()["detail"]
assert (await client.get(f"/api/apps/{app_id}")).json()["backfill"] == []
async with SessionLocal() as session:
launched = (await session.execute(select(Run.id).where(Run.app_id == app_id))).scalars().all()
assert launched == []
async def test_backfill_writing_to_an_external_system_is_refused(client, monkeypatch):
"""调用工具节点绑的能力会写外部系统(capabilities.registry.writes_externally):同样拒绝。"""
from types import SimpleNamespace
from app.capabilities import registry
v2 = kpi(200)
v2["nodes"].insert(3, node("pay", "tool", "付款", tool="erp_pay", args={}))
v2["edges"] = [{"source": a["id"], "target": b["id"]} for a, b in zip(v2["nodes"], v2["nodes"][1:])]
wf, h1, h2 = await workflow(v2)
await formal(wf, 1, "W1")
app_id = await upgraded_app(client, wf, h1, h2, "recompute")
async def get_capability(session, name):
return SimpleNamespace(name=name, effect="write", effect_system="write", endpoint_id=None) \
if name == "erp_pay" else None
monkeypatch.setattr(registry, "get_capability", get_capability)
res = await client.post(f"/api/apps/{app_id}/views/latest/backfill")
assert res.status_code == 409 and "会写外部系统(erp_pay)" in res.json()["detail"]
async def test_concurrent_backfills_launch_each_input_once(client):
wf, h1, h2 = await workflow()
for week in ("W1", "W2"):
await formal(wf, 1, week)
app_id = await upgraded_app(client, wf, h1, h2, "recompute")
first, second = await asyncio.gather(client.post(f"/api/apps/{app_id}/views/latest/backfill"),
client.post(f"/api/apps/{app_id}/views/latest/backfill"))
outs = sorted((first.json(), second.json()), key=lambda o: -o["launched"])
assert [(o["launched"], o["skipped"]) for o in outs] == [(2, 0), (0, 2)]
for run_id in outs[0]["run_ids"]:
await wait(run_id)
async with SessionLocal() as session:
launched = (await session.execute(select(Run.input).where(Run.app_id == app_id,
Run.origin_kind == "app_backfill"))).scalars().all()
assert sorted(i["week"] for i in launched) == ["W1", "W2"]
+44
View File
@@ -0,0 +1,44 @@
"""应用视图里端点快照的遮罩(BE-3 遗留;设计文档 A6 第 7 条、P4)。
端点快照(endpoint_snapshot)的单元格、表格按能力登记现在的遮罩字段(capabilities.registry.masked_paths)遮:快照里记下的
mask_columns 是落快照那一刻的,管理员事后给能力加了遮罩字段,来源运行没换,视图也得跟着遮——同数据源事后加遮罩列。
"""
from __future__ import annotations
import uuid
from sqlalchemy import delete
from app.apps import resolve
from app.core.artifact_store import put_json
from app.db.base import SessionLocal
from app.db.models import Capability
TABULAR = {"rows": "/items", "columns": {"单号": "/id", "金额": "/amount", "电话": "/phone"}}
async def test_endpoint_snapshots_follow_the_capabilitys_current_masks():
name = f"erp_orders_{uuid.uuid4().hex[:6]}"
artifact = await put_json({"source": "erp", "capability": name, "columns": ["单号", "金额", "电话"],
"rows": [["A1", 12.5, "13900000001"]], "row_count": 1, "truncated": False,
"request": {}, "status": 200, "response": {}, "mask_columns": ["电话"]},
kind="endpoint_snapshot", run_id=f"r-{uuid.uuid4().hex[:6]}", node_id="read")
source = resolve.Source(id="v", kind="latest_sealed", state="ok")
async with SessionLocal() as session:
session.add(Capability(name=name, tabular=TABULAR, masked=["/phone"]))
await session.commit()
try:
snap, hidden = await resolve._snapshot(source, artifact)
assert snap is not None and hidden == {"电话"}
async with SessionLocal() as session:
(await session.get(Capability, name)).masked = ["/phone", "/amount"]
await session.commit()
_, hidden = await resolve._snapshot(resolve.Source(id="v", kind="latest_sealed", state="ok"), artifact)
assert hidden == {"电话", "金额"}, "事后加的遮罩字段照样遮"
finally:
async with SessionLocal() as session:
await session.execute(delete(Capability).where(Capability.name == name))
await session.commit()
# 能力删掉了:按快照记下的遮,不会把当时遮着的列亮出来
_, hidden = await resolve._snapshot(resolve.Source(id="v", kind="latest_sealed", state="ok"), artifact)
assert hidden == {"电话"}
+100
View File
@@ -0,0 +1,100 @@
"""助手润色只改呈现(D-h;设计文档 A8 第 2 条)。
AppApplier 只放行组件操作和页面(app_add / app_update / app_remove / app_move / app_page);app_set 只放行待办外观
(task_ui)和表单里的外观键(title、description、x-order、x-widget、x-enum-labels)。改钉住的版本、入口、视图、岗位、
表单的数据约束一律拒(Applied(False, False, problem));拒掉的原因在自查时交回模型一次,交付时列给人看。
编辑器自己调 apply_app_op 不受这层限制。
"""
from __future__ import annotations
import copy
from app.api.copilot_gen import POLISH_REFUSED, AppApplier, AppChecker
from app.apps.ops import apply_app_op, polish_problem
def spec() -> dict:
return {
"format": "agentlab.app/1", "name": "报销",
"pins": [{"alias": "main", "workflow_id": "wf", "version": 1, "graph_hash": "h" * 64, "use": "entry"}],
"slots": {"finance": {"label": "财务"}},
"entries": [{"id": "apply", "label": "发起", "pin": "main", "form": {"$ref": "#/forms/apply"}, "map": {}}],
"forms": {"apply": {"type": "object", "required": ["amount"], "title": "报销单", "properties": {
"amount": {"type": "number", "title": "金额", "minimum": 0},
"kind": {"type": "string", "enum": ["travel", "meal"], "x-enum-labels": ["差旅", "餐费"]}}}},
"views": {"inst": {"source": {"kind": "instance"}}},
"task_ui": {},
"pages": [{"id": "home", "title": "首页", "body": [{"id": "new", "type": "entry_button",
"props": {"entry": "apply"}}]}],
}
def test_component_and_page_operations_pass():
s = spec()
applier = AppApplier(s)
assert applier.apply({"op": "app_update", "id": "new", "props": {"label": "我要报销"}}).changed
assert applier.apply({"op": "app_page", "page": {"id": "mine", "title": "我发起的"}}).changed
assert applier.apply({"op": "app_set", "path": "task_ui", "key": "review",
"value": {"title": "财务审批", "x-order": ["note"]}}).changed
assert applier.refused == []
def test_form_looks_pass_but_constraints_do_not():
s = spec()
applier = AppApplier(s)
looks = copy.deepcopy(s["forms"]["apply"])
looks["title"] = "差旅报销单"
looks["x-order"] = ["kind", "amount"]
looks["properties"]["amount"].update({"title": "金额(元)", "description": "含税", "x-widget": "number"})
looks["properties"]["kind"]["x-enum-labels"] = ["出差", "吃饭"]
assert applier.apply({"op": "app_set", "path": "forms", "key": "apply", "value": looks}).changed
assert s["forms"]["apply"]["properties"]["amount"]["title"] == "金额(元)"
for change in (
lambda f: f["properties"]["amount"].update(minimum=10), # 取值范围
lambda f: f.update(required=[]), # 必填
lambda f: f["properties"]["kind"].update(enum=["travel"]), # 可选值
lambda f: f["properties"].update(extra={"type": "string"}), # 新字段
lambda f: f["properties"]["amount"].update(type="string"), # 类型
):
form = copy.deepcopy(s["forms"]["apply"])
change(form)
out = applier.apply({"op": "app_set", "path": "forms", "key": "apply", "value": form})
assert (out.changed, out.forward) == (False, False) and "数据约束" in out.problem
assert s["forms"]["apply"] == looks, "被拒的改动一处都没落地"
assert "新增表单" in applier.apply({"op": "app_set", "path": "forms", "key": "other",
"value": {"type": "object", "properties": {}}}).problem
assert "删掉" in applier.apply({"op": "app_set", "path": "forms", "key": "apply", "value": None}).problem
def test_pins_entries_views_slots_are_refused():
s = spec()
before = copy.deepcopy(s)
applier = AppApplier(s)
for path, value in (("entries", {"label": "改", "pin": "main", "form": {}, "map": {}}),
("views", {"source": {"kind": "instance"}}), ("slots", {"label": "总监"}),
("pins", {"alias": "x"})):
out = applier.apply({"op": "app_set", "path": path, "key": "k", "value": value})
assert (out.changed, out.forward) == (False, False) and "润色只改呈现" in out.problem, path
assert s == before
assert [r["path"] for r in applier.refused] == ["entries.k", "views.k", "slots.k", "pins.k"]
# 编辑器不受这层限制:同一个操作直接落地
assert apply_app_op(s, {"op": "app_set", "path": "slots", "key": "k", "value": {"label": "总监"}}).changed
assert polish_problem(s, {"op": "app_add"}) is None
async def test_refusals_go_back_to_the_model_once_and_are_listed_for_people(monkeypatch):
from app.apps import lint
async def _lint(session, spec, bindings, **_):
return []
monkeypatch.setattr(lint, "lint_app", _lint)
applier = AppApplier(spec())
checker = AppChecker(applier)
applier.apply({"op": "app_set", "path": "views", "key": "inst", "value": {"source": {"kind": "instance"}}})
first = await checker.issues()
assert [(i["code"], i["level"], i["path"]) for i in first] == [(POLISH_REFUSED, "error", "views.inst")]
assert "润色只改呈现" in checker.repair_request(first, "")
assert await checker.issues() == [], "同一条只交回一次,不在后面几轮反复交回"
assert [(i["code"], i["level"]) for i in applier.refusals()] == [(POLISH_REFUSED, "warning")]
+14 -5
View File
@@ -278,13 +278,22 @@ async def test_login_off_allows_everything(client):
assert grants.json()["items"] == [{"principal_kind": "group", "principal": "财务", "role": "observe"},
{"principal_kind": "everyone", "principal": "", "role": "use"}]
assert (await client.get(f"/api/apps/{app_id}/grants")).json() == grants.json()
hook = (await client.post(f"/api/apps/{app_id}/hooks", json={"name": "erp", "kind": "start", "entry_id": "apply"})).json()
assert hook["secret"] and hook["kind"] == "start"
# 钩子按名字(D-g):草稿里声明了才能生成密钥
assert (await client.post(f"/api/apps/{app_id}/hooks/erp/rotate")).status_code == 404
draft = (await client.get(f"/api/apps/{app_id}")).json()["draft"]
draft["hooks"] = [{"name": "erp", "kind": "start", "entry": "apply"}]
assert (await client.patch(f"/api/apps/{app_id}", json={"draft": draft})).status_code == 200
hook = (await client.post(f"/api/apps/{app_id}/hooks/erp/rotate")).json()
assert hook["secret"] and hook["kind"] == "start" and hook["entry_id"] == "apply" and hook["has_secret"]
assert (hook["in_draft"], hook["in_published"]) == (True, False)
listed = (await client.get(f"/api/apps/{app_id}/hooks")).json()["items"]
assert [h["name"] for h in listed] == ["erp"] and "secret" not in listed[0]
rotated = (await client.post(f"/api/apps/{app_id}/hooks/{hook['id']}/rotate")).json()
assert rotated["secret"] != hook["secret"]
assert (await client.delete(f"/api/apps/{app_id}/hooks/{hook['id']}")).status_code == 204
rotated = (await client.post(f"/api/apps/{app_id}/hooks/erp/rotate")).json()
assert rotated["secret"] != hook["secret"] and rotated["id"] == hook["id"]
assert (await client.patch(f"/api/apps/{app_id}/hooks/erp", json={"enabled": False})).json()["enabled"] is False
assert (await client.delete(f"/api/apps/{app_id}/hooks/erp")).status_code == 204
listed = (await client.get(f"/api/apps/{app_id}/hooks")).json()["items"]
assert [(h["name"], h["id"], h["has_secret"]) for h in listed] == [("erp", None, False)], "声明还在草稿里"
async def test_users_see_only_granted_apps(client, monkeypatch):
+3 -2
View File
@@ -280,12 +280,13 @@ async def test_sent_then_restart_becomes_unknown(capabilities, monkeypatch):
[rec] = await _records(run_id)
assert rec.state == "sent"
await run_manager.setup()
assert await actions.startup() == 1
# 启动整理按全库数:同一个测试进程里别的测试留下的结果未知(还没核实)也在里面,这里只看这一次运行的那条
assert await actions.startup() >= 1
[rec] = await _records(run_id)
assert rec.state == "unknown"
await _task(run_id, "reconcile")
assert len(await _events(run_id, "action.unknown")) == 1
assert await actions.startup() == 1 and len(await _events(run_id, "action.unknown")) == 1 # 不重复安排
assert await actions.startup() >= 1 and len(await _events(run_id, "action.unknown")) == 1 # 不重复安排
async def test_continue_api_refuses_while_unresolved(erp, capabilities):
+107 -13
View File
@@ -41,20 +41,25 @@ def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
async def _app() -> str:
#: 发布版里声明的钩子(D-g:收请求时按当前发布版的声明认 kind 和入口)
DECLARED = [{"name": "paid", "kind": "signal"}, {"name": "new", "kind": "start", "entry": "apply"}]
async def _app(declared: list | None = None) -> str:
async with SessionLocal() as session:
app = App(name=f"付款-{uuid.uuid4().hex[:6]}", published_version=1)
session.add(app)
await session.flush()
session.add(AppVersion(app_id=app.id, version=1, spec={}, spec_hash="h" * 64, bindings={}))
session.add(AppVersion(app_id=app.id, version=1, spec={"hooks": DECLARED if declared is None else declared},
spec_hash="h" * 64, bindings={}))
await session.commit()
return app.id
async def _hook(app_id: str, kind: str, entry_id: str | None = None) -> tuple[str, str]:
async with SessionLocal() as session:
row, secret = await hooks.create_hook(session, app_id, name="paid" if kind == "signal" else "new", kind=kind,
entry_id=entry_id)
row, secret, _ = await hooks.rotate_hook(session, app_id, "paid" if kind == "signal" else "new", kind=kind,
entry=entry_id)
await session.commit()
return row.id, secret
@@ -228,20 +233,109 @@ async def test_a_receipt_stuck_in_processing_can_be_reclaimed_without_starting_t
async def test_hook_management_functions():
"""按名字一行(D-g):生成、轮换都落在同一行上,地址(行 id)不变、密钥换掉;删掉按名字删。"""
app_id = await _app()
async with SessionLocal() as session:
with pytest.raises(ValueError):
await hooks.create_hook(session, app_id, name="x", kind="start") # 发起实例要指定入口
row, secret = await hooks.create_hook(session, app_id, name="paid", kind="signal")
await hooks.rotate_hook(session, app_id, "x", kind="nope")
row, secret, created = await hooks.rotate_hook(session, app_id, "paid", kind="signal")
await session.commit()
listed = await hooks.list_hooks(session, app_id)
assert [h["id"] for h in listed] == [row.id] and "secret" not in listed[0]
assert listed[0]["url_path"] == f"/api/hooks/{row.id}"
assert row.secret != secret and len(secret) >= 32 # 库里是密文
assert await hooks.delete_hook(session, "other-app", row.id) is False
assert await hooks.delete_hook(session, app_id, row.id) is True
assert created and row.secret != secret and len(secret) >= 32 # 库里是密文
again, second, created = await hooks.rotate_hook(session, app_id, "paid", kind="signal")
await session.commit()
assert await hooks.list_hooks(session, app_id) == []
assert not created and again.id == row.id and second != secret
assert (await hooks.hook_row(session, app_id, "paid")).id == row.id
assert await hooks.delete_hook(session, "other-app", "paid") is False
assert await hooks.delete_hook(session, app_id, "paid") is True
await session.commit()
assert await hooks.hook_row(session, app_id, "paid") is None
async def test_one_name_one_row_even_under_races(client, monkeypatch):
"""库里还没有 (app_id, name) 的唯一约束:并发生成只留一行;插入后发现别人抢先插了,删掉自己、改去轮换那一行;万一
多出一行,收请求只认最早那一行,停用按名字改全部行。"""
from app.db.base import utcnow
from app.db.models import AppHook
app_id = await _app()
async with SessionLocal() as session:
(await session.get(App, app_id)).draft = {"hooks": DECLARED}
await session.commit()
first, second = await asyncio.gather(client.post(f"/api/apps/{app_id}/hooks/paid/rotate"),
client.post(f"/api/apps/{app_id}/hooks/paid/rotate"))
assert first.status_code == second.status_code == 200, (first.text, second.text)
assert first.json()["id"] == second.json()["id"] and first.json()["secret"] != second.json()["secret"]
async with SessionLocal() as session:
rows = list((await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars())
assert len(rows) == 1
# 另一个进程在「读不到行」和「插入」之间抢先插好了:这一次插入后再查,删掉自己、轮换最早那一行
real = hooks.hook_row
calls = {"n": 0}
async def stale_first_read(session, app, name):
calls["n"] += 1
return None if calls["n"] == 1 else await real(session, app, name)
monkeypatch.setattr(hooks, "hook_row", stale_first_read)
async with SessionLocal() as session:
row, secret, created = await hooks.rotate_hook(session, app_id, "paid", kind="signal")
await session.commit()
monkeypatch.setattr(hooks, "hook_row", real)
assert not created and row.id == rows[0].id
async with SessionLocal() as session:
assert len(list((await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars())) == 1
# 真的多出一行(多进程各自插入):它的密钥收不到请求;停用两行一起停
from app.core.crypto import encrypt
async with SessionLocal() as session:
stray = AppHook(app_id=app_id, name="paid", kind="signal", enabled=True, secret=encrypt("stray-secret") or "",
created_at=utcnow())
session.add(stray)
await session.commit()
stray_id = stray.id
headers, body = signed("stray-secret", {"correlation": "x"}, key="k-stray")
assert (await client.post(f"/api/hooks/{stray_id}", content=body, headers=headers)).status_code == 404
assert (await client.patch(f"/api/apps/{app_id}/hooks/paid", json={"enabled": False})).status_code == 200
async with SessionLocal() as session:
states = [r.enabled for r in (await session.execute(select(AppHook).where(AppHook.app_id == app_id))).scalars()]
assert states == [False, False]
async def test_a_hook_works_by_what_the_published_version_declares(client, monkeypatch):
"""D-g:钩子做什么按应用当前发布版的声明认,不按行上记的。发布版里没声明的、停用的拒收;行上记的入口和发布版不一样时,
以发布版为准。"""
calls: list[str] = []
async def start_entry(app_id, entry_id, values, principal, **kw):
calls.append(entry_id)
return SimpleNamespace(id=f"run-{entry_id}")
monkeypatch.setattr("app.apps.entries.start_entry", start_entry)
app_id = await _app(declared=[{"name": "new", "kind": "start", "entry": "apply_v2"}])
hook_id, secret = await _hook(app_id, "start", entry_id="apply") # 行上记的是旧入口
headers, body = signed(secret, {"amount": 1}, key="k-1")
resp = await client.post(f"/api/hooks/{hook_id}", content=body, headers=headers)
assert resp.status_code == 200, resp.text
assert calls == ["apply_v2"] and resp.json()["kind"] == "start"
undeclared = await _app(declared=[]) # 只在草稿里、还没发布
hook_id, secret = await _hook(undeclared, "signal")
headers, body = signed(secret, {"correlation": "x"}, key="k-2")
resp = await client.post(f"/api/hooks/{hook_id}", content=body, headers=headers)
assert resp.status_code == 404 and "发布版本里没有声明" in resp.json()["detail"]
headers, body = signed("wrong", {"correlation": "x"}, key="k-3")
assert (await client.post(f"/api/hooks/{hook_id}", content=body, headers=headers)).status_code == 401, \
"没验过签名的请求不该探出声明"
async with SessionLocal() as session:
row = await hooks.hook_row(session, app_id, "new")
row.enabled = False
await session.commit()
hook_id = row.id
headers, body = signed(secret, {"amount": 1}, key="k-4")
assert (await client.post(f"/api/hooks/{hook_id}", content=body, headers=headers)).status_code == 404
def test_outbound_generic_signature_verifies_inbound():
+86
View File
@@ -0,0 +1,86 @@
"""待办判定后恢复运行出错(D-o)。
以前只接 ValueError / KeyError:别的异常(库被锁、检查点读不出……)冒上去,待办停在 resolving,没人能再回复,只有
重启时才收拾。现在任何异常都把待办退回 pending、这一票撤回、记日志,原因交回调用方(task_resume_failed);修好之后
同一个人可以再回复一次。超时驳回同理:退回 pending,下一拍再试。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.api.deps import Principal
from app.db.base import SessionLocal
from app.db.models import Run, Task, TaskAssignment
from app.engine.runner import run_manager
from app.process import tasks
from app.process.tasks import TaskRefused
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
FLOW = {"nodes": [node("start", "input", fields=[{"name": "q"}]),
node("review", "human", mode="approve", title="审一下"),
node("ok", "output", fields=[{"name": "结论", "value": "通过"}])],
"edges": [{"source": "start", "target": "review"},
{"source": "review", "target": "ok", "sourceHandle": "approved"}]}
ANN = Principal(username="ann", role="admin", mode="off")
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row and row.status in statuses and not run_manager.is_active(run_id):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
async def _pending(run_id: str) -> Task:
for _ in range(100):
async with SessionLocal() as session:
row = (await session.execute(select(Task).where(Task.run_id == run_id, Task.status == "pending"))).scalars().first()
if row is not None:
return row
await asyncio.sleep(0.05)
raise AssertionError("没有开出待办")
async def test_any_resume_error_puts_the_task_back(monkeypatch):
run = await run_manager.start(graph=FLOW, input_payload={"q": "x"})
await _wait(run.id, ("interrupted",))
task = await _pending(run.id)
real = run_manager.resume
async def broken(*_a, **_k):
raise RuntimeError("database is locked")
monkeypatch.setattr(run_manager, "resume", broken)
with pytest.raises(TaskRefused) as caught:
await tasks.respond(task.id, ANN, decision="approve")
assert caught.value.code == "task_resume_failed" and caught.value.status_code == 500
assert "恢复运行时出错" in str(caught.value) and "退回待处理" in str(caught.value)
async with SessionLocal() as session:
row = await session.get(Task, task.id)
mine = (await session.execute(select(TaskAssignment).where(TaskAssignment.task_id == task.id))).scalars().all()
assert row.status == "pending" and row.outcome is None and row.resolved_by is None
assert [(a.status, a.responded_by) for a in mine] == [("pending", None)], "这一票撤回,可以再回复"
monkeypatch.setattr(run_manager, "resume", real)
out = await tasks.respond(task.id, ANN, decision="approve")
assert out["resolved"] is True
assert (await _wait(run.id, ("succeeded", "failed"))).status == "succeeded"
@@ -0,0 +1,69 @@
"""调用工具节点作证的第二道(D-p,同 nodes/llm.py):名字是平台执行的查询还不够,非数据源的工具交回的工件还得是这次
运行落下的端点快照(capabilities.registry.snapshot_owned)。拼一个同样形状的 JSON、塞别的工件 id,不进台账、tool.end
上也不带 query_artifact。
"""
from __future__ import annotations
import asyncio
import pytest
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine.nodes import tools as tool_node
from app.engine.runner import run_manager
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
GRAPH = {"nodes": [node("start", "input"), node("read", "tool", tool="calculator", args={"expression": "1+1"}),
node("out", "output", fields=[{"name": "r", "value": "完成"}])],
"edges": [{"source": "start", "target": "read"}, {"source": "read", "target": "out"}]}
FORGED = {"artifact": "f" * 64, "source": "erp", "columns": ["金额"], "rows": [[999]], "truncated": False,
"request": {}}
async def _tool_end(monkeypatch, *, owned: bool | None) -> dict:
async def call_tool(*_a, **_k):
return dict(FORGED)
monkeypatch.setattr(tool_node, "call_tool", call_tool)
# 假装这个名字是登记里平台执行的端点读取(第一道:按名字认)
monkeypatch.setattr(tool_node, "is_platform_query", lambda name: name == "calculator")
if owned is not None:
async def snapshot_owned(*_a, **_k):
return owned
monkeypatch.setattr(tool_node, "snapshot_owned", snapshot_owned)
run = await run_manager.start(graph=GRAPH, input_payload={})
for _ in range(400):
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed") and not run_manager.is_active(run.id):
break
await asyncio.sleep(0.03)
assert row.status == "succeeded", row.error
async with SessionLocal() as session:
[end] = (await session.execute(select(RunEvent).where(RunEvent.run_id == run.id,
RunEvent.type == "tool.end"))).scalars().all()
return end.data
async def test_a_forged_snapshot_is_not_evidence(monkeypatch):
data = await _tool_end(monkeypatch, owned=None) # 真的 snapshot_owned:这件工件不是这次运行落的
assert "query_artifact" not in data
async def test_an_owned_snapshot_still_is(monkeypatch):
data = await _tool_end(monkeypatch, owned=True) # 阳性对照:工件对得上时照常作证
assert data["query_artifact"] == FORGED["artifact"]
+175
View File
@@ -0,0 +1,175 @@
"""子工作流固定版本与流程检查(D-q),删除工作流时的生效记录(D-n 的删工作流部分)。
- 正式运行发起时,图里每个固定了版本的子工作流节点,那一版必须「已发布且未退役」:草稿快照、不存在的拒
(subgraph_version_unpublished),退役的拒(subgraph_version_retired);
- 发布、发布前检查、应用检查都先读好子工作流(lint_process.load_subgraphs / with_subgraphs),子工作流里的人工节点
真的报出 process.human_in_subgraph(应用按流程钉住时升成 app.process_unsafe);
- 发布、发布前检查、应用检查和发起用同一条规则(apps/versions.subgraph_pin_problems):子工作流钉在草稿或退役版本上的
工作流发布不出去,钉住它的应用也报错,机器码和发起时一样;
- 删工作流:任何版本(含已退役的)有生效记录都拒删,演练不算。
"""
from __future__ import annotations
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from app.core.artifact_store import graph_hash
from app.db.base import SessionLocal, utcnow
from app.db.models import ActionRecord, Run, Workflow, WorkflowVersion
from app.engine.launch import LaunchRefused, launch_run
from app.engine.runner import run_manager
from app.main import app
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
def node(nid, ntype, label=None, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
SIMPLE = chain(node("start", "input", "开始"), node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]))
WITH_HUMAN = chain(node("start", "input", "开始"), node("ask", "human", "经理审批", mode="approve", title="批一下"),
node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]))
async def workflow(versions: dict[int, dict], *, current: int | None, graph: dict | None = None) -> str:
"""versions:{版本号: {graph, level, retired}}。"""
async with SessionLocal() as session:
wf = Workflow(name=f"子流程-{uuid.uuid4().hex[:6]}", graph=graph or SIMPLE, published_version=current,
status="published" if current else "draft", version=max(versions))
session.add(wf)
await session.flush()
for number, spec in versions.items():
g = spec.get("graph") or SIMPLE
session.add(WorkflowVersion(workflow_id=wf.id, version=number, graph=g, graph_hash=graph_hash(g),
level=spec.get("level"), published_at=utcnow() if spec.get("level") else None,
retired_at=utcnow() if spec.get("retired") else None))
await session.commit()
return wf.id
def outer(inner: str, version: int | None) -> dict:
# 固定旧版本要声明上游升版时的处置(口径升版是必须处置的事件,另有检查)
sub = {"workflow_id": inner, "upgrade_policy": "incomparable",
**({"workflow_version": version} if version is not None else {})}
return chain(node("start", "input", "开始"), node("sub", "subgraph", "子流程", **sub),
node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]))
async def launch(outer_id: str):
async with SessionLocal() as session:
return await launch_run(session, workflow_id=outer_id, run_class="formal", actor="测试")
async def test_formal_runs_refuse_subgraph_pins_that_are_not_in_use():
inner = await workflow({1: {}, 2: {"level": "published"}, 3: {"level": "published", "retired": True},
4: {"level": "published"}}, current=4)
for version, code in ((1, "subgraph_version_unpublished"), (3, "subgraph_version_retired"),
(9, "subgraph_version_unpublished")):
g = outer(inner, version)
wf = await workflow({1: {"graph": g, "level": "published"}}, current=1, graph=g)
with pytest.raises(LaunchRefused) as caught:
await launch(wf)
assert caught.value.code == code and caught.value.status_code == 409, version
assert "子工作流「子流程」" in caught.value.message
# 已发布、没退役的旧版(不是当前发布版)可以;没固定版本的跟随最新,这里不拦
for version in (2, None):
g = outer(inner, version)
wf = await workflow({1: {"graph": g, "level": "published"}}, current=1, graph=g)
run = await launch(wf)
assert run.run_class == "formal"
# 探索运行不查
async with SessionLocal() as session:
run = await launch_run(session, graph=outer(inner, 1), actor="测试")
assert run.run_class == "exploratory"
async def test_publish_and_app_lint_refuse_the_same_subgraph_pins(client):
"""发布门禁和发起同一条规则:钉在草稿、退役版本上的子工作流各级都是错误,发布前检查列出来、发布被拦;钉在已发布
版本上的照常发布。应用钉住的版本里子工作流固定的版本后来退役了,应用检查同样报错。"""
inner = await workflow({1: {}, 2: {"level": "published"}, 3: {"level": "published", "retired": True}}, current=2)
for version, code in ((1, "subgraph_version_unpublished"), (3, "subgraph_version_retired")):
g = outer(inner, version)
wf = await workflow({1: {"graph": g}}, current=None, graph=g)
check = (await client.post(f"/api/workflows/{wf}/publish-check", json={})).json()
hit = [i for i in check["issues"] if i.get("code") == code]
assert not check["ok"] and hit and hit[0]["level"] == "error" and hit[0]["node_id"] == "sub"
assert "子工作流「子流程」" in hit[0]["message"]
published = (await client.post(f"/api/workflows/{wf}/publish", json={"version": 1})).json()
assert not published["ok"] and code in {i.get("code") for i in published["issues"]}
g = outer(inner, 2)
wf = await workflow({1: {"graph": g}}, current=None, graph=g)
assert (await client.post(f"/api/workflows/{wf}/publish", json={"version": 1})).json()["ok"]
# 发布之后子工作流的 v2 退役了:钉住外层的应用要报错(正式运行发起时会被拒)
async with SessionLocal() as session:
row = (await session.execute(WorkflowVersion.__table__.select().where(
WorkflowVersion.workflow_id == inner, WorkflowVersion.version == 2))).first()
await session.execute(WorkflowVersion.__table__.update().where(WorkflowVersion.id == row.id)
.values(retired_at=utcnow()))
await session.commit()
spec = {"format": "agentlab.app/1", "name": "报表",
"pins": [{"alias": "rep", "workflow_id": wf, "version": 1, "graph_hash": graph_hash(g), "use": "entry"}],
"pages": [{"id": "home", "title": "首页", "body": []}]}
app_id = (await client.post("/api/apps", json={"name": "报表", "spec": spec})).json()["id"]
issues = (await client.post(f"/api/apps/{app_id}/lint")).json()["issues"]
hit = [i for i in issues if i["code"] == "subgraph_version_retired"]
assert hit and hit[0]["level"] == "error" and hit[0]["path"] == "pins[0]"
async def test_human_in_subgraph_is_really_checked_on_publish_and_in_apps(client):
inner = await workflow({1: {"graph": WITH_HUMAN, "level": "published"}}, current=1, graph=WITH_HUMAN)
g = outer(inner, 1)
wf = await workflow({1: {"graph": g}}, current=None, graph=g)
check = (await client.post(f"/api/workflows/{wf}/publish-check", json={})).json()
hit = [i for i in check["issues"] if i.get("code") == "process.human_in_subgraph"]
assert hit and hit[0]["level"] == "warning" and "经理审批" in hit[0]["message"]
published = (await client.post(f"/api/workflows/{wf}/publish", json={"version": 1})).json()
assert published["ok"] and "process.human_in_subgraph" in {i.get("code") for i in published["issues"]}
spec = {"format": "agentlab.app/1", "name": "流程",
"pins": [{"alias": "proc", "workflow_id": wf, "version": 1, "graph_hash": graph_hash(g), "use": "process"}],
"pages": [{"id": "home", "title": "首页", "body": []}]}
app_id = (await client.post("/api/apps", json={"name": "流程", "spec": spec})).json()["id"]
issues = (await client.post(f"/api/apps/{app_id}/lint")).json()["issues"]
unsafe = [i for i in issues if i["code"] == "app.process_unsafe"]
assert unsafe and unsafe[0]["level"] == "error" and "子工作流" in unsafe[0]["message"]
async def test_effects_on_a_retired_version_block_deleting_the_workflow(client):
wf = await workflow({1: {"level": "published", "retired": True}, 2: {"level": "published"}}, current=2)
async with SessionLocal() as session:
run = Run(workflow_name="付款", run_class="formal", status="succeeded")
session.add(run)
await session.flush()
key = uuid.uuid4().hex
rec = ActionRecord(action_key=key, idem_key=f"{key}:1", run_id=run.id, node_id="pay", capability="erp_pay",
payload_hash="h", state="confirmed", workflow_id=wf, workflow_version=1)
session.add(rec)
await session.commit()
rec_id = rec.id
refused = await client.delete(f"/api/workflows/{wf}")
assert refused.status_code == 409 and refused.json()["code"] == "workflow_in_use"
assert "v1(已退役) 有 1 条生效记录" in refused.json()["detail"]
async with SessionLocal() as session:
(await session.get(ActionRecord, rec_id)).state = "simulated"
await session.commit()
assert (await client.delete(f"/api/workflows/{wf}")).status_code == 204, "演练不算生效"