diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index a30dd51b..4a55b5c5 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.deps import Principal, actor_of, current_principal -from app.apps.access import app_role, at_least, grants_of +from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of from app.db.base import get_session from app.db.models import Run from app.db.models_apps import App, AppGrant, AppHook, AppVersion @@ -32,7 +32,8 @@ APP_GONE = "应用不存在,可能已被删除" NO_ACCESS = "app_forbidden" NOT_PUBLISHED = "app_unpublished" PUBLISH_CONFLICT = "app_publish_conflict" -_ROLE_TEXT = {"use": "使用", "observe": "查看全部实例", "manage": "管理"} +#: 归档了的应用不再改草稿、不再发布(没有恢复的接口:归档后这个应用只留着给进行中的实例走完、给人查) +APP_ARCHIVED = "app_archived" @router.get("/catalog") @@ -65,10 +66,15 @@ async def _app(session: AsyncSession, app_id: str, principal: Principal, need: s # 没有任何授权的人不该知道这个应用在不在 raise HTTPException(404, APP_GONE) if not at_least(role, need): - raise CodedHTTPException(403, f"需要这个应用的「{_ROLE_TEXT[need]}」权限", NO_ACCESS) + raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS) return app, role +def _refuse_archived(app: App, what: str) -> None: + if app.status == "archived": + raise CodedHTTPException(409, f"「{app.name}」已归档,不能再{what}", APP_ARCHIVED) + + def _require_builder(principal: Principal) -> None: if not principal.at_least("builder"): raise CodedHTTPException(403, "搭建应用需要搭建者权限", NO_ACCESS) @@ -248,6 +254,7 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep """改草稿:spec、岗位绑定、名称等。草稿随便改,发布时才过检查(形状不对的检查里报 app.format)。""" principal = _principal(x_actor) app, role = await _app(session, app_id, principal, "manage") + _refuse_archived(app, "修改") if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)): raise HTTPException(422, problem) for key in ("name", "description", "icon"): @@ -266,7 +273,8 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep @router.post("/{app_id}/archive") async def archive_app(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: - """归档:不再出现在应用列表里,入口拒绝发起;版本、实例、生效记录都留着。""" + """归档:不再出现在应用列表里,入口拒绝发起、入站钩子一律拒收(apps/entries.py、process/hooks.py),不再改草稿、 + 不再发布;进行中的实例照常走完,版本、实例、生效记录都留着。没有恢复的接口。""" from app.apps.audit import record principal = _principal(x_actor) @@ -375,6 +383,7 @@ async def publish(app_id: str, payload: PublishIn, session: AsyncSession = Depen principal = _principal(x_actor) app, _ = await _app(session, app_id, principal, "manage") + _refuse_archived(app, "发布") previous = await _previous(session, app) draft = app.draft or {} issues = await lint_app(session, draft, app.draft_bindings or {}, previous=previous, @@ -465,8 +474,14 @@ async def get_grants(app_id: str, session: AsyncSession = Depends(get_session), @router.put("/{app_id}/grants") async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: - """整份替换授权。授权不进应用版本(改了不用重新发布),写审计。""" + """整份替换授权。授权不进应用版本(改了不用重新发布),写审计。 + + 给的人和组按名录核对(auth/directory.py,同选人控件、同应用检查):账号密码登录时名录里没有的、任何登录方式下停用了 + 的拒收,说清是哪一档、哪个名字——界面上选人时已经拦着,这里拦旧客户端和直接调接口的。单点登录的人第一次登录后才进 + 名录,名录里还没有的照收(检查里提醒);关登录不核对。""" + from app.api.deps import auth_mode from app.apps.audit import record + from app.auth.directory import load_directory, mismatch from app.db.models_apps import GRANT_ROLES app, _ = await _app(session, app_id, _principal(x_actor), "manage") @@ -480,6 +495,12 @@ async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Dep key = (item.principal_kind, principal, item.role) if key not in wanted: wanted.append(key) + if (mode := auth_mode()) != "off": + known = await load_directory(session) + refused = [f"「{ROLE_TEXT[role]}」里的{m.text}" for kind, who, role in wanted + if (m := mismatch(known, mode, kind, who)) is not None and m.blocking] + if refused: + raise HTTPException(422, f"{';'.join(refused)}:授权只能给名录里在用的人和组,请移除或从候选中重新选择") before = [_grant_out(g) for g in await grants_of(session, app.id)] for g in await grants_of(session, app.id): await session.delete(g) diff --git a/backend/app/api/copilot_gen.py b/backend/app/api/copilot_gen.py index 75f57d95..f33abc79 100644 --- a/backend/app/api/copilot_gen.py +++ b/backend/app/api/copilot_gen.py @@ -411,11 +411,12 @@ APP_REPAIR_PROMPT = ( ) REPAIR_LOOKS_PROMPT = "\n\n这一轮查看过的内容(和起草时看到的是同一份):\n{text}" -#: 应用检查里只有人能处理的问题:钉住哪个版本、升版怎么处置、岗位和核实人绑给谁、授权、通知渠道。不交回模型改, -#: 照常列在交付的问题里 +#: 应用检查里只有人能处理的问题:钉住哪个版本、升版怎么处置、岗位和核实人绑给谁(绑的人在不在名录里)、授权、通知 +#: 渠道。不交回模型改,照常列在交付的问题里 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.slot_principal_unknown", "app.reconciler_unbound", "app.acl_empty", "app.acl_principal_unknown", + "app.notify_channel_missing", "app.process_unsafe", "app.view_source_empty", "subgraph_version_unpublished", "subgraph_version_retired", }) diff --git a/backend/app/api/identity.py b/backend/app/api/identity.py index 75eadbe5..01759cd5 100644 --- a/backend/app/api/identity.py +++ b/backend/app/api/identity.py @@ -214,12 +214,14 @@ async def delete_group(group_id: str, session: AsyncSession = Depends(get_sessio @directory_router.get("/directory") async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: - """选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。""" - users = (await session.execute(select(User).order_by(User.username))).scalars() - groups = (await session.execute(select(Group).order_by(Group.name))).scalars() - return {"users": [{"username": u.username, "display_name": u.display_name or u.username, "disabled": u.disabled} - for u in users], - "groups": [{"name": g.name, "label": g.label or g.name} for g in groups]} + """选人用的名录。停用的人也列出来(标 disabled):已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」。 + 和应用检查、改授权时的核对同一处取(auth/directory.py)。""" + from app.auth.directory import load_directory + + known = await load_directory(session) + return {"users": [{"username": u.username, "display_name": u.display_name, "disabled": u.disabled} + for u in known.users.values()], + "groups": [{"name": g.name, "label": g.label} for g in known.groups.values()]} # ---------------------------------------------------------------- 审计 diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index ac7eb2fa..13efb265 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -14,7 +14,7 @@ from app.capabilities import secrets as vault from app.capabilities.endpoints import bound_endpoint_ref, find_endpoint from app.core.errors import explain, raw from app.db.base import get_session -from app.db.models import CustomTool, Endpoint, McpServer +from app.db.models import Capability, CustomTool, Endpoint, McpServer from app.tools.custom import ToolFailure, held_write, http_failure, schema_problem, trial from app.tools.mcp_manager import mcp_manager from app.tools.registry import ( @@ -72,6 +72,15 @@ async def list_tools(session: AsyncSession = Depends(get_session)) -> list[dict[ for t in await mcp_manager.list_tools()) except Exception: # noqa: BLE001 - MCP 连不上不该让整个工具列表挂掉 pass + # 在工具库里执行是不是只演练(写外部系统的能力,和执行接口 _held 同一个判断):详情区的提示条照这个说「这里只演练, + # 不真的发送」,不说「请求会实际发出」 + customs = {row.name: row for row in (await session.execute(select(CustomTool))).scalars()} + # 能力登记一次读进会话:_held 逐个 session.get,登记里有的直接从会话里取,不再一个工具查一次库(会话按弱引用记着 + # 读过的行,列表留到循环结束) + _caps = (await session.execute(select(Capability))).scalars().all() + for item in out: + custom = customs.get(item["id"]) if item["source"] == "custom" else None + item["write_rehearsal"] = await _held(item["id"], custom, session) return out diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 801b6e99..f68c6fb4 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from app.core.errors import graph_error from app.api.coded import CodedHTTPException @@ -66,6 +67,9 @@ class WorkflowOut(BaseModel): published_by: str | None = None #: 当前发布版本的级别(published / governed)。status 在画布上改过之后会回到 draft,「这是不是受管工作流」看这一项 published_level: str | None = None + #: 画布这一版的图和当前发布版的图内容相同(按 graph_hash 比):撤回改动后保存出来的新版本号更大,内容却和发布版 + #: 一样,页头照实写「与发布版内容相同」,不写「领先 N 版」。没发布过、老数据没记哈希的为 false + same_as_published: bool = False created_at: Any = None updated_at: Any = None run_count: int = 0 @@ -82,6 +86,17 @@ async def _published_levels(session: AsyncSession, ids: list[str] | None = None) return {wid: level for wid, level in (await session.execute(stmt)).all() if level} +async def _same_as_published(session: AsyncSession, ids: list[str] | None = None) -> set[str]: + """画布这一版(Workflow.version)的图哈希和当前发布版相同的工作流 id。两边哈希都记着才比,不在前端比图。""" + current, published = aliased(WorkflowVersion), aliased(WorkflowVersion) + stmt = select(Workflow.id, current.graph_hash, published.graph_hash).join( + current, (current.workflow_id == Workflow.id) & (current.version == Workflow.version)).join( + published, (published.workflow_id == Workflow.id) & (published.version == Workflow.published_version)) + if ids is not None: + stmt = stmt.where(Workflow.id.in_(ids)) + return {wid for wid, mine, theirs in (await session.execute(stmt)).all() if mine and mine == theirs} + + async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]: """各工作流的运行次数 {工作流 id: 次数},ids 为 None 时算全部。列表和单个工作流的接口都按它算:以前只有列表 计数,详情、保存、恢复的返回落在 WorkflowOut 的默认值 0 上,同一个工作流两处说的次数不一样。""" @@ -96,6 +111,7 @@ async def _workflow_out(session: AsyncSession, workflow: Workflow) -> WorkflowOu item = WorkflowOut.model_validate(workflow) item.run_count = (await _run_counts(session, [workflow.id])).get(workflow.id, 0) item.published_level = (await _published_levels(session, [workflow.id])).get(workflow.id) + item.same_as_published = workflow.id in await _same_as_published(session, [workflow.id]) return item @@ -106,11 +122,13 @@ async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[W ) counts = await _run_counts(session) levels = await _published_levels(session) + same = await _same_as_published(session) out: list[WorkflowOut] = [] for row in rows: item = WorkflowOut.model_validate(row) item.run_count = counts.get(row.id, 0) item.published_level = levels.get(row.id) + item.same_as_published = row.id in same out.append(item) return out diff --git a/backend/app/apps/access.py b/backend/app/apps/access.py index 0ddbc778..a0ce020e 100644 --- a/backend/app/apps/access.py +++ b/backend/app/apps/access.py @@ -17,6 +17,8 @@ from app.api.deps import Principal from app.db.models_apps import AppGrant RANK = {"use": 1, "observe": 2, "manage": 3} +#: 三档授权的叫法(同前端 GRANT_ROLE_LABEL):权限不够的报错、授权核对的说明里写这个 +ROLE_TEXT = {"use": "使用", "observe": "查看全部实例", "manage": "管理"} def at_least(role: str | None, need: str) -> bool: diff --git a/backend/app/apps/lint.py b/backend/app/apps/lint.py index b4030f0d..98f13295 100644 --- a/backend/app/apps/lint.py +++ b/backend/app/apps/lint.py @@ -553,6 +553,7 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[ label = (info or {}).get("label") or slot why = f"({';'.join(used[slot][:3])}):没有人,流程走到这一步就派给了任何人" if used.get(slot) else "" out.error("app.slot_unbound", f"岗位「{label}」还没有绑定到人或组{why}", f"slots.{slot}") + await _lint_slot_principals(session, slots, bound, out) for state in process: nodes = node_map(state.graph) for node_id, cap in writes[state.alias].items(): @@ -572,6 +573,33 @@ async def _lint_process(session: AsyncSession, spec: dict[str, Any], pins: dict[ f"pins[{_pin_index(spec, state.alias)}]", node_id=issue.node_id) +async def _lint_slot_principals(session: AsyncSession, slots: dict[str, Any], bound: dict[str, Any], + out: _Issues) -> None: + """岗位绑定的人和组要在名录里(auth/directory.py,同选人控件的名录):绑了名录里没有的、已停用的,流程走到这一步就 + 派给了没人能处理的名字——界面上选人时拦着,旧客户端、直接调接口、绑好之后人被停用或删除的照样会漏过来,所以发布时 + 再核一次。账号密码登录报错;单点登录的人第一次登录后才进名录,名录里还没有的只提醒;关登录不核对。""" + from app.api.deps import auth_mode + from app.auth.directory import load_directory, mismatch + + mode = auth_mode() + if mode == "off" or not any(bound.get(s) for s in slots): + return + known = await load_directory(session) + for slot, info in slots.items(): + label = (info or {}).get("label") or slot + for item in bound.get(slot) or []: + m = mismatch(known, mode, item.get("kind"), str(item.get("name"))) if isinstance(item, dict) else None + if m is None: + continue + if m.blocking: + out.error("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}:派给这个名字的待办没有人能处理" + "(全体会签时整条待办办不完)。请从候选中重新选择,或从岗位里移除", f"slots.{slot}", + problem=m.problem) + else: + out.warning("app.slot_principal_unknown", f"岗位「{label}」绑定的{m.text}", f"slots.{slot}", + problem=m.problem) + + def _pin_index(spec: dict[str, Any], alias: str) -> int: return next((i for i, p in enumerate(spec.get("pins") or []) if p.get("alias") == alias), 0) @@ -597,17 +625,37 @@ async def _lint_notify(session: AsyncSession, spec: dict[str, Any], out: _Issues async def acl_issues(session: AsyncSession, app_id: str) -> list[dict[str, Any]]: - """开了登录却没有任何 use 以上的授权:除了管理员和搭建者,谁都用不了这个应用。关登录时不报。""" - from app.api.deps import auth_mode - from app.db.models_apps import AppGrant + """开了登录却没有任何 use 以上的授权:除了管理员和搭建者,谁都用不了这个应用。关登录时不报。 - if auth_mode() == "off": + 授权给的人和组要在名录里(同岗位绑定,见 _lint_slot_principals):授给名录里没有的名字,以后建了同名的人就直接拿到 + 这一档权限;授给停用的人,重新启用就又能用——都说清是哪一档、哪个名字。账号密码登录报错,单点登录名录里还没有的只提醒。""" + from app.api.deps import auth_mode + from app.apps.access import ROLE_TEXT, grants_of + from app.auth.directory import load_directory, mismatch + + mode = auth_mode() + if mode == "off": return [] - count = len((await session.execute(select(AppGrant.id).where(AppGrant.app_id == app_id))).all()) - if count: - return [] - return [{"message": "还没有授权给任何人或组使用这个应用:只有管理员和搭建者能打开它", "level": "warning", - "code": "app.acl_empty", "path": None}] + grants = await grants_of(session, app_id) + if not grants: + return [{"message": "还没有授权给任何人或组使用这个应用:只有管理员和搭建者能打开它", "level": "warning", + "code": "app.acl_empty", "path": None}] + out = _Issues() + known = await load_directory(session) + for g in grants: + if (m := mismatch(known, mode, g.principal_kind, g.principal)) is None: + continue + what = f"「{ROLE_TEXT.get(g.role, g.role)}」授权给的{m.text}" + if m.blocking: + out.error("app.acl_principal_unknown", f"{what}:{_GRANT_RISK[m.problem]}", "grants", problem=m.problem) + else: + out.warning("app.acl_principal_unknown", what, "grants", problem=m.problem) + return out.items + + +#: 授权给了名录里对不上的名字,会出什么事 +_GRANT_RISK = {"missing": "以后建了同名的人会直接拿到这一档权限。请从授权里移除,或从候选中重新选择", + "disabled": "重新启用后他又能用这个应用。请从授权里移除"} def pins_with_state(spec: dict[str, Any], pins: dict[str, Any]) -> list[dict[str, Any]]: diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 14f0d645..652dbfe3 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -405,6 +405,8 @@ class _Page: params: dict[str, Any] draft: bool actor: str | None + #: 正在解析的这一页(组件之间要互相知道的:同一页上有没有绑同一条待办的会签组件) + page: dict[str, Any] = field(default_factory=dict) pins: dict[str, PinState] = field(default_factory=dict) sources: dict[str, Source] = field(default_factory=dict) problems: list[dict[str, Any]] = field(default_factory=list) @@ -454,7 +456,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip if page is None: raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found") ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role, - instance=instance, params=params or {}, draft=draft, actor=actor) + instance=instance, params=params or {}, draft=draft, actor=actor, page=page) ctx.pins = await load_pins(session, spec) upgrade = needs_upgrade(ctx.pins) level = version.level if (version is not None and not draft) else app_level( @@ -555,7 +557,7 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return Source(id=vid, kind=kind, state="empty", pin=state, message="按需运行的视图:点「刷新」后按当前参数运行一次") if run.status in _ACTIVE: - return Source(id=vid, kind=kind, state="running", run=run, pin=state, message="正在运行,完成后刷新页面") + return Source(id=vid, kind=kind, state="running", run=run, pin=state, message=await _active_text(session, run)) source = Source(id=vid, kind=kind, state="ok" if run.status == "succeeded" else "error", run=run, pin=state, origin=src.get("from")) if run.status != "succeeded": @@ -563,6 +565,19 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return source +#: 来源的运行还没结束时各块写什么。页面在跟着(应用页轮询、实例页轮询或接事件流),不用人去刷新 +RUNNING_TEXT = "正在运行,完成后自动刷新" + + +async def _active_text(session: AsyncSession, run: Run) -> str: + """来源的运行还没结束:在跑写「正在运行」;停着等人(interrupted)按在等的那一步说等什么(等待审批、等待确认发送、 + 等待核实写入结果……,同实例的状态 instance_state),不说「正在运行」——页头写着「等待审批」,这里不能对不上。""" + if run.status != "interrupted": + return RUNNING_TEXT + text = instance_state(run, (await _trails(session, [run]))[run.id])["text"] + return f"{text},流程走完后自动显示" + + async def _empty_text(session: AsyncSession, state: PinState, origin: Any) -> str: """最近封存运行的视图还没有数据时说什么:还没有、什么时候会有(定时任务的下次运行时间,或者要等一次正式运行)。""" if isinstance(origin, str) and origin.startswith("schedule:"): @@ -1095,12 +1110,42 @@ def _decision(trail: _Trail, node_id: str) -> tuple[str | None, str | None]: return done[-1].kind, (done[-1].outcome or {}).get("decision") +#: 人工节点的「驳回」出口(engine/layout.py 的 approved / rejected) +REJECTED_HANDLE = "rejected" +_REJECTIONS = ("reject", "expired_reject") + + +def rejected_outputs(graph: Any) -> dict[str, set[str]]: + """成果节点 → 从哪几个人工节点的「驳回」出口一路走到它(中间不经过别的人工节点:再遇到人工节点,结局由那一个人 + 的决定说了算)。按图认,不按成果节点的标题猜。""" + from app.apps.tree import node_map + + nodes = node_map(graph) + edges = [e for e in (graph or {}).get("edges") or [] if isinstance(e, dict)] + out: dict[str, set[str]] = {} + for gate in (e for e in edges if e.get("sourceHandle") == REJECTED_HANDLE + and (nodes.get(str(e.get("source"))) or {}).get("type") == "human"): + seen: set[str] = set() + todo = [str(gate.get("target"))] + while todo: + nid = todo.pop() + if nid in seen or nid not in nodes or nodes[nid]["type"] == "human": + continue + seen.add(nid) + if nodes[nid]["type"] == "output": + out.setdefault(nid, set()).add(str(gate["source"])) + todo += [str(e.get("target")) for e in edges if str(e.get("source")) == nid] + return out + + def instance_state(run: Run, trail: _Trail) -> dict[str, str]: """实例给人看的状态 {code, text}:code 是界面画状态标记用的状态(运行的那几种,加 held),text 说这件事办到哪了、 结论是什么,不只是运行状态(P1)。 - 走完了:图里有几个成果节点(分支的几种结局,如「已通过」「已退回」)、恰好走到了其中一个的,写走到的那一个;只有 - 一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局; + 一个成果节点的、几个成果节点并行都走到了的(「周报」「明细」一起出)写「已完成」——那不是哪一种结局。走到的是 + 否定的那种结局——这次运行里人工节点驳回了、从它的「驳回」出口一路走到的成果节点(rejected_outputs)——code 记 + cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题; - 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……); - 失败:停在一个被人驳回的待办上的——动作确认被驳回(「付款」未确认(被驳回))、审批设了驳回即终止、补充输入被 驳回——是人的决定,不是故障,code 记 cancelled;核实为未生效、选了放弃的同理。外部系统拒绝之类照写失败。 @@ -1124,7 +1169,9 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: outputs = {nid for nid, n in nodes.items() if n["type"] == "output"} reached = [nid for etype, nid in trail.events if etype == "node.finished" and nid in outputs] if len(outputs) > 1 and len(set(reached)) == 1: - return {"code": "succeeded", "text": title(reached[0])} + gates = rejected_outputs(run.graph).get(reached[0], set()) + negative = any(_decision(trail, gate)[1] in _REJECTIONS for gate in gates) + return {"code": "cancelled" if negative else "succeeded", "text": title(reached[0])} return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]} if run.status == "failed": failed = [nid for etype, nid in trail.events if etype == "node.failed"] @@ -1133,7 +1180,7 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: kind, decision = _decision(trail, node_id) if kind == "action_confirm" and decision == "reject": return {"code": "cancelled", "text": f"「{title(node_id)}」未确认(被驳回)"} - if decision in ("reject", "expired_reject"): + if decision in _REJECTIONS: return {"code": "cancelled", "text": f"「{title(node_id)}」被驳回"} record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None) if record is not None and record.state == "failed": @@ -1241,7 +1288,8 @@ async def _timeline(session: AsyncSession, ctx: _Page) -> dict[str, Any]: text = WAITING_TEXT.get(pending[-1].kind) elif step["state"] == "failed" and kind == "action_confirm" and decision == "reject": step["state"], text = "rejected", "未确认(被驳回)" - elif step["state"] == "failed" and decision in ("reject", "expired_reject"): + elif step["state"] in ("failed", "done") and decision in _REJECTIONS: + # 驳回了:驳回即终止的(节点失败)和从「驳回」出口接着走的(节点完成)都是人的决定,不画成成功 step["state"], text = "rejected", _DECISION_TEXT[decision] elif step["state"] == "failed" and record is not None and (record.resolution or {}).get("outcome") == "abandon": step["state"], text = "rejected", "核实为未生效,已放弃发送" @@ -1324,9 +1372,14 @@ async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str """待办(task_form、signoffs)、动作(action_status)。 待办这里就判定读者是不是这条待办的处理人(process.tasks.handles):是的,界面按 id 取完整的待办画待办卡(能处理的 - 给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。""" + 给表单);不是的(发起人、旁观的人)只看这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是 404)。 + + 同一页上另有绑同一条待办的会签组件(signoffs)时,待办表单不再画会签进度(signoffs_on_page):同一页上「会签进度 + 0/2」不写两遍。人名给显示名(people:用户名 → 显示名),界面写显示名、悬停给用户名。""" + from app.apps.tree import components + from app.auth.directory import display_names from app.db.models_process import ActionRecord, Task, TaskAssignment - from app.process.tasks import handles, my_response, principal_with_actor, progress + from app.process.tasks import handles, my_response, people_of, principal_with_actor, progress run = ctx.instance if run is None: @@ -1340,13 +1393,18 @@ async def _process_data(session: AsyncSession, ctx: _Page, ctype: Any, kind: str .order_by(TaskAssignment.created_at))).scalars()) reader = principal_with_actor(ctx.principal, ctx.actor) resolved_at = _aware(task.resolved_at).isoformat() if task.resolved_at else None + signoffs = ctype == "task_form" and any( + c.get("type") == "signoffs" and isinstance(c.get("bind"), dict) and c["bind"].get("task") == node_id + for c, _, _ in components({"pages": [ctx.page]})) return {"state": "ok", "task": {**_task_row(task, instance_title(run)), "form": task.form, "resolved_by": task.resolved_by, "resolved_at": resolved_at, "outcome": {"decision": (task.outcome or {}).get("decision")} if task.outcome else None}, "task_ui": (ctx.spec.get("task_ui") or {}).get(node_id) or {}, "handler": handles(rows, reader), "assignments": [_assignment_row(a) for a in rows], - "progress": progress(task, rows), "my_response": my_response(rows, reader)} + "progress": progress(task, rows), "my_response": my_response(rows, reader), + "people": await display_names(session, people_of(task, rows)), + **({"signoffs_on_page": True} if signoffs else {})} records = (await session.execute(select(ActionRecord).where(ActionRecord.run_id == run.id, ActionRecord.node_id == node_id) .order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars() diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py new file mode 100644 index 00000000..6f689ec1 --- /dev/null +++ b/backend/app/auth/directory.py @@ -0,0 +1,97 @@ +"""人和组的名录:选人控件的候选(GET /api/directory)、应用检查核对岗位绑定和授权、改授权时核对填的名字,同一处取、 +同一个口径(停用的人也在名录里,标 disabled:已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」)。 + +名录里对不上怎么算看登录方式(同前端 PrincipalPicker): +- 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错; +- 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错; +- 关登录:没有用户表,签批记的是各自填的署名,不核对。 +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Literal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +#: 名录里对不上的两种:没有这个人或组、人停用了 +Problem = Literal["missing", "disabled"] +_KIND = {"user": "用户", "group": "组"} + + +@dataclass(frozen=True) +class DirectoryUser: + username: str + display_name: str + disabled: bool + + +@dataclass(frozen=True) +class DirectoryGroup: + name: str + label: str + + +@dataclass +class Directory: + users: dict[str, DirectoryUser] = field(default_factory=dict) + groups: dict[str, DirectoryGroup] = field(default_factory=dict) + + def problem(self, kind: str, name: str) -> Problem | None: + """这一项在名录里对不上的地方;所有人(everyone)不判。""" + if kind == "group": + return None if name in self.groups else "missing" + if kind == "user": + user = self.users.get(name) + return "missing" if user is None else "disabled" if user.disabled else None + return None + + +async def display_names(session: AsyncSession, usernames: Iterable[str | None]) -> dict[str, str]: + """用户名 → 显示名(名录里有的;没写显示名的就是用户名)。待办上派给谁、谁回复的、谁处理的给人看时写显示名,悬停给 + 用户名。关登录时没有用户表,查不到的照写署名(调用方 people.get(名字, 名字))。""" + from app.db.models import User + + wanted = sorted({u for u in usernames if u}) + if not wanted: + return {} + rows = (await session.execute(select(User.username, User.display_name).where(User.username.in_(wanted)))).all() + return {username: display or username for username, display in rows} + + +async def load_directory(session: AsyncSession) -> Directory: + from app.db.models import Group, User + + users = (await session.execute(select(User).order_by(User.username))).scalars() + groups = (await session.execute(select(Group).order_by(Group.name))).scalars() + return Directory( + users={u.username: DirectoryUser(u.username, u.display_name or u.username, u.disabled) for u in users}, + groups={g.name: DirectoryGroup(g.name, g.label or g.name) for g in groups}) + + +@dataclass(frozen=True) +class Mismatch: + """一项对不上:blocking 为真是错(账号密码登录名录里没有、任何登录方式下停用了),为假只提醒(单点登录名录里还没有)。 + text 是「用户「x」已停用」这样的半句,调用方接上是哪个岗位、哪一档授权。""" + kind: str + name: str + problem: Problem + blocking: bool + text: str + + +def mismatch(directory: Directory, mode: str, kind: str, name: str) -> Mismatch | None: + """按登录方式判这一项:关登录、所有人、对得上的都是 None。""" + if mode == "off": + return None + problem = directory.problem(kind, name) + if problem is None: + return None + what = f"{_KIND.get(kind, kind)}「{name}」" + if problem == "disabled": + return Mismatch(kind, name, problem, True, f"{what}已停用") + if mode == "oidc": + return Mismatch(kind, name, problem, False, + f"{what}名录里还没有:单点登录的人和组第一次登录后才进名录,名字要和身份提供方里的一致才生效") + return Mismatch(kind, name, problem, True, f"{what}名录里没有") diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 78f125f7..d0726872 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -22,6 +22,7 @@ from typing import Any from sqlalchemy import and_, exists, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.directory import display_names from app.core.events import EventType from app.db.base import SessionLocal, utcnow from app.db.models import ( @@ -909,6 +910,7 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> .where(Run.id.in_({t.run_id for t in tasks})))).all()} apps = dict((await session.execute(select(App.id, App.name).where( App.id.in_({t.app_id for t in tasks if t.app_id})))).all()) + names = await display_names(session, [u for t in tasks for u in people_of(t, assignments.get(t.id, []))]) out = [] for task in tasks: rows = assignments.get(task.id, []) @@ -936,10 +938,18 @@ async def _describe(session: AsyncSession, tasks: list[Task], principal: Any) -> "my_response": my_response(rows, principal), "outcome": task.outcome, "resolved_by": task.resolved_by, "resolved_at": _iso(task.resolved_at), "created_at": _iso(task.created_at), + # 这条待办上出现的人的显示名(派给谁、谁回复、谁处理、升级给谁):界面写显示名,悬停给用户名 + "people": {u: names[u] for u in people_of(task, rows) if u in names}, }) return out +def people_of(task: Task, rows: list[TaskAssignment]) -> set[str]: + """待办上按用户名出现的人:派给的人、回复的人、处理的人。""" + return ({a.principal for a in rows if a.principal_kind == "user"} | {a.responded_by for a in rows if a.responded_by} + | ({task.resolved_by} if task.resolved_by else set())) + + def visible(item: dict[str, Any], principal: Any) -> bool: """这条待办(_describe 的形状)这个人看不看得到:关登录、builder 以上都看得到;user 角色只看派给自己(或自己的组)的, 撤回了的不算。待办里带着中断内容(报告概况、动作预览),不能谁都能按 id 读。""" diff --git a/backend/tests/test_app_instance_page.py b/backend/tests/test_app_instance_page.py index 83112a8f..764273a0 100644 --- a/backend/tests/test_app_instance_page.py +++ b/backend/tests/test_app_instance_page.py @@ -5,9 +5,13 @@ 不是的只给这一步的状态(派给了谁、会签进度、时限),不让界面去取; - 会签进度把登录核实过的审批人写成「自报署名」、没有处理时间:页面解析给的派给谁要带上这两项; - 同意过的人再打开写「没有派给你」:待办上带这个人的回复,写「你已处理」; -- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点; +- 走了「已退回」分支的实例写「已完成」:实例的状态说流程走到了哪个成果节点;走到的是从人工节点「驳回」出口一路走到的 + 成果节点(否定的结局)时不画成成功的绿勾,时间线上「已驳回」那一步同样; - 升级之后写「0/3 已同意」,其实总监一人同意就通过:会签人数不算升级加派的那一份,升级后的规则单列; -- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间。 +- 同一个人两笔报销,待办列表里标题一样分不开:待办带上实例标题和开出的时间; +- 停着等审批时印章那一块写「正在运行,完成后刷新页面」,和页头的「等待审批」「自动刷新」对不上:按在等的那一步说; +- 「会签进度 0/2」画了两遍(待办表单一遍、会签组件一遍):同一页上有绑同一条待办的会签组件时,服务端告诉待办表单不画; +- 等待名单、会签名单写的是用户名:服务端给显示名(people),界面写显示名、悬停给用户名。 """ from __future__ import annotations @@ -49,11 +53,13 @@ def expense() -> dict: return {"nodes": nodes, "edges": edges} -SPEC = {"format": "agentlab.app/1", "name": "报销", "pages": [{"id": "instance", "title": "实例", "body": [ - {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, - {"id": "form", "type": "task_form", "bind": {"task": "review"}}, - {"id": "signs", "type": "signoffs", "bind": {"task": "review"}}, - {"id": "mine", "type": "task_list", "props": {"scope": "mine"}}]}]} +SPEC = {"format": "agentlab.app/1", "name": "报销", "views": {"inst": {"source": {"kind": "instance"}}}, + "pages": [{"id": "instance", "title": "实例", "body": [ + {"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"}, + {"id": "form", "type": "task_form", "bind": {"task": "review"}}, + {"id": "signs", "type": "signoffs", "bind": {"task": "review"}}, + {"id": "mine", "type": "task_list", "props": {"scope": "mine"}}, + {"id": "st", "type": "stamp", "bind": {"view": "inst"}}]}]} def user(name: str) -> Principal: @@ -76,7 +82,7 @@ async def _setup() -> tuple[str, str, str, str]: u = uuid.uuid4().hex[:6] a, b, boss, group = f"zhang{u}", f"wang{u}", f"zhao{u}", f"finance{u}" async with SessionLocal() as session: - people = [User(username=n, role="user") for n in (a, b)] + people = [User(username=n, role="user", display_name=f"财务{n[:1]}") for n in (a, b)] session.add_all(people) g = Group(name=group) session.add(g) @@ -137,6 +143,14 @@ async def test_the_initiator_sees_the_step_not_a_missing_task(): hdr = view["hdr"] assert (hdr["status_code"], hdr["status_text"]) == ("waiting", "等待审批") assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "等待审批" + # 停着等审批:印章那一块按在等的那一步说,不写「正在运行,完成后刷新页面」(页头写着等待审批、自动刷新) + assert view["st"]["state"] == "running" and view["st"]["message"] == "等待审批,流程走完后自动显示" + # 同一页上有绑同一条待办的会签组件:待办表单不再画会签进度,会签组件照画 + assert form["signoffs_on_page"] is True and "signoffs_on_page" not in view["signs"] + # 等待名单写显示名:服务端给用户名 → 显示名,界面悬停给用户名 + assert form["people"] == {a: "财务z", b: "财务w"} and view["signs"]["people"] == form["people"] + detail = await tasks.detail(task.id, user(a)) + assert detail["people"] == {a: "财务z", b: "财务w"}, "待办卡(待办服务)同一个口径" async def test_signoffs_say_who_verified_and_when_and_the_voter_sees_their_own_answer(): @@ -176,9 +190,12 @@ async def test_the_instance_says_which_outcome_it_reached(): await tasks.respond((await _task(back)).id, user(b), decision="reject", note="票据不全") await _wait(back, ("succeeded",)) view = await _page(app_id, back, user("qianqi")) - # 走了退回分支:运行是「已完成」,流程的结论是「已退回」 - assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("succeeded", "已退回") - assert next(s for s in view["tl"]["steps"] if s["node"] == "review")["text"] == "已驳回" + # 走了退回分支:运行是「已完成」,流程的结论是「已退回」。这是否定的结局(从「驳回」出口走到的成果节点),状态 + # 标记同「未确认(被驳回)」,不画成成功;时间线上驳回的那一步同样 + assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "已退回") + assert view["tl"]["status_code"] == "cancelled" + review = next(s for s in view["tl"]["steps"] if s["node"] == "review") + assert (review["state"], review["text"]) == ("rejected", "已驳回") # 判定之后没回复的都撤回了:会签人数按判定那一刻的算,不缩成 1 assert view["signs"]["progress"] == {"policy": "all", "voters": 2, "need": 2, "agreed": 0, "rejected": 1} from app.apps.resolve import list_instances @@ -186,6 +203,38 @@ async def test_the_instance_says_which_outcome_it_reached(): async with SessionLocal() as session: rows = {r["run_id"]: r for r in await list_instances(session, app_id, mine="qianqi")} assert (rows[back]["status_text"], rows[passed]["status_text"]) == ("已退回", "已通过") + assert (rows[back]["status_code"], rows[passed]["status_code"]) == ("cancelled", "succeeded") + + +def test_a_negative_outcome_is_read_from_the_graph_and_this_runs_decision(): + """否定的结局按图认(人工节点「驳回」出口一路走到的成果节点,中间不经过别的人工节点),再看这次运行里那个人工节点 + 是不是真的驳回了:两条路汇到同一个成果节点时,批准走过来的不算否定。不按成果节点的标题猜。""" + from types import SimpleNamespace + + from app.apps.resolve import _Trail, instance_state, rejected_outputs + + graph = {"nodes": [node("start", "input", "发起"), node("review", "human", "财务会签", mode="approve"), + node("pay", "tool", "付款"), node("note", "tool", "记一笔"), + node("done", "output", "办结"), node("back", "output", "打回重填"), + node("again", "human", "复核", mode="approve"), node("ok", "output", "复核通过")], + "edges": [{"source": "start", "target": "review"}, + {"source": "review", "target": "pay", "sourceHandle": "approved"}, + {"source": "review", "target": "note", "sourceHandle": "rejected"}, + {"source": "pay", "target": "done"}, {"source": "note", "target": "done"}, + {"source": "note", "target": "back"}, + {"source": "review", "target": "again", "sourceHandle": "rejected"}, + {"source": "again", "target": "ok", "sourceHandle": "approved"}]} + # 「复核通过」在另一个人工节点之后:结局由复核的人说了算,不算 review 驳回的结局 + assert rejected_outputs(graph) == {"done": {"review"}, "back": {"review"}} + + def state(decision, reached): + task = SimpleNamespace(node_id="review", kind="approve", status="resolved", outcome={"decision": decision}) + trail = _Trail(tasks=[task], events=[("node.finished", reached)]) + return instance_state(SimpleNamespace(status="succeeded", graph=graph), trail) + + assert state("reject", "back") == {"code": "cancelled", "text": "打回重填"} + assert state("expired_reject", "done") == {"code": "cancelled", "text": "办结"} + assert state("approve", "done") == {"code": "succeeded", "text": "办结"}, "批准走过来的同一个成果节点不算否定" async def test_outputs_reached_in_parallel_are_not_one_outcome(): diff --git a/backend/tests/test_app_principal_directory.py b/backend/tests/test_app_principal_directory.py new file mode 100644 index 00000000..0e4582d4 --- /dev/null +++ b/backend/tests/test_app_principal_directory.py @@ -0,0 +1,144 @@ +"""岗位绑定、应用授权里的人和组按名录核对(auth/directory.py,同 /api/directory、同前端 PrincipalPicker)。 + +界面上选人时拦着,旧客户端、直接调接口、绑好之后人被停用或删除的照样会漏过来: +- 账号密码登录:名录里没有的、停用了的,应用检查报错(说清是哪个岗位、哪一档授权、哪个名字),改授权回 422; +- 单点登录:人第一次登录后才进名录,名录里还没有的只提醒、照收;停用的照样算错; +- 关登录:不核对。 +""" +from __future__ import annotations + +import pytest +from sqlalchemy import delete + +from app.api import deps +from app.apps.derive import derive_app +from app.apps.lint import acl_issues, lint_app +from app.db.base import SessionLocal +from app.db.models import App, AppGrant, Group +from tests.fakes import auth_env +from tests.test_app_spec_tools import chain, make_workflow, node + + +def _approval(): + return chain( + node("start", "input", "开始", fields=[{"name": "amount", "type": "number", "required": True}]), + node("check", "human", "财务审批", mode="approve", assignee={"kind": "slot", "slot": "finance"}), + node("out", "output", "成果", fields=[{"name": "r", "value": "完成"}]), + ) + + +@pytest.fixture +async def people(): + """名录里:在用的 alice、停用的 dora、组 fin。""" + await auth_env.reset() + await auth_env.make_user("alice", "user") + await auth_env.make_user("dora", "user", disabled=True) + async with SessionLocal() as s: + s.add(Group(name="fin", label="财务", source="local")) + await s.commit() + try: + yield + finally: + async with SessionLocal() as s: + for model in (AppGrant, App): + await s.execute(delete(model)) + await s.commit() + await auth_env.reset() + + +def _mode(monkeypatch, mode: str) -> None: + monkeypatch.setattr(deps, "auth_mode", lambda: mode) + + +def _bound(*items): + return {"slots": {"finance": [{"kind": k, "name": n} for k, n in items]}} + + +def _principal_issues(issues, code="app.slot_principal_unknown"): + return [i for i in issues if i["code"] == code] + + +async def test_slot_bindings_are_checked_against_the_directory(people, monkeypatch): + wf, _ = await make_workflow(_approval()) + async with SessionLocal() as session: + spec = await derive_app(session, wf, 1) + spec["slots"]["finance"] = {"label": "财务审批人"} + + _mode(monkeypatch, "local") + assert not _principal_issues(await lint_app(session, spec, _bound(("user", "alice"), ("group", "fin")))) + issues = _principal_issues(await lint_app(session, spec, _bound( + ("user", "alice"), ("user", "wangwu"), ("group", "nogroup"), ("user", "dora")))) + assert [i["level"] for i in issues] == ["error"] * 3 + assert all(i["path"] == "slots.finance" for i in issues) + text = [i["message"] for i in issues] + assert "岗位「财务审批人」绑定的用户「wangwu」名录里没有" in text[0] + assert "组「nogroup」名录里没有" in text[1] + assert "用户「dora」已停用" in text[2] and issues[2]["problem"] == "disabled" + + # 单点登录:名录里还没有的只提醒(第一次登录后才进名录),停用的照样报错 + _mode(monkeypatch, "oidc") + issues = _principal_issues(await lint_app(session, spec, _bound(("user", "wangwu"), ("user", "dora")))) + assert [(i["level"], i["problem"]) for i in issues] == [("warning", "missing"), ("error", "disabled")] + assert "第一次登录后才进名录" in issues[0]["message"] + + # 关登录:没有用户表,不核对 + _mode(monkeypatch, "off") + assert not _principal_issues(await lint_app(session, spec, _bound(("user", "wangwu"), ("user", "dora")))) + + +async def test_grants_are_checked_against_the_directory(people, monkeypatch): + async with SessionLocal() as session: + app = App(name="授权核对") + session.add(app) + await session.flush() + session.add_all([AppGrant(app_id=app.id, principal_kind="user", principal="alice", role="use"), + AppGrant(app_id=app.id, principal_kind="everyone", principal="", role="use"), + AppGrant(app_id=app.id, principal_kind="user", principal="wangwu", role="observe"), + AppGrant(app_id=app.id, principal_kind="user", principal="dora", role="manage")]) + await session.commit() + + _mode(monkeypatch, "local") + issues = await acl_issues(session, app.id) + assert [(i["code"], i["level"], i["path"]) for i in issues] == [("app.acl_principal_unknown", "error", "grants")] * 2 + assert "「查看全部实例」授权给的用户「wangwu」名录里没有" in issues[0]["message"] + assert "以后建了同名的人会直接拿到这一档权限" in issues[0]["message"] + assert "「管理」授权给的用户「dora」已停用" in issues[1]["message"] + + _mode(monkeypatch, "oidc") + assert [i["level"] for i in await acl_issues(session, app.id)] == ["warning", "error"] + _mode(monkeypatch, "off") + assert await acl_issues(session, app.id) == [] + + +async def test_put_grants_refuses_names_not_in_the_directory(people, monkeypatch): + admin = await auth_env.turn_on_local() + app_id = (await admin.post("/api/apps", json={"name": "授权核对"})).json()["id"] + url = f"/api/apps/{app_id}/grants" + + r = await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "wangwu", "role": "use"}, + {"principal_kind": "user", "principal": "dora", "role": "observe"}]}) + assert r.status_code == 422 + assert "「使用」里的用户「wangwu」名录里没有" in r.json()["detail"] + assert "「查看全部实例」里的用户「dora」已停用" in r.json()["detail"] + assert (await admin.get(url)).json()["items"] == [], "拒收时一行都不改" + + ok = [{"principal_kind": "user", "principal": "alice", "role": "use"}, + {"principal_kind": "group", "principal": "fin", "role": "observe"}, + {"principal_kind": "everyone", "principal": "", "role": "use"}] + r = await admin.put(url, json={"items": ok}) + assert r.status_code == 200 and r.json()["items"] == ok + + # 单点登录:名录里还没有的照收(第一次登录后才进名录),停用的照样拒收 + _mode(monkeypatch, "oidc") + assert (await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "wangwu", "role": "use"}]}) + ).status_code == 200 + assert (await admin.put(url, json={"items": [{"principal_kind": "user", "principal": "dora", "role": "use"}]}) + ).status_code == 422 + + +async def test_directory_endpoint_uses_the_same_source(people): + admin = await auth_env.turn_on_local() + body = (await admin.get("/api/directory")).json() + users = {u["username"]: u for u in body["users"]} + assert users["dora"]["disabled"] is True and users["alice"]["display_name"] == "alice" + assert body["groups"] == [{"name": "fin", "label": "财务"}] diff --git a/backend/tests/test_app_use.py b/backend/tests/test_app_use.py index 9843a6a1..24a4f083 100644 --- a/backend/tests/test_app_use.py +++ b/backend/tests/test_app_use.py @@ -181,6 +181,27 @@ async def test_entries_validate_map_and_title(client): assert home["forms"]["apply"]["properties"]["amount"]["title"] == "金额(元)" +async def test_an_archived_app_keeps_its_instances_but_takes_no_new_ones(client): + """归档(编辑器里的「归档」照这里写后果):应用列表不再列出,入口不再发起新实例;进行中、已有的实例照常能看; + 不再改草稿、不再发布(409 app_archived);没有恢复的接口。""" + wf, hash_ = await workflow(EXPENSE) + app_id = await published_app(client, expense_spec(wf, hash_), name=f"归档-{uuid.uuid4().hex[:6]}") + res = await client.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"amount": 1}}, headers=actor("张三")) + run_id = res.json()["run_id"] + archived = (await client.post(f"/api/apps/{app_id}/archive")).json() + assert archived["status"] == "archived" + assert app_id not in [a["id"] for a in (await client.get("/api/apps")).json()["items"]] + assert app_id in [a["id"] for a in (await client.get("/api/apps", params={"include_archived": True})).json()["items"]] + refused = await client.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"amount": 1}}) + assert refused.status_code == 404 and "已归档" in refused.json()["detail"] + assert (await client.get(f"/api/apps/{app_id}/instances/{run_id}")).status_code == 200, "已有的实例照常能看" + for res in (await client.patch(f"/api/apps/{app_id}", json={"name": "改名"}), + await client.post(f"/api/apps/{app_id}/publish", json={})): + assert res.status_code == 409 and res.json()["code"] == "app_archived", res.text + assert "不能再发布" in (await client.post(f"/api/apps/{app_id}/publish", json={})).json()["detail"] + assert (await client.get(f"/api/apps/{app_id}")).json()["status"] == "archived" + + async def test_an_app_on_a_retired_version_refuses_new_instances(client): wf, hash_ = await workflow(EXPENSE, versions=(1, 2), current=2) app_id = await published_app(client, expense_spec(wf, hash_)) diff --git a/backend/tests/test_app_versions.py b/backend/tests/test_app_versions.py index e6ae7a7d..a36765d9 100644 --- a/backend/tests/test_app_versions.py +++ b/backend/tests/test_app_versions.py @@ -400,3 +400,20 @@ async def test_only_an_admin_downgrades_a_governed_workflow(client, monkeypatch) plain = await workflow({1: "published", 2: None}, current=1, status="draft") ok = await client.post(f"/api/workflows/{plain}/publish", json={"level": "published", "version": 2}) assert ok.status_code == 200, ok.text + + +async def test_a_draft_equal_to_the_published_graph_says_so(client): + """撤回改动后保存出来的新版本号更大、内容却和发布版一样:服务端按 graph_hash 比好给标记(same_as_published), + 页头照实写「与发布版内容相同」,不写「领先 N 版」。详情、列表、保存的回包同一个口径。""" + created = (await client.post("/api/workflows", json={"name": f"撤回-{uuid.uuid4().hex[:6]}", "graph": simple()})).json() + wf = created["id"] + assert created["same_as_published"] is False, "没发布过不算相同" + assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"})).status_code == 200 + assert (await client.get(f"/api/workflows/{wf}")).json()["same_as_published"] is True + changed = (await client.patch(f"/api/workflows/{wf}", json={"graph": simple("改过")})).json() + assert changed["version"] == 2 and changed["same_as_published"] is False + # 视口不算内容(graph_hash 剔除 viewport) + back = (await client.patch(f"/api/workflows/{wf}", json={"graph": {**simple(), "viewport": {"x": 3, "y": 4, "zoom": 1}}})).json() + assert back["version"] == 3 and back["published_version"] == 1 and back["same_as_published"] is True + listed = next(w for w in (await client.get("/api/workflows")).json() if w["id"] == wf) + assert listed["same_as_published"] is True diff --git a/backend/tests/test_auth_access.py b/backend/tests/test_auth_access.py index 3b04aa44..0ecb431e 100644 --- a/backend/tests/test_auth_access.py +++ b/backend/tests/test_auth_access.py @@ -13,7 +13,7 @@ from __future__ import annotations import pytest -from sqlalchemy import delete, select +from sqlalchemy import delete from app.api.deps import Principal, can_read_artifact, can_read_run from app.core.artifact_store import put_json @@ -72,10 +72,12 @@ async def world(): s.add(DataSource(name="crm", kind="sqlite", options={"mask_columns": ["phone"]})) await s.commit() ids = {k: r.id for k, r in runs.items()} + # 按 id 交回:同一个测试进程里别的测试留下的应用也可能叫「报销」,按名字找会撞上 + apps = {"报销": app_a.id, "周报": app_b.id} uma = await auth_env.login("uma") bob = await auth_env.login("bob") try: - yield {"admin": admin, "uma": uma, "bob": bob, "runs": ids} + yield {"admin": admin, "uma": uma, "bob": bob, "runs": ids, "apps": apps} finally: async with SessionLocal() as s: for model in (TaskAssignment, Task, AppGrant, AppVersion, App): @@ -191,8 +193,8 @@ async def test_action_receipt_body_is_masked_for_a_user(world): async def test_a_manage_grant_does_not_make_a_user_an_app_builder(world): """§3.12:搭建、发布、绑定岗位、授权、钩子 user 一律不给。授给使用者的 manage 按 observe 算。""" uma = world["uma"] + app_id = world["apps"]["报销"] async with SessionLocal() as s: - app_id = (await s.execute(select(App.id).where(App.name == "报销"))).scalar_one() s.add(AppGrant(app_id=app_id, principal_kind="user", principal="uma", role="manage")) await s.commit() assert (await uma.post(f"/api/apps/{app_id}/publish", json={})).status_code == 403 diff --git a/backend/tests/test_capabilities_registry.py b/backend/tests/test_capabilities_registry.py index ae131b4d..e1ba777b 100644 --- a/backend/tests/test_capabilities_registry.py +++ b/backend/tests/test_capabilities_registry.py @@ -140,6 +140,22 @@ async def test_tool_library_rehearses_anything_governed_as_a_write(world): assert out.json()["rehearsal"] is True and out.json()["result"]["args"] == {"q": "x"} +async def test_tool_list_says_which_tools_only_rehearse(world): + """工具列表上每个工具带 write_rehearsal(和执行接口同一个判断):详情区的提示条照它说「这里只演练,不真的发送」, + 不说「请求会实际发出」。绑定端点的 POST 是写入;GET、Python 工具、没治理成写入的内置工具不是。""" + c = auth_env.client() + tools = {t["id"]: t for t in (await c.get("/api/tools")).json()} + assert tools["erp_pay"]["write_rehearsal"] is True + assert tools["erp_orders"]["write_rehearsal"] is False and tools["calc_py"]["write_rehearsal"] is False + assert tools["file_write"]["write_rehearsal"] is False, "写工作目录不是对外写入,照旧确认后真的执行" + r = await c.put("/api/capabilities/http_request/governance", json={"version": 1, "effect": "write"}) + assert r.status_code == 200, r.text + tools = {t["id"]: t for t in (await c.get("/api/tools")).json()} + assert tools["http_request"]["write_rehearsal"] is True, "治理成写入的内置工具同样只演练" + run = await c.post("/api/tools/http_request/run", json={"args": {"url": "https://example.com"}}) + assert run.status_code == 200 and run.json()["rehearsal"] is True, "列表上的标记和执行接口同一个判断" + + async def test_platform_query_is_by_identity_not_by_shape(world): c = auth_env.client() tabular = {"rows": "/items", "columns": {"单号": "/id"}} diff --git a/frontend/src/apps/Stamp.tsx b/frontend/src/apps/Stamp.tsx index 5a1a2a10..ff3de4e6 100644 --- a/frontend/src/apps/Stamp.tsx +++ b/frontend/src/apps/Stamp.tsx @@ -1,6 +1,9 @@ /** * 印章(应用平台 §2.3):每个视图都带的「截至 / 口径 / 出具档位 / 来源」,服务端算好、这里只照写(P1)。 * + * 截至那一项服务端给的是整句(as_of_text:「截至 2026-10-15 07:29;统计期 …」,没结束的写「尚未结束」),紧凑和完整两种 + * 都照写这一句、不再另加「截至」的标签:标签加上值里的「截至」就成了「截至 截至 …」。 + * * 出具档位按三档配色(完整出具绿、降档琥珀、不予出具红),旁边总有字,不只靠颜色;还没封存的写明「尚未封存」。 * 服务端照实标出的错位(定时任务已改运行新版本、应用还固定在旧版本,引用的版本已退役……)逐条列在下面,琥珀色。 */ @@ -40,7 +43,6 @@ export function Stamp({ stamp, compact = false, className }: { stamp: StampData; ) } const rows: [string, ReactNode, string][] = [ - [STAMP_TEXT.asOf, stamp.as_of_text, 'as_of'], [STAMP_TEXT.caliber, {caliber}, 'caliber'], [STAMP_TEXT.tier, {stamp.tier_text || '—'}, 'tier'], [STAMP_TEXT.from, stamp.from, 'from'], @@ -48,6 +50,8 @@ export function Stamp({ stamp, compact = false, className }: { stamp: StampData; return (
+ {/* 截至:服务端的整句自己带着「截至」,单独一行写,不进下面带标签的三项 */} +

