复测收尾:账号密码登录时应用检查和授权接口核对名录(没有的、已停用的报错或拒收,单点登录只提醒),名录口径收成 auth/directory 一处;受管发布版在页头、版本历史、选择器、发布前检查里按发布级别写,草稿和发布版内容相同时照实说;写入能力在工具库写「只演练」;印章的「截至」只写一次;走驳回出口到达的结局不用成功样式;停着等人时说在等哪一步,同页已有会签进度时待办状态不再画一遍,名单写显示名;应用编辑器可以归档(写清后果),已归档的应用拒收草稿修改和发布;受管发布成功的提示写明受管;权限测试按 id 取应用,不撞别的测试留下的同名应用

This commit is contained in:
ThearchyHelios
2026-10-15 08:35:28 +08:00
parent 77cd95901a
commit e182c2d214
37 changed files with 918 additions and 127 deletions
+26 -5
View File
@@ -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)
+4 -3
View File
@@ -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",
})
+8 -6
View File
@@ -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()]}
# ---------------------------------------------------------------- 审计
+10 -1
View File
@@ -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
+18
View File
@@ -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
+2
View File
@@ -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:
+57 -9
View File
@@ -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]]:
+67 -9
View File
@@ -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()
+97
View File
@@ -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}名录里没有")
+10
View File
@@ -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 读。"""
+60 -11
View File
@@ -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():
@@ -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": "财务"}]
+21
View File
@@ -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_))
+17
View File
@@ -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
+5 -3
View File
@@ -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
@@ -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"}}
+5 -1
View File
@@ -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, <span className="mono">{caliber}</span>, 'caliber'],
[STAMP_TEXT.tier, <span style={tier ? { color: tier, fontWeight: 600 } : undefined}>{stamp.tier_text || '—'}</span>, 'tier'],
[STAMP_TEXT.from, stamp.from, 'from'],
@@ -48,6 +50,8 @@ export function Stamp({ stamp, compact = false, className }: { stamp: StampData;
return (
<div className={clsx('rounded-md border border-dashed px-2.5 py-2 text-2xs', className)}
data-stamp="" data-stamp-tier={stamp.tier ?? ''} data-stamp-sealed={stamp.sealed ? '1' : '0'}>
{/* 截至:服务端的整句自己带着「截至」,单独一行写,不进下面带标签的三项 */}
<p className="mb-0.5 min-w-0 break-words text-fg" data-stamp-row="as_of">{stamp.as_of_text}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
{rows.map(([label, value, key]) => (
<div key={key} className="contents" data-stamp-row={key}>
+19 -10
View File
@@ -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<string, any>): 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 <div className="text-2xs text-faint" data-component="task_form">{VIEW_TEXT.tasks.empty}</div>
return (
<div className="overflow-hidden rounded-lg border" data-component="task_form" data-task-handler={handler ? 'yes' : 'no'}>
{preview ? <TaskRow task={summary} />
: !handler ? <TaskState task={taskOf(node.data!)} />
: !handler ? <TaskState task={taskOf(node.data!)} signoffs={signoffs} />
: error ? <ErrorState compact error={error} onRetry={retry} className="m-2" />
: !task ? <div className="px-3 py-3"><Spinner size={11} /></div>
: <TaskCard task={task} onResolved={() => { retry(); onReload?.() }} />}
: <TaskCard task={task} showSignoffs={signoffs} onResolved={() => { retry(); onReload?.() }} />}
</div>
)
}
/** 这一步的状态(不是处理人看的):种类、标题、状态、时限、升级;在等谁、会签进度;处理完了写谁、什么时候、结果 */
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 (
<div className="space-y-1.5 p-3" data-task-state={task.status} data-task={task.id}>
<div className="tnum flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">
@@ -282,7 +287,11 @@ function TaskState({ task }: { task: Task }) {
</div>
{!pending && <Resolved task={task} />}
{pending && waiting.length > 0 && (
<div className="text-2xs text-dim" data-task-waiting-on="">{TASK_TEXT.waitingOn(waiting.join('、'))}</div>
// 悬停给用户名(写的是显示名的那几个)
<div className="text-2xs text-dim" data-task-waiting-on=""
title={waiting.map((a) => assigneeHint(a, task.people)).filter(Boolean).join('、') || undefined}>
{TASK_TEXT.waitingOn(waiting.map((a) => assigneeName(a, task.people)).join('、'))}
</div>
)}
{voting && <div className="rounded border bg-bg p-2"><SignoffHead task={task} /></div>}
{pending && task.my_response && (
+10 -8
View File
@@ -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<number | null>(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 (
<Modal open onClose={onDone} title={VERSION_USAGE_TEXT.published(done)} width={480}
<Modal open onClose={onDone} title={VERSION_USAGE_TEXT.published(done.version, done.level)} width={480}
footer={<>
<button className="btn" onClick={onDone} data-autofocus="">完成</button>
<button className="btn btn-primary" data-publish-make-app=""
onClick={() => {
onDone()
navigate(newAppPath({ workflow: workflow.id, version: done }))
navigate(newAppPath({ workflow: workflow.id, version: done.version }))
}}>
<LayoutGrid size={12} aria-hidden /> {VERSION_USAGE_TEXT.makeApp}
</button>
</>}>
<div className="flex items-start gap-2 text-xs leading-relaxed" data-publish-done={done}>
<div className="flex items-start gap-2 text-xs leading-relaxed" data-publish-done={done.version} data-publish-done-level={done.level}>
<CheckCircle2 size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--ok)' }} aria-hidden />
<span>{VERSION_USAGE_TEXT.publishedNext}</span>
</div>
@@ -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<PublishLevel>(workflow?.status === 'governed' ? 'governed' : 'published')
// 默认按当前发布版的级别查:画布上改过之后 status 回到草稿,受管工作流仍按受管查
const [level, setLevel] = useState<PublishLevel>(workflow ? publishedLevelOf(workflow) : 'published')
// 修复正在出预览、存草稿、重查时不换档(和发布弹窗同一条):方向键也换不了
const busy = useRef(false)
const pick = useCallback((l: PublishLevel) => { if (!busy.current) setLevel(l) }, [])
+6 -3
View File
@@ -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
<span title={formatDateTime(v.created_at)}>{formatTime(v.created_at)}</span>
{current && <span className="chip">{dirty ? '画布基于此版本' : '当前画布版本'}</span>}
{published && (
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>
// 按这一版自己的级别写:画布上改过之后 status 回到草稿,受管发布版照实写「受管」。老数据没记级别的
// 退回看工作流当前发布版的级别
<span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}
data-version-published={v.level ?? publishedLevelOf(workflow)}>
<ShieldCheck size={9} />
{WORKFLOW_STATUS_LABEL[workflow.status === 'governed' ? 'governed' : 'published']}
{WORKFLOW_STATUS_LABEL[v.level ?? publishedLevelOf(workflow)]}
</span>
)}
{!published && v.level && !v.retired_at && (
+3 -2
View File
@@ -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) {
+32 -1
View File
@@ -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: '请求',
+3 -2
View File
@@ -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: '刷新',
+18 -8
View File
@@ -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 (
<span className="flex shrink-0 items-center overflow-hidden rounded-full border text-2xs leading-5" title={title}>
{!live && <span className="tnum px-2 text-dim">草稿 v{w.version}</span>}
<span className="flex shrink-0 items-center overflow-hidden rounded-full border text-2xs leading-5" title={title}
data-version-label="" data-same-as-published={same || undefined}>
{!live && (
<span className="tnum px-2 text-dim">
草稿 v{w.version}
{same && <span className="text-faint @max-[1120px]:hidden">({VERSION_LABEL_TEXT.sameAsPublished(p)})</span>}
</span>
)}
{/* 最窄的工具栏里画布不是已发布那一版时只留「草稿 vN」:已发布的版本号在正式运行按钮上 */}
{p != null && (
<span className={clsx('tnum flex items-center gap-1 px-2', !live && 'border-l @max-[760px]:hidden')}
style={{ color: 'var(--ok)', ...(w.status === 'governed' ? { background: 'var(--st-done-soft)' } : {}) }}>
style={{ color: 'var(--ok)', ...(governed ? { background: 'var(--st-done-soft)' } : {}) }}>
{/* 工具栏窄了只留盾牌和版本号,字留给读屏(全文在 title 里) */}
<ShieldCheck size={10} /> <span><span className="@max-[880px]:sr-only">{pubWord} </span>v{p}</span>
{!live && ahead > 0 && <span className="text-faint @max-[1120px]:hidden">(领先 {ahead} 版)</span>}
+15 -4
View File
@@ -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() {
</div>
)}
{picked.dangerous && (
<div className="mb-3 flex gap-2 rounded-lg border px-2.5 py-2 text-xs leading-relaxed"
{picked.dangerous && (picked.write_rehearsal ? (
// 写入能力:执行不先确认(演练没有副作用)、也不真的发出,提示条和结果的「演练」同一个颜色
<div className="mb-3 flex gap-2 rounded-lg border px-2.5 py-2 text-xs leading-relaxed" data-tool-side-effect="rehearsal"
style={{ borderColor: 'color-mix(in srgb, var(--st-waiting) 40%, var(--border))', background: 'var(--st-waiting-soft)' }}>
<StatusBadge status="waiting" size={13} decorative className="mt-0.5 shrink-0" />
<span>{sideEffectNote(picked)}</span>
</div>
) : (
<div className="mb-3 flex gap-2 rounded-lg border px-2.5 py-2 text-xs leading-relaxed" data-tool-side-effect="send"
style={{ borderColor: 'color-mix(in srgb, var(--warn) 40%, var(--border))', background: 'color-mix(in srgb, var(--warn) 7%, transparent)' }}>
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warn)]" aria-hidden />
<span>
此处直接执行,不经审批:执行前会先说明具体操作,确认后才会执行。{sideEffectNote(picked)}
</span>
</div>
)}
))}
<div className="mb-1 flex items-center gap-2">
<label className="label !mb-0" htmlFor="tool-args">参数</label>
+39 -8
View File
@@ -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<EditorState | null>(() => (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 <ErrorState error={error} onRetry={() => void load()} className="m-6" />
// 没有 manage 的人取不到草稿(D-m):手动打开编辑地址时说清楚,别一直停在骨架屏
@@ -237,12 +262,18 @@ function EditApp({ appId }: { appId: string }) {
title={T.assistantToggle} onClick={() => setAssistant((v) => !v)}>
<Bot size={11} aria-hidden /> {T.assistant}
</button>
<button type="button" className="btn btn-sm btn-primary" disabled={!dirty || saving} onClick={() => void save()}
data-app-save="">
{!ctx.archived && (
<button type="button" className="btn btn-sm" disabled={archiving} onClick={() => void archive()} data-app-archive="">
{archiving ? <Spinner size={11} /> : <Archive size={11} aria-hidden />} {T.archive}
</button>
)}
<button type="button" className="btn btn-sm btn-primary" disabled={!dirty || saving || ctx.archived}
title={ctx.archived ? T.archivedLocked : undefined} onClick={() => void save()} data-app-save="">
{saving ? <Spinner size={11} /> : <Save size={11} aria-hidden />} {saving ? T.saving : T.save}
</button>
</>}
/>
{ctx.archived && <Notice tone="warn" className="mx-4 mt-3" attr={{ 'data-app-archived': '' }}>{T.archivedBanner}</Notice>}
<div className="flex min-h-0 flex-1">
<nav className="w-36 shrink-0 border-r p-2" aria-label={T.title}>
{SECTIONS.map((s) => (
@@ -147,7 +147,7 @@ export function CheckSection() {
}
export function PublishSection({ onPublished }: { onPublished: () => void }) {
const { appId, dirty, save, goto, draft } = useEditor()
const { appId, dirty, save, goto, draft, archived } = useEditor()
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const [blocked, setBlocked] = useState<LintIssue[] | null>(null)
@@ -194,8 +194,9 @@ export function PublishSection({ onPublished }: { onPublished: () => void }) {
<Field label={T.publishNote} className="min-w-0 flex-1">
{(p) => <input {...p} className="field" value={note} onChange={(e) => setNote(e.target.value)} />}
</Field>
<button type="button" className="btn btn-primary" disabled={busy || !draft.pins.length} onClick={() => void publish()}
data-app-publish-submit="">
{/* 归档了的应用不再发布(服务端同样拒绝,app_archived) */}
<button type="button" className="btn btn-primary" disabled={busy || !draft.pins.length || archived}
title={archived ? T.archivedLocked : undefined} onClick={() => void publish()} data-app-publish-submit="">
{busy ? <Spinner size={11} /> : <ShieldCheck size={11} aria-hidden />} {busy ? T.publishing : T.publish}
</button>
</div>
+2
View File
@@ -7,6 +7,8 @@ export interface EditorState {
appId: string
/** 应用详情(manage):带着草稿、岗位绑定和能按新口径补算的视图 */
detail: AppDetail
/** 应用已归档(服务端的 status):不能再改草稿、发布(服务端同样拒绝),保存和发布置灰 */
archived: boolean
draft: AppSpec
/** 改草稿:传函数拿到最新的那一份(助手的操作和人的编辑可能挨着到) */
setDraft: (fn: (spec: AppSpec) => AppSpec) => void
+41 -13
View File
@@ -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<TaskKind, typeof Hand> = {
@@ -68,7 +70,7 @@ const KIND_ICON: Record<TaskKind, typeof Hand> = {
/** 一天没人处理就用琥珀色写等了多久,同审批卡 */
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<string, any> = task.payload ?? {}
const kind = task.kind
@@ -148,7 +150,7 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
<TaskMeta task={task} showOwner={showOwner} />
{!pending && <Resolved task={task} />}
{(task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />}
{showSignoffs && (task.assignments.length > 1 || task.policy.kind !== 'any') && <Signoffs task={task} />}
{(kind === 'approve' || kind === 'input' || kind === 'edit') && payload.kind === 'human_node' && runClass && (
<ApprovalRunClass runClass={runClass} approve={kind === 'approve'} />
@@ -333,7 +335,7 @@ export function Due({ dueAt, now }: { dueAt: string | null; now: number }) {
}
/** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */
export function Resolved({ task }: { task: Pick<Task, 'status' | 'outcome' | 'resolved_by' | 'resolved_at'> }) {
export function Resolved({ task }: { task: Pick<Task, 'status' | 'outcome' | 'resolved_by' | 'resolved_at' | 'people'> }) {
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' | 'outcome' | 're
<span className="chip">{TASK_STATUS_LABEL[task.status] ?? task.status}</span>
{decision && <span>{TASK_TEXT.outcome}:{decision}</span>}
{(task.resolved_by || at) && (
<span>{task.resolved_by ? TASK_TEXT.resolvedBy(task.resolved_by, at) : TASK_TEXT.resolvedAt(at)}</span>
<span title={usernameHint(task.resolved_by, task.people)}>
{task.resolved_by ? TASK_TEXT.resolvedBy(personName(task.resolved_by, task.people), at) : TASK_TEXT.resolvedAt(at)}
</span>
)}
</div>
)
@@ -369,27 +373,51 @@ function progressOf(task: Pick<Task, 'progress' | 'policy' | 'assignments'>): Ta
}
/** 升级之后的规则:升级给谁、其中一人说了算;有人定了就写谁定了什么 */
function escalationText(p: TaskProgress): string | null {
function escalationText(p: TaskProgress, people?: Record<string, string> | 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<TaskAssignment, 'principal_kind' | 'principal' | 'responded_by'>): string {
/**
* 人名:有显示名写显示名(服务端在待办上给 people:用户名 → 显示名),名录里没有的(关登录时自报的署名)照写原名。
* usernameHint 是悬停给的用户名:显示名和用户名一样时不给
*/
export function personName(username: string, people?: Record<string, string> | null): string {
return people?.[username] || username
}
export function usernameHint(username: string | null | undefined, people?: Record<string, string> | null): string | undefined {
return username && personName(username, people) !== username ? username : undefined
}
export function assigneeName(a: Pick<TaskAssignment, 'principal_kind' | 'principal' | 'responded_by'>,
people?: Record<string, string> | 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<TaskAssignment, 'principal_kind' | 'principal' | 'responded_by'>,
people?: Record<string, string> | 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<Task, 'progress' | 'policy' | 'assignments'> }) {
export function SignoffHead({ task }: { task: Pick<Task, 'progress' | 'policy' | 'assignments' | 'people'> }) {
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 (
<>
<div className="mb-1 flex flex-wrap items-center gap-x-2 text-2xs" data-signoff-head="">
@@ -435,7 +463,7 @@ export function Signoffs({ task }: { task: Task }) {
<span aria-hidden style={{ color: agreed(a) ? 'var(--st-done)' : rejected(a) ? 'var(--err)' : 'var(--text-faint)' }}>
{agreed(a) ? '✓' : rejected(a) ? '✕' : '○'}
</span>
<span className="text-fg">{assigneeName(a)}</span>
<span className="text-fg" title={assigneeHint(a, task.people)} data-assignee="">{assigneeName(a, task.people)}</span>
{escalated(a) && <span className="chip" style={{ color: 'var(--st-waiting)' }}>{TASK_TEXT.escalationTag}</span>}
<span className="text-dim">
{a.status === 'responded'
+4 -3
View File
@@ -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<StudioState>((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 })
+7
View File
@@ -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
}
/**
+2
View File
@@ -455,6 +455,8 @@ export interface Task {
resolved_by: string | null
resolved_at: string | null
created_at: string
/** 待办上出现的人的显示名(用户名 → 显示名:派给谁、谁回复、谁处理)。界面写显示名、悬停给用户名;老服务端不给 */
people?: Record<string, string>
}
export interface TaskRespond {
+40 -3
View File
@@ -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()
+33 -10
View File
@@ -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)
+18 -1
View File
@@ -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]')
+8
View File
@@ -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()
+47
View File
@@ -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('、'))