diff --git a/README.md b/README.md index d31d54b9..131d4bb1 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,16 @@ flowchart LR 写入结果未知的核实后继续、不会重发;停在 Agent 半路审批的实例升级后无法继续,所以发布时 `process.wait_mid_agent` 会提醒,应用按流程引用时直接拦下。 +**填报登记与附件** 填报登记的每次提交是平台托管的记录表里的一行(`data/records/`,一个表版本一个 SQLite 文件, +只许追加):写入只经「调用工具」节点的平台能力 `records_append`,同一次提交只落一行;更正是追加一行并指向原行,原行 +不改,更正的那一行仍记原提交人(他照样看得见),动手更正的人记进动作记录和审计。看板用 `db_query__<记录表>` 查, +一次运行只看得到开始时已有的记录;使用者逐行只看得到自己提交的(「记录列表」组件),正式运行的汇总是全量的。 +表单只加可选字段时同一版本加列,改类型、删字段、加必填字段出新版本,旧记录照原样保留:看板查新一版时, +旧版本只读并进同一张 `records`(旧版本没有的字段、类型改得对不上的字段为空),各版本都按运行 +开始时的记录固定;新一版发布之前已经发起、还在半路的实例照常写完旧版本,之后发起的只写新一版。记录表按单进程锁写入, +面向人工填报的频率,不适合每秒多次的高频写入。表单里的附件先上传(`data/files/`,按内容哈希存、只读)、提交时绑到 +这次运行,读得到那次运行或那一行记录的人才能下载;上传后 24 小时没提交的自动清理。 + **验收用的假外部系统**:`python scripts/fake-erp.py --port 8765`,可以切换「正常 / 写成功后超时 / 500 / 4xx」,接口见 脚本开头。 @@ -365,15 +375,27 @@ cd backend && python -m app.auth reset-admin --username admin # 不给 --passw | 问数据、助手 | ✓ | ✓ | 默认不能,管理员可在「设置 → 身份」开启 | | 编排、探索运行、以已发布级发布、数据源、数据目录、定时任务、知识库、自定义工具 | ✓ | ✓ | ✗ | | 以受管级发布、把受管工作流降为已发布、版本退役 | ✓ | ✗ | ✗ | -| 记录页、运行的实时推送 | ✓ | ✓ | 只限自己的问数据运行 | +| 记录页、运行的实时推送 | ✓(别人的个人应用除外) | ✓(别人的个人应用除外) | 只限自己的问数据运行 | | MCP 的 stdio、端点和凭据、能力治理、模型接入 | ✓ | 只读 | ✗ | | 应用:使用 / 查看全部实例 / 搭建发布 | ✓ | 能用能看;管理要创建者或有管理授权 | 按授权使用、查看 | +| 共享蓝图:读、改、试运行、出发布单 | ✓ | 自己建的、自己管理的应用出自的 | ✗ | +| 建个人应用、刷新、提交审核 | ✓ | ✓ | 默认不能,管理员可在「设置 → 应用」开启 | +| 别人的个人应用:名称、拥有者、创建和更新时间,归档 | ✓ | ✗ | ✗ | +| 别人的个人应用:页面、刷新和试运行的运行、证据、附件、蓝图 | ✗ | ✗ | ✗ | | 处理派给自己的待办、核实动作 | ✓ | ✓ | ✓ | | 用户、组、登录方式、审计 | ✓ | ✗ | ✗ | **使用者的问数据**(管理员开启后)只能发起探索运行:图里只许输入、模型调用、Agent、报告撰写、成果、知识检索、 -数据整形、条件分支、口径卡、合并查询;Agent 只能用数据源查询和只读的内置工具;查询结果在工具边界先遮、数据源一律 -只读;记忆范围固定为自己的。对话按建立人隔离。 +数据整形、条件分支、口径卡、合并查询、调用工具;Agent 和调用工具节点都只能用数据源查询和只读的内置工具;查询结果在 +工具边界先遮、数据源一律只读(调用工具节点和 Agent 走同一道),能力治理里改成写入的工具也不执行;记忆范围固定为 +自己的。对话按建立人隔离。 + +**个人应用**:在蓝图上「存成我的应用」,只给拥有者自己看;使用者要管理员在「设置 → 应用」里开启。图和使用者的问数据 +同一份白名单,周期看板、异常处置(只看清单)这些模板的取数节点就是查数据源的调用工具节点;每次刷新是一次探索运行, +查询结果在工具边界先遮、数据源一律只读,和按需视图共用每人每天的次数上限。刷新和个人蓝图的试运行只有拥有者读得到: +管理员、搭建者也看不到这些运行、证据、工件和附件,运行列表里不列。管理员在应用列表里只看得到名称、拥有者、创建和 +更新时间,可以归档,看不到页面和蓝图。个人应用没有入口表单,刷新不带附件;要随表单交附件的,请提交审核,由搭建者 +做成正式应用。 **哪些角色看得到原值**:数据源设置的遮罩列,对开了登录的使用者是边界——查询快照按派生遮罩处理后才给,工具快照的 原文、运行事件里的结果预览一律不给,工件按白名单给。对搭建者、管理员,以及未开启登录时的所有人,遮罩只减少暴露, @@ -807,7 +829,8 @@ pip install -e 'backend[microvm,dev,db,docs]' -c backend/requirements.lock # ### 数据位置 全部位于 `data/`:`agentlab.db` 业务数据、`checkpoints.db` 执行断点、 -`artifacts/` 工件库、`workspace/` 沙箱文件、`uploads/` 上传文件、`.secret_key` 加密密钥。 +`artifacts/` 工件库、`workspace/` 沙箱文件、`uploads/` 上传文件、`records/` 填报登记的记录表、`files/` 表单附件、 +`.secret_key` 加密密钥。 删除 `data/` 即恢复出厂设置。 --- diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index bd532f5a..68b04481 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -133,19 +133,29 @@ async def _summary(session: AsyncSession, app: App, principal: Principal, role: "published_version": app.published_version, "level": version.level if version else None, "needs_upgrade": await _needs_upgrade(session, version), "pending_tasks": await _pending(session, app.id, principal), "my_role": role, + "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, # 个人应用(N7):scope=personal、拥有者;出自哪个蓝图(共享应用经发布单生成的也有) "scope": app.scope or "shared", "owner": app.owner, "blueprint_id": app.blueprint_id} def _personal_stub(app: App) -> dict[str, Any]: - """管理员在应用列表里看到的别人的个人应用:在不在、谁的、叫什么、状态,能归档;说明、页面、数一概不给(P4)。""" + """管理员看到的别人的个人应用(列表、详情、归档的回包同一份):名称、拥有者、状态、创建和更新时间,能归档;说明、 + 页面、数、运行、蓝图一概不给(P4,N7「管理员看得到谁有几个个人应用,可以归档,但不看内容」)。""" return {"id": app.id, "name": app.name, "description": "", "icon": "app", "status": app.status, "published_version": None, "level": None, "needs_upgrade": False, "pending_tasks": 0, "my_role": None, + "created_at": app.created_at.isoformat() if app.created_at else None, "updated_at": app.updated_at.isoformat() if app.updated_at else None, "scope": "personal", "owner": app.owner, "blueprint_id": None} +def _sees_stub(principal: Principal, app: App) -> bool: + """这个人对这个应用只看得到元数据(_personal_stub):开了登录的管理员看别人的个人应用。""" + from app.apps.access import is_personal + + return is_personal(app) and principal.mode != "off" and principal.role == "admin" + + def _published_outline(version: AppVersion | None) -> dict[str, Any] | None: """当前发布版里使用面要的那一点:有哪些页面、哪些入口(名字和标题)。页面内容走页面解析,表单走页面的 forms。""" if version is None: @@ -158,6 +168,36 @@ def _published_outline(version: AppVersion | None) -> dict[str, Any] | None: if isinstance(e, dict) and e.get("id")]} +def _draft_changed(app: App, previous: AppVersion | None) -> bool | None: + """草稿和当前发布版是否一致(N6,页面上直接改时说「有未发布的改动」还是「和发布版相同」)。按发布时的规整方式比 + (publish_draft:parse_spec 规整成同一个形状),两边都过一遍,老版本发布时还没有的字段按缺省补齐、不算改动;钉住项的 + 级别发布时按库里的那一版补、不出自草稿,不比。岗位绑定一并比。没发布过、发布版规整不了的为 None(不知道,界面不猜); + 草稿规整不了的算改过(发布前得先改好)。""" + from pydantic import ValidationError + + from app.apps.lint import pins_with_state + from app.apps.spec import parse_spec + from app.core.artifact_store import canonical_json + + def normal(spec: Any) -> str: + out = parse_spec(spec or {}).model_dump(mode="json", by_alias=True) + out["pins"] = pins_with_state(out, {}) + return canonical_json(out) + + if previous is None: + return None + try: + published = normal(previous.spec) + except ValidationError: + return None + try: + draft = normal(app.draft) + except ValidationError: + return True + bindings = {"slots": {}, **(app.draft_bindings or {})} + return draft != published or canonical_json(bindings) != canonical_json(previous.bindings or {"slots": {}}) + + async def _detail(session: AsyncSession, app: App, principal: Principal, role: str | None, *, with_backfill: bool = False) -> dict[str, Any]: """应用详情。草稿和草稿里的岗位绑定只给 manage(D-m):草稿是还没发布、没过检查的东西,岗位绑定是这个部署里具体 @@ -167,8 +207,8 @@ async def _detail(session: AsyncSession, app: App, principal: Principal, role: s 归档这些改动的回包里不算(编辑器存一次草稿就算一遍太浪费,补算只看发布版,草稿改了也不变)。""" from app.apps.spec import empty_spec - out = {**await _summary(session, app, principal, role), - "published": _published_outline(await _previous(session, app))} + previous = await _previous(session, app) + out = {**await _summary(session, app, principal, role), "published": _published_outline(previous)} if not at_least(role, "manage"): return out out.update(draft=app.draft or empty_spec(app.name), draft_bindings={"slots": {}, **(app.draft_bindings or {})}, @@ -179,6 +219,8 @@ async def _detail(session: AsyncSession, app: App, principal: Principal, role: s out["personal"] = await personal_state(session, app, principal) return out + if (changed := _draft_changed(app, previous)) is not None: + out["draft_changed"] = changed if with_backfill: from app.apps.backfill import backfillable @@ -200,7 +242,6 @@ async def list_apps(include_archived: bool = False, scope: str | None = None, 个人看管理员的开关,blueprints/personal.allowed)。""" from sqlalchemy import or_ - from app.apps.access import is_personal from app.blueprints.personal import allowed principal = _principal(x_actor) @@ -215,7 +256,7 @@ async def list_apps(include_archived: bool = False, scope: str | None = None, for app in (await session.execute(query)).scalars(): role = await app_role(session, principal, app) if role is None: - if is_personal(app) and principal.mode != "off" and principal.role == "admin": + if _sees_stub(principal, app): items.append(_personal_stub(app)) continue items.append(await _summary(session, app, principal, role)) @@ -279,7 +320,12 @@ async def derive(payload: DeriveIn, session: AsyncSession = Depends(get_session) @router.get("/{app_id}") async def get_app(app_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """应用详情(_detail)。管理员打开别人的个人应用只拿到元数据(_personal_stub,同列表里那一条),页面、草稿、刷新、 + 蓝图的接口照旧当它不存在。""" principal = _principal(x_actor) + stub = await session.get(App, app_id) + if stub is not None and _sees_stub(principal, stub) and await app_role(session, principal, stub) is None: + return {**_personal_stub(stub), "published": None} app, role = await _app(session, app_id, principal, "use") return await _detail(session, app, principal, role, with_backfill=True) @@ -354,7 +400,7 @@ async def archive_app(app_id: str, session: AsyncSession = Depends(get_session), await session.commit() await session.refresh(app) if role is None: - return _personal_stub(app) + return {**_personal_stub(app), "published": None} return await _detail(session, app, principal, role) diff --git a/backend/app/api/artifacts.py b/backend/app/api/artifacts.py index 798037ca..e8099dbe 100644 --- a/backend/app/api/artifacts.py +++ b/backend/app/api/artifacts.py @@ -17,11 +17,12 @@ _MAX_INLINE = 2 * 1024 * 1024 async def get_artifact(artifact_id: str) -> dict[str, Any]: """按内容哈希取回一个工件。取回时会复验哈希——内容不可变是这层的全部承诺。 - 开了登录:builder 及以上照旧拿原文。user 角色只能取它能读的运行引用的工件(can_read_artifact),而且对它来说遮罩 + 开了登录:builder 及以上照旧拿原文,只被别人的个人应用刷新、个人蓝图试运行引用的除外(只给拥有者,管理员也一样, + auth/access.artifact_readable)。user 角色只能取它能读的运行引用的工件(can_read_artifact),而且对它来说遮罩 就是边界:只给白名单里的几种,按派生遮罩处理后再给(_masked_for_user),别的种类一律不给。""" principal = current_principal() restricted = principal.mode != "off" and not principal.at_least("builder") - if restricted and not await can_read_artifact(principal, artifact_id): + if principal.mode != "off" and not await can_read_artifact(principal, artifact_id): raise HTTPException(403, "没有权限查看这件工件") try: content = artifact_store.load(artifact_id) diff --git a/backend/app/api/blueprints.py b/backend/app/api/blueprints.py index c71504b1..1e6e6fa2 100644 --- a/backend/app/api/blueprints.py +++ b/backend/app/api/blueprints.py @@ -1,7 +1,9 @@ """蓝图、试运行、发布单的接口(docs/design/normal-user-apps.md §3.5、§3.6、§3.10)。 -权限在接口里判:共享蓝图要 builder(受管级发布单再要 admin),个人蓝图要拥有者且能用个人应用(blueprints/personal.allowed: -开了登录的搭建者及以上随时能,使用者和关登录时看管理员开没开 apps.user_personal_apps)。别人的个人蓝图一律当不存在(404)。 +权限在接口里判:共享蓝图要 builder(受管级发布单再要 admin),而且只给拥有者、它发布出来的应用的管理者和管理员 +(blueprints/store.can_access),别的搭建者列表里看不到、直接访问 404;个人蓝图要拥有者且能用个人应用 +(blueprints/personal.allowed:开了登录的搭建者及以上随时能,使用者和关登录时看管理员开没开 apps.user_personal_apps)。 +看不到的蓝图一律当不存在(404)。 - 起点四个,都落到同一个蓝图(N1):conversation(空蓝图,另建一段蓝图对话)、template(按模板预置卡片)、run(问数据 的一轮回答:按 /copilot/from-run 同一个提取规则取出执行路径,模板记「自由形」)、workflow_version(已发布的版本: @@ -36,6 +38,8 @@ FREEFORM = "freeform" #: 蓝图对话(Conversation.kind) CONVERSATION_KIND = "blueprint" DEFAULT_NAME = "新应用" +#: 蓝图列表最多列几张 +_LIST_MAX = 200 # -------------------------------------------------------------------------- @@ -66,8 +70,8 @@ async def _personal_allowed(session: AsyncSession, principal: Principal) -> None async def _blueprint(session: AsyncSession, blueprint_id: str) -> Blueprint: - """取蓝图并判权限(blueprints/store.load,蓝图接口和蓝图助手同一条规则):共享的要搭建者;个人的要拥有者(别人的 - 个人蓝图当不存在)且能用个人应用。""" + """取蓝图并判权限(blueprints/store.load,蓝图接口和蓝图助手同一条规则):共享的要搭建者,且是拥有者、它发布出来的 + 应用的管理者或管理员;个人的要拥有者且能用个人应用。看不到的当不存在(404)。""" from app.blueprints.store import load return await load(session, blueprint_id) @@ -247,23 +251,29 @@ async def _from_version(session: AsyncSession, spec: dict[str, Any], workflow_id async def list_blueprints(scope: Literal["shared", "personal"] | None = None, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: - """共享蓝图(搭建者看得到)和我的个人蓝图(能用个人应用时),按最近改动排。""" + """我能读写的共享蓝图(搭建者及以上:自己的、自己管理的应用出自的;管理员全部,blueprints/store.can_access)和我的 + 个人蓝图(能用个人应用时),按最近改动排。别的搭建者的共享蓝图不列。""" from app.blueprints.personal import allowed + from app.blueprints.store import shared_listed principal = _principal(x_actor) - wanted = [] + rows: list[Blueprint] = [] if scope in (None, "shared") and principal.at_least("builder"): - wanted.append(Blueprint.scope == "shared") + query = select(Blueprint).where(Blueprint.scope == "shared") + if principal.mode != "off" and principal.role != "admin": + # 候选只有自己的、发布成了应用的(要不要列看应用的管理权,shared_listed 逐个应用核对) + query = query.where(or_(Blueprint.owner == (principal.username or ""), Blueprint.app_id.is_not(None))) + else: + query = query.order_by(Blueprint.updated_at.desc()).limit(_LIST_MAX) + rows += await shared_listed(session, principal, list((await session.execute(query)).scalars())) if scope in (None, "personal") and await allowed(session, principal): mine = Blueprint.scope == "personal" if principal.mode != "off": mine = mine & (Blueprint.owner == (principal.username or "")) - wanted.append(mine) - if not wanted: - return {"items": []} - rows = (await session.execute(select(Blueprint).where(or_(*wanted)).order_by(Blueprint.updated_at.desc()) - .limit(200))).scalars() - return {"items": [blueprint_out(bp) for bp in rows]} + rows += list((await session.execute(select(Blueprint).where(mine).order_by(Blueprint.updated_at.desc()) + .limit(_LIST_MAX))).scalars()) + rows.sort(key=lambda bp: bp.updated_at or bp.created_at, reverse=True) + return {"items": [blueprint_out(bp) for bp in rows[:_LIST_MAX]]} @router.get("/blueprints/{blueprint_id}") @@ -351,7 +361,8 @@ class TrialIn(BaseModel): @router.post("/blueprints/{blueprint_id}/trial") async def trial(blueprint_id: str, payload: TrialIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> Any: - """用真数试运行:实例化 → 保存图 → 发起探索运行,交回 {run_id, blueprint}。缺项、待填的关节挡住时回 409 和问题。""" + """用真数试运行:实例化 → 保存图 → 发起探索运行,交回 {run_id, blueprint, inputs}(inputs 是这次实际用的输入: + 看板类补齐了统计期,流程类是示例表单按入口映射换成的图输入)。缺项、待填的关节挡住时回 409 和问题。""" from app.blueprints.trial import TrialRefused, start_trial from app.engine.launch import LaunchRefused @@ -365,7 +376,7 @@ async def trial(blueprint_id: str, payload: TrialIn, session: AsyncSession = Dep raise _refused(e) from e except LaunchRefused as e: raise _refused(e) from e - return {"run_id": run.id, "blueprint": blueprint_out(bp)} + return {"run_id": run.id, "blueprint": blueprint_out(bp), "inputs": dict(run.input or {})} @router.get("/blueprints/{blueprint_id}/preview") diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 6111d378..993e6313 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -821,23 +821,22 @@ def _graph_system(sources: list[Any], scope: set[str] | None, datasources: str, def _chat_user_kinds() -> str: from app.auth.chat_policy import CHAT_NODE_TYPES - return "、".join(sorted(CHAT_NODE_TYPES - {"merge"})) + return "、".join(sorted(CHAT_NODE_TYPES)) #: 开登录后 user 角色在问数据里生成的图(D-c)要守的约束,接在结构要求后面(_graph_system 的 chat_user)。发起时按 -#: app/auth/chat_policy.py 收窄,节点手册里取数的标准写法(tool 节点查库、merge 按键合并)搭出来的图发起时一律被拒,所以先 -#: 说给模型听。规则本身只在 chat_policy 一处:节点类型从那里取,自查也按它判(_chat_user_issues),这里只是把它讲成模型 -#: 照着搭得出来的写法 +#: app/auth/chat_policy.py 收窄:节点只许读数据、整理、撰写这一类,tool 节点和 agent 只能用数据源查询和只读的内置工具, +#: 所以先说给模型听。规则本身只在 chat_policy 一处:节点类型从那里取,自查也按它判(_chat_user_issues),这里只是把它讲成 +#: 模型照着搭得出来的写法 _CHAT_USER_PROMPT = ( "\n\n这位用户是「使用者」:这张图以他的身份运行,只能读数据、整理、撰写。下面几条优先于上面节点手册和搭图规则里的" "写法:\n" - f"1. 节点只用 {_chat_user_kinds()}。不要用 tool、code、merge、supervisor、subgraph、loop、human、memory、validate 等" - "其他节点(merge 的输入只能是 tool 节点,所以同样用不了)\n" - "2. 取数只用 agent:tools 里绑定 db_query__<数据源> 和 db_schema__<数据源>。数字要进口径卡、报告时,给这个 agent 配 " - "output_schema 和 cite_fields: true,口径卡取 vars..字段。数据在两个库时,让一个 agent 绑两个库的" - "查询工具,各自聚合后在 output_schema 里一起交回\n" + f"1. 节点只用 {_chat_user_kinds()}。不要用 code、supervisor、subgraph、loop、human、memory、validate 等其他节点\n" + "2. tool 节点和 agent 都只能用 db_query__<数据源>、db_schema__<数据源> 和上面列出的内置工具:取数用 tool 节点" + "(tool 写 db_query__<数据源>,args.sql 写好 SQL)或 agent(tools 里绑定这两种数据源工具)。不要用 http_request、" + "records_append 这类写入或连外部的工具,工具名不要用 {{ }} 拼\n" "3. 查询一律只读,不要写改数据的 SQL\n" - "4. 任何节点、图级 defaults 里都不要写 memory_scope、collection:记忆范围和知识库由平台定" + "4. 任何节点、图级 defaults、tool 节点的 args 里都不要写 memory_scope、collection:记忆范围和知识库由平台定" ) @@ -851,7 +850,7 @@ def _chat_user_issues(nodes: dict[str, dict[str, Any]], edges: list[dict[str, An return [] return [{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": "chat.not_allowed", "message": problem, - "for_model": f"{problem}。按 system 里「使用者」那一段的约束改:取数改成 agent 绑定数据源查询工具," + "for_model": f"{problem}。按 system 里「使用者」那一段的约束改:取数用 tool 节点或 agent 调数据源查询工具," "去掉不允许的节点、工具和记忆范围 / 知识库配置"}] @@ -2593,10 +2592,13 @@ async def extract_run_path(session: AsyncSession, run_id: str) -> RunPath: 参数化成入口字段的默认值。不落库。「提取成工作流」(/from-run)和蓝图的「从问数据的回答开始」共用。""" from sqlalchemy import select as _sel + from app.api.deps import can_read_run, current_principal from app.db.models import Run, RunEvent run = await session.get(Run, run_id) - if not run: + # 读不到的运行当它不存在:别人的个人应用刷新、个人蓝图试运行只给拥有者(auth/access.private_owner),提取出的图就是 + # 那张蓝图的内容,管理员、搭建者也不给 + if not run or not await can_read_run(current_principal(), run): raise PathRefused(404, "运行记录不存在,可能已被删除") # 没跑通的路径提出来是半截骨架:失败节点之后的步骤一步都不在里面, # 人拿去审改时却看不出它缺了什么 @@ -2752,12 +2754,15 @@ async def review_run( 规则层先扫一遍(engine/review.py:scan),没扫出东西就到此为止、直接返回, 不花一次模型调用;扫出来了才叫模型重组答案并把异常说破。 """ + from app.api.deps import PRIVATE_RUN, can_read_run, current_principal from app.db.models import Run, RunEvent from app.engine import review as rv run = await session.get(Run, payload.run_id) if not run: raise HTTPException(404, "运行记录不存在,可能已被删除") + if not await can_read_run(current_principal(), run): + raise HTTPException(403, PRIVATE_RUN) rows = list((await session.execute( select(RunEvent).where(RunEvent.run_id == payload.run_id).order_by(RunEvent.seq) diff --git a/backend/app/api/copilot_blueprint.py b/backend/app/api/copilot_blueprint.py index b7cac612..6058028e 100644 --- a/backend/app/api/copilot_blueprint.py +++ b/backend/app/api/copilot_blueprint.py @@ -4,8 +4,8 @@ - POST /api/copilot/fill-joints-stream:按模板填生成关节(sql、metric_expr)。TemplateApplier 只收 fill_joint、只收模板声明过的 关节;不同 group 的关节并行填。 -都复用 api/copilot_gen.Generation。权限按蓝图判(blueprints/store.ensure_access):共享蓝图要搭建者;个人蓝图只给拥有者,且 -管理员开了个人应用。 +都复用 api/copilot_gen.Generation。权限按蓝图判(blueprints/store.ensure_access):共享蓝图要搭建者,且是拥有者、它发布出来 +的应用的管理者或管理员;个人蓝图只给拥有者,且管理员开了个人应用。 **调用次数的预算**(N11):起草蓝图 1 次主调用加最多 2 次查看;填关节每组 1 次调用、各自最多 1 次查看(系统先代为查看 这一组要用的表,不占次数),不同的组并行。并行调用出错(多半是模型服务限流)的组,等别的组做完再按顺序试一次。 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 7448f913..6fa03e2d 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -11,7 +11,9 @@ - run_events_readable(principal, run):运行的原始记录、事件、实时流能不能读(user 只读查询结果在工具边界遮过的); - ensure_conversation_access(session, conversation_id):接受 conversation_id 的接口核对对话归属(对话按人隔离); - run_scope / evidence_scope / chat_scope:main.py 挂在运行、证据、问数据几组路由上的依赖,按 run_id 判断能不能读。 - run_scope 同样管运行的 WebSocket,握手时按运行判。 + run_scope 同样管运行的 WebSocket,握手时按运行判; +- private_run_scope:挂在只给搭建者的运行子路由(对比、可验证报告、质询、假设分析)上,别人的个人应用刷新、个人蓝图 + 试运行只有拥有者能碰(auth/access.py 的 private_owner),管理员、搭建者也不例外。 关登录时:所有人等同 admin、全部放行,行为和加这一层之前一模一样(界面上挂横幅说明)。 @@ -189,12 +191,15 @@ def masked_at_boundary(run: Any) -> bool: async def run_events_readable(principal: Principal, run: Any) -> bool: """能不能读这次运行的原始记录、事件和实时流(GET /api/runs/{id}、/events、/graph,WebSocket /stream)。 - 关登录、builder 及以上:能。user:这次运行它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。 - 两个条件缺一不可:没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的 - 应用实例、有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。实际上满足两条的就是它自己开登录后发起的 + 关登录:能。builder 及以上:这次运行它能读就能(can_read_run:别人的个人应用刷新、个人蓝图试运行读不了)。 + user:这次运行它能读(can_read_run),而且查询结果在工具边界就遮过(masked_at_boundary)。两个条件缺一不可: + 没在边界遮过的运行,事件里 tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到——自己发起的应用实例、 + 有授权的应用视图运行经应用页和证据接口读(那两处按派生遮罩给)。实际上满足两条的就是它自己开登录后发起的 问数据运行;关登录时自报同名留下的老运行没在边界遮过,开了登录也读不到。""" - if principal.mode == "off" or principal.at_least("builder"): + if principal.mode == "off": return True + if principal.at_least("builder"): + return await can_read_run(principal, run) if run is None or not principal.username or not masked_at_boundary(run): return False return await can_read_run(principal, run) @@ -206,7 +211,8 @@ def _own_chat_run(principal: Principal, run: Any) -> bool: async def _run_scope(conn: HTTPConnection) -> None: - """/api/runs 下的接口和运行的实时流。builder 及以上全部放行;user 角色只在管理员开了问数据(Q2)时能碰这组接口: + """/api/runs 下的接口和运行的实时流。builder 及以上放行,只有别人的个人应用刷新、个人蓝图试运行例外(只给拥有者, + _refuse_private);user 角色只在管理员开了问数据(Q2)时能碰这组接口: - 列表、别人的运行一律不给(记录页是给搭建者看的); - 能发起(只能是问数据的探索运行,start_run 里按请求体再查);能停止自己的问数据运行; - 能读运行记录、事件、运行时的图,能连 WebSocket:按运行判(run_events_readable),握手时就判,不再一刀切只给 @@ -214,7 +220,10 @@ async def _run_scope(conn: HTTPConnection) -> None: - 自己发起的应用实例、有授权的应用视图运行、参与的实例,经应用页(/api/apps/{id}/instances)和证据接口 (evidence_scope)读,这里不给原始事件和状态:tool.end 的预览、运行状态里是查询结果的原文,遮罩管不到。""" principal = current_principal() - if principal.mode == "off" or principal.at_least("builder"): + if principal.mode == "off": + return + if principal.at_least("builder"): + await _refuse_private(conn, principal) return if conn.scope["type"] == "websocket": # 握手前拒:服务端回 1008,不建立连接。没登录的中间件已经挡掉了,这里只剩按运行判 @@ -256,6 +265,34 @@ async def _load_run(run_id: str | None) -> Any: return await session.get(Run, run_id) +#: 搭建者、管理员碰别人的个人应用刷新、个人蓝图试运行时的说法 +PRIVATE_RUN = "没有权限查看这次运行:个人应用的刷新和个人蓝图的试运行只有拥有者能看" +#: 运行子路由里另带的运行 id(对比的基准 ?base=) +_RUN_QUERY_KEYS = ("base",) + + +async def _refuse_private(conn: HTTPConnection, principal: Principal) -> None: + """builder 及以上:路径里的运行(和对比的基准)是别人的个人应用刷新、个人蓝图试运行时拒(403,WebSocket 握手前 + 1008)。不存在的运行放过去,由接口照旧回 404。""" + ids = [conn.path_params.get("run_id"), *(conn.query_params.get(k) for k in _RUN_QUERY_KEYS)] + for run_id in (i for i in ids if i): + if await can_read_run(principal, run_id): + continue + if conn.scope["type"] == "websocket": + from fastapi import WebSocketException, status + + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) + raise HTTPException(403, PRIVATE_RUN) + + +async def _private_run_scope(conn: HTTPConnection) -> None: + """只给搭建者的运行子路由(main.py 里对比、可验证报告、质询、假设分析):角色由 require_role 判,这里只管别人的 + 个人应用刷新、个人蓝图试运行。""" + principal = current_principal() + if principal.mode != "off": + await _refuse_private(conn, principal) + + def restricted() -> bool: """这次请求的人是不是开登录后的 user 角色:对它来说遮罩就是边界,只能做问数据、用应用这几件事。""" principal = current_principal() @@ -263,10 +300,13 @@ def restricted() -> bool: async def _evidence_scope(conn: HTTPConnection) -> None: - """/api/runs/{run_id}/evidence… 这组证据接口:builder 及以上全部放行;user 角色只读(GET),而且这次运行要能读 - (can_read_run)。证据接口交出的查询行本身按派生遮罩处理过。""" + """/api/runs/{run_id}/evidence… 这组证据接口:builder 及以上放行,别人的个人应用刷新、个人蓝图试运行除外(只给 + 拥有者);user 角色只读(GET),而且这次运行要能读(can_read_run)。证据接口交出的查询行本身按派生遮罩处理过。""" principal = current_principal() - if principal.mode == "off" or principal.at_least("builder") or conn.scope["type"] != "http": + if principal.mode == "off" or conn.scope["type"] != "http": + return + if principal.at_least("builder"): + await _refuse_private(conn, principal) return ensure_role("user") if conn.scope.get("method", "GET") != "GET": @@ -298,6 +338,7 @@ _CHAT_ROUTES = frozenset({("POST", "/api/copilot/generate-stream"), ("GET", "/ap run_scope = Depends(_run_scope) evidence_scope = Depends(_evidence_scope) chat_scope = Depends(_chat_scope) +private_run_scope = Depends(_private_run_scope) def role_by_method(*, read: str, write: str) -> Any: diff --git a/backend/app/api/governance.py b/backend/app/api/governance.py index 8ae5f324..710e1707 100644 --- a/backend/app/api/governance.py +++ b/backend/app/api/governance.py @@ -98,12 +98,17 @@ async def exploratory_clusters( 这就是模板生长的触发器。用本地 embedding 做贪心聚类, 出现 ≥3 次的簇标记为晋升候选。 """ + from sqlalchemy import not_ + + from app.api.deps import current_principal + from app.auth.access import hidden_runs from app.memory.embeddings import cosine, embed_texts rows = list( await session.execute( select(Run.id, Run.input, Run.workflow_name, Run.created_at) - .where(Run.run_class != "formal") + # 别人的个人应用刷新、个人蓝图试运行只给拥有者(同运行列表),不进聚类 + .where(Run.run_class != "formal", not_(hidden_runs(current_principal()))) .order_by(Run.created_at.desc()) .limit(limit) ) diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index abba526c..3a35364e 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -170,8 +170,14 @@ async def list_runs( 以前只有 limit:待审批的运行排在第 145 位,列表只取 100 条,点了徽标也找不到它。 筛选都走服务端,翻页用 created_at 做游标(before 传上一页最后一条的 created_at)。 + 别人的个人应用刷新、个人蓝图试运行不列(只给拥有者,auth/access.hidden_runs),管理员也一样。 """ - stmt = select(Run).order_by(Run.created_at.desc()).limit(limit) + from sqlalchemy import not_ + + from app.api.deps import current_principal + from app.auth.access import hidden_runs + + stmt = select(Run).where(not_(hidden_runs(current_principal()))).order_by(Run.created_at.desc()).limit(limit) if workflow_id == UNSAVED: stmt = stmt.where(Run.workflow_id.is_(None)) elif workflow_id: @@ -649,12 +655,18 @@ async def list_approvals( limit: int = Query(default=100, ge=1, le=500), session: AsyncSession = Depends(get_session), ) -> list[ApprovalOut]: + from sqlalchemy import not_ + + from app.api.deps import current_principal + from app.auth.access import hidden_runs from app.db.models import Task stmt = ( select(Approval, Run, Task.id) .join(Run, Run.id == Approval.run_id, isouter=True) .join(Task, Task.approval_id == Approval.id, isouter=True) + # 别人的个人应用刷新、个人蓝图试运行上的审批不列(只给拥有者,同运行列表) + .where(or_(Run.id.is_(None), not_(hidden_runs(current_principal())))) .order_by(Approval.created_at.desc()) .limit(limit) ) @@ -698,9 +710,13 @@ async def decide( x_actor: str | None = Header(default=None), ) -> Any: """兼容垫片:改走待办服务(app/process/tasks.py 的 respond)。请求体照旧,只当一票、原样交给节点。""" + from app.api.deps import PRIVATE_RUN, can_read_run, current_principal + approval = await session.get(Approval, approval_id) if not approval: raise HTTPException(404, "审批记录不存在,所属的运行可能已被删除") + if not await can_read_run(current_principal(), approval.run_id): + raise HTTPException(403, PRIVATE_RUN) if approval.status != "pending": raise HTTPException(409, "这条审批已处理过,请刷新页面查看最新状态") # 在落签批人之前拦:拒掉的批准不该留下「谁试图批的」 diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index 4e41b4bd..ed823d37 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -1,7 +1,8 @@ """场景模板列表(docs/design/normal-user-apps.md §3.3):GET /api/templates。登录即可看(新建应用的「从场景模板开始」)。 -模板登记在导入这个模块时核对一遍(blueprints/instantiate.registry_problems):首批都登记了、关节类型认得、每个模板的 -示例蓝图实例化出来过得了画布校验、骨架比对没有差异。缺一样启动即报错(N3:模板定义、实例化、测试三样都要给)。 +模板登记在导入这个模块时核对一遍(blueprints/templates.check_registry → instantiate.registry_problems):首批都登记了、 +关节类型认得、每个模板的示例蓝图实例化出来过得了画布校验、骨架比对没有差异。缺一样启动即报错(N3:模板定义、实例化、 +测试三样都要给)。 """ from __future__ import annotations @@ -9,12 +10,11 @@ from typing import Any from fastapi import APIRouter -from app.blueprints.instantiate import registry_problems -from app.blueprints.templates import list_templates +from app.blueprints.templates import check_registry, list_templates router = APIRouter(prefix="/api/templates", tags=["templates"]) -_PROBLEMS = registry_problems() +_PROBLEMS = check_registry() if _PROBLEMS: raise RuntimeError("场景模板登记不完整:" + ";".join(_PROBLEMS)) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 1025d12a..97564ea6 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -282,6 +282,7 @@ async def stamp_of(session: AsyncSession, source: Source) -> dict[str, Any]: if source.kind == "exploratory" and source.state == "ok" and source.message: # 个人应用最近一次刷新没成功、这里给的是上一次的(_exploratory_source) notes.append(source.message) + notes += [gap for entry in (run.data_versions or {}).values() if (gap := _records_gap(entry))] if not sealed.trusted: notes.append("这次运行还没有封存(或封存核对不一致),数字仅供参考") return { @@ -304,10 +305,25 @@ async def stamp_of(session: AsyncSession, source: Source) -> dict[str, Any]: def _version_text(entry: dict[str, Any]) -> str: """印章上一个数据源固定的版本:上传表格写快照 id 前 8 位;记录表写「截至第 N 条」(records/views.pin_entry)。""" if entry.get("origin") == "records": - return f"{entry.get('name')} 截至第 {entry.get('seq') or 0} 条" + # 跨版本汇总并进来的旧版本(records/views.py)各有高水位,一并写上;数据文件不在了的没并进来,不写(缺了哪版见 + # _records_gap) + older = "、".join(f"第 {o.get('version')} 版截至第 {o.get('seq') or 0} 条" for o in entry.get("older") or [] + if isinstance(o, dict) and not o.get("missing")) + return f"{entry.get('name')} 截至第 {entry.get('seq') or 0} 条" + (f"(连同{older})" if older else "") return f"{entry.get('name')} {str(entry.get('snapshot') or '')[:8]}" +def _records_gap(entry: Any) -> str | None: + """记录表的跨版本汇总缺了哪几版(旧版本的数据文件不在了,records/views.pin_entry 记成 missing):印章上照实说数不全。""" + if not isinstance(entry, dict) or entry.get("origin") != "records": + return None + lost = [str(o.get("version")) for o in entry.get("older") or [] if isinstance(o, dict) and o.get("missing")] + if not lost: + return None + return (f"记录表「{entry.get('name')}」第 {'、'.join(lost)} 版的数据文件不在了,汇总里没有" + f"{'这一版' if len(lost) == 1 else '这几版'}的记录,数不全") + + # -------------------------------------------------------------------------- # 按需视图:缓存键、单飞、每日上限 # -------------------------------------------------------------------------- @@ -335,6 +351,7 @@ async def cache_key(session: AsyncSession, state: PinState, inputs: dict[str, An from app.engine.launch import graph_tool_names from app.engine.runner import _data_versions from app.engine.schema import GraphSpec + from app.records.views import cache_token as records_cache_token try: versions = await _data_versions(session, GraphSpec.model_validate(state.graph)) @@ -343,7 +360,7 @@ async def cache_key(session: AsyncSession, state: PinState, inputs: dict[str, An capabilities = await capability_versions_for(session, graph_tool_names(state.graph)) text = canonical_json({"graph_hash": state.hash, "input": inputs, # 记录表按高水位(seq):有新的提交,缓存就不再算数 - "data_versions": {k: (v or {}).get("snapshot") or (v or {}).get("seq") + "data_versions": {k: (v or {}).get("snapshot") or records_cache_token(v) for k, v in sorted(versions.items())}, "capabilities": capabilities}) return hashlib.sha256(text.encode()).hexdigest() @@ -1415,14 +1432,14 @@ async def _instance_list(session: AsyncSession, ctx: _Page, props: dict[str, Any async def _records_list(session: AsyncSession, ctx: _Page, props: dict[str, Any]) -> dict[str, Any]: """记录列表(docs/design/normal-user-apps.md N8):每次打开按看的人实时过滤(records/listing.py),不走封存,同实例列表。 - 草稿预览不列真实的记录。""" + 草稿预览不列真实的记录:state=preview(不说成「还没有记录表」,形状同 RecordsPage)。""" from app.records.listing import page who = props.get("who") if props.get("who") in ("mine", "all") else None limit = props.get("limit") if isinstance(props.get("limit"), int) else 50 if ctx.draft: return {"table": props.get("table"), "version": 0, "columns": [], "items": [], "mine_only": who == "mine", - "state": "empty", "next_cursor": None} + "state": "preview", "next_cursor": None} return await page(session, ctx.app, principal=ctx.principal, role=ctx.role, table=props.get("table"), who=who, limit=limit) diff --git a/backend/app/apps/spec.py b/backend/app/apps/spec.py index 592f6bd6..a64b7df0 100644 --- a/backend/app/apps/spec.py +++ b/backend/app/apps/spec.py @@ -47,8 +47,9 @@ FILE_FIELD_TYPE = "object" FILE_REF_KEYS = ("file_id", "name", "size", "sha256", "type") #: 表单字段能用的 format。不支持 pattern:JS 和 Python 的正则语义不同,会出现前端放行、后端拒收 FORM_FORMATS = ("date", "date-time", "email", "uri") -#: 表单校验错误的 code -FORM_ERROR_CODES = ("required", "type", "enum", "minimum", "maximum", "min_length", "max_length", "max_items", "format") +#: 表单校验错误的 code。file:文件控件的值不对(没上传、不是本人传的、已经用过、类型或大小不符,app/files/store.py) +FORM_ERROR_CODES = ("required", "type", "enum", "minimum", "maximum", "min_length", "max_length", "max_items", "format", + "file") class _Strict(BaseModel): diff --git a/backend/app/auth/access.py b/backend/app/auth/access.py index ead2e70d..3c04db08 100644 --- a/backend/app/auth/access.py +++ b/backend/app/auth/access.py @@ -1,14 +1,17 @@ """运行、工件的读权限(§3.12「运行、证据、工件(只读)」那一行)。 -关登录、builder 及以上:都能读。开登录后 user 角色只能读: -- 自己发起的运行; -- 自己参与的实例:派给过自己(或自己所在的组)的待办,或者自己回复过的待办所在的运行。派给「任何人」的不算:那是没有 - 应用绑定的运行(画布、定时任务),user 角色不该因此读到; -- 自己的个人应用(docs/design/normal-user-apps.md N7)的刷新:只有拥有者,别人的个人应用即使有人自报同名也不算; -- 有授权(use / observe / manage,含 everyone 和自己所在的组)的应用里: - - 视图运行(来源是应用视图、按新口径补算); - - observe、manage 授权另能读全部实例; - - 应用发布版本里作为视图固定的工作流版本的正式运行(视图读的是定时任务那次运行,它不带应用 id)。 +关登录:都能读。开登录后: +- 只给拥有者的运行(PRIVATE_ORIGINS,docs/design/normal-user-apps.md N7、§3.12「看别人的个人应用内容」一行全是 ✗): + 个人应用的刷新、个人蓝图的试运行只有拥有者能读——管理员、搭建者也不例外,运行、证据、工件、文件下载、运行列表、 + 事件和实时流一个口径(private_owner / hidden_runs); +- builder 及以上:别的运行都能读; +- user 角色只能读: + - 自己发起的运行; + - 自己参与的实例:派给过自己(或自己所在的组)的待办,或者自己回复过的待办所在的运行。派给「任何人」的不算:那是 + 没有应用绑定的运行(画布、定时任务),user 角色不该因此读到; + - 有授权(use / observe / manage,含 everyone 和自己所在的组)的应用里:视图运行(来源是应用视图、按新口径补算); + observe、manage 授权另能读全部实例;应用发布版本里作为视图固定的工作流版本的正式运行(视图读的是定时任务那次 + 运行,它不带应用 id)。 user 角色读到的查询快照一律按派生遮罩处理、tool_snapshot 的原文不给(api/artifacts.py),这一层只回答「能不能读」。 """ @@ -21,10 +24,56 @@ from sqlalchemy.ext.asyncio import AsyncSession #: 视图运行的来源(engine/origin.py) _VIEW_ORIGINS = ("app_view", "app_backfill") +#: 只给拥有者读的运行来源:个人应用的刷新(personal_app);试运行(trial)里出自个人蓝图的那些 +PRIVATE_ORIGINS = ("personal_app", "trial") -def _unrestricted(principal: Any) -> bool: - return principal.mode == "off" or principal.at_least("builder") +async def private_owner(session: AsyncSession, run: Any) -> tuple[bool, str | None]: + """这次运行是不是只给拥有者读的,是的话交回拥有者:(是不是, 拥有者)。 + + - 个人应用的刷新:拥有者按应用上记的认(刷新只能由拥有者发起,这里不靠发起人)。应用不在了、不是个人应用的,拥有者 + 为 None——谁都读不了,不往宽处猜; + - 试运行:出自个人蓝图的(run.origin 里记的蓝图 id,发起时由服务端写)只给蓝图的拥有者;共享蓝图的试运行照旧按 + 搭建者读。""" + from app.db.models_apps import App + from app.db.models_blueprints import Blueprint + + kind = getattr(run, "origin_kind", None) + if kind == "personal_app": + owner = await session.scalar(select(App.owner).where(App.id == run.app_id, App.scope == "personal")) \ + if run.app_id else None + return True, owner + if kind == "trial": + blueprint_id = (run.origin or {}).get("blueprint_id") if isinstance(run.origin, dict) else None + row = (await session.execute(select(Blueprint.scope, Blueprint.owner).where( + Blueprint.id == str(blueprint_id or "")))).first() + if row is not None and row.scope == "personal": + return True, row.owner + return False, None + + +def hidden_runs(principal: Any) -> Any: + """SQL 条件:这个人读不了的只给拥有者的运行(别人的个人应用刷新、别人的个人蓝图试运行)。运行列表、审批列表、 + 探索问题聚类按 not_(hidden_runs(...)) 筛,和 run_readable 同一个口径。关登录时什么都不藏(没有可信的身份)。 + + 各项都按非空比较(coalesce):来源、应用、蓝图 id 为空的老运行不能因为 NULL 被一起筛掉。""" + from sqlalchemy import false, func + + from app.db.models import Run + from app.db.models_apps import App + from app.db.models_blueprints import Blueprint + + if principal.mode == "off": + return false() + me = principal.username or "" + mine = select(App.id).where(App.scope == "personal", App.owner == me) + others = select(Blueprint.id).where(Blueprint.scope == "personal", + func.coalesce(Blueprint.owner, "") != me) + kind = func.coalesce(Run.origin_kind, "") + return or_( + and_(kind == "personal_app", or_(Run.app_id.is_(None), Run.app_id.not_in(mine))), + and_(kind == "trial", func.coalesce(func.json_extract(Run.origin, "$.blueprint_id"), "").in_(others)), + ) async def app_roles(session: AsyncSession, principal: Any) -> dict[str, set[str]]: @@ -73,10 +122,17 @@ async def _viewed_by_granted_app(session: AsyncSession, run: Any, apps: set[str] async def run_readable(principal: Any, run: Any) -> bool: - """能不能读这次运行(证据、事件、工件)。run 可以是 Run,也可以是运行 id;不存在的运行读不了。""" - if _unrestricted(principal): + """能不能读这次运行(证据、事件、工件)。run 可以是 Run,也可以是运行 id。 + + 关登录:能。只给拥有者的运行(private_owner):只有拥有者能读,管理员、搭建者也不例外。builder 及以上:别的都能读, + 不存在的运行也答「能」(接口照旧回 404,不改成 403)。user:见模块开头。""" + if principal.mode == "off": return True - if not principal.username: + builder = principal.at_least("builder") + if builder and run is not None and not isinstance(run, str) \ + and (getattr(run, "origin_kind", None) or "") not in PRIVATE_ORIGINS: + return True + if not builder and not principal.username: return False from app.db.base import SessionLocal from app.db.models import Run @@ -85,11 +141,14 @@ async def run_readable(principal: Any, run: Any) -> bool: if isinstance(run, str): run = await session.get(Run, run) if run is None: - return False + return builder + private, owner = await private_owner(session, run) + if private: + return bool(owner) and bool(principal.username) and owner == principal.username + if builder: + return True if run.started_by == principal.username: return True - if run.origin_kind == "personal_app" and run.app_id: - return await _owns_personal_app(session, principal, run.app_id) if await _participated(session, principal, run.id): return True apps = await app_roles(session, principal) @@ -99,18 +158,10 @@ async def run_readable(principal: Any, run: Any) -> bool: return await _viewed_by_granted_app(session, run, set(apps)) -async def _owns_personal_app(session: AsyncSession, principal: Any, app_id: str) -> bool: - """个人应用的刷新只有这个应用的拥有者能读:别人参与不了(不派待办)、也没有授权可言(个人应用不能分享)。按应用上记的 - 拥有者认,不按发起人——刷新只能由拥有者发起,这里不靠它。""" - from app.db.models import App - - owner = await session.scalar(select(App.owner).where(App.id == app_id, App.scope == "personal")) - return bool(owner) and owner == principal.username - - async def artifact_readable(principal: Any, artifact_id: str) -> bool: - """能不能读这件工件:引用它的某次运行能读就能读。工件按内容寻址,同一件可能被好几次运行引用。""" - if _unrestricted(principal): + """能不能读这件工件:引用它的某次运行能读就能读。工件按内容寻址,同一件可能被好几次运行引用。不挂在任何运行上的 + 工件:builder 及以上照旧能读,user 读不了。只被别人的个人应用刷新、个人蓝图试运行引用的,builder 及以上也读不了。""" + if principal.mode == "off": return True from app.db.base import SessionLocal from app.db.models import Artifact @@ -118,6 +169,8 @@ async def artifact_readable(principal: Any, artifact_id: str) -> bool: async with SessionLocal() as session: run_ids = [r for r in (await session.execute( select(Artifact.run_id).where(Artifact.id == artifact_id))).scalars() if r] + if not run_ids: + return principal.at_least("builder") for run_id in run_ids: if await run_readable(principal, run_id): return True diff --git a/backend/app/auth/chat_policy.py b/backend/app/auth/chat_policy.py index f42ed761..5811cad7 100644 --- a/backend/app/auth/chat_policy.py +++ b/backend/app/auth/chat_policy.py @@ -4,16 +4,23 @@ start_run 先查来源和运行类别)。图本身再按这里收窄,理由是同一条:对 user 角色来说遮罩就是边界(A7 第 6 条),它能 碰的只有「读数据、整理、撰写」这一类,不能执行代码、不能调外部系统、不能写任何东西: -- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询。调用工具(会写外部系统)、 - 代码、协作、子工作流、循环、人工审批、记忆写入、校验、双盲复算、反方审稿一律不许——后几种不是危险,而是问数据用不到, - 多放一种就多一处要单独核对遮罩的地方; -- Agent 的工具只许数据源工具(db_query__ / db_schema__,查询结果按 MASK_RESULTS_OPTION 在工具边界遮)和只读的内置工具 - (计算、当前时间、JSON 取值、知识库检索、记忆读取)。自定义工具、MCP、文件、网页、HTTP、代码执行一律不许;模板拼出来的 - 工具名({{ vars.tool }})发起时不知道是谁,也不许。数据源查询一律只读:可写源上的写语句由守卫拒掉,不交给审批(api/runs.py - 给运行打上 READ_ONLY_OPTION)——Agent 的 approval 是发起人自己写在图里的,写成 never 就不问人了; +- 节点只许:输入、模型、Agent、报告、输出、知识库检索、数据整形、条件分支、口径卡、合并查询、调用工具。代码、协作、 + 子工作流、循环、人工审批、记忆写入、校验、双盲复算、反方审稿一律不许——后几种不是危险,而是问数据用不到,多放一种 + 就多一处要单独核对遮罩的地方; +- 工具(Agent 绑的、调用工具节点调的同一份白名单,engine/launch.tool_bindings 一并列出)只许数据源工具(db_query__ / + db_schema__,查询结果按 MASK_RESULTS_OPTION 在工具边界遮)和只读的内置工具(计算、当前时间、JSON 取值、知识库检索、 + 记忆读取)。自定义工具、MCP、文件、网页、HTTP、代码执行、平台的写入能力(records_append 这类)一律不许;模板拼出来的 + 工具名({{ vars.tool }})发起时不知道是谁,也不许。调用工具节点放行的理由:它和 Agent 调同一个查询工具等价——同一个 + ToolContext 带着遮罩、只读两个开关(engine/nodes/tools.py 的 _tool_ctx),参数由人写死反而比模型挑的更好核对; + 周期看板、异常处置这些模板的取数节点就是它(个人应用和问数据同一份白名单,blueprints/personal.policy_problem); +- 数据源查询一律只读:可写源上的写语句由守卫拒掉,不交给审批(api/runs.py、个人应用刷新、个人蓝图试运行给运行打上 + READ_ONLY_OPTION)——Agent、调用工具节点的 approval 是发起人自己写在图里的,写成 never 就不问人了。能力治理里改成 + 「写入」的工具在只读运行里同样不执行(调用工具节点在走动作流程之前拒,Agent 建工具时拒); - 记忆范围、知识库不由发起人挑:记忆和知识库里的内容不经遮罩,搭建者的 Agent 存进去的查询原文,换个范围就读到了。 记忆固定为这位使用者自己的范围(chat_memory_scope;问数据里写不了记忆,它读到的只会是空的),知识库固定为设置里的 - 运行默认值。节点上、图级 defaults 里写了 memory_scope / collection 的图不许发起,请求体里带的同样拒(api/runs.py)。 + 运行默认值。节点上、图级 defaults 里、调用工具节点的参数里写了 memory_scope / collection 的图不许发起,请求体里带的 + 同样拒(api/runs.py)。这只是发起前的一道:根子在运行时——遮罩、只读的运行里范围由运行定,节点配置改不了,kb_search + 的 collection 参数(参数名纠错改出来的、模型挑的)一律拒(tools/registry.ToolContext.fixed_scope)。 没在这里列出的节点类型、工具,以后加了也默认不许(白名单),要放开得先想清楚遮罩怎么守。 """ @@ -23,7 +30,7 @@ from typing import Any #: 问数据运行里 user 角色能用的节点类型(engine/schema.py 的 NodeType 取值) CHAT_NODE_TYPES = frozenset({"input", "llm", "agent", "report", "output", "retrieve", "transform", "branch", - "metrics", "merge"}) + "metrics", "merge", "tool"}) #: Agent 能绑的只读内置工具(tools/builtin/) CHAT_BUILTIN_TOOLS = frozenset({"calculator", "current_time", "json_query", "kb_search", "memory_recall"}) #: 运行时改记忆范围、知识库的配置键(engine/nodes 的 ctx.cfg("memory_scope") / ctx.cfg("collection"),图级 defaults 同样生效) @@ -54,7 +61,7 @@ def chat_graph_problem(graph: Any, *, where: str = "问数据") -> str | None: 校验的图本来就发起不了。 where 是说法里的「在哪」:个人应用(blueprints/personal.py)用的是同一份白名单,只把「问数据」换成「个人应用」。""" - from app.engine.launch import tool_bindings + from app.engine.launch import node_setting, tool_bindings from app.engine.schema import GraphSpec, type_label if not isinstance(graph, dict): @@ -72,6 +79,12 @@ def chat_graph_problem(graph: Any, *, where: str = "问数据") -> str | None: "如需这类处理,请联系搭建者做成应用") if any(_set(node.config.get(key)) for key in SCOPE_KEYS): return f"{scope_refused}(「{node.title}」)" + # 调用工具节点的参数直接交给工具:kb_search 的 collection 这类参数写了,同样是自己挑了范围。取值和运行时同一个规则 + # (node_setting:节点上没写就取图级 defaults 里的 args)。参数名写错、运行时被纠成 collection 的,这里认不出—— + # 运行时那一道由工具自己拒(tools/registry.ToolContext.fixed_scope,kb_search 只能检索运行定下的知识库) + args = node_setting(spec, node, "args") if str(node.type) == "tool" else None + if isinstance(args, dict) and any(_set(args.get(key)) for key in SCOPE_KEYS): + return f"{scope_refused}(「{node.title}」)" for who, name in tool_bindings(spec): if "{{" in name: return f"没有权限:{who}的工具名由模板拼成,发起前无法确定是哪个工具,使用者在{where}中不能这样绑定工具" diff --git a/backend/app/blueprints/instantiate.py b/backend/app/blueprints/instantiate.py index e7a59e1c..d7e966ab 100644 --- a/backend/app/blueprints/instantiate.py +++ b/backend/app/blueprints/instantiate.py @@ -208,26 +208,28 @@ def instantiate(blueprint: dict[str, Any], *, filled: dict[str, Any] | None = No "name": name, "workflow_id": workflow_id, "blueprint_id": blueprint_id, "schedule_id": schedule_id, "records": records, "graph": graph}} raw, joint_defs = template.skeleton(bp) - extras = c.EXTRAS.get(template.id) # 自由形导入的图原样用、不重新排版:它的哈希要和导入的那一版对得上,发布单才按哈希复用那一版 - skeleton = copy.deepcopy(raw) if extras is not None and extras.imports_graph else normalize(raw) + skeleton = copy.deepcopy(raw) if template.imports_graph else normalize(raw) out = copy.deepcopy(skeleton) - briefs = extras.briefs(bp) if extras is not None and extras.briefs is not None else {} + briefs = template.briefs(bp) if template.briefs is not None else {} basis = {g: c.basis_of(text, template.id, template.version) for g, text in briefs.items()} joints: dict[str, Any] = {} filled = filled or {} for j in joint_defs: + # 模板声明的说法、类型、分组随状态一起存(界面写「第几组、填的是什么」,不只写关节 id) + said = {"label": j.label or j.id, "kind": j.kind, "group": j.group} if j.kind not in GENERATED_JOINT_KINDS: - joints[j.id] = {"status": "filled", "by": "template"} + joints[j.id] = {"status": "filled", "by": "template", **said} continue value = filled.get(j.id) config = node_config(out, j.node_id) if isinstance(value, str) and value.strip() and config is not None: write_at(config, j.field, value.strip()) - joints[j.id] = {"status": "filled", "by": "assistant", "value": value.strip(), + joints[j.id] = {"status": "filled", "by": "assistant", "value": value.strip(), **said, **({"basis": basis[j.group]} if j.group in basis else {})} else: - joints[j.id] = {"status": "pending", "by": "template", "note": "这一项助手还没填上:可以请助手填,或者换个说法再试"} + joints[j.id] = {"status": "pending", "by": "template", **said, + "note": "这一项助手还没填上:可以请助手填,或者换个说法再试"} problems.append({"code": JOINT_UNFILLED, "level": "error", "node_id": j.node_id, "field": j.field, "joint": j.id, "message": f"「{j.label or j.id}」还没填"}) problems += skeleton_changes(skeleton, out, joint_defs) @@ -301,24 +303,23 @@ def trial_inputs(blueprint: dict[str, Any], *, at: datetime | None = None) -> di def registry_problems() -> list[str]: - """模板登记齐不齐:首批都登记了(check_registry)、关节类型都认得、每个模板都给了示例蓝图,示例实例化出来过得了 - 画布校验、骨架比对没有差异(模板定义、实例化、测试三样都要给,缺一样启动即报错,N3)。""" - from app.blueprints.templates import JOINT_KINDS, check_registry, list_templates - from app.blueprints.templates import _common as c + """模板登记齐不齐(templates.check_registry 调这里):首批都登记了、关节类型都认得、每个模板都给了示例蓝图 + (TemplateDef.example / example_fill),示例实例化出来过得了画布校验、骨架比对没有差异(模板定义、实例化、测试 + 三样都要给,缺一样启动即报错,N3)。""" + from app.blueprints.templates import JOINT_KINDS, TEMPLATE_IDS, get_template, list_templates - problems = list(check_registry()) + problems = [f"模板「{t}」没有登记" for t in TEMPLATE_IDS if get_template(t) is None] for template in list_templates(): - extras = c.EXTRAS.get(template.id) - if extras is None: + if template.example is None or template.example_fill is None: problems.append(f"模板「{template.id}」没有示例蓝图") continue try: graph = None - if extras.imports_graph: + if template.imports_graph: from app.blueprints.templates.freeform import example_graph graph = example_graph() - inst = instantiate(extras.example(), filled=extras.example_fill(), graph=graph) + inst = instantiate(template.example(), filled=template.example_fill(), graph=graph) except Exception as e: # noqa: BLE001 - 核对本身出错也照实报,不让启动卡在异常上 problems.append(f"模板「{template.id}」的示例实例化出错:{e}") continue diff --git a/backend/app/blueprints/personal.py b/backend/app/blueprints/personal.py index d1bb7dad..4c73a020 100644 --- a/backend/app/blueprints/personal.py +++ b/backend/app/blueprints/personal.py @@ -99,8 +99,9 @@ def owns(principal: Any, owner: str | None) -> bool: def policy_problem(graph: Any) -> str | None: """个人应用的图能不能用:和使用者问数据同一份白名单(auth/chat_policy.chat_graph_problem),说法只换「在哪」。 - 能用返回 None。没有人工节点、不写入都在白名单里:人工、调用工具、代码、记忆写入这几类节点不在名单上,写入能力和 - 自定义工具不是只读工具;数据源查询在运行时另按 READ_ONLY 只读。""" + 能用返回 None。没有人工节点、不写入都在白名单里:人工、代码、记忆写入这几类节点不在名单上;调用工具节点可以用, + 但和 Agent 一样只能绑数据源查询和只读的内置工具(模板的取数节点就是它),写入能力和自定义工具不是只读工具;数据源 + 查询在运行时另按 READ_ONLY 只读、按 MASK_RESULTS 在工具边界遮,调用工具节点和 Agent 走同一道。""" from app.auth.chat_policy import chat_graph_problem return chat_graph_problem(graph, where="个人应用") @@ -656,7 +657,9 @@ async def review_payload(session: AsyncSession, task: Any) -> dict[str, Any]: blueprint_id = (task.correlation_key or "").removeprefix(REVIEW_KEY) blueprint = await session.get(Blueprint, blueprint_id) if blueprint_id else None if blueprint is None: - return {"kind": REVIEW} + # 蓝图已经不在了(被删):键照样都给,界面按同一个形状读,不另判缺键 + return {"kind": REVIEW, "blueprint_id": blueprint_id or None, "app_id": task.app_id, "name": None, + "purpose": "", "template_id": None, "submitted_by": None, "note": "", "copies": []} await settle_review(session, blueprint) submitted = (await session.execute(select(BlueprintEvent).where( BlueprintEvent.blueprint_id == blueprint.id, BlueprintEvent.op == "submit_review") @@ -677,14 +680,16 @@ async def review_payload(session: AsyncSession, task: Any) -> dict[str, Any]: async def personal_state(session: AsyncSession, app: Any, principal: Any) -> dict[str, Any]: - """个人应用详情里给拥有者的那一块:今天还能刷新几次、蓝图的状态(是不是改过还没重新存)、审核到哪了(在等谁、退回了、 - 已发布成哪个正式应用)。""" + """个人应用详情里给拥有者的那一块:能不能刷新、提交审核(allowed,同 ensure_allowed 那一条规则:管理员关了开关之后 + 使用者的个人应用还看得到,只是不能再刷新、提交审核;界面照它置灰,不自己再推一遍)、今天还能刷新几次、蓝图的状态 + (是不是改过还没重新存)、审核到哪了(在等谁、退回了、已发布成哪个正式应用)。""" from app.db.models_blueprints import Blueprint remaining, limit = await remaining_today(session, principal.username) blueprint = await session.get(Blueprint, app.blueprint_id) if app.blueprint_id else None pin = _pin(app.draft) - out: dict[str, Any] = {"remaining_today": remaining, "daily_limit": limit, + out: dict[str, Any] = {"allowed": await allowed(session, principal), "remaining_today": remaining, + "daily_limit": limit, "blueprint_status": blueprint.status if blueprint is not None else None, "graph_changed": blueprint is not None and pin is not None and pin.get("graph_hash") != graph_hash_of(blueprint), diff --git a/backend/app/blueprints/store.py b/backend/app/blueprints/store.py index d09ed7c3..2eed3298 100644 --- a/backend/app/blueprints/store.py +++ b/backend/app/blueprints/store.py @@ -1,8 +1,9 @@ """蓝图的读写与留痕(N2、§3.1、§3.10)。蓝图接口(api/blueprints.py 的 PATCH)、蓝图助手和填关节的流式接口 (api/copilot_blueprint.py)都经这里落地,规则只有一处: -- 权限:共享蓝图要搭建者;个人蓝图只给拥有者,而且管理员开了个人应用(blueprints/personal.allowed)。别人的个人蓝图 - 当没有(404),不说它在不在; +- 权限:共享蓝图要搭建者,而且只给拥有者、它发布出来的应用的管理者(app_role=manage)和管理员(can_access)——别的 + 搭建者列表里看不到、直接访问回 404;审核副本的拥有者是处理审核的那位搭建者(personal.copy_for_review)。个人蓝图只给 + 拥有者,而且管理员开了个人应用(blueprints/personal.allowed)。看不到的蓝图一律当没有(404),不说它在不在; - 落地:bp_* 操作按 blueprints/ops.apply_bp_op 逐个落到存着的那一份上,每个操作写一条 blueprint_events(谁、核实过没有、 来源、位置、改之前、改之后,P2)。复用口径卡的操作先按库里那一版核对(look/calibers.verify_reuse)并补上它读的列; - 关节:卡片改了,生成关节的依据跟着变的那几组标回「待填」(instantiate.filled_from),蓝图上的关节状态随之更新。 @@ -50,8 +51,9 @@ async def load(session: AsyncSession, blueprint_id: str) -> Any: async def ensure_access(session: AsyncSession, blueprint: Any) -> None: - """这个人能不能读写这张蓝图:共享的要搭建者及以上;个人的要拥有者,且个人应用开着(关登录时只看开关)。""" - from app.api.deps import current_principal, ensure_role + """这个人能不能读写这张蓝图(读、改卡片、试运行、出发布单都按它):共享的要搭建者及以上,而且是 can_access 认的 + 那几种人;个人的要拥有者,且个人应用开着(关登录时只看开关)。看不到的一律 404。""" + from app.api.deps import current_principal from app.blueprints import personal principal = current_principal() @@ -61,7 +63,51 @@ async def ensure_access(session: AsyncSession, blueprint: Any) -> None: if not await personal.allowed(session, principal): raise HTTPException(403, PERSONAL_OFF) return - ensure_role("builder") + # 使用者碰共享蓝图也回 404:先回 403 再回 404,两者一比就试得出这张蓝图在不在(D5「看不到的一律当不存在」) + if not principal.at_least("builder") or not await can_access(session, principal, blueprint): + raise HTTPException(404, BLUEPRINT_GONE) + + +async def can_access(session: AsyncSession, principal: Any, blueprint: Any) -> bool: + """这个人能不能读写这张蓝图,不看角色门槛和个人应用开关(那两样由 ensure_access 另判,文件上传按它核对上下文): + + - 关登录:都能(没有可信的身份); + - 个人蓝图:只有拥有者; + - 共享蓝图:拥有者、管理员、它发布出来的应用(blueprint.app_id)的管理者(apps/access.app_role 是 manage)。 + 别的搭建者不行:卡片上写的是谁来审批、谁能看、往哪写,改一张别人的蓝图再出单,等于替别人改了应用。""" + from app.blueprints import personal + + if principal.mode == "off": + return True + if getattr(blueprint, "scope", "shared") == "personal": + return personal.owns(principal, blueprint.owner) + if principal.role == "admin" or (principal.username and blueprint.owner == principal.username): + return True + return await _manages_app(session, principal, blueprint.app_id) + + +async def _manages_app(session: AsyncSession, principal: Any, app_id: str | None) -> bool: + from app.apps.access import app_role + from app.db.models_apps import App + + app = await session.get(App, app_id) if app_id else None + return app is not None and await app_role(session, principal, app) == "manage" + + +async def shared_listed(session: AsyncSession, principal: Any, rows: list[Any]) -> list[Any]: + """列表里这个人看得到的共享蓝图(同 can_access):同一个应用只查一次授权。""" + managed: dict[str, bool] = {} + out = [] + for bp in rows: + if principal.mode == "off" or principal.role == "admin" or ( + principal.username and bp.owner == principal.username): + out.append(bp) + continue + if bp.app_id and bp.app_id not in managed: + managed[bp.app_id] = await _manages_app(session, principal, bp.app_id) + if bp.app_id and managed[bp.app_id]: + out.append(bp) + return out # ========================================================================== @@ -274,8 +320,9 @@ def blueprint_issues_of(blueprint: Any) -> list[dict[str, Any]]: def blueprint_out(blueprint: Any, *, issues: bool = True) -> dict[str, Any]: - """一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态,不给填的值(值在图上看)。""" - joints = {jid: {k: v for k, v in state.items() if k in ("status", "by", "note")} + """一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态和模板声明的说法、类型、分组 + (JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。""" + joints = {jid: {k: v for k, v in state.items() if k in ("status", "by", "note", "label", "kind", "group")} for jid, state in (blueprint.joints or {}).items() if isinstance(state, dict)} out = { "id": blueprint.id, "name": blueprint.name, "owner": blueprint.owner, "scope": blueprint.scope, diff --git a/backend/app/blueprints/templates/__init__.py b/backend/app/blueprints/templates/__init__.py index 25751dd4..6c01c7a5 100644 --- a/backend/app/blueprints/templates/__init__.py +++ b/backend/app/blueprints/templates/__init__.py @@ -4,7 +4,7 @@ 关节分两种:确定性关节由蓝图直接填,不经模型;生成关节只有 sql 和 metric_expr 两类,模型在 TemplateApplier 下 只能用 fill_joint 填模板声明过的关节。实例化后的图和骨架逐节点比对,只许关节位置不同(tpl.skeleton_changed)。 -开发者扩展时,模板定义、实例化、测试三样都要给;check_registry 在启动时核对。 +开发者扩展时,模板定义(连同示例蓝图 example / example_fill)、实例化、测试三样都要给;check_registry 在启动时核对。 """ from __future__ import annotations @@ -55,6 +55,18 @@ class TemplateDef: #: schedule(bp) -> 定时任务参数 | None schedule: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None tags: list[str] = field(default_factory=list) + #: 一张完整的示例蓝图(测试和启动核对用):实例化、生成关节都填上后要能通过画布校验和发布前检查。必须给 + example: Callable[[], dict[str, Any]] | None = None + #: 示例蓝图的生成关节怎么填(测试和启动核对用):{关节 id: 值}。必须给(没有生成关节的给 dict) + example_fill: Callable[[], dict[str, Any]] | None = None + #: prefill(spec):选模板时预置还没填的卡片(原地改,已经写了的不动) + prefill: Callable[[dict[str, Any]], None] | None = None + #: briefs(bp) -> {group: 说明}:每组生成关节给模型看的说明(填关节时交给助手,依据指纹按它算) + briefs: Callable[[dict[str, Any]], dict[str, str]] | None = None + #: problems(spec) -> [{code, level, path, message}]:卡片内容超出模板能力的地方(bp.template_mismatch 等) + problems: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = None + #: 自由形:图是导入的(实例化时从上下文拿),模板不出骨架、不重新排版 + imports_graph: bool = False _REGISTRY: dict[str, TemplateDef] = {} @@ -77,8 +89,10 @@ def list_templates() -> list[TemplateDef]: def check_registry() -> list[str]: - """启动时核对:首批模板都登记了、关节类型都认得。交回问题清单(空 = 没问题)。""" - problems = [f"模板「{t}」没有登记" for t in TEMPLATE_IDS if t not in _REGISTRY] - return problems + """启动时核对(api/templates.py 导入时调):首批模板都登记了、关节类型都认得、每个模板都给了示例蓝图,示例实例化出来 + 过得了画布校验、骨架比对没有差异(blueprints/instantiate.registry_problems)。交回问题清单(空 = 没问题)。""" + from app.blueprints.instantiate import registry_problems + + return registry_problems() from app.blueprints.templates import approval, exception_handling, freeform, periodic_board, reconcile_writeback, records # noqa: E402,F401 diff --git a/backend/app/blueprints/templates/_common.py b/backend/app/blueprints/templates/_common.py index c810f306..822ebff4 100644 --- a/backend/app/blueprints/templates/_common.py +++ b/backend/app/blueprints/templates/_common.py @@ -7,8 +7,8 @@ 口径卡的项落成 caliber_from; - 应用骨架:apps/derive.py 的派生规则(_results、_entries、岗位、待办外观)加模板自己的排布。 -模板登记(TemplateDef)之外每个模板另给几样(EXTRAS):一张完整的示例蓝图(测试和启动核对用)、选模板时的预置、 -给模型看的每组关节说明。 +每个模板的登记(TemplateDef)连同示例蓝图(测试和启动核对用)、选模板时的预置、给模型看的每组关节说明、模板自己认的问题 +一起写,不另开旁路登记;这里的 prefill / template_problems 按模板 id 取登记来用。 """ from __future__ import annotations @@ -16,11 +16,10 @@ import copy import hashlib import json import re -from collections.abc import Callable from dataclasses import dataclass, field from typing import Any -from app.blueprints.templates import Joint +from app.blueprints.templates import Joint, get_template #: 实例化时交给模板的上下文(蓝图 id、工作流 id、定时任务 id、记录表、导入的图):放在 bp 的这个键下,模板读、不写回 CONTEXT_KEY = "_context" @@ -741,35 +740,10 @@ def finish_app(spec: dict[str, Any]) -> dict[str, Any]: # ========================================================================== -# 模板登记之外的几样(示例蓝图、预置、每组关节的说明) +# 示例蓝图的写法、预置、模板自己认的问题(各模板写进自己的 TemplateDef) # ========================================================================== -@dataclass -class Extras: - #: 一张完整的示例蓝图(测试和启动核对用):实例化、生成关节都填上后要能通过画布校验和发布前检查 - example: Callable[[], dict[str, Any]] - #: 示例蓝图的生成关节怎么填(测试用):{关节 id: 值} - example_fill: Callable[[], dict[str, Any]] - #: 选模板时预置还没填的卡片 - prefill: Callable[[dict[str, Any]], None] | None = None - #: 每组生成关节给模型看的说明:{group: 说明} - briefs: Callable[[dict[str, Any]], dict[str, str]] | None = None - #: 自由形:图是导入的(实例化时从上下文拿),模板不出骨架 - imports_graph: bool = False - #: 卡片内容超出模板能力的地方(bp.template_mismatch 等):[{code, level, path, message}] - problems: Callable[[dict[str, Any]], list[dict[str, Any]]] | None = None - - -EXTRAS: dict[str, Extras] = {} - - -def extend(template_id: str, extras: Extras) -> None: - if template_id in EXTRAS: - raise ValueError(f"模板「{template_id}」的示例和预置重复登记") - EXTRAS[template_id] = extras - - def user_item(v: Any) -> dict[str, Any]: """用户填过、确认过的一项(示例蓝图用);没填的记「缺」。""" return {"value": v, "source": "user", "status": "confirmed" if v is not None else "missing", "why": None} @@ -781,11 +755,11 @@ def template_item(v: Any) -> dict[str, Any]: def template_problems(template_id: str, spec: dict[str, Any]) -> list[dict[str, Any]]: - """模板自己认的问题:卡片内容超出模板能力(只能从一个库查、复用的口径卡接不上……)。""" - extras = EXTRAS.get(template_id) - if extras is None or extras.problems is None: + """模板自己认的问题(TemplateDef.problems):卡片内容超出模板能力(只能从一个库查、复用的口径卡接不上……)。""" + template = get_template(template_id) + if template is None or template.problems is None: return [] - return extras.problems(spec) + return template.problems(spec) def mismatch(path: str, message: str) -> dict[str, Any]: @@ -793,10 +767,10 @@ def mismatch(path: str, message: str) -> dict[str, Any]: def prefill(template_id: str, spec: dict[str, Any]) -> None: - """选模板时按模板预置还没填的卡片(原地改)。已经写了的不动。""" - extras = EXTRAS.get(template_id) - if extras is not None and extras.prefill is not None: - extras.prefill(spec) + """选模板时按模板预置还没填的卡片(TemplateDef.prefill,原地改)。已经写了的不动。""" + template = get_template(template_id) + if template is not None and template.prefill is not None: + template.prefill(spec) def default_cadence(preset: str = "weekly") -> dict[str, Any]: diff --git a/backend/app/blueprints/templates/approval.py b/backend/app/blueprints/templates/approval.py index 3b0b9816..c262aba5 100644 --- a/backend/app/blueprints/templates/approval.py +++ b/backend/app/blueprints/templates/approval.py @@ -109,5 +109,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能发起、谁能看全部、谁能管理"}, personal=None, skeleton=skeleton, app_skeleton=app_skeleton, schedule=None, tags=["流程", "审批"], + example=example, example_fill=dict, prefill=_prefill, problems=problems, )) -c.extend(ID, c.Extras(example=example, example_fill=dict, prefill=_prefill, problems=problems)) diff --git a/backend/app/blueprints/templates/exception_handling.py b/backend/app/blueprints/templates/exception_handling.py index 968e721f..9cb2d606 100644 --- a/backend/app/blueprints/templates/exception_handling.py +++ b/backend/app/blueprints/templates/exception_handling.py @@ -176,6 +176,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能用、谁能看全部、谁能管理"}, personal="只看清单的那一部分(没有处置和复核)", skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["流程", "定时", "异常"], + example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, problems=problems, )) -c.extend(ID, c.Extras(example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, - problems=problems)) diff --git a/backend/app/blueprints/templates/freeform.py b/backend/app/blueprints/templates/freeform.py index 452c948a..42cc6c3a 100644 --- a/backend/app/blueprints/templates/freeform.py +++ b/backend/app/blueprints/templates/freeform.py @@ -106,5 +106,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能用、谁能看全部、谁能管理"}, personal="导入的图满足问数据的规则(只读、没有人工节点和写入)时可以", skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["导入"], + example=example, example_fill=dict, imports_graph=True, )) -c.extend(ID, c.Extras(example=example, example_fill=dict, imports_graph=True)) diff --git a/backend/app/blueprints/templates/periodic_board.py b/backend/app/blueprints/templates/periodic_board.py index a43a3587..48bef45d 100644 --- a/backend/app/blueprints/templates/periodic_board.py +++ b/backend/app/blueprints/templates/periodic_board.py @@ -122,6 +122,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能用、谁能看全部、谁能管理"}, personal="全部可以做成个人应用(只读,自己点刷新)", skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["看板", "定时"], + example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, problems=problems, )) -c.extend(ID, c.Extras(example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, - problems=problems)) diff --git a/backend/app/blueprints/templates/reconcile_writeback.py b/backend/app/blueprints/templates/reconcile_writeback.py index 5925570e..686b3831 100644 --- a/backend/app/blueprints/templates/reconcile_writeback.py +++ b/backend/app/blueprints/templates/reconcile_writeback.py @@ -197,6 +197,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能用、谁能看全部、谁能管理"}, personal=None, skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["流程", "定时", "写回"], + example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, problems=problems, )) -c.extend(ID, c.Extras(example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, - problems=problems)) diff --git a/backend/app/blueprints/templates/records.py b/backend/app/blueprints/templates/records.py index 0e0916ec..06ec33ba 100644 --- a/backend/app/blueprints/templates/records.py +++ b/backend/app/blueprints/templates/records.py @@ -208,6 +208,5 @@ TEMPLATE = register(TemplateDef( "access": "可选:谁能填(只看得到自己的记录)、谁能看全部、谁能管理"}, personal=None, skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["流程", "记录", "看板"], + example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, problems=problems, )) -c.extend(ID, c.Extras(example=example, example_fill=example_fill, prefill=_prefill, briefs=briefs, - problems=problems)) diff --git a/backend/app/blueprints/trial.py b/backend/app/blueprints/trial.py index 23c112bd..29ef7f2e 100644 --- a/backend/app/blueprints/trial.py +++ b/backend/app/blueprints/trial.py @@ -212,6 +212,22 @@ def trial_inputs(blueprint: Blueprint, inputs: dict[str, Any] | None) -> dict[st return {**base, **{k: v for k, v in (inputs or {}).items() if v not in (None, "")}} +def retry_form(app_spec: dict[str, Any]) -> dict[str, Any] | None: + """「换一期再试」填的表单(预览的 trial.form,界面照它画,不自己挑):流程类、填报登记是第一个带表单的入口的示例表单 + (entry_of_draft,填了就走 entry_inputs,和应用入口提交同一条路);看板类没有入口,是按条件查看的条件表单(视图来源的 + params,就是图的输入,trial_inputs 拿用户给的覆盖按「多久更新」算出的那一期)。都没有为 None(只能原样再试一次)。""" + from app.apps.tree import resolve_form + + if (found := entry_of_draft(app_spec)) is not None: + return found[1] + for view in (app_spec.get("views") or {}).values(): + source = view.get("source") if isinstance(view, dict) else None + form = resolve_form(app_spec, source.get("params")) if isinstance(source, dict) else None + if isinstance(form, dict) and (form.get("properties") or {}): + return form + return None + + def entry_of_draft(app_spec: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]] | None: """应用草稿里第一个带表单的入口和它的表单(流程类:「填什么」就是它);看板类没有入口,为 None。""" from app.apps.tree import resolve_form @@ -263,8 +279,10 @@ async def start_trial(session: AsyncSession, blueprint: Blueprint, *, inputs: di raise TrialRefused(409, NOT_READY_TEXT, NOT_READY, [p for p in result.problems if p.get("level") == "error"]) options = None memory_scope = None - if restricted(): - # 开登录后的使用者(个人蓝图):和问数据、个人应用同一份约束(auth/chat_policy.py,blueprints/personal.policy_problem) + if restricted() or blueprint.scope == "personal": + # 开登录后的使用者,和任何人的个人蓝图:和问数据、个人应用的刷新同一份约束(auth/chat_policy.py, + # blueprints/personal.policy_problem)。个人蓝图的试运行就是刷新的预演,搭建者自己的个人蓝图也照样遮罩、只读, + # 试出来的才是存成个人应用以后刷新出来的样子 from app.auth.chat_policy import chat_memory_scope from app.blueprints.personal import policy_problem from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION @@ -334,7 +352,8 @@ class PreviewApp: async def preview_page(session: AsyncSession, blueprint: Blueprint, *, page_id: str | None, run_id: str | None, principal: Principal) -> dict[str, Any]: """按这次试运行解析应用草稿的一页(agentlab.view/1),印章 from=试运行、trial=true。run_id 不给就是最近一次。 - 草稿用这次试运行时记下的那一份(之后改了卡片,预览的仍是这次运行对应的样子)。""" + 草稿用这次试运行时记下的那一份(之后改了卡片,预览的仍是这次运行对应的样子)。trial 里另给「换一期再试」的表单 + (retry_form)。""" from app.apps.resolve import resolve_page from app.apps.tree import page_of @@ -354,5 +373,7 @@ async def preview_page(session: AsyncSession, blueprint: Blueprint, *, page_id: raise ViewRefused(404, f"应用里没有页面「{wanted}」", "app_page_not_found") app = PreviewApp(id=blueprint.app_id or blueprint.id, name=blueprint.name, draft=spec) - return await resolve_page(session, app, page_id=wanted, principal=principal, role="manage", draft=True, + page = await resolve_page(session, app, page_id=wanted, principal=principal, role="manage", draft=True, trial=run, actor=principal.username) + # 「换一期再试」的表单按这次试运行对应的那份草稿给(retry_form):界面照它画,不自己从骨架里挑 + return {**page, "trial": {**(page.get("trial") or {}), "form": retry_form(spec)}} diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index e9308f5d..0d253e43 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -62,7 +62,7 @@ 'agent.step.end': 'Agent 步骤结束', 'agent.route.start': '调度决策开始', 'agent.route.end': '调度决策结束', 'human.requested': '请求人工审批', 'human.resolved': '人工审批结果', 'approval.inquiry': '审批质询', 'issuance': '出具判定', 'caliber.upgrade': '口径卡升版处置', 'catalog.drift': '数据目录变化', - 'report.checked': '报告自查', 'evidence.judged': '按需裁判', 'log': '日志', 'usage': '用量', + 'report.checked': '报告自查', 'evidence.judged': '按需裁判', 'file.extracted': '读入附件', 'log': '日志', 'usage': '用量', } const T = { allPass: '全部通过', diff --git a/backend/app/core/events.py b/backend/app/core/events.py index 71a1c231..ed898610 100644 --- a/backend/app/core/events.py +++ b/backend/app/core/events.py @@ -105,6 +105,11 @@ class EventType(StrEnum): ACTION_RECONCILED = "action.reconciled" ACTION_SIMULATED = "action.simulated" + # 普通用户建应用(docs/design/normal-user-apps.md N9、§3.8) + # 读入一个附件的文字(模板函数 file_text,app/files/text.py):哪个文件、读入多少字、全文多少字、是否截断, + # 读入的那一段存成工件(artifact)。抽不出时 ok=false、reason 写原因 + FILE_EXTRACTED = "file.extracted" + # 其他 LOG = "log" USAGE = "usage" # token / 成本累计 diff --git a/backend/app/data/engine.py b/backend/app/data/engine.py index 1aac53f9..7cdb4c41 100644 --- a/backend/app/data/engine.py +++ b/backend/app/data/engine.py @@ -942,6 +942,10 @@ async def run_query( raise SqlRejected(f"「{source.name}」只能按表名查询(如 FROM records),不能在表名前加库名「{hit}.」:" "这张表只给看这次运行能看的那些行") prelude: tuple[str, ...] = tuple(getattr(source, "prelude", None) or ()) + attach: tuple[tuple[str, str], ...] = tuple(getattr(source, "attach", None) or ()) + if attach and (kind != "sqlite" or not source.readonly): + # 挂旧版本靠 URI 写法的 mode=ro:只有以 URI 只读打开的 SQLite 连接认它(_sqlite_url) + raise ValueError("只有只读的 SQLite 数据源能挂其他库") engine = await engines.get(source) started = time.perf_counter() @@ -950,6 +954,9 @@ async def run_query( return await _run_write(engine, kind, statement, limits, started) async def _read(conn: AsyncConnection) -> tuple[list[str], list[list[Any]], bool, dict[str, str]]: + # 记录表跨版本汇总:旧版本先只读挂到这条连接上(prelude 里的临时视图要引用它们) + if prelude or attach: + await _attach_readonly(conn, attach) # 连接上先执行的语句(记录表的同名临时视图)。按驱动原样执行,不经 text():视图里拼着看的人的名字, # 名字里的冒号不能被当成绑定参数 for stmt in prelude: @@ -981,6 +988,41 @@ async def run_query( ) +#: 平台挂到查询连接上的库名只许这个样子(records/views.py 的 records_v<版本>):拼进 ATTACH / DETACH 语句 +_ATTACH_NAME = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + + +def _same_file(a: str, b: str) -> bool: + return bool(a) and bool(b) and os.path.realpath(a) == os.path.realpath(b) + + +async def _attach_readonly(conn: AsyncConnection, wanted: tuple[tuple[str, str], ...]) -> None: + """让这条连接上挂着的库正好是 wanted(库名 → 文件),一律以 URI 的 mode=ro 只读挂上。 + + 连接在池子里复用,挂过的库留在连接上:已经挂好、文件对得上的不重挂;不在 wanted 里的(或文件对不上的)先摘掉—— + 不然 SQL 写个库名就能读到这次没并进来的那一版。用户 SQL 里的 ATTACH / DETACH 由只读守卫拒(data/guard.py), + 挂库只有这里做;DETACH 失败(连接上还有没读完的语句)照实抛出,不带着不该挂的库查。""" + from urllib.parse import quote + + want = dict(wanted) + for name in want: + if not _ATTACH_NAME.match(name) or name in ("main", "temp"): + raise ValueError(f"挂库的库名不合规:{name}") + have = {str(r[1]): str(r[2] or "") for r in (await conn.exec_driver_sql("PRAGMA database_list")).fetchall()} + for name, file in have.items(): + if name in ("main", "temp"): + continue + if name not in want or not _same_file(file, want[name]): + await conn.exec_driver_sql(f'DETACH DATABASE "{name}"') + for name, path in want.items(): + if name in have and _same_file(have[name], path): + continue + if not os.path.isfile(path): + raise FileNotFoundError(f"要并进来的数据文件不在了:{os.path.basename(path)}") + uri = f"file:{quote(os.path.realpath(path))}?mode=ro" + await conn.exec_driver_sql(f"ATTACH DATABASE '{uri}' AS \"{name}\"") + + class _TypeTally: """边读边记每列见过的值类型。只看驱动交回的原始值:_jsonable 之后 Decimal 就成了字符串, 小数位为 0 的 DECIMAL("45678")和文本列的 "2026" 再也分不开。""" diff --git a/backend/app/db/models_process.py b/backend/app/db/models_process.py index c368a765..100ebdf6 100644 --- a/backend/app/db/models_process.py +++ b/backend/app/db/models_process.py @@ -16,8 +16,10 @@ from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow -#: 待办的种类。人工节点三种 + 现有的几种中断 + 动作确认、核实、等外部系统 -TASK_KINDS = ("approve", "input", "edit", "tool", "code", "dual_check", "action_confirm", "reconcile", "signal") +#: 待办的种类。人工节点三种 + 现有的几种中断 + 动作确认、核实、等外部系统 + 个人应用提交审核(review, +#: docs/design/normal-user-apps.md N7,app/process/tasks.REVIEW_KIND) +TASK_KINDS = ("approve", "input", "edit", "tool", "code", "dual_check", "action_confirm", "reconcile", "signal", + "review") #: pending 等人;resolving 已凑齐、正在恢复运行(条件 UPDATE 抢到的那个请求负责);resolved / cancelled / expired 终态 TASK_STATUSES = ("pending", "resolving", "resolved", "cancelled", "expired") #: 动作状态。simulated:探索运行里端点没有 test 环境时的演练,不发送 diff --git a/backend/app/engine/nodes/knowledge.py b/backend/app/engine/nodes/knowledge.py index 501f268c..62d9a415 100644 --- a/backend/app/engine/nodes/knowledge.py +++ b/backend/app/engine/nodes/knowledge.py @@ -9,6 +9,7 @@ from app.engine.context import NodeContext, NodeError from app.engine.evidence import ledger_enabled, next_exec from app.engine.state import GraphState, template_context from app.memory import kb, store +from app.tools.datasource import run_scope_fixed async def run_memory(state: GraphState, ctx: NodeContext) -> dict[str, Any]: @@ -77,7 +78,9 @@ async def run_retrieve(state: GraphState, ctx: NodeContext) -> dict[str, Any]: if not query.strip(): raise NodeError(ctx.node.id, "「知识检索」节点没有填写检索问题") - collection = ctx.render_str(ctx.cfg("collection", "") or ctx.run.collection, state) + # 遮罩、只读的运行:知识库只认运行定下的(tools/registry.ToolContext.fixed_scope,同调用工具节点、Agent) + collection = ctx.run.collection if run_scope_fixed(ctx.run) \ + else ctx.render_str(ctx.cfg("collection", "") or ctx.run.collection, state) limit = int(ctx.cfg("limit", 5) or 5) # 不写死 0.5:没配置就跟着 embedder 的能力走(见 embeddings.default_alpha) raw_alpha = ctx.cfg("alpha") diff --git a/backend/app/engine/nodes/llm.py b/backend/app/engine/nodes/llm.py index b5bbc148..3c93e39f 100644 --- a/backend/app/engine/nodes/llm.py +++ b/backend/app/engine/nodes/llm.py @@ -48,7 +48,7 @@ from app.providers.factory import ( get_chat_model, ) from app.engine.sql_problems import event_checks -from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_versions +from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions from app.tools.registry import ( ToolArgsError, ToolBuildError, @@ -329,15 +329,18 @@ async def run_llm(state: GraphState, ctx: NodeContext) -> dict[str, Any]: async def _resolve_tools(ctx: NodeContext, state: GraphState) -> list[BaseTool]: names = ctx.cfg("tools", []) or [] + # 遮罩、只读的运行:记忆范围、知识库只认运行定下的,模型在参数里挑的也不算(tools/registry.ToolContext.fixed_scope) + fixed = run_scope_fixed(ctx.run) tool_ctx = ToolContext( run_id=ctx.run.run_id, node_id=ctx.node.id, sandbox_session=ctx.run.thread_id, - memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope, - collection=ctx.cfg("collection") or ctx.run.collection, + memory_scope=ctx.run.memory_scope if fixed else ctx.cfg("memory_scope") or ctx.run.memory_scope, + collection=ctx.run.collection if fixed else ctx.cfg("collection") or ctx.run.collection, data_versions=run_versions(ctx.run), mask_results=run_masks_results(ctx.run), force_readonly=run_reads_only(ctx.run), + fixed_scope=fixed, # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, ) diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 4111112f..9815407c 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -28,7 +28,7 @@ from app.engine.toolcalls import ( from app.providers import catalog from app.providers.factory import ModelSpec, bind_tools_safely, get_chat_model from app.engine.sql_problems import event_checks -from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_versions +from app.tools.datasource import QUERY_PREFIX, run_masks_results, run_reads_only, run_scope_fixed, run_versions from app.tools.registry import ( ToolArgsError, ToolContext, @@ -316,6 +316,7 @@ async def run_supervisor(state: GraphState, ctx: NodeContext) -> dict[str, Any]: data_versions=run_versions(ctx.run), mask_results=run_masks_results(ctx.run), force_readonly=run_reads_only(ctx.run), + fixed_scope=run_scope_fixed(ctx.run), # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, ) diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 48b21149..1b26352d 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -20,7 +20,8 @@ from app.sandbox.base import SandboxLimits from app.sandbox.manager import sandbox_manager from app.engine.sql_problems import event_checks from app.capabilities.registry import is_platform_query, snapshot_owned -from app.tools.datasource import DATA_UNAVAILABLE, SCHEMA_PREFIX, run_masks_results, run_reads_only, run_versions +from app.tools.datasource import (DATA_UNAVAILABLE, SCHEMA_PREFIX, run_masks_results, run_reads_only, + run_scope_fixed, run_versions) from app.tools.registry import ( ToolArgsError, ToolBuildError, @@ -41,15 +42,18 @@ EVIDENCE_ROLES = ("source", "compute") def _tool_ctx(ctx: NodeContext) -> ToolContext: + # 遮罩、只读的运行:记忆范围、知识库只认运行定下的,节点配置、图级 defaults 里写的都不算(tools/registry.ToolContext.fixed_scope) + fixed = run_scope_fixed(ctx.run) return ToolContext( run_id=ctx.run.run_id, node_id=ctx.node.id, sandbox_session=ctx.run.thread_id, - memory_scope=ctx.cfg("memory_scope") or ctx.run.memory_scope, - collection=ctx.cfg("collection") or ctx.run.collection, + memory_scope=ctx.run.memory_scope if fixed else ctx.cfg("memory_scope") or ctx.run.memory_scope, + collection=ctx.run.collection if fixed else ctx.cfg("collection") or ctx.run.collection, data_versions=run_versions(ctx.run), mask_results=run_masks_results(ctx.run), force_readonly=run_reads_only(ctx.run), + fixed_scope=fixed, # 数据源工具运行中途补固定版本时经它留一条事件(tools/datasource.py 的 _pin_late) emit=ctx.emit, ) @@ -85,6 +89,11 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 能力治理后效果为写入的:走动作的路径(先留生效记录再发送,app/process/actions.py)。登记里没有、或不是写入的, # 照以前的路径执行 if (capability := await _write_capability(name, ctx)) is not None: + if run_reads_only(ctx.run): + # 只读的运行(使用者的问数据、个人应用的刷新、个人蓝图的试运行,auth/chat_policy.py)什么都不写:能力治理里 + # 改成「写入」的工具也不进动作流程(探索运行在那里会演练、交回一张假回执),同 Agent 建工具时拒写入能力 + raise NodeError(ctx.node.id, f"这次运行只能读数据:「{name}」是写入能力,不能调用。需要写入的," + "请联系搭建者做成应用") return await _run_write(state, ctx, name, args, capability) # 审批通过后节点会整个重放、再算一遍要不要问人,所以这里只能取决于 name 和 args。 diff --git a/backend/app/files/store.py b/backend/app/files/store.py index bd94dcb8..f3f74aa5 100644 --- a/backend/app/files/store.py +++ b/backend/app/files/store.py @@ -70,9 +70,8 @@ _TEXT_TYPES = ("text/csv", "text/plain") _SHA = re.compile(r"^[0-9a-f]{64}$") #: 文件名最长存几个字 NAME_MAX = 300 -#: 文件控件(x-widget: file)的值要是引用这个形状;校验不过时表单错误的 code。 -#: 前端的表单错误 code 联合类型里还没有专门的一项(contract_requests),先用 type -FILE_ERROR_CODE = "type" +#: 文件控件(x-widget: file)的值要是引用这个形状;校验不过时表单错误的 code(apps/spec.FORM_ERROR_CODES 的 file) +FILE_ERROR_CODE = "file" class FileRefused(Exception): @@ -255,12 +254,14 @@ async def _task_context(session: AsyncSession, principal: Any, ctx: dict[str, st async def _blueprint_context(session: AsyncSession, principal: Any, ctx: dict[str, str]) -> dict[str, Any] | None: from app.db.models_blueprints import Blueprint + from app.blueprints.store import BLUEPRINT_GONE, can_access + bp = await session.get(Blueprint, ctx["blueprint_id"]) - if bp is None: - raise FileRefused(404, "files.context_invalid", "蓝图不存在") - if principal.mode != "off" and not ( - (bp.owner and bp.owner == principal.username) or (bp.scope == "shared" and principal.at_least("builder"))): - raise FileRefused(403, "files.forbidden", _NO_ACCESS) + # 同读写蓝图(blueprints/store.can_access):拥有者;共享蓝图另有管理员、它发布出来的应用的管理者。看不到的和不存在的 + # 一个说法(同 store.ensure_access「看不到的一律 404」):回 403 就等于告诉人这张蓝图在 + if bp is None or (principal.mode != "off" and not ( + (bp.scope != "shared" or principal.at_least("builder")) and await can_access(session, principal, bp))): + raise FileRefused(404, "files.context_invalid", BLUEPRINT_GONE) return None diff --git a/backend/app/files/text.py b/backend/app/files/text.py index 2356ae09..dae3e4ef 100644 --- a/backend/app/files/text.py +++ b/backend/app/files/text.py @@ -162,13 +162,7 @@ def _record(node: Any, meta: dict[str, Any], *, included: str, total: int, trunc data = {"file_id": meta["file_id"], "name": meta["name"], "sha256": meta["sha256"], "chars": len(included), "total_chars": total, "truncated": truncated, "ok": problem is None, "artifact": artifact, **({"reason": problem} if problem else {})} - event = getattr(EventType, "FILE_EXTRACTED", None) try: - if event is not None: - node.emit(event, **data) - else: - # 事件类型还没登记(contract_requests):先记成日志事件,code 写明是哪一种 - node.emit(EventType.LOG, level="info", code="file.extracted", - message=f"读入附件「{meta['name']}」{len(included)} 字" + ("(截断)" if truncated else ""), **data) + node.emit(EventType.FILE_EXTRACTED, **data) except Exception: # noqa: BLE001 - 事件只是留痕,读入的文字照样交回 logger.warning("file.extracted 事件没发出去", exc_info=True) diff --git a/backend/app/main.py b/backend/app/main.py index 827c9311..c5fa8f46 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,7 +24,7 @@ from app.api import ( templates as templates_api, ) from app.api.coded import CodedHTTPException, coded_handler -from app.api.deps import chat_scope, evidence_scope, require_role, role_by_method, run_scope +from app.api.deps import chat_scope, evidence_scope, private_run_scope, require_role, role_by_method, run_scope from app.auth.middleware import AuthMiddleware from app.core.config import settings from app.db.base import init_db @@ -196,11 +196,12 @@ for router, guard in ( (workflows.router, _BUILDER), # 这几个也挂在 /api/runs 下面,要在 runs.router 之前登记:以后加的固定路径(/api/runs/compare 之类) # 才不会被 runs 的 /{run_id} 当成运行 id 吃掉。/{run_id}/… 的子路径放在哪边都不冲突。 - # 运行对比、可验证报告、质询、假设分析交的是查询原文,只给 builder 及以上 - (compare.router, _BUILDER), - (bundle.router, _BUILDER), - (inquiry.router, _BUILDER), - (whatif.router, _BUILDER), + # 运行对比、可验证报告、质询、假设分析交的是查询原文,只给 builder 及以上;别人的个人应用刷新、个人蓝图试运行只给 + # 拥有者(private_run_scope),管理员也不例外 + (compare.router, [*_BUILDER, private_run_scope]), + (bundle.router, [*_BUILDER, private_run_scope]), + (inquiry.router, [*_BUILDER, private_run_scope]), + (whatif.router, [*_BUILDER, private_run_scope]), (runs.router, [run_scope]), (runs.approvals_router, _BUILDER), (evidence.router, [evidence_scope]), diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 73dfc046..9f1dd240 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -146,9 +146,10 @@ async def open_review(session: AsyncSession, *, run: Run, app_id: str, blueprint to: list[tuple[str, str]]) -> Task: """个人应用提交审核:开一条 review 待办,派给选定的搭建者或组(组在这一刻展开成成员,同改派),来源记 review。 - 待办的 run_id 不能为空(带外键,开待办的事件、通知都按运行走):挂在个人应用最近一次成功的刷新上——审核的人要看的 - 也正是这次用真数出的结果。这次运行已经结束,不会被恢复:回复走 _respond_review,不调 run_manager.resume。调用方提交, - 提交后 announce。""" + 待办的 run_id 不能为空(带外键,开待办的事件、通知都按运行走):挂在个人应用最近一次成功的刷新上,只是外键挂靠—— + 个人应用的刷新只有拥有者读得到(auth/access.private_owner),处理人(搭建者、管理员也一样)读不到这次运行、打不开这个 + 个人应用;他要看结果,取共享副本(blueprints/personal.copy_for_review)在副本上试运行。这次运行已经结束,不会被恢复: + 回复走 _respond_review,不调 run_manager.resume。调用方提交,提交后 announce。""" task = Task(run_id=run.id, node_id=None, app_id=app_id, app_version=None, kind=REVIEW_KIND, title=title[:300], policy={"kind": "any"}, correlation_key=f"blueprint:{blueprint_id}") session.add(task) diff --git a/backend/app/records/actions.py b/backend/app/records/actions.py index 66bf0f3a..1b94218a 100644 --- a/backend/app/records/actions.py +++ b/backend/app/records/actions.py @@ -4,17 +4,24 @@ 交回回执。幂等键就是动作键:发送用的是「动作键:第几次」,这里只取动作键,同一个动作不论第几次发都只落一行 (记录表 store.append 按它去重)。写下的行里有文件字段的,把那几个文件绑到这一行(下载按行级可见核对)。 +表版本停用了(表单不兼容地改过、新一版已经发布):停用之前发起的运行照常写进它(同「进行中的实例照常走完」:审批停在半路的 +实例,批完写进它发起时的那一版);停用之后发起的拒(records.table_retired)。没有运行的直接调用一律拒。 + 写之前核对三样(写进去的东西谁都看得到,不能凭图里写的一个名字就写): - 正式运行只写自己的记录表:表出自哪个蓝图,就只收那个蓝图发布出来的工作流的运行(没有蓝图的表,收钉着它的应用里的 工作流)——不然一张手改过的图就能往别的应用的表里写(records.table_foreign); - 更正(supersedes)只能更正提交的人看得到的那一行:按行级可见(records/access),使用者只能更正自己提交的; - 文件只绑这次运行提交时绑上的(StoredFile.bound_run_id 是这次运行):值里随便写一个文件编号,不会把别人的文件挂到 这一行上、让看得到这一行的人都能下载。 + +更正行的提交人沿用原行的(records/store.append):原提交人照样看得见改过的那一行。动手更正的人记进回执(corrected_by, +随动作记录的回执落工件)和审计(records.correct),不记进行里。 """ from __future__ import annotations import asyncio import json +import logging from typing import Any from sqlalchemy import select @@ -22,6 +29,8 @@ from sqlalchemy import select from app.db.base import SessionLocal from app.records import store +logger = logging.getLogger(__name__) + def _result(status: int | None, body: Any = None, *, error: str | None = None, environment: str = "prod") -> Any: from app.capabilities.registry import EndpointResult @@ -37,6 +46,7 @@ def action_key_of(idem_key: str | None) -> str: async def append_action(args: dict[str, Any], *, idem_key: str | None, run_id: str | None, on_behalf: Any, environment: str) -> Any: from app.capabilities.platform import actor_of + from app.db.models import Run from app.records.tables import columns_of, find_table, path_of, refuse_retired values = args.get("values") @@ -56,7 +66,9 @@ async def append_action(args: dict[str, Any], *, idem_key: str | None, run_id: s source = await session.get(DataSource, table.source_id) try: if table is not None: - refuse_retired(table) + # 停用的版本:停用之前发起的运行照常写完(同进行中的实例照常走完),之后发起的拒 + run = await session.get(Run, run_id) if run_id else None + refuse_retired(table, launched_at=run.created_at if run is not None else None) await _check_writer(session, table, run_id) if supersedes: await _check_supersedes(session, table, supersedes, actor) @@ -74,6 +86,8 @@ async def append_action(args: dict[str, Any], *, idem_key: str | None, run_id: s return _result(None, error=e.message, environment=environment) if not done.duplicate: await _bind_files(table, columns, done, run_id) + if done.corrected_by is not None: + await _audit_correction(table, done, supersedes, run_id) return _result(200, {"table": table.table_key, "version": table.version, "source": source.name if source else None, **done.receipt()}, environment=environment) @@ -116,6 +130,23 @@ async def _check_supersedes(session: Any, table: Any, row_id: str, actor: str | raise store.RecordsRefused("records.supersede_missing", "要更正的那一行不在这张记录表里") +async def _audit_correction(table: Any, done: Any, supersedes: str | None, run_id: str | None) -> None: + """更正留一条审计:谁改的(操作人)、改的是谁交的哪一行、换成了哪一行、哪次运行。审计没写成不改变已经写下的那一行 + (行已经落了,动作记录照常确认),只记日志。""" + from app.apps.audit import record + + try: + async with SessionLocal() as session: + await record(session, action="records.correct", subject_kind="records_table", subject_id=table.id, + actor=done.corrected_by, + data={"table": table.table_key, "version": table.version, "name": table.name, + "row_id": done.row_id, "supersedes": supersedes, "submitted_by": done.submitted_by, + "run_id": run_id}) + await session.commit() + except Exception: # noqa: BLE001 + logger.exception("记录表的更正没能写进审计(表 %s,行 %s)", table.id, done.row_id) + + async def _bind_files(table: Any, columns: list[Any], done: Any, run_id: str | None) -> None: """这一行的文件字段:把文件绑到这一行(StoredFile.bound_record)。下载时读得到这一行的人才能下(files/store.py)。 只绑这次运行提交时绑上的文件(bound_run_id 是这次运行):值里写的别的文件编号不认。""" diff --git a/backend/app/records/listing.py b/backend/app/records/listing.py index 08c992ee..5e6562d5 100644 --- a/backend/app/records/listing.py +++ b/backend/app/records/listing.py @@ -7,8 +7,9 @@ 遮罩:别人提交的行里,数据源设了遮罩的列(「填什么」里标了敏感的,加上管理员在数据页手工加的)换成「已遮罩」——和看板证据 同一套遮罩列、同一个说法(P4、P5);自己提交的行照原样给(是自己填的)。附件列遮掉之后连文件的引用也不给。 -返回的形状同 frontend/src/typesBlueprint.ts 的 RecordsPage:{table, version, columns, items, mine_only},另带 next_cursor -(下一页的游标,「版本:序号」,没有下一页为空)和 state(empty:这个应用还没有记录表)。 +返回的形状同 frontend/src/typesBlueprint.ts 的 RecordsPage,每个键都在:{table, version, columns, items, mine_only, +next_cursor, state}。next_cursor 是下一页的游标(「版本:序号」),没有下一页为 null;state 是 ok(有这张表,items 可能 +为空)、empty(这个应用还没有记录表),草稿预览(apps/resolve._records_list)是 preview(不列真实的记录)。 """ from __future__ import annotations @@ -104,4 +105,4 @@ async def page(session: AsyncSession, app: Any, *, principal: Any, role: str | N last = items[-1] if items else None return {"table": current.table_key, "version": versions[0].version if versions else current.version, "columns": columns, "items": items, "mine_only": scope == "mine", - "next_cursor": f"{last['_version']}:{last['_seq']}" if more and last else None} + "next_cursor": f"{last['_version']}:{last['_seq']}" if more and last else None, "state": "ok"} diff --git a/backend/app/records/store.py b/backend/app/records/store.py index 27ff1a67..1a370754 100644 --- a/backend/app/records/store.py +++ b/backend/app/records/store.py @@ -9,7 +9,8 @@ (INTEGER PRIMARY KEY),只增不减:从不删行,也就不会复用。 **读。** 高水位(high_water)是现在最大的 _seq。一次运行在开始时记下它(engine/runner._data_versions),之后这次 -运行查到的都是 _seq ≤ 高水位、在那一刻没被更正过的行:行只追加、不改,所以同一次运行查几遍都是同一份。 +运行查到的都是 _seq ≤ 高水位、在那一刻没被更正过的行:行只追加、不改,所以同一次运行查几遍都是同一份。表单不兼容地改过 +之后,临时视图把比这一版老的各版本(只读挂在连接上)按 UNION ALL 接上,各按自己的高水位(view_prelude 的 older)。 这里的函数都是同步的(sqlite3 直连、关 DQS),调用方放进线程。表结构的登记、数据源在 records/tables.py。 """ @@ -282,10 +283,15 @@ class Appended: #: 同一个动作键已经写过:交回的是第一次写下的那一行,这次没有再写 duplicate: bool values: dict[str, Any] + #: 这一行记的提交人(更正行沿用原行的,见 append) + submitted_by: str | None = None + #: 更正行:动手更正的人(不记进行里,记进回执、动作记录和审计);不是更正为 None + corrected_by: str | None = None def receipt(self) -> dict[str, Any]: return {"_seq": self.seq, "_id": self.row_id, "_submitted_at": self.submitted_at, - "duplicate": self.duplicate} + "_submitted_by": self.submitted_by, "duplicate": self.duplicate, + **({"corrected_by": self.corrected_by} if self.corrected_by is not None else {})} def append(path: Path, columns: list[Column], values: Any, *, version: int, run_id: str | None, actor: str | None, @@ -293,7 +299,11 @@ def append(path: Path, columns: list[Column], values: Any, *, version: int, run_ """写一行。同一个 action_key 第二次来交回第一次的那一行(duplicate=True),不再写。 values 只认表里的业务列:多出来的字段、必填没填、类型收不成都拒(RecordsRefused),一行都不写。 - supersedes 是被更正的那一行的 _id:它得在这张表里、而且还没被更正过(更正要改最新的那一行)。""" + supersedes 是被更正的那一行的 _id:它得在这张表里、而且还没被更正过(更正要改最新的那一行)。 + + 提交人(_submitted_by):新提交记 actor;更正行沿用原行的提交人——这一条还是他交的那一条,按「只看自己的」看的人 + 照样看得见改过的那一行,别人代为更正也不会让它从原提交人眼前消失。动手更正的是 actor,交回在 corrected_by 里, + 由调用方记进动作记录和审计(records/actions.py)。""" if not action_key: raise RecordsRefused("records.action_key_missing", "写记录表需要动作键,只能经调用工具节点写入") if not isinstance(values, dict): @@ -311,24 +321,28 @@ def append(path: Path, columns: list[Column], values: Any, *, version: int, run_ conn = _rw(path) try: conn.execute("BEGIN IMMEDIATE") - done = conn.execute(f"SELECT _seq, _id, _submitted_at FROM {_q(TABLE)} WHERE {ACTION_KEY_COLUMN} = ?", - (action_key,)).fetchone() + done = conn.execute(f"SELECT _seq, _id, _submitted_at, _submitted_by, _supersedes FROM {_q(TABLE)} " + f"WHERE {ACTION_KEY_COLUMN} = ?", (action_key,)).fetchone() if done is not None: conn.rollback() return Appended(seq=int(done[0]), row_id=str(done[1]), submitted_at=str(done[2]), duplicate=True, - values=_row_values(path, columns, int(done[0]))) + values=_row_values(path, columns, int(done[0])), submitted_by=done[3], + corrected_by=actor if done[4] else None) + submitted_by = actor if supersedes: - target = conn.execute(f"SELECT _seq FROM {_q(TABLE)} WHERE _id = ?", (supersedes,)).fetchone() + target = conn.execute(f"SELECT _seq, _submitted_by FROM {_q(TABLE)} WHERE _id = ?", + (supersedes,)).fetchone() if target is None: raise RecordsRefused("records.supersede_missing", "要更正的那一行不在这张记录表里") later = conn.execute(f"SELECT 1 FROM {_q(TABLE)} WHERE _supersedes = ? LIMIT 1", (supersedes,)).fetchone() if later is not None: raise RecordsRefused("records.supersede_twice", "这一行已经被更正过了,请在最新的那一行上更正") + submitted_by = target[1] row_id, at = new_id(), _now() names = ["_id", "_submitted_by", "_submitted_at", "_run_id", "_supersedes", "_schema_version", ACTION_KEY_COLUMN, *row] - params = [row_id, actor, at, run_id, supersedes or None, version, action_key, *row.values()] + params = [row_id, submitted_by, at, run_id, supersedes or None, version, action_key, *row.values()] cur = conn.execute(f"INSERT INTO {_q(TABLE)} ({', '.join(_q(n) for n in names)}) " f"VALUES ({', '.join('?' for _ in names)})", params) seq = int(cur.lastrowid or 0) @@ -338,7 +352,8 @@ def append(path: Path, columns: list[Column], values: Any, *, version: int, run_ raise finally: conn.close() - return Appended(seq=seq, row_id=row_id, submitted_at=at, duplicate=False, values=row) + return Appended(seq=seq, row_id=row_id, submitted_at=at, duplicate=False, values=row, submitted_by=submitted_by, + corrected_by=actor if supersedes else None) def _row_values(path: Path, columns: list[Column], seq: int) -> dict[str, Any]: @@ -380,16 +395,32 @@ def lookup(path: Path, columns: list[Column], action_key: str) -> dict[str, Any] return {"_seq": int(got[0]), "_id": str(got[1]), "_submitted_at": str(got[2]), "duplicate": True} +def union_compatible(new: Column, old: Column) -> bool: + """跨版本汇总时,旧版本的这一列能不能并进新版本的同名列:类型一样,或者整数和数之间(都是数)。别的类型改动(数改成 + 文字、文字改成是或否……)意思变了,旧版本那一列补空,不把两种东西混在一列里算。""" + return new.type == old.type or {new.type, old.type} <= {"number", "integer"} + + def view_select(columns: list[Column], *, high_water: int | None, scope: str, viewer: str | None, - schema: str = "main") -> str: + schema: str = "main", present: list[Column] | None = None) -> str: """临时视图(和看板、记录列表)看到的那一份: - _seq ≤ 高水位(None 为不限,现在有的都算); - 没被更正过:在高水位之内没有一行指向它(_supersedes)。按高水位判,不靠可改的标记——原行永远不改; - 行级过滤:scope=all 全部,mine 只看 _submitted_by = viewer 的,none 一行都不给。 - 不露 _action_key。列名照 SYSTEM_COLUMNS + 业务列的顺序。""" - cols = ", ".join(f"r.{_q(n)}" for n in (*SYSTEM_COLUMNS, *(c.name for c in columns))) + 不露 _action_key。列名照 SYSTEM_COLUMNS + 业务列的顺序。present 是 schema 那个文件里实际有的业务列(跨版本汇总时 + 旧版本的列):columns 里它没有的、类型对不上的(union_compatible)补 NULL;None 为 columns 都在。""" + have = None if present is None else {c.name.lower(): c for c in present} + + def pick(c: Column) -> str: + if have is None: + return f"r.{_q(c.name)}" + old = have.get(c.name.lower()) + return f"r.{_q(old.name)} AS {_q(c.name)}" if old is not None and union_compatible(c, old) \ + else f"NULL AS {_q(c.name)}" + + cols = ", ".join([*(f"r.{_q(n)}" for n in SYSTEM_COLUMNS), *(pick(c) for c in columns)]) src = f"{_q(schema)}.{_q(TABLE)}" where = [] if high_water is not None: @@ -406,12 +437,28 @@ def view_select(columns: list[Column], *, high_water: int | None, scope: str, vi return f"SELECT {cols} FROM {src} AS r WHERE {' AND '.join(where)}" -def view_prelude(columns: list[Column], *, high_water: int | None, scope: str, viewer: str | None) -> tuple[str, ...]: +@dataclass(frozen=True) +class OlderVersion: + """跨版本汇总时并进来的一个旧表版本:挂在查询连接上的库名(ATTACH … AS schema)、那一版的业务列、这次查询的高水位 + (None 为不限)。""" + + schema: str + columns: list[Column] + high_water: int | None + + +def view_prelude(columns: list[Column], *, high_water: int | None, scope: str, viewer: str | None, + older: tuple[OlderVersion, ...] | list[OlderVersion] = ()) -> tuple[str, ...]: """查询连接上建同名临时视图的两条语句。SQLite 解析不带库名的表名时先找 temp 库,于是 FROM records 查到的就是 - 这份视图;直接写 main.records 的 SQL 由只读守卫拒掉(data/engine.run_query 的 forbid_schemas)。""" + 这份视图;直接写 main.records 的 SQL 由只读守卫拒掉(data/engine.run_query 的 forbid_schemas)。 + + older 是这张表比这一版老的各版本(只读挂在连接上,records/views.py):视图按 UNION ALL 把它们接在这一版后面, + 列照这一版的来,旧版本没有的列补 NULL;每一版各按自己的高水位、自己文件里的更正判,行级过滤同一个。""" + parts = [view_select(columns, high_water=high_water, scope=scope, viewer=viewer)] + parts += [view_select(columns, high_water=o.high_water, scope=scope, viewer=viewer, schema=o.schema, + present=o.columns) for o in older] return (f"DROP VIEW IF EXISTS temp.{_q(TABLE)}", - f"CREATE TEMP VIEW {_q(TABLE)} AS " - + view_select(columns, high_water=high_water, scope=scope, viewer=viewer)) + f"CREATE TEMP VIEW {_q(TABLE)} AS " + " UNION ALL ".join(parts)) def rows(path: Path, columns: list[Column], *, scope: str, viewer: str | None, history: bool = False, diff --git a/backend/app/records/tables.py b/backend/app/records/tables.py index 4c8b29ba..878c7896 100644 --- a/backend/app/records/tables.py +++ b/backend/app/records/tables.py @@ -4,7 +4,8 @@ **表版本。** 「填什么」卡片变了时按列的变化分档(column_changes): - 只加可选字段、改中文名、改敏感标记:兼容,在同一个版本上 ADD COLUMN、更新登记; - 改类型、删字段、加必填字段、可选改必填:不兼容,出新版本(新文件、新数据源、records_tables 再登记一行), - 旧版本标 retired_at、只读保留——旧记录照原样留着,照样查得到,只是不再往里写。 + 旧版本标 retired_at、只读保留——旧记录照原样留着,照样查得到(新一版的查询按 UNION ALL 并进来,records/views.py), + 只是不再往里写:停用之前发起、还在半路的运行照常写完(records/actions.py),之后发起的拒。 不兼容的判定沿用按期累积的 INCOMPATIBLE_KINDS(data/recipe_accumulate.py,列类型变了同样是不兼容),再加上记录表 特有的三种:删字段、加必填字段、可选改必填(旧行在那一列上是空的)。 @@ -38,13 +39,14 @@ RECORDS_INCOMPATIBLE = (*INCOMPATIBLE_KINDS, "column_removed", "required_added", SOURCE_PREFIX = "rec_" #: 系统列在表结构里的说明(工具描述、db_schema、数据目录起草看得到) SYSTEM_NOTES = { - "_seq": "序号:只增不减,一次运行只看得到开始时已有的序号以内的记录", + "_seq": "序号:同一版内只增不减,一次运行只看得到开始时已有的序号以内的记录。表单改过、出了新版本时,旧版本的记录" + "一并列在这张表里,各版本各自编号,按 _schema_version 分开", "_id": "这一行的编号", "_submitted_by": "提交人", "_submitted_at": "提交时间(UTC)", "_run_id": "写入这一行的运行", "_supersedes": "更正的是哪一行(那一行的编号);更正过的行不再出现在查询里", - "_schema_version": "写入时的表版本", + "_schema_version": "写入时的表版本(并进来的旧版本的记录是旧的版本号)", } _SQL_TYPE_LABEL = {"string": "TEXT", "number": "REAL", "integer": "INTEGER", "boolean": "INTEGER", "array": "TEXT", "file": "TEXT"} @@ -171,7 +173,8 @@ def schema_cache_of(columns: list[Column], label: str) -> dict[str, Any]: def _description(label: str, version: int) -> str: return (f"记录表「{label}」{'' if version == 1 else f'(第 {version} 版)'}:平台托管、只许追加的填报记录,表名 records。" - "一次运行只看得到开始时已有的记录;更正过的行只留最新的一行") + "一次运行只看得到开始时已有的记录;更正过的行只留最新的一行" + + ("" if version == 1 else ";前几版的记录一并列在里面,旧版本没有的字段为空")) async def _free_name(session: AsyncSession, base: str) -> str: @@ -402,8 +405,10 @@ def path_of(row: RecordsTable) -> Any: return store.table_path(row.table_key, row.version) -def refuse_retired(row: RecordsTable) -> None: - if row.retired_at is not None: +def refuse_retired(row: RecordsTable, *, launched_at: datetime | None = None) -> None: + """往停用的版本写:拒。launched_at 是要写的那次运行发起的时刻——停用之前发起的照常写完(同进行中的实例照常走完, + 写进它发起时图里写的那一版);停用之后发起的、不在运行里的一律拒。""" + if row.retired_at is not None and (launched_at is None or launched_at >= row.retired_at): raise RecordsRefused("records.table_retired", f"记录表「{row.name}」的第 {row.version} 版已经停用(表单改过,出了新版本),旧记录只读保留。" "请重新发布应用,让它写到新版本") diff --git a/backend/app/records/views.py b/backend/app/records/views.py index 66fd9b7b..33460c5f 100644 --- a/backend/app/records/views.py +++ b/backend/app/records/views.py @@ -7,11 +7,19 @@ origin=records 的数据源查询时换成一个 RecordsView(字段和 DataSou 高水位来自运行的 data_versions(发起时记下,engine/runner._data_versions),不在运行里(工具库试用、数据剖析)的查询 用现在的数据、看全部(这些入口只有搭建者以上进得来)。 + +**跨版本汇总。** 表单不兼容地改过之后,看板查的是新一版的数据源,旧记录在比它老的版本文件里。查询时把这张表比这一版老的 +各版本只读挂到连接上(attach:ATTACH 'file:…?mode=ro' AS records_v<版本>,data/engine.run_query 执行),临时视图按 +UNION ALL 接上,列照这一版的来、旧版本没有的(或类型改得对不上的)补 NULL(records/store.view_prelude)。各版本各有高水位: +发起时一并记进 data_versions 那一项的 older(停用之前发起的运行还能往停用的版本写完,records/actions.py,所以停用的版本 +同样要按高水位固定)。挂上去的库名同样进 forbid_schemas:records_v1.records 和 main.records 一样绕过视图,一律拒。 +用户 SQL 里写 ATTACH 照旧被只读守卫拒(data/guard.py),挂库只有平台自己做。 """ from __future__ import annotations import asyncio import copy +import logging from dataclasses import dataclass, field from datetime import datetime from typing import Any @@ -20,8 +28,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.records import RECORDS_ORIGIN, store -#: data_versions 里记录表那一项的形状:{"name", "origin": "records", "table", "version", "seq", "rows", "viewer"?} -PIN_KEYS = ("name", "origin", "table", "version", "seq", "rows", "viewer") +logger = logging.getLogger(__name__) + +#: data_versions 里记录表那一项的形状:{"name", "origin": "records", "table", "version", "seq", "older", "rows", +#: "viewer"?}。older 是比这一版老的各版本的高水位 [{"version", "seq"}](跨版本汇总);数据文件不在了的那一版记成 +#: {"version", "seq": None, "missing": True}——汇总里没有它,印章(apps/resolve.stamp_of)、查询快照照实说 +MISSING = "missing" +PIN_KEYS = ("name", "origin", "table", "version", "seq", "older", "rows", "viewer") +#: 旧版本挂在查询连接上的库名:records_v<版本> +ATTACH_PREFIX = "records_v" @dataclass @@ -57,20 +72,46 @@ class RecordsView: viewer: str | None = None #: 连接上先执行的语句(建同名临时视图) prelude: tuple[str, ...] = () - #: SQL 里不许直接写的库名 + #: 先只读挂到连接上的旧版本:((库名, 文件路径), …),data/engine.run_query 在 prelude 之前挂好 + attach: tuple[tuple[str, str], ...] = () + #: SQL 里不许直接写的库名(main 和挂上去的各旧版本) forbid_schemas: tuple[str, ...] = ("main",) + #: 并进来的旧版本和各自的高水位 [{"version", "seq"}],数据文件不在了、没并进来的记 {"version", "seq": None, + #: "missing": True}(查询快照里记下,照实说这次汇总含哪几版、缺哪几版) + older: tuple[dict[str, Any], ...] = () + + +@dataclass(frozen=True) +class Older: + """并进汇总的一个旧版本:版本号、文件、业务列、这次查询的高水位(None 为不限)。""" + + version: int + path: str + columns: list[store.Column] + high_water: int | None + + @property + def schema(self) -> str: + return f"{ATTACH_PREFIX}{int(self.version)}" def bind_view(source: Any, columns: list[store.Column], *, high_water: int | None, rows: str = "all", - viewer: str | None = None) -> RecordsView: - """数据源行 + 高水位 + 行级过滤 → RecordsView。""" + viewer: str | None = None, older: list[Older] | tuple[Older, ...] = (), + missing: list[int] | tuple[int, ...] = ()) -> RecordsView: + """数据源行 + 高水位 + 行级过滤(+ 并进来的旧版本;missing 是数据文件不在了、并不进来的旧版本号)→ RecordsView。""" rows = rows if rows in ("all", "mine", "none") else "none" + older = tuple(older) return RecordsView( id=source.id, name=source.name, database=source.database, options=dict(source.options or {}), description=source.description or "", schema_cache=copy.deepcopy(source.schema_cache or {}), schema_synced_at=getattr(source, "schema_synced_at", None), enabled=bool(source.enabled), last_check=getattr(source, "last_check", None), high_water=high_water, rows=rows, viewer=viewer, - prelude=store.view_prelude(columns, high_water=high_water, scope=rows, viewer=viewer)) + prelude=store.view_prelude(columns, high_water=high_water, scope=rows, viewer=viewer, + older=[store.OlderVersion(o.schema, o.columns, o.high_water) for o in older]), + attach=tuple((o.schema, o.path) for o in older), + forbid_schemas=("main", *(o.schema for o in older)), + older=(*({"version": o.version, "seq": o.high_water} for o in older), + *({"version": int(v), "seq": None, MISSING: True} for v in missing))) def columns_of_source(source: Any) -> list[store.Column]: @@ -87,17 +128,44 @@ def _type_of(sql_type: str) -> str: return {"REAL": "number", "INTEGER": "integer"}.get(sql_type.upper(), "string") +async def _older_versions(session: AsyncSession, table: Any) -> list[Any]: + """这张表比这一版老的各版本(新的在前):停用了的旧版本,以及起草出的新一版还没发布时线上在写的那一版。""" + from app.records.tables import versions_of + + if table is None: + return [] + return [row for row in await versions_of(session, table.table_key) if row.version < table.version] + + async def resolve(session: AsyncSession, source: Any, entry: Any = None) -> RecordsView: - """查询时实际该用的视图。entry 是这次运行 data_versions 里这张表的那一项;None 是不在运行里(现在的数据、看全部)。""" - from app.records.tables import columns_of, table_of_source + """查询时实际该用的视图。entry 是这次运行 data_versions 里这张表的那一项;None 是不在运行里(现在的数据、看全部)。 + + 比这一版老的各版本一起并进来(跨版本汇总):在运行里的,只并发起时固定了高水位的那几版(entry 的 older),各按自己的 + 高水位;不在运行里的,各版本现在的数据都算。""" + from app.records.tables import columns_of, path_of, table_of_source table = await table_of_source(session, source.id) columns = columns_of(table) if table is not None else columns_of_source(source) + older_rows = await _older_versions(session, table) if isinstance(entry, dict): seq = entry.get("seq") + marked = [o for o in entry.get("older") or [] if isinstance(o, dict) and isinstance(o.get("version"), int)] + # 发起时文件就不在的那几版:没有高水位,不挂;照原样带着,快照里照实说汇总缺了它们 + missing = [int(o["version"]) for o in marked if o.get(MISSING)] + pinned = {int(o["version"]): o.get("seq") for o in marked if not o.get(MISSING)} + older = [Older(row.version, str(path_of(row)), columns_of(row), + int(pinned[row.version]) if isinstance(pinned[row.version], int) else 0) + for row in older_rows if row.version in pinned] return bind_view(source, columns, high_water=int(seq) if isinstance(seq, int) else 0, - rows=str(entry.get("rows") or "none"), viewer=entry.get("viewer")) - return bind_view(source, columns, high_water=None, rows="all") + rows=str(entry.get("rows") or "none"), viewer=entry.get("viewer"), older=older, + missing=missing) + older = [Older(row.version, str(path_of(row)), columns_of(row), None) for row in older_rows + if path_of(row).is_file()] + missing = [row.version for row in older_rows if not path_of(row).is_file()] + if missing: + logger.warning("记录表 %s 第 %s 版的数据文件不在了,跨版本汇总里不含这几版", table.table_key, + "、".join(str(v) for v in missing)) + return bind_view(source, columns, high_water=None, rows="all", older=older, missing=missing) def default_view(source: Any) -> RecordsView: @@ -120,8 +188,17 @@ async def pin_entry(session: AsyncSession, source: Any, *, run_class: str | None seq = await asyncio.to_thread(store.high_water, path_of(table)) except store.RecordsRefused: return None + older: list[dict[str, Any]] = [] + for row in await _older_versions(session, table): + try: + older.append({"version": row.version, "seq": await asyncio.to_thread(store.high_water, path_of(row))}) + except store.RecordsRefused: + # 旧版本的文件不在了(管理员手工删了文件才会这样):汇总里没有它。记进这一项(印章、查询快照照实说缺了哪一版, + # 看的人知道数不全),另记一条日志 + older.append({"version": row.version, "seq": None, MISSING: True}) + logger.warning("记录表 %s 第 %s 版的数据文件不在了,跨版本汇总里不含这一版", table.table_key, row.version) entry: dict[str, Any] = {"name": source.name, "origin": RECORDS_ORIGIN, "table": table.table_key, - "version": table.version, "seq": seq} + "version": table.version, "seq": seq, "older": older} if run_class is not None: entry.update(await scope_of_run(session, table.app_id, run_class=run_class, started_by=started_by)) return entry @@ -141,5 +218,14 @@ async def scope_entries(session: AsyncSession, versions: dict[str, Any] | None, started_by=started_by)) +def cache_token(entry: Any) -> Any: + """按需视图缓存键里记录表那一项(apps/resolve.cache_key):这一版的高水位,并进来的旧版本有的话连同它们的高水位—— + 停用之前发起的运行还能往旧版本写(records/actions.py),旧版本多了一行,汇总就变了。""" + if not isinstance(entry, dict): + return None + older = [[o.get("version"), o.get("seq")] for o in entry.get("older") or [] if isinstance(o, dict)] + return [entry.get("seq"), older] if older else entry.get("seq") + + def is_records(source: Any) -> bool: return (getattr(source, "origin", None) or "manual") == RECORDS_ORIGIN diff --git a/backend/app/tools/builtin/knowledge.py b/backend/app/tools/builtin/knowledge.py index fd8688ab..fa63991c 100644 --- a/backend/app/tools/builtin/knowledge.py +++ b/backend/app/tools/builtin/knowledge.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, Field from app.db.base import SessionLocal from app.memory import kb, store -from app.tools.registry import ToolContext, register +from app.tools.registry import ToolArgsError, ToolContext, register class KbSearchArgs(BaseModel): @@ -23,6 +23,11 @@ class KbSearchArgs(BaseModel): ) async def kb_search(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]: args = KbSearchArgs(**kwargs) + if ctx.fixed_scope and args.collection and args.collection != ctx.collection: + # 遮罩、只读的运行(使用者的问数据、个人应用、个人蓝图的试运行):知识库的内容不经遮罩,只能检索运行定下的那一个。 + # 拒而不是悄悄改用默认的:检索的不是参数里写的那个库,结果却照常交回,看的人会当成查过了那一个 + raise ToolArgsError(f"这次运行只能检索管理员设好的知识库,不能另指定知识库「{args.collection}」:" + "去掉 collection 参数即可") async with SessionLocal() as session: hits = await kb.search( session, diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 519d9bba..53e11ecf 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -120,6 +120,13 @@ def run_reads_only(run_ctx: Any) -> bool: return _run_option(run_ctx, READ_ONLY_OPTION) +def run_scope_fixed(run_ctx: Any) -> bool: + """这次运行的记忆范围、知识库是不是由运行定、节点配置和工具参数都改不了(ToolContext.fixed_scope):查询结果要遮、 + 或者一律只读的运行(使用者的问数据、个人应用的刷新、个人蓝图的试运行,auth/chat_policy.py)。记忆、知识库的内容不经 + 遮罩,对这类运行来说换个范围就绕过了遮罩。""" + return run_masks_results(run_ctx) or run_reads_only(run_ctx) + + def run_versions(run_ctx: Any) -> dict[str, Any] | Versions | None: """一次运行固定的数据版本(runner 放在 RunContext.extra 里)。构造 ToolContext 时传进去。 @@ -677,7 +684,9 @@ def _make_query_tool(source: Any, ctx: ToolContext, *, fixed: bool = False, # id,出处链按它找快照文件 if getattr(source, "origin", None) == "records" and getattr(source, "prelude", None): extra["records_view"] = {"high_water": getattr(source, "high_water", None), - "rows": getattr(source, "rows", None)} + "rows": getattr(source, "rows", None), + # 跨版本汇总并进来的旧版本和各自的高水位(records/views.py) + **({"older": list(source.older)} if getattr(source, "older", None) else {})} # 工件引用的 meta 记下源和 SQL 里用到的表:数据目录按它数每张表被运行查询过几次 # (catalog.table_usage),不用再逐个读工件内容。meta 不进内容哈希,快照本身一字不变 payload["artifact"] = await put_json( diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 59e66d80..c1215267 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -45,6 +45,10 @@ class ToolContext: #: 数据源查询一律只读,可写源上的写语句也拒(tools/datasource.py 的 run_reads_only):同样是 user 角色的问数据运行。 #: 不能交给审批去拦——Agent 的 approval 是发起人自己写在图里的 force_readonly: bool = False + #: 记忆范围、知识库由运行定,工具参数改不了(tools/datasource.py 的 run_scope_fixed:遮罩、只读的运行——使用者的问数据、 + #: 个人应用的刷新、个人蓝图的试运行)。记忆、知识库的内容不经遮罩:kb_search 的 collection 参数(调用工具节点写的、 + #: 参数名纠错改出来的、图级 defaults 补上的、模型挑的)一律拒,只能检索 collection 这一个 + fixed_scope: bool = False extra: dict[str, Any] = field(default_factory=dict) diff --git a/backend/tests/test_app_use.py b/backend/tests/test_app_use.py index 24a4f083..2a765519 100644 --- a/backend/tests/test_app_use.py +++ b/backend/tests/test_app_use.py @@ -271,6 +271,28 @@ async def test_on_demand_views(client): await session.commit() +async def test_the_detail_says_whether_the_draft_differs_from_the_published_version(client): + """AppDetail.draft_changed(N6):按发布时的规整方式比草稿和当前发布版——刚发布完一致;改了页面的字、改了岗位绑定都算 + 改过;再发布又一致。只读草稿规整的那一份不算改过(钉住项的级别是发布时按库里补的);没发布过的不给。""" + wf, hash_ = await workflow(KPI) + spec = kpi_spec(wf, hash_) + app_id = await published_app(client, spec, name="按需看板") + detail = (await client.get(f"/api/apps/{app_id}")).json() + assert detail["draft_changed"] is False, "刚发布完:草稿就是发布版" + + renamed = {**spec, "pages": [{**spec["pages"][0], "title": "周报首页"}]} + detail = (await client.patch(f"/api/apps/{app_id}", json={"draft": renamed})).json() + assert detail["draft_changed"] is True + assert (await client.post(f"/api/apps/{app_id}/publish", json={})).json()["ok"] + assert (await client.get(f"/api/apps/{app_id}")).json()["draft_changed"] is False + + bound = {"slots": {"finance": [{"kind": "user", "name": "钱七"}]}} + assert (await client.patch(f"/api/apps/{app_id}", json={"draft_bindings": bound})).json()["draft_changed"] is True + + fresh = (await client.post("/api/apps", json={"name": "没发布过", "spec": spec})).json() + assert "draft_changed" not in (await client.get(f"/api/apps/{fresh['id']}")).json(), "没发布过的不比" + + async def test_auto_views_launch_when_the_page_opens(client): wf, hash_ = await workflow(KPI) app_id = await published_app(client, kpi_spec(wf, hash_, auto=True), name="自动看板") diff --git a/backend/tests/test_blueprint_generation.py b/backend/tests/test_blueprint_generation.py index 55ee30d3..b0fb23c1 100644 --- a/backend/tests/test_blueprint_generation.py +++ b/backend/tests/test_blueprint_generation.py @@ -20,12 +20,13 @@ import pytest from app.blueprints.generation import BlueprintApplier, BlueprintChecker, TemplateApplier, TemplateChecker from app.blueprints.instantiate import generated_groups, instantiate from app.blueprints.templates import _common as c +from app.blueprints.templates import get_template from app.db.base import SessionLocal import app.db.models # noqa: F401 - 建表前要先登记全部模型(conftest 按 Base.metadata 建) def _board(): - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") inst = instantiate(extras.example()) group = generated_groups(inst)["fetch_shop"] return extras, inst, group @@ -143,7 +144,7 @@ async def test_apply_ops_and_save_writes_events_and_marks_stale_joints(): from app.blueprints.ops import BpRefused from app.db.models_blueprints import Blueprint, BlueprintEvent - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") spec = extras.example() inst = instantiate(spec, filled=extras.example_fill()) bp_id = await _blueprint(spec, template_id="periodic_board", template_version=1, joints=inst.joints) diff --git a/backend/tests/test_blueprint_starts.py b/backend/tests/test_blueprint_starts.py index 2ce0994f..e071f3ce 100644 --- a/backend/tests/test_blueprint_starts.py +++ b/backend/tests/test_blueprint_starts.py @@ -204,7 +204,8 @@ async def test_who_can_start_what(login): listed = {b["id"] for b in (await builder.get("/api/blueprints")).json()["items"]} assert r.json()["id"] in listed and mine["id"] not in listed # 使用者看不到共享蓝图 - assert (await user.get(f"/api/blueprints/{r.json()['id']}")).status_code == 403 + # 使用者碰共享蓝图回 404,和看不到的一样,试不出它在不在(D5) + assert (await user.get(f"/api/blueprints/{r.json()['id']}")).status_code == 404 async def test_patch_applies_card_ops_as_the_user(client): diff --git a/backend/tests/test_blueprint_template_run.py b/backend/tests/test_blueprint_template_run.py index ccd2a6ad..30ee2467 100644 --- a/backend/tests/test_blueprint_template_run.py +++ b/backend/tests/test_blueprint_template_run.py @@ -10,7 +10,7 @@ import uuid import pytest from app.blueprints.instantiate import instantiate -from app.blueprints.templates import _common as c +from app.blueprints.templates import get_template from app.data.engine import engines from app.db.base import SessionLocal from app.db.models import DataSource, Run @@ -48,7 +48,7 @@ async def shop(tmp_path): async def test_the_board_template_runs_and_issues_by_metrics(shop): from app.blueprints.templates._common import safe_id - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") spec = extras.example() for item in spec["data"]: item["table"]["value"]["source"] = shop diff --git a/backend/tests/test_blueprint_templates.py b/backend/tests/test_blueprint_templates.py index 5fb69a71..2f054fdd 100644 --- a/backend/tests/test_blueprint_templates.py +++ b/backend/tests/test_blueprint_templates.py @@ -14,14 +14,14 @@ import copy import pytest from app.blueprints.instantiate import filled_from, generated_groups, instantiate, registry_problems, skeleton_changes -from app.blueprints.templates import GENERATED_JOINT_KINDS, TEMPLATE_IDS, get_template +from app.blueprints.templates import GENERATED_JOINT_KINDS, TEMPLATE_IDS, check_registry, get_template from app.blueprints.templates import _common as c from app.engine.governance import publish_issues from app.engine.schema import GraphSpec, validate_graph def _graph_for(template_id: str): - if c.EXTRAS[template_id].imports_graph: + if get_template(template_id).imports_graph: from app.blueprints.templates.freeform import example_graph return example_graph() @@ -30,16 +30,31 @@ def _graph_for(template_id: str): def test_registry_is_complete(): assert registry_problems() == [] + assert check_registry() == [], "启动核对(check_registry)走的就是 registry_problems 这一份" assert all(get_template(t) is not None for t in TEMPLATE_IDS) for t in TEMPLATE_IDS: template = get_template(t) assert template.applies_when and template.cards and template.skeleton assert all(note.startswith(("必填", "可选")) for note in template.cards.values()), t + # 示例蓝图、预置、每组关节的说明、模板自己认的问题都登记在 TemplateDef 上,没有旁路登记 + assert callable(template.example) and callable(template.example_fill), t + assert template.imports_graph == (t == "freeform") + assert not hasattr(c, "EXTRAS") + + +def test_a_template_without_an_example_is_reported(monkeypatch): + """模板定义、实例化、测试三样缺一样启动即报错:少了示例蓝图的模板,check_registry 照实报出来。""" + import dataclasses + + from app.blueprints.templates import _REGISTRY + + monkeypatch.setitem(_REGISTRY, "approval", dataclasses.replace(get_template("approval"), example=None)) + assert check_registry() == ["模板「approval」没有示例蓝图"] @pytest.mark.parametrize("template_id", TEMPLATE_IDS) def test_every_template_instantiates_validates_and_publishes(template_id): - extras = c.EXTRAS[template_id] + extras = get_template(template_id) example = extras.example() graph = _graph_for(template_id) @@ -71,7 +86,7 @@ def test_every_template_instantiates_validates_and_publishes(template_id): def test_board_details(): - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") inst = instantiate(extras.example(), filled=extras.example_fill(), schedule_id="sch_1", blueprint_id="bp_1") nodes = {n["id"]: n for n in inst.graph["nodes"]} assert nodes["fetch_shop"]["data"]["config"]["tool"] == "db_query__shop" @@ -95,7 +110,7 @@ def test_board_details(): def test_board_with_reused_caliber_and_two_sources(): - spec = c.EXTRAS["periodic_board"].example() + spec = get_template("periodic_board").example() spec["data"].append({"id": "visits", "label": {"value": "访客数", "source": "user", "status": "confirmed", "why": None}, "unit": {"value": "人", "source": "user", "status": "confirmed", "why": None}, "table": {"value": {"source": "crm", "table": "visits"}, "source": "user", @@ -125,7 +140,7 @@ def test_board_with_reused_caliber_and_two_sources(): def test_approval_details(): - inst = instantiate(c.EXTRAS["approval"].example()) + inst = instantiate(get_template("approval").example()) nodes = {n["id"]: n for n in inst.graph["nodes"]} finance = nodes["review_finance"]["data"]["config"] assert finance["assignee"] == {"kind": "slot", "slot": "finance"} and finance["policy"] == "all" @@ -143,7 +158,7 @@ def test_approval_details(): assert inst.app_spec["entries"][0]["map"] == {"amount": "form.amount", "reason": "form.reason"} assert inst.schedule is None # 确定性关节按卡片换:改了金额线,分支跟着变;骨架比对只许关节位置不同 - other = c.EXTRAS["approval"].example() + other = get_template("approval").example() other["people"][1]["threshold"]["value"]["value"] = 8000 changed = instantiate(other) assert {n["id"]: n for n in changed.graph["nodes"]}["gate_finance"]["data"]["config"]["cases"][0]["condition"] \ @@ -151,7 +166,7 @@ def test_approval_details(): def test_approval_writeback_confirms_with_last_step(monkeypatch): - spec = c.EXTRAS["approval"].example() + spec = get_template("approval").example() item = c.template_item spec["writeback"] = {"capability": item("erp_reimburse"), "mapping": item({"amount": "form.amount", "kind": "'报销'"}), "confirm": item(True)} @@ -170,7 +185,7 @@ def test_approval_writeback_confirms_with_last_step(monkeypatch): def test_records_routes_submit_and_summary(): from app.records import RECORDS_APPEND - extras = c.EXTRAS["records"] + extras = get_template("records") inst = instantiate(extras.example(), records={"table": "loss", "source": "records_loss"}, blueprint_id="bp_9") nodes = {n["id"]: n for n in inst.graph["nodes"]} append = nodes["append"]["data"]["config"] @@ -190,18 +205,18 @@ def test_records_routes_submit_and_summary(): def test_exception_and_reconcile_details(): - inst = instantiate(c.EXTRAS["exception_handling"].example()) + inst = instantiate(get_template("exception_handling").example()) nodes = {n["id"]: n for n in inst.graph["nodes"]} assert nodes["handle"]["data"]["config"]["mode"] == "input" assert nodes["handle"]["data"]["config"]["assignee"] == {"kind": "slot", "slot": "owner"} assert nodes["review"]["data"]["config"]["assignee"] == {"kind": "slot", "slot": "区域经理"} assert nodes["gate"]["data"]["config"]["cases"][0]["condition"] == "nodes.parse.row_count > 0" # 只看清单(个人应用能用的那一部分):没有人工节点 - spec = c.EXTRAS["exception_handling"].example() + spec = get_template("exception_handling").example() spec["people"] = [] assert not [n for n in instantiate(spec).graph["nodes"] if n["type"] == "human"] - inst = instantiate(c.EXTRAS["reconcile_writeback"].example()) + inst = instantiate(get_template("reconcile_writeback").example()) write = {n["id"]: n for n in inst.graph["nodes"]}["writeback"]["data"]["config"] assert write["args"] == {"items": "{{ nodes.parse.rows | compact }}", "total": "{{ nodes.caliber.metrics[1].value }}"} assert write["assignee"] == {"kind": "slot", "slot": "check"} @@ -210,7 +225,7 @@ def test_exception_and_reconcile_details(): def test_skeleton_changes_catch_anything_but_joints(): - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") inst = instantiate(extras.example(), filled=extras.example_fill()) joints = inst.joint_defs graph = copy.deepcopy(inst.graph) @@ -228,7 +243,7 @@ def test_skeleton_changes_catch_anything_but_joints(): def test_filled_values_go_stale_when_the_cards_change(): - extras = c.EXTRAS["periodic_board"] + extras = get_template("periodic_board") spec = extras.example() inst = instantiate(spec, filled=extras.example_fill()) joints = inst.joints @@ -247,6 +262,6 @@ def test_trial_inputs_follow_the_cadence(): from app.blueprints.instantiate import trial_inputs - inputs = trial_inputs(c.EXTRAS["periodic_board"].example(), at=datetime(2026, 10, 15, 3, tzinfo=timezone.utc)) + inputs = trial_inputs(get_template("periodic_board").example(), at=datetime(2026, 10, 15, 3, tzinfo=timezone.utc)) assert inputs == {"period": "2026-W41", "period_start": "2026-10-05", "period_end": "2026-10-11"} - assert trial_inputs(c.EXTRAS["approval"].example()) == {} + assert trial_inputs(get_template("approval").example()) == {} diff --git a/backend/tests/test_blueprint_trial.py b/backend/tests/test_blueprint_trial.py index 1b08217a..d5ad468b 100644 --- a/backend/tests/test_blueprint_trial.py +++ b/backend/tests/test_blueprint_trial.py @@ -78,6 +78,7 @@ async def test_trial_runs_the_unpublished_graph_and_previews_with_a_trial_stamp( # 自由形导入的图原样跑(哈希和导入的那一版一样),名字记蓝图名 assert graph_hash(run.graph) == bp["graph_hash"] and run.workflow_name == bp["name"] assert run.input["period_start"] == s.PERIOD["period_start"] + assert r.json()["inputs"] == run.input, "回包里是这次实际用的输入" r = await client.get(f"/api/blueprints/{bp['id']}/preview") assert r.status_code == 200, r.text @@ -171,6 +172,8 @@ async def test_process_trial_assigns_every_step_to_the_trial_runner_and_never_es assert form["trial"] is True and form["handler"] is True and form["task"]["id"] == task.id timeline = find(vm, "instance_timeline")[0]["data"] assert timeline["state"] == "ok" and timeline["status_text"] == "等待审批" + # 「换一期再试」的表单随预览给(blueprints/trial.retry_form):流程类是入口的示例表单 + assert set(vm["trial"]["form"]["properties"]) >= {"amount"}, vm["trial"]["form"] # 首页的「我发起的」列的就是这次试运行 home = (await client.get(f"/api/blueprints/{bp['id']}/preview", params={"page": "home"})).json() assert [i["run_id"] for i in find(home, "instance_list")[0]["data"]["items"]] == [run_id] @@ -253,7 +256,8 @@ async def login(): async def test_a_users_trial_follows_the_chat_rules(login, tmp_path): - """使用者试运行自己的个人蓝图:和问数据同一份约束——结果遮罩、只读、记忆是他自己的;图里有使用者不能用的节点不试。""" + """使用者试运行自己的个人蓝图:和问数据同一份约束——结果遮罩、只读、记忆是他自己的;取数的调用工具节点(查数据源) + 能试(D1),绑了使用者不能用的工具(写入能力)的不试。""" from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION await login.turn_on_local() @@ -282,7 +286,22 @@ async def test_a_users_trial_follows_the_chat_rules(login, tmp_path): source = await s.register_source(s.make_source(tmp_path)) bp = await personal(s.board_graph(source)) r = await user.post(f"/api/blueprints/{bp['id']}/trial", json={}) + assert r.status_code == 200, r.text + run = await s.wait(r.json()["run_id"]) + assert run.status == "succeeded" and run.options[READ_ONLY_OPTION] is True, run.error + + # 同一张图里取数节点换成写记录表:使用者不能用的工具,不试 + from app.db.models_blueprints import Blueprint + + writes = s.board_graph(source) + writes["nodes"][1]["data"]["config"] = {"tool": "records_append", "args": {"table": "x", "values": {}}} + async with SessionLocal() as session: + row = await session.get(Blueprint, bp["id"]) + row.graph, row.graph_hash = writes, graph_hash(writes) + await session.commit() + r = await user.post(f"/api/blueprints/{bp['id']}/trial", json={}) assert r.status_code == 403 and r.json()["code"] == "trial_policy" and "个人应用" in r.json()["detail"], r.text + assert "records_append" in r.json()["detail"] async def test_a_template_blueprint_trials_with_the_latest_period_and_keeps_its_filled_joints(client, tmp_path): @@ -327,6 +346,8 @@ async def test_a_template_blueprint_trials_with_the_latest_period_and_keeps_its_ vm = (await client.get(f"/api/blueprints/{bp['id']}/preview")).json() stamp = find(vm, "stamp")[0]["data"]["stamp"] assert stamp["from"] == TRIAL_FROM and stamp["trial"] is True + # 「换一期再试」的表单随预览给:看板类没有入口,是按条件查看的条件表单(图的输入,统计期在里面),界面照它画 + assert {"period_start", "period_end"} <= set(vm["trial"]["form"]["properties"]), vm["trial"]["form"] # 周期看板有趋势:试运行只有一个点,照实说没有趋势 trends = [n["data"] for n in walk(vm["tree"]) if (n.get("data") or {}).get("note") == TRIAL_SERIES] assert trends and len(trends[0]["series"][0]["points"]) == 1 diff --git a/backend/tests/test_contract_shapes.py b/backend/tests/test_contract_shapes.py new file mode 100644 index 00000000..e676db10 --- /dev/null +++ b/backend/tests/test_contract_shapes.py @@ -0,0 +1,97 @@ +"""普通用户建应用的后端约定(docs/design/normal-user-apps.md 第五部分):前端类型(typesApp.ts、typesBlueprint.ts)对着的 +那些常量和回包形状,在这里钉住,改了就要两边一起改。 + +- 待办种类有 review(个人应用提交审核),事件类型有 file.extracted,表单错误 code 有 file; +- 记录列表(RecordsPage)每个键都在:next_cursor 没有下一页为 null,state 是 ok / empty / preview; +- 审核待办的 payload 不论蓝图在不在,键都一样; +- 关节状态带模板声明的说法、类型、分组。 +""" +from __future__ import annotations + +import uuid +from types import SimpleNamespace + +import pytest + +import app.db.models # noqa: F401 - 建表前要先登记全部模型(conftest 按 Base.metadata 建) +import app.db.models_apps # noqa: F401 +import app.db.models_blueprints # noqa: F401 +import app.db.models_process # noqa: F401 +from app.core.config import settings +from app.db.base import SessionLocal + +RECORDS_PAGE_KEYS = {"table", "version", "columns", "items", "mine_only", "next_cursor", "state"} +REVIEW_PAYLOAD_KEYS = {"kind", "blueprint_id", "app_id", "name", "purpose", "template_id", "submitted_by", "note", + "copies"} + + +@pytest.fixture(autouse=True) +def data_dir(monkeypatch, tmp_path): + data = tmp_path / "data" + (data / "uploads").mkdir(parents=True) + monkeypatch.setattr(settings, "data_dir", data) + yield data + + +def test_constants_the_frontend_types_mirror(): + from app.apps.spec import FORM_ERROR_CODES + from app.core.events import EventType + from app.db.models_process import TASK_KINDS + from app.files.store import FILE_ERROR_CODE + from app.process.tasks import REVIEW_KIND + + assert REVIEW_KIND in TASK_KINDS + assert EventType.FILE_EXTRACTED == "file.extracted" + assert FILE_ERROR_CODE == "file" and FILE_ERROR_CODE in FORM_ERROR_CODES + + +async def test_records_page_always_has_every_key(): + from app.api.deps import Principal + from app.apps.resolve import _records_list + from app.db.models_apps import App + from app.records.listing import page + from app.records.tables import ensure_table + + async with SessionLocal() as session: + app = App(name=f"报损-{uuid.uuid4().hex[:4]}", created_by="建立人") + session.add(app) + await session.commit() + viewer = Principal(username="amy", role="admin", verified=True, mode="local") + async with SessionLocal() as session: + empty = await page(session, app, principal=viewer, role="manage") + assert set(empty) == RECORDS_PAGE_KEYS and empty["state"] == "empty" and empty["next_cursor"] is None + async with SessionLocal() as session: + await ensure_table(session, app, [{"name": "门店", "type": "string"}]) + await session.commit() + async with SessionLocal() as session: + ok = await page(session, app, principal=viewer, role="manage") + assert set(ok) == RECORDS_PAGE_KEYS and ok["state"] == "ok" and ok["items"] == [] and ok["next_cursor"] is None + preview = await _records_list(None, SimpleNamespace(draft=True), {"who": "mine"}) + assert set(preview) == RECORDS_PAGE_KEYS and preview["state"] == "preview", "草稿预览不说成「还没有记录表」" + + +async def test_review_payload_keeps_its_keys_when_the_blueprint_is_gone(): + from app.blueprints.personal import review_payload + + async with SessionLocal() as session: + got = await review_payload(session, SimpleNamespace(correlation_key="blueprint:gone", app_id="a1")) + assert set(got) == REVIEW_PAYLOAD_KEYS + assert got["kind"] == "review" and got["blueprint_id"] == "gone" and got["copies"] == [] and got["name"] is None + + +def test_joint_states_carry_the_templates_label_kind_and_group(): + from app.blueprints.instantiate import instantiate + from app.blueprints.store import blueprint_out + from app.blueprints.templates import get_template + + inst = instantiate(get_template("periodic_board").example()) + defs = {j.id: j for j in inst.joint_defs} + assert defs and all(state["kind"] == defs[jid].kind and state["group"] == defs[jid].group + and state["label"] == (defs[jid].label or jid) for jid, state in inst.joints.items()) + bp = SimpleNamespace(id="b1", name="周报", owner=None, scope="shared", status="drafting", spec={}, + template_id="periodic_board", template_version=1, source={}, conversation_id=None, + workflow_id=None, app_id=None, last_trial_run_id=None, graph_hash=None, joints=inst.joints, + copied_from=None, created_at=None, updated_at=None) + out = blueprint_out(bp, issues=False)["joints"] + assert all(set(state) <= {"status", "by", "note", "label", "kind", "group"} for state in out.values()) + assert all("value" not in state and "basis" not in state for state in out.values()), "填的值不给前端" diff --git a/backend/tests/test_copilot_blueprint_stream.py b/backend/tests/test_copilot_blueprint_stream.py index 3c44731e..9d158b97 100644 --- a/backend/tests/test_copilot_blueprint_stream.py +++ b/backend/tests/test_copilot_blueprint_stream.py @@ -227,8 +227,9 @@ async def test_personal_blueprints_need_the_switch(client, monkeypatch): def _board_spec(shop: str, crm: str) -> dict: from app.blueprints.templates import _common as c + from app.blueprints.templates import get_template - spec = c.EXTRAS["periodic_board"].example() + spec = get_template("periodic_board").example() for item in spec["data"]: item["table"]["value"] = {"source": shop, "table": "orders"} spec["data"] = spec["data"][:2] @@ -381,7 +382,7 @@ async def test_records_fill_uses_the_records_table(client, monkeypatch): 这一个表版本上(「键.v版本」)。""" import re - from app.blueprints.templates import _common as c + from app.blueprints.templates import get_template from app.db.models_blueprints import RecordsTable def route(messages, n): @@ -396,7 +397,7 @@ async def test_records_fill_uses_the_records_table(client, monkeypatch): {"op": "fill_joint", "joint": "metric_loss_count", "value": f"cell(nodes.{node}, 0, 'loss_count')"}, {"op": "done"}] - bp_id = await _blueprint(spec=c.EXTRAS["records"].example(), template_id="records", template_version=1) + bp_id = await _blueprint(spec=get_template("records").example(), template_id="records", template_version=1) try: events = await _events(client, monkeypatch, Scripted(route), "/api/copilot/fill-joints-stream", {"blueprint_id": bp_id}) diff --git a/backend/tests/test_files.py b/backend/tests/test_files.py index b4aa6e5e..861d3d8c 100644 --- a/backend/tests/test_files.py +++ b/backend/tests/test_files.py @@ -178,15 +178,23 @@ async def test_entry_binds_the_file_once_and_file_text_reads_it(client): assert text.startswith(TEXT[:12]) and f"共 {len(TEXT)} 字" in text and "只读入了前 12 字" in text async with SessionLocal() as session: events = list((await session.execute(select(RunEvent).where(RunEvent.run_id == run_id))).scalars()) - extracted = [e.data for e in events if e.type == "file.extracted" or e.data.get("code") == "file.extracted"] + extracted = [e.data for e in events if e.type == "file.extracted"] assert len(extracted) == 1 assert extracted[0]["truncated"] is True and extracted[0]["chars"] == 12 assert extracted[0]["total_chars"] == len(TEXT) and extracted[0]["artifact"] + # 可验证报告的事件名表认得它(运行时间线的说法在前端 run/decode.ts,check-decode 有一节) + from pathlib import Path + + from app import bundle as bundle_pkg + + verifier = (Path(bundle_pkg.__file__).parent / "verifier.js").read_text(encoding="utf-8") + assert "'file.extracted': '读入附件'" in verifier again = await client.post(f"/api/apps/{app_id}/entries/report", json={"values": {"doc": ref}}, headers=actor("amy")) assert again.status_code == 422 assert again.json()["errors"][0]["path"] == "doc" and "已经提交过" in again.json()["errors"][0]["message"] + assert again.json()["errors"][0]["code"] == "file" bobs = (await upload(client, app_id, "别人的.txt", "二店报损".encode(), who="bob")).json() stolen = await client.post(f"/api/apps/{app_id}/entries/report", json={"values": {"doc": bobs}}, @@ -355,6 +363,7 @@ async def test_import_period_uses_the_recipe_and_leaves_decisions_to_people(clie def test_form_file_widget_rules(): from app.apps.forms import input_schema, validate_form, validate_form_schema + from app.apps.spec import FORM_ERROR_CODES form = {"type": "object", "required": ["doc"], "properties": {"doc": FILE_FIELD}} assert validate_form_schema(form) == [] @@ -367,9 +376,10 @@ def test_form_file_widget_rules(): ref = {"file_id": "f", "name": "a.txt", "size": 3, "sha256": "0" * 64, "type": "text/plain"} assert validate_form(form, {"doc": ref}) == [] assert validate_form(form, {})[0]["code"] == "required" - assert validate_form(form, {"doc": "a.txt"})[0]["message"] == "「附件」需要先上传文件" + assert validate_form(form, {"doc": "a.txt"})[0] == {"path": "doc", "code": "file", "message": "「附件」需要先上传文件"} wrong = validate_form(form, {"doc": {**ref, "name": "a.pdf", "type": "application/pdf"}}) - assert wrong and "只收这些类型" in wrong[0]["message"] + assert wrong and wrong[0]["code"] == "file" and "只收这些类型" in wrong[0]["message"] + assert "file" in FORM_ERROR_CODES, "文件控件的错误有自己的 code(前端 FormError.code 同一份)" schema = input_schema([{"name": "doc", "type": "file", "required": True, "accept": [".pdf"], "max_mb": 5}]) assert schema["properties"]["doc"] == {"type": "object", "x-widget": "file", "title": "doc", "x-file": {"accept": [".pdf"], "max_mb": 5}} diff --git a/backend/tests/test_frontend_type_unions.py b/backend/tests/test_frontend_type_unions.py new file mode 100644 index 00000000..8ea2cf16 --- /dev/null +++ b/backend/tests/test_frontend_type_unions.py @@ -0,0 +1,125 @@ +"""前端约定(frontend/src/typesApp.ts、typesBlueprint.ts)里的取值和服务端的常量逐个对上。 + +前端的联合类型是照服务端的常量抄的:服务端加了一种(待办种类 review、运行来源 trial / personal_app、查看对象 caliber、 +表单错误 file……),前端没跟上,界面就把它当成别的、或者写不出名字;反过来前端多写了一种,界面就在等一个永远不会来的值。 +这里直接读两份 .ts 文件,把联合类型里的字面量和服务端的常量逐个比(能比顺序的连顺序一起比),改了一边没改另一边就过不去。 +""" +from __future__ import annotations + +import re +import typing +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TYPES_APP = ROOT / "frontend" / "src" / "typesApp.ts" +TYPES_BLUEPRINT = ROOT / "frontend" / "src" / "typesBlueprint.ts" +TYPES = ROOT / "frontend" / "src" / "types.ts" + +_COMMENT = re.compile(r"/\*.*?\*/|//[^\n]*", re.S) + + +def _source(path: Path) -> str: + """去掉注释的源码:注释里举例写的字面量不算进联合类型。""" + return _COMMENT.sub("", path.read_text(encoding="utf-8")) + + +def _union(path: Path, name: str) -> list[str]: + """`export type Name = | 'a' | 'b'` 里的字面量,按写的顺序。""" + text = _source(path) + found = re.search(rf"export type {name} =(.*?)(?=\nexport |\n\n|\Z)", text, re.S) + assert found is not None, f"{path.name} 里没有 export type {name}" + return re.findall(r"'([^']+)'", found.group(1)) + + +def _field(path: Path, interface: str, field: str) -> list[str]: + """`export interface X { field: 'a' | 'b' }` 里这一项的字面量,按写的顺序。""" + text = _source(path) + body = re.search(rf"export interface {interface}\b[^{{]*\{{(.*?)\n\}}", text, re.S) + assert body is not None, f"{path.name} 里没有 export interface {interface}" + line = re.search(rf"^\s*{re.escape(field)}\??: ([^\n]+)$", body.group(1), re.M) + assert line is not None, f"{interface} 里没有 {field}" + return re.findall(r"'([^']+)'", line.group(1)) + + +def test_task_kinds_and_statuses(): + from app.db.models_process import TASK_KINDS, TASK_STATUSES + + assert _union(TYPES_APP, "TaskKind") == list(TASK_KINDS), "待办种类(含 review)" + assert _union(TYPES_APP, "TaskStatus") == list(TASK_STATUSES) + + +def test_origin_kinds_in_the_same_order(): + from app.engine.origin import ORIGIN_KINDS + + # 运行列表按来源筛选的下拉照这个顺序(lib/terms.ORIGIN_LABEL 的类型是 Record,少一个编译不过) + assert _union(TYPES_APP, "OriginKind") == list(ORIGIN_KINDS) + + +def test_look_object_kinds(): + from app.look.base import LOOK_KINDS + + assert _field(TYPES_APP, "LookObject", "kind") == list(LOOK_KINDS), "查看对象(含口径卡 caliber)" + + +def test_form_contract(): + from app.apps.spec import FILE_FIELD_TYPE, FORM_ERROR_CODES, FORM_FORMATS, FORM_TYPES, FORM_WIDGETS + + assert _field(TYPES_APP, "FormError", "code") == list(FORM_ERROR_CODES), "表单错误(含文件控件的 file)" + assert _field(TYPES_APP, "FormField", "type") == [*FORM_TYPES, FILE_FIELD_TYPE], "字段类型:object 只给文件控件" + assert _union(TYPES_APP, "FormWidget") == list(FORM_WIDGETS) + assert _field(TYPES_APP, "FormField", "format") == list(FORM_FORMATS) + file_ref = re.search(r"export interface FileRef \{([^}]*)\}", _source(TYPES_BLUEPRINT)) + assert file_ref is not None + from app.apps.spec import FILE_REF_KEYS + + assert re.findall(r"(\w+):", file_ref.group(1)) == list(FILE_REF_KEYS), "文件引用的五个键" + + +def test_app_view_source_kinds(): + from app.apps.spec import ViewSource + + kinds = list(typing.get_args(ViewSource.model_fields["kind"].annotation)) + assert _field(TYPES_APP, "AppViewSource", "kind") == kinds, "视图来源(个人应用的 exploratory)" + # 解析出来的来源另有试运行预览的 trial(apps/resolve.Source.out) + assert _field(TYPES_APP, "ViewSourceState", "kind") == [*kinds, "trial"] + + +def test_blueprint_enums(): + from app.blueprints.publish_plan import LEVELS, PROBLEM_WHO, STEP_KINDS + from app.blueprints.spec import BP_OPS, ITEM_SOURCES, ITEM_STATUSES + from app.blueprints.templates import JOINT_KINDS + from app.db.models_blueprints import BLUEPRINT_SCOPES, BLUEPRINT_SOURCES, BLUEPRINT_STATUSES, PLAN_STATUSES + + assert _union(TYPES_BLUEPRINT, "ItemSource") == list(ITEM_SOURCES) + assert _union(TYPES_BLUEPRINT, "ItemStatus") == list(ITEM_STATUSES) + assert _union(TYPES_BLUEPRINT, "BpOpName") == list(BP_OPS) + assert _union(TYPES_BLUEPRINT, "BlueprintScope") == list(BLUEPRINT_SCOPES) + assert _union(TYPES_BLUEPRINT, "BlueprintStatus") == list(BLUEPRINT_STATUSES) + assert _union(TYPES_BLUEPRINT, "BlueprintSourceKind") == list(BLUEPRINT_SOURCES) + assert _union(TYPES_BLUEPRINT, "JointKind") == list(JOINT_KINDS) + assert _union(TYPES_BLUEPRINT, "PlanStepKind") == list(STEP_KINDS), "发布单的步骤(顺序即执行顺序)" + assert _field(TYPES_BLUEPRINT, "PublishPlanRow", "status") == list(PLAN_STATUSES) + assert _field(TYPES_BLUEPRINT, "PublishPlanRow", "level") == list(LEVELS) + assert _union(TYPES_BLUEPRINT, "ProblemWho") == list(PROBLEM_WHO) + + +def test_trial_refusal_codes(): + from app.blueprints import trial + + assert _union(TYPES_BLUEPRINT, "TrialRefusalCode") == [trial.NOT_READY, trial.NO_GRAPH, trial.POLICY, trial.FORM, + trial.NO_RUN] + + +def test_record_rows_carry_every_system_column(): + from app.records import SYSTEM_COLUMNS + + row = re.search(r"export interface RecordRow \{(.*?)\n\}", _source(TYPES_BLUEPRINT), re.S) + assert row is not None + fields = re.findall(r"^\s*(\w+)\??:", row.group(1), re.M) + assert set(SYSTEM_COLUMNS) <= set(fields), f"RecordRow 少了系统列:{set(SYSTEM_COLUMNS) - set(fields)}" + assert "_version" in fields, "这一行在第几版表里(records/listing._shown)" + + +def test_conversation_kinds_include_what_the_server_creates(): + # 前端建的只有 chat、canvas(api/conversations.ConversationIn);服务端另建应用助手的 app、蓝图对话的 blueprint + assert _field(TYPES, "Conversation", "kind") == ["chat", "canvas", "app", "blueprint"] diff --git a/backend/tests/test_personal_apps.py b/backend/tests/test_personal_apps.py index d9e130ab..8439a8d7 100644 --- a/backend/tests/test_personal_apps.py +++ b/backend/tests/test_personal_apps.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio import copy +import json import uuid from datetime import timedelta @@ -28,7 +29,7 @@ from app.api.deps import Principal from app.api.settings import APPS_GROUP from app.core.artifact_store import graph_hash from app.db.base import SessionLocal, utcnow -from app.db.models import Run, Setting, Workflow, WorkflowVersion +from app.db.models import Run, RunEvent, Setting, Workflow, WorkflowVersion from app.db.models_apps import App from app.db.models_blueprints import Blueprint, BlueprintEvent from app.db.models_process import Task, TaskAssignment @@ -165,13 +166,17 @@ async def test_the_switch_gates_users_but_not_builders(): # 搭建者不受开关管(§3.12) assert (await c["bob"].get("/api/apps")).json()["can_create"] == {"shared": True, "personal": True} - await saved(c["bob"], await blueprint("bob")) + bob_app = await saved(c["bob"], await blueprint("bob")) await switch(True) assert (await c["uma"].get("/api/apps")).json()["can_create"] == {"shared": False, "personal": True} app_id = await saved(c["uma"], uma_bp) + assert (await c["uma"].get(f"/api/apps/{app_id}")).json()["personal"]["allowed"] is True await switch(False) + # 关了开关:个人应用照样看得到,详情照同一条规则说现在不能刷新、提交审核(界面照它置灰,不自己再推一遍) + assert (await c["uma"].get(f"/api/apps/{app_id}")).json()["personal"]["allowed"] is False + assert (await c["bob"].get(f"/api/apps/{bob_app}")).json()["personal"]["allowed"] is True, "搭建者不受开关管" assert (await c["uma"].post(f"/api/apps/{app_id}/refresh")).json()["code"] == "personal.off" r = await c["uma"].post(f"/api/apps/{app_id}/submit-review", json={"to": [{"kind": "user", "name": "bob"}]}) assert r.status_code == 403 and r.json()["code"] == "personal.off" @@ -398,19 +403,31 @@ async def test_only_the_owner_sees_a_personal_app(): async def test_an_admin_sees_that_it_exists_and_can_archive_it_but_not_its_content(): + """管理员看别人的个人应用(D7):列表和详情里只有名称、拥有者、状态、创建和更新时间,能归档;页面、草稿、候选、 + 版本、刷新的运行、蓝图一概看不到。""" root, c = await people() await switch(True) - app_id = await saved(c["uma"], await blueprint("uma")) - await refreshed(c["uma"], app_id) - for path in (f"/api/apps/{app_id}", f"/api/apps/{app_id}/pages/home", f"/api/apps/{app_id}/pages/home?draft=1", - f"/api/apps/{app_id}/candidates", f"/api/apps/{app_id}/versions"): + bp = await blueprint("uma") + app_id = await saved(c["uma"], bp) + run = await refreshed(c["uma"], app_id) + for path in (f"/api/apps/{app_id}/pages/home", f"/api/apps/{app_id}/pages/home?draft=1", + f"/api/apps/{app_id}/candidates", f"/api/apps/{app_id}/versions", f"/api/blueprints/{bp}"): assert (await root.get(path)).status_code == 404, path + for path in (f"/api/runs/{run.id}", f"/api/runs/{run.id}/evidence"): + assert (await root.get(path)).status_code == 403, path listed = next(a for a in (await root.get("/api/apps")).json()["items"] if a["id"] == app_id) assert (listed["owner"], listed["scope"], listed["my_role"], listed["description"], listed["name"]) == ( "uma", "personal", None, "", "我的周报") + assert listed["created_at"] and listed["updated_at"] and listed["blueprint_id"] is None + detail = await root.get(f"/api/apps/{app_id}") + assert detail.status_code == 200, detail.text + assert {k: detail.json()[k] for k in listed} == listed, "详情和列表里那一条一样,只有元数据" + assert not {"draft", "draft_bindings", "personal"} & set(detail.json()) and detail.json()["published"] is None r = await root.post(f"/api/apps/{app_id}/archive") assert r.status_code == 200 and r.json()["status"] == "archived", r.text assert "draft" not in r.json() and "personal" not in r.json() + # 别的搭建者连元数据也没有:当这个应用不存在 + assert (await c["bob"].get(f"/api/apps/{app_id}")).status_code == 404 r = await c["uma"].post(f"/api/apps/{app_id}/refresh") assert r.status_code == 409 and "已归档" in r.json()["detail"] @@ -471,6 +488,11 @@ async def test_review_gives_the_builder_a_shared_copy_and_leaves_the_original(mo assert (dup["copied_from"], dup["scope"], dup["owner"], dup["status"]) == (bp, "shared", "bob", "drafting") assert dup["graph_hash"] == graph_hash(KPI) and dup["spec"]["purpose"]["value"] == "每周看销售额" assert (await c["bob"].post(f"/api/blueprints/{bp}/copy")).json()["id"] == dup["id"], "再取是同一份" + # 副本归处理审核的人(D5):别的搭建者看不到;审核的人也读不到拥有者那次刷新(D2,只给拥有者) + assert (await c["bob"].get(f"/api/blueprints/{dup['id']}")).status_code == 200 + assert (await c["bea"].get(f"/api/blueprints/{dup['id']}")).status_code == 404 + assert dup["id"] not in [b["id"] for b in (await c["bea"].get("/api/blueprints")).json()["items"]] + assert (await c["bob"].get(f"/api/runs/{run.id}")).status_code == 403 async with SessionLocal() as session: original = await session.get(Blueprint, bp) assert original.status == "in_review" and original.scope == "personal" and original.graph == KPI @@ -812,11 +834,11 @@ async def test_a_personal_blueprint_never_creates_a_records_table(): 个人蓝图建记录表、登记全局数据源。""" from app.blueprints.ops import blueprint_issues from app.blueprints.store import instantiate_context - from app.blueprints.templates._common import EXTRAS + from app.blueprints.templates import get_template from app.db.models import DataSource from app.db.models_blueprints import RecordsTable - spec = EXTRAS["records"].example() + spec = get_template("records").example() assert any(i["code"] == "bp.template_mismatch" and i["path"] == "/template" for i in blueprint_issues(spec, for_="personal")) async with SessionLocal() as session: @@ -830,3 +852,284 @@ async def test_a_personal_blueprint_never_creates_a_records_table(): assert "records" not in ctx assert not (await session.execute(select(RecordsTable).where(RecordsTable.table_key == row.id))).first() assert int(await session.scalar(select(func.count(DataSource.id))) or 0) == sources + + +# -------------------------------------------------------------------------- +# 只有拥有者读得到的运行(D2) +# -------------------------------------------------------------------------- + + +async def test_personal_runs_are_read_by_their_owner_only(): + """个人应用的刷新、个人蓝图的试运行只有拥有者读得到:管理员、搭建者也不行——运行记录、事件、运行时的图、证据、工件、 + 对比、可验证报告、文件下载、运行列表、审批列表、实时流一个口径(auth/access.private_owner / hidden_runs)。 + 共享蓝图的试运行、别的运行照旧给搭建者读。""" + from app.api.deps import run_events_readable + from app.auth import artifact_readable, run_readable + from app.db.models import Artifact + from app.db.models_blueprints import StoredFile + from tests.test_user_chat_access import _cookie, _refused, _ws + + root, c = await people() + await switch(True) + bp = await blueprint("uma", trialed=False) + r = await c["uma"].post(f"/api/blueprints/{bp}/trial", json={}) + assert r.status_code == 200, r.text + trial = await wait(r.json()["run_id"]) + assert trial.status == "succeeded" and trial.origin_kind == "trial", trial.error + app_id = await saved(c["uma"], bp) + refresh = await refreshed(c["uma"], app_id) + async with SessionLocal() as session: + shared_bp = Blueprint(name="共享周报", owner="bob", scope="shared", status="drafting", spec={}, + source={"kind": "template"}) + session.add(shared_bp) + await session.flush() + shared_trial = Run(workflow_name="共享周报", graph=KPI, status="succeeded", origin_kind="trial", + origin={"blueprint_id": shared_bp.id}, started_by="bob") + old = Run(workflow_name="老运行", graph=KPI, status="succeeded", origin_kind=None, started_by="bob") + session.add_all([shared_trial, old]) + await session.commit() + assert await session.scalar(select(Artifact.id).where(Artifact.run_id == refresh.id).limit(1)), "刷新落了工件" + # 工件按内容寻址,同样内容的工件可能也挂在别的(读得到的)运行上:另落一件只挂在这次刷新上的 + from app.core.artifact_store import put_json + + artifact = await put_json({"note": uuid.uuid4().hex}, kind="node_output", run_id=refresh.id, node_id="card") + up = await c["uma"].post("/api/files", files={"file": ("说明.txt", "本周说明".encode(), "text/plain")}, + data={"context": json.dumps({"blueprint_id": bp})}) + assert up.status_code == 201, up.text + file_id = up.json()["file_id"] + async with SessionLocal() as session: + (await session.get(StoredFile, file_id)).bound_run_id = trial.id + await session.commit() + + def principal(name: str, role: str) -> Principal: + return Principal(username=name, role=role, verified=True, mode="local") + + for who, role in (("root", "admin"), ("bob", "builder")): + client = root if who == "root" else c[who] + for run in (trial, refresh): + for path in ("", "/events", "/graph", "/evidence", "/compare/base", "/bundle.html", "/artifacts"): + r = await client.get(f"/api/runs/{run.id}{path}") + assert r.status_code == 403, (who, run.origin_kind, path, r.status_code) + assert not await run_readable(principal(who, role), run.id) + assert not await run_events_readable(principal(who, role), run) + assert (await client.get(f"/api/artifacts/{artifact}")).status_code == 403, who + assert not await artifact_readable(principal(who, role), artifact) + assert (await client.get(f"/api/files/{file_id}")).status_code == 403, who + listed = {r["id"] for r in (await client.get("/api/runs?limit=200")).json()} + assert not {trial.id, refresh.id} & listed, who + assert {shared_trial.id, old.id} <= listed, "共享蓝图的试运行、来源没记的老运行照旧列出" + assert (await client.get(f"/api/runs/{shared_trial.id}")).status_code == 200 + assert _refused(await _ws(f"/api/runs/{refresh.id}/stream", _cookie(client))), "实时流握手前就拒" + + for run in (trial, refresh): + assert await run_readable(principal("uma", "user"), run.id) + assert (await c["uma"].get(f"/api/runs/{run.id}/evidence")).status_code == 200 + assert (await c["uma"].get(f"/api/files/{file_id}")).status_code == 200 + assert await artifact_readable(principal("uma", "user"), artifact) + # 拥有者是搭建者时:他自己的个人应用刷新、个人蓝图试运行照样读得到、列得出,别的搭建者读不到;试运行和刷新一样 + # 遮罩、只读(试运行是刷新的预演) + from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION + + bob_bp = await blueprint("bob", trialed=False) + r = await c["bob"].post(f"/api/blueprints/{bob_bp}/trial", json={}) + assert r.status_code == 200, r.text + bob_trial = await wait(r.json()["run_id"]) + assert bob_trial.options[READ_ONLY_OPTION] is True and bob_trial.options[MASK_RESULTS_OPTION] is True + assert bob_trial.memory_scope == "chat:bob" + bob_run = await refreshed(c["bob"], await saved(c["bob"], bob_bp)) + for run_id in (bob_trial.id, bob_run.id): + assert (await c["bob"].get(f"/api/runs/{run_id}")).status_code == 200 + assert run_id in {r["id"] for r in (await c["bob"].get("/api/runs?limit=200")).json()} + assert (await c["bea"].get(f"/api/runs/{run_id}")).status_code == 403 + + +async def test_the_other_doors_to_a_private_run_are_shut_too(monkeypatch): + """D2 的其余入口:别人的个人应用刷新、个人蓝图试运行,管理员、搭建者从这些地方同样碰不到——「提取成工作流」、运行复核、 + 从问数据的回答开始建蓝图(当它不存在);质询、假设分析(403);审批列表、探索问题聚类里不出现。拥有者自己的照旧能用、 + 列得出;别的运行照旧。""" + from app.api.deps import PRIVATE_RUN + from app.db.models import Approval + from app.memory import embeddings + + root, c = await people() + await switch(True) + bp = await blueprint("uma", trialed=False) + r = await c["uma"].post(f"/api/blueprints/{bp}/trial", json={}) + assert r.status_code == 200, r.text + trial = await wait(r.json()["run_id"]) + refresh = await refreshed(c["uma"], await saved(c["uma"], bp)) + bob_bp = await blueprint("bob", trialed=False) + r = await c["bob"].post(f"/api/blueprints/{bob_bp}/trial", json={}) + assert r.status_code == 200, r.text + bob_trial = await wait(r.json()["run_id"]) + question = {"question": "上周各门店的销售额"} + async with SessionLocal() as session: + # 聚类只列两条以上的簇:别的探索运行放两条 + shared, also = (Run(workflow_name="共享的运行", graph=KPI, status="succeeded", run_class="exploratory", + origin_kind="chat", started_by="bob", input=question) for _ in range(2)) + session.add_all([shared, also]) + await session.flush() + for run_id in (trial.id, refresh.id, bob_trial.id): + (await session.get(Run, run_id)).input = question + approvals = {run_id: Approval(run_id=run_id, node_id="card", title="看一眼") for run_id in + (refresh.id, bob_trial.id, shared.id)} + session.add_all(approvals.values()) + await session.commit() + approval_ids = {run_id: a.id for run_id, a in approvals.items()} + + async def _same(texts): + return [[1.0, 0.0] for _ in texts] + + # 聚类只看谁进了候选:每条输入的向量一样,候选就都在一簇里 + monkeypatch.setattr(embeddings, "embed_texts", _same) + + async def clustered(client) -> set[str]: + body = (await client.get("/api/governance/exploratory-clusters", params={"limit": 500})).json() + return {rid for cluster in body["clusters"] for rid in cluster["run_ids"]} + + async def approvals_of(client) -> set[str]: + return {a["id"] for a in (await client.get("/api/approvals", params={"status": "all", "limit": 500})).json()} + + for who, client in (("root", root), ("bob", c["bob"])): + for run in (trial, refresh): + r = await client.post("/api/copilot/from-run", json={"run_id": run.id}) + assert r.status_code == 404, (who, run.origin_kind, r.status_code) + r = await client.post("/api/copilot/review", json={"run_id": run.id}) + assert r.status_code == 403 and r.json()["detail"] == PRIVATE_RUN, (who, r.status_code) + r = await client.post("/api/blueprints", json={"source": {"kind": "run", "id": run.id}, "scope": "shared"}) + assert r.status_code == 404, (who, r.status_code) + assert (await client.get(f"/api/runs/{run.id}/inquiries")).status_code == 403, who + assert (await client.post(f"/api/runs/{run.id}/inquiries", json={"question": "x"})).status_code == 403 + assert (await client.post(f"/api/runs/{run.id}/evidence/whatif", json={})).status_code == 403, who + assert approval_ids[refresh.id] not in await approvals_of(client), who + assert approval_ids[shared.id] in await approvals_of(client), "别的运行上的审批照旧列出" + seen = await clustered(client) + assert not {trial.id, refresh.id} & seen, who + assert shared.id in seen, "别的探索运行照旧进聚类" + + # 拥有者是搭建者时:自己的个人蓝图试运行照样能用、列得出;别的搭建者不行 + assert approval_ids[bob_trial.id] in await approvals_of(c["bob"]) + assert approval_ids[bob_trial.id] not in await approvals_of(c["bea"]) + assert bob_trial.id in await clustered(c["bob"]) and bob_trial.id not in await clustered(c["bea"]) + assert (await c["bob"].get(f"/api/runs/{bob_trial.id}/inquiries")).status_code == 200 + assert (await c["bea"].get(f"/api/runs/{bob_trial.id}/inquiries")).status_code == 403 + assert (await c["bob"].post("/api/copilot/review", json={"run_id": bob_trial.id})).status_code == 200 + r = await c["bob"].post("/api/blueprints", json={"source": {"kind": "run", "id": bob_trial.id}, "scope": "shared"}) + assert r.status_code == 201, r.text + assert (await c["bea"].post("/api/blueprints", json={"source": {"kind": "run", "id": bob_trial.id}, + "scope": "shared"})).status_code == 404 + + +# -------------------------------------------------------------------------- +# 按模板做个人应用(D1:取数的调用工具节点过同一份白名单) +# -------------------------------------------------------------------------- + + +def _example(template_id: str, **cards) -> tuple[dict, dict, dict | None]: + """模板登记的示例蓝图(卡片可以改几张)、生成关节的填法、自由形导入的示例图。""" + from app.blueprints.templates import get_template + from app.blueprints.templates.freeform import example_graph + + template = get_template(template_id) + spec = {**template.example(), **cards} + return spec, template.example_fill(), example_graph() if template.imports_graph else None + + +@pytest.mark.parametrize("template_id, cards", [ + ("periodic_board", {}), + # 异常处置只看清单的那一部分:不写「谁来做」,没有处置和复核 + ("exception_handling", {"people": []}), + ("freeform", {}), +]) +def test_the_template_examples_that_can_be_personal_pass_the_personal_rules(template_id, cards): + """模板说能做成个人应用的那一部分,示例实例化出来的图过个人应用的约束(取数节点是调用工具节点查数据源)。""" + from app.blueprints.instantiate import instantiate + from app.blueprints.personal import policy_problem + from app.blueprints.templates import get_template + + assert get_template(template_id).personal + spec, fill, graph = _example(template_id, **cards) + inst = instantiate(spec, filled=fill, graph=graph) + assert not [p for p in inst.problems if p["level"] == "error"], inst.problems + nodes = (graph or inst.graph)["nodes"] + assert any(n["type"] == "tool" and n["data"]["config"]["tool"].startswith("db_query__") for n in nodes) + assert policy_problem(graph or inst.graph) is None + + +def test_the_parts_that_cannot_be_personal_are_still_refused(): + """异常处置写了「谁来做」就有人工处置;审批、对账回写、填报登记不能做成个人应用(personal 为 None)。""" + from app.blueprints.instantiate import instantiate + from app.blueprints.personal import policy_problem + from app.blueprints.templates import get_template + + spec, fill, _ = _example("exception_handling") + problem = policy_problem(instantiate(spec, filled=fill).graph) + assert problem and "人工" in problem + for template_id in ("approval", "reconcile_writeback", "records"): + assert get_template(template_id).personal is None, template_id + + +async def test_a_personal_board_from_the_template_trials_saves_and_refreshes(tmp_path): + """使用者按周期看板模板(示例卡片,库换成现造的)做个人应用:试运行、存成我的应用、刷新都跑通。取数的调用工具节点 + 在刷新里照样遮罩、只读:遮罩列只有「已遮罩」,页面上的数是这次刷新算的。""" + import sqlite3 + from datetime import date, timedelta as days + + from app.blueprints.instantiate import instantiate + from app.data import introspect + from app.db.models import DataSource + from app.tools.datasource import MASK_RESULTS_OPTION, READ_ONLY_OPTION + + _, c = await people() + await switch(True) + name = f"shop_{uuid.uuid4().hex[:6]}" + path = tmp_path / f"{name}.db" + today = date.today() + with sqlite3.connect(path) as db: + db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL, refunded INTEGER, paid_at TEXT, " + "phone TEXT)") + db.executemany("INSERT INTO orders (amount, refunded, paid_at, phone) VALUES (?, ?, ?, ?)", + [(100.0, i % 5 == 0, (today - days(days=i)).isoformat(), "13900000001") for i in range(40)]) + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=False, enabled=True, + options={"mask_columns": "phone"}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + + r = await c["uma"].post("/api/blueprints", json={"source": {"kind": "template", "id": "periodic_board"}, + "scope": "personal", "name": "我的门店周报"}) + assert r.status_code == 201, r.text + bp = r.json()["id"] + spec, fill, _ = _example("periodic_board") + spec = json.loads(json.dumps(spec, ensure_ascii=False).replace('"source": "shop"', f'"source": "{name}"')) + fill = json.loads(json.dumps(fill, ensure_ascii=False).replace("fetch_shop", f"fetch_{name}")) + # 取数时顺带查了遮罩列:刷新里它只会是「已遮罩」 + fill[f"sql_fetch_{name}"] = fill[f"sql_fetch_{name}"].replace(" FROM orders", ", MAX(phone) AS phone FROM orders") + filled = instantiate(spec, filled=fill, name="我的门店周报", blueprint_id=bp) + assert not [p for p in filled.problems if p["level"] == "error"], filled.problems + async with SessionLocal() as session: + row = await session.get(Blueprint, bp) + row.spec, row.joints = spec, filled.joints + await session.commit() + + r = await c["uma"].post(f"/api/blueprints/{bp}/trial", json={}) + assert r.status_code == 200, r.text + trial = await wait(r.json()["run_id"]) + assert trial.status == "succeeded", trial.error + assert trial.options[READ_ONLY_OPTION] is True and trial.options[MASK_RESULTS_OPTION] is True + app_id = await saved(c["uma"], bp) + run = await refreshed(c["uma"], app_id) + assert run.options[READ_ONLY_OPTION] is True and run.options[MASK_RESULTS_OPTION] is True + async with SessionLocal() as session: + events = list((await session.execute(select(RunEvent).where(RunEvent.run_id == run.id))).scalars()) + dumped = json.dumps([e.data for e in events], ensure_ascii=False) + assert "13900000001" not in dumped and "已遮罩" in dumped + page = (await c["uma"].get(f"/api/apps/{app_id}/pages/home")).json() + metrics = [n for n in walk(page["tree"]) if n["type"] == "metric"] + assert metrics and all(m["data"]["state"] == "ok" for m in metrics), metrics + assert {m["data"]["evidence"]["run_id"] for m in metrics} == {run.id} + async with SessionLocal() as session: + await session.execute(delete(DataSource).where(DataSource.name == name)) + await session.commit() diff --git a/backend/tests/test_publish_plan.py b/backend/tests/test_publish_plan.py index c1a30a48..bc838b1f 100644 --- a/backend/tests/test_publish_plan.py +++ b/backend/tests/test_publish_plan.py @@ -313,7 +313,8 @@ async def test_governed_plans_need_an_admin(login, tmp_path): # 使用者看不到发布单 await auth_env.make_user(f"lee{u}", "user") user = await auth_env.login(f"lee{u}") - assert (await user.get(f"/api/publish-plans/{plan['id']}")).status_code == 403 + # 使用者碰共享蓝图的发布单回 404,和看不到的一样(D5) + assert (await user.get(f"/api/publish-plans/{plan['id']}")).status_code == 404 async def test_runs_launched_by_a_plan_are_formal_and_from_the_published_version(client, tmp_path): @@ -432,11 +433,11 @@ async def test_problems_are_split_into_three_kinds(client): async def _approval_blueprint(client) -> str: """审批流程模板的蓝图:只有一步主管审批(处理人写了具体的人,岗位按卡片绑上),没有写回。""" - from app.blueprints.templates._common import EXTRAS + from app.blueprints.templates import get_template bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "approval"}, "scope": "shared", "name": f"报销-{uuid.uuid4().hex[:4]}"})).json() - spec = EXTRAS["approval"].example() + spec = get_template("approval").example() spec["people"] = spec["people"][:1] spec["access"] = {"use": [{"kind": "user", "name": "zhang"}], "observe": [], "manage": []} await s.edit_spec(bp["id"], **spec) @@ -489,8 +490,14 @@ async def test_cards_changed_after_the_trial_are_what_gets_published(client): async def test_a_plan_cannot_take_over_an_app_the_builder_cannot_manage(login, tmp_path): """蓝图已经发布成应用:只有对那个应用有管理权限的人能出单、执行(发布单会改它的草稿、发布它、整份替换授权)。 - 只能查看的搭建者出单时预检报 plan.app_forbidden(要找管理员),执行 403;应用的授权没被改。""" - await auth_env.turn_on_local() + + - 别的搭建者连这张共享蓝图都看不到(D5):读、改、出单、看单子一律 404,列表里没有;给了那个应用的管理授权之后才 + 看得到; + - 蓝图的拥有者对应用没有管理权限(应用是管理员替他发布的、只授给他使用)时,把自己写进「谁能看」的管理想借发布单 + 拿到管理权:出单时预检报 plan.app_forbidden(要找管理员),执行 403;应用的授权没被改。""" + import json + + root = await auth_env.turn_on_local() u = uuid.uuid4().hex[:6] await auth_env.make_user(f"zhao{u}", "builder") await auth_env.make_user(f"qian{u}", "builder") @@ -502,26 +509,51 @@ async def test_a_plan_cannot_take_over_an_app_the_builder_cannot_manage(login, t "scope": "shared"})).json() await s.edit_spec(bp["id"], purpose=s.item("每周看上周销售额"), access={"use": [{"kind": "user", "name": f"zhao{u}"}], "observe": [], "manage": []}) - plan = (await owner.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published", - "first_run": False})).json() + # 管理员能读写、出单(D5):应用由管理员发布,创建人是管理员 + plan = (await root.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published", + "first_run": False})).json() assert not [p for p in plan["problems"] if p["code"] == "plan.app_forbidden"] - done = (await owner.post(f"/api/publish-plans/{plan['id']}/run")).json() + done = (await root.post(f"/api/publish-plans/{plan['id']}/run")).json() assert done["status"] == "done", done["steps"] app_id = (await s.blueprint(bp["id"])).app_id - # 另一个搭建者把自己写进「谁能看」的管理,想借发布单拿到管理权 - await s.edit_spec(bp["id"], access={"use": [], "observe": [], "manage": [{"kind": "user", "name": f"qian{u}"}]}) - plan = (await other.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published", + # 别的搭建者:当这张蓝图不存在 + assert (await other.get(f"/api/blueprints/{bp['id']}")).status_code == 404 + assert (await other.patch(f"/api/blueprints/{bp['id']}", json={"name": "改个名"})).status_code == 404 + assert (await other.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).status_code \ + == 404 + assert (await other.get(f"/api/publish-plans/{plan['id']}")).status_code == 404 + assert (await other.post(f"/api/publish-plans/{plan['id']}/run")).status_code == 404 + assert bp["id"] not in [b["id"] for b in (await other.get("/api/blueprints")).json()["items"]] + assert bp["id"] in [b["id"] for b in (await owner.get("/api/blueprints")).json()["items"]] + up = await other.post("/api/files", files={"file": ("说明.txt", "x".encode(), "text/plain")}, + data={"context": json.dumps({"blueprint_id": bp["id"]})}) + assert up.status_code == 404 and up.json()["code"] == "files.context_invalid", "往别人的蓝图上传附件同样不收" + ghost = await other.post("/api/files", files={"file": ("说明.txt", "x".encode(), "text/plain")}, + data={"context": json.dumps({"blueprint_id": uuid.uuid4().hex})}) + assert (ghost.status_code, ghost.json()) == (up.status_code, up.json()), "看不到的和不存在的一个说法,试不出它在不在" + assert bp["id"] in [b["id"] for b in (await root.get("/api/blueprints")).json()["items"]] + + # 拥有者把自己写进「谁能看」的管理,想借发布单拿到管理权 + await s.edit_spec(bp["id"], access={"use": [], "observe": [], "manage": [{"kind": "user", "name": f"zhao{u}"}]}) + plan = (await owner.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published", "first_run": False})).json() forbidden = [p for p in plan["problems"] if p["code"] == "plan.app_forbidden"] assert plan["ok"] is False and forbidden and forbidden[0]["who"] == "admin" and forbidden[0]["plain"]["title"] - r = await other.post(f"/api/publish-plans/{plan['id']}/run") + r = await owner.post(f"/api/publish-plans/{plan['id']}/run") assert r.status_code == 403 and r.json()["code"] == "plan.app_forbidden", r.text async with SessionLocal() as session: grants = {(g.principal, g.role) for g in (await session.execute( select(AppGrant).where(AppGrant.app_id == app_id))).scalars()} assert grants == {(f"zhao{u}", "use")}, "授权没被改" + # 应用的管理者看得到它出自的蓝图(D5) + async with SessionLocal() as session: + session.add(AppGrant(app_id=app_id, principal_kind="user", principal=f"qian{u}", role="manage")) + await session.commit() + assert (await other.get(f"/api/blueprints/{bp['id']}")).status_code == 200 + assert bp["id"] in [b["id"] for b in (await other.get("/api/blueprints")).json()["items"]] + async def test_plans_left_running_by_a_dead_process_are_stopped_at_startup(client, tmp_path): """执行到一半进程没了:单子停在「正在执行」,谁也接不着。启动时改成停下,停在没做完的那一步、写明执行中断,就能接着做。""" diff --git a/backend/tests/test_records.py b/backend/tests/test_records.py index b82ac801..88f435aa 100644 --- a/backend/tests/test_records.py +++ b/backend/tests/test_records.py @@ -670,3 +670,251 @@ async def test_a_correction_only_reaches_rows_the_submitter_can_see(monkeypatch) assert (await fix("amy", amy_row.row_id, "a2")).status == 200 assert (await fix("cat", bob_row.row_id, "c1")).status == 200 assert "不在这张记录表里" in (await fix("dan", amy_row.row_id, "d1")).error + + +def correct_graph(table_ref: str) -> dict: + """写记录表的那一步带上要更正的行(supersedes)。""" + return chain(node("start", "input", fields=[{"name": "store"}, {"name": "qty"}, {"name": "fix"}]), + node("w", "tool", tool=RECORDS_APPEND, approval="never", + args={"table": table_ref, "values": {"门店": "{{ input.store }}", "数量": "{{ input.qty }}"}, + "supersedes": "{{ input.fix }}"}), + node("out", "output", fields=[{"name": "id", "value": "{{ nodes.w.receipt._id }}"}])) + + +async def test_a_correction_keeps_the_submitter_and_records_who_corrected(engine_up): + """别人代为更正(D6):更正行的提交人沿用原行的——原提交人按「只看自己的」照样看得见改过的那一行;动手更正的人记进 + 动作记录的回执和审计(records.correct),不记进行里。重放同一个动作不再写行、也不再记审计。""" + from sqlalchemy import func + + from app.capabilities.platform import execute + from app.capabilities.registry import get_capability + from app.core.artifact_store import load + from app.db.models import AuditEvent + + app = await make_app(grants={"amy": "use", "cat": "observe"}) + table = await make_table(app) + source = await source_of(table) + original = add(table, {"门店": "一店", "数量": 3}, by="amy") + workflow_id = await writer_of(app, correct_graph(source.name)) + await _sync() + + run = await run_manager.start(graph=correct_graph(source.name), + input_payload={"store": "一店", "qty": "9", "fix": original.row_id}, + run_class="formal", started_by="cat", workflow_id=workflow_id) + done = await wait(run.id, ("succeeded", "failed", "interrupted")) + assert done.status == "succeeded", done.error + async with SessionLocal() as session: + [rec] = list((await session.execute(select(ActionRecord).where(ActionRecord.run_id == run.id))).scalars()) + audits = list((await session.execute(select(AuditEvent).where(AuditEvent.action == "records.correct", + AuditEvent.subject_id == table.id))).scalars()) + body = load(rec.receipt["artifact"])["body"] + assert (body["_submitted_by"], body["corrected_by"]) == ("amy", "cat"), "回执里记着谁交的、谁改的" + assert [(a.actor, a.data["submitted_by"], a.data["supersedes"], a.data["row_id"], a.data["run_id"]) + for a in audits] == [("cat", "amy", original.row_id, body["_id"], run.id)] + + mine, _ = store.rows(path_of(table), columns_of(table), scope="mine", viewer="amy") + assert [(r["_id"], r["_submitted_by"], r["values"]["数量"]) for r in mine] == [(body["_id"], "amy", 9)], \ + "原提交人看得见改过的那一行" + assert store.rows(path_of(table), columns_of(table), scope="mine", viewer="cat")[0] == [], "更正的人不算提交人" + history, _ = store.rows(path_of(table), columns_of(table), scope="all", viewer=None, history=True) + assert {r["_id"]: r["_run_id"] for r in history}[body["_id"]] == run.id, "这一行出自哪次运行照样查得到" + + async with SessionLocal() as session: + cap = await get_capability(session, RECORDS_APPEND) + again = await execute(None, cap, {"table": source.name, "values": {"门店": "一店", "数量": 9}, + "supersedes": original.row_id}, + environment="prod", idem_key=rec.idem_key, on_behalf={"sub": "cat"}, run_id=run.id) + assert again.status == 200 and again.body["duplicate"] is True and again.body["_submitted_by"] == "amy" + async with SessionLocal() as session: + assert int(await session.scalar(select(func.count(AuditEvent.id)).where( + AuditEvent.action == "records.correct", AuditEvent.subject_id == table.id)) or 0) == 1 + + # 自己交的新一行:提交人就是自己,回执里没有 corrected_by + fresh = store.append(path_of(table), columns_of(table), {"门店": "二店", "数量": 1}, version=table.version, + run_id=None, actor="amy", action_key=uuid.uuid4().hex) + assert fresh.submitted_by == "amy" and "corrected_by" not in fresh.receipt() + + +# -------------------------------------------------------------------------- +# 跨版本汇总(停用的版本只读挂在连接上,UNION ALL 接上);停用之前发起的运行照常写完 +# -------------------------------------------------------------------------- + +#: 不兼容的改动:数量从整数放宽成数(都是数,旧记录照样并进来)、原因从文字改成是或否(意思变了,旧记录这一列为空)、 +#: 加必填的店长(旧记录没有,为空) +CHANGED = [COLUMNS[0], {**COLUMNS[1], "type": "number"}, {**COLUMNS[2], "type": "boolean"}, + {"name": "店长", "label": "店长", "type": "string", "required": True}] +SUMMARY = 'SELECT count(*) AS n, sum("数量") AS s FROM records' + + +async def _publish_changed(app: App, columns=CHANGED) -> tuple[RecordsTable, RecordsTable]: + """草稿出第 2 版(draft_table),发布单切换(publish_table):第 1 版这时才停用。交回(第 1 版,第 2 版)。""" + from app.records.tables import draft_table, publish_table + + async with SessionLocal() as session: + owner = await session.get(App, app.id) + drafted = await draft_table(session, owner, columns) + await publish_table(session, owner, f"{drafted.table_key}.v{drafted.version}", columns) + await session.commit() + key = drafted.table_key + async with SessionLocal() as session: + v1, v2 = list((await session.execute(select(RecordsTable).where(RecordsTable.table_key == key) + .order_by(RecordsTable.version))).scalars()) + return v1, v2 + + +async def test_the_summary_reads_every_version_after_an_incompatible_change(engine_up): + """表单不兼容地改过并发布之后,看板查新一版的数据源,旧版本的记录照样在汇总里:列照新一版的来,旧版本没有的、类型改得 + 对不上的为空,整数放宽成数的照样并进来;各版本各按自己的高水位固定,同一次运行审批前后查到的数一样;挂上去的旧版本和 + main 一样不能直接写库名查;用户 SQL 写 ATTACH 照旧被拒。""" + from app.apps.resolve import _version_text + from app.records.views import cache_token + + app = await make_app() + v1 = await make_table(app) + first = add(v1, {"门店": "一店", "数量": 2, "原因": "破损"}) + add(v1, {"门店": "一店", "数量": 5, "原因": "破损"}, supersedes=first.row_id) + add(v1, {"门店": "二店", "数量": 1}, by="bob") + v1, v2 = await _publish_changed(app) + assert v1.retired_at is not None and v2.retired_at is None and v2.version == 2 + add(v2, {"门店": "三店", "数量": 7.5, "原因": True, "店长": "钱七"}) + source = await source_of(v2) + + async with SessionLocal() as session: + now = await resolve(session, source) + got = await run_query(now, 'SELECT _schema_version AS v, "门店", "数量", "原因", "店长" FROM records ' + 'ORDER BY v, "门店"') + assert got.rows == [[1, "一店", 5, None, None], [1, "二店", 1, None, None], [2, "三店", 7.5, 1, "钱七"]], \ + "旧版本的记录并进来(更正过的只留最新的一行),对不上的列为空" + assert (await run_query(now, SUMMARY)).rows == [[3, 13.5]] + for sql in ("SELECT count(*) FROM records_v1.records", 'SELECT count(*) FROM "records_v1"."records"', + "SELECT count(*) FROM [RECORDS_V1].records", "SELECT count(*) FROM main.records"): + with pytest.raises(SqlRejected): + await run_query(now, sql) + for sql in (f"ATTACH DATABASE '{path_of(v1)}' AS sneak", "DETACH DATABASE records_v1"): + with pytest.raises(SqlRejected): + await run_query(now, sql) + # 同一个数据源不经解析查(数据剖析这类入口):连接上挂过的旧版本摘掉了,库名写了也查不到 + with pytest.raises(Exception) as gone: + await run_query(source, "SELECT count(*) FROM records_v1.records") + assert "records_v1" in str(gone.value) + assert [r[1] for r in (await run_query(source, "PRAGMA database_list")).rows] == ["main", "temp"] + assert (await run_query(now, "PRAGMA database_list")).rows[-1][1] == "records_v1", "再按汇总查又挂上" + # 行级过滤对每一版都生效 + async with SessionLocal() as session: + bobs = await resolve(session, source, {"seq": 1, "rows": "mine", "viewer": "bob", + "older": [{"version": 1, "seq": 3}]}) + assert (await run_query(bobs, SUMMARY)).rows == [[1, 1]] + + # 高水位:旧版本同样按发起时固定(停用之前发起的运行还能往旧版本写完) + tool = f"db_query__{source.name}" + graph = chain(node("start", "input"), node("t1", "tool", tool=tool, args={"sql": SUMMARY}), + node("gate", "human", mode="approve", title="看一眼"), + node("t2", "tool", tool=tool, args={"sql": SUMMARY}), + node("out", "output", fields=[{"name": "t1", "value": "{{ nodes.t1 }}"}, + {"name": "t2", "value": "{{ nodes.t2 }}"}])) + run = await run_manager.start(graph=graph, input_payload={}, run_class="formal") + assert (await wait(run.id)).status == "interrupted" + add(v1, {"门店": "四店", "数量": 4}) + add(v2, {"门店": "五店", "数量": 6, "店长": "孙八"}) + await run_manager.resume(run.id, {"approved": True}) + done = await wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + assert json.loads(done.output["t1"])["rows"] == [[3, 13.5]] + assert json.loads(done.output["t2"])["rows"] == [[3, 13.5]], "审批期间两个版本新来的都没混进这次运行" + entry = done.data_versions[source.id] + assert entry["seq"] == 1 and entry["older"] == [{"version": 1, "seq": 3}] + assert _version_text(entry) == f"{source.name} 截至第 1 条(连同第 1 版截至第 3 条)" + assert cache_token(entry) != cache_token({**entry, "older": [{"version": 1, "seq": 4}]}), \ + "旧版本多了一行,按需视图的缓存就不再算数" + again = await run_manager.start(graph=chain(node("start", "input"), + node("t", "tool", tool=tool, args={"sql": SUMMARY}), + node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}])), + input_payload={}, run_class="formal") + assert json.loads((await wait(again.id, ("succeeded", "failed"))).output["t"])["rows"] == [[5, 23.5]] + + +async def test_a_lost_old_version_is_named_not_silently_dropped(engine_up): + """旧版本的数据文件不在了(管理员手工删了):汇总照常查得出来、只是少了那一版,但不是悄悄少——发起时记进 data_versions 的 + older(missing),印章写明缺了哪一版、数不全;查询快照里同样记着;不在运行里的查询也一样。""" + from app.apps.resolve import _records_gap, _version_text + from app.core.artifact_store import load + from app.db.models import Artifact + from app.records.views import pin_entry + + app = await make_app() + v1 = await make_table(app) + add(v1, {"门店": "一店", "数量": 2}) + v1, v2 = await _publish_changed(app) + add(v2, {"门店": "三店", "数量": 7.5, "店长": "钱七"}) + source = await source_of(v2) + path_of(v1).unlink() + + async with SessionLocal() as session: + entry = await pin_entry(session, source) + now = await resolve(session, source) + assert entry["seq"] == 1 and entry["older"] == [{"version": 1, "seq": None, "missing": True}] + assert now.older == ({"version": 1, "seq": None, "missing": True},) and not now.attach + assert (await run_query(now, SUMMARY)).rows == [[1, 7.5]], "查得出来,只是没有第 1 版" + assert _version_text({**entry, "name": source.name}) == f"{source.name} 截至第 1 条" + assert _records_gap({**entry, "name": source.name}) == \ + f"记录表「{source.name}」第 1 版的数据文件不在了,汇总里没有这一版的记录,数不全" + + tool = f"db_query__{source.name}" + run = await run_manager.start(graph=chain(node("start", "input"), node("t", "tool", tool=tool, args={"sql": SUMMARY}), + node("out", "output", fields=[{"name": "t", "value": "{{ nodes.t }}"}])), + input_payload={}, run_class="formal") + done = await wait(run.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + assert json.loads(done.output["t"])["rows"] == [[1, 7.5]] + pinned = done.data_versions[source.id] + assert pinned["older"] == [{"version": 1, "seq": None, "missing": True}] and _records_gap(pinned) + async with SessionLocal() as session: + snaps = list((await session.execute(select(Artifact).where(Artifact.run_id == run.id, + Artifact.kind == "query_snapshot"))).scalars()) + assert snaps and load(snaps[0].id)["records_view"]["older"] == [{"version": 1, "seq": None, "missing": True}] + + +def test_union_keeps_numbers_widened_and_drops_meanings_that_changed(): + from app.records.store import Column, union_compatible + + assert union_compatible(Column("数量", type="number"), Column("数量", type="integer")) + assert union_compatible(Column("门店"), Column("门店")) + assert not union_compatible(Column("原因", type="boolean"), Column("原因")) + assert not union_compatible(Column("数量"), Column("数量", type="number")), "数改成文字:旧记录这一列为空" + + +async def test_runs_launched_before_a_version_is_retired_still_write_to_it(engine_up): + """停用之前发起、审批停在半路的实例,批完照常写进它发起时的那一版(同「进行中的实例照常走完」);停用之后发起的 + 运行不能往停用的版本写,一行都不落。""" + app = await make_app() + v1 = await make_table(app) + source = await source_of(v1) + graph = chain(node("start", "input", fields=[{"name": "store"}, {"name": "qty"}]), + node("gate", "human", mode="approve", title="批一下"), + node("w", "tool", tool=RECORDS_APPEND, approval="never", + args={"table": source.name, "values": {"门店": "{{ input.store }}", "数量": "{{ input.qty }}"}}), + node("out", "output", fields=[{"name": "id", "value": "{{ nodes.w.receipt._id }}"}])) + workflow_id = await writer_of(app, graph) + await _sync() + before = await run_manager.start(graph=graph, input_payload={"store": "一店", "qty": "3"}, run_class="formal", + started_by="amy", workflow_id=workflow_id) + assert (await wait(before.id)).status == "interrupted" + v1, v2 = await _publish_changed(app) + after = await run_manager.start(graph=graph, input_payload={"store": "二店", "qty": "4"}, run_class="formal", + started_by="amy", workflow_id=workflow_id) + assert (await wait(after.id)).status == "interrupted" + for run in (before, after): + await run_manager.resume(run.id, {"approved": True}) + + done = await wait(before.id, ("succeeded", "failed")) + assert done.status == "succeeded", done.error + [row], _ = store.rows(path_of(v1), columns_of(v1), scope="all", viewer=None) + assert row["_run_id"] == before.id and row["_schema_version"] == 1 and row["values"]["门店"] == "一店" + assert done.output["id"] == row["_id"] + + late = await wait(after.id, ("succeeded", "failed", "interrupted")) + assert late.status != "succeeded" + async with SessionLocal() as session: + [rec] = list((await session.execute(select(ActionRecord).where(ActionRecord.run_id == after.id))).scalars()) + assert rec.state == "failed" and "停用" in (rec.error or ""), (rec.state, rec.error) + assert store.high_water(path_of(v1)) == 1 and store.high_water(path_of(v2)) == 0, "停用之后发起的一行都没写" diff --git a/backend/tests/test_user_chat_access.py b/backend/tests/test_user_chat_access.py index e2dd29bc..d503a907 100644 --- a/backend/tests/test_user_chat_access.py +++ b/backend/tests/test_user_chat_access.py @@ -1,7 +1,10 @@ """开登录后使用者的问数据(D-c):能发起什么样的运行、查询结果在工具边界遮、运行的 WebSocket 按运行判、数据源名单。 -- 图:只许输入、模型、Agent、报告、成果、知识检索、数据整形、条件分支、口径卡、合并查询;Agent 只许数据源工具和只读的 - 内置工具(app/auth/chat_policy.py)。调用工具、沙箱代码、自定义工具、MCP、网页、HTTP、文件一律 403; +- 图:只许输入、模型、Agent、报告、成果、知识检索、数据整形、条件分支、口径卡、合并查询、调用工具;Agent 和调用工具 + 节点只许数据源工具和只读的内置工具(app/auth/chat_policy.py)。沙箱代码、自定义工具、MCP、网页、HTTP、文件、平台的 + 写入能力一律 403; +- 调用工具节点查数据源:和 Agent 同一条路,结果在工具边界遮、可写源上的写语句被守卫拒掉;能力治理里改成写入的工具 + 在只读运行里不执行; - 使用者发起的问数据运行一定带 MASK_RESULTS_OPTION:真的执行一次(演示模型调数据源查询),事件、快照、实时流里都没有 遮罩列的原值; - WebSocket:没登录的握手前关掉;使用者只能连自己能读、查询结果在工具边界遮过的运行(握手时判,1008 拒); @@ -54,6 +57,10 @@ def chat_graph(*tools: str, middle: dict | None = None) -> dict: "memory_recall"), chain(node("start", "input"), node("kb", "retrieve", query="x"), node("t", "transform", expression="1"), node("out", "output")), + # 调用工具节点:绑定的工具过同一份白名单就行(D1) + chat_graph(middle=node("ask", "tool", tool="db_query__crm", args={"sql": "SELECT city FROM customers"})), + chat_graph(middle=node("ask", "tool", tool="calculator", args={"expression": "1 + 1"})), + chat_graph(middle=node("ask", "tool", tool="kb_search", args={"query": "退款规则"})), {"nodes": "坏的"}, # 不成形的图交给 launch_run 照常报校验问题 ]) def test_graphs_a_user_may_run_in_chat(graph): @@ -62,7 +69,11 @@ def test_graphs_a_user_may_run_in_chat(graph): @pytest.mark.parametrize("graph, said", [ (chat_graph(middle=node("ask", "code", code="print(1)")), "沙箱代码"), - (chat_graph(middle=node("ask", "tool", tool="calculator")), "调用工具"), + (chat_graph(middle=node("ask", "tool", tool="http_request")), "http_request"), + (chat_graph(middle=node("ask", "tool", tool="records_append")), "records_append"), + (chat_graph(middle=node("ask", "tool", tool="memory_write")), "memory_write"), + (chat_graph(middle=node("ask", "tool", tool="mcp:fs/read_file")), "mcp:fs/read_file"), + (chat_graph(middle=node("ask", "tool", tool="{{ vars.tool }}")), "模板"), (chat_graph(middle=node("ask", "supervisor", agents=[])), "多 Agent 协作"), (chat_graph(middle=node("ask", "subgraph", workflow_id="w")), "子工作流"), (chat_graph(middle=node("ask", "human", mode="approve")), "人工审批"), @@ -85,7 +96,11 @@ def test_graphs_a_user_may_not_run_in_chat(graph, said): chat_graph(middle=node("ask", "agent", prompt="x", tools=["memory_recall"], memory_scope="builders")), chat_graph(middle=node("kb", "retrieve", query="x", collection="hr")), {**chat_graph("kb_search"), "defaults": {"collection": "hr"}}, + # 调用工具节点的参数直接交给工具:在参数里挑知识库同样不许 + chat_graph(middle=node("kb", "tool", tool="kb_search", args={"query": "x", "collection": "hr"})), {**chat_graph("memory_recall"), "defaults": {"memory_scope": " "}}, + # 节点上没写参数、运行时取图级 defaults 里的 args(和运行时同一个取值规则,engine/launch.node_setting) + {**chat_graph(middle=node("kb", "tool", tool="kb_search")), "defaults": {"args": {"query": "x", "collection": "hr"}}}, ]) def test_users_may_not_pick_memory_or_knowledge_scope(graph): """记忆、知识库的内容不经遮罩:节点上、图级 defaults 里都不许指定范围,用管理员设好的默认值。""" @@ -268,6 +283,181 @@ async def test_a_users_chat_run_cannot_write_a_writable_source(world, monkeypatc assert sqlite3.connect(path).execute("SELECT COUNT(*) FROM ledger").fetchone()[0] == 0, "搭建者照旧能写" +async def _writable_masked_source(tmp_path) -> tuple[str, object]: + """可写的客户库(手机号是遮罩列):调用工具节点查它、想删它。""" + path = tmp_path / f"crm_rw_{uuid.uuid4().hex[:6]}.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT, city TEXT);" + f"INSERT INTO customers VALUES (1, '张三', '{PHONE}', '杭州'), (2, '李四', '13900000002', '宁波');") + db.commit() + db.close() + name = f"crm_rw_{uuid.uuid4().hex[:6]}" + async with SessionLocal() as session: + row = DataSource(name=name, kind="sqlite", database=str(path), readonly=False, enabled=True, + description="客户(可写)", options={"mask_columns": "phone"}) + session.add(row) + await session.flush() + row.schema_cache = await introspect.introspect(row) + await session.commit() + return name, path + + +def _tool_chat(tool: str, sql: str) -> dict: + """问数据当场搭的图:取数用调用工具节点(D1),不经 Agent、不调模型。approval=never 是发起人自己写的:只读不交给 + 审批,写成 never 也照样拒写。""" + return chain(node("start", "input", fields=[{"name": "question"}]), + node("q", "tool", tool=tool, args={"sql": sql}, fail_fast=False, approval="never"), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.q }}"}])) + + +async def test_a_users_tool_node_is_masked_and_read_only(world, tmp_path): + """调用工具节点这条路和 Agent 一样(D1):使用者问数据的运行里,结果在工具边界就遮(事件、查询快照、运行产出里都没有 + 原值),可写源上的写语句被守卫拒掉、一行没少(节点上写了免审批也一样);搭建者发起的同一张写图照旧能写。""" + from app.core.artifact_store import load + from app.tools.datasource import FORCED_READONLY, READ_ONLY_OPTION + + await _allow_chat(world) + name, path = await _writable_masked_source(tmp_path) + tool = f"db_query__{name}" + + r = await world["uma"].post("/api/runs", json={"origin": "chat", "input": {"question": "客户"}, + "graph": _tool_chat(tool, "SELECT name, phone, city FROM customers")}) + assert r.status_code == 201, r.text + run = await _finish(r.json()["id"]) + assert run.status == "succeeded", run.error + assert run.options[MASK_RESULTS_OPTION] is True and run.options[READ_ONLY_OPTION] is True + async with SessionLocal() as s: + events = list((await s.execute(select(RunEvent).where(RunEvent.run_id == run.id))).scalars()) + snaps = list((await s.execute(select(Artifact).where(Artifact.run_id == run.id, + Artifact.kind == "query_snapshot"))).scalars()) + dumped = json.dumps([e.data for e in events], ensure_ascii=False) + json.dumps(run.output, ensure_ascii=False) + assert "tool.end" in {e.type for e in events} + assert PHONE not in dumped and MASKED in dumped and "张三" in dumped, "遮罩列换掉,别的列照旧" + assert snaps and all(PHONE not in json.dumps(load(snap.id), ensure_ascii=False) for snap in snaps) + + delete_all = _tool_chat(tool, "DELETE FROM customers") + r = await world["uma"].post("/api/runs", json={"origin": "chat", "input": {"question": "清空"}, "graph": delete_all}) + assert r.status_code == 201, r.text + run = await _finish(r.json()["id"]) + async with SessionLocal() as s: + events = list((await s.execute(select(RunEvent).where(RunEvent.run_id == run.id))).scalars()) + assert FORCED_READONLY[:12] in json.dumps([e.data for e in events], ensure_ascii=False), "写语句被守卫拒掉" + assert sqlite3.connect(path).execute("SELECT COUNT(*) FROM customers").fetchone()[0] == 2 + + r = await world["bob"].post("/api/runs", json={"origin": "chat", "input": {"question": "清空"}, "graph": delete_all}) + assert r.status_code == 201, r.text + await _finish(r.json()["id"]) + assert sqlite3.connect(path).execute("SELECT COUNT(*) FROM customers").fetchone()[0] == 0, "搭建者照旧能写" + + +async def test_a_tool_governed_as_write_is_not_run_in_a_read_only_run(world, tmp_path): + """能力治理把数据源查询改成「写入」:使用者问数据里的调用工具节点不进动作流程(探索运行在那里会演练、交回假回执), + 直接拒、说清是只读运行;表里的行一条没动。""" + from app.db.models import ActionRecord, Capability + + await _allow_chat(world) + name, path = await _writable_masked_source(tmp_path) + tool = f"db_query__{name}" + async with SessionLocal() as s: + s.add(Capability(name=tool, system=name, title="客户库", effect_system="dynamic", effect="write")) + await s.commit() + try: + r = await world["uma"].post("/api/runs", json={"origin": "chat", "input": {"question": "清空"}, + "graph": _tool_chat(tool, "DELETE FROM customers")}) + assert r.status_code == 201, r.text + run = await _finish(r.json()["id"]) + assert run.status == "failed" and "只能读数据" in (run.error or "") and tool in run.error, run.error + async with SessionLocal() as s: + assert not list((await s.execute(select(ActionRecord).where(ActionRecord.run_id == run.id))).scalars()) + assert sqlite3.connect(path).execute("SELECT COUNT(*) FROM customers").fetchone()[0] == 2 + finally: + async with SessionLocal() as s: + await s.execute(delete(Capability).where(Capability.name == tool)) + await s.commit() + + +async def test_kb_search_keeps_to_the_runs_knowledge_base_when_the_scope_is_fixed(monkeypatch): + """工具层的那一道(D1):范围固定的运行里 kb_search 不认 collection 参数——直接写的、参数名写错被纠成 collection 的都拒, + 不悄悄改查默认库;没写的照常查运行定下的那一个。范围不固定(搭建者的运行)照旧按参数查。""" + from app.memory import kb + from app.tools.registry import ToolArgsError, ToolContext, Versions, call_tool + + searched: list[str] = [] + + async def _search(session, *, collection, query, limit, **_): + searched.append(collection) + return [] + + monkeypatch.setattr(kb, "search", _search) + fixed = ToolContext(collection="default", fixed_scope=True, data_versions=Versions.CURRENT) + for args in ({"query": "x", "collection": "hr"}, {"query": "x", "limit": 5, "coll": "hr"}): + with pytest.raises(ToolArgsError) as refused: + await call_tool("kb_search", args, fixed) + assert "只能检索管理员设好的知识库" in str(refused.value) and "hr" in str(refused.value) + assert searched == [] + await call_tool("kb_search", {"query": "x"}, fixed) + await call_tool("kb_search", {"query": "x", "collection": "default"}, fixed) + await call_tool("kb_search", {"query": "x", "collection": "hr"}, + ToolContext(collection="default", data_versions=Versions.CURRENT)) + assert searched == ["default", "default", "hr"] + + +def _kb_search_spy(monkeypatch) -> list[str]: + from app.memory import kb + + searched: list[str] = [] + + async def _search(session, *, collection, query, limit, **_): + searched.append(collection) + return [{"content": f"{collection} 库里的一段", "score": 1.0}] + + monkeypatch.setattr(kb, "search", _search) + return searched + + +async def test_a_users_run_cannot_reach_another_knowledge_base_through_the_tool_layer(world, monkeypatch): + """白名单认不出的两种写法,运行时在工具层拒(D1):调用工具节点的参数名写错(coll 被纠成 collection)、Agent 在参数里 + 挑知识库。使用者的问数据只查得到运行定下的知识库;搭建者发起的同一张图照旧按参数查。""" + from app.providers import mock_model + + await _allow_chat(world) + searched = _kb_search_spy(monkeypatch) + typo = chain(node("start", "input", fields=[{"name": "question"}]), + node("kb", "tool", tool="kb_search", args={"query": "x", "limit": 5, "coll": "hr"}), + node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.kb }}"}])) + assert chat_graph_problem(typo) is None, "白名单只看字面写的参数名,这一道在工具层" + r = await world["uma"].post("/api/runs", json={"origin": "chat", "graph": typo, "input": {"question": "x"}}) + assert r.status_code == 201, r.text + run = await _finish(r.json()["id"]) + assert run.status == "failed" and "只能检索管理员设好的知识库" in (run.error or ""), run.error + assert "hr" not in searched + + replies: list[str] = [] + + def _decide(self, messages): + if seen := [m for m in messages if isinstance(m, ToolMessage)]: + replies.append(str(seen[-1].content)) + return AIMessage(content="好了。") + return AIMessage(content="", tool_calls=[{"name": "kb_search", "args": {"query": "薪酬", "collection": "hr"}, + "id": "call_1"}]) + + monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide) + r = await world["uma"].post("/api/runs", json={"origin": "chat", "graph": chat_graph("kb_search"), + "input": {"question": "薪酬"}}) + assert r.status_code == 201, r.text + run = await _finish(r.json()["id"]) + assert run.status == "succeeded", run.error + assert replies and "只能检索管理员设好的知识库" in replies[-1], replies + assert "hr" not in searched + + replies.clear() + r = await world["bob"].post("/api/runs", json={"origin": "chat", "graph": chat_graph("kb_search"), + "input": {"question": "薪酬"}}) + assert r.status_code == 201, r.text + assert (await _finish(r.json()["id"])).status == "succeeded" + assert searched == ["hr"] and "hr 库里的一段" in replies[-1], "搭建者的运行照旧按参数查" + + async def test_a_users_chat_run_keeps_to_its_own_memory_scope(world): """请求体里带记忆范围、知识库的拒;不带的,记忆是这位使用者自己的范围,知识库是设置里的默认值。""" await _allow_chat(world) @@ -287,10 +477,12 @@ async def test_a_users_chat_run_keeps_to_its_own_memory_scope(world): class _Scripted: - """第一轮按节点手册的标准写法搭(tool 节点查库),收到自查请求时改成 Agent 查库。记下 system 和每一轮的请求。""" + """第一轮按节点手册的标准写法搭(tool 节点调 fetch_tool,缺省是查库),收到自查请求时改成 Agent 查库。记下 system 和 + 每一轮的请求。""" - def __init__(self, source: str) -> None: + def __init__(self, source: str, fetch_tool: str | None = None) -> None: self.source = source + self.fetch_tool = fetch_tool or f"db_query__{source}" self.systems: list[str] = [] self.humans: list[str] = [] @@ -308,7 +500,7 @@ class _Scripted: {"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {"fields": [{"name": "question"}]}}}, {"op": "add_node", "node": {"id": "fetch", "type": "tool", "label": "查库", - "config": {"tool": f"db_query__{self.source}", + "config": {"tool": self.fetch_tool, "args": {"sql": "SELECT city FROM customers"}}}}, {"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", "config": {"fields": [{"name": "回答", "value": "{{ last_message }}"}]}}}, @@ -344,13 +536,21 @@ async def _generate(client, monkeypatch, model: _Scripted) -> list[dict]: async def test_generation_for_a_user_keeps_to_the_chat_policy(world, monkeypatch): - """使用者问数据时,提示词先说清只能 Agent 查库;照标准写法搭出的 tool 节点图按 chat_policy 交回去改,改好才交付、 - 自动运行。搭建者照旧按节点手册来,tool 节点不算问题。""" + """使用者问数据时,提示词先说清只能用数据源查询和只读的内置工具;tool 节点查库照标准写法搭,一次就过、自动运行(D1)。 + tool 节点绑了不许的工具(http_request)按 chat_policy 交回去改,改好才交付。搭建者照旧按节点手册来。""" await _allow_chat(world) model = _Scripted(world["source"]) events = await _generate(world["uma"], monkeypatch, model) assert "这位用户是「使用者」" in model.systems[0] and "- http_request" not in model.systems[0] - assert len(model.humans) == 2 and "调用工具" in model.humans[1], "tool 节点按 chat_policy 交回去改" + assert "tool 节点和 agent 都只能用" in model.systems[0] + assert len(model.humans) == 1, "tool 节点查库不算问题" + final = next(e for e in events if e.get("op") == "final") + assert final["autorun"] is True and "tool" in {n["type"] for n in final["graph"]["nodes"]} + assert chat_graph_problem(final["graph"]) is None + + model = _Scripted(world["source"], fetch_tool="http_request") + events = await _generate(world["uma"], monkeypatch, model) + assert len(model.humans) == 2 and "http_request" in model.humans[1], "绑了不许的工具按 chat_policy 交回去改" check = [e for e in events if e.get("op") == "check"][-1] final = next(e for e in events if e.get("op") == "final") assert check["status"] == "passed" and final["autorun"] is True @@ -367,9 +567,9 @@ async def test_generation_for_a_user_keeps_to_the_chat_policy(world, monkeypatch async def test_generation_for_a_user_reports_what_it_could_not_fix(world, monkeypatch): """改不好的照实交付:自查失败、final 里列出 chat.not_allowed,不自动运行。""" await _allow_chat(world) - model = _Scripted(world["source"]) - model._lines = lambda repair: _Scripted(world["source"])._lines(False) if not repair else [ - json.dumps({"op": "done", "explanation": "没改"}, ensure_ascii=False)] + model = _Scripted(world["source"], fetch_tool="http_request") + model._lines = lambda repair: _Scripted(world["source"], fetch_tool="http_request")._lines(False) if not repair \ + else [json.dumps({"op": "done", "explanation": "没改"}, ensure_ascii=False)] events = await _generate(world["uma"], monkeypatch, model) check = [e for e in events if e.get("op") == "check"][-1] final = next(e for e in events if e.get("op") == "final") @@ -448,9 +648,12 @@ async def test_source_names_for_the_chat_page(world): assert (await uma.get("/api/datasources")).status_code == 403 await _allow_chat(world) body = (await uma.get("/api/datasources/names")).json() - assert [i["name"] for i in body["items"]] == [world["source"]], "只列启用着的" - assert set(body["items"][0]) == {"id", "name", "description", "kind"}, "连接信息不在名单里" - assert body["items"][0]["description"] == "客户" and body["items"][0]["kind"] == "sqlite" + # 同一个测试进程里别的测试留下的数据源也会在名单里:只核对这里建的两个(一个启用、一个停用) + names = [i["name"] for i in body["items"]] + assert world["source"] in names and not [n for n in names if n.startswith("off_")], "只列启用着的" + item = next(i for i in body["items"] if i["name"] == world["source"]) + assert set(item) == {"id", "name", "description", "kind"}, "连接信息不在名单里" + assert item["description"] == "客户" and item["kind"] == "sqlite" assert (await uma.get("/api/datasources")).status_code == 403, "完整的数据源列表照旧只给搭建者" assert (await bob.get("/api/datasources/names")).status_code == 200 diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 713e9ab5..44f603c0 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -5,7 +5,7 @@ * 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。 */ import type { - AppBindings, AppDetail, AppGrant, AppHookRow, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, + AppBindings, AppDetail, AppGrant, AppHookRow, AppList, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, } from '../typesApp' import type { ConversationDetail, EvidenceSegmentDetail } from '../types' @@ -16,7 +16,7 @@ const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURICompone export const apps = { catalog: (opts?: RequestOptions) => get('/apps/catalog', opts), - list: (opts?: RequestOptions) => get<{ items: AppSummary[] }>('/apps', opts), + list: (opts?: RequestOptions) => get('/apps', opts), get: (id: string, opts?: RequestOptions) => get(at(id), opts), create: (body: { name: string; description?: string; spec?: AppSpec }) => post('/apps', body), /** 从工作流版本确定性派生一份应用草稿(不落库),编辑器拿去预览 / 新建 */ diff --git a/frontend/src/api/blueprintCopilot.ts b/frontend/src/api/blueprintCopilot.ts index 9e20aa81..45c40993 100644 --- a/frontend/src/api/blueprintCopilot.ts +++ b/frontend/src/api/blueprintCopilot.ts @@ -14,6 +14,7 @@ * 开流之前就被拒(4xx/5xx)时 onEnd 带服务端那句话、状态码和机读码;流到一半断了和一开始就连不上一样,说无法连接服务。 * 请求函数从 ./http 导入,不要从 ./client 导入(循环导入,见 http.ts 开头)。 */ +import type { BlueprintStreamEvent, FillJointsEvent } from '../typesBlueprint' import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, codeOf, describeDetail, isGatewayFailure, parseBody, report } from './http' export interface BlueprintStreamRequest { @@ -31,8 +32,8 @@ export interface FillJointsRequest { export type StreamEnd = (error?: string, info?: { status?: number; code?: string; aborted?: boolean }) => void -/** 一条 SSE:返回取消函数 */ -function sse(path: string, body: unknown, onOp: (op: any) => void, onEnd: StreamEnd): () => void { +/** 一条 SSE:返回取消函数。帧的形状以服务端为准(typesBlueprint 的 BlueprintStreamEvent、FillJointsEvent),这里不逐帧核对 */ +function sse(path: string, body: unknown, onOp: (op: E) => void, onEnd: StreamEnd): () => void { const controller = new AbortController() void (async () => { try { @@ -94,8 +95,8 @@ function sse(path: string, body: unknown, onOp: (op: any) => void, onEnd: Stream return () => controller.abort() } -export const streamBlueprint = (body: BlueprintStreamRequest, onOp: (op: any) => void, onEnd: StreamEnd) => +export const streamBlueprint = (body: BlueprintStreamRequest, onOp: (op: BlueprintStreamEvent) => void, onEnd: StreamEnd) => sse('/copilot/blueprint-stream', body, onOp, onEnd) -export const streamFillJoints = (body: FillJointsRequest, onOp: (op: any) => void, onEnd: StreamEnd) => +export const streamFillJoints = (body: FillJointsRequest, onOp: (op: FillJointsEvent) => void, onEnd: StreamEnd) => sse('/copilot/fill-joints-stream', body, onOp, onEnd) diff --git a/frontend/src/apps/ViewRenderer.tsx b/frontend/src/apps/ViewRenderer.tsx index 49f40d0b..26ce0974 100644 --- a/frontend/src/apps/ViewRenderer.tsx +++ b/frontend/src/apps/ViewRenderer.tsx @@ -44,9 +44,8 @@ export interface ViewEditMarks { const EditMarksCtx = createContext(null) -/** 服务端按草稿解析时给隐藏的组件带的标记(发布版里没有这样的组件) */ -const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => - (node as ViewNode & { hidden?: boolean }).hidden === true || !!marks?.hidden.has(node.id) +/** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */ +const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id) export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) { const [evidence, setEvidence] = useState(null) diff --git a/frontend/src/apps/components/display.tsx b/frontend/src/apps/components/display.tsx index f7a841ec..6f86c1ba 100644 --- a/frontend/src/apps/components/display.tsx +++ b/frontend/src/apps/components/display.tsx @@ -129,7 +129,8 @@ function pointLabel(asOf: string, sameDay: boolean): string { /** * 趋势:服务端按处置给好线和断点——dual 两条线(旧版本、新版本),incomparable 两段之间断开并写明前后不可比, - * recompute 只给新版本、写一句说明。点一个点打开那一次运行里这个指标的出处 + * recompute 只给新版本、写一句说明;试运行预览里只有一个点,服务端写在 note 里的说明照写。点一个点打开那一次运行里 + * 这个指标的出处 */ export function SeriesBlock({ node }: NodeProps) { const { openEvidence } = useView() @@ -161,6 +162,8 @@ export function SeriesBlock({ node }: NodeProps) { if (index > 0 && index < keys.length) brk.push({ index, label: VIEW_TEXT.incomparable }) if (str(b?.text)) extra.push(str(b.text)) } + // 服务端照实写的说明:试运行预览里只有这一次试运行一个点,写明没有趋势 + if (str(data.note)) extra.push(str(data.note)) return { categories: keys.map((k) => pointLabel(k, sameDay)), series: lines, breaks: brk, notes: extra } }, [data, label]) const unit = str(data.unit) diff --git a/frontend/src/apps/components/records.tsx b/frontend/src/apps/components/records.tsx index 5449a516..1e347222 100644 --- a/frontend/src/apps/components/records.tsx +++ b/frontend/src/apps/components/records.tsx @@ -22,14 +22,16 @@ import { RECORDS_TEXT as T } from '../../lib/termsApp' import type { RecordRow, RecordsPage } from '../../typesBlueprint' import { str, useView, type NodeProps } from '../context' -/** 服务端可能给的下一页游标、状态(typesBlueprint.RecordsPage 还没有这两项,契约待补):empty 是这个应用还没有记录表 */ -type Page = RecordsPage & { next_cursor?: string | null; state?: string } - -/** 服务端解析页面时给的第一页(node.data):形状对得上才用 */ -function pageOf(data: unknown): Page | null { - return data && typeof data === 'object' && Array.isArray((data as Page).items) && Array.isArray((data as Page).columns) - ? data as Page : null +/** + * 服务端解析页面时给的第一页(node.data,形状同 RecordsPage):形状对得上才用。state=empty 是这个应用还没有记录表, + * preview 是按草稿预览(不列真实的记录) + */ +function isPage(data: unknown): data is RecordsPage { + if (!data || typeof data !== 'object') return false + const d: { items?: unknown; columns?: unknown } = data + return Array.isArray(d.items) && Array.isArray(d.columns) } +const pageOf = (data: unknown): RecordsPage | null => (isPage(data) ? data : null) /** 一格的值写给人看:文件是可下载的链接,数组用顿号连,布尔写是或否,其余原样 */ function Value({ v }: { v: unknown }) { @@ -56,7 +58,7 @@ export function RecordsList({ node }: NodeProps) { const table = str(node.props.table) || undefined const who = node.props.who === 'mine' || node.props.who === 'all' ? node.props.who : undefined const initial = pageOf(node.data) - const [page, setPage] = useState(initial) + const [page, setPage] = useState(initial) const [error, setError] = useState(null) const [more, setMore] = useState(false) const [query, setQuery] = useState('') @@ -82,7 +84,7 @@ export function RecordsList({ node }: NodeProps) { if (!page?.next_cursor) return setMore(true) try { - const next: Page = await api.personal.records(appId, { table, who, cursor: page.next_cursor }) + const next = await api.personal.records(appId, { table, who, cursor: page.next_cursor }) setPage({ ...next, items: [...page.items, ...next.items] }) } catch (e) { setError(e) @@ -124,12 +126,15 @@ export function RecordsList({ node }: NodeProps) { {page && page.items.length > 0 && ( {T.count(rows.length, page.items.length)} )} - + {page?.state !== 'preview' && ( + + )} - {page?.state === 'empty' && !page.items.length ?
{T.tableMissing}
+ {page?.state === 'preview' ?
{T.preview}
+ : page?.state === 'empty' && !page.items.length ?
{T.tableMissing}
: error && !page ? : !page ?
: ( diff --git a/frontend/src/components/FileInput.tsx b/frontend/src/components/FileInput.tsx index 2ce97bc0..2678e238 100644 --- a/frontend/src/components/FileInput.tsx +++ b/frontend/src/components/FileInput.tsx @@ -20,7 +20,8 @@ import type { UploadProgress } from '../api/http' import { formatBytes } from '../lib/format' import { humanizeError } from '../lib/errors' import { FILE_TEXT } from '../lib/termsApp' -import { isFileRef, toFileRef, type FileLimits } from '../lib/schemaForm' +import { isFileRef, toFileRef } from '../lib/schemaForm' +import type { FileLimits } from '../typesApp' import type { FileRef } from '../typesBlueprint' import { Spinner } from './ui' diff --git a/frontend/src/lib/schemaForm.ts b/frontend/src/lib/schemaForm.ts index 5822bf1c..b08088a1 100644 --- a/frontend/src/lib/schemaForm.ts +++ b/frontend/src/lib/schemaForm.ts @@ -8,26 +8,28 @@ * 只有一层:顶层 object,字段是 string / number / integer / boolean,数组只装枚举字符串(多选)。 * * 文件(normal-user-apps N9、§3.8):字段 type 为 object、x-widget 为 file,只有这个控件能用 object。值是上传后拿回的 - * 引用 {file_id, name, size, sha256, type},规则和服务端 apps/forms.py 同一份:必填,形状是文件引用。允许的类型和大小 - * 写在 x-file(accept、max_mb),只在上传时用,提交时不再查(上传那一步服务端已经核对过)。 + * 引用 {file_id, name, size, sha256, type},规则和服务端 apps/forms.py 同一份:必填,形状是文件引用,不合时错误的 code 是 + * file(同服务端 files/store.FILE_ERROR_CODE)。允许的类型和大小写在 x-file(accept、max_mb),只在上传时用,提交时不再查 + * (上传那一步服务端已经核对过)。 */ -import type { FormError, FormField, FormSchema, FormWidget } from '../typesApp' +import type { FileLimits, FormError, FormField, FormSchema, FormWidget } from '../typesApp' import type { FileRef } from '../typesBlueprint' import { FILE_TEXT, FORM_TEXT } from './termsApp' -/** 文件控件的限制:允许的类型(MIME 或扩展名)、大小上限(MB)。x-file 由服务端随表单给 */ -export interface FileLimits { accept?: string[]; max_mb?: number | null } - -/** 表单字段上的 x-file(typesApp.FormField 还没有这一项,见报告的接口约定请求) */ +/** + * 表单字段上的 x-file(FormField['x-file'])整理成控件用的样子:可选类型去掉空的,大小上限不是正数的当没写。 + * 服务端保存表单时已经按同一套规则查过(apps/forms._file_field_problems),这里只防手写的老表单 + */ export function fileLimitsOf(field: FormField): FileLimits { - const raw = (field as FormField & { 'x-file'?: unknown })['x-file'] - if (!raw || typeof raw !== 'object') return {} - const x = raw as { accept?: unknown; max_mb?: unknown } - const accept = Array.isArray(x.accept) ? x.accept.filter((a): a is string => typeof a === 'string' && !!a.trim()) - : typeof x.accept === 'string' ? x.accept.split(',').map((a) => a.trim()).filter(Boolean) : undefined + const x = field['x-file'] + if (!x || typeof x !== 'object') return {} + // 画布上手写的老图可能把可选类型写成一串逗号分隔的字 + const raw: unknown = x.accept + const accept = Array.isArray(raw) ? raw.filter((a): a is string => typeof a === 'string' && !!a.trim()) + : typeof raw === 'string' ? raw.split(',').map((a) => a.trim()).filter(Boolean) : undefined return { accept: accept?.length ? accept : undefined, - max_mb: typeof x.max_mb === 'number' && Number.isFinite(x.max_mb) && x.max_mb > 0 ? x.max_mb : null, + max_mb: typeof x.max_mb === 'number' && Number.isFinite(x.max_mb) && x.max_mb > 0 ? x.max_mb : undefined, } } @@ -44,6 +46,17 @@ export function toFileRef(raw: FileRef): FileRef { return { file_id: raw.file_id, name: raw.name, size: raw.size, sha256: raw.sha256, type: raw.type } } +/** 表单引用的写法(同服务端 apps/tree.FORM_REF_PREFIX):{"$ref": "#/forms/<名字>"} */ +const FORM_REF_PREFIX = '#/forms/' + +/** 内联的表单原样交回;引用换成 forms 里的那一份;引用不到的是 null(同服务端 apps/tree.resolve_form) */ +export function resolveForm(forms: Record | null | undefined, + form: FormSchema | { $ref: string } | null | undefined): FormSchema | null { + if (!form) return null + if (!('$ref' in form)) return form + return form.$ref.startsWith(FORM_REF_PREFIX) ? forms?.[form.$ref.slice(FORM_REF_PREFIX.length)] ?? null : null +} + /** 字段顺序:x-order 里写到的在前(按写的顺序),没写到的按 properties 的原顺序跟在后面;不存在的名字跳过 */ export function fieldOrder(schema: FormSchema): string[] { const props = schema.properties ?? {} @@ -54,8 +67,8 @@ export function fieldOrder(schema: FormSchema): string[] { /** 这个字段画成哪种控件:写了 x-widget 照它,没写按类型推 */ export function widgetOf(field: FormField): FormWidget { if (field['x-widget']) return field['x-widget'] - // 只有文件控件用 object(typesApp.FormField 的 type 还没列 object) - if ((field.type as string) === 'object') return 'file' + // 只有文件控件用 object + if (field.type === 'object') return 'file' switch (field.type) { case 'boolean': return 'checkbox' case 'array': return 'multiselect' @@ -139,7 +152,7 @@ export function validateForm(schema: FormSchema, values: Record function fieldError(field: FormField, v: unknown, required: boolean): Omit | null { if (isEmpty(v)) return required ? { code: 'required', message: FORM_TEXT.required } : null - if (widgetOf(field) === 'file') return isFileRef(v) ? null : { code: 'type', message: FILE_TEXT.invalid } + if (widgetOf(field) === 'file') return isFileRef(v) ? null : { code: 'file', message: FILE_TEXT.invalid } switch (field.type) { case 'string': { if (typeof v !== 'string') return { code: 'type', message: FORM_TEXT.type.string } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index bce668b1..979c7541 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -4934,6 +4934,8 @@ export const ORIGIN_LABEL: Record = { app_backfill: '按新口径补算', hook: '外部系统触发', api: '接口', + trial: '蓝图试运行', + personal_app: '个人应用刷新', } export const ORIGIN_TEXT = { @@ -5188,6 +5190,8 @@ export const NOTIFY_TEXT = { 'task.opened': '待办打开', 'task.escalated': '待办升级', 'task.expired': '待办超时', 'task.resolved': '待办处理完', 'action.unknown': '动作结果未知', 'action.failed': '动作失败', 'action.confirmed': '动作已生效', 'run.failed': '运行失败', 'run.finished': '运行结束', + // 个人应用提交审核有了结果(发布了或退回了),通知提交人:只对个人渠道、全局渠道有意义(服务端 notify/service.notify_review) + 'review.done': '我提交审核的应用有结果', } as Record, enabled: '启用', signed: '已签名', @@ -5624,7 +5628,7 @@ export const TASK_NOTIFY_TEXT = { export const SETTINGS_ROLE_TEXT = { subtitleUser: '个人的主题和通知', themeLocal: '主题只保存在当前浏览器中,更换设备后需重新选择', - prefsReadOnly: '运行默认值、证据裁判和核查的设置全站共用,只有管理员可以修改', + prefsReadOnly: '运行默认值、证据裁判、核查和应用的设置全站共用,只有管理员可以修改', notifyPersonal: '个人渠道只推送派给你的待办和你发起的实例的事件', } diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 9ae506fc..db982f63 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -65,11 +65,8 @@ export const APP_HOME_TEXT = { taskNotFound: '待办不存在,所属的运行可能已被删除', } -/** - * 待办的种类:看中断内容里的 kind,不看 mode(§3.7)。review 是个人应用提交审核(docs/design/normal-user-apps.md N7): - * typesApp 的 TaskKind 还没有它(契约待补),这里先按多一种写 - */ -export const TASK_KIND_LABEL: Record = { +/** 待办的种类:看中断内容里的 kind,不看 mode(§3.7)。review 是个人应用提交审核(docs/design/normal-user-apps.md N7) */ +export const TASK_KIND_LABEL: Record = { approve: '人工审批', input: '补充输入', edit: '改稿', @@ -547,6 +544,14 @@ export const FILE_TEXT = { /** 值不是上传后拿回的引用(前后端同一条:必填,形状是文件引用) */ invalid: '请上传文件', empty: '未上传', + // ---- 运行详情里读入附件的一行(file.extracted,服务端 files/text.py 的模板函数 file_text)。数由调用方按千分位写好 ---- + extracted: (name: string, chars: string | null) => { + const what = name ? `读入附件「${name}」` : '读入附件' + return chars == null ? what : `${what}${name ? '' : ' '}${chars} 字` + }, + extractedAll: (total: string) => `全文 ${total} 字,已全部读入`, + extractedCut: (chars: string, total: string) => `全文 ${total} 字,只读入了前 ${chars} 字,其余没有读入`, + extractFailed: (name: string) => (name ? `附件「${name}」抽不出文字` : '附件抽不出文字'), typeLabels: { 'application/pdf': 'PDF', 'application/msword': 'Word', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word', 'application/vnd.ms-excel': 'Excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel', @@ -586,6 +591,7 @@ export const RECORDS_TEXT = { more: '加载更多', reload: '重新读取', tableMissing: '这个应用还没有记录表:发布之后、有人提交过才会有记录', + preview: '按草稿预览:这里不列真实的记录,发布之后按看的人列出', yes: '是', no: '否', } @@ -625,6 +631,25 @@ export const PERSONAL_TEXT = { rejectedNote: (note: string) => `上次的审核已退回:${note}`, graphChanged: '蓝图在存成个人应用之后又改过:刷新按存下的那一版运行,要用改过的,请回到蓝图重新存成你的应用。', graphChangedGo: '回到蓝图', + switchedOff: '管理员关闭了「使用者可以建个人应用」:已有的结果还能看,暂时不能刷新、提交审核', + // ---- 管理员看别人的个人应用:只有名称、拥有者、时间,可以归档,看不到内容 + othersGroup: '使用者的个人应用', + othersGroupHint: '使用者自己建的个人应用。管理员只看得到名称、拥有者和时间,可以归档,看不到页面、运行和蓝图的内容', + owner: (who: string) => `拥有者:${who}`, + ownerUnknown: '拥有者未记录', + createdAt: (t: string) => `创建于 ${t}`, + updatedAt: (t: string) => `更新于 ${t}`, + stubTitle: (who: string) => `这是 ${who} 的个人应用`, + stubBody: '个人应用只有拥有者看得见。管理员看得到名称、拥有者和时间,可以归档,但看不到页面、运行和蓝图的内容。', + archive: '归档', + archiveTitle: (name: string) => `归档「${name}」?`, + archiveConsequences: [ + '拥有者的应用列表里不再列出它,不能再刷新或提交审核', + '它的蓝图和以前的刷新记录照常保留,你看不到它们的内容', + '目前没有恢复的操作', + ], + archiveConfirm: '归档个人应用', + archiveDone: (name: string) => `已归档「${name}」`, } /** 审核待办(个人应用提交审核,N7):处理人在待办里取一份共享副本,在副本上试运行、走发布单;不发布就退回 */ diff --git a/frontend/src/lib/termsBlueprint.ts b/frontend/src/lib/termsBlueprint.ts index 8e2fc13d..ddab443f 100644 --- a/frontend/src/lib/termsBlueprint.ts +++ b/frontend/src/lib/termsBlueprint.ts @@ -131,7 +131,6 @@ export const BLUEPRINT_TEXT = { templateOf: (label: string, version: number) => `${label}(模板 v${version})`, // 对到数据的项 dataTable: '表', - dataTableLabel: '中文名', dataGrain: '粒度', dataDate: '业务日期', dataReuseText: (metric: string, version: number) => `复用口径卡里的「${metric}」(v${version}),不重写指标式`, diff --git a/frontend/src/pages/apps/AppPage.tsx b/frontend/src/pages/apps/AppPage.tsx index 8723a10d..0d8638d0 100644 --- a/frontend/src/pages/apps/AppPage.tsx +++ b/frontend/src/pages/apps/AppPage.tsx @@ -5,8 +5,8 @@ * 每 5 秒重取一次,生成完自动换上;引用的版本退役了照实提示,入口暂停发起。 * * 另取一次应用详情:有 manage 的人点「编辑」在页面上直接改(地址上记 edit=1,见 PageEdit.tsx,normal-user-apps N6); - * 个人应用(scope 为 personal)顶上写明是个人应用,给「刷新」和「提交审核」(personal.tsx,N7)。详情取不到不挡页面: - * 照只能用的人画。 + * 个人应用(scope 为 personal)顶上写明是个人应用,给「刷新」和「提交审核」(personal.tsx,N7);开了登录的管理员打开别人的 + * 个人应用只拿到元数据,换成只写名称、拥有者、时间和「归档」的一页。详情取不到不挡页面:照只能用的人画。 */ import { useNavigate, useParams, useSearchParams } from 'react-router-dom' import { LayoutGrid, PencilLine, RotateCw, Settings2 } from 'lucide-react' @@ -14,14 +14,14 @@ import { api } from '../../api/client' import { ViewRenderer } from '../../apps/ViewRenderer' import { parseViewParams } from '../../apps/components/input' import { Notice, PageHeader, Skeleton, Spinner, Tabs } from '../../components/ui' -import { useAtLeast } from '../../store/auth' +import { loginOn, useAtLeast, useAuth } from '../../store/auth' import { usePageTitle } from '../../lib/notify' import { APP_TEXT, PAGE_EDIT_TEXT, PERSONAL_TEXT } from '../../lib/termsApp' import type { AppDetail } from '../../typesApp' import { AppMeta, BackLink, LoadError, appPath, loadHome, useLoad, usePoll } from './shared' import { BackfillPanel } from './backfill' import { PageEditor } from './PageEdit' -import { PersonalBar } from './personal' +import { PersonalBar, PersonalStubPage, isPersonalStub } from './personal' const POLL_MS = 5000 @@ -34,7 +34,9 @@ export function AppPage() { const [search, setSearch] = useSearchParams() const raw = search.get('params') const navigate = useNavigate() - const { data: detail, reload: reloadDetail } = useLoad((signal) => api.apps.get(appId, { signal }), [appId]) + const { data: detail, error: detailError, reload: reloadDetail } = useLoad((signal) => api.apps.get(appId, { signal }), [appId]) + // 开了登录的管理员打开的可能是别人的个人应用(只有元数据):等详情回来再决定画哪一种,免得先去取一次看不到的页面 + const mayBeStub = useAuth((s) => loginOn(s.me) && s.me?.role === 'admin') const editing = editableOf(detail) && search.get('edit') === '1' const setEditing = (on: boolean) => setSearch((prev) => { const next = new URLSearchParams(prev) @@ -51,6 +53,9 @@ export function AppPage() { navigate(`${appPath(appId)}/p/${encodeURIComponent(id)}${tail ? `?${tail}` : ''}`) } + // 别人的个人应用(开了登录的管理员):服务端只给元数据,页面、运行都看不到,不去取 + if (detail && isPersonalStub(detail)) return + if (mayBeStub && !detail && !detailError) return
if (editing && detail) { return ( api.apps.list({ signal }), []) if (error && !data) return if (!data) return - // 个人应用(N7)单独一组「我的应用」放在前面:只有拥有者看得见,服务端只把自己的交来 - const mine = data.items.filter((a) => a.scope === 'personal') + // 个人应用(N7)单独一组「我的应用」放在前面:只有拥有者看得见,服务端只把自己的交来。开了登录的管理员另看得到别人的 + // 个人应用在不在(服务端只给元数据,my_role 为空):单列一组「使用者的个人应用」放在最后,不链到页面,只给归档 + const others = data.items.filter(isPersonalStub) + const mine = data.items.filter((a) => a.scope === 'personal' && !isPersonalStub(a)) const shared = data.items.filter((a) => a.scope !== 'personal') - if (mine.length) { + if (mine.length || others.length) { return (
-
-

{PERSONAL_TEXT.group}

-

{PERSONAL_TEXT.groupHint}

-
    - {mine.map((a) => )} -
-
+ {mine.length > 0 && ( +
+

{PERSONAL_TEXT.group}

+

{PERSONAL_TEXT.groupHint}

+
    + {mine.map((a) => )} +
+
+ )} {shared.length > 0 && (

{PERSONAL_TEXT.sharedGroup}

@@ -181,6 +186,15 @@ function AppsTab() {
)} + {others.length > 0 && ( +
+

{PERSONAL_TEXT.othersGroup}

+

{PERSONAL_TEXT.othersGroupHint}

+
    + {others.map((a) => )} +
+
+ )}
) } diff --git a/frontend/src/pages/apps/PageEdit.tsx b/frontend/src/pages/apps/PageEdit.tsx index adaf6be2..55dade70 100644 --- a/frontend/src/pages/apps/PageEdit.tsx +++ b/frontend/src/pages/apps/PageEdit.tsx @@ -45,13 +45,10 @@ import { AppMeta, BackLink, LoadError, appPath } from './shared' interface Box { top: number; left: number; width: number; height: number } /** - * 服务端给的「草稿和当前发布版是否一致」(AppDetail.draft_changed,见报告的接口约定请求)。没给的当不知道: - * 只在这一次改过之后说「有未发布的改动」,不在浏览器里拿草稿和发布版逐项比(发布时服务端会规整 spec,比不准) + * 服务端给的「草稿和当前发布版是否一致」(AppDetail.draft_changed)。没给的当不知道:只在这一次改过之后说「有未发布的 + * 改动」,不在浏览器里拿草稿和发布版逐项比(发布时服务端会规整 spec,比不准) */ -const draftChangedOf = (d: AppDetail): boolean | null => { - const v = (d as AppDetail & { draft_changed?: unknown }).draft_changed - return typeof v === 'boolean' ? v : null -} +const draftChangedOf = (d: AppDetail): boolean | null => d.draft_changed ?? null /** 组件在界面上叫什么:属性里写的名字、标题,再退到目录里的中文名 */ function labelOf(comp: AppComponent | undefined, entry: CatalogComponent | undefined): string { diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index 60a6c568..d29cf615 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -344,7 +344,8 @@ export function NotifySection() { const [channels, setChannels] = useState(null) useEffect(() => { api.notify.channels().then(({ items }) => setChannels(items)).catch(() => setChannels([])) }, []) const set = (notify: typeof draft.notify) => setDraft((s) => ({ ...s, notify })) - const events = Object.keys(NOTIFY_TEXT.eventLabel) + // 「我提交审核的应用有结果」是发给提交人的,不归哪个应用:应用的通知里不列 + const events = Object.keys(NOTIFY_TEXT.eventLabel).filter((e) => e !== 'review.done') return (
diff --git a/frontend/src/pages/apps/personal.tsx b/frontend/src/pages/apps/personal.tsx index dde58eb8..c5298c15 100644 --- a/frontend/src/pages/apps/personal.tsx +++ b/frontend/src/pages/apps/personal.tsx @@ -3,47 +3,30 @@ * 结果遮罩、探索运行未经发布审核),「刷新」写明今天还剩几次,「提交审核」交给一位搭建者或一个组。 * * 印章上的「探索 · 未经发布审核」由服务端写,这里不另编(P1)。剩余次数以服务端为准:详情里给了(AppDetail.personal, - * 服务端 blueprints/personal.personal_state 的形状,typesApp 还没有它、契约待补)就写,刷新的回包里也带;不知道的不猜, - * 只说有上限。超了上限服务端回 429,照实说用完了。蓝图改过、还没重新存的(graph_changed),照实说刷新要先重新存。 + * 服务端 blueprints/personal.personal_state)就写,刷新的回包里也带;不知道的不猜,只说有上限。超了上限服务端回 429, + * 照实说用完了。蓝图改过、还没重新存的(graph_changed),照实说刷新要先重新存。 * * 审核状态同样以服务端为准:最近一条审核待办还在等(pending)写「审核中」;搭建者发布了(decision=published)写 * 「已有正式版本:去看看」,链到发布的那个应用;退回了写退回和原因,可以再提交。详情没给时看蓝图的状态(in_review)。 + * + * 别人的个人应用(开了登录的管理员看到的,my_role 为空):服务端只给名称、拥有者、状态、创建和更新时间(api/apps._personal_stub), + * 这里照它写,给「归档」;页面、运行、蓝图的内容都看不到,也不去取(PersonalStubCard、PersonalStubPage)。 */ import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' -import { FileCheck2, Hourglass, RotateCw, Send, UserRound } from 'lucide-react' +import { Archive, FileCheck2, Hourglass, LayoutGrid, RotateCw, Send, UserRound } from 'lucide-react' import { ApiError, api } from '../../api/client' import { PrincipalPicker, type Principal } from '../../components/PrincipalPicker' -import { Modal, Notice, Spinner, toast } from '../../components/ui' -import { PERSONAL_TEXT as T } from '../../lib/termsApp' -import type { AppDetail } from '../../typesApp' +import { Modal, Notice, PageHeader, Spinner, confirmDialog, toast } from '../../components/ui' +import { formatDateTime } from '../../lib/format' +import { APP_TEXT, PERSONAL_TEXT as T } from '../../lib/termsApp' +import type { AppDetail, AppSummary, PersonalReview } from '../../typesApp' import { blueprintPath } from '../blueprints/shared' -import { appPath } from './shared' - -/** 最近一条审核待办(服务端 personal_state.review):status 是待办的状态,decision 是结论(published / reject) */ -export interface PersonalReview { - task_id: string - status: 'pending' | 'resolving' | 'resolved' | 'cancelled' | 'expired' - decision?: 'published' | 'reject' | null - note?: string - /** 发布出来的正式应用(decision=published 时) */ - app_id?: string | null - to?: { kind: 'user' | 'group'; name: string }[] - resolved_at?: string | null -} - -/** 个人应用在详情里多给的(服务端 blueprints/personal.personal_state):今天还能刷新几次、蓝图改过没有、审核到哪了 */ -export interface PersonalInfo { - remaining_today?: number | null - daily_limit?: number | null - blueprint_status?: string | null - graph_changed?: boolean - review?: PersonalReview | null -} +import { BackLink, appPath } from './shared' /** 界面上的审核状态:在等、发布了、退回了;还没提交过(或审核取消、过期)为 null */ type ReviewView = - | { state: 'in_review'; to?: { kind: 'user' | 'group'; name: string }[] } + | { state: 'in_review'; to?: PersonalReview['to'] } | { state: 'published'; appId?: string | null } | { state: 'rejected'; note?: string } @@ -55,11 +38,11 @@ function reviewView(r: PersonalReview | null | undefined): ReviewView | null { return null } -export const personalOf = (d: AppDetail | undefined): PersonalInfo | null => - ((d as (AppDetail & { personal?: PersonalInfo }) | undefined)?.personal) ?? null - export function PersonalBar({ detail, onRefreshed }: { detail: AppDetail; onRefreshed: () => void }) { - const info = personalOf(detail) + const info = detail.personal ?? null + // 管理员关了开关:刷新、提交审核服务端都会拒,详情里照同一条规则给了 allowed(blueprints/personal.allowed),这里照它写明、 + // 按钮置灰。详情没给(老服务端)的不猜,按钮照常,由服务端拒并说清 + const allowed = info?.allowed !== false const [remaining, setRemaining] = useState(info?.remaining_today ?? null) const [busy, setBusy] = useState(false) const [review, setReview] = useState(reviewView(info?.review)) @@ -102,6 +85,7 @@ export function PersonalBar({ detail, onRefreshed }: { detail: AppDetail; onRefr return (
{T.banner} + {!allowed && {T.switchedOff}} {info?.graph_changed && detail.blueprint_id && ( {T.graphChanged}{' '} @@ -109,8 +93,8 @@ export function PersonalBar({ detail, onRefreshed }: { detail: AppDetail; onRefr )}
- @@ -137,7 +121,8 @@ export function PersonalBar({ detail, onRefreshed }: { detail: AppDetail; onRefr {review.note ? T.rejectedNote(review.note) : T.rejected} )} - @@ -198,3 +183,95 @@ function ReviewDialog({ appId, onClose, onSent }: { ) } + +// ---------------------------------------------------------------- 别人的个人应用(管理员) + +/** 别人的个人应用:开了登录的管理员看到的只有元数据(服务端 api/apps._personal_stub,my_role 为空) */ +export const isPersonalStub = (a: AppSummary): boolean => a.scope === 'personal' && a.my_role == null + +/** 归档别人的个人应用:确认框写明后果;成功交回服务端的那一份(同样只有元数据),取消或失败为 null */ +async function archiveStub(app: AppSummary): Promise { + if (!(await confirmDialog({ + title: T.archiveTitle(app.name), consequences: T.archiveConsequences, confirmLabel: T.archiveConfirm, danger: true, + }))) return null + try { + const next = await api.apps.archive(app.id) + toast.ok(T.archiveDone(next.name)) + return next + } catch (e) { + toast.error(e) + return null + } +} + +/** 拥有者、创建和更新时间:服务端只给这几样 */ +function StubMeta({ app }: { app: AppSummary }) { + return ( +
+

{app.owner ? T.owner(app.owner) : T.ownerUnknown}

+ {app.created_at &&

{T.createdAt(formatDateTime(app.created_at))}

} +

{T.updatedAt(formatDateTime(app.updated_at))}

+
+ ) +} + +function ArchiveButton({ app, onArchived }: { app: AppSummary; onArchived: (next: AppSummary) => void }) { + const [busy, setBusy] = useState(false) + if (app.status === 'archived') return {APP_TEXT.archived} + return ( + + ) +} + +/** 应用首页「使用者的个人应用」一组里的一张卡:不链到应用页(看不到内容),只写元数据,给「归档」 */ +export function PersonalStubCard({ app, onArchived }: { app: AppSummary; onArchived: () => void }) { + return ( +
  • +
    + + + +
    +
    {app.name}
    + +
    +
    +
    + {T.badge} + + +
    +
  • + ) +} + +/** 管理员打开别人的个人应用:写明看不到内容,只给元数据和「归档」,不去取页面 */ +export function PersonalStubPage({ app, onArchived }: { app: AppSummary; onArchived: () => void }) { + const who = app.owner ?? T.ownerUnknown + return ( +
    + } title={app.name} + actions={{T.badge}} /> +
    +
    +
    + + {T.stubTitle(who)}:{T.stubBody} + +
    +
    + +
    +
    +
    +
    + ) +} diff --git a/frontend/src/pages/blueprints/BlueprintCards.tsx b/frontend/src/pages/blueprints/BlueprintCards.tsx index da81053c..ef941ebe 100644 --- a/frontend/src/pages/blueprints/BlueprintCards.tsx +++ b/frontend/src/pages/blueprints/BlueprintCards.tsx @@ -342,16 +342,18 @@ function AddButton({ ctx, label, prompt, path, make }: { ) } -/** 「看哪些数」的一项:先写对到的数据(表、中文名、粒度、业务日期、为什么),再逐项列 */ +/** + * 「看哪些数」的一项:先写对到的数据(表、粒度、业务日期、为什么),再逐项列。表只写「源.表」(服务端 BpDataItem.table 是 + * {source, table}):表的中文名不在蓝图里,要看去数据目录,这里不另取 + */ function DataBlock({ ctx, item, index }: { ctx: CardCtx; item: BpDataItem; index: number }) { - const table = (item.table?.value ?? null) as { source?: string; table?: string; label?: string } | null - const date = item.date?.value as { column?: string; rule?: string } | null + const table = item.table?.value ?? null + const date = item.date?.value ?? null const head = table?.table ? (
    {BLUEPRINT_TEXT.dataTable}:{table.source}.{table.table} - {table.label && {BLUEPRINT_TEXT.dataTableLabel}:{table.label}} {item.grain?.value != null && {BLUEPRINT_TEXT.dataGrain}:{String(item.grain.value)}} {date?.column && {BLUEPRINT_TEXT.dataDate}:{valueText('date', date)}}
    @@ -448,9 +450,6 @@ function AccessCard({ ctx, note, issues }: { ctx: CardCtx; note?: string; issues // ---------------------------------------------------------------- 关节 -/** 关节的说明:服务端在关节状态上给了名字和种类时用它,没给写关节 id */ -type JointView = JointState & { label?: string | null; kind?: string | null } - /** 按卡片直接填好的确定性关节(表单、处理人、定时这些):不经模型,不逐个列 */ const deterministic = (j: JointState) => j.status === 'filled' && j.by === 'template' @@ -460,7 +459,7 @@ function Joints({ blueprintId }: { blueprintId: string }) { const bp = entry?.blueprint if (!bp) return null const fill = entry?.fill - const all = Object.entries(bp.joints ?? {}) as [string, JointView][] + const all = Object.entries(bp.joints ?? {}) // 只列生成关节(取数的 SQL、指标的算法):确定性关节按卡片填,汇总成一句 const joints = all.filter(([, j]) => !deterministic(j)) const fixed = all.length - joints.length @@ -479,8 +478,9 @@ function Joints({ blueprintId }: { blueprintId: string }) { setBusy(false) } } + // 关节的说法:模板声明的名字,没有就写种类和 id(实例化之前存下的关节没有这两项) const nameOf = (id: string) => { - const j = (bp.joints?.[id] ?? {}) as JointView + const j: Partial = bp.joints?.[id] ?? {} return j.label || (j.kind ? `${JOINT_KIND_LABEL[j.kind] ?? j.kind}(${id})` : id) } return ( diff --git a/frontend/src/pages/blueprints/BlueprintChat.tsx b/frontend/src/pages/blueprints/BlueprintChat.tsx index e6bb0820..79f6d2ee 100644 --- a/frontend/src/pages/blueprints/BlueprintChat.tsx +++ b/frontend/src/pages/blueprints/BlueprintChat.tsx @@ -16,10 +16,14 @@ import { decodeCopilot, type CopilotOp } from '../../run/decode' import { BLUEPRINT_TEXT } from '../../lib/terms' import { chatBusy, openAsk, useBlueprint, type BpTurn } from '../../store/blueprint' import type { ConversationTurn } from '../../types' +import type { BlueprintTurnMeta } from '../../typesBlueprint' -/** 落过库的一轮 → 显示用的一轮。meta.asked 是服务端记的反问 {question, options};answer 是回答或这一轮的说明 */ +/** + * 落过库的一轮 → 显示用的一轮。meta 的形状见 BlueprintTurnMeta(ConversationTurn.meta 是各种对话共用的宽类型,这里按蓝图 + * 对话的约定读,缺字段、类型不对的照样不崩);answer 是回答或这一轮的说明 + */ function pastTurn(t: ConversationTurn): StreamTurn { - const meta = (t.meta ?? {}) as { looks?: CopilotOp[]; asked?: { question?: string; options?: string[] } | null } + const meta: BlueprintTurnMeta = t.meta ?? {} const asked = meta.asked && typeof meta.asked === 'object' ? meta.asked : null const ops: CopilotOp[] = [...(Array.isArray(meta.looks) ? meta.looks.map((l) => ({ ...l, op: 'look' })) : [])] if (t.status !== 'error') ops.push({ op: 'done', explanation: t.explanation || '' }) diff --git a/frontend/src/pages/blueprints/NewAppStart.tsx b/frontend/src/pages/blueprints/NewAppStart.tsx index 65e0eebc..f7d86574 100644 --- a/frontend/src/pages/blueprints/NewAppStart.tsx +++ b/frontend/src/pages/blueprints/NewAppStart.tsx @@ -29,8 +29,6 @@ import { blueprintPath, useCreateScope } from './shared' /** 起点:建蓝图的请求体里 source 那一段 */ type Source = { kind: BlueprintSourceKind; id?: string; version?: number } -/** 模板说明:服务端给了会产出什么、要准备什么时用它的,没给时按模板 id 说(termsBlueprint.TEMPLATE_PRODUCES) */ -type TemplateCardInfo = TemplateInfo & { produces?: string | null; prepare?: string[] | null } export function NewAppStart() { const scope = useCreateScope() @@ -155,7 +153,7 @@ function TemplateStart({ scope, busy, onPick }: { }) { const { data, error, reload } = useLoad(() => api.templates.list(), []) // 「自由形」是从已有的运行或工作流导入用的,不作为从零开始的场景 - const items = (data?.items ?? []).filter((t) => t.id !== 'freeform') as TemplateCardInfo[] + const items = (data?.items ?? []).filter((t) => t.id !== 'freeform') return (
    @@ -177,14 +175,15 @@ function TemplateStart({ scope, busy, onPick }: { } function TemplateCard({ t, scope, busy, onPick }: { - t: TemplateCardInfo; scope: BlueprintScope; busy: string | null; onPick: (t: TemplateInfo) => void + t: TemplateInfo; scope: BlueprintScope; busy: string | null; onPick: (t: TemplateInfo) => void }) { // 个人应用只能用模板里只读的那部分;整个模板都用不了的(要派人审批、写外部系统)照实说,不给选 const blocked = scope === 'personal' && !t.personal - const produces = t.produces || TEMPLATE_PRODUCES[t.id] - const prepare = t.prepare?.length ? t.prepare - : t.cards.filter((c) => c.card !== 'purpose' && c.card !== 'template') - .map((c) => (c.note ? `${CARD_LABEL[c.card] ?? c.card}:${c.note}` : CARD_LABEL[c.card] ?? c.card)) + // 会产出什么:按模板 id 说(termsBlueprint.TEMPLATE_PRODUCES,模板说明里没有这一项);要准备什么:用模板说明里各张卡片的 + // 说明拼(服务端 TemplateInfo.cards) + const produces = TEMPLATE_PRODUCES[t.id] + const prepare = t.cards.filter((c) => c.card !== 'purpose' && c.card !== 'template') + .map((c) => (c.note ? `${CARD_LABEL[c.card] ?? c.card}:${c.note}` : CARD_LABEL[c.card] ?? c.card)) const key = `template:${t.id}` return (
  • diff --git a/frontend/src/pages/blueprints/PublishSummary.tsx b/frontend/src/pages/blueprints/PublishSummary.tsx index a47c801f..e5e2be62 100644 --- a/frontend/src/pages/blueprints/PublishSummary.tsx +++ b/frontend/src/pages/blueprints/PublishSummary.tsx @@ -23,10 +23,10 @@ import { appPath, usePoll } from '../apps/shared' const WHO: ProblemWho[] = ['you', 'assistant', 'admin'] /** - * 这条问题归谁:服务端在问题顶层给的 who(发布单按自动修复的种类、要管理员的编号算好的)为准,没有再看白话里登记的 - * 那一档。PlainIssue 还没有顶层的 who(契约待补),先按多一项读 + * 这条问题归谁:服务端在问题顶层给的 who(发布单按自动修复的种类、要管理员的编号算好的,没登记白话的也有)为准; + * 没给的(老服务端)再看白话里登记的那一档 */ -const whoOf = (p: PlainIssue): ProblemWho => (p as PlainIssue & { who?: ProblemWho }).who ?? p.plain?.who ?? 'you' +const whoOf = (p: PlainIssue): ProblemWho => p.who ?? p.plain?.who ?? 'you' const DISPOSITIONS: Disposition[] = ['recompute', 'dual', 'incomparable'] const POLL_MS = 1000 @@ -97,7 +97,8 @@ export function PublishSummary({ blueprintId, open, onClose, onFix }: { } const problems = plan?.problems ?? [] - const blocked = problems.some((p) => p.level === 'error') + // 能不能确认以服务端为准(ok:没有 error 级问题、也没停下),界面不另算 + const blocked = !!plan && !plan.ok const started = !!plan && plan.status !== 'planned' const scheduled = !!plan?.steps.some((s) => s.kind === 'create_schedule' && s.status !== 'skipped') const when = scheduled ? cadenceWhen(bp?.spec.cadence?.value) : '' @@ -264,16 +265,7 @@ function ProblemColumn({ who, items, onFix, onDispose, busy }: {
    {p.plain?.title ?? p.message}
    {p.plain?.why &&
    {p.plain.why}
    } {p.plain?.action &&
    {p.plain.action}
    } - {p.code === 'app.upgrade_policy' && typeof (p as { alias?: unknown }).alias === 'string' && ( -
    - {DISPOSITIONS.map((d) => ( - - ))} -
    - )} + {p.code === 'app.upgrade_policy' && p.alias && }
  • ))} @@ -286,3 +278,19 @@ function ProblemColumn({ who, items, onFix, onDispose, busy }: {
    ) } + +/** 钉住项升了版、要选处置的(app.upgrade_policy 带 alias):就地给三种处置,选了带着处置重出发布单 */ +function DispositionChoice({ alias, busy, onDispose }: { + alias: string; busy: boolean; onDispose: (alias: string, d: Disposition) => void +}) { + return ( +
    + {DISPOSITIONS.map((d) => ( + + ))} +
    + ) +} diff --git a/frontend/src/pages/blueprints/TrialSection.tsx b/frontend/src/pages/blueprints/TrialSection.tsx index a47bc376..ccde2e17 100644 --- a/frontend/src/pages/blueprints/TrialSection.tsx +++ b/frontend/src/pages/blueprints/TrialSection.tsx @@ -21,7 +21,7 @@ import { cleanValues, validateForm, withDefaults } from '../../lib/schemaForm' import { TRIAL_TEXT } from '../../lib/terms' import { TaskCard } from '../../run/TaskCard' import { useBlueprint } from '../../store/blueprint' -import type { AppSpec, FormError, FormSchema, ViewModel } from '../../typesApp' +import type { FormError, FormSchema, ViewModel } from '../../typesApp' import { useLoad, usePoll } from '../apps/shared' import { blueprintPath } from './shared' @@ -29,12 +29,11 @@ const POLL_MS = 4000 /** 流程类模板:试运行里有人工步骤,右侧要列待办 */ const PROCESS_TEMPLATES = new Set(['approval', 'exception_handling', 'reconcile_writeback']) -/** 应用骨架里第一个入口的表单(试运行的输入) */ -function entryForm(spec: AppSpec | null | undefined): FormSchema | null { - const form = spec?.entries?.[0]?.form - if (!form) return null - return '$ref' in form ? spec?.forms?.[String((form as { $ref: string }).$ref)] ?? null : form as FormSchema -} +/** + * 「换一期再试」填的表单:服务端随预览给(ViewModel.trial.form,blueprints/trial.retry_form,和试运行时认表单是同一条规则), + * 这里照着画,不自己从骨架里挑。没有的只能原样再试一次 + */ +const trialForm = (model: ViewModel | null | undefined): FormSchema | null => model?.trial?.form ?? null /** 视图里报错的那一块:服务端在来源上写了为什么 */ function failureOf(model: ViewModel | null | undefined): string | null { @@ -136,8 +135,7 @@ function TrialBanner({ blueprintId, runId, model, onRetried }: { }) { const entry = useBlueprint((s) => s.byId[blueprintId]) const last = entry?.lastTrial?.run_id === runId ? entry.lastTrial : null - // 输入表单:服务端随试运行给的,没给就用按卡片生成的应用骨架里的入口表单 - const form = last?.form ?? entryForm(entry?.instantiated?.app_spec) + const form = trialForm(model) const [open, setOpen] = useState(false) const [values, setValues] = useState>({}) const [errors, setErrors] = useState([]) diff --git a/frontend/src/pages/blueprints/shared.ts b/frontend/src/pages/blueprints/shared.ts index 5434b647..171ea35c 100644 --- a/frontend/src/pages/blueprints/shared.ts +++ b/frontend/src/pages/blueprints/shared.ts @@ -19,10 +19,10 @@ export const blueprintPath = (id: string, section?: 'trial', query?: Record = { +const KIND_ICON: Record = { approve: Hand, input: Keyboard, edit: PenLine, tool: Wrench, code: Code, dual_check: Hand, action_confirm: Send, reconcile: SearchCheck, signal: Webhook, review: UserCheck, } @@ -85,7 +82,7 @@ const STALE_MS = 24 * 3_600_000 export function TaskCard({ task, onResolved, compact = false, send, showOwner = true, showSignoffs = true, trial = false }: TaskCardProps) { const toast = useToast() const payload: Record = task.payload ?? {} - const kind = task.kind as CardKind + const kind = task.kind const pending = task.status === 'pending' const canAct = pending && task.can_respond const inquiring = useInquiryBusy(task.run_id) @@ -301,9 +298,10 @@ function Review({ task, canAct, busy, note, setNote, onReject }: { const me = usePrincipal() const actor = useLocalActor() const [copying, setCopying] = useState(false) - const payload: Record = task.payload ?? {} + const payload: Partial = task.payload ?? {} const blueprintId = typeof payload.blueprint_id === 'string' ? payload.blueprint_id : null - const copies: { id: string; owner: string | null; status: string }[] = Array.isArray(payload.copies) ? payload.copies : [] + const copies = Array.isArray(payload.copies) ? payload.copies : [] + // 审核副本归取它的那位搭建者:取过的打开自己那一份(别的处理人取的副本不算) const myName = me?.username ?? actor ?? null const mine = copies.find((c) => c.owner === myName) const submitter = typeof payload.submitted_by === 'string' ? payload.submitted_by : null @@ -382,7 +380,7 @@ function TaskMeta({ task, showOwner }: { task: Task; showOwner: boolean }) { const owner = !showOwner ? null : task.app_name ? task.app_name : task.workflow_name ? runName(task) : null return (
    - {TASK_KIND_LABEL[task.kind as CardKind] ?? task.kind} + {TASK_KIND_LABEL[task.kind] ?? task.kind} {owner && <>·{owner}} {where && <>·{TASK_TEXT.node(where)}} {created && <>·{TASK_TEXT.createdAt(formatTime(created))}} @@ -774,15 +772,22 @@ function Signer() { ) } -/** 去实例页(应用里的)或记录页(别处的,搭建者以上才看得到记录页) */ +/** + * 去实例页(应用里的)或记录页(别处的,搭建者以上才看得到记录页)。 + * + * 审核个人应用的待办不给这两个:run_id 是提交人那次个人应用的刷新、app_id 是他的个人应用,只有拥有者读得到(服务端 + * auth/access.private_owner,处理人是管理员、搭建者也不例外),链出去只会是打不开的页面。处理人要看的是取副本之后在副本 + * 蓝图上试运行出来的(Review 里的「取副本,去发布」「打开我的副本」) + */ function TaskLinks({ task }: { task: Task }) { const builder = useAtLeast('builder') const links = useMemo(() => { const out: { to: string; label: string }[] = [] + if (task.kind === 'review') return out if (task.app_id) out.push({ to: `/apps/${encodeURIComponent(task.app_id)}/i/${encodeURIComponent(task.run_id)}`, label: TASK_TEXT.openInstance }) if (builder) out.push({ to: `/runs/${encodeURIComponent(task.run_id)}`, label: TASK_TEXT.openRun }) return out - }, [task.app_id, task.run_id, builder]) + }, [task.kind, task.app_id, task.run_id, builder]) if (!links.length) return null return (
    diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 09111190..427e24bc 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -6,7 +6,7 @@ import { ABSENCE_CLAIM_TEXT, ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, ACTION_LOG_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, LOOK_TEXT, MERGE_TEXT, ORIGIN_TEXT, RUN_SQL_CHECK_TEXT, SQL_CHECK_TEXT, TASK_KIND_LABEL, TASK_LOG_TEXT, TASK_STATUS_LABEL, TASK_TEXT, TYPE_LABEL, originLabel, CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, JUDGE_TRUNCATED_TEXT, MASKED_CELL_TEXT, NODE_TYPE_LABEL, - VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, + VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -2798,6 +2798,28 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt break } + case 'file.extracted': { + // 模板函数 file_text 读入一个附件的文字(服务端 files/text.py):读入多少字、全文多少字、是否截断,读入的那一段存成 + // 工件可以点开;抽不出时 ok=false、reason 写原因。截断、抽不出都要看得见:下游拿到的不是全文 + const name = typeof d.name === 'string' ? d.name.trim() : '' + const chars = num(d.chars) + const total = num(d.total_chars) + const failedRead = d.ok === false + const cut = !failedRead && d.truncated === true + const reason = typeof d.reason === 'string' && d.reason.trim() ? d.reason.trim() : undefined + push({ + id: `fx-${seq}`, seq, kind: 'tool', nodeId, status: 'done', code: 'file_extracted', + ...(failedRead || cut ? { level: 'warn' as const } : {}), + title: failedRead ? FILE_TEXT.extractFailed(name) : FILE_TEXT.extracted(name, chars != null ? formatNumber(chars) : null), + ...(failedRead ? (reason ? { sub: reason } : {}) + : total == null ? {} + : !cut ? { sub: FILE_TEXT.extractedAll(formatNumber(total)) } + : chars != null ? { sub: FILE_TEXT.extractedCut(formatNumber(chars), formatNumber(total)) } : {}), + ...(typeof d.artifact === 'string' && d.artifact ? { artifact: d.artifact } : {}), + }, nodeId) + break + } + default: // 新增事件类型时不要静默吞掉——宁可显示一条,也比让人觉得"什么都没 // 发生"好。但内部类型名(agent.route.end 这种)对使用者是一串代码, @@ -3171,13 +3193,6 @@ export function catalogDrift(d: Record): { } } -/** - * 查看对象的类型。口径卡(caliber,docs/design/normal-user-apps.md N2:蓝图对到已发布的口径卡)typesApp 的 LookObject 还没有, - * 契约待补,先在这里按多一种认 - */ -type LookKind = LookObject['kind'] | 'caliber' -const kindOf = (o: LookObject): LookKind => o.kind as LookKind - /** * look 事件(L2,服务端 app/look/base.py 的形状)读进来:objects 按类型标记;字段缺了的补空,认不出的形状返回 null * (不出这一行,也不报错)。旧写法 sources[] 当作表类型读 @@ -3190,7 +3205,7 @@ export function lookEventOf(op: CopilotOp): LookEvent | null { const objects: LookObject[] = list .filter((x): x is Record => !!x && typeof x === 'object') .map((x) => { - const kind = (x.kind === 'capability' || x.kind === 'workflow_version' || x.kind === 'caliber' ? x.kind : 'table') as LookObject['kind'] + const kind: LookObject['kind'] = x.kind === 'capability' || x.kind === 'workflow_version' || x.kind === 'caliber' ? x.kind : 'table' const total = num(x.total) const explored = num(x.explored) return { @@ -3213,10 +3228,10 @@ export function lookEventOf(op: CopilotOp): LookEvent | null { } /** 这一类对象一共给了几个(表:点名的 + 挑出的 + 补上的,去重) */ -function lookCount(objects: LookObject[], kind: LookKind): number { +function lookCount(objects: LookObject[], kind: LookObject['kind']): number { const seen = new Set() for (const o of objects) { - if (kindOf(o) !== kind) continue + if (o.kind !== kind) continue for (const n of [...o.named, ...(o.picked ?? []), ...(o.joined ?? [])]) seen.add(`${o.source ?? ''}\u0000${n}`) } return seen.size @@ -3236,7 +3251,8 @@ function lookLine(o: LookObject): string { o.picked?.length ? LOOK_TEXT.picked(o.picked) : '', o.joined?.length ? LOOK_TEXT.joined(o.joined) : '', ].filter(Boolean) - const kind = kindOf(o) + // 口径卡(caliber)是蓝图对到的已发布口径卡上的指标(docs/design/normal-user-apps.md N2) + const kind = o.kind const head = kind === 'table' ? `「${o.source ?? ''}」` : kind === 'capability' ? '能力' : kind === 'caliber' ? '口径卡' : '工作流版本' const partial = o.total != null && o.explored != null ? `(${LOOK_TEXT.partial(o.total, o.explored)})` : '' return parts.length ? `${head}${partial}:${parts.join(';')}` : '' diff --git a/frontend/src/run/inputSchema.ts b/frontend/src/run/inputSchema.ts index 7d6c1d62..272b2a9a 100644 --- a/frontend/src/run/inputSchema.ts +++ b/frontend/src/run/inputSchema.ts @@ -8,7 +8,7 @@ * 文件字段(normal-user-apps N9):输入节点的字段写 type: file,转成 type 为 object、x-widget 为 file 的字段,允许的类型 * 和大小(accept、max_mb)进 x-file。值是上传后拿回的引用,没上传的不传。 */ -import type { FormField, FormSchema } from '../typesApp' +import type { FileLimits, FormField, FormSchema } from '../typesApp' /** 输入节点的一个字段(canvas/nodeDefs 的 ioFields;type / enum / format / 上下限是应用平台新加的,老图没有) */ export interface InputField { @@ -17,8 +17,8 @@ export interface InputField { description?: string default?: unknown example?: unknown - /** file:文件(上传后拿回的引用) */ - type?: FormField['type'] | 'file' + /** file:文件(上传后拿回的引用);表单里的 object 只给文件控件,输入节点上写 file */ + type?: Exclude | 'file' enum?: (string | number)[] format?: FormField['format'] /** 数字类型的上下限(闭区间,表单子集没有 exclusiveMinimum:「大于 0」的金额写 0.01) */ @@ -29,15 +29,15 @@ export interface InputField { max_mb?: number } -/** 文件字段:type 为 object(typesApp.FormField 还没列 object 和 x-file,见报告的接口约定请求) */ +/** 文件字段:type 为 object、x-widget 为 file,限制进 x-file(同服务端 apps/forms.input_schema) */ function fileField(f: InputField): FormField { - const limits: { accept?: string[]; max_mb?: number } = {} + const limits: FileLimits = {} if (Array.isArray(f.accept) && f.accept.length) limits.accept = f.accept.filter((a) => typeof a === 'string' && a) if (typeof f.max_mb === 'number' && Number.isFinite(f.max_mb) && f.max_mb > 0) limits.max_mb = f.max_mb - const field: Record = { type: 'object', title: f.name, 'x-widget': 'file' } + const field: FormField = { type: 'object', title: f.name, 'x-widget': 'file' } if (f.description) field.description = f.description if (Object.keys(limits).length) field['x-file'] = limits - return field as unknown as FormField + return field } const TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array']) @@ -53,7 +53,7 @@ export function inputSchemaOf(fields: InputField[]): FormSchema { if (f.required) required.push(f.name) continue } - const type = f.type && TYPES.has(f.type) ? f.type as FormField['type'] : 'string' + const type = f.type && TYPES.has(f.type) ? f.type as Exclude : 'string' const options = Array.isArray(f.enum) && f.enum.length ? f.enum : null const field: FormField = { type, title: f.name } if (f.description) field.description = f.description diff --git a/frontend/src/store/blueprint.ts b/frontend/src/store/blueprint.ts index 1f9295c3..d867edd5 100644 --- a/frontend/src/store/blueprint.ts +++ b/frontend/src/store/blueprint.ts @@ -4,8 +4,8 @@ import { streamBlueprint, streamFillJoints } from '../api/blueprintCopilot' import { COPILOT_PHASE_TEXT, copilotLook, type CopilotOp } from '../run/decode' import { BLUEPRINT_TEXT, LOOK_TEXT } from '../lib/terms' import type { ConversationTurn } from '../types' -import type { AppSpec, FormSchema } from '../typesApp' -import type { Blueprint, BpAsk, BpOp, JointState, PlainIssue, TrialStart } from '../typesBlueprint' +import type { AppSpec } from '../typesApp' +import type { Blueprint, BpAsk, BpOp, JointState, PlainIssue } from '../typesBlueprint' /** * 蓝图页的状态(docs/design/normal-user-apps.md §2.2、§2.11、§4.2):每个蓝图一桶——蓝图本身、对话的轮次、填关节的进度。 @@ -71,13 +71,10 @@ export interface BpEntry { answered: string[] /** 最近一次按卡片生成的结果:应用骨架(试运行的输入表单从它的入口取)和检查出的问题 */ instantiated?: { app_spec: AppSpec; problems: PlainIssue[] } | null - /** 这一页里发起的最近一次试运行:用了什么输入、输入表单(服务端给了的话) */ - lastTrial?: { run_id: string; inputs?: Record | null; form?: FormSchema | null } | null + /** 这一页里发起的最近一次试运行:实际用了什么输入(服务端交回的,看板类补齐了统计期) */ + lastTrial?: { run_id: string; inputs?: Record | null } | null } -/** 试运行的回包:服务端可以多给这次用的输入(按「多久更新」算出的最近一期)和输入表单 */ -export type TrialStartOut = TrialStart & { inputs?: Record | null; form?: FormSchema | null } - interface BlueprintState { byId: Record /** force:手上有也再取一次 */ @@ -193,15 +190,15 @@ export const useBlueprint = create((set, get) => { const key = `${id}:chat` const stop = streamBlueprint( { blueprint_id: id, instruction }, - (op: CopilotOp) => { + (op) => { got.push(op) patchTurn(id, turnId, (t) => ({ ops: pushOp(t.ops, op) })) switch (op.op) { - case 'heartbeat': - patchTurn(id, turnId, () => ({ - status: op.phase === 'looking' ? LOOK_TEXT.running : COPILOT_PHASE_TEXT[String(op.phase)] ?? COPILOT_PHASE_TEXT.planning, - })) + case 'heartbeat': { + const status = op.phase === 'looking' ? LOOK_TEXT.running : COPILOT_PHASE_TEXT[op.phase] ?? COPILOT_PHASE_TEXT.planning + patchTurn(id, turnId, () => ({ status })) break + } case 'look': { const look = copilotLook(op) if (look) patchTurn(id, turnId, () => ({ status: look.title })) @@ -212,13 +209,17 @@ export const useBlueprint = create((set, get) => { // 模型写成通用的 ask 也认(服务端存进蓝图时换成 bp_ask) patchTurn(id, turnId, () => ({ status: BLUEPRINT_TEXT.turnAsked })) break - case 'final': + case 'final': { // 服务端落地后的整份蓝图为准 - if (isBlueprint(op.blueprint)) { finalSeen = true; patch(id, () => ({ blueprint: op.blueprint as Blueprint })) } + const bp = op.blueprint + if (isBlueprint(bp)) { finalSeen = true; patch(id, () => ({ blueprint: bp })) } break - case 'error': - patchTurn(id, turnId, () => ({ phase: 'error', error: String(op.message ?? BLUEPRINT_TEXT.turnFailed) })) + } + case 'error': { + const error = op.message || BLUEPRINT_TEXT.turnFailed + patchTurn(id, turnId, () => ({ phase: 'error', error })) break + } default: if (BP_EDIT_OPS.has(op.op)) patchTurn(id, turnId, () => ({ status: BLUEPRINT_TEXT.turnRunning })) } @@ -257,10 +258,10 @@ export const useBlueprint = create((set, get) => { }, trial: async (id, inputs) => { - const res = await api.blueprints.trial(id, inputs ? { inputs } : {}) as TrialStartOut + const res = await api.blueprints.trial(id, inputs ? { inputs } : {}) patch(id, () => ({ blueprint: res.blueprint, loadState: 'ready', - lastTrial: { run_id: res.run_id, inputs: res.inputs ?? inputs ?? null, form: res.form ?? null }, + lastTrial: { run_id: res.run_id, inputs: res.inputs ?? inputs ?? null }, })) return res.run_id }, @@ -281,52 +282,64 @@ export const useBlueprint = create((set, get) => { }) const stop = streamFillJoints( { blueprint_id: id, ...(groups?.length ? { groups } : {}) }, - (op: CopilotOp) => { - const gid = typeof op.group === 'string' && op.group ? op.group : null - const joint = typeof op.joint === 'string' ? op.joint : null + (op) => { + const gid = op.group || null switch (op.op) { - case 'group': + case 'group': { // 组的结局:started / retrying(并行出错退回串行再试)是在填,filled 填好,failed 没填上(带原因或没过的检查) - if (gid) { - const issue = Array.isArray(op.issues) ? op.issues.find((i: any) => i?.message) : null - group(gid, (g) => ({ - joints: Array.isArray(op.joints) ? op.joints.map(String) : g.joints, - status: op.status === 'filled' ? 'done' : op.status === 'failed' ? 'failed' : 'running', - line: op.status === 'retrying' ? BLUEPRINT_TEXT.groupRetrying : g.line, - ...(op.message || issue ? { message: String(op.message ?? issue.message) } : {}), - })) - } + const message = op.message || op.issues?.find((i) => i.message)?.message + const status = op.status === 'filled' ? 'done' : op.status === 'failed' ? 'failed' : 'running' + const joints = op.joints?.map(String) + const retrying = op.status === 'retrying' + group(op.group, (g) => ({ + joints: joints ?? g.joints, status, line: retrying ? BLUEPRINT_TEXT.groupRetrying : g.line, + ...(message ? { message } : {}), + })) break - case 'plan': - if (gid && op.summary) group(gid, () => ({ line: String(op.summary) })) + } + case 'plan': { + const line = op.summary + if (gid && line) group(gid, () => ({ line })) break - case 'check': - if (gid) group(gid, () => ({ line: op.status === 'repairing' ? COPILOT_PHASE_TEXT.repairing : BLUEPRINT_TEXT.groupChecked })) + } + case 'check': { + const line = op.status === 'repairing' ? COPILOT_PHASE_TEXT.repairing : BLUEPRINT_TEXT.groupChecked + if (gid) group(gid, () => ({ line })) break - case 'fill_joint': - if (joint) patchFill(id, (f) => ({ joints: { ...f.joints, [joint]: { status: 'filled' } } })) - if (gid && joint) group(gid, (g) => ({ joints: g.joints.includes(joint) ? g.joints : [...g.joints, joint] })) + } + case 'fill_joint': { + const joint = op.joint + patchFill(id, (f) => ({ joints: { ...f.joints, [joint]: { status: 'filled' } } })) + if (gid) group(gid, (g) => ({ joints: g.joints.includes(joint) ? g.joints : [...g.joints, joint] })) break - case 'heartbeat': - if (gid) group(gid, () => ({ line: op.phase === 'looking' ? LOOK_TEXT.running : COPILOT_PHASE_TEXT[String(op.phase)] })) + } + case 'heartbeat': { + const line = op.phase === 'looking' ? LOOK_TEXT.running : COPILOT_PHASE_TEXT[op.phase] + if (gid) group(gid, () => ({ line })) break + } case 'look': { const look = copilotLook(op) if (gid && look) group(gid, () => ({ line: look.title })) break } - case 'final': - if (isBlueprint(op.blueprint)) { finalSeen = true; patch(id, () => ({ blueprint: op.blueprint as Blueprint })) } - if (Array.isArray(op.problems)) patchFill(id, () => ({ problems: op.problems as PlainIssue[] })) + case 'final': { + const bp = op.blueprint + const problems = op.problems + if (isBlueprint(bp)) { finalSeen = true; patch(id, () => ({ blueprint: bp })) } + if (Array.isArray(problems)) patchFill(id, () => ({ problems })) // 各组的结局以 final 为准(卡片在填的时候又改过的组,服务端判它不算数) - for (const [g, r] of Object.entries((op.groups ?? {}) as Record)) { + for (const [g, r] of Object.entries(op.groups ?? {})) { group(g, () => ({ status: r?.status === 'filled' ? 'done' : 'failed' })) } break - case 'error': - if (gid) group(gid, () => ({ status: 'failed', message: String(op.message ?? BLUEPRINT_TEXT.groupFailed) })) - else patchFill(id, () => ({ error: String(op.message ?? BLUEPRINT_TEXT.fillFailed) })) + } + case 'error': { + const message = op.message + if (gid) group(gid, () => ({ status: 'failed', message: message || BLUEPRINT_TEXT.groupFailed })) + else patchFill(id, () => ({ error: message || BLUEPRINT_TEXT.fillFailed })) break + } } }, (error, info) => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fc665b5d..edd239da 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1569,7 +1569,11 @@ export interface VarIssue { export interface Conversation { id: string title: string - kind: 'chat' | 'canvas' + /** + * 前端建的只有 chat、canvas;服务端另建两种:app 是应用编辑器的助手对话,blueprint 是蓝图对话(docs/design/normal-user-apps.md + * §2.2,权限跟着蓝图走,经 GET /api/blueprints/{id}/conversation 读) + */ + kind: 'chat' | 'canvas' | 'app' | 'blueprint' workflow_id?: string | null archived: boolean created_at?: string diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index ecb4b20f..744c8beb 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -21,6 +21,11 @@ export interface Principal { verified: boolean /** 开登录后 user 角色能不能问数据(Q2,管理员开关) */ user_can_chat?: boolean + /** + * 管理员开没开「使用者可以建个人应用」(设置 apps.user_personal_apps,docs/design/normal-user-apps.md N7)。使用者读不到 + * 设置,开关随认人、登录接口一起给(服务端 api/auth.principal_full);老服务端不给时按关 + */ + user_personal_apps?: boolean } export interface UserRow { @@ -70,8 +75,15 @@ export type FormWidget = /** 文件(N9):字段 type 为 object,值是上传后拿回的引用 FileRef(typesBlueprint.ts) */ | 'file' +/** 文件控件的限制(x-file,服务端 apps/forms._file_field_problems):可选的类型(扩展名或 MIME)、大小上限(MB) */ +export interface FileLimits { + accept?: string[] + max_mb?: number +} + export interface FormField { - type: 'string' | 'number' | 'integer' | 'boolean' | 'array' + /** object 只有文件控件能用(x-widget 为 file,服务端 apps/spec.FILE_FIELD_TYPE),值是 FileRef */ + type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' title?: string description?: string default?: unknown @@ -86,6 +98,8 @@ export interface FormField { format?: 'date' | 'date-time' | 'email' | 'uri' items?: { type: 'string'; enum?: string[] } 'x-widget'?: FormWidget + /** 文件控件的限制,只配 x-widget: file */ + 'x-file'?: FileLimits } export interface FormSchema { @@ -97,10 +111,13 @@ export interface FormSchema { 'x-order'?: string[] } -/** 前后端同一种错误格式 */ +/** + * 前后端同一种错误格式(服务端 apps/spec.FORM_ERROR_CODES)。file:文件控件的值不是上传后拿回的引用、类型或大小不合 + * x-file 的限制,或提交时核对引用没过(不是这个人传的、已经用过) + */ export interface FormError { path: string - code: 'required' | 'type' | 'enum' | 'minimum' | 'maximum' | 'min_length' | 'max_length' | 'max_items' | 'format' + code: 'required' | 'type' | 'enum' | 'minimum' | 'maximum' | 'min_length' | 'max_length' | 'max_items' | 'format' | 'file' message: string } @@ -193,6 +210,10 @@ export interface CatalogComponent { version: number children: boolean bindings: string[] + /** + * 属性的 JSON Schema(服务端 apps/catalog.py)。给人看的文字标了 `x-text: true`(页面编辑「改文字」只列这几项,N6); + * 写给解析器的字符串(列名、换算)不标。注解不参与校验 + */ props: Record } @@ -221,8 +242,12 @@ export interface AppSummary { needs_upgrade: boolean /** 当前用户在这个应用里的待办数 */ pending_tasks: number - /** 当前用户的授权(关登录时为 manage) */ + /** + * 当前用户的授权(关登录时为 manage)。为 null 的只有一种:开了登录的管理员看别人的个人应用(服务端 api/apps._personal_stub, + * 列表、详情、归档的回包同一份)——只有名称、拥有者、状态、创建和更新时间,能归档;说明、页面、运行、蓝图一概不给(N7) + */ my_role: GrantRole | null + created_at: string | null updated_at: string /** 个人应用(N7):只有拥有者看得见、跑探索运行;老服务端不给时按 shared */ scope?: 'shared' | 'personal' @@ -230,6 +255,15 @@ export interface AppSummary { blueprint_id?: string | null } +/** + * GET /api/apps(scope=shared|personal 只列那一种):items 是这个人看得到的应用;can_create 是他能建哪种应用(共享要搭建者及 + * 以上,个人看管理员的开关),和认人接口的 user_personal_apps 出自同一条规则(服务端 blueprints/personal.allowed) + */ +export interface AppList { + items: AppSummary[] + can_create: { shared: boolean; personal: boolean } +} + export interface AppDetail extends AppSummary { /** 草稿和岗位绑定只给 manage(D-m);use / observe 拿到的详情里没有 */ draft?: AppSpec @@ -239,6 +273,47 @@ export interface AppDetail extends AppSummary { published?: { version: number; pages: { id: string; title: string }[]; entries: { id: string; label: string }[] } | null /** 能按新口径补算的视图(只在 manage 取详情时算,D-f) */ backfill?: BackfillPlan[] + /** 个人应用给拥有者的那一块(服务端 blueprints/personal.personal_state):只在拥有者取个人应用的详情时给 */ + personal?: PersonalState + /** + * 草稿(连同岗位绑定)和当前发布版是否一致(服务端 api/apps._draft_changed,按发布时的规整方式比,normal-user-apps N6)。 + * 只给 manage,而且只在有发布版、发布版规整得了时给;没给的是不知道:界面只在这一次改过之后说「有未发布的改动」, + * 不在浏览器里逐项比 + */ + draft_changed?: boolean +} + +/** + * 个人应用的状态(N7、§4.6):能不能刷新、提交审核,今天还能刷新几次(和按需视图共用每日上限,按人计)、蓝图改过没有、 + * 审核到哪了。blueprint_status 同 typesBlueprint.BlueprintStatus;蓝图被删了为 null + */ +export interface PersonalState { + /** + * 这个人现在能不能刷新、提交审核(服务端 blueprints/personal.allowed:开了登录的搭建者及以上随时能,使用者和关登录时看 + * 管理员的开关)。为假时个人应用照样看得到,按钮置灰 + */ + allowed: boolean + remaining_today: number + daily_limit: number + blueprint_status: import('./typesBlueprint').BlueprintStatus | null + /** 蓝图在存成个人应用之后又改过(图的哈希对不上):刷新按存下的那一版跑,要用改过的得回蓝图重新存 */ + graph_changed: boolean + /** 最近一条审核待办;没提交过审核为 null */ + review: PersonalReview | null +} + +/** 个人应用最近一条审核待办:status 是待办的状态,decision 是结论(published 搭建者发布了副本,reject 退回) */ +export interface PersonalReview { + task_id: string + status: TaskStatus + decision: 'published' | 'reject' | null + /** 退回的原因(没写为空串) */ + note: string + /** 发布出来的正式应用(decision=published 时);别的时候为 null */ + app_id: string | null + /** 派给了谁(没撤回的那几份) */ + to: { kind: TaskAssignment['principal_kind']; name: string }[] + resolved_at: string | null } /** 按新口径补算会发起几次(GET /api/apps/{id}/views/{view}/backfill,不发起运行) */ @@ -313,6 +388,8 @@ export interface Stamp { tier_text: string from: string sealed: boolean + /** 蓝图的试运行(N4):来源写「试运行」、说明里写这不是正式结果。不是试运行时不给 */ + trial?: boolean /** 照实标出的错位:定时任务已改跑新版本、应用还钉在旧版本,等 */ notes?: string[] } @@ -341,17 +418,26 @@ export interface ViewNode { id: string type: string props: Record + /** + * 作者隐藏了它(N6):只在按草稿解析(编辑状态,draft=1)时下发并带上这个标记,界面画成半透明;发布版、个人应用的 + * 使用面不下发隐藏的组件 + */ + hidden?: boolean /** * 服务端解析好的数据(backend/app/apps/resolve.py 的 _data),形状随组件而定;绑定了视图的都带 state 和可选的 message: * - stamp:{view, state, stamp};metric:{state, text, name, unit, evidence} * - series:{state, mode: single|dual|incomparable|recompute, unit, series: [{version, label, points: [{run_id, as_of, - * value, text, evidence}]}], breaks: [{after_run?, from, to, text}]} + * value, text, evidence, backfill?}]}], breaks: [{after_run?, from, to, text}], note?, trial?}——version 是固定的工作流 + * 版本号;试运行预览里只有这一次试运行一个点,version 为 null、trial 为真、note 照实写没有趋势;钉住项还没有版本号 + * (试运行派生的草稿)时 state=empty、message 说明 * - table、chart:{state, columns, rows(遮罩的格子已换成「已遮罩」), column_types, masked, total_rows, truncated, evidence} * - report:{state, node, doc_artifact, evidence};field:{state, text, value?, evidence?};status:{state, value, text} - * - form:{form};entry_button:{entry, label, disabled, reason?};instance_list:{who, entry, items} + * - form:{form};entry_button:{entry, label, disabled, reason?};instance_list:{who, entry, items, trial?} * - instance_header:InstanceRow + {state, stamp};instance_timeline:{state, status, status_code, status_text, steps} - * - task_list:{scope, items};task_form、signoffs:{state, task, task_ui, handler, assignments, progress, my_response} + * - task_list:{scope, items};task_form、signoffs:{state, task, task_ui, handler, assignments, progress, my_response, + * people, signoffs_on_page?, trial?}——trial:试运行里的待办派给了试运行的人自己 * - action_status:{state, node, records: [{id, state, environment, attempt, payload_hash, error?, note?, receipt?}]} + * - records_list:RecordsPage(typesBlueprint.ts):服务端取好的第一页,界面翻页再取 */ data?: Record | null children?: ViewNode[] @@ -366,6 +452,12 @@ export interface ViewModel { tree: ViewNode[] forms: Record problems: { component: string; code: string; message: string }[] + /** + * 蓝图的试运行预览(GET /api/blueprints/{id}/preview,N4):对着的是哪次试运行、跑到哪了。应用页上不给。 + * 试运行没有应用版本,app.version 为 0。form 是「换一期再试」填的表单(服务端 blueprints/trial.retry_form,按这次试运行 + * 对应的草稿:流程类、填报登记是入口的示例表单,看板类是按条件查看的条件表单);没有为 null,只能原样再试一次 + */ + trial?: { run_id: string; status: string; status_text: string; form?: FormSchema | null } } export interface InstanceRow { @@ -388,8 +480,12 @@ export interface InstanceRow { // 待办(§3.7) // --------------------------------------------------------------------------- +/** + * 待办的种类。review 是个人应用提交审核(docs/design/normal-user-apps.md N7):不挂在运行的中断上,同意不在待办上点—— + * 处理人取一份共享副本、走发布单(发布单做完这条自动记为已发布),不发布就退回(decision=reject);payload 见 ReviewTaskPayload + */ export type TaskKind = - | 'approve' | 'input' | 'edit' | 'tool' | 'code' | 'dual_check' | 'action_confirm' | 'reconcile' | 'signal' + | 'approve' | 'input' | 'edit' | 'tool' | 'code' | 'dual_check' | 'action_confirm' | 'reconcile' | 'signal' | 'review' export type TaskStatus = 'pending' | 'resolving' | 'resolved' | 'cancelled' | 'expired' export type TaskPolicy = { kind: 'any' } | { kind: 'all' } | { kind: 'quorum'; m: number } @@ -452,7 +548,10 @@ export interface Task { status: TaskStatus due_at: string | null escalation_level: number - /** 给人看的上下文:人工节点的 message / context / draft / reports,工具审批的参数,动作确认的预览等(原样来自中断内容) */ + /** + * 给人看的上下文:人工节点的 message / context / draft / reports,工具审批的参数,动作确认的预览等(原样来自中断内容); + * 审核个人应用(kind=review)是 ReviewTaskPayload + */ payload: Record assignments: TaskAssignment[] /** 当前用户能不能回复这一条 */ @@ -471,6 +570,24 @@ export interface Task { people?: Record } +/** + * 审核个人应用的待办给处理人看的(服务端 blueprints/personal.review_payload):提交的是哪个蓝图、哪个个人应用、谁提交的、 + * 留了什么话,取过的共享副本(审核副本归取它的那位搭建者,owner 是他)。不含个人应用页面上的数:要看数,取副本后在 + * 副本上用真数试一次。键总是都在;蓝图被删了时 name、submitted_by 为 null、copies 为空 + */ +export interface ReviewTaskPayload { + kind: 'review' + blueprint_id: string | null + /** 提交审核的那个个人应用 */ + app_id: string | null + name: string | null + purpose: string + template_id: string | null + submitted_by: string | null + note: string + copies: { id: string; owner: string | null; status: import('./typesBlueprint').BlueprintStatus; app_id: string | null }[] +} + export interface TaskRespond { decision: 'approve' | 'reject' | 'submit' | 'confirmed' | 'not_applied_resend' | 'not_applied_abandon' values?: Record @@ -582,11 +699,14 @@ export interface NotifyChannelRow { // 运行来源(§2.10 X3)与助手查看(§2.9 L2) // --------------------------------------------------------------------------- +/** 运行来源(服务端 engine/origin.ORIGIN_KINDS,顺序相同)。trial:蓝图的试运行;personal_app:个人应用的刷新(都是探索运行) */ export type OriginKind = | 'chat' | 'canvas' | 'launcher' | 'schedule' | 'app_entry' | 'app_view' | 'app_backfill' | 'hook' | 'api' + | 'trial' | 'personal_app' +/** 查看的对象。caliber 是已发布口径卡上的指标(蓝图助手复用口径时看,服务端 look/calibers.py) */ export interface LookObject { - kind: 'table' | 'capability' | 'workflow_version' + kind: 'table' | 'capability' | 'workflow_version' | 'caliber' source?: string /** 点名了不在这一轮范围里的数据源时为 null,missing 里是点名的表 */ source_id?: string | null diff --git a/frontend/src/typesBlueprint.ts b/frontend/src/typesBlueprint.ts index 51751a75..a7218b88 100644 --- a/frontend/src/typesBlueprint.ts +++ b/frontend/src/typesBlueprint.ts @@ -2,7 +2,7 @@ * 普通用户建应用的接口约定(docs/design/normal-user-apps.md 第三部分):蓝图、场景模板、试运行、发布单、文件、记录。 * 和后端 app/blueprints/spec.py、app/db/models_blueprints.py 一一对应。改这里要同时改后端(骨架阶段冻结)。 */ -import type { AppSpec, LintIssue, ViewModel } from './typesApp' +import type { AppComponent, Disposition, LintIssue, LookEvent, ViewModel } from './typesApp' // ---------------------------------------------------------------- 蓝图 agentlab.blueprint/1 @@ -87,7 +87,23 @@ export type BlueprintScope = 'personal' | 'shared' export type BlueprintStatus = 'drafting' | 'trialed' | 'published' | 'personal' | 'in_review' export type BlueprintSourceKind = 'conversation' | 'template' | 'run' | 'workflow_version' -export interface JointState { status: 'filled' | 'pending' | 'refused'; by: 'template' | 'assistant' | 'user'; note?: string | null } +/** 关节的类型(服务端 blueprints/templates.JOINT_KINDS):sql、metric_expr 交给助手填(生成关节),其余按卡片确定性地填 */ +export type JointKind = + | 'sql' | 'metric_expr' | 'fields' | 'assignee' | 'policy' | 'due' | 'on_timeout' | 'threshold' | 'capability' | 'contract' + +/** + * 一个关节的状态(服务端 blueprints/store.blueprint_out,只给状态,不给填的值——值在图上看)。by:确定性关节和还没填的 + * 生成关节是 template,助手填上的是 assistant。label、kind、group 是模板声明的说法、类型和分组(同一组的关节一起交给 + * 助手填,组之间并行);实例化之前存下的关节没有这三项 + */ +export interface JointState { + status: 'filled' | 'pending' | 'refused' + by: 'template' | 'assistant' | 'user' + note?: string | null + label?: string + kind?: JointKind + group?: string | null +} export interface Blueprint { id: string @@ -116,8 +132,24 @@ export interface Blueprint { export type ProblemWho = 'you' | 'assistant' | 'admin' export interface Plain { title: string; why: string; who: ProblemWho; action: string } -/** 检查问题加上白话(后端 app/plain.with_plain);没登记白话的 plain 为空,照旧显示 message */ -export type PlainIssue = LintIssue & { plain?: Plain | null } +/** 发布单的问题出自哪一项预检(服务端 blueprints/publish_plan._preflight) */ +export type PlanProblemSource = 'blueprint' | 'plan' | 'workflow' | 'schedule' | 'app' | 'grants' +/** + * 检查问题加上白话(后端 app/plain.with_plain);没登记白话的 plain 为空,照旧显示 message。 + * + * 发布单上的问题另带(blueprints/publish_plan._with_who):who 归谁处理(摘要按它分「你来定 / 助手可以修 / 要找管理员」三栏, + * 没登记白话的也有);source 出自哪一项预检;工作流检查带 fix_kind(自动修复的种类:choice 要人选,auto、assist 助手能做); + * 应用检查的 app.upgrade_policy 带 alias(要选处置的那个钉住项)。蓝图检查带 joint(还没填的关节)。 + */ +export type PlainIssue = LintIssue & { + plain?: Plain | null + who?: ProblemWho + source?: PlanProblemSource + fix_kind?: 'choice' | 'auto' | 'assist' | null + alias?: string + joint?: string + field?: string | null +} // ---------------------------------------------------------------- 场景模板 @@ -136,8 +168,32 @@ export interface TemplateInfo { // ---------------------------------------------------------------- 试运行 -export interface TrialStart { run_id: string; blueprint: Blueprint } -/** 试运行解析出来的一页:和应用页同一个视图模型,印章 trial=true */ +/** + * POST /api/blueprints/{id}/trial 的回包(服务端 api/blueprints.trial)。inputs 是这次运行实际用的输入(运行的 input):看板类 + * 补齐了按「多久更新」算出的统计期,流程类是示例表单按入口映射换成的图输入。「换一期再试」的表单不随回包给,取自按卡片 + * 生成的应用骨架(入口的示例表单,看板类是按条件查看的条件表单) + */ +export interface TrialStart { + run_id: string + blueprint: Blueprint + inputs: Record +} +/** 试运行、预览自己的拒绝码(服务端 blueprints/trial 的 NOT_READY、NO_GRAPH、POLICY、FORM、NO_RUN) */ +export type TrialRefusalCode = 'trial_not_ready' | 'trial_no_graph' | 'trial_policy' | 'trial_form' | 'trial_no_run' +/** + * 试运行(POST /api/blueprints/{id}/trial)、按卡片生成(/instantiate)、预览(/preview)被拒的回包(服务端 + * blueprints/trial.TrialRefused,api/blueprints 转成 {detail, code, issues?}): + * - 409 trial_not_ready:缺项、待填的关节挡住,issues 逐条列(带白话); + * - 409 trial_no_graph:蓝图还没有能运行的图(没选场景、自由形没导入图); + * - 403 trial_policy:出的图过不了个人应用的约束(auth/chat_policy,同问数据)。个人蓝图一律按它(任何人的,搭建者 + * 自己的也一样:试运行是刷新的预演),开了登录的使用者也一样; + * - 422 trial_form:示例表单没通过校验; + * - 404 trial_no_run:预览指的不是这张蓝图的试运行,或者还没试运行过; + * - 别处的拒绝照原样透传 status 和 code:记录表起草被拒(records.*,422)、附件核对没过(files.*,404 / 403 / 422)、 + * 发起被拒(engine/launch.LaunchRefused) + */ +export interface TrialRefusal { detail: string; code?: TrialRefusalCode | (string & {}); issues?: PlainIssue[] } +/** 试运行解析出来的一页:和应用页同一个视图模型,印章 trial=true,ViewModel.trial 写对着哪次试运行 */ export type TrialPage = ViewModel // ---------------------------------------------------------------- 发布单 @@ -154,6 +210,11 @@ export interface PlanStep { message?: string | null at?: string | null } +/** + * 发布单(服务端 blueprints/publish_plan.plan_out)。ok:没有挡路的问题(error 级)、也没停下,能确认执行。actor 是出单的人。 + * options 是出单时定下的:first_run(发布后立即跑一次,不给时看板类为真、流程类为假)、dispositions(钉住项升版的处置)、 + * spec_hash(出单那一刻卡片内容的指纹,执行时重算,对不上回 409 plan.graph_changed;服务端自己用,界面不读) + */ export interface PublishPlanRow { id: string blueprint_id: string @@ -164,7 +225,11 @@ export interface PublishPlanRow { /** 白话摘要:每一步一句 */ summary: { step: PlanStepKind; text: string }[] problems: PlainIssue[] - options: { first_run?: boolean } + options: { first_run?: boolean; dispositions?: Record; spec_hash?: string } + ok: boolean + actor: string | null + created_at: string | null + updated_at: string | null } // ---------------------------------------------------------------- 文件、记录 @@ -172,19 +237,103 @@ export interface PublishPlanRow { /** 上传后拿回的引用,作为表单值提交 */ export interface FileRef { file_id: string; name: string; size: number; sha256: string; type: string } +/** + * 记录表的一行(服务端 records/listing.py)。系统列照 records.SYSTEM_COLUMNS:_seq 只增不减的序号,_run_id 写入它的那次运行, + * _schema_version 写入时表单的版本;_version 是这一行在第几版表里(表单不兼容地改过之后出新一版,列表把各版合在一起列)。 + * 更正行的 _submitted_by 沿用原行的提交人(原提交人照样看得见),更正人记在动作记录和审计里。别人提交的行里遮罩列的值已 + * 换成「已遮罩」 + */ export interface RecordRow { _id: string _seq: number _submitted_by: string | null _submitted_at: string + _run_id: string | null _supersedes: string | null - /** 被更正过(有更新的一行指向它) */ - superseded?: boolean + _schema_version: number + _version: number + /** 被更正过(有更新的一行指向它)。默认只列当前有效的行,要了更正历史(history)时才会有为真的 */ + superseded: boolean values: Record } -export interface RecordsPage { table: string; version: number; columns: { name: string; label: string; type: string }[]; items: RecordRow[]; mine_only: boolean } +/** + * 一页记录(GET /api/apps/{id}/records、组件 records_list 的数据,服务端 records/listing.page)。mine_only:服务端只给了这个人 + * 自己提交的(use 权限,或组件写了 who=mine)。next_cursor 是下一页的游标(「版本:序号」),没有下一页为 null。state:ok 有 + * 这张表(items 可能为空);empty 这个应用还没有记录表(table 是请求里写的、version 为 0);preview 按草稿预览,不列真实的 + * 记录(apps/resolve._records_list) + */ +export interface RecordsPage { + table: string | null + version: number + columns: { name: string; label: string; type: string }[] + items: RecordRow[] + mine_only: boolean + next_cursor: string | null + state: 'ok' | 'empty' | 'preview' +} // ---------------------------------------------------------------- 页面编辑 -/** 「加一张数据卡片」的候选(GET /api/apps/{id}/candidates) */ -export interface AppCandidate { kind: 'metric' | 'report' | 'table' | 'series'; label: string; group: string; component: AppSpec['pages'][number]['body'][number] } +/** + * 「加一张数据卡片」的候选(GET /api/apps/{id}/candidates,服务端 apps/candidates.py):component 可以直接 app_add——绑定的 + * 视图一定在这份草稿的 views 里(候选只从草稿里有图的视图列,实例视图不列),id 在草稿里不重名。草稿里已经有一模一样的 + * 不再列。group 是出自哪个节点(草稿里不止一个视图时加上来源的说法) + */ +export interface AppCandidate { kind: 'metric' | 'report' | 'table' | 'series'; label: string; group: string; component: AppComponent } + +// ---------------------------------------------------------------- 助手流(蓝图对话、填关节) + +/** + * 两条流共有的过程事件(服务端 api/copilot_gen.Generation、api/copilot._with_heartbeat):用的哪个模型、心跳(阶段和已用时长)、 + * 思考、开场的打算(plan)、收尾的说明(done)、查看(look,同问数据)、自查(check:repairing 交回模型改、error 修不成、 + * passed / failed 交付前的结论)、出错(error,这一轮没完成) + */ +export type CopilotStreamStep = + | { op: 'model'; model: string } + | { op: 'heartbeat'; phase: string; elapsed_ms: number } + | { op: 'thinking'; delta: string } + | { op: 'plan'; summary: string } + | { op: 'done'; explanation?: string } + | Omit + | { op: 'check'; status: 'repairing' | 'error' | 'passed' | 'failed'; round?: number; repaired?: number; + issues?: LintIssue[]; message?: string; detail?: string } + | { op: 'error'; message: string; hint?: string | null; detail?: string | null } + +/** + * 蓝图对话落库的一轮的 meta(ConversationTurn.meta,服务端 api/copilot_blueprint 的 _close_turn):这一轮查看了什么(looks, + * 同流里的 look 事件)、模型调了几次(calls)、问回来的那一句和候选(asked,没问为空)。之前的轮次只重放这些,改动已经落在 + * 卡片上 + */ +export interface BlueprintTurnMeta { + looks?: LookEvent[] + calls?: number + asked?: { question: string; options: string[] } | null +} + +/** + * 蓝图对话(POST /api/copilot/blueprint-stream,服务端 api/copilot_blueprint.blueprint_stream)的事件:模型提的 bp_* 操作(服务端 + * 已经落到它维护的那一份上,来源记 assistant);bp_ask 问回来一句(模型写成通用的 ask 也认),这一轮就此结束;reply 只回答、 + * 不改卡片;最后 final 带存好的整份蓝图(以它为准)、这一轮的说明、没采纳的操作(issues,警告级)和问了什么(asked) + */ +export type BlueprintStreamEvent = + | CopilotStreamStep + | { op: 'bp_set' | 'bp_add' | 'bp_remove'; path: string; value?: unknown } + | { op: 'bp_template'; template: { id: string; version: number } | null } + | { op: 'bp_ask' | 'ask'; question: string; options?: string[]; path?: string | null; id?: string } + | { op: 'reply'; text: string } + | { op: 'final'; blueprint: Blueprint; explanation: string; issues: LintIssue[]; + asked?: { question: string; options: string[] } } + +/** + * 填关节(POST /api/copilot/fill-joints-stream,服务端 api/copilot_blueprint.fill_joints_stream)的事件。不同组并行,组里的过程 + * 事件都带 group;group 事件报一组的进展(started 开始、retrying 并行出错退回串行再试、filled 填好、failed 没填上,带没过的 + * 检查或原因);fill_joint 是模型填了一个关节。最后 final 带存好的蓝图、各组的结局(以它为准:填的时候卡片又改过的组不算数) + * 和实例化查出的问题(带白话) + */ +export type FillJointsEvent = + | (CopilotStreamStep & { group?: string }) + | { op: 'group'; group: string; status: 'started' | 'retrying' | 'filled' | 'failed'; joints?: string[]; + issues?: LintIssue[]; message?: string } + | { op: 'fill_joint'; joint: string; value: string; group?: string } + | { op: 'final'; blueprint: Blueprint; problems: PlainIssue[]; group?: undefined; + groups: Record } diff --git a/scripts/check-auth.mjs b/scripts/check-auth.mjs index 24a48ae7..fe118b05 100644 --- a/scripts/check-auth.mjs +++ b/scripts/check-auth.mjs @@ -13,7 +13,10 @@ // - 搭建者:导航九项都在,账户菜单写搭建者; // - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明; // - 管理员允许使用者问数据时(D-c):问数据页只取数据源的名字(/api/datasources/names),不取完整列表和表结构; -// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s)。 +// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s); +// - 管理员的「使用者可以建个人应用」开关(认人接口的 user_personal_apps,docs/design/normal-user-apps.md N7):关着时使用者 +// 没有「新建应用」、新建页写明管理员还没开放、进不了蓝图页;开着时有「新建应用」(建的是个人应用),自己的个人应用单列 +// 一组「我的应用」,能进自己的蓝图页。 // // 身份接口在浏览器层答掉(/api/auth/**),按 state 切登录方式和角色;其余 GET 放行(外壳要它),写请求拦下记在探针里。 // 编排者的完整关口另起一套开着 local 登录的服务跑真接口,见 docs/design §6.3。 @@ -60,6 +63,28 @@ const TASK = { can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: new Date().toISOString(), } +// 使用者自己建的个人应用和它的蓝图(形状同服务端 api/apps._summary、blueprints/store.blueprint_out) +const ago = (ms) => new Date(Date.now() - ms).toISOString() +const MY_APP = { + id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active', published_version: null, + level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: ago(86_400_000), updated_at: ago(600_000), + scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine', +} +const SHARED_APP = { + id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active', published_version: 3, + level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', created_at: ago(7 * 86_400_000), + updated_at: ago(3600_000), scope: 'shared', owner: null, blueprint_id: null, +} +const item = (value, status = 'confirmed') => ({ value, source: 'user', status, why: null }) +const MY_BLUEPRINT = { + id: 'bp_mine', name: '我的退款周报', owner: 'zhangsan', scope: 'personal', status: 'personal', + spec: { format: 'agentlab.blueprint/1', purpose: item('每周看自己门店的退款'), template: null, data: [], + cadence: item(null, 'missing'), people: [], fields: [], writeback: null, access: { use: [], observe: [], manage: [] }, asks: [] }, + template_id: null, template_version: null, source: { kind: 'conversation' }, conversation_id: null, workflow_id: null, + app_id: 'app_mine', last_trial_run_id: null, graph_hash: null, joints: {}, copied_from: null, issues: [], + created_at: ago(86_400_000), updated_at: ago(600_000), +} + const browser = await chromium.launch({ executablePath: CHROME }) /** @@ -103,13 +128,21 @@ async function open(state) { return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode, can_bootstrap: !!state.bootstrap } }) } - return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat } }) + return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat, + user_personal_apps: !!state.personal } }) } state.reads?.push(p) if (state.mode !== 'off' && (!state.who || state.expire)) { return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } }) } - if (p === '/api/apps') return route.fulfill({ json: { items: [] } }) + if (p === '/api/apps') { + return route.fulfill({ json: { items: state.apps ?? [], can_create: { shared: state.who !== 'user', personal: !!state.personal } } }) + } + // 蓝图:使用者自己的个人蓝图(开关开着时能进);没有准备的一律当没有 + if (p === '/api/blueprints') return route.fulfill({ json: { items: state.personal ? [MY_BLUEPRINT] : [] } }) + if (p === '/api/blueprints/bp_mine') return route.fulfill({ json: MY_BLUEPRINT }) + if (p.startsWith('/api/blueprints')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } }) + if (p === '/api/templates') return route.fulfill({ json: { items: [] } }) if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } }) if (p === '/api/tasks') return route.fulfill({ json: { items: [TASK] } }) if (p === '/api/tasks/task_x') return route.fulfill({ json: TASK }) @@ -296,6 +329,43 @@ await section('使用者的设置页:只剩偏好设置和通知,换主题 check('启动时也不去读服务端的设置', !state.reads.includes('/api/settings')) }) +await section('使用者:个人应用开关关着时没有新建入口,开着时有「新建应用」和「我的应用」', async () => { + // 关着:首页没有「新建应用」,也没有「我的应用」一组;直接打开新建页写明管理员还没开放;进不了蓝图页 + const off = { mode: 'local', who: 'user', personal: false, apps: [SHARED_APP], writes: [] } + const page = await open(off) + await page.goto(`${WEB}/apps?tab=apps`) + await page.locator('[data-app="app_weekly"]').waitFor({ timeout: 15000 }) + check('关着:页头没有「新建应用」', await page.locator('[data-app-create]').count() === 0) + check('……没有「我的应用」一组', await page.locator('[data-app-group="personal"]').count() === 0) + await page.goto(`${WEB}/apps/create`) + await page.locator('[data-new-app]').waitFor({ timeout: 8000 }) + check('……打开新建页:写明管理员还没有开放个人应用,不给起点', (await page.locator('[data-new-app]').getAttribute('data-new-app')) === 'none' + && (await page.locator('main').innerText()).includes('管理员还没有开放个人应用')) + await page.goto(`${WEB}/blueprints/bp_mine`) + check('……进不了蓝图页(送回应用首页)', await page.waitForURL(/\/apps(\?|$)/, { timeout: 8000 }).then(() => true, () => false), page.url()) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') + + // 开着:页头有「新建应用」(建的是个人应用),自己的个人应用单列「我的应用」;能进自己的蓝图页 + const on = { mode: 'local', who: 'user', personal: true, apps: [MY_APP, SHARED_APP], writes: [] } + const p2 = await open(on) + await p2.goto(`${WEB}/apps?tab=apps`) + const mine = p2.locator('[data-app-group="personal"]') + check('开着:自己的个人应用单列「我的应用」', await mine.waitFor({ timeout: 15000 }).then(() => true, () => false) + && await mine.locator('[data-app="app_mine"]').count() === 1) + check('……共享的应用另列一组', await p2.locator('[data-app-group="shared"] [data-app="app_weekly"]').count() === 1) + const create = p2.locator('[data-app-create]') + check('……页头有「新建应用」,建的是个人应用', await create.count() === 1 && (await create.getAttribute('data-app-create')) === 'personal' + && (await create.getAttribute('href')) === '/apps/create', String(await create.getAttribute('data-app-create').catch(() => ''))) + await p2.goto(`${WEB}/apps/create`) + await p2.locator('[data-new-app]').waitFor({ timeout: 8000 }) + check('……新建页按个人应用给起点', (await p2.locator('[data-new-app]').getAttribute('data-new-app')) === 'personal' + && await p2.locator('[data-new-app-personal]').count() === 1) + await p2.goto(`${WEB}/blueprints/bp_mine`) + await p2.waitForTimeout(800) + check('……能进自己的蓝图页', /\/blueprints\/bp_mine/.test(p2.url()), p2.url()) + check('没有未捕获的运行时错误', p2.errors.length === 0, p2.errors[0] ?? '') +}) + await browser.close() console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-blueprint.mjs b/scripts/check-blueprint.mjs index ed89cafb..84595ae6 100644 --- a/scripts/check-blueprint.mjs +++ b/scripts/check-blueprint.mjs @@ -68,7 +68,7 @@ const USER_OFF = { ...USER_ON, user_personal_apps: false } const TEMPLATES = [ { id: 'periodic_board', version: 1, label: '周期看板', description: '每天、每周或每月看一组固定的数', applies_when: '', tags: [], - personal: '全部可用', produces: '一页看板:印章、指标、趋势、明细', + personal: '全部可用', cards: [{ card: 'purpose', note: '必填' }, { card: 'data', note: '必填:要看的数' }, { card: 'cadence', note: '必填:多久出一次数' }, { card: 'access', note: '谁能用' }] }, { id: 'approval', version: 1, label: '审批流程', description: '有人发起、有人审批,可以按金额分级', applies_when: '', tags: [], @@ -120,8 +120,11 @@ const READY = (extra = {}) => ({ ...clone(BP), issues: [], joints: { ...BP.joint const ASK = { id: 'q1', question: '销售额按下单日还是支付日算?', options: ['按下单日', '按支付日'], path: '/data/0/date', answer: null } const STAMP = { as_of: ago(3600_000), as_of_text: '截至 2026-10-13 09:00;统计期 上一 ISO 周 2026-W41', pin: '门店周报(试运行)', - graph_hash: 'ab12cd34', calibers: [], tier: 'formal', tier_text: '完整出具', from: '试运行', sealed: true, - notes: ['数据是真的;这不是正式结果,发布后才会按定时任务出数'] } + graph_hash: 'ab12cd34', calibers: [], tier: 'formal', tier_text: '完整出具', from: '试运行', sealed: true, trial: true, + notes: ['数据是真的,但这不是正式结果:发布之后才会按设定出数'] } +// 形状同服务端试运行预览(blueprints/trial.preview_page):印章 trial=true;趋势只有这一次试运行一个点,version 为 null, +// note 照实说没有趋势(apps/resolve._trial_series);视图模型带 trial(对着哪次试运行) +const TRIAL_SERIES_NOTE = '试运行只有一次,没有趋势:发布之后每出一期数,这里多一个点' const PREVIEW = { format: 'agentlab.view/1', app: { id: '', name: '门店周报', version: 0, level: null, needs_upgrade: false }, @@ -130,14 +133,29 @@ const PREVIEW = { tree: [ { id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'ok', stamp: STAMP } }, { id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '45.68 万元', name: '销售额' } }, + { id: 'gmv_trend', type: 'series', props: { label: '销售额的趋势' }, data: { state: 'ok', mode: 'single', unit: '万元', + trial: true, breaks: [], note: TRIAL_SERIES_NOTE, + series: [{ version: null, label: '试运行', points: [{ run_id: 'run_trial', as_of: '2026-10-12', value: 45.68, text: '45.68 万元', + evidence: null }] }] } }, ], forms: {}, problems: [], + trial: { run_id: 'run_trial', status: 'succeeded', status_text: '已完成' }, } const PREVIEW_FAILED = { ...PREVIEW, sources: { wk: { kind: 'trial', state: 'error', run_id: 'run_trial', stamp: null, message: '「取数」这一步查询失败:表 refunds 里没有列 refund_amt' } }, tree: [{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { view: 'wk', state: 'error' } }], } +// 看板类的应用骨架(同服务端 periodic_board.app_skeleton):没有入口;「按条件查看」那一页的条件表单是图的输入(统计期), +// 按引用写在 forms 里({"$ref": "#/forms/start"})——「换一期再试」填的就是它 +const BOARD_SPEC = { + format: 'agentlab.app/1', name: '门店周报', pins: [{ alias: 'main', workflow_id: null, version: null, graph_hash: 'ab12cd34', use: 'view' }], + slots: {}, entries: [], task_ui: {}, hooks: [], notify: [], pages: [], + forms: { start: { format: 'agentlab.form/1', type: 'object', required: [], 'x-order': ['period'], + properties: { period: { type: 'string', title: '统计期' } } } }, + views: { wk: { source: { kind: 'latest_sealed', pin: 'main' } }, + custom: { source: { kind: 'on_demand', pin: 'main', params: { $ref: '#/forms/start' }, ttl_s: 3600 } } }, +} const TRIAL_TASK = { id: 'task_trial', approval_id: null, run_id: 'run_trial', node_id: 'review', app_id: null, app_name: null, workflow_name: null, kind: 'approve', title: '财务审批', form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0, @@ -157,21 +175,26 @@ const SUMMARY = [ { step: 'publish_workflow', text: '发布「门店周报」:此后每周一 09:00 按这一版自动出数' }, { step: 'grant', text: '门店组的人可以使用' }, ] +// 形状同服务端 blueprints/publish_plan.plan_out:ok(没有 error 级问题、也没停下)由服务端算,界面照它决定能不能确认; +// 每条问题顶层带 who(归谁处理)和 source(出自哪一项预检) const PLAN_BLOCKED = { id: 'plan_1', blueprint_id: 'bp_week', level: 'published', graph_hash: 'ab12cd34', status: 'planned', steps: steps(0), - summary: SUMMARY, options: { first_run: true }, + summary: SUMMARY, options: { first_run: true, dispositions: {}, spec_hash: 'f00d' }, ok: false, actor: 'lisi', + created_at: '2026-10-14T09:00:00+00:00', updated_at: '2026-10-14T09:00:00+00:00', problems: [ - { code: 'bp.unconfirmed', level: 'error', path: '/data/0/table', message: '「看哪些数·第 1 项·对到的表」还没确认', + { code: 'bp.unconfirmed', level: 'error', path: '/data/0/table', message: '「看哪些数·第 1 项·对到的表」还没确认', who: 'you', + source: 'blueprint', plain: { title: '「销售额」对到的表还没确认', why: '这是助手按数据目录对的', who: 'you', action: '在卡片上点「确认」' } }, - { code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置', - plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } }, - { code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务', - plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } }, - { code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据', + { code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置', who: 'you', + source: 'app', plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } }, + { code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务', who: 'assistant', + source: 'app', plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } }, + { code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据', who: 'admin', + source: 'workflow', fix_kind: null, plain: { title: '有的指标追不到原始数据', why: '受管级要求', who: 'admin', action: '请管理员处理,或改选已发布级' } }, ], } -const PLAN_OK = { ...PLAN_BLOCKED, id: 'plan_2', problems: [] } +const PLAN_OK = { ...PLAN_BLOCKED, id: 'plan_2', problems: [], ok: true } // ---------------------------------------------------------------- 浏览器 @@ -277,8 +300,8 @@ async function open({ width = 1280, height = 900, state }) { } if (what === 'preview') return json(state.preview ?? PREVIEW) if (what === 'instantiate') { - return json({ blueprint: state.bp ?? BP, app_spec: { format: 'agentlab.app/1', name: '门店周报', pins: [], slots: {}, entries: [], - forms: {}, task_ui: {}, views: {}, pages: [], hooks: [], notify: [] }, problems: [] }) + return json({ blueprint: state.bp ?? BP, app_spec: state.appSpec ?? { format: 'agentlab.app/1', name: '门店周报', pins: [], slots: {}, + entries: [], forms: {}, task_ui: {}, views: {}, pages: [], hooks: [], notify: [] }, problems: [] }) } if (what === 'publish-plan') { state.planBodies = [...(state.planBodies ?? []), body()] @@ -545,6 +568,9 @@ await section('试运行:横幅、输入、换一期再试、待办、出错', check('横幅写这次的输入', (await banner.locator('[data-trial-inputs]').innerText()).includes('2026-W41')) check('应用的样子用 ViewRenderer 渲染(预览)', await page.locator('[data-view-renderer="preview"]').count() === 1 && (await page.locator('[data-stamp]').first().innerText()).includes('试运行')) + const trend = page.locator('[data-component="series"]') + check('趋势只有试运行一个点:照写服务端的说明(没有趋势),不当成出错', (await trend.locator('[data-series-note]').allInnerTexts()) + .includes(TRIAL_SERIES_NOTE), (await trend.innerText().catch(() => '')).replace(/\s+/g, ' ')) const tasks = page.locator('[data-trial-tasks]') check('有待办:右侧列试运行里的待办', await tasks.waitFor({ timeout: 5000 }).then(() => true, () => false)) check('按钮写「以试运行身份…」', (await tasks.locator('[data-task-approve]').innerText()).includes('以试运行身份同意') @@ -553,9 +579,32 @@ await section('试运行:横幅、输入、换一期再试、待办、出错', await tasks.locator('[data-task-approve]').click() check('以试运行身份同意:回复待办', await until(() => (state.responds ?? []).length === 1) && state.responds[0].decision === 'approve') await banner.locator('[data-trial-retry]').click() + check('预览没给表单:不自己从骨架里挑,只能原样再试', await banner.locator('[data-trial-retry-form] [data-field]').count() === 0) await banner.locator('[data-trial-retry-go]').click() check('换一期再试:再发起一次试运行', await until(() => (state.trials ?? []).length === 2)) + // 看板类:「换一期再试」的表单由服务端随预览给(ViewModel.trial.form,blueprints/trial.retry_form:看板类是按条件查看的 + // 条件表单),界面照它画、不自己从骨架里挑;填的期带进试运行 + const board = { bp: READY(), appSpec: BOARD_SPEC, preview: { ...PREVIEW, trial: { ...PREVIEW.trial, form: BOARD_SPEC.forms.start } } } + const b = await open({ state: board }) + await b.page.goto(`${WEB}/blueprints/bp_week`) + await b.page.locator('[data-bp-instantiate]').click() + await b.page.waitForTimeout(300) + await b.page.locator('[data-blueprint-trial]').click() + await b.page.waitForURL(/\/trial\?run=run_trial/, { timeout: 5000 }) + const bBanner = b.page.locator('[data-trial-banner]') + await bBanner.waitFor({ timeout: 5000 }) + check('横幅写服务端交回的这次输入(字段按表单写中文名)', (await bBanner.locator('[data-trial-inputs]').innerText()).includes('统计期:2026-W41'), + await bBanner.locator('[data-trial-inputs]').innerText()) + await bBanner.locator('[data-trial-retry]').click() + const retryForm = bBanner.locator('[data-trial-retry-form] [data-field="period"] input') + check('看板类:「换一期再试」照服务端随预览给的条件表单画,带上这次的期', await retryForm.count() === 1 + && await retryForm.inputValue() === '2026-W41', String(await retryForm.inputValue().catch(() => '(没有表单)'))) + await retryForm.fill('2026-W40') + await bBanner.locator('[data-trial-retry-go]').click() + check('……换的期带进试运行', await until(() => (board.trials ?? []).length === 2) && board.trials[1]?.inputs?.period === '2026-W40', + JSON.stringify(board.trials)) + // 出错:白话说为什么,给「交给助手修」「回到卡片改」 const bad = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), preview: PREVIEW_FAILED, streams: [{ when: '试运行出错了', ops: [{ op: 'done', explanation: '改了' }] }] } @@ -604,7 +653,7 @@ await section('发布摘要:三栏、处置、交给助手修', async () => { await section('发布摘要:确认、逐步打勾、停下、接着做', async () => { const running = { ...clone(PLAN_OK), status: 'running', steps: steps(2) } - const stopped = { ...clone(PLAN_OK), status: 'stopped', steps: steps(7, 'grant', '名录里没有组「门店」:先在设置里建这个组,或者在卡片上改授权') } + const stopped = { ...clone(PLAN_OK), status: 'stopped', ok: false, steps: steps(7, 'grant', '名录里没有组「门店」:先在设置里建这个组,或者在卡片上改授权') } const done = { ...clone(PLAN_OK), status: 'done', steps: steps(9) } const state = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_OK)], diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index cc9d50bc..98dd2172 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2682,5 +2682,47 @@ await section('单次运行时限将至(cause: run_deadline):核查没做 check('……不出 undefined、NaN、[object Object]', !odd.some((s) => /undefined|NaN|object Object/.test(`${s.title}${s.sub ?? ''}${s.next ?? ''}`))) }) +await section('读入附件 file.extracted:读入多少字、全文多少字、截断和抽不出都看得见,工件可点开', async () => { + // 服务端 files/text.py:模板函数 file_text 每读一个附件记一条,node_id 是读它的节点。以前这类事件落进「未识别的事件」 + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data }) + const ART = 'f1e0'.repeat(16) + const base = { file_id: 'f1', sha256: 'ab'.repeat(32), artifact: ART } + const steps = mod.decodeRun([ + E(1, 'run.started', null, { nodes: 2 }), + E(2, 'node.started', 'llm', { node_type: 'llm', label: '读说明' }), + E(3, 'file.extracted', 'llm', { ...base, name: '报损说明.txt', chars: 1200, total_chars: 1200, truncated: false, ok: true }), + E(4, 'file.extracted', 'llm', { ...base, name: '合同.pdf', chars: 20000, total_chars: 53120, truncated: true, ok: true }), + E(5, 'file.extracted', 'llm', { ...base, name: '扫描件.png', chars: 0, total_chars: 0, truncated: false, ok: false, + reason: '图片里的文字目前读不出来' }), + E(6, 'node.finished', 'llm', {}), + ]) + const all = flatten(steps) + const rows = all.filter((s) => s.code === 'file_extracted') + check('每读一个附件一行,挂在读它的节点下,不再是「未识别的事件」', rows.length === 3 && !all.some((s) => s.title === '未识别的事件') + && all.find((s) => s.kind === 'node' && s.nodeId === 'llm')?.children?.filter((c) => c.code === 'file_extracted').length === 3, + all.map((s) => s.title).join(' | ')) + const [whole, cut, failedRead] = rows + check('读全了:标题写文件名和读入字数,副标题写全文已全部读入,不用提醒色', whole?.title === '读入附件「报损说明.txt」1,200 字' + && whole.sub === '全文 1,200 字,已全部读入' && whole.level === undefined, `${whole?.title} / ${whole?.sub} / ${whole?.level}`) + check('截断了:副标题写全文多少字、只读入了前多少字,提醒色', cut?.title === '读入附件「合同.pdf」20,000 字' + && cut.sub === '全文 53,120 字,只读入了前 20,000 字,其余没有读入' && cut.level === 'warn', `${cut?.title} / ${cut?.sub}`) + check('抽不出:标题说抽不出文字,副标题写原因,提醒色', failedRead?.title === '附件「扫描件.png」抽不出文字' + && failedRead.sub === '图片里的文字目前读不出来' && failedRead.level === 'warn', `${failedRead?.title} / ${failedRead?.sub}`) + check('读入的那一段可以点开(工件)', rows.every((s) => s.artifact === ART)) + check('标题、副标题里没有内部代码', !rows.some((s) => /file\.extracted|file_extracted|total_chars|truncated/.test(`${s.title}${s.sub ?? ''}`))) + // 缺字段、类型不对:不崩,不出 undefined、NaN、[object Object] + let broken = null + let odd = [] + try { + odd = flatten(mod.decodeRun([{}, { name: 7, chars: 'x', ok: 'yes' }, { ok: false }].map((d, i) => E(i + 1, 'file.extracted', null, d)))) + .filter((s) => s.code === 'file_extracted') + } catch (e) { + broken = String(e?.message ?? e) + } + check('缺字段的事件:不抛异常,每条照样一行', broken === null && odd.length === 3, broken ?? `${odd.length} 行`) + check('……不出 undefined、NaN、[object Object],没有文件名就不写名字,不知道字数就不写数', !odd.some((s) => /undefined|NaN|object Object|「」|\d/.test(`${s.title}${s.sub ?? ''}`)) + && odd[0]?.title === '读入附件' && odd[2]?.title === '附件抽不出文字', odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; ')) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index 602c986c..6bb2ee6c 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -12,7 +12,10 @@ // - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」; // 待办箱一行写所属实例,同标题的分得开; // - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段; -// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」。 +// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」; +// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有 +// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那 +// 一份,别人取的不算);「退回」可以写原因,走待办接口 decision=reject。 // // 接口全部在浏览器层答掉(/api/tasks/**),数据是这里虚构的;写请求记在探针里。 // @@ -110,6 +113,26 @@ const TASKS = { escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } }, payload: { kind: 'human_node', message: '钱七报销 300 元' }, }, + // 审核个人应用:不挂在中断上(node_id 为空),派给处理审核的人;payload 同服务端 blueprints/personal.review_payload + review: { + ...base, id: 'review', kind: 'review', title: '审核个人应用「我的退款周报」', node_id: null, app_id: 'app_mine', + app_name: '我的退款周报', app_version: null, workflow_name: null, run_id: 'run_p1', + assignments: [assignment('r1', '审批人甲', 'pending', null, { source: 'review' })], + payload: { kind: 'review', blueprint_id: 'bp_mine', app_id: 'app_mine', name: '我的退款周报', purpose: '每周看自己门店的退款', + template_id: 'periodic_board', submitted_by: 'zhangsan', note: '想给门店组的人都用', copies: [] }, + people: { zhangsan: '张三', 审批人甲: '审批人甲' }, + }, + // 已经取过副本:自己取的那一份给「打开我的副本」;别的处理人取的不算 + reviewed: { + ...base, id: 'reviewed', kind: 'review', title: '审核个人应用「门店日报」', node_id: null, app_id: 'app_daily', + app_name: '门店日报', app_version: null, workflow_name: null, run_id: 'run_p2', + assignments: [assignment('r2', '审批人甲', 'pending', null, { source: 'review' })], + payload: { kind: 'review', blueprint_id: 'bp_daily', app_id: 'app_daily', name: '门店日报', purpose: '每天看门店的销售', + template_id: 'periodic_board', submitted_by: 'zhangsan', note: '', copies: [ + { id: 'bp_daily_other', owner: 'lisi', status: 'drafting', app_id: null }, + { id: 'bp_daily_mine', owner: '审批人甲', status: 'trialed', app_id: null }] }, + people: { zhangsan: '张三' }, + }, input: { ...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息', form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: { @@ -144,9 +167,13 @@ async function open(state) { const resolved = m[1] !== 'signoff' return route.fulfill({ json: { task: { ...t, status: resolved ? 'resolved' : 'pending' }, resolved } }) } + // 审核待办「取副本,去发布」:服务端建一份归处理人的共享副本,交回副本蓝图 + if (p === '/api/blueprints/bp_mine/copy') return route.fulfill({ json: { id: 'bp_mine_copy', scope: 'shared', owner: '审批人甲' } }) return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } }) } if (p === '/api/auth/me') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } }) + // 进了副本蓝图之后的页面不在这里查:蓝图一律答「不存在」,不打到真实服务端 + if (p.startsWith('/api/blueprints/')) return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } }) if (p === '/api/tasks/count') return route.fulfill({ json: { mine: Object.keys(TASKS).length, apps: Object.keys(TASKS).length, other: 0 } }) if (p === '/api/tasks') return route.fulfill({ json: { items: Object.values(TASKS) } }) const m = p.match(/^\/api\/tasks\/([^/]+)$/) @@ -279,11 +306,63 @@ await section('补充输入:表单校验、422 挂回字段', async () => { check('提交的是表单的值,decision 是 submit', sent?.body?.decision === 'submit' && sent?.body?.values?.invoice === 'FP-001', JSON.stringify(sent?.body)) }) +await section('审核个人应用:取副本去发布、打开我的副本、退回', async () => { + const state = { writes: [] } + const page = await open(state) + await page.goto(`${WEB}/apps/inbox/review`) + const box = page.locator('[data-task-review]') + await box.waitFor({ timeout: 15000 }) + const text = await box.innerText() + check('写提交人(显示名)、目的、提交说明', text.includes('提交人:张三') && text.includes('每周看自己门店的退款') + && text.includes('想给门店组的人都用'), text.replace(/\s+/g, ' ')) + check('同意不在这里点:没有「同意」「驳回」按钮,也不写「没有派给你」', + await page.locator('[data-task-approve], [data-task-reject]').count() === 0 && !(await card(page).innerText()).includes('没有派给你')) + check('没取过副本:给「取副本,去发布」,没有「打开我的副本」', await box.locator('[data-task-review-copy]').count() === 1 + && await box.locator('[data-task-review-open]').count() === 0) + // 待办挂在提交人那次个人应用的刷新上,只有拥有者读得到:不给「打开实例」「打开运行记录」(链出去只会 404 / 403) + const links = await card(page).locator('a[href]').evaluateAll((as) => as.map((a) => a.getAttribute('href'))) + check('不给打不开的链接:没有指向提交人个人应用的实例页、也没有运行记录页', !links.some((h) => /\/apps\/app_mine\/i\/|^\/runs\//.test(h ?? '')) + && !/查看实例|查看运行/.test(await card(page).innerText()), links.join(' , ')) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') + await box.locator('[data-task-review-copy]').click() + check('取副本:POST /api/blueprints/{原蓝图}/copy', await page.waitForURL(/\/blueprints\/bp_mine_copy$/, { timeout: 5000 }).then(() => true, () => false) + && state.writes.some((w) => w.method === 'POST' && w.path === '/api/blueprints/bp_mine/copy'), page.url()) + check('……然后进副本蓝图(在副本上试运行、走发布单)', /\/blueprints\/bp_mine_copy$/.test(page.url()), page.url()) + check('取副本不回复待办(待办等发布单做完自动记为已发布)', !state.writes.some((w) => w.path === '/api/tasks/review/respond')) + + // 已经取过副本:只认自己取的那一份 + const p2 = await open({ writes: [] }) + await p2.goto(`${WEB}/apps/inbox/reviewed`) + const box2 = p2.locator('[data-task-review]') + await box2.waitFor({ timeout: 15000 }) + const openCopy = box2.locator('[data-task-review-open]') + check('取过副本:给「打开我的副本」,链到自己取的那一份', await openCopy.count() === 1 + && (await openCopy.getAttribute('href')) === '/blueprints/bp_daily_mine', String(await openCopy.getAttribute('href').catch(() => ''))) + check('……别的处理人取的副本不算,也不再给「取副本」', await box2.locator('[data-task-review-copy]').count() === 0 + && !(await box2.innerHTML()).includes('bp_daily_other')) + + // 退回:可以写原因,走待办接口 + const s3 = { writes: [] } + const p3 = await open(s3) + await p3.goto(`${WEB}/apps/inbox/review`) + const box3 = p3.locator('[data-task-review]') + await box3.waitFor({ timeout: 15000 }) + await box3.getByRole('textbox', { name: '退回的原因(选填)' }).fill('口径和财务的对不上') + await box3.locator('[data-task-review-reject]').click() + await p3.waitForTimeout(300) + const sent = s3.writes.find((w) => w.path === '/api/tasks/review/respond') + check('退回:decision 是 reject,带上原因', sent?.body?.decision === 'reject' && sent?.body?.note === '口径和财务的对不上', JSON.stringify(sent?.body)) + check('退回后提示「已退回」', await p3.getByText('已退回').first().waitFor({ timeout: 3000 }).then(() => true, () => false)) + check('退回不取副本', !s3.writes.some((w) => w.path.endsWith('/copy'))) +}) + await section('待办箱:列表和徽标', async () => { const page = await open({ writes: [] }) await page.goto(`${WEB}/apps?tab=inbox`) await page.locator('[data-inbox-task]').first().waitFor({ timeout: 15000 }) check('每条待办一行,链到详情', (await page.locator('[data-inbox-task]').count()) === Object.keys(TASKS).length) + check('审核个人应用的一行写种类「审核个人应用」', (await page.locator('[data-inbox-task="review"]').innerText()).includes('审核个人应用') + && (await page.locator('[data-inbox-task="review"]').getAttribute('data-task-kind')) === 'review') check('「应用」导航上的徽标数应用里的待办', (await page.locator('[data-apps-badge]').innerText()).trim() === String(Object.keys(TASKS).length)) check('页签上带数', /\d/.test(await page.getByRole('tab', { name: /待办/ }).innerText())) }) diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index 5c95dad1..d5546dbd 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -679,6 +679,58 @@ await section('设置 · 起草模型', async () => { await old.close() }) +await section('设置 · 应用:使用者可以建个人应用(只有管理员能改)', async () => { + // 管理员开关 apps.user_personal_apps(docs/design/normal-user-apps.md N7):在「应用」一组,和按需视图每日上限一起整组写; + // 只有管理员能改,搭建者看得到、改不了。使用者那边的入口随认人接口的 user_personal_apps 变(check-auth 查) + let stored = await get('/settings') + stored = { ...stored, apps: { ...(stored.apps ?? {}), on_demand_daily_limit: 200, user_personal_apps: false } } + const { page, sent, errors, close } = await open('/settings/prefs', { + handlers: [ + [/^GET \/settings$/, (route) => json(stored)(route)], + [/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }], + ], + }) + const box = page.locator('[data-apps-settings]') + await box.waitFor() + const toggle = box.locator('[data-apps-personal]') + check('「应用」一组里有「使用者可以建个人应用」,存着的是关', await toggle.count() === 1 && !(await toggle.isChecked()) + && (await box.innerText()).includes('使用者可以建个人应用')) + const hint = await page.locator('#pref-apps-personal-hint').innerText().catch(() => '') + check('……写明开了之后使用者能做什么:只有自己看得见、只读遮罩、给别人用要审核', hint.includes('只有自己看得见') + && hint.includes('遮罩') && hint.includes('审核'), hint) + check('管理员(关登录)可以改', await toggle.isEnabled()) + await toggle.check() + const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ') + check('……保存条写「使用者可以建个人应用」', bar.includes('有 1 项未保存') && bar.includes('使用者可以建个人应用'), bar) + await page.getByRole('button', { name: /保存设置/ }).click() + await page.waitForTimeout(500) + const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values + check('保存只 PUT apps 一组:开关写成 true,每日上限原样带回', Object.keys(put ?? {}).join() === 'apps' + && put.apps.user_personal_apps === true && put.apps.on_demand_daily_limit === 200, JSON.stringify(put)) + check('……读回来还是开着', await toggle.isChecked()) + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() + + // 搭建者(开了登录):开关照服务端显示,改不了,写明只有管理员可以修改 + const builder = await open('/settings/prefs', { + handlers: [ + [/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true, + user_personal_apps: true })], + [/^GET \/settings$/, (route) => json({ ...stored, apps: { ...stored.apps, user_personal_apps: true } })(route)], + ], + }) + const t2 = builder.page.locator('[data-apps-personal]') + await t2.waitFor() + check('搭建者:开关照服务端显示(开着),但改不了', await t2.isChecked() && await t2.isDisabled()) + check('……写明只有管理员可以修改', (await builder.page.locator('[data-prefs-readonly]').innerText().catch(() => '')).includes('只有管理员')) + await t2.click({ force: true, timeout: 2000 }).catch(() => {}) + await builder.page.waitForTimeout(300) + check('……点了也不变、不出保存条、不发 PUT', await t2.isChecked() && await builder.page.locator('[data-prefs-bar]').count() === 0 + && !builder.sent.some((s) => s.key === 'PUT /settings')) + check('没有运行时报错', builder.errors.length === 0, builder.errors[0] ?? '') + await builder.close() +}) + await section('设置 · 点名的接入已停用或不存在', async () => { // 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了; // 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的 diff --git a/scripts/check-personal.mjs b/scripts/check-personal.mjs index ece95d53..feb5761b 100644 --- a/scripts/check-personal.mjs +++ b/scripts/check-personal.mjs @@ -10,7 +10,12 @@ // 「审核中」和交给了谁,不再给提交按钮; // - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」; // 结论是已发布写「已有正式版本:去看看」并链到那个应用; -// 详情没给时看蓝图的状态(in_review 写「审核中」)。 +// 详情没给时看蓝图的状态(in_review 写「审核中」); +// - 管理员关了「使用者可以建个人应用」:已有的个人应用还看得到,写明暂时不能刷新、提交审核,两个按钮置灰; +// - 开了登录的管理员看别人的个人应用(服务端 api/apps._personal_stub,my_role 为空):应用首页另列一组「使用者的个人应用」, +// 只写名称、拥有者、创建和更新时间,不链到页面、不混进「我的应用」;可以归档(确认框写后果);直接打开它的地址只给 +// 元数据和「归档」,不去取页面、蓝图; +// - 别人的共享蓝图(不是拥有者、不管它发布出来的应用):服务端当没有(404),蓝图页写「不存在或已被删除」。 // 使用者在蓝图里「存成我的应用」那一步在 check-blueprint 里查。 // // 页面是真页面(走真的应用外壳),/api/apps/**、/api/blueprints/** 在浏览器层答掉,数据是这里虚构的(「我的退款周报」); @@ -45,20 +50,39 @@ async function section(name, fn) { } const ago = (ms) => new Date(Date.now() - ms).toISOString() -const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true } +// 认人接口:管理员开了「使用者可以建个人应用」(user_personal_apps,服务端 api/auth.principal_full) +const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true, + user_personal_apps: true } +const ADMIN = { mode: 'local', username: 'root', display_name: '管理员甲', role: 'admin', groups: [], verified: true, + user_personal_apps: true } +const BUILDER = { mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true, + user_personal_apps: true } // ---------------------------------------------------------------- 假数据 +// 拥有者对自己的个人应用是管理权(服务端 apps/access.personal_role),详情里带草稿和 personal const MINE = { id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active', - published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(600_000), - scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine', + published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'manage', created_at: ago(86_400_000), + updated_at: ago(600_000), scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine', } const SHARED = { id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active', - published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(3600_000), - scope: 'shared', owner: null, blueprint_id: null, + published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', created_at: ago(7 * 86_400_000), + updated_at: ago(3600_000), scope: 'shared', owner: null, blueprint_id: null, } +// 管理员看到的别人的个人应用:只有元数据(服务端 api/apps._personal_stub,列表、详情、归档的回包同一份) +const STUB = { + id: 'app_mine', name: '我的退款周报', description: '', icon: 'app', status: 'active', published_version: null, level: null, + needs_upgrade: false, pending_tasks: 0, my_role: null, created_at: '2026-10-01T08:00:00+00:00', + updated_at: '2026-10-14T09:30:00+00:00', scope: 'personal', owner: 'zhangsan', blueprint_id: null, +} +/** 详情里的 personal:形状同服务端 blueprints/personal.personal_state */ +const personalOf = (over = {}) => ({ allowed: true, remaining_today: 2, daily_limit: 5, blueprint_status: 'personal', + graph_changed: false, review: null, ...over }) +/** personal_state.review:最近一条审核待办 */ +const reviewOf = (over = {}) => ({ task_id: 't1', status: 'pending', decision: null, note: '', app_id: null, to: [], + resolved_at: null, ...over }) const STAMP = { as_of: ago(3600_000), as_of_text: '截至 2026-10-14 09:05;统计期 上一 ISO 周', pin: 'mine', graph_hash: '9c1e22aa', calibers: [], tier: 'formal', tier_text: '完整出具', from: '探索 · 未经发布审核', sealed: true, notes: [], @@ -76,7 +100,10 @@ const HOME = { forms: {}, problems: [], } -const detailOf = (personal) => ({ ...MINE, published: null, ...(personal === undefined ? {} : { personal }) }) +const DRAFT = { format: 'agentlab.app/1', name: '我的退款周报', pins: [], slots: {}, entries: [], forms: {}, task_ui: {}, + views: {}, pages: [{ id: 'home', title: '本周退款', body: [] }], hooks: [], notify: [] } +const detailOf = (personal) => ({ ...MINE, published: null, draft: DRAFT, draft_bindings: { slots: {} }, created_by: 'zhangsan', + ...(personal === undefined ? {} : { personal }) }) // ---------------------------------------------------------------- 浏览器 @@ -100,13 +127,23 @@ async function open({ state, width = 1280, height = 900 }) { return next.status ? route.fulfill({ status: next.status, json: next.json }) : route.fulfill({ json: next }) } if (p === '/api/apps/app_mine/submit-review') return route.fulfill({ json: { task_id: 'task_review_1' } }) + if (p === '/api/apps/app_mine/archive') { + state.archived = true + return route.fulfill({ json: { ...STUB, status: 'archived', published: null } }) + } return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } }) } if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER }) if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } }) if (p === '/api/tasks') return route.fulfill({ json: { items: [] } }) - if (p === '/api/apps') return route.fulfill({ json: { items: [MINE, SHARED] } }) + if (p === '/api/apps') { + // 归档了的不再列(服务端默认不列归档的) + const items = (state.apps ?? [MINE, SHARED]).filter((a) => !(state.archived && a.id === 'app_mine')) + return route.fulfill({ json: { items, can_create: { shared: false, personal: true } } }) + } if (p === '/api/apps/app_mine') return route.fulfill({ json: state.detail }) + // 别人的共享蓝图:服务端当没有(blueprints/store.can_access) + if (p === '/api/blueprints/bp_other') return route.fulfill({ status: 404, json: { detail: '蓝图不存在或已被删除' } }) if (p === '/api/apps/app_mine/pages/home') { state.pageHits = (state.pageHits ?? 0) + 1 return route.fulfill({ json: HOME }) @@ -126,7 +163,7 @@ const bar = (page) => page.locator('[data-personal-bar]') // ---------------------------------------------------------------- 首页 await section('首页:「我的应用」单独一组,角标「个人」', async () => { - const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }) } + const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) } const { page } = await open({ state }) await page.goto(`${WEB}/apps?tab=apps`) const group = page.locator('[data-app-group="personal"]') @@ -145,7 +182,7 @@ await section('首页:「我的应用」单独一组,角标「个人」', as await section('应用页:写明是个人应用、印章照服务端写、刷新写剩余次数', async () => { const state = { - writes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }), + writes: [], detail: detailOf(personalOf()), refreshes: [{ run_id: 'run_p2', remaining: 1 }, { run_id: 'run_p3', remaining: 0 }], } const { page } = await open({ state }) @@ -155,7 +192,8 @@ await section('应用页:写明是个人应用、印章照服务端写、刷 && /未经发布审核/.test(await page.locator('[data-personal-banner]').innerText())) check('页头角标写「个人」', (await page.locator('header [data-app-personal]').innerText()) === '个人') check('印章的来源照服务端写「探索 · 未经发布审核」', (await page.locator('[data-stamp-row="from"]').innerText()).includes('探索 · 未经发布审核')) - check('个人应用没有「编辑」(使用者没有管理权)', await page.locator('[data-page-edit-start]').count() === 0) + check('拥有者对自己的个人应用有管理权:可以在页面上直接改', await page.locator('[data-page-edit-start]').count() === 1) + check('……开关开着:没有「暂时不能刷新」的提示', await page.locator('[data-personal-off]').count() === 0) const left = page.locator('[data-personal-remaining]') check('「刷新」旁写明今天还剩几次(服务端给的)', (await left.innerText()) === '今天还剩 2 次', await left.innerText()) @@ -193,7 +231,7 @@ await section('刷新:服务端回 429 时照实说原话、按钮置灰;不 // ---------------------------------------------------------------- 提交审核 await section('提交审核:选人才能交、带说明;提交后写「审核中」', async () => { - const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 5, review: null }) } + const state = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 5 })) } const { page } = await open({ state }) await page.goto(`${WEB}/apps/app_mine`) await bar(page).waitFor({ timeout: 15000 }) @@ -219,15 +257,16 @@ await section('提交审核:选人才能交、带说明;提交后写「审 }) await section('审核状态:以服务端为准(审核中、已有正式版本、详情没给时看蓝图)', async () => { - const a = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3, review: { task_id: 't1', status: 'pending', to: [{ kind: 'group', name: '搭建组' }] } }) } + const a = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3, blueprint_status: 'in_review', + review: reviewOf({ to: [{ kind: 'group', name: '搭建组' }] }) })) } const one = await open({ state: a }) await one.page.goto(`${WEB}/apps/app_mine`) await bar(one.page).waitFor({ timeout: 15000 }) check('详情说审核中:写「审核中」和交给的组', /已提交给 搭建组 审核/.test(await one.page.locator('[data-personal-review="in_review"]').innerText())) check('……没有「提交审核」', await one.page.locator('[data-personal-submit-review]').count() === 0) - const b = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3, - review: { task_id: 't1', status: 'resolved', decision: 'published', app_id: 'app_refund' } }) } + const b = { writes: [], refreshes: [], detail: detailOf(personalOf({ remaining_today: 3, + review: reviewOf({ status: 'resolved', decision: 'published', app_id: 'app_refund', resolved_at: ago(600_000) }) })) } const two = await open({ state: b }) await two.page.goto(`${WEB}/apps/app_mine`) await bar(two.page).waitFor({ timeout: 15000 }) @@ -246,8 +285,85 @@ await section('审核状态:以服务端为准(审核中、已有正式版 [one, two, three].map((x) => x.page.errors[0]).filter(Boolean).join(' | ')) }) +await section('审核退回:写退回和原因,可以再提交', async () => { + const state = { writes: [], refreshes: [], detail: detailOf(personalOf({ + review: reviewOf({ status: 'resolved', decision: 'reject', note: '口径和财务的对不上', resolved_at: ago(600_000) }) })) } + const { page } = await open({ state }) + await page.goto(`${WEB}/apps/app_mine`) + await bar(page).waitFor({ timeout: 15000 }) + const rejected = page.locator('[data-personal-review="rejected"]') + check('退回了:写退回和原因', (await rejected.innerText().catch(() => '')).includes('口径和财务的对不上'), await rejected.innerText().catch(() => '')) + check('……可以再提交', await page.locator('[data-personal-submit-review]').isEnabled()) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') +}) + +await section('开关关了:已有的个人应用还看得到,暂时不能刷新、提交审核', async () => { + // 能不能刷新、提交审核由服务端在详情里给(personal_state.allowed,同 blueprints/personal.allowed),界面不按开关自己推 + const state = { me: { ...USER, user_personal_apps: false }, writes: [], refreshes: [], + detail: detailOf(personalOf({ allowed: false })) } + const { page } = await open({ state }) + await page.goto(`${WEB}/apps/app_mine`) + await bar(page).waitFor({ timeout: 15000 }) + check('写明管理员关了开关、暂时不能刷新和提交审核', (await page.locator('[data-personal-off]').innerText().catch(() => '')) + .includes('暂时不能刷新、提交审核')) + check('……「刷新」「提交审核」置灰', await page.locator('[data-personal-refresh]').isDisabled() + && await page.locator('[data-personal-submit-review]').isDisabled()) + check('……页面照常显示', await page.locator('[data-component="metric"], [data-node="refund"]').count() >= 1) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') +}) + +await section('管理员看别人的个人应用:只有名称、拥有者、时间,可以归档', async () => { + const state = { me: ADMIN, apps: [STUB, { ...SHARED, my_role: 'manage' }], writes: [], refreshes: [], + detail: { ...STUB, published: null } } + const { page } = await open({ state }) + await page.goto(`${WEB}/apps?tab=apps`) + const group = page.locator('[data-app-group="others"]') + check('另列一组「使用者的个人应用」', await group.waitFor({ timeout: 15000 }).then(() => true, () => false) + && (await group.locator('h2').innerText()) === '使用者的个人应用') + check('……写明只看得到名称、拥有者和时间,看不到内容', (await group.innerText()).includes('看不到页面、运行和蓝图的内容')) + const card = group.locator('[data-app-stub="app_mine"]') + const text = (await card.innerText()).replace(/\s+/g, ' ') + check('卡片写名称、拥有者、创建和更新时间', text.includes('我的退款周报') && text.includes('拥有者:zhangsan') + && await card.locator('[data-app-stub-created]').count() === 1 && await card.locator('[data-app-stub-updated]').count() === 1, text) + check('……不链到应用页(看不到内容)', await card.locator('a[href="/apps/app_mine"]').count() === 0) + check('……不混进「我的应用」', await page.locator('[data-app-group="personal"]').count() === 0) + await card.locator('[data-app-stub-archive]').click() + const dlg = page.getByRole('dialog') + await dlg.waitFor({ timeout: 5000 }) + const said = await dlg.innerText() + check('归档先确认,写明后果(拥有者不能再刷新、没有恢复)', said.includes('不能再刷新或提交审核') && said.includes('没有恢复'), said.replace(/\s+/g, ' ')) + await dlg.getByRole('button', { name: '归档个人应用' }).click() + await page.waitForTimeout(400) + check('确认后 POST /archive', state.writes.some((w) => w.path === '/api/apps/app_mine/archive')) + check('……归档之后列表里不再有它', await page.locator('[data-app-stub="app_mine"]').waitFor({ state: 'detached', timeout: 5000 }) + .then(() => true, () => false)) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') + + // 直接打开它的地址:只给元数据和「归档」,不去取页面、蓝图 + const s2 = { ...state, archived: false, writes: [], pageHits: 0, blueprintHits: 0 } + const two = await open({ state: s2 }) + await two.page.goto(`${WEB}/apps/app_mine`) + const stubPage = two.page.locator('[data-app-stub-page="app_mine"]') + check('打开地址:写明这是谁的个人应用、看不到内容', await stubPage.waitFor({ timeout: 15000 }).then(() => true, () => false) + && (await stubPage.locator('[data-app-stub-notice]').innerText()).includes('这是 zhangsan 的个人应用')) + check('……给「归档」', await stubPage.locator('[data-app-stub-archive]').count() === 1) + await two.page.waitForTimeout(500) + check('……不去取页面、蓝图', s2.pageHits === 0 && s2.blueprintHits === 0, `页面 ${s2.pageHits} 次、蓝图 ${s2.blueprintHits} 次`) + check('……没有刷新、提交审核', await two.page.locator('[data-personal-refresh], [data-personal-submit-review]').count() === 0) + check('没有未捕获的运行时错误', two.page.errors.length === 0, two.page.errors[0] ?? '') +}) + +await section('别人的共享蓝图:服务端当没有,蓝图页写「不存在或已被删除」', async () => { + const state = { me: BUILDER, writes: [], refreshes: [], detail: detailOf(personalOf()) } + const { page } = await open({ state }) + await page.goto(`${WEB}/blueprints/bp_other`) + check('写「蓝图不存在或已被删除」,不说它在不在', await page.getByText('蓝图不存在或已被删除').first().waitFor({ timeout: 15000 }) + .then(() => true, () => false)) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') +}) + await section('窄屏:390px 不出横向滚动', async () => { - const state = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 2, review: null }) } + const state = { writes: [], refreshes: [], detail: detailOf(personalOf()) } const { page } = await open({ state, width: 390, height: 844 }) for (const path of ['/apps?tab=apps', '/apps/app_mine']) { await page.goto(`${WEB}${path}`) diff --git a/scripts/check-records.mjs b/scripts/check-records.mjs index 32b5ec4e..47b32f40 100644 --- a/scripts/check-records.mjs +++ b/scripts/check-records.mjs @@ -65,9 +65,12 @@ const HOME = { { id: 'my_records', type: 'records_list', props: { table: 'loss', title: '报损登记' } }, { id: 'no_table', type: 'records_list', props: {} }, { id: 'server_page', type: 'records_list', props: { who: 'mine' }, - data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [] } }, + data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [], next_cursor: null, state: 'ok' } }, { id: 'empty_page', type: 'records_list', props: {}, data: { table: null, version: 0, columns: [], items: [], mine_only: true, state: 'empty', next_cursor: null } }, + // 按草稿预览(服务端 apps/resolve._records_list):不列真实的记录 + { id: 'preview_page', type: 'records_list', props: {}, + data: { table: null, version: 0, columns: [], items: [], mine_only: false, state: 'preview', next_cursor: null } }, ], forms: {}, problems: [], @@ -79,8 +82,10 @@ const COLUMNS = [ { name: 'photo', label: '照片', type: 'file' }, ] const PHOTO = { file_id: 'f_photo', name: '破损.png', size: 20480, sha256: 'ab'.repeat(32), type: 'image/png' } +// 一行的形状同服务端 records/listing.py:系统列(records.SYSTEM_COLUMNS)加上这一行在第几版表里(_version) const row = (id, seq, by, values, extra = {}) => ({ - _id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _supersedes: null, values, ...extra, + _id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _run_id: `run_${id}`, _supersedes: null, _schema_version: 1, + _version: 1, superseded: false, values, ...extra, }) /** zhangsan 的三条:r3 是对 r1 的更正(r1 被更正过,只在历史里) */ const MINE_ROWS = [ @@ -120,7 +125,7 @@ async function open({ state, width = 1280, height = 900 }) { const items = mine ? MINE_ROWS : ALL_ROWS const history = url.searchParams.get('history') === 'true' || url.searchParams.get('history') === '1' return route.fulfill({ json: { table: 'loss', version: 1, columns: COLUMNS, mine_only: mine, - items: history ? [R1, ...items] : items } }) + items: history ? [R1, ...items] : items, next_cursor: null, state: 'ok' } }) } return route.continue() }) @@ -167,6 +172,10 @@ await section('我的记录:使用者只看到自己的、标题写「我的 check('服务端给了第一页的照它画(这里是空的,写「你还没有提交过记录」)', (await page.locator('[data-node="server_page"]').innerText()).includes('你还没有提交过记录')) check('服务端说还没有记录表的照实说', (await page.locator('[data-node="empty_page"]').innerText()).includes('还没有记录表')) + const preview = page.locator('[data-node="preview_page"]') + check('按草稿预览(state=preview):照实说不列真实的记录,不说成「还没有记录表」,也不给重新读取', + await preview.locator('[data-records-preview]').count() === 1 && !(await preview.innerText()).includes('还没有记录表') + && await preview.getByRole('button', { name: '重新读取' }).count() === 0, (await preview.innerText()).replace(/\s+/g, ' ')) check('只读:没有发出写请求', state.writes.length === 0, state.writes.map((w) => w.path).join(',')) check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') })