{stamp.as_of_text}

{rows.map(([label, value, key]) => (
diff --git a/frontend/src/apps/components/process.tsx b/frontend/src/apps/components/process.tsx index cbbfbac8..91cc2332 100644 --- a/frontend/src/apps/components/process.tsx +++ b/frontend/src/apps/components/process.tsx @@ -12,7 +12,7 @@ import clsx from 'clsx' import { api } from '../../api/client' import { ErrorState, Spinner, StatusPill, useTicker } from '../../components/ui' import { - Due, Resolved, SignoffHead, Signoffs, TaskCard, assigneeName, respondedText, + Due, Resolved, SignoffHead, Signoffs, TaskCard, assigneeHint, assigneeName, respondedText, } from '../../run/TaskCard' import { formatDateTime, formatRelative, formatTime } from '../../lib/format' import { @@ -223,7 +223,7 @@ function taskOf(data: Record): Task { return { ...summary, policy: summary.policy ?? { kind: 'any' }, payload: {}, can_respond: false, outcome: summary.outcome ?? null, resolved_by: summary.resolved_by ?? null, resolved_at: summary.resolved_at ?? null, - progress: data.progress ?? null, my_response: data.my_response ?? null, + progress: data.progress ?? null, my_response: data.my_response ?? null, people: data.people ?? {}, assignments: raw.map((a, i) => ({ id: a.id ?? String(i), principal_kind: a.principal_kind ?? 'anyone', principal: str(a.principal), source: str(a.source), status: a.status ?? 'pending', responded_by: a.responded_by ?? null, @@ -242,31 +242,36 @@ function useFullTask(summary: TaskSummary | undefined, enabled: boolean) { /** * 待办表单:服务端解析时就判定了读者是不是这条待办的处理人(data.handler)。是的按 id 取完整的待办画待办卡(能处理的给 - * 表单);不是的(发起人、旁观的人)只画这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是「待办不存在」) + * 表单);不是的(发起人、旁观的人)只画这一步的状态——派给了谁、会签进度、时限——不去取待办(取了也是「待办不存在」)。 + * 同一页上另有绑同一条待办的会签组件时服务端说一声(data.signoffs_on_page),这里不再画会签进度:同一页上不写两遍 */ export function TaskForm({ node }: NodeProps) { const { preview, onReload } = useView() const summary = node.data?.task as TaskSummary | undefined const handler = node.data?.handler !== false + const signoffs = node.data?.signoffs_on_page !== true const { task, error, retry } = useFullTask(summary, !preview && handler) if (!summary) return
{VIEW_TEXT.tasks.empty}
return (
{preview ? - : !handler ? + : !handler ? : error ? : !task ?
- : { retry(); onReload?.() }} />} + : { retry(); onReload?.() }} />}
) } -/** 这一步的状态(不是处理人看的):种类、标题、状态、时限、升级;在等谁、会签进度;处理完了写谁、什么时候、结果 */ -function TaskState({ task }: { task: Task }) { +/** + * 这一步的状态(不是处理人看的):种类、标题、状态、时限、升级;在等谁、会签进度;处理完了写谁、什么时候、结果。 + * 人名写显示名,悬停给用户名(服务端给的 people) + */ +function TaskState({ task, signoffs }: { task: Task; signoffs: boolean }) { const now = useTicker(60_000) const pending = task.status === 'pending' - const waiting = task.assignments.filter((a) => a.status === 'pending').map(assigneeName) - const voting = task.progress && (task.assignments.length > 1 || task.policy.kind !== 'any') + const waiting = task.assignments.filter((a) => a.status === 'pending') + const voting = signoffs && task.progress && (task.assignments.length > 1 || task.policy.kind !== 'any') return (
@@ -282,7 +287,11 @@ function TaskState({ task }: { task: Task }) {
{!pending && } {pending && waiting.length > 0 && ( -
{TASK_TEXT.waitingOn(waiting.join('、'))}
+ // 悬停给用户名(写的是显示名的那几个) +
assigneeHint(a, task.people)).filter(Boolean).join('、') || undefined}> + {TASK_TEXT.waitingOn(waiting.map((a) => assigneeName(a, task.people)).join('、'))} +
)} {voting &&
} {pending && task.my_response && ( diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx index 95ef8fc2..4168a95c 100644 --- a/frontend/src/canvas/PublishDialog.tsx +++ b/frontend/src/canvas/PublishDialog.tsx @@ -14,6 +14,7 @@ import { Modal, Spinner, confirmDialog, toast, useRadioGroup } from '../componen import { humanizeError } from '../lib/errors' import { CATALOG_UI_TEXT as UT, PROVIDER_REF_TEXT, PUBLISH_FIX_TEXT as T, VERSION_USAGE_TEXT, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL, + publishedLevelOf, } from '../lib/terms' import { VersionUsageLine, useVersionUsage } from './versionUsage' import { newAppPath } from '../pages/apps/shared' @@ -78,8 +79,8 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { const downgrade = governedWf && level === 'published' // 还没选档时按「已发布」查:能选的只有这一档,问题先列出来 const pf = usePreflight(workflow.id, level ?? 'published', { onRechecked: () => setGate(null) }) - /** 发布成功:弹窗不立刻关,给「做成应用」的入口(A8)。关掉时才算完成(onDone) */ - const [done, setDone] = useState(null) + /** 发布成功:弹窗不立刻关,给「做成应用」的入口(A8)。关掉时才算完成(onDone)。记着发的是哪一档:受管级标题写明受管 */ + const [done, setDone] = useState<{ version: number; level: PublishLevel } | null>(null) const navigate = useNavigate() // 当前已发布的那一版被哪些应用固定、有几条生效记录(L4):发布新版之后它们仍然固定在旧版上,发布前先知道 const usage = useVersionUsage(workflow.id, published ?? null) @@ -117,14 +118,14 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { try { const res = await api.workflows.publish(workflow.id, level) if (res.ok) { - toast.ok(`已发布 v${res.version}${level === 'governed' ? ' · 受管' : ''}${actor ? ` · ${actor}` : ''}`) + toast.ok(`${VERSION_USAGE_TEXT.published(res.version ?? workflow.version, level)}${actor ? ` · ${actor}` : ''}`) const fresh = await api.workflows.get(workflow.id) // 发布不动图、不升版本,只换状态和发布人:换掉工作流的元信息就够了。走 load 的话 // 撤销栈、助手的轮次、运行态全被清空,镜头还要重新取景——发布一下什么都没了。 // 版本对不上(这期间别处又存过)才整张重载,画布这时没有未保存的改动(有就发不了) if (fresh.version === useStudio.getState().workflow?.version) useStudio.setState({ workflow: fresh }) else load(fresh) - setDone(res.version ?? fresh.published_version ?? workflow.version) + setDone({ version: res.version ?? fresh.published_version ?? workflow.version, level }) } else { setGate(normalizeCheck({ issues: res.issues }, level).issues) toast.warn('发布未通过门禁检查。请查看下方问题清单,点击条目可定位到对应节点') @@ -145,18 +146,18 @@ export function PublishDialog({ workflow, onClose, onDone, onLocate }: { if (done != null) { return ( - }> -
+
{VERSION_USAGE_TEXT.publishedNext}
@@ -911,7 +912,8 @@ const LEVELS = ['published', 'governed'] as const */ export function PublishPreflightPane({ onLocate }: { onLocate: (issue: ValidationIssue) => void }) { const workflow = useStudio((s) => s.workflow) - const [level, setLevel] = useState(workflow?.status === 'governed' ? 'governed' : 'published') + // 默认按当前发布版的级别查:画布上改过之后 status 回到草稿,受管工作流仍按受管查 + const [level, setLevel] = useState(workflow ? publishedLevelOf(workflow) : 'published') // 修复正在出预览、存草稿、重查时不换档(和发布弹窗同一条):方向键也换不了 const busy = useRef(false) const pick = useCallback((l: PublishLevel) => { if (!busy.current) setLevel(l) }, []) diff --git a/frontend/src/canvas/VersionsSheet.tsx b/frontend/src/canvas/VersionsSheet.tsx index d15a02be..31c98dd5 100644 --- a/frontend/src/canvas/VersionsSheet.tsx +++ b/frontend/src/canvas/VersionsSheet.tsx @@ -9,7 +9,7 @@ import { import { ErrorState, Skeleton, toast } from '../components/ui' import { useDatasources } from '../store/catalog' import { formatDateTime, formatTime } from '../lib/format' -import { CATALOG_DRIFT_TEXT, VERSION_USAGE_TEXT, WORKFLOW_STATUS_LABEL } from '../lib/terms' +import { CATALOG_DRIFT_TEXT, VERSION_USAGE_TEXT, WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms' import { useAtLeast } from '../store/auth' import { RetiredNote, VersionUsageLine, retireBlocked, retireVersion, useVersionUsage } from './versionUsage' import { hintOf } from './shortcuts' @@ -132,9 +132,12 @@ export function VersionsSheet({ workflow, onClose }: { workflow: Workflow; onClo {formatTime(v.created_at)} {current && {dirty ? '画布基于此版本' : '当前画布版本'}} {published && ( - + // 按这一版自己的级别写:画布上改过之后 status 回到草稿,受管发布版照实写「受管」。老数据没记级别的 + // 退回看工作流当前发布版的级别 + - {WORKFLOW_STATUS_LABEL[workflow.status === 'governed' ? 'governed' : 'published']} + {WORKFLOW_STATUS_LABEL[v.level ?? publishedLevelOf(workflow)]} )} {!published && v.level && !v.retired_at && ( diff --git a/frontend/src/canvas/WorkflowPicker.tsx b/frontend/src/canvas/WorkflowPicker.tsx index db71db5e..17bc205f 100644 --- a/frontend/src/canvas/WorkflowPicker.tsx +++ b/frontend/src/canvas/WorkflowPicker.tsx @@ -9,7 +9,7 @@ import { EmptyState, IconButton, Modal, confirmDialog, promptDialog, toast } fro import { formatDateTime, formatTime, parseServerTime } from '../lib/format' import { formatShortcut } from '../lib/keys' import { leavePass, registerLeaveGuard } from '../lib/leave' -import { WORKFLOW_STATUS_LABEL } from '../lib/terms' +import { WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms' import type { GraphSpec, Workflow } from '../types' /** 标签的中文。后端写的是机器标记,和中文界面混排很突兀 */ @@ -155,7 +155,8 @@ export async function createWorkflow( function StatusChip({ w }: { w: Workflow }) { const p = w.published_version - const pub = WORKFLOW_STATUS_LABEL[w.status === 'governed' ? 'governed' : 'published'] + // 当前发布版的级别看 published_level(同工具栏的版本标签):画布上改过之后 status 回到草稿,受管发布版照实写「受管」 + const pub = WORKFLOW_STATUS_LABEL[publishedLevelOf(w)] // 和工具栏的版本标签同一个口径:最新一版就是发布的那版才算「已发布」。发布后又保存过, // 状态退回草稿,但已发布的 vN 还在、正式运行照样跑它——只写「草稿」就把这件事藏起来了 if ((w.status === 'governed' || w.status === 'published') && p === w.version) { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 2a8ace72..e09fb09e 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -70,6 +70,19 @@ export const WORKFLOW_STATUS_LABEL: Record<'draft' | 'published' | 'governed', s governed: '受管', } +/** + * 当前发布版的级别,叫法取 WORKFLOW_STATUS_LABEL[…](「已发布」「受管」):按服务端给的当前发布版级别(published_level)。 + * 画布上改过之后 status 回到草稿,看 status 会把受管发布版写成「已发布」;老服务端不给级别时退回看 status + */ +export function publishedLevelOf(w: { status?: string | null; published_level?: string | null }): 'published' | 'governed' { + return (w.published_level ?? w.status) === 'governed' ? 'governed' : 'published' +} + +/** 工具栏的版本标签:画布这一版和发布版内容相同(撤回改动后保存出来的)时不写「领先 N 版」 */ +export const VERSION_LABEL_TEXT = { + sameAsPublished: (v: number) => `与发布版 v${v} 内容相同`, +} + /** 发布弹窗里的选项说明:和工具栏 chip「已发布 v4」说法对得上 */ export const WORKFLOW_STATUS_HINT: Record<'draft' | 'published' | 'governed', string> = { draft: '草稿 — 只能发起探索运行', @@ -5299,7 +5312,8 @@ export const VERSION_USAGE_TEXT = { retired_ok: (v: number) => `已退役 v${v}`, makeApp: '做成应用', makeAppHint: '从这一版确定性地派生一个应用草稿,之后在应用编辑中调整', - published: (v: number) => `已发布 v${v}`, + /** 发布成功(弹窗标题、提示):受管级发布写明受管,和工具栏「受管 v6」对得上 */ + published: (v: number, level?: 'published' | 'governed' | null) => `已发布 v${v}${level === 'governed' ? ' · 受管' : ''}`, publishedNext: '可以从这一版做成应用:入口表单、待办、指标和报告按图确定性派生,之后再调整布局和文案', } @@ -5339,6 +5353,21 @@ export const APP_EDITOR_TEXT = { open: '打开应用', assistant: '助手', assistantToggle: '显示或隐藏助手', + // ---- 归档(照服务端的实际行为写:api/apps.py 的 archive_app、apps/entries.py、process/hooks.py)。没有恢复的接口 + archive: '归档', + archiveTitle: (name: string) => `归档「${name}」?`, + archiveConsequences: [ + '应用列表里不再列出它,已打开的链接仍能查看', + '入口不再接受新实例;入站钩子一律拒收,在等外部系统回调的实例收不到回调', + '进行中的实例照常走完,待办照常处理;版本、实例、生效记录都保留', + '归档后不能再修改草稿、发布新版本,目前没有恢复的操作', + ], + /** 有未保存的修改时多写一条:归档之后存不上 */ + archiveUnsaved: '草稿里还没保存的修改不会保存', + archiveConfirm: '归档应用', + archiveDone: (name: string) => `已归档「${name}」`, + archivedBanner: '这个应用已归档:应用列表里不再列出,入口和入站钩子不再接受新实例,进行中的实例照常走完。不能再修改草稿、发布新版本', + archivedLocked: '应用已归档,不能再修改草稿、发布新版本', // ---- 新建(向导的起点:现在只有「从已有的工作流开始」一种) startFromWorkflow: '从已有的工作流开始', newIntro: '从一个已发布的工作流版本确定性地派生应用:入口表单、待办、指标、报告和表格按图生成,之后再调整布局和文案。', @@ -5636,6 +5665,8 @@ export const MASK_BOUNDARY_TEXT = { */ export const TOOL_REHEARSAL_TEXT = { badge: '演练:写入能力不在这里真的发送', + /** 工具库详情区的提示条(写入能力):执行不经确认、也不真的发出,不说「请求会实际发出」 */ + library: '这里只演练,不真的发送:执行后交回将要发送的请求。对外写入只经「调用工具」节点发送:要确认、带幂等键、记入生效记录。', hint: '对外写入只经「调用工具」节点发送:要确认、带幂等键、记入生效记录。下面是将要发送的请求', hintArgs: '对外写入只经「调用工具」节点发送,这里没有执行。下面是这次的参数', request: '请求', diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 4c5ec1dc..310a222d 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -227,7 +227,7 @@ export const ACTION_TEXT = { /** 印章:截至 / 口径 / 出具档位 / 来源(§2.3) */ export const STAMP_TEXT = { - asOf: '截至', + // 截至一项不另写标签:服务端给的整句(as_of_text)自己带着「截至」 caliber: '口径', tier: '出具档位', from: '来源', @@ -255,7 +255,8 @@ export const APP_TERM_TEXT = { /** 视图来源的状态 */ export const VIEW_TEXT = { empty: '还没有可读的运行', - running: '正在生成,完成后自动刷新', + /** 服务端没给原话时的兜底,和服务端 apps/resolve.RUNNING_TEXT 同一句 */ + running: '正在运行,完成后自动刷新', stale: '数据已过期,可点击刷新', error: '这部分数据无法读取', refresh: '刷新', diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 99c3d4eb..e0fe16fe 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -29,7 +29,7 @@ import { import { isNetworkError } from '../lib/errors' import { formatClock } from '../lib/format' import { formatShortcut, isTypingTarget, matchShortcut } from '../lib/keys' -import { WORKFLOW_STATUS_LABEL } from '../lib/terms' +import { VERSION_LABEL_TEXT, WORKFLOW_STATUS_LABEL, publishedLevelOf } from '../lib/terms' import type { Workflow } from '../types' // 三栏的收起状态存在这台浏览器里。读写都可能抛(隐私模式、存储被禁),抛了就用默认值 @@ -793,22 +793,32 @@ function VersionLabel({ workflow: w }: { workflow: Workflow }) { // 画布就是已发布的那一版才算「在线」。老数据里有状态还挂着受管、版本却已经往前走了的 // (旧的 restore 接口不退回草稿),那也得说清画布是草稿 const live = (w.status === 'governed' || w.status === 'published') && p === w.version - const ahead = p != null ? w.version - p : 0 - const pubWord = WORKFLOW_STATUS_LABEL[w.status === 'governed' ? 'governed' : 'published'] + // 撤回改动后保存出来的新版本:版本号大了,内容和发布版一样(服务端按图哈希比),不算「领先」 + const same = !live && p != null && !!w.same_as_published + const ahead = p != null && !same ? w.version - p : 0 + // 当前发布版的级别看 published_level:画布上改过之后 status 回到草稿,受管发布版照实写「受管」 + const governed = publishedLevelOf(w) === 'governed' + const pubWord = WORKFLOW_STATUS_LABEL[publishedLevelOf(w)] const pubText = p != null ? `${pubWord} v${p}` : '' const title = [ - live ? `画布与${pubText}一致` : `画布是草稿 v${w.version}`, - p != null && !live ? `正式运行使用已发布的 v${p}${ahead > 0 ? `,画布领先 ${ahead} 版` : ''}` : '', + live ? `画布与${pubText}一致` : `画布是草稿 v${w.version}${same ? `,${VERSION_LABEL_TEXT.sameAsPublished(p)}` : ''}`, + p != null && !live ? `正式运行使用${pubWord}的 v${p}${ahead > 0 ? `,画布领先 ${ahead} 版` : ''}` : '', p == null ? '尚未发布,只能发起探索运行' : '', w.published_by ? `发布人:${w.published_by}` : '', ].filter(Boolean).join('\n') return ( - - {!live && 草稿 v{w.version}} + + {!live && ( + + 草稿 v{w.version} + {same && ({VERSION_LABEL_TEXT.sameAsPublished(p)})} + + )} {/* 最窄的工具栏里画布不是已发布那一版时只留「草稿 vN」:已发布的版本号在正式运行按钮上 */} {p != null && ( + style={{ color: 'var(--ok)', ...(governed ? { background: 'var(--st-done-soft)' } : {}) }}> {/* 工具栏窄了只留盾牌和版本号,字留给读屏(全文在 title 里) */} {pubWord} v{p} {!live && ahead > 0 && (领先 {ahead} 版)} diff --git a/frontend/src/pages/ToolsPage.tsx b/frontend/src/pages/ToolsPage.tsx index 5098c356..f80b7290 100644 --- a/frontend/src/pages/ToolsPage.tsx +++ b/frontend/src/pages/ToolsPage.tsx @@ -92,8 +92,12 @@ function sampleOf(spec: any): any { } } -/** 在工具库里直接执行时,它的副作用落在哪里。说实话:内置工具有围栏,外接的没有 */ +/** + * 在工具库里直接执行时,它的副作用落在哪里。说实话:内置工具有围栏,外接的没有;写外部系统的能力在这里只演练 + * (服务端给的 write_rehearsal,和执行接口同一个判断),不说「请求会实际发出」 + */ function sideEffectNote(tool: ToolInfo): string { + if (tool.write_rehearsal) return TOOL_REHEARSAL_TEXT.library if (tool.source !== 'builtin') return '自定义工具和 MCP 工具的副作用由工具自身决定,此处无法拦截:请求会实际发出,并可能修改外部系统。' if (tool.category === '文件') return '文件读写限定在工作目录内,无法访问其他位置。' if (tool.category === '网络') return '内网、回环和云元数据地址会被拦截,公网请求会实际发出。' @@ -240,15 +244,22 @@ function ToolLibrary() {
)} - {picked.dangerous && ( -
+ + {sideEffectNote(picked)} +
+ ) : ( +
此处直接执行,不经审批:执行前会先说明具体操作,确认后才会执行。{sideEffectNote(picked)}
- )} + ))}
diff --git a/frontend/src/pages/apps/edit/AppEditor.tsx b/frontend/src/pages/apps/edit/AppEditor.tsx index ab5779db..3939a50b 100644 --- a/frontend/src/pages/apps/edit/AppEditor.tsx +++ b/frontend/src/pages/apps/edit/AppEditor.tsx @@ -11,10 +11,12 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' -import { Bot, ExternalLink, LayoutGrid, Save } from 'lucide-react' +import { Archive, Bot, ExternalLink, LayoutGrid, Save } from 'lucide-react' import clsx from 'clsx' import { api } from '../../../api/client' -import { EmptyState, ErrorState, Field, PageHeader, Skeleton, Spinner, confirmDialog, isComposing, toast } from '../../../components/ui' +import { + EmptyState, ErrorState, Field, Notice, PageHeader, Skeleton, Spinner, confirmDialog, isComposing, toast, +} from '../../../components/ui' import { formatTime } from '../../../lib/format' import { matchShortcut } from '../../../lib/keys' import { useLeaveGuard } from '../../../lib/leave' @@ -180,6 +182,27 @@ function EditApp({ appId }: { appId: string }) { } }, [appId]) + /** 归档:确认框照服务端的实际行为写后果(入口不再发起、钩子拒收、进行中的照常走完、不能再改和发布,没有恢复) */ + const [archiving, setArchiving] = useState(false) + const archive = async () => { + if (!detail || !(await confirmDialog({ + title: T.archiveTitle(detail.name), consequences: dirty ? [...T.archiveConsequences, T.archiveUnsaved] : T.archiveConsequences, + confirmLabel: T.archiveConfirm, danger: true, + }))) return + setArchiving(true) + try { + const next = await api.apps.archive(appId) + setDetail((cur) => ({ ...next, backfill: next.backfill ?? cur?.backfill })) + // 归档之后改不了草稿:没保存的修改也存不上,不再拦着离开 + setDirty(false) + toast.ok(T.archiveDone(next.name)) + } catch (e) { + toast.error(e) + } finally { + setArchiving(false) + } + } + // 只在离开编辑器时问:切换分节是同一个草稿 useLeaveGuard(dirty, () => confirmDialog({ title: T.unsaved, body: T.leaveBody, confirmLabel: '放弃修改', cancelLabel: '返回保存', danger: true, @@ -187,18 +210,20 @@ function EditApp({ appId }: { appId: string }) { blocks: (next) => !next?.pathname.startsWith(`/apps/${encodeURIComponent(appId)}/edit`), }) + // 归档了的应用不能再改草稿(服务端同样拒绝):快捷键和置灰的「保存草稿」一致,不发请求 + const archived = detail?.status === 'archived' useEffect(() => { const onKey = (e: KeyboardEvent) => { if (isComposing(e) || !matchShortcut(e, 'Mod+S')) return e.preventDefault() - void save() + if (!archived) void save() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [save]) + }, [save, archived]) const ctx = useMemo(() => (detail && draft ? { - appId, detail, draft, catalog, dirty, selected, + appId, detail, draft, catalog, dirty, selected, archived, setDraft: (fn) => { setDraftState((cur) => (cur ? fn(cur) : cur)); setDirty(true) }, setBindings: (fn) => { setBindingsState((cur) => fn(cur)); setDirty(true) }, bindings, save, @@ -210,7 +235,7 @@ function EditApp({ appId }: { appId: string }) { }, focusPath, clearFocus: () => setFocusPath(null), - } : null), [appId, detail, draft, catalog, dirty, selected, bindings, save, navigate, focusPath]) + } : null), [appId, detail, draft, catalog, dirty, selected, archived, bindings, save, navigate, focusPath]) if (error) return void load()} className="m-6" /> // 没有 manage 的人取不到草稿(D-m):手动打开编辑地址时说清楚,别一直停在骨架屏 @@ -237,12 +262,18 @@ function EditApp({ appId }: { appId: string }) { title={T.assistantToggle} onClick={() => setAssistant((v) => !v)}> {T.assistant} - + )} + } /> + {ctx.archived && {T.archivedBanner}}
diff --git a/frontend/src/pages/apps/edit/context.ts b/frontend/src/pages/apps/edit/context.ts index b561e433..724fd7e1 100644 --- a/frontend/src/pages/apps/edit/context.ts +++ b/frontend/src/pages/apps/edit/context.ts @@ -7,6 +7,8 @@ export interface EditorState { appId: string /** 应用详情(manage):带着草稿、岗位绑定和能按新口径补算的视图 */ detail: AppDetail + /** 应用已归档(服务端的 status):不能再改草稿、发布(服务端同样拒绝),保存和发布置灰 */ + archived: boolean draft: AppSpec /** 改草稿:传函数拿到最新的那一份(助手的操作和人的编辑可能挨着到) */ setDraft: (fn: (spec: AppSpec) => AppSpec) => void diff --git a/frontend/src/run/TaskCard.tsx b/frontend/src/run/TaskCard.tsx index f283b3ad..2508b809 100644 --- a/frontend/src/run/TaskCard.tsx +++ b/frontend/src/run/TaskCard.tsx @@ -58,6 +58,8 @@ export interface TaskCardProps { send?: (body: TaskRespond) => Promise<{ task: Task; resolved: boolean }> /** 卡头写不写应用或工作流名。画布上工作流就是眼前这张,不用再说一遍 */ showOwner?: boolean + /** 画不画会签进度。应用页上同一页另有会签组件时服务端说不画(signoffs_on_page),同一页上不写两遍 */ + showSignoffs?: boolean } const KIND_ICON: Record = { @@ -68,7 +70,7 @@ const KIND_ICON: Record = { /** 一天没人处理就用琥珀色写等了多久,同审批卡 */ const STALE_MS = 24 * 3_600_000 -export function TaskCard({ task, onResolved, compact = false, send, showOwner = true }: TaskCardProps) { +export function TaskCard({ task, onResolved, compact = false, send, showOwner = true, showSignoffs = true }: TaskCardProps) { const toast = useToast() const payload: Record = task.payload ?? {} const kind = task.kind @@ -148,7 +150,7 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner = {!pending && } - {(task.assignments.length > 1 || task.policy.kind !== 'any') && } + {showSignoffs && (task.assignments.length > 1 || task.policy.kind !== 'any') && } {(kind === 'approve' || kind === 'input' || kind === 'edit') && payload.kind === 'human_node' && runClass && ( @@ -333,7 +335,7 @@ export function Due({ dueAt, now }: { dueAt: string | null; now: number }) { } /** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */ -export function Resolved({ task }: { task: Pick }) { +export function Resolved({ task }: { task: Pick }) { const decision = typeof task.outcome?.decision === 'string' ? TASK_TEXT.decision[task.outcome.decision] ?? task.outcome.decision : null const at = task.resolved_at ? formatDateTime(task.resolved_at) : '' return ( @@ -341,7 +343,9 @@ export function Resolved({ task }: { task: Pick{TASK_STATUS_LABEL[task.status] ?? task.status} {decision && {TASK_TEXT.outcome}:{decision}} {(task.resolved_by || at) && ( - {task.resolved_by ? TASK_TEXT.resolvedBy(task.resolved_by, at) : TASK_TEXT.resolvedAt(at)} + + {task.resolved_by ? TASK_TEXT.resolvedBy(personName(task.resolved_by, task.people), at) : TASK_TEXT.resolvedAt(at)} + )}
) @@ -369,27 +373,51 @@ function progressOf(task: Pick): Ta } /** 升级之后的规则:升级给谁、其中一人说了算;有人定了就写谁定了什么 */ -function escalationText(p: TaskProgress): string | null { +function escalationText(p: TaskProgress, people?: Record | null): string | null { const e = p.escalation if (!e) return null - if (e.decided && e.by) return TASK_TEXT.escalationDecided(e.by, TASK_TEXT.decision[e.decided] ?? e.decided) - const who = e.slot ? TASK_TEXT.slot(e.slot) : e.assignees.map((a) => a.name || TASK_TEXT.assignee.anyone).join('、') + if (e.decided && e.by) return TASK_TEXT.escalationDecided(personName(e.by, people), TASK_TEXT.decision[e.decided] ?? e.decided) + const who = e.slot ? TASK_TEXT.slot(e.slot) + : e.assignees.map((a) => (a.kind === 'user' ? personName(a.name, people) : a.name) || TASK_TEXT.assignee.anyone).join('、') return TASK_TEXT.escalationRule(who) } -export function assigneeName(a: Pick): string { +/** + * 人名:有显示名写显示名(服务端在待办上给 people:用户名 → 显示名),名录里没有的(关登录时自报的署名)照写原名。 + * usernameHint 是悬停给的用户名:显示名和用户名一样时不给 + */ +export function personName(username: string, people?: Record | null): string { + return people?.[username] || username +} + +export function usernameHint(username: string | null | undefined, people?: Record | null): string | undefined { + return username && personName(username, people) !== username ? username : undefined +} + +export function assigneeName(a: Pick, + people?: Record | null): string { if (a.principal_kind === 'anyone') return TASK_TEXT.assignee.anyone if (a.principal_kind === 'hook') return TASK_TEXT.assignee.hook - if (a.principal_kind === 'group') return a.responded_by ? `${a.responded_by}(${TASK_TEXT.assignee.group(a.principal)})` : TASK_TEXT.assignee.group(a.principal) - return a.principal + if (a.principal_kind === 'group') { + return a.responded_by ? `${personName(a.responded_by, people)}(${TASK_TEXT.assignee.group(a.principal)})` : TASK_TEXT.assignee.group(a.principal) + } + return personName(a.principal, people) +} + +/** 派给谁的悬停:写的是显示名时给用户名(组里回复的人同理) */ +export function assigneeHint(a: Pick, + people?: Record | null): string | undefined { + if (a.principal_kind === 'user') return usernameHint(a.principal, people) + if (a.principal_kind === 'group') return usernameHint(a.responded_by, people) + return undefined } /** 会签进度的那一行(几人同意了、几人驳回、规则),升级了另写一行升级后的规则。不列名单:实例页上只看状态的人用它 */ -export function SignoffHead({ task }: { task: Pick }) { +export function SignoffHead({ task }: { task: Pick }) { const p = progressOf(task) const policy = task.policy.kind === 'all' ? TASK_TEXT.policy.all : task.policy.kind === 'quorum' ? TASK_TEXT.policy.quorum(task.policy.m) : TASK_TEXT.policy.any - const up = escalationText(p) + const up = escalationText(p, task.people) return ( <>
@@ -435,7 +463,7 @@ export function Signoffs({ task }: { task: Task }) { {agreed(a) ? '✓' : rejected(a) ? '✕' : '○'} - {assigneeName(a)} + {assigneeName(a, task.people)} {escalated(a) && {TASK_TEXT.escalationTag}} {a.status === 'responded' diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index 7ba52467..768f8a21 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -11,7 +11,7 @@ import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs' import { toast } from '../components/ui' import { humanizeError } from '../lib/errors' import { formatShortcut } from '../lib/keys' -import { CATALOG_PATCH_TEXT, LOOK_TEXT, UPGRADE_TEXT } from '../lib/terms' +import { CATALOG_PATCH_TEXT, LOOK_TEXT, UPGRADE_TEXT, publishedLevelOf } from '../lib/terms' import type { CopilotOp } from '../run/decode' import type { LookEvent } from '../typesApp' import { copilotContext, copilotLook, endingOf, looksOf, reduceTeam, settleTeam } from '../run/decode' @@ -1290,8 +1290,9 @@ export const useStudio = create((set, get) => ({ const seq = ++upgradeSeq const { nodes, edges, workflow } = get() const graph = toGraph(nodes, edges) - // 受管的图升级完还要按受管重新发布:新写出来的报告节点直接按受管门禁的要求配 - const level = workflow?.status === 'governed' ? 'governed' : 'published' + // 受管的图升级完还要按受管重新发布:新写出来的报告节点直接按受管门禁的要求配。按当前发布版的级别认(画布上改过 + // 之后 status 回到草稿,受管工作流仍是受管的) + const level = workflow ? publishedLevelOf(workflow) : 'published' set({ upgrade: { status: 'loading', assist, seq } }) try { const raw = await api.copilot.upgradeEvidence({ graph, level, ...(assist ? { assist: true } : {}) }, { signal: ctl.signal }) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8ba13590..fc665b5d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -38,6 +38,8 @@ export interface Workflow { published_version?: number | null /** 当前发布版本的级别。画布上改过之后 status 回到 draft,「这是不是受管工作流」看这一项(老服务端不给) */ published_level?: 'published' | 'governed' | null + /** 画布这一版的图和当前发布版内容相同(服务端按图哈希比):撤回改动后保存出来的新版本不算「领先」(老服务端不给) */ + same_as_published?: boolean /** 最近一次发布的署名。发布时没有署名为 null */ published_by?: string | null run_count?: number @@ -252,6 +254,11 @@ export interface ToolInfo { * 一定失败,列表上要醒目地标出来。null / 缺省表示没问题 */ problem?: string | null + /** + * 在工具库里执行只演练、不发送:写外部系统的能力(被治理为写入,或绑定端点、视参数而定),和执行接口同一个判断。 + * 详情区的提示条照它说「这里只演练」,不说「请求会实际发出」(老服务端不给) + */ + write_rehearsal?: boolean } /** diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index 4591d02d..99a0f73e 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -455,6 +455,8 @@ export interface Task { resolved_by: string | null resolved_at: string | null created_at: string + /** 待办上出现的人的显示名(用户名 → 显示名:派给谁、谁回复、谁处理)。界面写显示名、悬停给用户名;老服务端不给 */ + people?: Record } export interface TaskRespond { diff --git a/scripts/check-app-editor.mjs b/scripts/check-app-editor.mjs index 5ad406c0..9f8ac5d8 100644 --- a/scripts/check-app-editor.mjs +++ b/scripts/check-app-editor.mjs @@ -101,7 +101,7 @@ const LINT = [ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', stream = [], spec = SPEC, lint = LINT, versions = [], versionSpec = null, hooks = [], conversation = { id: 'conv-ae', kind: 'app', title: '', turns: [] }, - backfill = undefined, plan = null } = {}) { + backfill = undefined, plan = null, status = 'active' } = {}) { const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) opened.add(ctx) await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } }) @@ -110,11 +110,12 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', const errors = [] page.on('pageerror', (e) => errors.push(e.message)) const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) - const app = { id: 'app-ae', name: SPEC.name, description: '', icon: 'app', status: 'active', published_version: 2, level: 'published', + const app = { id: 'app-ae', name: SPEC.name, description: '', icon: 'app', status, published_version: 2, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(spec), draft_bindings: { slots: {} }, created_by: '检查脚本', ...(backfill ? { backfill } : {}) } const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [], - hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0 } + hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0, + archives: 0 } await page.route(/\/api\//, (route) => { const req = route.request() @@ -151,6 +152,11 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', state.app = { ...state.app, ...(body.draft ? { draft: body.draft } : {}), ...(body.draft_bindings ? { draft_bindings: body.draft_bindings } : {}) } return json(route, state.app) } + if (sub === 'archive') { + state.archives += 1 + state.app = { ...state.app, status: 'archived' } + return json(route, state.app) + } if (sub === 'lint') { state.lints += 1 return json(route, { level: null, issues: lint }) @@ -603,6 +609,37 @@ await section('岗位与授权:三档各写标题和说明;搭建者从名 check('名录只取一次,几个选择器共用', asked.length === 1, String(asked.length)) }) +// 归档(api/apps.py 的 archive_app):后果照服务端的实际行为写;没有恢复的接口,界面上也不给「恢复」 +await section('归档:确认框写明后果;归档后顶部写明已归档,保存和发布置灰', async () => { + const { page, state, errors } = await open({ path: '/apps/app-ae/edit/publish' }) + const archive = page.locator('[data-app-archive]') + check('编辑器给「归档」', await archive.count() === 1) + await archive.click() + const dlg = page.getByRole('dialog') + await dlg.waitFor({ timeout: 3000 }).catch(() => {}) + const text = (await dlg.innerText().catch(() => '')).replace(/\s+/g, ' ') + check('确认框写明后果:列表不再列出、入口不再接受新实例、钩子拒收、进行中的照常走完、不能再改和发布、没有恢复', + ['应用列表里不再列出', '入口不再接受新实例', '入站钩子一律拒收', '进行中的实例照常走完', '不能再修改草稿、发布新版本', '没有恢复'] + .every((k) => text.includes(k)), text) + await dlg.getByRole('button', { name: '取消' }).click() + await page.waitForTimeout(200) + check('……取消就不归档', state.archives === 0 && await page.locator('[data-app-archived]').count() === 0) + await archive.click() + await page.getByRole('dialog').getByRole('button', { name: '归档应用' }).click() + check('确认后发归档请求', await waitFor(page, async () => state.archives === 1)) + const banner = page.locator('[data-app-archived]') + check('归档后顶部写明已归档', await waitFor(page, async () => await banner.count() === 1) + && (await banner.innerText()).includes('已归档')) + check('……不再给「归档」', await page.locator('[data-app-archive]').count() === 0) + check('……保存和发布置灰', await page.locator('[data-app-save]').isDisabled() && await page.locator('[data-app-publish-submit]').isDisabled() + && ((await page.locator('[data-app-publish-submit]').getAttribute('title')) ?? '').includes('已归档')) + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + + const again = await open({ path: '/apps/app-ae/edit/pages', status: 'archived' }) + check('打开已归档的应用:一进来就写明已归档,没有「归档」,保存置灰', await again.page.locator('[data-app-archived]').count() === 1 + && await again.page.locator('[data-app-archive]').count() === 0 && await again.page.locator('[data-app-save]').isDisabled()) +}) + check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、')) console.log(failed ? `\n${failed} 项未通过` : '\n全部通过') await browser.close() diff --git a/scripts/check-apps.mjs b/scripts/check-apps.mjs index 7d53ea85..04788aec 100644 --- a/scripts/check-apps.mjs +++ b/scripts/check-apps.mjs @@ -148,7 +148,16 @@ const TASK = { can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: ago(1800_000), } -const INSTANCE = { +/** 会签组件和待办表单绑同一条待办:页面解析给的数据一样,只有待办表单带 signoffs_on_page */ +function withSignoffsData(instance) { + const tree = instance.view.tree + const form = tree.find((n) => n.id === 'review') + const { signoffs_on_page: _, ...data } = form.data + tree.find((n) => n.id === 'signs').data = data + return instance +} + +const INSTANCE = withSignoffsData({ run_id: 'run_claim_1', title: '张三 的报销', entry_id: 'apply', status: 'interrupted', started_by: '张三', created_at: ago(1800_000), finished_at: null, pending_tasks: 1, tier: null, // 给人看的状态服务端说(apps/resolve.instance_state):结果未知时是在等核实,不是「等待审批」 @@ -157,7 +166,7 @@ const INSTANCE = { format: 'agentlab.view/1', app: { id: 'app_claim', name: '报销审批', version: 2, level: 'published', needs_upgrade: false }, page: { id: 'instance', title: '实例' }, pages: [{ id: 'home', title: '报销' }, { id: 'instance', title: '实例' }], - sources: { inst: { state: 'running', run_id: 'run_claim_1', stamp: null, message: '正在运行,完成后刷新页面' } }, + sources: { inst: { state: 'running', run_id: 'run_claim_1', stamp: null, message: '等待核实写入结果,流程走完后自动显示' } }, tree: [ { id: 'inst_header', type: 'instance_header', props: {}, data: { state: 'ok', run_id: 'run_claim_1', title: '张三 的报销', status: 'interrupted', status_code: 'waiting', @@ -169,9 +178,11 @@ const INSTANCE = { { node: 'finance', title: '财务审批', state: 'waiting', text: '等待审批', at: ago(1700_000) }, { node: 'pay', title: '付款', state: 'unknown', text: '结果未知,等待核实', at: ago(600_000) }, ] } }, - // 发起人不是这条待办的处理人:服务端解析时判定(handler 为假),只给这一步的状态,界面不去取待办 + // 发起人不是这条待办的处理人:服务端解析时判定(handler 为假),只给这一步的状态,界面不去取待办。同一页上另有绑 + // 同一条待办的会签组件(signs):服务端说一声(signoffs_on_page),待办表单不再画会签进度。人名服务端给显示名(people) { id: 'review', type: 'task_form', props: {}, bind: { task: 'review' }, data: { - state: 'ok', handler: false, task_ui: {}, + state: 'ok', handler: false, task_ui: {}, signoffs_on_page: true, + people: { lisi: '李四', wangwu: '王五', zhaoliu: '赵六' }, task: { id: 'task_other', run_id: 'run_claim_1', node_id: 'review', kind: 'approve', title: '复核', status: 'pending', due_at: new Date(Date.now() + 3600_000).toISOString(), policy: { kind: 'all' }, escalation_level: 1, created_at: ago(1700_000), instance_title: '张三 的报销', form: null }, @@ -186,6 +197,7 @@ const INSTANCE = { progress: { policy: 'all', voters: 2, need: 2, agreed: 1, rejected: 0, escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } }, my_response: null } }, + { id: 'signs', type: 'signoffs', props: {}, bind: { task: 'review' }, data: null }, { id: 'inst_tasks', type: 'task_list', props: { scope: 'instance' }, data: { scope: 'instance', items: [] } }, { id: 'pay', type: 'action_status', props: { label: '付款' }, data: { state: 'ok', node: 'pay', records: [ { id: 'act1', state: 'unknown', environment: 'prod', attempt: 1, payload_hash: '9c1e44ab', @@ -194,7 +206,7 @@ const INSTANCE = { ], forms: {}, problems: [], }, -} +}) /** * 指标链照服务端的形状(api/evidence 的 _metric_chain)。销售额按位置取数据整形的结果(vars.q.rows[0][0]),整形节点不是 @@ -330,6 +342,10 @@ await section('应用页:印章、指标、出处、趋势、表格、未知 const stamp = page.locator('[data-stamp]').first() const stampText = await stamp.innerText() check('印章四项:截至、口径、出具档位、来源', ['截至', '口径', '出具档位', '来源'].every((k) => stampText.includes(k)), stampText.replace(/\n/g, ' ')) + // 截至是服务端给的整句(自己带着「截至」):完整、紧凑两种印章都只写一次,不写成「截至 截至 …」 + const stampTexts = await page.locator('[data-stamp]').allInnerTexts() + check('印章里「截至」只出现一次(完整和紧凑两种)', stampTexts.length > 0 && stampTexts.every((t) => t.split('截至').length === 2), + stampTexts.map((t) => t.replace(/\s+/g, ' ').slice(0, 40)).join(' | ')) check('印章写口径名和哈希前 8 位', stampText.includes('周销售 v7 · 3fa2c1d0') && stampText.includes('周销售口径 v3')) check('服务端标出的错位照写', (await stamp.locator('[data-stamp-note]').innerText()).includes('固定在 v7')) check('页面有两页:页签', (await page.getByRole('tab').count()) >= 2) @@ -397,8 +413,8 @@ await section('应用页:印章、指标、出处、趋势、表格、未知 await section('应用页:视图生成中自动重取、需升级提示', async () => { const running = { ...HOME, app: { ...HOME.app, needs_upgrade: true }, - sources: { wk: { state: 'running', run_id: RUN, stamp: null, message: '正在运行,完成后刷新页面' } }, - tree: [{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'running', stamp: null, message: '正在运行,完成后刷新页面' } }] } + sources: { wk: { state: 'running', run_id: RUN, stamp: null, message: '正在运行,完成后自动刷新' } }, + tree: [{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'running', stamp: null, message: '正在运行,完成后自动刷新' } }] } const state = { writes: [], home: running } const { page } = await open({ state }) await page.goto(`${WEB}/apps/app_weekly`) @@ -474,10 +490,17 @@ await section('实例页:状态、进度、待办卡、动作状态', async () check('结果未知的那一步写「结果未知」,不写「完成」', (await page.locator('[data-timeline-step="unknown"]').innerText()).includes('结果未知')) const other = page.locator('[data-component="task_form"][data-task-handler="no"]') - check('不是处理人:只画这一步的状态(在等谁、会签进度、升级后的规则),不出红框', + check('不是处理人:只画这一步的状态(在等谁),不出红框', (await other.locator('[data-task-state="pending"]').count()) === 1 && (await other.locator('[role="alert"]').count()) === 0 - && (await other.innerText()).includes('等待 wangwu、zhaoliu 处理') && (await other.innerText()).includes('1/2 已同意') - && (await other.innerText()).includes('已升级给岗位「director」'), (await other.innerText()).replace(/\n/g, ' ')) + && (await other.innerText()).includes('等待 王五、赵六 处理'), (await other.innerText()).replace(/\n/g, ' ')) + check('……等待名单写显示名,悬停给用户名', (await other.locator('[data-task-waiting-on]').getAttribute('title')) === 'wangwu、zhaoliu') + const signs = page.locator('[data-component="signoffs"]') + check('同一页上有会签组件:待办表单不再画会签进度,会签进度只画一遍', (await other.locator('[data-signoff-head]').count()) === 0 + && (await signs.locator('[data-signoff-head]').count()) === 1 && (await signs.innerText()).includes('1/2 已同意') + && (await signs.innerText()).includes('已升级给岗位「director」'), (await signs.innerText()).replace(/\n/g, ' ')) + const lisi = signs.locator('[data-assignee]').first() + check('……会签名单写显示名,悬停给用户名', (await lisi.innerText()) === '李四' && (await lisi.getAttribute('title')) === 'lisi' + && (await signs.innerText()).includes('赵六'), (await signs.innerText()).replace(/\n/g, ' ')) check('……也不去取那条待办', !(state.taskReads ?? []).includes('/api/tasks/task_other'), JSON.stringify(state.taskReads)) await page.locator('[data-component="task_list"] [data-task]').waitFor({ timeout: 5000 }) check('本实例的待办就地处理(完整的待办卡)', (await page.locator('[data-component="task_list"] [data-task-approve]').count()) === 1) diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index 5e344f2a..5c95dad1 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -3976,11 +3976,14 @@ await section('工具', async () => { const brokenProblem = '参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,不能直接写 "string"' const brokenTool = { id: 'broken_demo', name: 'broken_demo', description: '检查脚本的坏工具', category: '自定义 · http', source: 'custom', dangerous: true, runtime_approval: false, schema: { properties: { store: 'string' } }, problem: brokenProblem } + // 写外部系统的能力:服务端在工具项上给 write_rehearsal(和执行接口同一个判断),在工具库里执行只演练 + const payTool = { id: 'pay_demo', name: 'pay_demo', description: '检查脚本的写入能力', category: '自定义 · http', + source: 'custom', dangerous: true, runtime_approval: true, write_rehearsal: true, schema: { properties: { amount: { type: 'number' } } } } const brokenRow = { id: 'check-manage-broken', name: 'broken_demo', kind: 'http', description: '检查脚本的坏工具', parameters: { properties: { store: 'string' } }, config: { method: 'GET', url: 'https://example.com/x' }, enabled: true, problem: brokenProblem } const { page, sent, natives, errors, close } = await open(danger ? `/tools/library/${danger.id}` : '/tools', { handlers: [ - [/^GET \/tools$/, async (route) => json([...await (await route.fetch()).json(), brokenTool])(route)], + [/^GET \/tools$/, async (route) => json([...await (await route.fetch()).json(), brokenTool, payTool])(route)], [/^GET \/custom-tools$/, async (route) => json([...await (await route.fetch()).json(), brokenRow])(route)], [/^POST \/tools\/[^/]+\/run$/, (route, { body }) => { calls++ @@ -4031,6 +4034,20 @@ await section('工具', async () => { check('参数名被纠正过:成功结果下面用 warn 写出来,提醒先改对再抄进工作流', note.includes('cuont') && note.includes('修正参数名称'), note) check('……结果和提醒落在看得见的地方(不用自己滚)', await inView(page.locator('[data-tool-note]'))) } + { + // 写入能力在这里只演练(执行不先确认、也不真的发出):提示条不说「请求会实际发出」;别的外接工具照旧 + await page.locator('nav[aria-label="工具列表"] button', { hasText: 'pay_demo' }).first().click() + await page.waitForTimeout(300) + const bar = page.locator('[data-tool-side-effect]') + const barText = await bar.innerText().catch(() => '') + check('写入能力:提示条写这里只演练、不真的发送,不说请求会实际发出', await bar.getAttribute('data-tool-side-effect') === 'rehearsal' + && barText.includes('这里只演练,不真的发送') && !barText.includes('实际发出') && !barText.includes('确认后才会执行'), barText) + await page.locator('nav[aria-label="工具列表"] button', { hasText: 'broken_demo' }).first().click() + await page.waitForTimeout(300) + const other = await bar.innerText().catch(() => '') + check('……不是写入能力的自定义工具照旧警示请求会实际发出', await bar.getAttribute('data-tool-side-effect') === 'send' + && other.includes('请求会实际发出'), other) + } { const item = page.locator('nav[aria-label="工具列表"] button', { hasText: 'broken_demo' }).first() const chip = item.locator('[data-tool-problem]') diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index 79ceeff1..388dd6ca 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -876,6 +876,11 @@ await section('auto:修完没有错了,发布按钮才可用;发布只在 await submit(page).click() await waitFor(page, async () => state.publishes.length === 1) check('人点了「发布为受管」才发出一次发布', state.publishes.length === 1 && state.publishes[0].level === 'governed', JSON.stringify(state.publishes)) + // 受管级发布成功:弹窗标题写明受管(和工具栏「受管 vN」、发布提示同一种说法),不只写「已发布 vN」 + const done = dialog(page).locator('[data-publish-done-level="governed"]') + check('受管发布成功:弹窗换成成功的说明', await waitFor(page, async () => await done.count() === 1)) + const head = await dialog(page).innerText().catch(() => '') + check('……标题写明受管', head.includes(`已发布 v${state.version} · 受管`), head.split('\n')[0]) }) await section('choice:没选之前不能应用;选了之后载荷里是选的值', async () => { @@ -1967,6 +1972,9 @@ await section('应用平台:发布成功做成应用;已发布版本的使 await waitFor(page, async () => state.publishes.length === 1) const done = dialog(page).locator('[data-publish-done]') check('发布成功:弹窗不关,换成成功的说明', await waitFor(page, async () => await done.count() === 1)) + const head = await dialog(page).innerText().catch(() => '') + check('……已发布级的标题不写受管', head.includes(`已发布 v${state.version}`) && !head.includes('受管') + && await done.getAttribute('data-publish-done-level') === 'published', head.split('\n')[0]) const make = dialog(page).locator('[data-publish-make-app]') check('……给「做成应用」的入口', await make.count() === 1 && (await make.innerText()).includes('做成应用')) await make.click() diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index 29ab2f11..7dc2e036 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -135,6 +135,7 @@ const wf = (id, extra) => ({ id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH, tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template, status: extra.status ?? 'draft', published_version: extra.published_version ?? null, + published_level: extra.published_level ?? null, same_as_published: !!extra.same_as_published, published_by: extra.published_by ?? null, run_count: 0, created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z', }) @@ -2979,6 +2980,52 @@ await section('应用平台:人工节点的流程配置、入口字段的类 } }) +await section('受管工作流在画布上改过之后:页头、版本历史、选择器照当前发布版的级别写「受管」;和发布版内容相同时不写领先', async () => { + // 画布上改过之后 status 回到草稿,当前发布版仍是受管级(published_level)。st-govs:撤回改动后保存出来的 v8, + // 服务端按图哈希比出和 v6 相同(same_as_published);st-gova:真的改过、领先 2 版 + FAKES['st-govs'] = wf('st-govs', { name: '__studio_check_gov_same__', version: 8, published_version: 6, published_level: 'governed', same_as_published: true }) + FAKES['st-gova'] = wf('st-gova', { name: '__studio_check_gov_ahead__', version: 8, published_version: 6, published_level: 'governed' }) + const versions = [8, 7, 6, 5].map((n) => ({ id: `gv${n}`, version: n, note: '', created_at: '2026-10-15T02:00:00Z', + published: n === 6, level: n === 6 ? 'governed' : n === 5 ? 'published' : null })) + const before = (page) => page.route(/\/api\/workflows\/st-gov[sa]\/versions$/, (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(versions) })) + try { + const same = await open({ path: '/studio/st-govs', before }) + const label = (p) => p.locator('[data-version-label]').innerText().then((t) => t.replace(/\s+/g, ' '), () => '') + const sameText = await label(same.page) + check('内容和发布版相同:写「与发布版 v6 内容相同」,不写领先', sameText.includes('草稿 v8') && sameText.includes('与发布版 v6 内容相同') + && !sameText.includes('领先'), sameText) + check('……发布版写「受管 v6」,不写「已发布」', sameText.includes('受管 v6') && !sameText.includes('已发布'), sameText) + await blur(same.page) + await same.page.keyboard.press('Alt+KeyH') + const v6 = same.page.locator('ol[aria-label="版本"] > li').nth(2) + await v6.waitFor({ timeout: 3000 }).catch(() => {}) + const v6Text = await v6.innerText().catch(() => '') + check('版本历史:当前发布版按这一版自己的级别写「受管」', await v6.locator('[data-version-published="governed"]').count() === 1 + && v6Text.includes('受管') && !v6Text.includes('已发布'), v6Text.replace(/\s+/g, ' ')) + check('没有运行时报错', same.errors.length === 0, same.errors.slice(0, 2).join(' | ')) + await same.ctx.close() + + const ahead = await open({ path: '/studio/st-gova', before }) + const aheadText = await label(ahead.page) + check('真的改过:照旧写领先 2 版,发布版写「受管 v6」', aheadText.includes('领先 2 版') && aheadText.includes('受管 v6') + && !aheadText.includes('内容相同') && !aheadText.includes('已发布'), aheadText) + await ahead.page.locator('button[title^="切换、新建"]').click() + await ahead.page.waitForTimeout(300) + await ahead.page.keyboard.type('__studio_check_gov_ahead__') + await ahead.page.waitForTimeout(200) + const row = ahead.page.locator('li[role="option"]', { has: ahead.page.locator('span.truncate', { hasText: /^__studio_check_gov_ahead__$/ }) }) + const chips = await row.locator('.chip').allInnerTexts() + check('选择器:草稿之外写「受管 v6」', chips.includes('草稿') && chips.some((t) => t.includes('受管 v6')) && !chips.some((t) => t.includes('已发布')), + chips.join(' | ')) + await ahead.page.keyboard.press('Escape') + await ahead.ctx.close() + } finally { + delete FAKES['st-govs'] + delete FAKES['st-gova'] + } +}) + check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`) check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))