mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
应用平台集成(后端应用与流程):身份断言 sub 为发起人、确认人进 act;应用助手对话(取或建、带历史)与助手润色只能改呈现;按新口径补算(只对无副作用的视图、加锁、同一输入只补一次);钩子按名字关联、入站按发布版声明认;应用详情只对 manage 给草稿与岗位绑定;删工作流的阻挡算上已退役版本;待办恢复失败退回待处理;调用工具节点作证核对快照归属;子图固定版本须已发布且未退役(发起与发布检查都查);视图里端点快照按能力的遮罩字段遮(含统一复核后的修复)
This commit is contained in:
+188
-79
@@ -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
@@ -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, "运行记录不存在,可能已被删除")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):一个工作流版本被谁固定、生效了几次、正式运行了几次。退役确认、版本详情、删除约束、账本接口共用
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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", ""):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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 == {"电话"}
|
||||
@@ -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")]
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"]
|
||||
@@ -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, "演练不算生效"
|
||||
Reference in New Issue
Block a user