diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 5e222d48..98221793 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -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 diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 67c13126..55946277 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -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 绑两个库的" + "查询工具,各自聚合后在 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, "运行记录不存在,可能已被删除") diff --git a/backend/app/api/copilot_gen.py b/backend/app/api/copilot_gen.py index 0670ceab..75f57d95 100644 --- a/backend/app/api/copilot_gen.py +++ b/backend/app/api/copilot_gen.py @@ -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) diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 5b184835..021163e0 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -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"]}) diff --git a/backend/app/apps/backfill.py b/backend/app/apps/backfill.py new file mode 100644 index 00000000..6777986c --- /dev/null +++ b/backend/app/apps/backfill.py @@ -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 diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index 0e1b5edf..fd09217c 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -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) diff --git a/backend/app/apps/ops.py b/backend/app/apps/ops.py index d74028d4..f0d28eb3 100644 --- a/backend/app/apps/ops.py +++ b/backend/app/apps/ops.py @@ -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 + diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 3c835a6d..1aeb579e 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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 diff --git a/backend/app/apps/versions.py b/backend/app/apps/versions.py index 6444975f..0bed9541 100644 --- a/backend/app/apps/versions.py +++ b/backend/app/apps/versions.py @@ -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):一个工作流版本被谁固定、生效了几次、正式运行了几次。退役确认、版本详情、删除约束、账本接口共用 # -------------------------------------------------------------------------- diff --git a/backend/app/capabilities/endpoints.py b/backend/app/capabilities/endpoints.py index 5ea9a8bc..66fd883d 100644 --- a/backend/app/capabilities/endpoints.py +++ b/backend/app/capabilities/endpoints.py @@ -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: diff --git a/backend/app/engine/launch.py b/backend/app/engine/launch.py index d22c9d81..d87f50ff 100644 --- a/backend/app/engine/launch.py +++ b/backend/app/engine/launch.py @@ -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: diff --git a/backend/app/engine/lint_process.py b/backend/app/engine/lint_process.py index 45e3b81b..5e95b271 100644 --- a/backend/app/engine/lint_process.py +++ b/backend/app/engine/lint_process.py @@ -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() diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index cd4e1b09..b5bbc148 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -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, ) diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 93db1ff2..4111112f 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -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, ) diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index b5b73732..d861955c 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -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", ""): diff --git a/backend/app/process/actions.py b/backend/app/process/actions.py index 678ff7ee..9c8424a6 100644 --- a/backend/app/process/actions.py +++ b/backend/app/process/actions.py @@ -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: diff --git a/backend/app/process/hooks.py b/backend/app/process/hooks.py index bb1479bc..0066ef85 100644 --- a/backend/app/process/hooks.py +++ b/backend/app/process/hooks.py @@ -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) diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 8cf3a2e0..bd8ce31f 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -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) diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 0cc29a80..f51a4dde 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -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: diff --git a/backend/tests/test_action_assertion.py b/backend/tests/test_action_assertion.py new file mode 100644 index 00000000..52bc5afe --- /dev/null +++ b/backend/tests/test_action_assertion.py @@ -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) diff --git a/backend/tests/test_app_access_scope.py b/backend/tests/test_app_access_scope.py new file mode 100644 index 00000000..67a0f025 --- /dev/null +++ b/backend/tests/test_app_access_scope.py @@ -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) diff --git a/backend/tests/test_app_backfill.py b/backend/tests/test_app_backfill.py new file mode 100644 index 00000000..4c10ce1b --- /dev/null +++ b/backend/tests/test_app_backfill.py @@ -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"] diff --git a/backend/tests/test_app_endpoint_masks.py b/backend/tests/test_app_endpoint_masks.py new file mode 100644 index 00000000..fcbf1ef6 --- /dev/null +++ b/backend/tests/test_app_endpoint_masks.py @@ -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 == {"电话"} diff --git a/backend/tests/test_app_polish_scope.py b/backend/tests/test_app_polish_scope.py new file mode 100644 index 00000000..bf3302b4 --- /dev/null +++ b/backend/tests/test_app_polish_scope.py @@ -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")] diff --git a/backend/tests/test_app_use.py b/backend/tests/test_app_use.py index f6e8838c..16f25e7d 100644 --- a/backend/tests/test_app_use.py +++ b/backend/tests/test_app_use.py @@ -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): diff --git a/backend/tests/test_process_actions.py b/backend/tests/test_process_actions.py index 105cd80a..d4706f0f 100644 --- a/backend/tests/test_process_actions.py +++ b/backend/tests/test_process_actions.py @@ -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): diff --git a/backend/tests/test_process_hooks.py b/backend/tests/test_process_hooks.py index 90fb6922..f45c9d8a 100644 --- a/backend/tests/test_process_hooks.py +++ b/backend/tests/test_process_hooks.py @@ -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(): diff --git a/backend/tests/test_task_resume_errors.py b/backend/tests/test_task_resume_errors.py new file mode 100644 index 00000000..db9b885e --- /dev/null +++ b/backend/tests/test_task_resume_errors.py @@ -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" diff --git a/backend/tests/test_tool_node_snapshot_owner.py b/backend/tests/test_tool_node_snapshot_owner.py new file mode 100644 index 00000000..10f63b43 --- /dev/null +++ b/backend/tests/test_tool_node_snapshot_owner.py @@ -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"] diff --git a/backend/tests/test_workflow_pins_process.py b/backend/tests/test_workflow_pins_process.py new file mode 100644 index 00000000..5b1b75fb --- /dev/null +++ b/backend/tests/test_workflow_pins_process.py @@ -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, "演练不算生效"