mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
复测收尾:账号密码登录时应用检查和授权接口核对名录(没有的、已停用的报错或拒收,单点登录只提醒),名录口径收成 auth/directory 一处;受管发布版在页头、版本历史、选择器、发布前检查里按发布级别写,草稿和发布版内容相同时照实说;写入能力在工具库写「只演练」;印章的「截至」只写一次;走驳回出口到达的结局不用成功样式;停着等人时说在等哪一步,同页已有会签进度时待办状态不再画一遍,名单写显示名;应用编辑器可以归档(写清后果),已归档的应用拒收草稿修改和发布;受管发布成功的提示写明受管;权限测试按 id 取应用,不撞别的测试留下的同名应用
This commit is contained in:
+26
-5
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
@@ -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()]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 审计
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}名录里没有")
|
||||
@@ -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 读。"""
|
||||
|
||||
@@ -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": "财务"}]
|
||||
@@ -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_))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) }, [])
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '请求',
|
||||
|
||||
@@ -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: '刷新',
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface EditorState {
|
||||
appId: string
|
||||
/** 应用详情(manage):带着草稿、岗位绑定和能按新口径补算的视图 */
|
||||
detail: AppDetail
|
||||
/** 应用已归档(服务端的 status):不能再改草稿、发布(服务端同样拒绝),保存和发布置灰 */
|
||||
archived: boolean
|
||||
draft: AppSpec
|
||||
/** 改草稿:传函数拿到最新的那一份(助手的操作和人的编辑可能挨着到) */
|
||||
setDraft: (fn: (spec: AppSpec) => AppSpec) => void
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user