From 845809486a1d29cd6754b2f5981acfcf67283c28 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:14:27 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=B0=E6=88=90=E5=A4=B1=E8=B4=A5=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E4=BA=BA=E7=9A=84=E9=A9=B3=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E6=88=90=20cancelled=20+=20end=5Freason=3Drejected=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E8=B4=B9=E6=96=B9=E9=83=BD=E8=AF=BB=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:动作确认、工具 / 代码审批、审批驳回即终止、填写 / 编辑被驳回、双盲裁决的驳回以前抛普通 NodeError,运行记成 failed。 通知推「实例失败」,定时推「运行失败」,发布单把驳回过的试运行说成「试运行失败了」并挡住发布,记录页还给「继续运行」 (接着跑只会读到同一个驳回答复、再停一次)。人的决定被说成故障,每个消费方各写一份映射,已经漏了四处(05-已知遗留 (a)、总方案 X9)。 做了什么: - engine/context 新增 NodeRejected(NodeError),带 node_id、kind、decision(reject / expired_reject)、actor、note;六处驳回改抛它, 原话一字不变。待办超时驳回的答复带 expired,Decision.expired 据此记成 expired_reject。 - runner._drive 在通用 except 之前单独接住:status=cancelled、end_reason=rejected、error 写原话、error_node 写那一步, run.cancelled 带 reason/node_id/label/kind/decision/actor/note;关审批、回收沙箱、关流、封存走取消的那一套。手动停止 (执行中取消、放弃断点上的运行)写 end_reason=stopped,run.cancelled 带 reason=stopped。 - 继续运行:被驳回的 409 code=run_rejected「被驳回的运行不能接着跑:改好内容后重新发起」;存量 failed+驳回(失败那一步上 最近一条处理完的待办是驳回,和实例页同一份判断 resolve.legacy_rejected)也拒。RunOut 带 end_reason。 - apps/resolve.instance_state:cancelled+rejected 和存量 failed+驳回说同一句(「「付款」未确认(被驳回)」「「x」被驳回」)。 - notify_run:状态那一句取自 instance_state,被驳回的走「实例结束」、ok=False,走到「已退回」成果的不再写「已完成」; 手动停止照旧不推。顺带修 04-对外集成与触达 B1:发起人是认证身份(run.origin.actor_verified)时 users 传 [started_by], 个人渠道订阅了实例完成 / 失败终于收得到;自报署名不算。 - 定时 webhook:cancelled+rejected 按「运行结束」推「未出具(被驳回)」、写驳回的那一步,不写备注和原话、不画成功色; 手动停止仍不推。 - 发布单:试运行被驳回不算 plan.trial_failed,记 warning plan.trial_rejected「最近一次试运行在「x」被驳回、没有走完」(带白话)。 - (h) 重启时排队中的运行改成 failed 时补发 run.failed(同一句原因),时间线里有了失败原因这一行;不写结束时间、不封存,同以前。 - (f) tasks.tick / actions.tick 交回这一拍处理过的 id 列表(调度循环只记条数),测试改成只断言自己造的待办;先加了预置 一条无关到期待办的 fixture,旧的按条数断言当场失败(3 == 1),改完通过。 已封存的老运行不改写。测试:新增 test_run_rejected(五种关卡参数化、超时驳回、继续运行拒绝含存量、手动停止、补发 run.failed、 instance_state 新老形状同一句)、test_notify_run(驳回走实例结束、手动停止不推、已退回不写已完成、个人渠道 B1 两条); webhook、发布单、动作确认、双盲、审批的老断言跟上。去掉 runner 里接 NodeRejected 的分支回跑,新测试 8 条失败。 --- backend/app/api/runs.py | 11 +- backend/app/apps/resolve.py | 48 ++++-- backend/app/blueprints/publish_plan.py | 20 +++ backend/app/engine/approval.py | 4 + backend/app/engine/context.py | 29 ++++ backend/app/engine/nodes/dual_check.py | 4 +- backend/app/engine/nodes/human.py | 6 +- backend/app/engine/nodes/tools.py | 19 ++- backend/app/engine/runner.py | 71 ++++++-- backend/app/notify/service.py | 26 ++- backend/app/plain.py | 3 + backend/app/process/actions.py | 9 +- backend/app/process/tasks.py | 14 +- backend/app/scheduler/loop.py | 4 +- backend/app/scheduler/webhook.py | 41 ++++- backend/tests/test_app_instance_page.py | 2 +- backend/tests/test_approval.py | 13 +- backend/tests/test_blueprint_trial.py | 2 +- backend/tests/test_dual_check_node.py | 10 +- backend/tests/test_notify_run.py | 199 ++++++++++++++++++++++ backend/tests/test_process_actions.py | 20 ++- backend/tests/test_process_tasks.py | 37 ++-- backend/tests/test_publish_plan.py | 24 +++ backend/tests/test_records.py | 2 +- backend/tests/test_run_rejected.py | 214 ++++++++++++++++++++++++ backend/tests/test_scheduler_webhook.py | 24 ++- 26 files changed, 768 insertions(+), 88 deletions(-) create mode 100644 backend/tests/test_notify_run.py create mode 100644 backend/tests/test_run_rejected.py diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index 3a35364e..2832e340 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -56,6 +56,9 @@ class RunOut(BaseModel): input: dict[str, Any] output: dict[str, Any] error: str | None + #: 结束在 cancelled 的原因(docs/design/next-round/00-总方案.md X9):rejected 人工驳回、stopped 手动停止。 + #: 其余终态、加这一列之前的运行为空。界面、通知、定时推送都读这一个字段,不再解析报错原文 + end_reason: str | None = None usage: dict[str, Any] run_class: str | None = "exploratory" version: int | None = None @@ -289,11 +292,13 @@ async def cancel_run(run_id: str, x_actor: str | None = Header(default=None)) -> run = await session.get(Run, run_id) if run is None: raise HTTPException(404, "运行记录不存在") - raise HTTPException(409, _cannot_cancel(run.status)) + raise HTTPException(409, _cannot_cancel(run.status, run.end_reason)) return {"ok": True, "status": outcome} -def _cannot_cancel(status: str) -> str: +def _cannot_cancel(status: str, end_reason: str | None = None) -> str: + if status == "cancelled" and end_reason == "rejected": + return "这次运行已被驳回结束,没有要停止的。" return { "succeeded": "这次运行已完成,没有要停止的。", "failed": "这次运行已失败结束,没有要停止的;如需继续,请点「继续运行」。", @@ -396,7 +401,7 @@ async def continue_run( except KeyError as e: raise _missing(e) from e except ContinueRefused as e: - # 正式运行带图(formal_graph_locked)、写入结果未知还没核实(action_unresolved):带机器码 + # 正式运行带图(formal_graph_locked)、写入结果未知还没核实(action_unresolved)、被驳回了的(run_rejected):带机器码 raise CodedHTTPException(409, str(e), e.code) from e except ValueError as e: raise HTTPException(409, str(e)) from e diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 97564ea6..0a23a758 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -51,6 +51,8 @@ NEEDS_UPGRADE = "app_needs_upgrade" STATUS_TEXT = {"queued": "排队中", "running": "运行中", "interrupted": "等待处理", "succeeded": "已完成", "failed": "失败", "cancelled": "已取消"} +#: 被驳回、又说不出是哪一步时(没有节点可指)的说法 +REJECTED_TEXT = "被驳回" TIER_TEXT = {"formal": "完整出具", "degraded": "降档出具", "withheld": "不予出具"} NO_TIER = "未声明出具契约" #: 蓝图试运行(docs/design/normal-user-apps.md N4):印章的来源、说明,趋势只有一个点时的说明,预览里入口按钮为什么点不了 @@ -1277,8 +1279,9 @@ async def _trails(session: AsyncSession, runs: list[Run]) -> dict[str, _Trail]: for row in (await session.execute(select(ActionRecord).where(ActionRecord.run_id.in_(ids)) .order_by(ActionRecord.created_at, ActionRecord.attempt))).scalars(): out[row.run_id].actions.append(row) - # 节点事件只有结束了的运行要看(走到了哪个成果节点、停在哪个节点上) - wanted = [r.id for r in runs if r.status in ("succeeded", "failed")] + # 节点事件只有结束了的运行要看(走到了哪个成果节点、停在哪个节点上)。取消的只有被驳回的要看(停在哪一步) + wanted = [r.id for r in runs if r.status in ("succeeded", "failed") + or (r.status == "cancelled" and r.end_reason == "rejected")] if wanted: for run_id, etype, node_id in (await session.execute( select(RunEvent.run_id, RunEvent.type, RunEvent.node_id).where( @@ -1325,6 +1328,26 @@ def rejected_outputs(graph: Any) -> dict[str, set[str]]: return out +def _rejection_text(trail: _Trail, node_id: str, title: str) -> str | None: + """停在被人驳回的待办上时怎么说:动作确认写「「付款」未确认(被驳回)」,别的写「「x」被驳回」;不是驳回的为 None。""" + kind, decision = _decision(trail, node_id) + if kind == "action_confirm" and decision == "reject": + return f"「{title}」未确认(被驳回)" + if decision in _REJECTIONS: + return f"「{title}」被驳回" + return None + + +async def legacy_rejected(session: AsyncSession, run: Run) -> bool: + """加 end_reason 之前被人驳回的运行记的是 failed(封存不改写):失败的那一步上最近一条处理完的待办是驳回。和实例页 + 认存量驳回(instance_state 的 failed 分支)是同一份判断;runner 据此不许它接着跑。""" + if run.status != "failed": + return False + trail = (await _trails(session, [run]))[run.id] + failed = [nid for etype, nid in trail.events if etype == "node.failed"] + return bool(failed) and _decision(trail, failed[-1])[1] in _REJECTIONS + + def instance_state(run: Run, trail: _Trail) -> dict[str, str]: """实例给人看的状态 {code, text}:code 是界面画状态标记用的状态(运行的那几种,加 held),text 说这件事办到哪了、 结论是什么,不只是运行状态(P1)。 @@ -1334,10 +1357,12 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: 否定的那种结局——这次运行里人工节点驳回了、从它的「驳回」出口一路走到的成果节点(rejected_outputs)——code 记 cancelled(同「未确认(被驳回)」,是人的决定,不画成成功),文字照旧写成果节点的标题; - 停着:有已发出、结果未知的动作,写「等待核实写入结果」;否则按在等的待办说(等待审批、等待确认发送……); - - 失败:停在一个被人驳回的待办上的——动作确认被驳回(「付款」未确认(被驳回))、审批设了驳回即终止、补充输入被 - 驳回——是人的决定,不是故障,code 记 cancelled;核实为未生效、选了放弃的同理。外部系统拒绝之类照写失败。 + - 被驳回(cancelled + end_reason=rejected,engine/context.NodeRejected):动作确认被驳回写「「付款」未确认(被驳回)」, + 审批设了驳回即终止、补充输入被驳回等写「「x」被驳回」,code 记 cancelled;手动停止的写「已取消」; + - 失败:加 end_reason 之前被人驳回的运行记的是 failed(封存不改写),停在被驳回的待办上的照上一条说,和新形状说法 + 一样;核实为未生效、选了放弃的同理,code 记 cancelled。外部系统拒绝之类照写失败。 - 运行本身的状态不改(驳回动作确认在引擎里就是节点失败、运行结束):这里只是把它说清楚。""" + 通知(notify/service.notify_run)的文字也取自这里:实例页和推送说同一句话。""" from app.apps.tree import node_map nodes = node_map(run.graph) @@ -1360,15 +1385,18 @@ def instance_state(run: Run, trail: _Trail) -> dict[str, str]: negative = any(_decision(trail, gate)[1] in _REJECTIONS for gate in gates) return {"code": "cancelled" if negative else "succeeded", "text": title(reached[0])} return {"code": "succeeded", "text": STATUS_TEXT["succeeded"]} + if run.status == "cancelled" and run.end_reason == "rejected": + failed = [nid for etype, nid in trail.events if etype == "node.failed"] + node_id = run.error_node_id or (failed[-1] if failed else None) + said = _rejection_text(trail, node_id, title(node_id)) if node_id else None + # 待办没留下(老的审批、没开出待办)时按驳回的那一步说,不退回「已取消」:这是人的决定,不是有人停了它 + return {"code": "cancelled", "text": said or (f"「{title(node_id)}」被驳回" if node_id else REJECTED_TEXT)} if run.status == "failed": failed = [nid for etype, nid in trail.events if etype == "node.failed"] if failed: node_id = failed[-1] - kind, decision = _decision(trail, node_id) - if kind == "action_confirm" and decision == "reject": - return {"code": "cancelled", "text": f"「{title(node_id)}」未确认(被驳回)"} - if decision in _REJECTIONS: - return {"code": "cancelled", "text": f"「{title(node_id)}」被驳回"} + if said := _rejection_text(trail, node_id, title(node_id)): + return {"code": "cancelled", "text": said} record = next((a for a in reversed(trail.actions) if a.node_id == node_id), None) if record is not None and record.state == "failed": if (record.resolution or {}).get("outcome") == "abandon": diff --git a/backend/app/blueprints/publish_plan.py b/backend/app/blueprints/publish_plan.py index 3bbcecf1..cdc1749b 100644 --- a/backend/app/blueprints/publish_plan.py +++ b/backend/app/blueprints/publish_plan.py @@ -70,6 +70,8 @@ NOT_INSTANTIATED = "plan.not_instantiated" GRAPH_CHANGED = "plan.graph_changed" UNTRIALED = "plan.untrialed" TRIAL_FAILED = "plan.trial_failed" +#: 最近一次试运行在某一步被人驳回、没有走完(cancelled + end_reason=rejected):人的决定,不是故障,不挡发布、照实说 +TRIAL_REJECTED = "plan.trial_rejected" TRIAL_DIFFERS = "plan.trial_differs" #: 蓝图检查:写回的结果不明时没人核实(blueprints/ops.blueprint_issues) RECONCILER_UNBOUND = "bp.reconciler_unbound" @@ -363,6 +365,12 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l if is_trial_of(trial, bp) and trial.status == "failed": why = (trial.error or "").strip().splitlines()[0][:200] if (trial.error or "").strip() else "" out.append(_problem(TRIAL_FAILED, f"最近一次试运行失败了{f':{why}' if why else ''}", source="plan")) + elif is_trial_of(trial, bp) and trial.status == "cancelled" and trial.end_reason == "rejected": + # 搭建者在试运行里驳回了写入、审批:那是他的决定,不是图出了错,不说成「试运行失败了」、也不挡发布;只照实说 + # 没走完——驳回之后的步骤没用真数看过 + where = _node_title(trial.graph, trial.error_node_id) + out.append(_problem(TRIAL_REJECTED, f"最近一次试运行{f'在「{where}」' if where else ''}被驳回、没有走完", + "warning", source="plan")) elif not is_trial_of(trial, bp) or trial.status != "succeeded": out.append(_problem(UNTRIALED, "还没有成功的试运行:发布之前没用真数看过结果", "warning", source="plan")) elif graph_hash(trial.graph or {}) != target.digest: @@ -370,6 +378,17 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l return [_with_who(p) for p in out] +def _node_title(graph: Any, node_id: str | None) -> str | None: + """节点在图上的名字(没有名字的给 id);没有节点时为 None。""" + if not node_id: + return None + for node in (graph or {}).get("nodes") or []: + if isinstance(node, dict) and node.get("id") == node_id: + data = node.get("data") if isinstance(node.get("data"), dict) else {} + return str(data.get("label") or node_id) + return node_id + + def _with_who(issue: dict[str, Any]) -> dict[str, Any]: """问题带上白话(没登记白话的照旧只有技术说法)和归谁处理(you / assistant / admin,摘要按它分三栏)。""" from app.plain import who_of, with_plain @@ -428,6 +447,7 @@ def _summary(bp: Blueprint, target: _Target, *, level: str, first_run: bool, opt if current and current > target.version else "" codes = {p.get("code") for p in problems} tried = ";发布的内容和最近一次试运行不同(试运行之后又改过),建议再试一次" if TRIAL_DIFFERS in codes else \ + ";最近一次试运行被驳回、没有走完" if TRIAL_REJECTED in codes else \ ";还没有用真数试运行过" if UNTRIALED in codes else "" if target.published_as == level: say("publish_workflow", f"「{name}」v{target.version} 已经按{LEVEL_TEXT[level]}级别发布过,不重复发布{tried}") diff --git a/backend/app/engine/approval.py b/backend/app/engine/approval.py index cb8beb21..5da191b8 100644 --- a/backend/app/engine/approval.py +++ b/backend/app/engine/approval.py @@ -39,6 +39,9 @@ class Decision: #: 审批卡上点的是「始终允许」:批准这次,并且这个工具以后改由门控把关(tools/trust.py)。 #: 只认明确的 True,而且只跟着批准走 always: bool = False + #: 这次驳回是待办超时、按「超时驳回」替人做的(process/tasks.py 的 tick),不是有人点了驳回。只跟着驳回走, + #: 节点据此在 NodeRejected 上记 decision=expired_reject + expired: bool = False def _word(value: Any) -> str: @@ -71,6 +74,7 @@ def read_decision(response: Any) -> Decision: code=code if isinstance(code, str) and code else None, value=response.get("value"), always=verdict is True and response.get("always") is True, + expired=verdict is False and response.get("expired") is True, ) verdict = _verdict(response) # 不是一个是/否词的字符串("拒绝,这个表是生产库")既算不上批准,本身又 diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index ad197844..9e4654a3 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -106,3 +106,32 @@ class NodeError(RuntimeError): super().__init__(message) self.node_id = node_id self.retryable = retryable + + +#: 驳回发生在哪种关卡上(NodeRejected.kind):人工节点的三种模式、写入前的动作确认、代码执行审批、工具调用审批、双盲裁决 +REJECTION_KINDS = ("approve", "input", "edit", "action_confirm", "code", "tool", "dual_check") + + +class NodeRejected(NodeError): + """人的决定让运行到这里结束:驳回、超时驳回(docs/design/next-round/05-已知遗留.md (a))。 + + 这不是故障。以前六处驳回都抛普通的 NodeError,运行记成 failed:通知推「实例失败」,定时推「运行失败」,发布单把 + 驳回过的试运行说成「试运行失败了」,记录页还给「继续运行」——接着跑只会读到同一个驳回答复、再停一次。现在 runner + 单独接住它,收成 cancelled + end_reason=rejected,run.cancelled 带上在哪一步、谁、结论和备注,消费方都读这一个字段。 + + 是 NodeError 的子类:节点失败的那一套(node.failed、on_error=continue)照旧认它;原话(str)照旧写给人看, + Run.error 和以前一字不差。kind 见 REJECTION_KINDS;decision 是 reject 或 expired_reject(同待办的结论)。""" + + def __init__(self, node_id: str, message: str, *, kind: str, decision: str = "reject", + actor: str | None = None, note: str = "") -> None: + super().__init__(node_id, message) + self.kind = kind + self.decision = decision + self.actor = actor + self.note = note + + @classmethod + def of(cls, ctx: Any, message: str, *, kind: str, decision: Any) -> NodeRejected: + """按节点上下文和读出来的审批结论(engine/approval.Decision)造一个:谁驳回的取 ctx.actor()。""" + return cls(ctx.node.id, message, kind=kind, decision="expired_reject" if decision.expired else "reject", + actor=ctx.actor(), note=decision.note or "") diff --git a/backend/app/engine/nodes/dual_check.py b/backend/app/engine/nodes/dual_check.py index 0152179c..d50cdcc0 100644 --- a/backend/app/engine/nodes/dual_check.py +++ b/backend/app/engine/nodes/dual_check.py @@ -70,7 +70,7 @@ from app.db.base import SessionLocal from app.db.models import DataSource from app.engine import deadline, recompute from app.engine.approval import read_decision -from app.engine.context import NodeContext, NodeError +from app.engine.context import NodeContext, NodeError, NodeRejected from app.engine.dual_check_facts import preview, preview_joint, record_choices, upload_dates from app.engine.evidence import build_catalog, is_number, next_exec from app.engine.expressions import ExpressionError, _cell_call, _chain_root, cell_value, column_kind, numeric_text, \ @@ -1396,7 +1396,7 @@ def _choices(ctx: NodeContext, response: Any, rows: list[dict[str, Any]]) -> tup 填写模式一样:这一步没有驳回的出口。""" decision = read_decision(response) if isinstance(response, dict) and decision.rejected: - raise NodeError(ctx.node.id, f"人工驳回:{decision.note or '未说明原因'}") + raise NodeRejected.of(ctx, f"人工驳回:{decision.note or '未说明原因'}", kind="dual_check", decision=decision) value = decision.value if isinstance(decision.value, dict) else response if isinstance(response, dict) else {} raw = value.get("choices") if isinstance(value.get("choices"), dict) else {} choices = {r["metric_id"]: raw[r["metric_id"]] for r in rows if raw.get(r["metric_id"]) in CHOICES} diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index 0c266bec..1e20e9b6 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -8,7 +8,7 @@ from typing import Any from app.core.events import EventType from app.db.base import SessionLocal from app.engine.approval import read_decision -from app.engine.context import NodeContext, NodeError +from app.engine.context import NodeContext, NodeError, NodeRejected from app.engine.replay import ask, once from app.engine.state import GraphState, merge_usage, message_text, template_context from app.providers import catalog @@ -52,7 +52,7 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "__decision__": "approved" if approved else "rejected", } if not approved and ctx.cfg("stop_on_reject", False): - raise NodeError(ctx.node.id, f"人工驳回:{result['note'] or '未说明原因'}") + raise NodeRejected.of(ctx, f"人工驳回:{result['note'] or '未说明原因'}", kind="approve", decision=decision) else: # edit / input # 驳回在这两种模式下以前是个摆设:__decision__ 硬编码成 approved, # approved 字段看都不看。界面上按钮在、toast 说"已驳回",运行却照常 @@ -65,7 +65,7 @@ async def run_human(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 只有结构体里明说了"不"才算驳回:这两种模式下裸字符串就是内容本身, # 用户在输入框里回一句"no"是在交稿,不是在驳回。 if isinstance(response, dict) and decision.rejected: - raise NodeError(ctx.node.id, f"人工驳回:{decision.note or '未说明原因'}") + raise NodeRejected.of(ctx, f"人工驳回:{decision.note or '未说明原因'}", kind=mode, decision=decision) note = decision.note if isinstance(response, dict) else "" # 裸字符串是内容,不是备注 result = {"value": decision.value, "note": note, "__decision__": "approved"} diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 1b26352d..e3591d35 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -11,7 +11,7 @@ from langgraph.func import task from app.core.artifact_store import canonical_json, content_hash from app.engine.approval import read_decision -from app.engine.context import NodeContext, NodeError +from app.engine.context import NodeContext, NodeError, NodeRejected from app.engine.evidence import ledger_enabled, next_exec, query_entry_fields, schema_ledger_entry from app.engine.replay import ask, once from app.engine.state import GraphState @@ -127,8 +127,8 @@ async def run_tool(state: GraphState, ctx: NodeContext) -> dict[str, Any]: await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note, actor=ctx.actor(), **({"always": True} if always else {})) if not decision.approved: - raise NodeError(ctx.node.id, f"工具 {name} 的调用已被驳回" - + (f":{decision.note}" if decision.note else "")) + raise NodeRejected.of(ctx, f"工具 {name} 的调用已被驳回" + (f":{decision.note}" if decision.note else ""), + kind="tool", decision=decision) limit = limit_of(tool, name, args) ctx.emit(EventType.TOOL_START, tool=name, args=args, **limit_fields(limit)) @@ -299,10 +299,11 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ await once(ctx, EventType.HUMAN_RESOLVED, tool=name, approved=decision.approved, note=decision.note, actor=ctx.actor(), payload_hash=confirmed) if not decision.approved: - # 驳回是人的决定,不是故障:运行到这里结束(状态记失败,没有可走的出口),说法写成「未确认(被驳回)」, - # 实例页按待办的结论另说(apps/resolve.instance_states) - raise NodeError(ctx.node.id, f"「{ctx.node.title}」未确认(被驳回),没有发送" - + (f":{decision.note}" if decision.note else "")) + # 驳回是人的决定,不是故障:运行到这里结束(没有可走的出口),收成 cancelled + end_reason=rejected + # (NodeRejected),说法写成「未确认(被驳回)」,实例页、通知按同一个结论说(apps/resolve.instance_state) + raise NodeRejected.of(ctx, f"「{ctx.node.title}」未确认(被驳回),没有发送" + + (f":{decision.note}" if decision.note else ""), + kind="action_confirm", decision=decision) if confirmed != prep.payload_hash: # 动作确认不接受改参数:要改在上游的表单里改。确认之后上游的数据变了,看过的就不是要发的,拒发 raise NodeError(ctx.node.id, f"「{name}」确认时看到的内容和现在要发送的不一致(内容指纹对不上)," @@ -380,8 +381,8 @@ async def run_code(state: GraphState, ctx: NodeContext) -> dict[str, Any]: await once(ctx, EventType.HUMAN_RESOLVED, approved=decision.approved, note=decision.note, actor=ctx.actor()) if not decision.approved: - raise NodeError(ctx.node.id, "代码执行已被驳回" - + (f":{decision.note}" if decision.note else "")) + raise NodeRejected.of(ctx, "代码执行已被驳回" + (f":{decision.note}" if decision.note else ""), + kind="code", decision=decision) # 隔离档位:strict 要硬件级(microVM),fast 要低延迟(Seatbelt/bwrap), # 留空跟随整机默认。要的那档不可用时会安静退回默认,不阻断运行。 diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index ef22bd1e..7fcd0a62 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -23,7 +23,7 @@ from app.db.base import SessionLocal from app.db.models import Approval, Run, RunEvent, Workflow from app.engine.approval import read_decision from app.engine.compiler import compile_graph, initial_state -from app.engine.context import NodeError, RunContext +from app.engine.context import NodeError, NodeRejected, RunContext from app.engine.deadline import segment from app.engine.replay import PROTOCOL, PROTOCOL_KEY, protocol_of from app.engine.schema import GraphSpec, NodeType, loop_steps, topology_of, validate_graph @@ -42,9 +42,18 @@ _EPHEMERAL = {EventType.LLM_TOKEN, EventType.LLM_THINKING_DELTA} RUN_OPTIONS_KEY = "run_options" -#: 接着跑被拒的机器码:正式运行带图(F2),有结果未知、还没核实的写入 +#: 接着跑被拒的机器码:正式运行带图(F2),有结果未知、还没核实的写入,被人驳回了的 FORMAL_GRAPH_LOCKED = "formal_graph_locked" ACTION_UNRESOLVED = "action_unresolved" +RUN_REJECTED = "run_rejected" + +#: 运行为什么结束在 cancelled(Run.end_reason,docs/design/next-round/00-总方案.md X9) +END_REJECTED = "rejected" +END_STOPPED = "stopped" +#: 被驳回的运行不能接着跑:从断点重跑会读到同一个驳回答复、再停在同一处 +REJECTED_CONTINUE = "被驳回的运行不能接着跑:改好内容后重新发起" +#: 服务重启时还在排队的运行改成失败时写的话(run.error 和补发的 run.failed 是同一句) +QUEUED_AT_RESTART = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行" class ContinueRefused(ValueError): @@ -94,6 +103,9 @@ class RunManager: stranded = list((await session.execute( select(Run.id, Run.last_seq).where(Run.status == "running") )).all()) + queued = list((await session.execute( + select(Run.id, Run.last_seq).where(Run.status == "queued") + )).all()) await session.execute( update(Run) .where(Run.status == "running") @@ -102,7 +114,7 @@ class RunManager: await session.execute( update(Run) .where(Run.status == "queued") - .values(status="failed", error="服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行") + .values(status="failed", error=QUEUED_AT_RESTART) ) await session.commit() # 进程被强杀时连 server_shutdown 日志都来不及发,事件流停在半路。补上这一条, @@ -113,6 +125,11 @@ class RunManager: "level": "warn", "code": "server_shutdown", "message": "服务上次未正常关闭,这次运行已中断;可从断点继续运行", }) + # 排队中的改成了失败,也补一条 run.failed:以前只改状态,时间线里没有失败原因这一行(05-已知遗留 (h))。 + # 不写结束时间、不封存:它从没开始执行,同以前(定时通知按「没有开始时间」认出它,scheduler/webhook.py) + for run_id, last_seq in queued: + bus.set_seq(run_id, last_seq or 0) + await self._emit(run_id, EventType.RUN_FAILED, data={"error": QUEUED_AT_RESTART}) async def shutdown(self) -> None: self._closing = True @@ -505,8 +522,13 @@ class RunManager: Approval.run_id == run_id, Approval.status.in_(["pending", "answered"]) ).limit(1) )).first() is not None + if run.status == "cancelled" and run.end_reason == END_REJECTED: + raise ContinueRefused(REJECTED_CONTINUE, code=RUN_REJECTED) if not (run.status == "failed" or (run.status == "interrupted" and not has_pending)): raise ValueError(_cannot_continue(run.status, has_pending)) + if run.status == "failed" and await _rejected_before(session, run): + # 加 end_reason 之前被驳回的运行记的是 failed(封存不改写):接着跑会读到同一个驳回答复、再停一次 + raise ContinueRefused(REJECTED_CONTINUE, code=RUN_REJECTED) if graph is not None and run.run_class == "formal": raise ContinueRefused( "正式运行固定在发布的版本上,不能修改配置后继续运行。如需修改,请在画布上修改并发布新版本后重新发起", @@ -613,7 +635,7 @@ class RunManager: # 或者另一个取消先到了,这里就什么都不做 claimed = await session.execute( update(Run).where(Run.id == run_id, Run.status == "interrupted") - .values(status="cancelled", finished_at=now) + .values(status="cancelled", end_reason=END_STOPPED, finished_at=now) ) if claimed.rowcount != 1: await session.rollback() @@ -631,7 +653,7 @@ class RunManager: bus.set_seq(run_id, await _seq_floor(session, run_id, run.last_seq)) # 和状态同一个事务提交,理由同 _finalize event = self._event(run_id, EventType.RUN_CANCELLED, data={ - "timing": timing, "actor": actor, + "reason": END_STOPPED, "timing": timing, "actor": actor, "message": f"放弃了这次运行,{closed} 条待审批一并关闭" if closed else "放弃了这次挂起的运行", }) @@ -684,6 +706,8 @@ class RunManager: #: 终态事件(run.failed / run.cancelled)等 _finalize 算完耗时再发,timing 才带得上 terminal: tuple[EventType, dict[str, Any]] | None = None error_node: str | None = None + #: 收成 cancelled 的原因(Run.end_reason):rejected 人的驳回,stopped 手动停止 + end_reason: str | None = None #: 结果未知、挂起等核实的动作(ActionPending),收尾之后发 action.unknown、安排核实 pending_action: str | None = None @@ -796,7 +820,9 @@ class RunManager: else: status = "cancelled" error = "用户取消" - terminal = (EventType.RUN_CANCELLED, {"actor": self._stopped_by.pop(run_id, None)}) + end_reason = END_STOPPED + terminal = (EventType.RUN_CANCELLED, {"reason": END_STOPPED, + "actor": self._stopped_by.pop(run_id, None)}) raise except ActionPending as e: # 写外部系统的结果未知(app/process/actions.py):不在节点里等,运行收成挂起、不封存,等核实之后 @@ -829,6 +855,12 @@ class RunManager: "(AGENTLAB_MAX_GRAPH_STEPS)" ) terminal = (EventType.RUN_FAILED, {"error": error}) + except NodeRejected as e: + # 人的决定(驳回、超时驳回),不是故障:收成 cancelled,同手动停止走一样的收尾(关掉并行支路上的审批、 + # 回收沙箱、关流、封存、不许继续运行)。原话照旧记在 error 上,在哪一步、谁、结论、备注进 run.cancelled + status, end_reason = "cancelled", END_REJECTED + error, error_node = str(e), e.node_id + terminal = (EventType.RUN_CANCELLED, rejected_data(e, spec)) except Exception as e: # noqa: BLE001 status = "failed" error, terminal = _failure(e, spec) @@ -840,7 +872,7 @@ class RunManager: self._finalizing.add(run_id) try: await self._finalize(run_id, status, error, final_state, elapsed, - terminal=terminal, error_node=error_node) + terminal=terminal, error_node=error_node, end_reason=end_reason) finally: # 收尾被打断(关停时的取消、库连接出错)也得把自己摘掉。以前写在 # _finalize 后面,一被打断就漏掉,留下的任务让下一次 shutdown 的 @@ -944,6 +976,7 @@ class RunManager: *, terminal: tuple[EventType, dict[str, Any]] | None = None, error_node: str | None = None, + end_reason: str | None = None, ) -> None: usage = dict(state.get("usage") or {}) output = dict(state.get("output") or {}) @@ -1000,6 +1033,7 @@ class RunManager: run.output = output run.usage = usage run.error_node_id = error_node + run.end_reason = end_reason if status != "interrupted": run.finished_at = now if status in ("failed", "cancelled"): @@ -1024,11 +1058,12 @@ class RunManager: # 关停时挂起的也要让在线的客户端收尾:进程马上就没了,不说一声, # 界面会一直以为它还在跑 await bus.close(run_id) - if app_id and status in ("succeeded", "failed"): - # 应用实例结束、失败:按应用订阅推送(app/notify/service.py)。放到后台,接收端慢不拖着收尾 + if app_id and (status in ("succeeded", "failed") or end_reason == END_REJECTED): + # 应用实例结束、失败:按应用订阅推送(app/notify/service.py)。放到后台,接收端慢不拖着收尾。被驳回的走 + # 「实例结束」(next-round X6):发起人要知道结果,订阅「实例失败」的人盯的是故障;手动停止的不推,同以前 from app.notify.service import notify_run, spawn - spawn(notify_run(run_id, "run.finished" if status == "succeeded" else "run.failed")) + spawn(notify_run(run_id, "run.failed" if status == "failed" else "run.finished")) async def _seal(self, run_id: str) -> None: """对到此为止的全部事件算清单哈希,记下封到了哪一条。""" @@ -1269,6 +1304,22 @@ def _trust_event(tool_trust: dict[str, str] | None) -> Any: return "formal" if tool_trust.get(FORMAL_MARK) else snapshot_levels(tool_trust) +def rejected_data(exc: NodeRejected, spec: GraphSpec) -> dict[str, Any]: + """被驳回的 run.cancelled 带什么:原因(rejected)、在哪一步(节点 id 和图上的名字)、哪种关卡、结论(驳回 / 超时 + 驳回)、谁、备注。进封存:谁在哪一步驳回、为什么,事后查得到。""" + node = spec.node_map().get(exc.node_id) + return {"reason": END_REJECTED, "node_id": exc.node_id, "label": node.title if node else exc.node_id, + "kind": exc.kind, "decision": exc.decision, "actor": exc.actor, "note": exc.note} + + +async def _rejected_before(session: Any, run: Run) -> bool: + """加 end_reason 之前被人驳回、记成 failed 的运行:失败的那一步上最近一条处理完的待办是驳回(和实例页认驳回的是 + 同一份判断,apps/resolve.legacy_rejected)。""" + from app.apps.resolve import legacy_rejected + + return await legacy_rejected(session, run) + + def _failure(exc: BaseException, spec: GraphSpec) -> tuple[str, tuple[EventType, dict[str, Any]]]: """失败的首行报错(给人看)和 run.failed 的负载(带上定位和原始异常)。""" if isinstance(exc, NodeError): diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index a836574a..33b8ff78 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -201,22 +201,36 @@ async def notify_task(task_id: str, event: str, *, users: list[str]) -> int: async def notify_run(run_id: str, event: str) -> int: - """应用实例结束、失败(run.finished / run.failed)。只推应用发起的运行。""" + """应用实例结束、失败(run.finished / run.failed)。只推应用发起的运行。 + + 状态那一句取自实例页的说法(apps/resolve.instance_state),两边说同一句话:被驳回的(cancelled + end_reason=rejected) + 走「实例结束」、写「「付款」未确认(被驳回)」;走到「已退回」成果的写「已退回」,不再一律写「已完成」。ok 只在真的 + 办成了(code 是 succeeded)时为真。 + + 发起人是认证过的身份(run.origin.actor_verified)时,他自己订阅了这件事的个人渠道也收得到(「我发起的实例完成 / 失败」, + 04-对外集成与触达 B1)。以前这里给 _channels 的 users 是空的,个人渠道勾了这两项永远收不到。自报的署名不算:关登录 + 时谁都能填别人的名字。""" + from app.apps.resolve import _trails, instance_state + async with SessionLocal() as session: run = await session.get(Run, run_id) if run is None or not run.app_id: return 0 - channels = await _channels(session, event, run, []) + verified = bool((run.origin or {}).get("actor_verified")) and bool(run.started_by) + channels = await _channels(session, event, run, [run.started_by] if verified else []) if not channels: return 0 base = await _base_url(session) where = await _where(session, run) + state = instance_state(run, (await _trails(session, [run]))[run.id]) finished = event == "run.finished" - lines = [("状态", "已完成" if finished else "失败"), ("实例", f"#{run_id[:6]}")] - if not finished and run.error_node_id: + ok = state["code"] == "succeeded" + lines = [("状态", state["text"]), ("实例", f"#{run_id[:6]}")] + if not finished and run.error_node_id and state["code"] == "failed": lines.append(("失败节点", f"「{_label(run.graph, run.error_node_id)}」")) - message = {"event": event, "status": run.status, "ok": finished, - "title": f"{where or '应用'}的实例{'已完成' if finished else '失败'}", "lines": lines, + head = ("已完成" if ok else "已结束") if finished else ("已结束" if state["code"] == "cancelled" else "失败") + message = {"event": event, "status": run.status, "ok": ok, + "title": f"{where or '应用'}的实例{head}", "lines": lines, "links": [("查看实例", f"{base}/apps/{run.app_id}/i/{run_id}")] if base else [], "subject": {"run_id": run_id, "app_id": run.app_id}} return await _push(channels, message, subject_kind="run", subject_id=run_id, event=event, run_id=run_id, diff --git a/backend/app/plain.py b/backend/app/plain.py index 617afe92..6c38d3a4 100644 --- a/backend/app/plain.py +++ b/backend/app/plain.py @@ -181,6 +181,9 @@ PLAIN: dict[str, Plain] = { "plan.trial_failed": Plain( "最近一次试运行失败了", "用真数运行出了错,这样发布出去,每次出数都会是同一个错。", _Y, "回到试运行看出错的原因(可以交给助手修),修好后再用真数试一次。"), + "plan.trial_rejected": Plain( + "最近一次试运行被驳回、没有走完", "那是你(或审批人)的决定,不是出错;驳回之后的步骤没用真数看过。", _Y, + "要看完整结果,再试运行一次并批准;确认不需要的话可以直接发布。"), "plan.untrialed": Plain( "还没用真数试运行过", "没看过真数就发布,可能不是你要的结果。", _Y, "先试运行一次,看过结果再发布。"), "plan.trial_differs": Plain( diff --git a/backend/app/process/actions.py b/backend/app/process/actions.py index e75a269e..87ab7fca 100644 --- a/backend/app/process/actions.py +++ b/backend/app/process/actions.py @@ -572,17 +572,18 @@ async def _continue(run_id: str, actor: str | None) -> Any: return None -async def tick(now: datetime | None = None) -> int: - """调度循环每一拍:对登记了 lookup 的结果未知动作带退避地查询。返回查了几条。""" +async def tick(now: datetime | None = None) -> list[str]: + """调度循环每一拍:对登记了 lookup 的结果未知动作带退避地查询。交回这一拍查过的动作记录 id(同 tasks.tick)。""" now = now or utcnow() async with SessionLocal() as session: due = list((await session.execute(select(ActionRecord.id, ActionRecord.next_lookup_at).where( ActionRecord.state == "unknown", ActionRecord.next_lookup_at.is_not(None), ActionRecord.next_lookup_at <= now))).all()) - done = 0 + done: list[str] = [] for action_id, claimed_at in due: try: - done += await _lookup(action_id, claimed_at, now) + if await _lookup(action_id, claimed_at, now): + done.append(action_id) except Exception: # noqa: BLE001 - 一条出错不拖住这一拍的其他动作 logger.exception("动作 %s 的结果查询失败,下一拍再试", action_id) return done diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 9f1dd240..6ec167af 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -761,8 +761,9 @@ async def _notify(task_id: str, event: str, users: list[str]) -> int: # -------------------------------------------------------------------------- -async def tick(now: datetime | None = None) -> int: - """调度循环每一拍:提醒、升级、超时驳回(永不自动批准)。返回处理的条数。试运行的待办不处理(只显示时限)。""" +async def tick(now: datetime | None = None) -> list[str]: + """调度循环每一拍:提醒、升级、超时驳回(永不自动批准)。交回这一拍处理过的待办 id(生效可查:办了哪几条, + 不只是几条;测试按自己造的那条断言,不受同一个库里别的待办影响)。试运行的待办不处理(只显示时限)。""" now = now or utcnow() action = func.json_extract(Task.on_timeout, "$.action") trial = select(Run.id).where(Run.origin_kind == TRIAL_ORIGIN) @@ -773,10 +774,11 @@ async def tick(now: datetime | None = None) -> int: and_(Task.due_at.is_not(None), Task.due_at <= now, action == "reject"), and_(Task.due_at.is_not(None), Task.due_at <= now, action == "escalate", Task.escalation_level == 0), )).order_by(Task.due_at))).scalars()) - done = 0 + done: list[str] = [] for task_id in due: try: - done += await _on_timeout(task_id, now) + if await _on_timeout(task_id, now): + done.append(task_id) except Exception: # noqa: BLE001 - 一条出错不拖住这一拍的其他待办 logger.exception("待办 %s 的超时处理失败,下一拍再试", task_id) return done @@ -838,7 +840,9 @@ async def _on_timeout(task_id: str, now: datetime) -> int: from app.engine.runner import run_manager try: - await run_manager.resume(run_id, {"approved": False, "note": EXPIRED_NOTE}, approval_id=approval_id) + # expired:节点据此把这次驳回记成超时驳回(engine/approval.Decision.expired → NodeRejected.decision) + await run_manager.resume(run_id, {"approved": False, "note": EXPIRED_NOTE, "expired": True}, + approval_id=approval_id) except (ValueError, KeyError): logger.warning("待办 %s 超时驳回时运行已无法恢复", task_id) async with SessionLocal() as session: diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index 243f5f97..ee6373c2 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -130,7 +130,9 @@ async def process_due(now: datetime | None = None) -> None: for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick), ("没有提交的上传文件清理", files_store.cleanup_tick)): try: - await step(now) + done = await step(now) + if isinstance(done, list) and done: + logger.info("%s:这一拍处理了 %d 条", name, len(done)) except Exception: # noqa: BLE001 logger.exception("%s这一拍出错,下一拍照常进行", name) diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index 4a19e1c6..2062db3c 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -5,7 +5,8 @@ 再导出,调用方和测试不用改;_transport、RETRY_DELAYS 仍是这个模块自己的(测试照旧替换它们),send 每次调用时读。 通知里只有任务名、状态、出具档位、缺口条数、运行链接、封存哈希(界面上叫清单哈希)前 12 位和起止时间,失败时 -另写失败节点的名字和下一步(不许诺能继续运行);运行成功结束后和同一任务上一次成功的运行的对比(auto_compare)只写 +另写失败节点的名字和下一步(不许诺能继续运行);被人驳回的那一期按「运行结束」推「未出具(被驳回)」、写驳回的那一步, +手动停止的不推;运行成功结束后和同一任务上一次成功的运行的对比(auto_compare)只写 各类指标的个数和对比页链接。不放报告里的具体数字,也不放失败时的原始报错——报错里可能带着查询到的数据,Webhook 推去的群不一定有权看。 """ @@ -71,6 +72,11 @@ NEXT_AS_USUAL = "下一次触发会照常发起" #: 从没开始执行、任务又不在排期上(停用了):原因刚写过,记录页上也没有更多可看的,下一步是重新发起。「立即运行」 #: 是定时任务页上那个按钮的名字,停用的任务也能点 RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点击「立即运行」" +#: 被人驳回的那一期(cancelled + end_reason=rejected,next-round X9):是人的决定,不是故障。按「运行结束」推(订阅 +#: 「运行失败」的人盯的是故障),标题、状态照实写被驳回;驳回的备注不写(可能带着数),驳回的是哪一步写节点名 +REJECTED_HEADLINE = "未出具(被驳回)" +REJECTED_STATUS = "被驳回" +REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看" # -------------------------------------------------------------------------- @@ -110,12 +116,18 @@ class Notice: #: 挂起的原因是写外部系统的结果未知(等核实,核实后自动接着跑),不是服务重启。只在 suspended 时有意义 action_pending: bool = False error_node: str | None = None + #: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步 + end_reason: str | None = None next_fire: bool = False compare: dict[str, Any] | None = None compare_url: str | None = None fmt: str | None = None signed: bool = False + @property + def rejected(self) -> bool: + return self.status == "cancelled" and self.end_reason == "rejected" + @property def never_started(self) -> bool: """失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。""" @@ -142,6 +154,8 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]: headline = "未能发起运行" elif notice.suspended: headline = "运行已挂起" + elif notice.rejected: + headline = REJECTED_HEADLINE else: headline = _HEADLINE.get(notice.event, notice.event) title = f"定时任务「{notice.schedule_name}」{headline}" @@ -163,6 +177,10 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]: lines += [("原因", ACTION_PENDING_REASON), ("处理", ACTION_PENDING_ACTION)] elif notice.suspended: lines += [("原因", SUSPENDED_REASON), ("处理", SUSPENDED_ACTION)] + elif notice.rejected: + if notice.error_node: + lines.append(("驳回节点", f"「{notice.error_node}」")) + lines.append(("处理", f"{REJECTED_ACTION};{NEXT_AS_USUAL}" if notice.next_fire else REJECTED_ACTION)) elif notice.status == "failed": # 不写原始报错(可能带着查询到的数据),写失败节点的名字和下一步 if notice.error_node: @@ -219,6 +237,8 @@ def _status_label(notice: Notice) -> str: if notice.pending_approvals: return "等待审批" return "已挂起,等待核实" if notice.action_pending else "已挂起,可继续运行" + if notice.rejected: + return REJECTED_STATUS return _STATUS_LABEL.get(notice.status or "", notice.status or "") @@ -239,7 +259,9 @@ def _links(notice: Notice) -> list[tuple[str, str]]: def _message(notice: Notice) -> core.Message: title, lines, _ = message(notice) - return core.Message(title=title, lines=lines, links=_links(notice), ok=notice.event in ("finished", "test")) + # 被驳回的那一期按 finished 推,但没出具:不画成成功的颜色 + return core.Message(title=title, lines=lines, links=_links(notice), + ok=notice.event in ("finished", "test") and not notice.rejected) def _text(notice: Notice) -> str: @@ -332,10 +354,17 @@ async def _record(schedule_id: str, fire_id: str | None, run_id: str | None, eve #: 运行到了这些状态才可能要推送 _WATCHED = ("succeeded", "failed", "cancelled", "interrupted") -#: 结束的运行 → 事件。cancelled 是有人手动停的,不推。interrupted 分停在审批和挂起两种,见 _state_of +#: 结束的运行 → 事件。cancelled 是有人手动停的,不推;被驳回的(end_reason=rejected)按 finished 推,见 _event_of。 +#: interrupted 分停在审批和挂起两种,见 _state_of _EVENT_OF = {"succeeded": "finished", "failed": "failed", "cancelled": None} +def _event_of(run: Run) -> str | None: + if run.status == "cancelled" and run.end_reason == "rejected": + return "finished" + return _EVENT_OF[run.status] + + @dataclass class _Job: fmt: str @@ -469,7 +498,7 @@ async def _state_of(session: Any, fire: ScheduleFire, run: Run | None, now: date since = run.finished_at or run.updated_at or fire.created_at if not sealed and (now - service.as_utc(since)).total_seconds() < SEAL_WAIT_S: return _Seen(None) - return _Seen(run.status, _EVENT_OF[run.status], sealed=sealed) + return _Seen(run.status, _event_of(run), sealed=sealed) if run.status != "interrupted": return _Seen(None) counts = dict((await session.execute( @@ -516,7 +545,9 @@ async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run pending_approvals=pending or None, suspended=run is not None and run.status == "interrupted" and not pending, action_pending=seen.action_pending, - error_node=_failed_node(run) if run is not None and run.status == "failed" else None, + error_node=_failed_node(run) if run is not None and (run.status == "failed" or run.end_reason == "rejected") + else None, + end_reason=run.end_reason if run is not None else None, next_fire=bool(schedule.enabled and schedule.next_run_at is not None), ) diff --git a/backend/tests/test_app_instance_page.py b/backend/tests/test_app_instance_page.py index 764273a0..ce502afb 100644 --- a/backend/tests/test_app_instance_page.py +++ b/backend/tests/test_app_instance_page.py @@ -258,7 +258,7 @@ async def test_after_escalation_the_rule_is_one_director_not_zero_of_three(): run_id = await _start(app_id, "报销审批·钱七") task = await _task(run_id) await tasks.respond(task.id, user(a), decision="approve") - assert await tasks.tick(utcnow() + timedelta(hours=2)) >= 1 + assert task.id in await tasks.tick(utcnow() + timedelta(hours=2)) detail = await tasks.detail(task.id, user(boss)) p = detail["progress"] # 升级加派的那一份不算进会签人数:还是 1/2,另写升级给了谁、他一人说了算 diff --git a/backend/tests/test_approval.py b/backend/tests/test_approval.py index f3d005a6..0f45888c 100644 --- a/backend/tests/test_approval.py +++ b/backend/tests/test_approval.py @@ -138,9 +138,10 @@ async def test_tool_node_takes_a_refusal_as_a_refusal(): run_id = await _start(_calc_graph()) await _wait(run_id, ("interrupted",)) await run_manager.resume(run_id, "拒绝") - run = await _wait(run_id, ("failed", "succeeded")) + run = await _wait(run_id, ("failed", "succeeded", "cancelled")) - assert run.status == "failed" and "驳回" in (run.error or "") + # 驳回是人的决定,运行收成 cancelled + end_reason=rejected(05-已知遗留 (a)) + assert (run.status, run.end_reason) == ("cancelled", "rejected") and "驳回" in (run.error or "") assert not [e for e in await _events(run_id) if e.type == "tool.start"], "被拒绝的工具执行了" @@ -208,8 +209,8 @@ async def test_a_refused_write_leaves_the_data_alone(writable): run_id = await _start(_query_graph(tool, "WITH k AS (SELECT 1) DELETE FROM t")) await _wait(run_id, ("interrupted",)) await run_manager.resume(run_id, "拒绝") - run = await _wait(run_id, ("failed", "succeeded")) - assert run.status == "failed" + run = await _wait(run_id, ("failed", "succeeded", "cancelled")) + assert (run.status, run.end_reason) == ("cancelled", "rejected") assert count() == 5 @@ -392,5 +393,5 @@ async def test_parallel_rejection_with_content_is_still_a_rejection(): approval_id=pending["h1"].id) await run_manager.resume(run_id, {"approved": False, "note": "不行", "value": "乙"}, approval_id=pending["h2"].id) - run = await _wait(run_id, ("failed", "succeeded")) - assert run.status == "failed" and "驳回" in (run.error or ""), run.error + run = await _wait(run_id, ("failed", "succeeded", "cancelled")) + assert (run.status, run.end_reason) == ("cancelled", "rejected") and "驳回" in (run.error or ""), run.error diff --git a/backend/tests/test_blueprint_trial.py b/backend/tests/test_blueprint_trial.py index d5ad468b..957a8549 100644 --- a/backend/tests/test_blueprint_trial.py +++ b/backend/tests/test_blueprint_trial.py @@ -157,7 +157,7 @@ async def test_process_trial_assigns_every_step_to_the_trial_runner_and_never_es assert task.due_at is not None and task.on_timeout["action"] == "escalate" # 时限到了:调度不升级试运行的待办(同一个库里别的测试的待办照常处理,只看这一条) - await tasks.tick(utcnow() + timedelta(hours=2)) + assert task.id not in await tasks.tick(utcnow() + timedelta(hours=2)) async with SessionLocal() as session: task = await session.get(Task, task.id) count = len(list((await session.execute(select(TaskAssignment).where( diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 436cd9bc..3ca9af15 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -519,15 +519,19 @@ async def test_a_missing_choice_is_recorded_as_no_choice(monkeypatch, shop, clie assert not logs(evs, "dual_check_catalog") and await notes_of(source_id) is None -async def test_rejecting_the_approval_fails_the_node(monkeypatch, shop, client): +async def test_rejecting_the_approval_ends_the_run_as_rejected(monkeypatch, shop, client): + """明确驳回裁决:人的决定,不是故障(05-已知遗留 (a))。运行收成 cancelled + end_reason=rejected,原话照旧。""" name, _ = shop Blind(monkeypatch, name, {"销售额": (RIGHT, "gmv")}) run = await start(graph(name, BY_ORDER_DATE)) approval = await pending(run.id) res = await client.post(f"/api/approvals/{approval.id}/decide", json={"approved": False, "note": "口径要重新对"}) assert res.status_code == 200, res.text - done = await wait(run.id, ("succeeded", "failed")) - assert done.status == "failed" and "人工驳回" in (done.error or "") and "口径要重新对" in done.error + done = await wait(run.id, ("succeeded", "failed", "cancelled")) + assert (done.status, done.end_reason) == ("cancelled", "rejected"), done.error + assert "人工驳回" in (done.error or "") and "口径要重新对" in done.error + [ended] = [e for e in await events(run.id) if e.type == "run.cancelled"] + assert (ended.data["kind"], ended.data["note"]) == ("dual_check", "口径要重新对") async def test_on_reject_fail_stops_the_run(monkeypatch, shop, client): diff --git a/backend/tests/test_notify_run.py b/backend/tests/test_notify_run.py new file mode 100644 index 00000000..a57e32ff --- /dev/null +++ b/backend/tests/test_notify_run.py @@ -0,0 +1,199 @@ +"""应用实例结束的通知(notify/service.notify_run):说法取自实例页,个人渠道按「我发起的实例」推。 + +- 被驳回的实例(cancelled + end_reason=rejected)走「实例结束」,状态写实例页那一句(「「review」被驳回」),不推 + 「实例失败」(05-已知遗留 (a)、总方案 X6); +- 走到「已退回」那种结局的运行,实例页写成果节点的标题、记成人的决定,推送以前却写「已完成」:现在两边一致; +- 个人渠道勾了「实例完成 / 失败」以前永远收不到(_channels 拿到的 users 是空的):发起人是认证过的身份时,他自己的 + 个人渠道收得到;自报的署名不算(04-对外集成与触达 B1)。 +""" +from __future__ import annotations + +import asyncio +import json +import uuid + +import httpx +import pytest +from sqlalchemy import select + +from app.core.crypto import encrypt +from app.db.base import SessionLocal +from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, User +from app.engine.runner import run_manager +from app.notify import core, service + +URL = "https://hooks.example.com/agentlab/run-3b8e1f" + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await service.drain() + await run_manager.shutdown() + + +@pytest.fixture +def received(monkeypatch): + got: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + got.append(request) + return httpx.Response(200, json={"ok": True}) + + monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler)) + monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0)) + # 个人渠道发送前要解析域名、核对不是内网:测试里不连外网 + async def public(url: str) -> None: + return None + + monkeypatch.setattr(service, "personal_url_problem", public) + return got + + +def node(nid, ntype, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def flow(**human): + nodes = [node("start", "input", fields=[{"name": "q"}]), + node("review", "human", mode="approve", title="审一下", **human), + node("ok", "output", fields=[{"name": "结论", "value": "通过"}]), + node("no", "output", fields=[{"name": "结论", "value": "退回"}])] + edges = [{"source": "start", "target": "review"}, + {"source": "review", "target": "ok", "sourceHandle": "approved"}, + {"source": "review", "target": "no", "sourceHandle": "rejected"}] + return {"nodes": nodes, "edges": edges} + + +async def _app(events: list[str]) -> tuple[str, str]: + async with SessionLocal() as session: + channel = NotifyChannel(name=f"群-{uuid.uuid4().hex[:6]}", format="generic", url=encrypt(URL), events=[]) + session.add(channel) + app = App(name=f"报销-{uuid.uuid4().hex[:6]}", published_version=1) + session.add(app) + await session.flush() + session.add(AppVersion(app_id=app.id, version=1, spec_hash="h" * 64, bindings={}, + spec={"notify": [{"channel": channel.id, "events": events}]})) + await session.commit() + return app.id, channel.id + + +async def _wait(run_id: str, statuses: tuple[str, ...]) -> Run: + for _ in range(400): + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row is not None and row.status in statuses: + return row + await asyncio.sleep(0.05) + raise AssertionError(f"运行 {run_id} 没有进入 {statuses}") + + +async def _decide(run_id: str, approved: bool) -> Run: + """经待办服务回复(同界面上点同意 / 驳回):实例页认结局靠待办的结论。""" + from app.api.deps import Principal + from app.db.models import Task + from app.process import tasks + + await _wait(run_id, ("interrupted",)) + await run_manager.wait_idle(run_id) + for _ in range(100): + async with SessionLocal() as session: + task = (await session.execute(select(Task).where(Task.run_id == run_id, Task.status == "pending")))\ + .scalars().first() + if task is not None: + break + await asyncio.sleep(0.05) + await tasks.respond(task.id, Principal(username=None, role="admin", mode="off"), + decision="approve" if approved else "reject", note="金额 45678 不对") + final = await _wait(run_id, ("succeeded", "failed", "cancelled")) + await run_manager.wait_idle(run_id) + await service.drain() + return final + + +async def _deliveries(channel_id: str) -> list[tuple[str, str]]: + async with SessionLocal() as session: + rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.channel_id == channel_id))).scalars() + return [(r.event, r.status) for r in rows] + + +async def test_a_rejected_instance_is_pushed_as_finished_in_the_instance_page_words(received): + app_id, channel_id = await _app(["run.finished", "run.failed"]) + run = await run_manager.start(graph=flow(stop_on_reject=True), input_payload={"q": "x"}, app_id=app_id, + app_version=1) + final = await _decide(run.id, approved=False) + assert (final.status, final.end_reason) == ("cancelled", "rejected") + assert await _deliveries(channel_id) == [("run.finished", "ok")], "驳回不进「实例失败」" + [request] = received + body = json.loads(request.content) + assert body["event"] == "run.finished" and body["title"].endswith("的实例已结束") + assert "状态:「review」被驳回" in body["text"] and "失败" not in body["text"] + assert "45678" not in request.content.decode(), "驳回的备注不进消息(可能带着数)" + + +async def test_a_manual_stop_is_not_pushed(received): + app_id, channel_id = await _app(["run.finished", "run.failed"]) + run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + assert await run_manager.cancel(run.id) == "cancelled" + await service.drain() + assert await _deliveries(channel_id) == [] and received == [] + + +async def test_reaching_the_returned_outcome_no_longer_says_completed(received): + app_id, channel_id = await _app(["run.finished"]) + run = await run_manager.start(graph=flow(), input_payload={"q": "x"}, app_id=app_id, app_version=1) + final = await _decide(run.id, approved=False) + assert final.status == "succeeded" and final.output.get("结论") == "退回" + [request] = received + body = json.loads(request.content) + # 实例页写走到的成果节点(no),记成人的决定:推送也这么写,不再说「已完成」 + assert "状态:no" in body["text"] and "已完成" not in body["title"] and body["title"].endswith("的实例已结束") + + +async def _personal(username: str, events: list[str]) -> str: + async with SessionLocal() as session: + user = (await session.execute(select(User).where(User.username == username))).scalar_one_or_none() + if user is None: + user = User(username=username, role="user") + session.add(user) + await session.flush() + channel = NotifyChannel(name="我的", scope="user", owner_id=user.id, format="generic", url=encrypt(URL), + events=events) + session.add(channel) + await session.commit() + return channel.id + + +async def _finished_instance(app_id: str, *, started_by: str, verified: bool) -> str: + async with SessionLocal() as session: + run = Run(workflow_name="报销", status="succeeded", graph=flow(), input={}, app_id=app_id, app_version=1, + started_by=started_by, origin_kind="app_entry", + origin={"kind": "app_entry", "actor": started_by, "actor_verified": verified}) + session.add(run) + await session.commit() + return run.id + + +async def test_the_initiator_s_personal_channel_receives_my_instance_finished(received): + """B1:个人渠道订阅了 run.finished,发起人自己发起的实例结束了收得到;应用自己没订阅也一样。""" + app_id, _ = await _app([]) + name = f"uma{uuid.uuid4().hex[:6]}" + mine = await _personal(name, ["run.finished"]) + run_id = await _finished_instance(app_id, started_by=name, verified=True) + assert await service.notify_run(run_id, "run.finished") == 1 + assert await _deliveries(mine) == [("run.finished", "ok")] + body = json.loads(received[0].content) + assert body["title"].endswith("的实例已完成") and "状态:已完成" in body["text"] + + +async def test_a_self_declared_initiator_does_not_reach_a_personal_channel(received): + """关登录时署名是自己填的,谁都能填别人的名字:不按它推给那个人的个人渠道。""" + app_id, _ = await _app([]) + name = f"vic{uuid.uuid4().hex[:6]}" + mine = await _personal(name, ["run.finished"]) + run_id = await _finished_instance(app_id, started_by=name, verified=False) + assert await service.notify_run(run_id, "run.finished") == 0 + assert await _deliveries(mine) == [] and received == [] diff --git a/backend/tests/test_process_actions.py b/backend/tests/test_process_actions.py index f11ae634..b0d0e992 100644 --- a/backend/tests/test_process_actions.py +++ b/backend/tests/test_process_actions.py @@ -173,8 +173,8 @@ async def test_unknown_result_with_lookup_is_reconciled_automatically_and_sent_o await run_manager.continue_failed(run_id) assert refused.value.code == "action_unresolved" - assert await actions.tick(utcnow()) == 0 # 还没到第一次查询的时候 - assert await actions.tick(utcnow() + timedelta(minutes=2)) == 1 + assert rec.id not in await actions.tick(utcnow()) # 还没到第一次查询的时候 + assert rec.id in await actions.tick(utcnow() + timedelta(minutes=2)) final = await _wait(run_id, ("succeeded", "failed")) assert final.status == "succeeded", final.error assert len(erp.writes()) == 1 and len(erp.lookups) == 1 @@ -410,7 +410,7 @@ async def test_a_changed_params_schema_does_not_fail_a_write_that_already_went_o with pytest.raises(actions.ActionPending): await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args) - assert await actions.tick(utcnow() + timedelta(minutes=2)) == 1 + assert rec.id in await actions.tick(utcnow() + timedelta(minutes=2)) final = await _wait(run_id, ("succeeded", "failed")) assert final.status == "succeeded", final.error assert len(erp.writes()) == 1 @@ -457,9 +457,15 @@ async def test_a_rejected_confirmation_reads_as_a_decision_not_a_fault(erp, capa run_id = await _formal(pay_graph()) task = await _task(run_id, "action_confirm") await tasks.respond(task.id, ANN, decision="reject", note="金额不对") - final = await _wait(run_id, ("succeeded", "failed")) - # 引擎里驳回就是节点失败、运行结束(没有可走的出口);说法写成未确认(被驳回),实例页按人的决定说 - assert final.status == "failed" and final.error.startswith("「pay」未确认(被驳回),没有发送:金额不对") + final = await _wait(run_id, ("succeeded", "failed", "cancelled")) + # 驳回是人的决定,不是故障(05-已知遗留 (a)):运行收成 cancelled + end_reason=rejected,原话照旧写未确认(被驳回), + # run.cancelled 带上在哪一步、谁、备注;实例页按人的决定说 + assert (final.status, final.end_reason) == ("cancelled", "rejected") + assert final.error.startswith("「pay」未确认(被驳回),没有发送:金额不对") + [ended] = await _events(run_id, "run.cancelled") + assert {k: ended.data[k] for k in ("reason", "node_id", "kind", "decision", "note")} == { + "reason": "rejected", "node_id": "pay", "kind": "action_confirm", "decision": "reject", "note": "金额不对"} + assert not await _events(run_id, "run.failed") view = await _instance_view(run_id) assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "「pay」未确认(被驳回)") step = next(s for s in view["tl"]["steps"] if s["node"] == "pay") @@ -481,7 +487,7 @@ async def test_unknown_then_reconciled_reads_as_waiting_then_a_footnote(erp, cap [row] = view["act"]["records"] assert row["state"] == "unknown" and "error" not in row and "ReadTimeout" in row["note"] - assert await actions.tick(utcnow() + timedelta(minutes=2)) == 1 + assert row["id"] in await actions.tick(utcnow() + timedelta(minutes=2)) final = await _wait(run_id, ("succeeded", "failed")) assert final.status == "succeeded", final.error view = await _instance_view(run_id) diff --git a/backend/tests/test_process_tasks.py b/backend/tests/test_process_tasks.py index 3bb85db9..b1f875fc 100644 --- a/backend/tests/test_process_tasks.py +++ b/backend/tests/test_process_tasks.py @@ -271,7 +271,24 @@ async def test_login_on_only_assignees_can_respond(): # -------------------------------------------------------------------------- -async def test_escalation_adds_the_director_whose_answer_decides(): +@pytest.fixture +async def stray_due_task(): + """别的测试留下的、早就到期、可升级的待办(-n 8 下同一个库里常有)。tick 扫全库,按条数断言的测试会被它带偏: + 这里先放一条,测试只断言自己造的那条在不在 tick 交回的 id 里(05-已知遗留 (f))。""" + async with SessionLocal() as session: + run = Run(workflow_name="别的测试留下的", status="interrupted", graph={}, input={}) + session.add(run) + await session.flush() + task = Task(run_id=run.id, node_id="x", kind="approve", title="残留", due_at=utcnow() - timedelta(days=1), + on_timeout={"action": "escalate", "to_slot": "nobody"}) + session.add(task) + await session.commit() + yield task.id + await session.execute(delete(Run).where(Run.id == run.id)) + await session.commit() + + +async def test_escalation_adds_the_director_whose_answer_decides(stray_due_task): a, b, g, boss = f"a{_u()}", f"b{_u()}", f"finance{_u()}", f"boss{_u()}" await _people(a, b, group=g) run_id = await _app_run( @@ -280,9 +297,9 @@ async def test_escalation_adds_the_director_whose_answer_decides(): {"finance": [{"kind": "group", "name": g}], "director": [{"kind": "user", "name": boss}]}) task = await _task(run_id) assert task.due_at is not None - assert await tasks.tick(utcnow()) == 0 # 还没到时限 - assert await tasks.tick(utcnow() + timedelta(hours=2)) == 1 - assert await tasks.tick(utcnow() + timedelta(hours=3)) == 0 # 只升一次 + assert task.id not in await tasks.tick(utcnow()) # 还没到时限 + assert task.id in await tasks.tick(utcnow() + timedelta(hours=2)) + assert task.id not in await tasks.tick(utcnow() + timedelta(hours=3)) # 只升一次 detail = await tasks.detail(task.id, off(None)) assert detail["escalation_level"] == 1 and detail["status"] == "pending" assert ("user", boss, "escalation") in [(x["principal_kind"], x["principal"], x["source"]) @@ -293,13 +310,13 @@ async def test_escalation_adds_the_director_whose_answer_decides(): assert (await _wait(run_id, ("succeeded", "failed"))).output.get("结论") == "通过" -async def test_timeout_reject_never_approves(): +async def test_timeout_reject_never_approves(stray_due_task): run = await run_manager.start( graph=approval_flow(due="PT30M", on_timeout={"action": "reject"}), input_payload={"q": "x"}) await _wait(run.id, ("interrupted",)) await run_manager.wait_idle(run.id) task = await _task(run.id) - assert await tasks.tick(utcnow() + timedelta(hours=1)) == 1 + assert task.id in await tasks.tick(utcnow() + timedelta(hours=1)) final = await _wait(run.id, ("succeeded", "failed")) assert final.output.get("结论") == "退回" assert len(await _events(run.id, "task.expired")) == 1 @@ -308,16 +325,16 @@ async def test_timeout_reject_never_approves(): assert row.status == "resolved" and row.outcome["decision"] == "expired_reject" -async def test_remind_repeats_every_interval(): +async def test_remind_repeats_every_interval(stray_due_task): run = await run_manager.start( graph=approval_flow(due="PT1H", on_timeout={"action": "remind", "every": "PT1H"}), input_payload={"q": "x"}) await _wait(run.id, ("interrupted",)) await run_manager.wait_idle(run.id) task = await _task(run.id) later = utcnow() + timedelta(hours=1, minutes=1) - assert await tasks.tick(later) == 1 - assert await tasks.tick(later) == 0 - assert await tasks.tick(later + timedelta(hours=1)) == 1 + assert task.id in await tasks.tick(later) + assert task.id not in await tasks.tick(later) + assert task.id in await tasks.tick(later + timedelta(hours=1)) assert len(await _events(run.id, "task.reminded")) == 2 async with SessionLocal() as session: assert (await session.get(Task, task.id)).status == "pending" diff --git a/backend/tests/test_publish_plan.py b/backend/tests/test_publish_plan.py index e38b8d97..00afa3ea 100644 --- a/backend/tests/test_publish_plan.py +++ b/backend/tests/test_publish_plan.py @@ -186,6 +186,30 @@ async def test_a_failed_trial_blocks_the_plan(client, tmp_path): assert "plan.untrialed" not in codes +async def test_a_rejected_trial_is_a_warning_not_a_failure(client, tmp_path): + """最近一次试运行在某一步被驳回(cancelled + end_reason=rejected):那是人的决定,不说成「试运行失败了」、不挡发布, + 照实提醒没走完(05-已知遗留 (a))。""" + from sqlalchemy import update + + bp = await _board_from_run(client, tmp_path) + async with SessionLocal() as session: + trial = await session.get(Run, bp["last_trial_run_id"]) + node_id = trial.graph["nodes"][-1]["id"] + label = trial.graph["nodes"][-1]["data"]["label"] + await session.execute(update(Run).where(Run.id == trial.id).values( + status="cancelled", end_reason="rejected", error_node_id=node_id, error="人工驳回:不对")) + await session.commit() + plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() + codes = {p["code"]: p for p in plan["problems"]} + assert "plan.trial_failed" not in codes and "plan.untrialed" not in codes + rejected = codes["plan.trial_rejected"] + assert rejected["level"] == "warning" and rejected["message"] == f"最近一次试运行在「{label}」被驳回、没有走完" + assert rejected["plain"]["title"] == "最近一次试运行被驳回、没有走完" + assert plan["ok"] is True, [p["code"] for p in plan["problems"] if p["level"] == "error"] + summary = {x["step"]: x["text"] for x in plan["summary"]} + assert "被驳回、没有走完" in summary["publish_workflow"] + + async def test_a_process_with_an_unbound_step_stops_at_preflight(client): wf_id = await s.published_workflow(s.expense_graph(), f"报销-{uuid.uuid4().hex[:4]}", description="报销审批") bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id}, diff --git a/backend/tests/test_records.py b/backend/tests/test_records.py index 88f435aa..c051c126 100644 --- a/backend/tests/test_records.py +++ b/backend/tests/test_records.py @@ -461,7 +461,7 @@ async def test_unknown_result_is_found_by_the_lookup_and_not_written_again(engin if (await session.get(ActionRecord, rec.id)).next_lookup_at is not None: break await asyncio.sleep(0.05) - assert await actions.tick(utcnow() + timedelta(minutes=5)) == 1 + assert rec.id in await actions.tick(utcnow() + timedelta(minutes=5)) done = await wait(run.id, ("succeeded", "failed")) assert done.status == "succeeded", done.error async with SessionLocal() as session: diff --git a/backend/tests/test_run_rejected.py b/backend/tests/test_run_rejected.py new file mode 100644 index 00000000..520d2d4f --- /dev/null +++ b/backend/tests/test_run_rejected.py @@ -0,0 +1,214 @@ +"""驳回 ≠ 失败(docs/design/next-round/05-已知遗留.md (a)、(h);总方案 X9)。 + +人的驳回(工具调用审批、代码执行审批、审批设了驳回即终止、填写 / 编辑被驳回、动作确认、双盲裁决)以前在引擎里抛普通的 +NodeError,运行记成 failed:通知推「实例失败」,定时推「运行失败」,记录页给「继续运行」——接着跑只会读到同一个驳回答复、 +再停一次。现在统一抛 NodeRejected,runner 收成 cancelled + end_reason=rejected,run.cancelled 带上在哪一步、哪种关卡、 +结论、谁、备注;原话(Run.error)一字不变。手动停止记 end_reason=stopped。被驳回的不能接着跑,存量的 failed+驳回也不能。 + +动作确认、双盲裁决的驳回在 test_process_actions.py、test_dual_check_node.py 里(要假的外部系统、口径卡)。 +""" +from __future__ import annotations + +import asyncio +import uuid +from datetime import timedelta + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal, utcnow +from app.db.models import Run, RunEvent, Task +from app.engine.runner import run_manager + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +@pytest.fixture +async def client(): + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +START = node("start", "input", fields=[{"name": "q"}]) +OUT = node("out", "output", fields=[{"name": "结果", "value": "到了"}]) + +#: (关卡, 那一步, 驳回之后 Run.error 的开头) +GATES = { + "tool": (node("gate", "tool", label="算一下", tool="calculator", args={"expression": "1+1"}, approval="always"), + "工具 calculator 的调用已被驳回:不对"), + "code": (node("gate", "code", label="跑代码", code="print(1)", language="python", approval="always"), + "代码执行已被驳回:不对"), + "approve": (node("gate", "human", label="把关", mode="approve", title="可以吗", stop_on_reject=True), + "人工驳回:不对"), + "edit": (node("gate", "human", label="改稿", mode="edit", title="改一下", draft="初稿"), "人工驳回:不对"), + "input": (node("gate", "human", label="补充", mode="input", title="补一下"), "人工驳回:不对"), +} + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row is not None and row.status in statuses: + return row + await asyncio.sleep(0.05) + raise AssertionError(f"运行 {run_id} 没有进入 {statuses}") + + +async def _events(run_id: str, kind: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + query = select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq) + if kind: + query = query.where(RunEvent.type == kind) + return list((await session.execute(query)).scalars()) + + +async def _rejected_run(kind: str, *, note: str = "不对", **reply) -> str: + gate, _ = GATES[kind] + run = await run_manager.start(graph=chain(START, gate, OUT), input_payload={"q": "x"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + await run_manager.resume(run.id, {"approved": False, "note": note, **reply}, actor="张工") + return run.id + + +@pytest.mark.parametrize("kind", list(GATES)) +async def test_a_rejection_ends_the_run_as_cancelled_with_who_and_why(kind): + run_id = await _rejected_run(kind) + final = await _wait(run_id, ("cancelled", "failed", "succeeded")) + assert (final.status, final.end_reason) == ("cancelled", "rejected"), final.error + # 原话不变:给人看的那一句照旧 + assert final.error == GATES[kind][1] and final.error_node_id == "gate" and final.finished_at is not None + assert not await _events(run_id, "run.failed"), "驳回不是失败" + [cancelled] = await _events(run_id, "run.cancelled") + data = {k: v for k, v in cancelled.data.items() if k != "timing"} + assert data == {"reason": "rejected", "node_id": "gate", "label": GATES[kind][0]["data"]["label"], "kind": kind, + "decision": "reject", "actor": "张工", "note": "不对"} + # 收尾和取消一样:封存了,在线的流收到终态 + async with SessionLocal() as session: + row = await session.get(Run, run_id) + assert row.manifest_hash and row.manifest_seq == cancelled.seq + + +async def test_a_timeout_rejection_is_recorded_as_expired_reject(): + from app.process import tasks + + gate = node("gate", "human", label="把关", mode="approve", title="可以吗", stop_on_reject=True, due="PT30M", + on_timeout={"action": "reject"}) + run = await run_manager.start(graph=chain(START, gate, OUT), input_payload={"q": "x"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + ticked = await tasks.tick(utcnow() + timedelta(hours=1)) + async with SessionLocal() as session: + mine = (await session.execute(select(Task.id).where(Task.run_id == run.id))).scalar_one() + assert mine in ticked + final = await _wait(run.id, ("cancelled", "failed", "succeeded")) + assert (final.status, final.end_reason) == ("cancelled", "rejected") + [cancelled] = await _events(run.id, "run.cancelled") + assert cancelled.data["decision"] == "expired_reject" and cancelled.data["actor"] is None + + +async def test_the_api_carries_end_reason_and_refuses_to_continue_a_rejected_run(client): + run_id = await _rejected_run("approve") + await _wait(run_id, ("cancelled",)) + body = (await client.get(f"/api/runs/{run_id}")).json() + assert (body["status"], body["end_reason"]) == ("cancelled", "rejected") + r = await client.post(f"/api/runs/{run_id}/continue", json={}) + assert r.status_code == 409 and r.json()["code"] == "run_rejected" + assert r.json()["detail"] == "被驳回的运行不能接着跑:改好内容后重新发起" + stop = await client.post(f"/api/runs/{run_id}/cancel") + assert stop.status_code == 409 and "被驳回" in stop.json()["detail"] + + +async def test_a_legacy_failed_rejection_cannot_be_continued_either(client): + """加 end_reason 之前被驳回的运行记的是 failed(封存不改写):失败的那一步上最近一条处理完的待办是驳回,也不许接着跑。 + 别的失败照旧能接着跑(这里断点不在,说的是没有断点,不是被驳回)。""" + async with SessionLocal() as session: + old = Run(workflow_name="老的驳回", status="failed", graph=chain(START, GATES["approve"][0], OUT), input={}, + error="人工驳回:不对", error_node_id="gate") + plain = Run(workflow_name="老的失败", status="failed", graph=chain(START, OUT), input={}, error="出错了") + session.add_all([old, plain]) + await session.flush() + session.add_all([ + RunEvent(run_id=old.id, seq=1, type="node.failed", node_id="gate", ts=1.0, data={"error": "人工驳回:不对"}), + Task(run_id=old.id, node_id="gate", kind="approve", title="可以吗", status="resolved", + outcome={"decision": "reject"}), + RunEvent(run_id=plain.id, seq=1, type="node.failed", node_id="out", ts=1.0, data={"error": "出错了"}), + ]) + await session.commit() + old_id, plain_id = old.id, plain.id + r = await client.post(f"/api/runs/{old_id}/continue", json={}) + assert r.status_code == 409 and r.json()["code"] == "run_rejected", r.text + other = await client.post(f"/api/runs/{plain_id}/continue", json={}) + assert other.status_code == 409 and other.json().get("code") != "run_rejected" + + +async def test_instance_state_says_the_same_for_the_new_shape_and_the_legacy_one(): + from app.apps.resolve import _trails, instance_state + + run_id = await _rejected_run("approve") + await _wait(run_id, ("cancelled",)) + async with SessionLocal() as session: + new = await session.get(Run, run_id) + legacy = Run(workflow_name="老的驳回", status="failed", graph=new.graph, input={}, error=new.error, + error_node_id="gate") + session.add(legacy) + await session.flush() + session.add_all([ + RunEvent(run_id=legacy.id, seq=1, type="node.failed", node_id="gate", ts=1.0, data={}), + Task(run_id=legacy.id, node_id="gate", kind="approve", title="可以吗", status="resolved", + outcome={"decision": "reject"}), + ]) + await session.commit() + trails = await _trails(session, [new, legacy]) + said = {"code": "cancelled", "text": "「把关」被驳回"} + assert instance_state(new, trails[new.id]) == said + assert instance_state(legacy, trails[legacy.id]) == said + + +async def test_stopping_is_recorded_as_stopped(): + gate, _ = GATES["approve"] + run = await run_manager.start(graph=chain(START, gate, OUT), input_payload={"q": "x"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + assert await run_manager.cancel(run.id, actor="李工") == "cancelled" + final = await _wait(run.id, ("cancelled",)) + assert final.end_reason == "stopped" + [cancelled] = await _events(run.id, "run.cancelled") + assert cancelled.data["reason"] == "stopped" and cancelled.data["actor"] == "李工" + + +async def test_a_run_still_queued_at_restart_gets_its_run_failed_event(): + """(h):重启时排队中的运行改成 failed,以前只改状态,时间线里没有失败原因这一行。""" + from app.core.bus import bus + + async with SessionLocal() as session: + run = Run(workflow_name=f"排队-{uuid.uuid4().hex[:6]}", status="queued", graph={}, input={}, last_seq=2) + session.add(run) + await session.commit() + run_id = run.id + await run_manager.shutdown() + bus._seq.pop(run_id, None) + await run_manager.setup() + async with SessionLocal() as session: + row = await session.get(Run, run_id) + assert row.status == "failed" and row.started_at is None and row.finished_at is None + [failed] = await _events(run_id, "run.failed") + assert failed.seq == 3 and failed.data == {"error": row.error} diff --git a/backend/tests/test_scheduler_webhook.py b/backend/tests/test_scheduler_webhook.py index 635a59ab..0b6207f4 100644 --- a/backend/tests/test_scheduler_webhook.py +++ b/backend/tests/test_scheduler_webhook.py @@ -426,11 +426,33 @@ async def test_without_webhook_old_fires_are_not_pushed_later(recorder): async def test_cancelled_run_is_not_pushed(recorder): - _, fire_id, _ = await make(run_status="cancelled") + _, fire_id, run_id = await make(run_status="cancelled") + await set_run(run_id, end_reason="stopped") await webhook.notify_due() assert recorder.requests == [] and (await fire_row(fire_id)).notified_status == "cancelled" +async def test_a_rejected_run_is_pushed_as_not_issued(recorder): + """被人驳回的那一期(cancelled + end_reason=rejected):按「运行结束」推「未出具(被驳回)」,写驳回的那一步,不写驳回的 + 备注和原话(可能带着数);订阅「运行失败」的不收(05-已知遗留 (a))。""" + _, fire_id, run_id = await make(run_status="cancelled", output={}, fmt="wecom", url=WECOM) + await set_run(run_id, end_reason="rejected", error_node_id="pay", error="「付款」未确认(被驳回),没有发送:金额 0042 不对", + graph={"nodes": [{"id": "pay", "type": "tool", "data": {"label": "付款", "config": {}}}], + "edges": []}) + assert await webhook.notify_due() == 1 + content = json.loads(recorder.requests[0].content)["markdown"]["content"] + assert "定时任务「门店周报」未出具(被驳回)" in content and '状态:被驳回' in content + assert "驳回节点:「付款」" in content and "失败" not in content + assert "0042" not in content and "金额" not in content + assert (await fire_row(fire_id)).notified_status == "cancelled" + [delivery] = await deliveries(fire_id) + assert delivery.event == "finished" + + _, only_failed, other = await make(run_status="cancelled", output={}, events=("failed",)) + await set_run(other, end_reason="rejected") + assert await webhook.notify_due() == 0 and (await fire_row(only_failed)).notified_status == "cancelled" + + async def test_waits_for_the_seal_before_pushing(recorder): _, fire_id, run_id = await make(sealed=False) assert await webhook.notify_due() == 0 and (await fire_row(fire_id)).notified_status is None From ec5d9398b5f872aa9711852ad848e62061358598 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:14:43 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E3=80=81=E7=B4=AF=E7=A7=AF=E5=BF=AB=E7=85=A7=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E3=80=81=E4=BD=9C=E5=BA=9F=E8=AE=B0=E5=BD=95=E3=80=81=E6=B8=85?= =?UTF-8?q?=E9=99=A4=E5=8E=9F=E4=BB=B6=E8=AE=B0=E5=BD=95=E5=A6=82=E5=AE=9E?= =?UTF-8?q?=E5=86=99=E3=80=8C=E6=98=AF=E5=90=A6=E8=AE=A4=E8=AF=81=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:这四处的 signed_by.verified / signed_by_verified 写死 False。开了登录时署名就是登录的人,封存证据里却记着 「未认证」,和审计表、待办派单、run.origin 的 verified 口径对不上——留痕记错了事实(05-已知遗留 (c))。 做了什么:api/deps 新增 signer_verified()(开了登录、这次请求的身份认证过时为真;关登录、不在请求里为假),和 signer_of 配对。表格导入提交、按期移除 / 作废接受、清除原件三个写接口在请求那一刻算好,和 signed_by 一起往下传到 recipe_imports._manifest、source_versions.manifest_doc / _revoked_record、table_versions.purge_import_raw;没有署名的 (退回暂存区上记的那个)照实记未认证。新参数都带默认值 False:运行里按配方导入一期(files/imports.py,署名取动作确认人 或发起人)没有请求身份,照旧记未认证。已封存的旧清单不改写,读取复验照旧。 测试:test_signer_verified(signer_verified 跟请求身份走;作废记录照给的写;开登录首次导入的清单 verified=True、清除原件 记录 signed_by 是登录的人且 verified=True,请求体里填的名字不看)。关登录的老断言(False)照旧通过。 --- backend/app/api/datasources.py | 5 ++- backend/app/api/deps.py | 9 ++++ backend/app/api/source_versions.py | 7 +-- backend/app/api/table_imports.py | 5 ++- backend/app/data/recipe_imports.py | 15 ++++--- backend/app/data/source_versions.py | 30 ++++++++----- backend/app/data/table_versions.py | 6 ++- backend/tests/test_signer_verified.py | 63 +++++++++++++++++++++++++++ 8 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_signer_verified.py diff --git a/backend/app/api/datasources.py b/backend/app/api/datasources.py index 38bebb9b..3f8a972f 100644 --- a/backend/app/api/datasources.py +++ b/backend/app/api/datasources.py @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api import health from app.api.coded import PROFILE_SETTINGS_INVALID, CodedHTTPException -from app.api.deps import signer_of +from app.api.deps import signer_of, signer_verified from app.core.errors import AUTH, NETWORK, TIMEOUT, classify, explain, raw from app.core.config import settings from app.core.crypto import encrypt, mask @@ -1104,7 +1104,8 @@ async def purge_raw( if imp.raw_state == "purged": raise CodedHTTPException(409, "这次导入的原件已经清除", "raw_already_purged") try: - purged = await table_versions.purge_import_raw(session, imp, reason=reason, signed_by=signed_by) + purged = await table_versions.purge_import_raw(session, imp, reason=reason, signed_by=signed_by, + signer_verified=signer_verified()) except table_versions.StoreUnavailable as e: raise CodedHTTPException(503, str(e), "store_unavailable") from e deleted, others = purged.deleted, purged.others diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6fa03e2d..eebbc76e 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -105,6 +105,15 @@ def signer_of(given: Any, header: str | None, limit: int = 100) -> str | None: return decode_actor(header) +def signer_verified() -> bool: + """signer_of 记下的署名是不是认证过的身份:开了登录、这次请求的身份认证过时为真;关登录时署名是自报的,为假。 + + 导入清单、累积快照清单、作废记录、清除原件记录的「是否认证」记这个(docs/design/next-round/05-已知遗留.md (c)), + 和审计表、待办派单、run.origin 的 verified 同一个口径。证据要在生成那一刻定下:调用方和 signed_by 一起往下传。""" + principal = _principal.get() + return principal is not None and principal.mode != "off" and bool(principal.verified) + + def current_principal() -> Principal: """这次请求的完整身份。关登录时:等同 admin、未认证,username 是中间件按 X-Actor 解出的自报署名;中间件没写 (不在请求里,比如测试直接调函数、启动时的整理、调度循环)时同样等同 admin、未认证,username 为空。""" diff --git a/backend/app/api/source_versions.py b/backend/app/api/source_versions.py index 7d8a07ba..bd77947f 100644 --- a/backend/app/api/source_versions.py +++ b/backend/app/api/source_versions.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.datasources import _out -from app.api.deps import signer_of +from app.api.deps import signer_of, signer_verified from app.data import source_versions, table_versions from app.data.recipe_imports import ImportRefused from app.db.base import get_session @@ -170,7 +170,8 @@ async def remove_period(source_id: str, import_id: str, request: Request, x_acto source = await _source(session, source_id) try: out = await source_versions.remove_period(session, source, import_id, expected_current=expected, - reason=reason or "", signed_by=_signed(body, x_actor)) + reason=reason or "", signed_by=_signed(body, x_actor), + signer_verified=signer_verified()) except _REFUSALS as e: raise _refused(e) from e return {"source": await _source_out(session, source_id), **out} @@ -193,7 +194,7 @@ async def revoke_acceptance(source_id: str, import_id: str, request: Request, try: out = await source_versions.revoke_acceptance( session, source, import_id, expected_current=expected, expected_target=target, ack_mask_lost=ack, - reason=reason or "", signed_by=_signed(body, x_actor)) + reason=reason or "", signed_by=_signed(body, x_actor), signer_verified=signer_verified()) except _REFUSALS as e: raise _refused(e) from e return {"source": await _source_out(session, source_id), **out} diff --git a/backend/app/api/table_imports.py b/backend/app/api/table_imports.py index 24e13370..1fdf408e 100644 --- a/backend/app/api/table_imports.py +++ b/backend/app/api/table_imports.py @@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.datasources import _NAME_PATTERN, _out, read_upload -from app.api.deps import current_principal, signer_of +from app.api.deps import current_principal, signer_of, signer_verified from app.data import recipe_imports, table_versions from app.data.recipe_imports import EditRequest, ImportRefused from app.data.recipe_types import Acceptance, AiAvailability, PeriodInput, Recipe, Selection @@ -422,7 +422,8 @@ async def commit(staging_id: str, request: Request, x_actor: str | None = Header try: result = await recipe_imports.commit_staging( session, staging, trial_id=trial_id if isinstance(trial_id, str) else "", - confirmations=confirmations, acceptances=_acceptances(body.get("acceptances"), who), signed_by=who) + confirmations=confirmations, acceptances=_acceptances(body.get("acceptances"), who), signed_by=who, + signer_verified=signer_verified()) except _REFUSALS as e: raise _refused(e) from e source = await session.get(DataSource, result.source_id, populate_existing=True) diff --git a/backend/app/data/recipe_imports.py b/backend/app/data/recipe_imports.py index 40dba2b1..34acb9f0 100644 --- a/backend/app/data/recipe_imports.py +++ b/backend/app/data/recipe_imports.py @@ -2361,7 +2361,7 @@ def _manifest(*, staging_id: str, kind: str, source_id: str, info: table_version ex_dict: dict[str, Any], file: dict[str, Any], recipe_meta: dict[str, Any], checks: list[dict[str, Any]], overrides: list[dict[str, Any]], waivers: list[dict[str, Any]], confirmed: list[dict[str, Any]], notes: SchemaNotes, ai: dict[str, Any], signed_by: str | None, - edits: list[dict[str, Any]] | None = None) -> dict[str, Any]: + edits: list[dict[str, Any]] | None = None, signer_verified: bool = False) -> dict[str, Any]: receipt = copy.deepcopy(ex_dict) receipt.pop("problems", None) return { @@ -2383,7 +2383,8 @@ def _manifest(*, staging_id: str, kind: str, source_id: str, info: table_version "ai": ai, # 期 3:修复、框选的修改记录(含已被整份替换覆盖的,作为历史;不带撤销用的修改前起点) "edits": copy.deepcopy(list(edits or [])), - "signed_by": {"name": signed_by, "verified": False}, + # 是否认证照实记(05-已知遗留 (c)):开了登录、署名是登录的人时为真。以前写死 False,封存的证据记错了事实 + "signed_by": {"name": signed_by, "verified": bool(signer_verified and signed_by)}, "staging_id": staging_id, "kind": kind, "created_at": _now_iso(), } @@ -2411,7 +2412,8 @@ def _file_sha(path: Path) -> str | None: async def commit_staging(session: AsyncSession, staging: ImportStaging, *, trial_id: str, confirmations: list[str], - acceptances: list[Acceptance], signed_by: str | None) -> CommitResult: + acceptances: list[Acceptance], signed_by: str | None, + signer_verified: bool = False) -> CommitResult: """POST imports/{id}/commit(7.8):预检 → (与现行导入相同时)不新建记录 → 发布构建、写配方、导入记录、 快照、清单,切指针。失败时指针不动,暂存区退回 drafting(试运行库已被发布流程消耗)。 @@ -2532,6 +2534,8 @@ async def commit_staging(session: AsyncSession, staging: ImportStaging, *, trial ex = extraction_from_dict(ex_dict) who = signed_by or staging.signed_by + # 署名是这次请求的(signed_by)才谈得上认证;退回暂存区上记的那个,是否认证这里不知道,照实记未认证 + who_verified = bool(signer_verified and signed_by) acc = [Acceptance(check_id=a.check_id, reason=a.reason.strip(), signed_by=who) for a in given.values()] # 这一期自己的说明(带实际的接受)进它的导入清单:版本页逐期显示「改写后的那一句」从这里取(第 5 节)。 # 快照冻结的说明在多期时另按各期最弱的结论合并(2.8),只剩本期时就是它 @@ -2603,7 +2607,8 @@ async def commit_staging(session: AsyncSession, staging: ImportStaging, *, trial file={"name": file_name, "size": file_size, "raw_sha256": raw_sha}, recipe_meta={"id": recipe_id, "sha256": recipe_sha, "origin": origin, "canonical": canonical}, checks=checks_full, overrides=overrides, waivers=waivers, confirmed=confirmed, - notes=notes if union_info is None else period_notes, ai=ai, signed_by=who, edits=edits) + notes=notes if union_info is None else period_notes, ai=ai, signed_by=who, edits=edits, + signer_verified=who_verified) async def schema_cache_for(view: table_versions.SourceView, info: table_versions.PublishInfo) -> dict[str, Any]: from app.core import artifact_store @@ -2663,7 +2668,7 @@ async def commit_staging(session: AsyncSession, staging: ImportStaging, *, trial "table_hashes": dict(union_report.get("table_hashes") or {}), "union_ver": recipe_types.UNION_VER}, parts=ordered, report=union_report, label_sets=list((plan or {}).get("label_sets") or []), gaps=list((plan or {}).get("gaps") or []), replaced=(plan or {}).get("replaces"), removed=None, - notes=notes, signed_by=who) + notes=notes, signed_by=who, signer_verified=who_verified) smid = await artifact_store.put_json(doc, kind="snapshot_manifest", meta={"source_id": source_id, "snapshot_id": info.snapshot_id}) cache["import_manifests"] = [mid if iid is None else by_imp[iid].info.manifest_artifact diff --git a/backend/app/data/source_versions.py b/backend/app/data/source_versions.py index a3dcb73f..4e389fb0 100644 --- a/backend/app/data/source_versions.py +++ b/backend/app/data/source_versions.py @@ -394,7 +394,7 @@ def columns_doc(report: dict[str, Any], periods: list[str]) -> dict[str, dict[st def manifest_doc(*, source_id: str, snapshot_id: str, action: str, recipe: dict[str, Any], union: dict[str, Any], parts: list[Part | dict[str, Any]], report: dict[str, Any], label_sets: list[Any], gaps: list[dict[str, str]], replaced: Any, removed: Any, - notes: SchemaNotes, signed_by: str | None) -> dict[str, Any]: + notes: SchemaNotes, signed_by: str | None, signer_verified: bool = False) -> dict[str, Any]: """快照清单(2.9)。parts 按统计期排序,与 report.part_rows 同序;本期写成 dict(导入记录还没落库)。""" part_rows = report.get("part_rows") or [] keys = [] @@ -422,7 +422,8 @@ def manifest_doc(*, source_id: str, snapshot_id: str, action: str, recipe: dict[ "checks": copy.deepcopy(report.get("checks") or []), "notes": {"templates": dict(notes.templates), "rendered": {t: {"comment": n.comment, "columns": dict(n.columns)} for t, n in notes.tables.items()}}, - "signed_by": {"name": signed_by, "verified": False}, + # 是否认证照实记(05-已知遗留 (c),同导入清单) + "signed_by": {"name": signed_by, "verified": bool(signer_verified and signed_by)}, "created_at": utcnow().isoformat(), } @@ -684,8 +685,10 @@ async def _current(session: AsyncSession, source: DataSource) -> _Current: mode=snap.mode or "replace", recipe=recipe) -def _revoked_record(reason: str, signed_by: str | None) -> dict[str, Any]: - return {"at": utcnow().isoformat(), "reason": reason, "signed_by": signed_by or None, "signed_by_verified": False} +def _revoked_record(reason: str, signed_by: str | None, signer_verified: bool = False) -> dict[str, Any]: + """作废接受的记录。是否认证照实记(05-已知遗留 (c)):开了登录、署名是登录的人时为真。""" + return {"at": utcnow().isoformat(), "reason": reason, "signed_by": signed_by or None, + "signed_by_verified": bool(signer_verified and signed_by)} async def rollback_target(session: AsyncSession, source: DataSource, import_id: str) -> SourceSnapshot | None: @@ -739,7 +742,8 @@ async def revoke_plan(session: AsyncSession, source: DataSource, imp: TableImpor async def _rebuild_without(session: AsyncSession, cur: _Current, imp: TableImport, *, expected_current: str | None, - kind: str, reason: str, signed_by: str | None, revoke: bool) -> tuple[str, bool]: + kind: str, reason: str, signed_by: str | None, revoke: bool, + signer_verified: bool = False) -> tuple[str, bool]: """从当前版本去掉一期、生成(或复用、复活)新快照(7.4)。配方和导入模式不变。返回 (快照 id, 是否复用)。 revoke=True 时同一个事务里写 table_imports.revoked(7.5):复用路径在调 activate_snapshot 之前改好(随它的提交 @@ -758,7 +762,7 @@ async def _rebuild_without(session: AsyncSession, cur: _Current, imp: TableImpor sid = table_versions.snapshot_id(source_id, keep, recipe_types.RECIPE_ENGINE_VER, mode="accumulate", recipe_sha=target_sha) imp_id = imp.id - revoked = _revoked_record(reason, signed_by) if revoke else None + revoked = _revoked_record(reason, signed_by, signer_verified) if revoke else None activation = {"kind": kind, "import_id": imp_id, "reason": reason, "signed_by": signed_by} existing = await session.get(SourceSnapshot, sid) @@ -871,7 +875,7 @@ async def _rebuild_without(session: AsyncSession, cur: _Current, imp: TableImpor "table_hashes": dict((info.union_report or report_json or {}).get("table_hashes") or {}), "union_ver": recipe_types.UNION_VER}, parts=parts, report=info.union_report or report_json or {}, label_sets=label_sets, gaps=gaps, - replaced=None, removed=removed, notes=notes, signed_by=signed_by) + replaced=None, removed=removed, notes=notes, signed_by=signed_by, signer_verified=signer_verified) cache["snapshot_manifest"] = await artifact_store.put_json( doc, kind="snapshot_manifest", meta={"source_id": source_id, "snapshot_id": info.snapshot_id}) return cache @@ -931,7 +935,7 @@ def _check_base(cur: _Current, expected_current: str | None) -> None: async def remove_period(session: AsyncSession, source: DataSource, import_id: str, *, expected_current: str | None, - reason: str, signed_by: str | None) -> dict[str, Any]: + reason: str, signed_by: str | None, signer_verified: bool = False) -> dict[str, Any]: """POST …/imports/{id}/remove(7.4)。""" imp = await session.get(TableImport, import_id) if imp is None or imp.source_id != source.id: @@ -947,13 +951,15 @@ async def remove_period(session: AsyncSession, source: DataSource, import_id: st if len(cur.imports) < 2: raise ImportRefused(409, "last_period", _LAST_PERIOD) sid, reused = await _rebuild_without(session, cur, imp, expected_current=expected_current, kind="remove_period", - reason=reason, signed_by=signed_by, revoke=False) + reason=reason, signed_by=signed_by, revoke=False, + signer_verified=signer_verified) return {"snapshot_id": sid, "removed_import_id": import_id, "reused": reused} async def revoke_acceptance(session: AsyncSession, source: DataSource, import_id: str, *, expected_current: str | None, expected_target: str | None, - ack_mask_lost: list[str] | None, reason: str, signed_by: str | None) -> dict[str, Any]: + ack_mask_lost: list[str] | None, reason: str, signed_by: str | None, + signer_verified: bool = False) -> dict[str, Any]: """POST …/imports/{id}/revoke-acceptance(7.5)。累积模式(至少两期)= 移除这一期;否则回滚到第 5 节定义的目标。""" imp = await session.get(TableImport, import_id) if imp is None or imp.source_id != source.id: @@ -968,7 +974,7 @@ async def revoke_acceptance(session: AsyncSession, source: DataSource, import_id if _accumulating(cur): sid, _reused = await _rebuild_without(session, cur, imp, expected_current=expected_current, kind="revoke_acceptance", reason=reason, signed_by=signed_by, - revoke=True) + revoke=True, signer_verified=signer_verified) return {"snapshot_id": sid, "action": "remove_period"} target = await rollback_target(session, source, imp.id) if target is None: @@ -976,7 +982,7 @@ async def revoke_acceptance(session: AsyncSession, source: DataSource, import_id if target.id != expected_target: raise ImportRefused(409, "revoke_target_changed", _TARGET_CHANGED) target_id = target.id - imp.revoked = _revoked_record(reason, signed_by) + imp.revoked = _revoked_record(reason, signed_by, signer_verified) try: await table_versions.activate_snapshot( session, source, target_id, expected_current=expected_current, kind="revoke_acceptance", reason=reason, diff --git a/backend/app/data/table_versions.py b/backend/app/data/table_versions.py index 2407d0e3..3302344b 100644 --- a/backend/app/data/table_versions.py +++ b/backend/app/data/table_versions.py @@ -2130,7 +2130,7 @@ class RawPurge: async def purge_import_raw( - session: AsyncSession, imp: TableImport, *, reason: str, signed_by: str | None + session: AsyncSession, imp: TableImport, *, reason: str, signed_by: str | None, signer_verified: bool = False ) -> RawPurge: """清除一次导入的原件(提交)。按两元组解包得到 (是否删了文件, 一并标成已清除的其他导入记录), discarded_stagings 是一并放弃的暂存区。 @@ -2160,7 +2160,9 @@ async def purge_import_raw( now = utcnow() record = { "at": now.isoformat(), "reason": reason, - "signed_by": (signed_by or "").strip() or None, "signed_by_verified": False, + # 是否认证照实记(05-已知遗留 (c)):开了登录、署名是登录的人时为真 + "signed_by": (signed_by or "").strip() or None, + "signed_by_verified": bool(signer_verified and (signed_by or "").strip()), } imp.raw_state, imp.purged = "purged", record for other in others: diff --git a/backend/tests/test_signer_verified.py b/backend/tests/test_signer_verified.py new file mode 100644 index 00000000..6d4df7f7 --- /dev/null +++ b/backend/tests/test_signer_verified.py @@ -0,0 +1,63 @@ +"""导入清单、累积快照清单、作废记录、清除原件记录如实写「是否认证」(docs/design/next-round/05-已知遗留.md (c))。 + +以前四处都写死 False:开了登录、署名就是登录的人,封存的证据里却记着「未认证」,和审计表、待办派单、run.origin 的 +verified 对不上。现在由 api/deps.signer_verified 在请求那一刻定下、和署名一起往下传:开登录为真,关登录(自报的署名) +为假。已封存的旧清单不改写(关登录时的形状见 test_table_imports_api、test_source_versions_api、test_datasources_upload)。 +""" +from __future__ import annotations + +import pytest + +from app.api import deps +from app.core import artifact_store +from app.db.models import TableImport +from tests.fakes import auth_env +from tests.test_table_imports_api import first_import, get, store, unique # noqa: F401 - store 是 autouse 夹具 + + +@pytest.fixture +async def admin(): + await auth_env.reset() + c = await auth_env.turn_on_local("root") + try: + yield c + finally: + await c.aclose() + await auth_env.reset() + + +def test_signer_verified_follows_the_request_identity(): + token = deps._principal.set(deps.Principal(username="root", role="admin", verified=True, mode="local")) + try: + assert deps.signer_verified() is True + finally: + deps._principal.reset(token) + token = deps._principal.set(deps.Principal(username="张三", role="admin", verified=False, mode="off")) + try: + assert deps.signer_verified() is False, "关登录时署名是自报的" + finally: + deps._principal.reset(token) + assert deps.signer_verified() is False, "不在请求里(启动整理、调度循环)不算认证" + + +def test_the_records_write_what_they_are_given(): + from app.data import source_versions + + assert source_versions._revoked_record("理由", "root", True)["signed_by_verified"] is True + assert source_versions._revoked_record("理由", "root")["signed_by_verified"] is False + assert source_versions._revoked_record("理由", None, True)["signed_by_verified"] is False, "没有署名谈不上认证" + + +async def test_login_on_import_manifest_and_purge_record_say_verified(admin): + out = (await first_import(admin, unique("signer")))["commit"] + imp = await get(TableImport, out["import_id"]) + manifest = artifact_store.load(imp.manifest_artifact) + assert manifest["signed_by"] == {"name": "root", "verified": True} + assert imp.signed_by == "root" + + source_id = out["source"]["id"] + resp = await admin.post(f"/api/datasources/{source_id}/imports/{imp.id}/purge-raw", + json={"reason": "含有不应保留的内容", "signed_by": "随手填的名字"}) + assert resp.status_code == 200, resp.text + purged = resp.json()["import"]["purged"] + assert (purged["signed_by"], purged["signed_by_verified"]) == ("root", True) From a59409cc85f6be3f12b732d619aff8933a234117 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:24:22 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=80=85=E9=80=89?= =?UTF-8?q?=E4=BA=BA=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E6=8C=89?= =?UTF-8?q?=E7=94=A8=E9=80=94=E7=BB=99=E5=80=99=E9=80=89=E6=B1=A0=EF=BC=8C?= =?UTF-8?q?=E5=BC=80=E7=99=BB=E5=BD=95=E6=97=B6=E5=85=A5=E5=8F=A3=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E3=80=81=E5=BE=85=E5=8A=9E=E8=A1=A8=E5=8D=95=E7=9A=84?= =?UTF-8?q?=E9=80=89=E4=BA=BA=E6=8E=A7=E4=BB=B6=E6=8C=89=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=B1=A0=E6=A0=B8=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:使用者在选人控件里没有候选(名录只给搭建者),服务端也不核对。填错的名字原样派下去,待办给了一个不存在的人, 没人能处理、也不报错(05-已知遗留 (b))。名录又不能整份放开给使用者(只读与最小可见)。 做了什么: - auth/directory 新增候选池:form 用途缺省是「能用这个应用的人」——使用及以上的授权(人;组列出组本身并展开成员), 加上管理它的人(管理员、建应用的人);应用授权给了所有人时池是全部启用的人(open),不带前缀 q 不列(needs_query)。 字段可以声明 x-principal: {pool: app | group:<组> | slot:<岗位>}(slot 按这次用到的应用版本的 bindings 展开)。 review 用途是启用的搭建者和管理员(同 personal._review_targets)。停用的人一律不列。 - GET /api/directory/candidates(任何登录的人;main.py 路由清单只加一行):purpose=form 带 app_id+entry 或 task_id、field, 调用的人不能用这个应用 403;只回 {users:[{username, display_name}], groups, truncated, needs_query?},不给停用、角色、 邮箱、组成员;q 按用户名或显示名开头筛,limit 最多 50。/api/directory 仍只给搭建者。 - 开了登录时 start_entry 和待办表单提交按同一个池核对,不在池里 422,errors 的 code=principal(FORM_ERROR_CODES 加一项), 话写成「「x」不在可选的人里,请从候选中选择」(单点登录另说要先登录一次才进名录)。直接生效(遗留-Q4 按推荐)。 关登录照旧手填、不核对;不属于应用的待办(试运行、画布)没有池,不核对。 - apps/forms:x-principal 进 _FIELD_KEYS,validate_form_schema 查写法(只能写在选人控件上、只能是三种池)。 搭建者没有授权时按 apps/access 只能「查看全部实例」,不算池里的人——池按「能用」算,这一点写进了 app_pool 的说明。 测试:test_directory_candidates(只给名字、停用的不列、前缀筛、没使用权 403、名录仍 403;授权给所有人要前缀、截断;review 只出搭建者和管理员;入口表单不在池 422、停用的 422、组名和池里的人放行;待办表单同一个池;声明 group:<组> 收窄;关登录 任何名字照收;x-principal 写法校验)。 --- backend/app/api/identity.py | 53 ++++ backend/app/apps/entries.py | 7 + backend/app/apps/forms.py | 11 +- backend/app/apps/spec.py | 5 +- backend/app/auth/directory.py | 178 +++++++++++++- backend/app/main.py | 1 + backend/app/process/tasks.py | 18 ++ backend/tests/test_directory_candidates.py | 266 +++++++++++++++++++++ 8 files changed, 535 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_directory_candidates.py diff --git a/backend/app/api/identity.py b/backend/app/api/identity.py index 01759cd5..845f9b64 100644 --- a/backend/app/api/identity.py +++ b/backend/app/api/identity.py @@ -27,6 +27,9 @@ router = APIRouter(prefix="/api", tags=["identity"]) #: 人和组的名录(GET /api/directory):只读、给搭建者及以上(main.py 挂 require_role("builder")),岗位绑定、应用授权的 #: 选人控件据此给候选、核对填的名字在不在。只有用户名、显示名、是否停用和组名,不含邮箱、角色、登录时间 directory_router = APIRouter(prefix="/api", tags=["identity"]) +#: 使用者选人的候选(GET /api/directory/candidates,05-已知遗留 (b)):任何登录的人都能调(main.py 挂 require_role("user")), +#: 按用途给候选池,只有用户名和显示名 +candidates_router = APIRouter(prefix="/api", tags=["identity"]) def _iso(value: Any) -> str | None: @@ -224,6 +227,56 @@ async def directory(session: AsyncSession = Depends(get_session)) -> dict[str, A "groups": [{"name": g.name, "label": g.label} for g in known.groups.values()]} +@candidates_router.get("/directory/candidates") +async def candidates(purpose: str, app_id: str | None = None, entry: str | None = None, task_id: str | None = None, + field: str | None = None, q: str | None = None, limit: int = 20, + session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """使用者选人时的候选:{users: [{username, display_name}], groups: [{name, label}], truncated, needs_query?}。 + + 不给是否停用(停用的直接不列)、角色、邮箱、组成员。purpose=form:表单里的选人控件,带 app_id 和 entry(入口表单) + 或 task_id(待办表单)、field(哪个控件),池按字段的 x-principal 算、缺省是能用这个应用的人,要求调用的人能用这个 + 应用(否则 403);应用授权给了所有人时不带前缀 q 不列(needs_query)。purpose=review:个人应用提交审核,启用的搭建者 + 和管理员。q 按用户名或显示名的开头筛,limit 最多 50。开了登录时提交的值按同一个池核对(auth/directory.principal_errors)。""" + from app.api.coded import CodedHTTPException + from app.api.deps import current_principal + from app.apps.access import app_role, at_least + from app.apps.tree import entry_of, resolve_form + from app.auth.directory import PURPOSES, field_pool, principal_fields, review_pool + from app.db.models_apps import App, AppVersion + from app.db.models_process import Task + + if purpose not in PURPOSES: + raise CodedHTTPException(422, f"候选的用途只能是 {' / '.join(PURPOSES)}", "candidates_purpose") + if purpose == "review": + return (await review_pool(session)).pick(q, limit) | {"groups": []} + principal = current_principal() + bindings: dict[str, Any] | None = None + form: Any = None + task = await session.get(Task, task_id) if task_id else None + app = await session.get(App, task.app_id if task is not None else app_id) if (task or app_id) else None + if app is None or (task_id and task is None): + raise CodedHTTPException(404, "应用或待办不存在", "candidates_not_found") + if not at_least(await app_role(session, principal, app), "use"): + raise CodedHTTPException(403, "你没有这个应用的使用权限,看不到它的候选人", "candidates_forbidden") + if task is not None: + form = task.form + if task.app_version is not None: + bindings = await session.scalar(select(AppVersion.bindings).where( + AppVersion.app_id == app.id, AppVersion.version == task.app_version)) + else: + version = (await session.execute(select(AppVersion).where( + AppVersion.app_id == app.id, AppVersion.version == app.published_version))).scalar_one_or_none() \ + if app.published_version else None + spec = version.spec if version is not None else {} + found = entry_of(spec or {}, entry or "") if entry else None + form = resolve_form(spec or {}, found.get("form")) if found else None + bindings = version.bindings if version is not None else None + target = principal_fields(form).get(field or "") + if target is None: + raise CodedHTTPException(404, "表单里没有这个选人控件", "candidates_not_found") + return (await field_pool(session, app, bindings, target)).pick(q, limit) + + # ---------------------------------------------------------------- 审计 diff --git a/backend/app/apps/entries.py b/backend/app/apps/entries.py index 56bd7c69..76b6980a 100644 --- a/backend/app/apps/entries.py +++ b/backend/app/apps/entries.py @@ -113,6 +113,13 @@ async def start_entry(app_id: str, entry_id: str, values: dict[str, Any], princi form = resolve_form(spec, entry.get("form")) or {"type": "object", "properties": {}} if errors := validate_form(form, values if isinstance(values, dict) else None): raise FormRejected(errors) + # 选人控件:开了登录时按候选池核对(auth/directory.py,05-已知遗留 (b))。以前原样传下去,填错的名字会把待办 + # 派给一个不存在的人——没人能处理,也不报错 + from app.auth.directory import principal_errors + + if errors := await principal_errors(session, app, version.bindings, form, values, + getattr(principal, "mode", "off")): + raise FormRejected(errors) # 表单里的文件(docs/design/normal-user-apps.md N9):核对是提交人在这个入口传的、还没用过,先占住;表单值换成 # 登记的那一份引用(连同 sha256 进运行输入、随封存固定)。发起成功绑到这次运行,发起不成放回去 values, claimed = await _claim_files(form, values, principal, app.id, entry_id) diff --git a/backend/app/apps/forms.py b/backend/app/apps/forms.py index fc4f5ff1..d0f63b67 100644 --- a/backend/app/apps/forms.py +++ b/backend/app/apps/forms.py @@ -28,6 +28,8 @@ _FIELD_KEYS = frozenset({ "minLength", "maxLength", "maxItems", "format", "items", "x-widget", # 文件控件的限制:accept(类型或扩展名的列表)、max_mb(docs/design/normal-user-apps.md §3.8) "x-file", + # 选人控件的候选池:{pool: app | group:<组> | slot:<岗位>}(auth/directory.py,05-已知遗留 (b)) + "x-principal", }) #: 文件控件只认这几项写法 _FILE_FIELD_KEYS = frozenset({"type", "title", "description", "readOnly", "x-widget", "x-file"}) @@ -88,6 +90,13 @@ def _field_problems(field: Any) -> list[str]: return out + _file_field_problems(field) if "x-file" in field: out.append("不是文件控件,不能写文件的限制") + if "x-principal" in field: + from app.auth.directory import pool_problem + + if field.get("x-widget") != "principal": + out.append("不是选人控件,不能写候选范围") + elif problem := pool_problem(field["x-principal"]): + out.append(problem) if kind not in FORM_TYPES: out.append(f"的类型只能是 {' / '.join(FORM_TYPES)}") if "format" in field and field["format"] not in FORM_FORMATS: @@ -153,7 +162,7 @@ def _clean_schema(form: dict[str, Any]) -> dict[str, Any]: continue props[name] = {k: v for k, v in field.items() if k in _FIELD_KEYS and k not in ("format", "x-widget", "x-enum-labels", "title", - "description", "readOnly", "default")} + "description", "readOnly", "default", "x-principal")} return {"type": "object", "properties": props, "required": [r for r in form.get("required") or [] if r in props]} diff --git a/backend/app/apps/spec.py b/backend/app/apps/spec.py index a64b7df0..e8f3edfd 100644 --- a/backend/app/apps/spec.py +++ b/backend/app/apps/spec.py @@ -47,9 +47,10 @@ 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。file:文件控件的值不对(没上传、不是本人传的、已经用过、类型或大小不符,app/files/store.py) +#: 表单校验错误的 code。file:文件控件的值不对(没上传、不是本人传的、已经用过、类型或大小不符,app/files/store.py); +#: principal:开了登录时选人控件的值不在候选池里(auth/directory.principal_errors) FORM_ERROR_CODES = ("required", "type", "enum", "minimum", "maximum", "min_length", "max_length", "max_items", "format", - "file") + "file", "principal") class _Strict(BaseModel): diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py index 6f689ec1..d981f9bc 100644 --- a/backend/app/auth/directory.py +++ b/backend/app/auth/directory.py @@ -1,6 +1,10 @@ """人和组的名录:选人控件的候选(GET /api/directory)、应用检查核对岗位绑定和授权、改授权时核对填的名字,同一处取、 同一个口径(停用的人也在名录里,标 disabled:已经绑着他的岗位、授权要能认出「这个人停用了」,不是「没有这个人」)。 +使用者选人(表单里的选人控件、个人应用提交审核)不拿整份名录,拿按用途算的候选池(candidates,GET +/api/directory/candidates):只有这件事能选的人,只给用户名和显示名,停用的不列;开了登录时提交的值按同一个池核对 +(docs/design/next-round/05-已知遗留.md (b))。名录整份仍只给搭建者。 + 名录里对不上怎么算看登录方式(同前端 PrincipalPicker): - 账号密码登录:人和组都在本系统里建,名录里没有就是填错了(或人被删了),停用的人处理不了待办——都算错; - 单点登录:人和组第一次登录后才进名录,名录里没有的照实提醒、不拒收;停用的人名录里认得出,照样算错; @@ -10,7 +14,7 @@ from __future__ import annotations from collections.abc import Iterable from dataclasses import dataclass, field -from typing import Literal +from typing import Any, Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -95,3 +99,175 @@ def mismatch(directory: Directory, mode: str, kind: str, name: str) -> Mismatch return Mismatch(kind, name, problem, False, f"{what}名录里还没有:单点登录的人和组第一次登录后才进名录,名字要和身份提供方里的一致才生效") return Mismatch(kind, name, problem, True, f"{what}名录里没有") + + +# -------------------------------------------------------------------------- +# 候选池:使用者选人时能选谁(05-已知遗留 (b)) +# -------------------------------------------------------------------------- + +#: 候选池的用途:form 表单里的选人控件(入口表单、待办表单);review 个人应用提交审核选审核的人 +PURPOSES = ("form", "review") +#: 一次最多给几位候选 +CANDIDATES_MAX = 50 +#: 表单字段声明候选池(x-principal.pool):app 能用这个应用的人(缺省);group:<组> 这个组的成员;slot:<岗位> 这个岗位 +#: 绑定的人和组(按应用版本的 bindings) +POOL_APP = "app" +POOL_GROUP = "group:" +POOL_SLOT = "slot:" + + +def pool_problem(value: Any) -> str | None: + """x-principal 的写法对不对:只能是 {pool: app | group:<组> | slot:<岗位>}。没问题为 None。""" + if not isinstance(value, dict) or set(value) != {"pool"} or not isinstance(value.get("pool"), str): + return "的候选范围只能写 {\"pool\": \"app\"}、{\"pool\": \"group:组名\"} 或 {\"pool\": \"slot:岗位\"}" + pool = value["pool"].strip() + if pool == POOL_APP: + return None + for prefix in (POOL_GROUP, POOL_SLOT): + if pool.startswith(prefix) and pool[len(prefix):].strip(): + return None + return "的候选范围只能是 app(能用这个应用的人)、group:组名 或 slot:岗位" + + +def pool_of(field: Any) -> str: + """表单字段声明的候选池;没声明、写法不对的按缺省(能用这个应用的人)。""" + setting = field.get("x-principal") if isinstance(field, dict) else None + if isinstance(setting, dict) and pool_problem(setting) is None: + return setting["pool"].strip() + return POOL_APP + + +@dataclass +class Pool: + """一个候选池。open:池就是全部启用的人(应用授权给了所有人)——不带前缀不列,免得等于发通讯录。""" + users: dict[str, DirectoryUser] = field(default_factory=dict) + groups: dict[str, DirectoryGroup] = field(default_factory=dict) + open: bool = False + + def has(self, name: str) -> bool: + """提交的这个名字在不在池里(用户名或组名)。""" + return name in self.users or name in self.groups + + def pick(self, q: str | None, limit: int = CANDIDATES_MAX) -> dict[str, Any]: + """接口给的那一份:{users: [{username, display_name}], groups: [{name, label}], truncated};按前缀筛 + (用户名或显示名,不分大小写)。池是 open 的、又没带前缀:什么都不给,needs_query 为真。""" + prefix = (q or "").strip().lower() + if self.open and not prefix: + return {"users": [], "groups": [], "truncated": False, "needs_query": True} + limit = max(1, min(int(limit or CANDIDATES_MAX), CANDIDATES_MAX)) + + def hit(*names: str) -> bool: + return not prefix or any(n.lower().startswith(prefix) for n in names if n) + + users = [u for u in sorted(self.users.values(), key=lambda u: u.username) if hit(u.username, u.display_name)] + groups = [g for g in sorted(self.groups.values(), key=lambda g: g.name) if hit(g.name, g.label)] + return {"users": [{"username": u.username, "display_name": u.display_name} for u in users[:limit]], + "groups": [{"name": g.name, "label": g.label} for g in groups[:limit]], + "truncated": len(users) > limit or len(groups) > limit} + + +async def _members(session: AsyncSession, group: str) -> dict[str, DirectoryUser]: + from app.db.models import Group, GroupMember, User + + rows = (await session.execute( + select(User).join(GroupMember, GroupMember.user_id == User.id).join(Group, Group.id == GroupMember.group_id) + .where(Group.name == group, User.disabled.is_(False)))).scalars() + return {u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows} + + +async def _people(session: AsyncSession, items: Iterable[tuple[str, str]]) -> Pool: + """一组(种类, 名字)展开成池:人按名录认(停用的、名录里没有的不列),组列出组本身、再展开成员。""" + from app.db.models import Group, User + + pool = Pool() + items = list(items) + names = sorted({n for k, n in items if k == "user" and n}) + if names: + for u in (await session.execute(select(User).where(User.username.in_(names), User.disabled.is_(False))))\ + .scalars(): + pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False) + groups = sorted({n for k, n in items if k == "group" and n}) + if groups: + for g in (await session.execute(select(Group).where(Group.name.in_(groups)))).scalars(): + pool.groups[g.name] = DirectoryGroup(g.name, g.label or g.name) + pool.users.update(await _members(session, g.name)) + return pool + + +async def _everyone(session: AsyncSession) -> Pool: + from app.db.models import Group, User + + pool = Pool(open=True) + for u in (await session.execute(select(User).where(User.disabled.is_(False)))).scalars(): + pool.users[u.username] = DirectoryUser(u.username, u.display_name or u.username, False) + for g in (await session.execute(select(Group))).scalars(): + pool.groups[g.name] = DirectoryGroup(g.name, g.label or g.name) + return pool + + +async def app_pool(session: AsyncSession, app: Any) -> Pool: + """能用这个应用的人:使用及以上的授权(人、组展开成员),加上管理它的人(管理员、建它的人)。授权给了所有人的,池就是 + 全部启用的人(open)。按 apps/access 的口径:搭建者没有授权时只能查看全部实例,不算「能用」的候选。""" + from app.apps.access import RANK, grants_of + from app.db.models import User + + grants = [g for g in await grants_of(session, app.id) if RANK.get(g.role, 0) >= RANK["use"]] + if any(g.principal_kind == "everyone" for g in grants): + return await _everyone(session) + admins = (await session.execute(select(User.username).where(User.role == "admin"))).scalars() + items = [(g.principal_kind, g.principal) for g in grants] + [("user", a) for a in admins] + if getattr(app, "created_by", None): + items.append(("user", app.created_by)) + return await _people(session, items) + + +async def field_pool(session: AsyncSession, app: Any, bindings: dict[str, Any] | None, field: Any) -> Pool: + """表单里一个选人控件的候选池(按字段的 x-principal,缺省能用这个应用的人)。bindings 是这次用到的应用版本的绑定。""" + pool = pool_of(field) + if pool.startswith(POOL_GROUP): + name = pool[len(POOL_GROUP):].strip() + return Pool(users=await _members(session, name)) + if pool.startswith(POOL_SLOT): + bound = ((bindings or {}).get("slots") or {}).get(pool[len(POOL_SLOT):].strip()) + items = [(str(b.get("kind") or "user"), str(b.get("name") or "")) for b in bound or [] if isinstance(b, dict)] + if any(k == "everyone" for k, _ in items): + return await _everyone(session) + return await _people(session, items) + return await app_pool(session, app) + + +async def review_pool(session: AsyncSession) -> Pool: + """个人应用提交审核能交给谁:启用的搭建者和管理员(同 blueprints/personal._review_targets 的规则)。""" + from app.db.models import User + + rows = (await session.execute(select(User).where(User.role.in_(("builder", "admin")), + User.disabled.is_(False)))).scalars() + return Pool(users={u.username: DirectoryUser(u.username, u.display_name or u.username, False) for u in rows}) + + +def principal_fields(form: Any) -> dict[str, dict[str, Any]]: + """表单里的选人控件:{字段名: 字段}。""" + props = form.get("properties") if isinstance(form, dict) else None + return {name: f for name, f in (props or {}).items() if isinstance(f, dict) and f.get("x-widget") == "principal"} + + +#: 选人控件的值不在候选池里时表单错误的 code(apps/spec.FORM_ERROR_CODES 的 principal) +PRINCIPAL_ERROR_CODE = "principal" + + +async def principal_errors(session: AsyncSession, app: Any, bindings: dict[str, Any] | None, form: Any, + values: Any, mode: str) -> list[dict[str, str]]: + """开了登录时按候选池核对表单里选人控件的值:不在池里的按表单错误交回(code=principal),话写成「「x」不在可选的人里」。 + 关登录不核对:没有用户表,签批记的是各自填的署名。没填的不在这里报(必填由表单校验报)。""" + if mode == "off" or not isinstance(values, dict): + return [] + out: list[dict[str, str]] = [] + for name, f in principal_fields(form).items(): + value = values.get(name) + if value in (None, "") or not isinstance(value, str): + continue + pool = await field_pool(session, app, bindings, f) + if not pool.has(value.strip()): + hint = ":单点登录的人要先登录一次才进名录" if mode == "oidc" else ",请从候选中选择" + out.append({"path": name, "code": PRINCIPAL_ERROR_CODE, "message": f"「{value.strip()}」不在可选的人里{hint}"}) + return out diff --git a/backend/app/main.py b/backend/app/main.py index 657c917a..b2647a1b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -238,6 +238,7 @@ for router, guard in ( (identity_api.router, _ADMIN), # 选人用的名录(用户名、显示名、是否停用、组名):岗位绑定、应用授权由搭建者做,只读 (identity_api.directory_router, _BUILDER), + (identity_api.candidates_router, _USER), # 能力登记、端点:搭建者只读,改动在接口里要管理员 (capabilities_api.router, _BUILDER), # 普通用户建应用:个人应用的接口共用 /api/apps 前缀,要在 apps_api 之前登记;蓝图助手共用 /api/copilot 前缀, diff --git a/backend/app/process/tasks.py b/backend/app/process/tasks.py index 6ec167af..818b4f76 100644 --- a/backend/app/process/tasks.py +++ b/backend/app/process/tasks.py @@ -406,6 +406,22 @@ def _check_hash(card: dict[str, Any], given: Any, *, approving: bool) -> None: status_code=422, code="payload_hash_mismatch") +async def _check_principals(session: AsyncSession, task: Task, principal: Any, values: dict[str, Any]) -> None: + """待办表单里的选人控件:开了登录时按候选池核对(同入口表单,auth/directory.principal_errors)。不属于应用的待办 + (试运行、画布)没有池,不核对。""" + from app.auth.directory import principal_errors + + if not task.app_id or getattr(principal, "mode", "off") == "off": + return + app = await session.get(App, task.app_id) + if app is None: + return + bindings = await session.scalar(select(AppVersion.bindings).where( + AppVersion.app_id == task.app_id, AppVersion.version == task.app_version)) if task.app_version else None + if errors := await principal_errors(session, app, bindings, task.form, values, principal.mode): + raise FormInvalid(errors) + + def _validated(form: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: from app.apps.forms import validate_form @@ -491,6 +507,8 @@ async def respond(task_id: str, principal: Any, *, decision: Any, values: dict[s rows = (await _assignments(session, [task.id])).get(task.id, []) mine = _pick(task, rows, principal) response, verdict = _engine_response(task, approval, decision, values, note, payload_hash) + if task.form and verdict in ("approve", "submit") and values: + await _check_principals(session, task, principal, values) response = await _bind_files(session, task, principal, response) now = utcnow() marked = await session.execute( diff --git a/backend/tests/test_directory_candidates.py b/backend/tests/test_directory_candidates.py new file mode 100644 index 00000000..c8fe3a36 --- /dev/null +++ b/backend/tests/test_directory_candidates.py @@ -0,0 +1,266 @@ +"""使用者选人:按用途给的候选池 + 开登录时服务端核对(docs/design/next-round/05-已知遗留.md (b))。 + +以前使用者在入口表单、待办表单的选人控件里没有候选(名录只给搭建者),服务端也不核对:填错的名字会把待办派给一个不存在 +的人,没人能处理,也不报错。现在: +- GET /api/directory/candidates 按用途给候选池,只有用户名和显示名,停用的不列;form 用途缺省是能用这个应用的人(使用 + 及以上的授权展开组成员,加上管理员和建应用的人),字段可以用 x-principal 声明别的池;应用授权给了所有人时不带前缀不列; + 调用的人不能用这个应用时 403;review 用途是启用的搭建者和管理员; +- 开了登录时,入口表单和待办表单的选人控件按同一个池核对,不在池里 422(errors 的 code=principal);关登录照旧手填; +- /api/directory 仍然只给搭建者。 + +名字都是虚构的。 +""" +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from sqlalchemy import delete, select + +from app.apps.forms import validate_form_schema +from app.core.artifact_store import graph_hash +from app.db.base import SessionLocal, utcnow +from app.db.models import App, AppGrant, Group, GroupMember, Run, Task, User, Workflow, WorkflowVersion +from app.engine.runner import run_manager +from tests.fakes import auth_env + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def chain(*nodes): + return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + + +PICK = {"type": "string", "title": "审批人", "x-widget": "principal"} + +GRAPH = chain( + node("start", "input", "开始", fields=[{"name": "approver"}, {"name": "reviewer"}]), + node("review", "human", "复核", mode="input", title="请指定复核人", + form={"type": "object", "properties": {"reviewer": {**PICK, "title": "复核人"}}}), + node("out", "output", "成果", fields=[{"name": "r", "value": "{{ input.approver }}"}])) + + +async def _workflow() -> tuple[str, str]: + async with SessionLocal() as session: + wf = Workflow(name=f"选人-{uuid.uuid4().hex[:6]}", graph=GRAPH, status="published", published_version=1, + version=1) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=GRAPH, graph_hash=graph_hash(GRAPH), + level="published", published_at=utcnow())) + await session.commit() + return wf.id, graph_hash(GRAPH) + + +def _spec(wf: str, hash_: str, approver: dict | None = None) -> dict: + return { + "format": "agentlab.app/1", "name": "选人", + "pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": hash_, "use": "entry"}], + "entries": [{"id": "apply", "label": "发起", "pin": "main", "form": {"$ref": "#/forms/apply"}, + "map": {"approver": "form.approver"}}], + "forms": {"apply": {"type": "object", "properties": {"approver": approver or PICK}}}, + "pages": [{"id": "home", "title": "首页", "body": [ + {"id": "new", "type": "entry_button", "props": {"entry": "apply"}}]}], + } + + +async def _new_app(root, spec: dict) -> str: + """root 建一个应用并发布,授权使用给 uma、停用的 dan 和组「财务」。""" + res = await root.post("/api/apps", json={"name": f"选人-{uuid.uuid4().hex[:4]}", "spec": spec}) + assert res.status_code == 201, res.text + app_id = res.json()["id"] + published = (await root.post(f"/api/apps/{app_id}/publish", json={})).json() + assert published["ok"], published + async with SessionLocal() as session: + session.add_all([AppGrant(app_id=app_id, principal_kind="user", principal="uma", role="use"), + AppGrant(app_id=app_id, principal_kind="user", principal="dan", role="use"), + AppGrant(app_id=app_id, principal_kind="group", principal="财务", role="use")]) + await session.commit() + return app_id + + +@pytest.fixture +async def world(): + """开账号密码登录:管理员 root(建应用)、搭建者 bob、使用者 uma(授权使用)、ulf(没授权)、停用的 dan(授权使用); + 组「财务」(fay、fin),整组授权使用。""" + await auth_env.reset() + root = await auth_env.turn_on_local("root") + try: + await auth_env.make_user("bob", "builder") + await auth_env.make_user("uma", "user") + await auth_env.make_user("ulf", "user") + await auth_env.make_user("dan", "user", disabled=True) + fay = await auth_env.make_user("fay", "user") + fin = await auth_env.make_user("fin", "user") + async with SessionLocal() as session: + for username, shown in (("uma", "乌玛"), ("fay", "费伊"), ("bob", "鲍勃")): + user = (await session.execute(select(User).where(User.username == username))).scalar_one() + user.display_name = shown + group = Group(name="财务", label="财务组", source="local") + session.add(group) + await session.flush() + session.add_all([GroupMember(group_id=group.id, user_id=fay.id), GroupMember(group_id=group.id, user_id=fin.id)]) + await session.commit() + wf, hash_ = await _workflow() + app_id = await _new_app(root, _spec(wf, hash_)) + yield {"root": root, "app_id": app_id, "wf": wf, "hash": hash_} + finally: + await root.aclose() + async with SessionLocal() as session: + for model in (AppGrant, App): + await session.execute(delete(model)) + await session.commit() + await auth_env.reset() + + +def _params(app_id: str, **more) -> dict: + return {"purpose": "form", "app_id": app_id, "entry": "apply", "field": "approver", **more} + + +async def test_users_get_the_people_who_can_use_the_app_and_only_their_names(world): + uma = await auth_env.login("uma") + r = await uma.get("/api/directory/candidates", params=_params(world["app_id"])) + assert r.status_code == 200, r.text + body = r.json() + assert [u["username"] for u in body["users"]] == ["fay", "fin", "root", "uma"] + assert all(set(u) == {"username", "display_name"} for u in body["users"]), "不给停用、角色、邮箱" + assert {u["username"]: u["display_name"] for u in body["users"]}["uma"] == "乌玛" + assert body["groups"] == [{"name": "财务", "label": "财务组"}] and body["truncated"] is False + # 前缀按用户名或显示名筛 + assert [u["username"] for u in (await uma.get("/api/directory/candidates", + params=_params(world["app_id"], q="费"))).json()["users"]] == ["fay"] + # 名录整份仍然只给搭建者 + assert (await uma.get("/api/directory")).status_code == 403 + # 不能用这个应用的人拿不到它的候选 + ulf = await auth_env.login("ulf") + assert (await ulf.get("/api/directory/candidates", params=_params(world["app_id"]))).status_code == 403 + for c in (uma, ulf): + await c.aclose() + + +async def test_an_app_granted_to_everyone_needs_a_prefix(world): + async with SessionLocal() as session: + session.add(AppGrant(app_id=world["app_id"], principal_kind="everyone", principal="", role="use")) + await session.commit() + ulf = await auth_env.login("ulf") + body = (await ulf.get("/api/directory/candidates", params=_params(world["app_id"]))).json() + assert body["users"] == [] and body["needs_query"] is True + found = (await ulf.get("/api/directory/candidates", params=_params(world["app_id"], q="u"))).json() + assert [u["username"] for u in found["users"]] == ["ulf", "uma"], "停用的 dan 不列" + capped = (await ulf.get("/api/directory/candidates", params=_params(world["app_id"], q="f", limit=1))).json() + assert len(capped["users"]) == 1 and capped["truncated"] is True + await ulf.aclose() + + +async def test_review_candidates_are_builders_and_admins(world): + uma = await auth_env.login("uma") + body = (await uma.get("/api/directory/candidates", params={"purpose": "review"})).json() + assert [u["username"] for u in body["users"]] == ["bob", "root"] and body["groups"] == [] + assert (await uma.get("/api/directory/candidates", params={"purpose": "nope"})).status_code == 422 + await uma.aclose() + + +async def test_starting_with_a_name_outside_the_pool_is_refused(world): + uma = await auth_env.login("uma") + url = f"/api/apps/{world['app_id']}/entries/apply" + bad = await uma.post(url, json={"values": {"approver": "wangwu"}}) + assert bad.status_code == 422, bad.text + [error] = bad.json()["errors"] + assert (error["path"], error["code"]) == ("approver", "principal") + assert error["message"].startswith("「wangwu」不在可选的人里") + disabled = await uma.post(url, json={"values": {"approver": "dan"}}) + assert disabled.status_code == 422 and disabled.json()["errors"][0]["code"] == "principal" + for ok in ("fay", "财务"): + good = await uma.post(url, json={"values": {"approver": ok}}) + assert good.status_code == 201, good.text + await uma.aclose() + + +async def test_a_task_form_is_checked_against_the_same_pool(world): + uma = await auth_env.login("uma") + started = await uma.post(f"/api/apps/{world['app_id']}/entries/apply", json={"values": {"approver": "fay"}}) + assert started.status_code == 201, started.text + run_id = started.json()["run_id"] + task = None + for _ in range(200): + async with SessionLocal() as session: + task = (await session.execute(select(Task).where(Task.run_id == run_id, Task.status == "pending")))\ + .scalars().first() + if task is not None: + break + await asyncio.sleep(0.05) + assert task is not None + cands = await uma.get("/api/directory/candidates", params={"purpose": "form", "task_id": task.id, + "field": "reviewer"}) + assert cands.status_code == 200 and "fin" in [u["username"] for u in cands.json()["users"]] + root = world["root"] + bad = await root.post(f"/api/tasks/{task.id}/respond", json={"decision": "submit", "values": {"reviewer": "wangwu"}}) + assert bad.status_code == 422, bad.text + assert bad.json()["errors"][0]["code"] == "principal" + good = await root.post(f"/api/tasks/{task.id}/respond", json={"decision": "submit", "values": {"reviewer": "fin"}}) + assert good.status_code == 200, good.text + await uma.aclose() + + +async def test_a_declared_pool_narrows_the_candidates(world): + """x-principal 声明了 group:财务:候选只有组里的人,提交组外的人也拒。""" + app_id = await _new_app(world["root"], _spec(world["wf"], world["hash"], + {**PICK, "x-principal": {"pool": "group:财务"}})) + uma = await auth_env.login("uma") + body = (await uma.get("/api/directory/candidates", params=_params(app_id))).json() + assert [u["username"] for u in body["users"]] == ["fay", "fin"] and body["groups"] == [] + refused = await uma.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"approver": "uma"}}) + assert refused.status_code == 422 and refused.json()["errors"][0]["code"] == "principal" + await uma.aclose() + + +async def test_login_off_takes_any_name(): + await auth_env.reset() + wf, hash_ = await _workflow() + from httpx import ASGITransport, AsyncClient + + from app.main import app as asgi + + async with AsyncClient(transport=ASGITransport(app=asgi), base_url="http://test") as c: + res = await c.post("/api/apps", json={"name": f"关登录-{uuid.uuid4().hex[:4]}", "spec": _spec(wf, hash_)}) + app_id = res.json()["id"] + assert (await c.post(f"/api/apps/{app_id}/publish", json={})).json()["ok"] + started = await c.post(f"/api/apps/{app_id}/entries/apply", json={"values": {"approver": "随手填的名字"}}) + assert started.status_code == 201, started.text + async with SessionLocal() as session: + await session.execute(delete(Run).where(Run.id == started.json()["run_id"])) + await session.execute(delete(App).where(App.id == app_id)) + await session.commit() + + +def test_x_principal_is_checked_in_the_form_schema(): + ok = {"type": "object", "properties": {"a": {**PICK, "x-principal": {"pool": "slot:finance"}}}} + assert validate_form_schema(ok) == [] + bad = {"type": "object", "properties": { + "a": {**PICK, "x-principal": {"pool": "everyone"}}, + "b": {"type": "string", "x-principal": {"pool": "app"}}, + "c": {**PICK, "x-principal": "app"}}} + problems = validate_form_schema(bad) + assert len(problems) == 3, problems + assert any("「b」不是选人控件" in p for p in problems) + + +async def test_users_table_is_untouched_by_candidates(world): + """候选接口只读:不建用户、不改名录。""" + async with SessionLocal() as session: + before = len((await session.execute(select(User.id))).all()) + uma = await auth_env.login("uma") + await uma.get("/api/directory/candidates", params=_params(world["app_id"], q="zz")) + await uma.aclose() + async with SessionLocal() as session: + assert len((await session.execute(select(User.id))).all()) == before From 3293ac0b5811d12f264652a1a24a42a1e493d2b6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:34:18 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=E8=87=AA=E7=94=B1=E5=BD=A2=E8=93=9D?= =?UTF-8?q?=E5=9B=BE=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E6=8C=89?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E7=9A=84=E5=9B=BE=E5=88=97=E5=87=BA=E3=80=8C?= =?UTF-8?q?=E8=B0=81=E6=9D=A5=E5=81=9A=E3=80=8D=EF=BC=8C=E5=8F=AA=E5=AE=9A?= =?UTF-8?q?=E7=94=B1=E8=B0=81=E5=81=9A=EF=BC=9B=E6=B2=A1=E7=BB=91=E6=97=B6?= =?UTF-8?q?=20bp.people=5Funbound=20=E6=8C=87=E5=90=91=E9=82=A3=E5=BC=A0?= =?UTF-8?q?=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:自由形的骨架就是导入的图,图里派给岗位的人工节点、没登记查询的写入在应用里都要绑人,可蓝图上没有「谁来做」卡片。 第一次发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话叫人「在「谁来做」里给这一步选人」,卡片却不存在;这时 应用还没建,也没法去应用编辑器绑,只能绕开蓝图(05-已知遗留 (d))。 做了什么: - freeform 的 cards 加 people(「可选:只定每一步由谁做;会签、时限、超时在画布上改」)。freeform.people_rows 按 tree.slot_refs 列岗位行(同一个岗位用在几处合成一行,label 用节点标题拼、detail 写全每一处用途,path 是 /people//who);写入能力没登记 查询时加一行「写入结果不明时谁去核实」(path /writeback/reconciler);发起人、表达式、外部系统回调的步骤列成只读行。 blueprint_out 给出 derived_people,界面照这一份画。 - 存储复用现有路径:岗位绑定存在 spec.people[](id 就是岗位名、只写 who),_common.slot_of / bindings_of 原样生效;核实人存在 /writeback/reconciler。needs_reconciler 对自由形按图里的写入算(with_graph 把蓝图存的图带给模板函数)。 - bp_set 对自由形的 people 只收 who(只收人和组,只由用户定),改会签、时限、超时、加减步骤报 bp.template_mismatch「在画布上 改」。apply_bp_op / blueprint_issues 新增 graph 参数,卡片接口、蓝图检查、试运行、实例化、蓝图助手都把蓝图存的图传下去。 - 检查:派生岗位、核实人没绑报 bp.people_unbound(试运行 warning、发布 error),路径就是那一行,带 slot;图里已经没有的岗位、 卡片上还留着的绑定报 warning,不静默删除。发布单照核实人的办法,去掉同一件事的 app.slot_unbound(slots.)和 app.reconciler_unbound。 偏离:岗位行不让助手代填(同「结果不明时谁去核实」,派活给谁是人的决定)。 测试:test_publish_plan 新增「财务审批(岗位)+ 写回(没登记查询)」建自由形蓝图一条(两行、不选人时只有 bp.people_unbound、 改会签被拒、选好人后绑到 finance 和核实岗位、发布单不再挡);老的两条按新说法改;test_blueprint_templates 新增 people_rows 的合并、升级岗位、只读行、残留绑定提醒。 --- backend/app/api/copilot_blueprint.py | 2 +- backend/app/blueprints/instantiate.py | 2 +- backend/app/blueprints/ops.py | 94 ++++++++++++++++++-- backend/app/blueprints/publish_plan.py | 14 ++- backend/app/blueprints/store.py | 20 +++-- backend/app/blueprints/templates/_common.py | 19 ++++ backend/app/blueprints/templates/freeform.py | 68 +++++++++++++- backend/app/blueprints/trial.py | 2 +- backend/tests/test_blueprint_templates.py | 34 +++++++ backend/tests/test_publish_plan.py | 65 +++++++++++++- 10 files changed, 297 insertions(+), 23 deletions(-) diff --git a/backend/app/api/copilot_blueprint.py b/backend/app/api/copilot_blueprint.py index 8068edc0..32cc2064 100644 --- a/backend/app/api/copilot_blueprint.py +++ b/backend/app/api/copilot_blueprint.py @@ -293,7 +293,7 @@ async def blueprint_stream(payload: BlueprintStreamIn, session: AsyncSession = D system = "\n\n".join(part for part in ( BLUEPRINT_SYSTEM_PROMPT, _templates_text(personal=personal), await looks.overview(), BLUEPRINT_PROTOCOL_PROMPT, looks.guide(), BLUEPRINT_LOOK_WHEN_PROMPT) if part) - missing = [i for i in blueprint_issues(spec, for_="trial") if i["level"] == "error"] + missing = [i for i in blueprint_issues(spec, for_="trial", graph=row.graph) if i["level"] == "error"] human = copilot._with_history(BLUEPRINT_REQUEST_PROMPT.format( spec=json.dumps(spec, ensure_ascii=False), instruction=instruction, issues=BLUEPRINT_ISSUES_PROMPT.format(lines="\n".join(f"- {i['path']} {i['message']}" for i in missing)) diff --git a/backend/app/blueprints/instantiate.py b/backend/app/blueprints/instantiate.py index d7e966ab..1cb96967 100644 --- a/backend/app/blueprints/instantiate.py +++ b/backend/app/blueprints/instantiate.py @@ -198,7 +198,7 @@ def instantiate(blueprint: dict[str, Any], *, filled: dict[str, Any] | None = No spec = _spec_of(blueprint) ref = spec.get("template") or {} template = get_template(str(ref.get("id") or "")) - problems = [i for i in blueprint_issues(spec, for_="trial") if i["level"] == "error"] + problems = [i for i in blueprint_issues(spec, for_="trial", graph=graph) if i["level"] == "error"] if template is None: from app.apps.spec import empty_spec diff --git a/backend/app/blueprints/ops.py b/backend/app/blueprints/ops.py index a626fed5..59d1aaa4 100644 --- a/backend/app/blueprints/ops.py +++ b/backend/app/blueprints/ops.py @@ -520,8 +520,47 @@ def _container(spec: dict[str, Any], tokens: list[str]) -> tuple[dict[str, Any], raise BpRefused(f"不能改这个位置:/{'/'.join(tokens)}") -def _set(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> None: +def is_freeform(spec: dict[str, Any]) -> bool: + from app.blueprints.templates._common import is_freeform as imports_graph + + return imports_graph(spec) + + +#: 自由形的「谁来做」只定由谁做(05-已知遗留 (d)、遗留-Q5) +FREEFORM_PEOPLE_ONLY_WHO = "自由形蓝图的「谁来做」只定每一步由谁做;会签、时限、超时、加减步骤在画布上改" +FREEFORM_PEOPLE_USER = "「谁来做」要由你来定:派活给谁是人的决定,助手不替你选" + + +def _freeform_who(spec: dict[str, Any], tokens: list[str], value: Any, source: str, graph: Any) -> None: + """自由形的 /people//who:i 是按导入的图列出的第几个岗位(freeform.people_rows 的岗位行,界面照那一份的 path 写), + 绑定存进 spec.people[](id 就是岗位名、只有 who)。只收人和组:岗位、发起人在图上已经写定了。""" + from app.blueprints.templates import freeform + from app.blueprints.templates._common import with_graph + + if len(tokens) != 3 or tokens[2] != "who": + raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch") + if source != "user": + raise BpRefused(FREEFORM_PEOPLE_USER) + if graph is None: + raise BpRefused("这张蓝图还没有导入的图,没法按图定谁来做") + rows = [r for r in freeform.people_rows(with_graph(spec, graph)) if r["kind"] == "slot"] + slot = rows[_index(tokens[1], len(rows))]["slot"] + checked = _checked("people", "who", value, spec) + if checked and any(p["kind"] not in ("user", "group") for p in checked): + raise BpRefused("这里只选人或组:岗位、发起人在图上已经定了") + entries = spec.setdefault("people", []) + entry = next((e for e in entries if e.get("id") == slot), None) + if entry is None: + entry = {"id": slot} + entries.append(entry) + entry["who"] = _item(checked, source) + + +def _set(spec: dict[str, Any], tokens: list[str], value: Any, source: str, graph: Any = None) -> None: card = tokens[0] + if card == "people" and is_freeform(spec): + _freeform_who(spec, tokens, value, source, graph) + return if card == "access": if len(tokens) != 2 or tokens[1] not in ACCESS_KINDS: raise BpRefused("「谁能看」只有使用、查看全部、管理三档") @@ -577,6 +616,8 @@ def _set(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> No def _add(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> None: card = tokens[0] + if card == "people" and is_freeform(spec): + raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch") if card == "access": if len(tokens) not in (2, 3) or tokens[1] not in ACCESS_KINDS: raise BpRefused("「谁能看」只有使用、查看全部、管理三档") @@ -598,8 +639,13 @@ def _add(spec: dict[str, Any], tokens: list[str], value: Any, source: str) -> No entries.insert(at, _new_entry(card, value, source, spec)) -def _remove(spec: dict[str, Any], tokens: list[str], source: str) -> None: +def _remove(spec: dict[str, Any], tokens: list[str], source: str, graph: Any = None) -> None: card = tokens[0] + if card == "people" and is_freeform(spec): + if len(tokens) != 3: + raise BpRefused(FREEFORM_PEOPLE_ONLY_WHO, code="bp.template_mismatch") + _freeform_who(spec, tokens, None, source, graph) + return if card == "access": if len(tokens) != 3 or tokens[1] not in ACCESS_KINDS: raise BpRefused("「谁能看」要写明删哪一档的第几个") @@ -655,8 +701,9 @@ def _template(spec: dict[str, Any], op: BpOp, source: str) -> None: prefill(template.id, spec) -def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dict[str, Any]: - """把一个 bp_* 操作落到蓝图上,交回新的蓝图(不改入参)。source 是 user / assistant。不合规时抛 BpRefused。""" +def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str, graph: Any = None) -> dict[str, Any]: + """把一个 bp_* 操作落到蓝图上,交回新的蓝图(不改入参)。source 是 user / assistant。不合规时抛 BpRefused。 + graph 是蓝图存的图:自由形的「谁来做」按它列岗位(没给时那张卡片改不了)。""" if source not in BP_SOURCES: raise BpRefused(f"操作的来源只能是用户或助手,当前为「{source}」") if not isinstance(op, dict) or op.get("op") not in BP_OPS: @@ -677,11 +724,11 @@ def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dic if source != "user" and (only := next((k for k in USER_ONLY if tuple(tokens[:len(k)]) == k), None)): raise BpRefused(_USER_ONLY_TEXT[only[-1]]) if parsed.op == "bp_set": - _set(out, tokens, parsed.value, source) + _set(out, tokens, parsed.value, source, graph) elif parsed.op == "bp_add": _add(out, tokens, parsed.value, source) else: - _remove(out, tokens, source) + _remove(out, tokens, source, graph) try: return parse_blueprint(out).model_dump(mode="json", by_alias=True) except ValidationError as e: @@ -708,9 +755,9 @@ def _value(item: Any) -> Any: return item.get("value") if isinstance(item, dict) else None -def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]]: +def blueprint_issues(spec: dict[str, Any], *, for_: str, graph: Any = None) -> list[dict[str, Any]]: """蓝图检查(§3.13 bp.*):for_ = trial(缺项挡试运行)/ publish(待确认项挡发布)/ personal。 - 每条 {code, level, path, message}。""" + 每条 {code, level, path, message}。graph 是蓝图存的图:自由形的「谁来做」按它查(没给时不查这一张)。""" from app.blueprints.templates import get_template if for_ not in ISSUE_PURPOSES: @@ -743,7 +790,10 @@ def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]] _data_issues(spec, "data" in required, add) if "cadence" in required and _value(spec.get("cadence")) is None: add("bp.missing", "/cadence", "「多久更新」还没定:每天、每周还是每月,几点,统计哪一期") - if "people" in used: + if "people" in used and template.imports_graph: + if graph is not None and for_ != "personal": + out += _freeform_people_issues(spec, graph, publish) + elif "people" in used: _people_issues(spec, "people" in required, publish, add) if "fields" in used: _field_issues(spec, "fields" in required, add) @@ -836,6 +886,32 @@ def _people_issues(spec: dict[str, Any], required: bool, publish: bool, add: Cal f"「{label}」的条件拿「{threshold['field']}」比大小,它要是数字字段") +def _freeform_people_issues(spec: dict[str, Any], graph: Any, publish: bool) -> list[dict[str, Any]]: + """自由形的「谁来做」(freeform.people_rows):岗位、核实人没绑的报 bp.people_unbound(试运行 warning:待办派给试运行的 + 人自己;发布 error),路径就是那一行的 path,带上 slot(发布单据此去掉同一件事的 app.slot_unbound / app.reconciler_unbound)。 + 图里已经没有的岗位、卡片上还留着绑定的,照实提醒(warning),不静默删掉。""" + from app.blueprints.templates import freeform + from app.blueprints.templates._common import with_graph + + out: list[dict[str, Any]] = [] + level = "error" if publish else "warning" + rows = freeform.people_rows(with_graph(spec, graph)) + for row in rows: + if row["kind"] not in ("slot", "reconciler") or row["who"]: + continue + message = (f"{row['label']}还没定:{row['detail']},写入外部系统的结果不明时要有人去目标系统里核实" + if row["kind"] == "reconciler" else f"{row['label']}还没定谁来处理({row['detail']})") + out.append({"code": "bp.people_unbound", "level": level, "path": row["path"], "message": message, + "slot": row["slot"]}) + known = {r["slot"] for r in rows if r["kind"] == "slot"} + for step in spec.get("people") or []: + slot = str(step.get("id") or "") + if slot and slot not in known and _value(step.get("who")): + out.append({"code": "bp.template_mismatch", "level": "warning", "path": "/people", + "message": f"岗位「{slot}」在导入的图里已经没有了:卡片上留着的这条绑定不会生效"}) + return out + + def _field_issues(spec: dict[str, Any], required: bool, add: Callable[..., None]) -> None: fields = spec.get("fields") or [] if required and not fields: diff --git a/backend/app/blueprints/publish_plan.py b/backend/app/blueprints/publish_plan.py index cdc1749b..b3c7a289 100644 --- a/backend/app/blueprints/publish_plan.py +++ b/backend/app/blueprints/publish_plan.py @@ -75,6 +75,8 @@ TRIAL_REJECTED = "plan.trial_rejected" TRIAL_DIFFERS = "plan.trial_differs" #: 蓝图检查:写回的结果不明时没人核实(blueprints/ops.blueprint_issues) RECONCILER_UNBOUND = "bp.reconciler_unbound" +#: 蓝图检查:「谁来做」有一步还没定由谁处理;自由形的带着 slot(哪个岗位,核实人是 apps/derive.RECONCILER) +PEOPLE_UNBOUND = "bp.people_unbound" ADMIN_REQUIRED = "plan.admin_required" DOWNGRADE_ADMIN = "plan.downgrade_admin" PREFLIGHT = "plan.preflight" @@ -239,8 +241,10 @@ async def _simulated_pins(session: AsyncSession, spec: dict[str, Any], target: _ def bindings_of(bp: Blueprint) -> dict[str, Any]: """岗位绑定:「谁来做」卡片上写了具体的人或组的,按模板定的岗位名绑上(templates/_common.bindings_of)。""" from app.blueprints.templates._common import bindings_of as from_cards + from app.blueprints.templates._common import with_graph - return from_cards(bp.spec or {}) + # 带上蓝图存的图:自由形的核实人要不要绑按导入的图算(05-已知遗留 (d)) + return from_cards(with_graph(bp.spec or {}, bp.graph)) def grant_items(bp: Blueprint) -> list[tuple[str, str, str]]: @@ -354,6 +358,14 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l issues = [i for i in issues if i.get("code") != "app.reconciler_unbound" and not (i.get("code") == "app.slot_unbound" and i.get("path") == f"slots.{RECONCILER}")] + if unbound := {str(i["slot"]) for i in out if i.get("code") == PEOPLE_UNBOUND and i.get("slot")}: + # 自由形的「谁来做」:岗位、核实人没绑,蓝图检查已经指到卡片上那一行(bp.people_unbound 带着 slot),应用检查说的是 + # 同一件事,不再重复列(同上面核实岗位的办法) + from app.apps.derive import RECONCILER + + issues = [i for i in issues if not (i.get("code") == "app.slot_unbound" + and str(i.get("path") or "").removeprefix("slots.") in unbound) + and not (i.get("code") == "app.reconciler_unbound" and RECONCILER in unbound)] out += [{**i, "source": "app"} for i in issues] # 授权:给的人和组要在名录里(同授权接口) _, problem = await grants_problem(session, grant_items(bp)) diff --git a/backend/app/blueprints/store.py b/backend/app/blueprints/store.py index a3ef2fc4..700711bc 100644 --- a/backend/app/blueprints/store.py +++ b/backend/app/blueprints/store.py @@ -183,7 +183,7 @@ async def apply_ops_and_save(session: AsyncSession, blueprint: Any, ops: list[di try: op = await _verified_reuse(session, raw) before = _at(spec, op.get("path")) - spec = apply_bp_op(spec, op, source=source) + spec = apply_bp_op(spec, op, source=source, graph=blueprint.graph) except BpRefused as e: if not partial: raise @@ -313,7 +313,7 @@ def blueprint_issues_of(blueprint: Any) -> list[dict[str, Any]]: from app.plain import with_plain spec = blueprint.spec or {} - issues = blueprint_issues(spec, for_="personal" if blueprint.scope == "personal" else "publish") + issues = blueprint_issues(spec, for_="personal" if blueprint.scope == "personal" else "publish", graph=blueprint.graph) for joint_id, state in (blueprint.joints or {}).items(): if isinstance(state, dict) and state.get("status") in ("pending", "refused"): issues.append({"code": "tpl.joint_unfilled" if state["status"] == "pending" else "tpl.joint_refused", @@ -375,11 +375,19 @@ async def blueprint_view(session: AsyncSession, blueprint: Any) -> dict[str, Any return blueprint_out(blueprint, extra=await data_source_issues(session, blueprint)) +def _derived_people(blueprint: Any) -> list[dict[str, Any]]: + from app.blueprints.templates import freeform + from app.blueprints.templates._common import with_graph + + return freeform.people_rows(with_graph(blueprint.spec or {}, blueprint.graph)) + + def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, Any]] | None = None) -> dict[str, Any]: """一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态和模板声明的说法、类型、分组 (JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。reconciler_needed:写回卡片上要不要 - 摆「结果不明时谁去核实」(目标能力没登记查询,templates/_common.needs_reconciler),界面不自己判。""" - from app.blueprints.templates._common import needs_reconciler + 摆「结果不明时谁去核实」(目标能力没登记查询,templates/_common.needs_reconciler),界面不自己判。derived_people:自由形 + 按导入的图列出的「谁来做」(templates/freeform.people_rows,05-已知遗留 (d)),别的模板为空。""" + from app.blueprints.templates._common import is_freeform, needs_reconciler, with_graph 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)} @@ -389,7 +397,9 @@ def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, "template_version": blueprint.template_version, "source": blueprint.source or {}, "conversation_id": blueprint.conversation_id, "workflow_id": blueprint.workflow_id, "app_id": blueprint.app_id, "last_trial_run_id": blueprint.last_trial_run_id, "graph_hash": blueprint.graph_hash, "joints": joints, - "copied_from": blueprint.copied_from, "reconciler_needed": needs_reconciler(blueprint.spec or {}), + "copied_from": blueprint.copied_from, + "reconciler_needed": needs_reconciler(with_graph(blueprint.spec or {}, blueprint.graph)), + "derived_people": _derived_people(blueprint) if is_freeform(blueprint.spec or {}) else None, "created_at": blueprint.created_at.isoformat() if blueprint.created_at else None, "updated_at": blueprint.updated_at.isoformat() if blueprint.updated_at else None, } diff --git a/backend/app/blueprints/templates/_common.py b/backend/app/blueprints/templates/_common.py index 589c221a..20ee6e0c 100644 --- a/backend/app/blueprints/templates/_common.py +++ b/backend/app/blueprints/templates/_common.py @@ -78,6 +78,19 @@ def context(bp: dict[str, Any]) -> dict[str, Any]: return ctx if isinstance(ctx, dict) else {} +def with_graph(spec: dict[str, Any], graph: Any) -> dict[str, Any]: + """只有卡片(Blueprint.spec)、没有实例化上下文的地方,带上蓝图存的图再交给模板的函数:自由形的「谁来做」、要不要 + 核实人都按导入的图算(freeform.people_rows / needs_reconciler)。""" + return {**spec, CONTEXT_KEY: {**context(spec), "graph": graph}} + + +def is_freeform(spec: dict[str, Any]) -> bool: + """卡片上选的是导入图的模板(自由形)。""" + ref = spec.get("template") + template = get_template(str(ref.get("id") or "")) if isinstance(ref, dict) else None + return template is not None and template.imports_graph + + def app_name(bp: dict[str, Any], fallback: str) -> str: """应用和工作流的名字:实例化时给的名字,没给就用目的的前一段。""" name = str(context(bp).get("name") or "").strip() @@ -434,6 +447,12 @@ def needs_reconciler(bp: dict[str, Any]) -> bool: 蓝图(reconciler_needed)共用这一份。""" ref = bp.get("template") template = get_template(str(ref.get("id") or "")) if isinstance(ref, dict) else None + if template is not None and template.imports_graph: + # 自由形没有「写回哪里」:按导入的图里的写入算(bp 要带着图,with_graph),核实人存在 /writeback/reconciler + # (05-已知遗留 (d)) + from app.blueprints.templates import freeform + + return freeform.needs_reconciler(bp) if template is None or "writeback" not in template.cards: return False capability = value((bp.get("writeback") or {}).get("capability")) diff --git a/backend/app/blueprints/templates/freeform.py b/backend/app/blueprints/templates/freeform.py index 42cc6c3a..db886bb4 100644 --- a/backend/app/blueprints/templates/freeform.py +++ b/backend/app/blueprints/templates/freeform.py @@ -1,5 +1,11 @@ """自由形 freeform(§3.3):从已有的运行(问数据的一轮回答,经 from-run 提取)或已发布的工作流版本导入,图原样用, -没有关节。蓝图只有目的、谁能看、多久更新三张卡片;要改图只能回画布(搭建者)。 +没有关节。蓝图有目的、谁能看、多久更新、谁来做四张卡片;要改图只能回画布(搭建者)。 + +「谁来做」(docs/design/next-round/05-已知遗留.md (d)):骨架就是导入的图,卡片只填「谁」这个关节——按图列出用到的岗位 +(人工节点派给谁、超时升级给谁、写入的发送确认派给谁,people_rows),写入能力没登记查询时再加一行「写入结果不明时谁去核实」。 +只定由谁做:会签、时限、超时在画布上改(改了会改图,图哈希就和发布的那一版对不上)。绑定存在 spec.people[](Step.id 就是 +岗位名、只写 who,_common.slot_of / bindings_of 原样生效),核实人存在 /writeback/reconciler。以前这张卡片不存在,第一次 +发布挡在 app.slot_unbound / app.reconciler_unbound 上,白话却叫人去「谁来做」里选人。 图由实例化的上下文给(graph,导入时存在蓝图上)。应用骨架按 apps/derive.py 的派生规则:有人工节点或写入的按流程, 否则按看板;写入节点按能力登记的进程内快照认(和运行时同一个判断,capabilities.registry.writes_externally)。 @@ -38,6 +44,65 @@ def _writes(graph: dict[str, Any]) -> dict[str, str]: return out +#: 「谁来做」一行里每处用途怎么说(apps/tree.SLOT_ROLES,加上核实) +USE_TEXT = {"assignee": "处理", "escalation": "超时升级", "confirm": "发送前确认", "reconcile": "写入结果不明时去核实"} +#: 不按岗位派的步骤(只读行):发起人自己、按表达式算出来的人、等外部系统回调 +FIXED_TEXT = {"initiator": "由发起人自己处理", "expression": "按图里的表达式算出由谁处理(在画布上改)", + "hook": "等外部系统回调,不派给人"} +#: 核实人那一行的名字 +RECONCILER_LABEL = "写入结果不明时谁去核实" + + +def people_rows(bp: dict[str, Any]) -> list[dict[str, Any]]: + """按导入的图列出「谁来做」:[{kind, slot, label, detail, path, who, uses}]。 + + kind=slot:图里引用的一个岗位(同一个岗位用在几处合成一行,按节点出现的顺序),path 是 /people//who(i 是岗位行的 + 序号),who 是卡片上绑的人或组;kind=reconciler:写入能力没登记查询时的核实人,path 是 /writeback/reconciler; + kind=initiator / expression / hook:不按岗位派的步骤,只读(path 为空)。label 用节点在图上的名字拼(岗位名是画布上 + 随手写的 id),detail 写全每一处用途。界面照这一份画,不自己从图里推。""" + from app.apps.derive import RECONCILER + from app.apps.tree import node_map, slot_refs + + graph = imported(bp) + writes = _writes(graph) + nodes = node_map(graph) + bound = {str(step.get("id")): c.value(step.get("who")) for step in bp.get("people") or [] if isinstance(step, dict)} + rows: list[dict[str, Any]] = [] + by_slot: dict[str, dict[str, Any]] = {} + for ref in slot_refs(graph, writes): + row = by_slot.get(ref["slot"]) + if row is None: + row = by_slot[ref["slot"]] = {"kind": "slot", "slot": ref["slot"], "path": f"/people/{len(by_slot)}/who", + "who": bound.get(ref["slot"]), "uses": []} + rows.append(row) + row["uses"].append({"node_id": ref["node_id"], "title": ref["title"], "role": ref["role"]}) + unchecked = [(nid, cap) for nid, cap in writes.items() if not c.write_lookup(cap)] + if unchecked: + rows.append({"kind": "reconciler", "slot": RECONCILER, "path": "/writeback/reconciler", + "who": c.value((bp.get("writeback") or {}).get("reconciler")), + "uses": [{"node_id": nid, "title": nodes[nid]["title"], "role": "reconcile"} for nid, _ in unchecked]}) + for nid, n in nodes.items(): + if n["type"] != "human" and nid not in writes: + continue + assignee = n["config"].get("assignee") + kind = assignee.get("kind") if isinstance(assignee, dict) else None + if kind in FIXED_TEXT: + rows.append({"kind": kind, "slot": None, "path": None, "who": None, + "uses": [{"node_id": nid, "title": n["title"], + "role": "assignee" if n["type"] == "human" else "confirm"}]}) + for row in rows: + titles = list(dict.fromkeys(u["title"] for u in row["uses"])) + row["label"] = RECONCILER_LABEL if row["kind"] == "reconciler" else "、".join(f"「{t}」" for t in titles) + row["detail"] = FIXED_TEXT[row["kind"]] if row["kind"] in FIXED_TEXT else ";".join( + f"「{u['title']}」{USE_TEXT[u['role']]}" for u in row["uses"]) + return rows + + +def needs_reconciler(bp: dict[str, Any]) -> bool: + """导入的图里有写入能力没登记查询(同 app_skeleton 加核实岗位的判断)。""" + return any(not c.write_lookup(cap) for cap in _writes(imported(bp)).values()) + + def app_skeleton(bp: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]: from app.apps.derive import ENTRY, INSTANCE_VIEW, PIN, VIEW, _entries, _results from app.apps.forms import input_schema @@ -103,6 +168,7 @@ TEMPLATE = register(TemplateDef( applies_when="用户已经有一次满意的问数据回答,或者一个已发布的工作流,想直接做成应用;需求在五个场景之外时也用它。", cards={"purpose": "必填:一句话说清这个应用做什么", "cadence": "可选:要不要定期自动出数", + "people": "可选:只定每一步由谁做;会签、时限、超时在画布上改", "access": "可选:谁能用、谁能看全部、谁能管理"}, personal="导入的图满足问数据的规则(只读、没有人工节点和写入)时可以", skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["导入"], diff --git a/backend/app/blueprints/trial.py b/backend/app/blueprints/trial.py index 1142ece8..96578058 100644 --- a/backend/app/blueprints/trial.py +++ b/backend/app/blueprints/trial.py @@ -147,7 +147,7 @@ async def materialize(session: AsyncSession, bp: Blueprint, *, principal: Princi digest = graph_hash(graph or {}) app_spec = _draft_pins(result.app_spec, bp, digest) # 实例化交回的是挡路的 error;蓝图检查里的提醒(模板版本旧了之类)另外带上 - warnings = [i for i in blueprint_issues(bp.spec or {}, for_="trial") if i.get("level") != "error"] + warnings = [i for i in blueprint_issues(bp.spec or {}, for_="trial", graph=bp.graph) if i.get("level") != "error"] problems = [with_plain(p) for p in [*result.problems, *warnings]] bp.graph, bp.graph_hash, bp.joints = graph, digest, result.joints bp.updated_by = principal.username if principal else bp.updated_by diff --git a/backend/tests/test_blueprint_templates.py b/backend/tests/test_blueprint_templates.py index 2f054fdd..888d890f 100644 --- a/backend/tests/test_blueprint_templates.py +++ b/backend/tests/test_blueprint_templates.py @@ -265,3 +265,37 @@ def test_trial_inputs_follow_the_cadence(): 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(get_template("approval").example()) == {} + + +def test_freeform_people_rows_follow_the_imported_graph(): + """自由形的「谁来做」按导入的图列(05-已知遗留 (d)):同一个岗位用在几处合成一行、说明写全;发起人、表达式派单的步骤是 + 只读行;没有人工节点和写入的图一行都没有。卡片上留着、图里已经没有的岗位照实提醒,不静默删掉。""" + from app.blueprints.ops import blueprint_issues + from app.blueprints.templates import freeform + + graph = {"nodes": [ + c.node("start", "input", "发起", fields=[{"name": "q"}]), + c.node("a", "human", "初审", mode="approve", assignee={"kind": "slot", "slot": "finance"}, + on_timeout={"action": "escalate", "to_slot": "director"}, due="PT1H"), + c.node("b", "human", "复审", mode="approve", assignee={"kind": "slot", "slot": "finance"}), + c.node("me", "human", "本人确认", mode="approve", assignee={"kind": "initiator"}), + c.node("calc", "human", "按金额派", mode="approve", assignee={"kind": "expression", "expression": "input.q"}), + c.node("done", "output", "完成", fields=[])], + "edges": [c.edge("start", "a"), c.edge("a", "b"), c.edge("b", "me"), c.edge("me", "calc"), c.edge("calc", "done")]} + spec = {**get_template("freeform").example(), "people": [ + {"id": "finance", "who": c.user_item([{"kind": "user", "name": "zhang"}])}, + {"id": "gone", "who": c.user_item([{"kind": "user", "name": "li"}])}]} + rows = freeform.people_rows(c.with_graph(spec, graph)) + assert [(r["kind"], r["slot"], r["path"]) for r in rows] == [ + ("slot", "finance", "/people/0/who"), ("slot", "director", "/people/1/who"), + ("initiator", None, None), ("expression", None, None)] + assert rows[0]["label"] == "「初审」、「复审」" and rows[0]["detail"] == "「初审」处理;「复审」处理" + assert rows[1]["detail"] == "「初审」超时升级" and rows[0]["who"] == [{"kind": "user", "name": "zhang"}] + issues = blueprint_issues(spec, for_="publish", graph=graph) + unbound = [i for i in issues if i["code"] == "bp.people_unbound"] + assert [(i["path"], i["slot"], i["level"]) for i in unbound] == [("/people/1/who", "director", "error")] + assert [i["level"] for i in blueprint_issues(spec, for_="trial", graph=graph) if i["code"] == "bp.people_unbound"] \ + == ["warning"], "试运行时待办派给试运行的人自己,不挡" + stale = [i for i in issues if i["code"] == "bp.template_mismatch" and "gone" in i["message"]] + assert stale and stale[0]["level"] == "warning" + assert freeform.people_rows(c.with_graph(get_template("freeform").example(), freeform.example_graph())) == [] diff --git a/backend/tests/test_publish_plan.py b/backend/tests/test_publish_plan.py index 00afa3ea..4fcdf785 100644 --- a/backend/tests/test_publish_plan.py +++ b/backend/tests/test_publish_plan.py @@ -218,8 +218,12 @@ async def test_a_process_with_an_unbound_step_stops_at_preflight(client): assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200 plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() assert plan["ok"] is False - unbound = [p for p in plan["problems"] if p["code"] == "app.slot_unbound"] - assert unbound and unbound[0]["plain"]["who"] == "you" and "谁来做" in unbound[0]["plain"]["action"] + # 自由形按导入的图列出「谁来做」(05-已知遗留 (d)):会签的 finance、超时升级的 director 各一行,没绑人报在那一行上; + # 应用检查说的是同一件事(app.slot_unbound),不再重复列——它指的「谁来做」以前在自由形蓝图上不存在 + unbound = {p["path"]: p for p in plan["problems"] if p["code"] == "bp.people_unbound"} + assert set(unbound) == {"/people/0/who", "/people/1/who"} + assert unbound["/people/0/who"]["plain"]["who"] == "you" and "谁来做" in unbound["/people/0/who"]["plain"]["action"] + assert "app.slot_unbound" not in {p["code"] for p in plan["problems"]} # 流程类不跑第一次;已发布的那一版按哈希复用,不另存 assert plan["options"]["first_run"] is False summary = {x["step"]: x["text"] for x in plan["summary"]} @@ -255,6 +259,59 @@ async def erp_write(): await registry.refresh_cache(session) +async def test_a_freeform_blueprint_binds_who_does_each_step_of_the_imported_graph(client, erp_write): + """从已发布的「财务审批(岗位 finance)+ 写回(没登记查询)」工作流建自由形蓝图(05-已知遗留 (d)):卡片上按图列出两行 + ——finance 和写入结果不明时的核实人;不选人时发布单在这两行上报 bp.people_unbound,不再有指不到卡片的 app.slot_unbound / + app.reconciler_unbound;选好人之后绑到应用的岗位,发布单不再挡。卡片上只定由谁做,改会签被拒(在画布上改)。""" + from app.blueprints.publish_plan import bindings_of + + graph = {"nodes": [ + s.node("start", "input", "发起", fields=[{"name": "amount", "type": "number"}]), + s.node("review", "human", "财务审批", mode="approve", title="审一下", assignee={"kind": "slot", "slot": "finance"}), + s.node("pay", "tool", "写回报销单", tool=erp_write, args={}), + s.node("done", "output", "完成", fields=[{"name": "结论", "value": "已写回"}])], + "edges": [{"source": "start", "target": "review"}, + {"source": "review", "target": "pay", "sourceHandle": "approved"}, + {"source": "pay", "target": "done"}]} + wf_id = await s.published_workflow(graph, f"写回-{uuid.uuid4().hex[:4]}", description="报销写回") + bp = (await client.post("/api/blueprints", json={"source": {"kind": "workflow_version", "id": wf_id}, + "scope": "shared"})).json() + await s.edit_spec(bp["id"], purpose=s.item("报销审批后写回")) + assert (await client.post(f"/api/blueprints/{bp['id']}/instantiate")).status_code == 200 + out = (await client.get(f"/api/blueprints/{bp['id']}")).json() + rows = out["derived_people"] + assert [(r["kind"], r["slot"], r["path"], r["who"]) for r in rows] == [ + ("slot", "finance", "/people/0/who", None), ("reconciler", "reconciler", "/writeback/reconciler", None)] + assert rows[0]["label"] == "「财务审批」" and rows[0]["detail"] == "「财务审批」处理" + assert rows[1]["detail"] == "「写回报销单」写入结果不明时去核实" + + plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() + assert plan["ok"] is False + codes = [p["code"] for p in plan["problems"]] + assert sorted(p["path"] for p in plan["problems"] if p["code"] == "bp.people_unbound") == [ + "/people/0/who", "/writeback/reconciler"] + assert "app.slot_unbound" not in codes and "app.reconciler_unbound" not in codes + summary_you = [p for p in plan["problems"] if p["code"] == "bp.people_unbound"] + assert all(p["who"] == "you" for p in summary_you) + + refused = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [ + {"op": "bp_set", "path": "/people/0/policy", "value": {"kind": "all"}}]}) + assert refused.status_code == 422 and refused.json()["code"] == "bp.template_mismatch" + assert "在画布上改" in refused.json()["detail"] + r = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [ + {"op": "bp_set", "path": "/people/0/who", "value": [{"kind": "user", "name": "zhang"}]}, + {"op": "bp_set", "path": "/writeback/reconciler", "value": [{"kind": "group", "name": "财务组"}]}]}) + assert r.status_code == 200, r.text + rows = r.json()["derived_people"] + assert rows[0]["who"] == [{"kind": "user", "name": "zhang"}] and rows[1]["who"] == [{"kind": "group", "name": "财务组"}] + assert not [i for i in r.json()["issues"] if i["code"] == "bp.people_unbound"] + assert bindings_of(await s.blueprint(bp["id"]))["slots"] == { + "finance": [{"kind": "user", "name": "zhang"}], "reconciler": [{"kind": "group", "name": "财务组"}]} + plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() + codes = [p["code"] for p in plan["problems"]] + assert "bp.people_unbound" not in codes and "app.slot_unbound" not in codes and "app.reconciler_unbound" not in codes + + async def test_the_reconciler_comes_from_the_writeback_card(client, erp_write): """审批通过后写回一项没登记查询的能力:结果不明时要人核实。发布单只列一条、指到「写回哪里」卡片上那一项(应用检查说的 核实岗位没绑、写入没人核实是同一件事,不再重复列);在卡片上选了人,绑到应用的核实岗位,这几条都不再有。""" @@ -524,8 +581,8 @@ async def test_problems_are_split_into_three_kinds(client): plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() assert all(p["who"] in ("you", "assistant", "admin") for p in plan["problems"]) assert {p["source"] for p in plan["problems"]} <= {"blueprint", "plan", "workflow", "schedule", "app", "grants"} - unbound = next(p for p in plan["problems"] if p["code"] == "app.slot_unbound") - assert unbound["who"] == "you" and unbound["source"] == "app" + unbound = next(p for p in plan["problems"] if p["code"] == "bp.people_unbound") + assert unbound["who"] == "you" and unbound["source"] == "blueprint" # -------------------------------------------------------------------------- From 2fa2db3ea013e237e5aed944054f3298ebb6f52a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:54:02 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AE=B0=E6=88=90=E5=A4=B1=E8=B4=A5=EF=BC=88=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=EF=BC=89=EF=BC=9A=E7=BB=88=E6=80=81=E6=8C=89=20end=5Freason=20?= =?UTF-8?q?=E5=86=99=E3=80=8C=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E5=89=AF=E6=A0=87=E9=A2=98=E5=86=99=E8=B0=81=E3=80=81=E5=9C=A8?= =?UTF-8?q?=E5=93=AA=E4=B8=80=E6=AD=A5=E3=80=81=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:服务端已把人的驳回收成 cancelled + end_reason=rejected(上一个提交)。前端以前靠正则解析报错原文认驳回,而且只认 「人工驳回」一种,动作确认、工具调用审批、代码执行审批的三种原话都落到兜底、给出注定再失败的「继续运行」(05-已知遗留 (a))。 做了什么: - types.ts:Run.end_reason(rejected / stopped)。 - lib/status:新增状态 rejected(「被驳回」,同取消的剪影、不画红、终态),resolveStatus 按 endReason 区分;记录页列表、详情、 航迹的终点按它写「被驳回 · 原话」。 - run/decode:run.cancelled 带 reason=rejected 时标题写「被驳回」,副标题「张工 驳回了「付款」:金额不对」(超时驳回写按设置 驳回、不重复备注),指向那一步;手动停止、老事件照旧「已取消」。 - store/chat + ChatPage:终态里被驳回的一轮照 cancelled 收(不给继续运行),徽标写「被驳回」,补救区说明这是审批人的决定、 副标题写谁和备注;刷新之后从 meta.rejected 恢复;核对状态时按运行的 end_reason 认。 - lib/explain:正则只留给存量 failed+驳回,补上「未确认(被驳回)」「的调用已被驳回」「代码执行已被驳回」三种原话,一律不给继续运行。 - 继续运行被拒的说法改成「被驳回的运行不能继续运行:改好内容后重新发起」(check-copy 不收「接着跑」)。 - check-decode 加一节:reason=rejected 的标题、副标题、没署名没备注、超时驳回、相位、手动停止和老事件。 偏离:问数据的「之前的尝试」(retry 留档,W1-a 的地盘)里被驳回的那一次仍写「已手动停止」,没动。 --- backend/app/engine/runner.py | 4 +-- backend/tests/test_run_rejected.py | 2 +- frontend/src/lib/explain.ts | 10 +++++--- frontend/src/lib/status.ts | 23 ++++++++++++++--- frontend/src/lib/terms.ts | 19 ++++++++++++++ frontend/src/pages/ChatPage.tsx | 6 +++-- frontend/src/pages/runs/RunDetailView.tsx | 7 ++++-- frontend/src/pages/runs/RunList.tsx | 10 +++++++- frontend/src/pages/runs/TracePane.tsx | 4 ++- frontend/src/pages/runs/model.ts | 4 +-- frontend/src/run/decode.ts | 8 +++++- frontend/src/store/chat.ts | 30 +++++++++++++++++++---- frontend/src/types.ts | 10 ++++++++ scripts/check-decode.mjs | 27 ++++++++++++++++++++ 14 files changed, 141 insertions(+), 23 deletions(-) diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index 7fcd0a62..25c5e5e1 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -50,8 +50,8 @@ RUN_REJECTED = "run_rejected" #: 运行为什么结束在 cancelled(Run.end_reason,docs/design/next-round/00-总方案.md X9) END_REJECTED = "rejected" END_STOPPED = "stopped" -#: 被驳回的运行不能接着跑:从断点重跑会读到同一个驳回答复、再停在同一处 -REJECTED_CONTINUE = "被驳回的运行不能接着跑:改好内容后重新发起" +#: 被驳回的运行不能继续运行:从断点重跑会读到同一个驳回答复、再停在同一处 +REJECTED_CONTINUE = "被驳回的运行不能继续运行:改好内容后重新发起" #: 服务重启时还在排队的运行改成失败时写的话(run.error 和补发的 run.failed 是同一句) QUEUED_AT_RESTART = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行" diff --git a/backend/tests/test_run_rejected.py b/backend/tests/test_run_rejected.py index 520d2d4f..690db845 100644 --- a/backend/tests/test_run_rejected.py +++ b/backend/tests/test_run_rejected.py @@ -132,7 +132,7 @@ async def test_the_api_carries_end_reason_and_refuses_to_continue_a_rejected_run assert (body["status"], body["end_reason"]) == ("cancelled", "rejected") r = await client.post(f"/api/runs/{run_id}/continue", json={}) assert r.status_code == 409 and r.json()["code"] == "run_rejected" - assert r.json()["detail"] == "被驳回的运行不能接着跑:改好内容后重新发起" + assert r.json()["detail"] == "被驳回的运行不能继续运行:改好内容后重新发起" stop = await client.post(f"/api/runs/{run_id}/cancel") assert stop.status_code == 409 and "被驳回" in stop.json()["detail"] diff --git a/frontend/src/lib/explain.ts b/frontend/src/lib/explain.ts index 4e6e06e5..d9c992fe 100644 --- a/frontend/src/lib/explain.ts +++ b/frontend/src/lib/explain.ts @@ -295,11 +295,15 @@ export function explainRunError(error: string | null | undefined, detail?: strin raw, } } - // 后端有一条路径以前写「人工拒绝:」(现在统一为「人工驳回:」),历史运行里两种都有 - if (/人工(?:驳回|拒绝)/.test(text)) { + // 只留给存量:加 end_reason 之前被驳回的运行记的是 failed,报错原文是这几种说法。新的驳回结束在 cancelled + + // end_reason=rejected,界面按那个字段说(lib/status、REJECTED_TEXT),不走这里(05-已知遗留 (a))。后端有一条路径 + // 以前写「人工拒绝:」(现在统一为「人工驳回:」);动作确认、工具调用审批、代码执行审批的驳回各有一种原话。 + // 接着跑只会读到同一个驳回答复、再停一次,所以都不给「继续运行」 + if (/人工(?:驳回|拒绝)|未确认(被驳回)|的调用已被驳回|代码执行已被驳回/.test(text)) { + const human = /^人工(?:驳回|拒绝)/.test(plain) return { title: '人工审批驳回,运行终止', - reason: plain.replace(/^人工(?:驳回|拒绝)[::]?\s*/, '') || undefined, + reason: (human ? plain.replace(/^人工(?:驳回|拒绝)[::]?\s*/, '') : plain) || undefined, action: '这是审批人的决定,不是故障。如有需要,请修改内容后重新发起运行。', continuable: false, raw, diff --git a/frontend/src/lib/status.ts b/frontend/src/lib/status.ts index c817fbf3..96a6eb16 100644 --- a/frontend/src/lib/status.ts +++ b/frontend/src/lib/status.ts @@ -22,6 +22,7 @@ export type StatusCode = | 'succeeded' // 运行跑完 | 'failed' | 'cancelled' + | 'rejected' // 运行:被人驳回结束(后端是 cancelled + end_reason=rejected,05-已知遗留 (a)) | 'suspended' // 服务重启后挂起,可续跑 | 'held' // 后端的 interrupted 但没有待审批:已挂起 · 可续跑 | 'skipped' // 被 skip_if 跳过 @@ -104,6 +105,11 @@ export const STATUS: Record = { code: 'cancelled', label: '已取消', short: '已取消', shape: 'stop', alert: false, terminal: true, ...tok('cancelled', 'var(--text-faint)'), }, + // 人的决定,不是故障:不画红、不给继续运行;和手动停止同一个剪影,叫法分开 + rejected: { + code: 'rejected', label: '被驳回', short: '被驳回', shape: 'stop', alert: false, terminal: true, + hint: '审批人驳回了这次运行,运行到这里结束;改好内容后重新发起', ...tok('cancelled', 'var(--text-faint)'), + }, suspended: { code: 'suspended', label: '已中断(服务重启)', short: '已中断', shape: 'pause', alert: true, terminal: true, hint: '服务重启时本次运行尚未结束,已在断点处挂起,可继续运行', ...tok('suspended', 'var(--warn)'), @@ -141,15 +147,25 @@ const ALIAS: Record = { * 才分得开——传 pendingApproval。不知道时(没传)按等待审批处理,这是 * interrupted 最常见的来源,也是改造前的说法,不会比原来更错。 */ -export function resolveStatus(status: string | null | undefined, opts?: { pendingApproval?: boolean }): StatusCode { +export function resolveStatus(status: string | null | undefined, opts?: StatusOpts): StatusCode { if (!status) return 'idle' if (status === 'interrupted') return opts?.pendingApproval === false ? 'held' : 'waiting' + // 被驳回只看服务端给的 end_reason,不解析报错原文(界面层不放逻辑) + if (status === 'cancelled' && opts?.endReason === 'rejected') return 'rejected' if (status in STATUS) return status as StatusCode return ALIAS[status] ?? 'idle' } +/** + * 落状态要的旁证:pendingApproval 分 interrupted 的两种;endReason 是运行的 end_reason(cancelled 时分被驳回和手动停止) + */ +export interface StatusOpts { + pendingApproval?: boolean + endReason?: string | null +} + /** 状态的完整外观。未知状态码回落到 idle 的外观,文字写原码,免得静默吞掉 */ -export function statusMeta(status: string | null | undefined, opts?: { pendingApproval?: boolean }): StatusMeta { +export function statusMeta(status: string | null | undefined, opts?: StatusOpts): StatusMeta { const code = resolveStatus(status, opts) const meta = STATUS[code] if (status && code === 'idle' && status !== 'idle' && !(status in ALIAS)) { @@ -159,7 +175,7 @@ export function statusMeta(status: string | null | undefined, opts?: { pendingAp } /** 只要文字 */ -export function statusLabel(status: string | null | undefined, opts?: { pendingApproval?: boolean; short?: boolean }): string { +export function statusLabel(status: string | null | undefined, opts?: StatusOpts & { short?: boolean }): string { const meta = statusMeta(status, opts) return opts?.short ? meta.short : meta.label } @@ -186,6 +202,7 @@ export function serverStatusOf(code: StatusCode): RunStatus | null { case 'waiting': case 'held': case 'suspended': return 'interrupted' case 'done': case 'succeeded': return 'succeeded' case 'queued': case 'running': case 'failed': case 'cancelled': return code + case 'rejected': return 'cancelled' default: return null } } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 51fb6dba..72bd4212 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5135,6 +5135,25 @@ export const ORIGIN_TEXT = { actor: (who: string, verified: boolean) => `${verified ? '已登录用户' : '署名'}「${who}」`, } +/** + * 驳回 ≠ 失败(docs/design/next-round/05-已知遗留.md (a)):人的驳回让运行结束在 cancelled + end_reason=rejected, + * run.cancelled 带着在哪一步(label)、谁(actor)、结论(decision)、备注(note)。时间线、问数据的终态、记录页都照这一份说, + * 不再按报错原文猜 + */ +export const REJECTED_TEXT = { + title: '被驳回', + /** 副标题:谁在哪一步驳回、备注;超时驳回写按设置驳回(备注是系统写的那句,不重复) */ + sub: (d: { actor?: unknown; label?: unknown; note?: unknown; decision?: unknown }): string => { + const who = typeof d.actor === 'string' ? d.actor.trim() : '' + const where = typeof d.label === 'string' && d.label.trim() ? `「${d.label.trim()}」` : '这一步' + const note = typeof d.note === 'string' ? d.note.trim() : '' + if (d.decision === 'expired_reject') return `${where}超过时限,按设置驳回` + return `${who ? `${who} 驳回了${where}` : `${where}被驳回`}${note ? `:${note}` : ''}` + }, + /** 问数据:这一轮的运行被驳回结束时,补救区的那一句 */ + chatReason: '这一轮的运行被审批人驳回,已经结束。这是人的决定,不是故障;改好内容后可以重新运行。', +} + /** 来源标签:老运行没有来源时写「来源未记录」 */ export const originLabel = (kind: string | null | undefined): string => kind && kind in ORIGIN_LABEL ? ORIGIN_LABEL[kind as OriginKind] : ORIGIN_TEXT.unknown diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 8bae6511..a93803b7 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -17,7 +17,7 @@ import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { canLeave, leavePass } from '../lib/leave' import { formatNumber, formatTime } from '../lib/format' -import { CHAT_BLUEPRINT_TEXT } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT, REJECTED_TEXT } from '../lib/terms' import { useCatalog, useCatalogListState, useDatasources, useOnReconnect, type CatalogListState } from '../store/catalog' import { useAtLeast } from '../store/auth' import { useBlueprint } from '../store/blueprint' @@ -383,7 +383,8 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { : turn.phase === 'error' ? 'error' : live || turn.phase === 'checking' ? 'running' : 'done', // 没核对上的不知道结局,用「未运行」那个空心点,不画成完成也不画成失败 - statusCode: turn.phase === 'cancelled' ? 'cancelled' + // 被人驳回的(turn.rejected,服务端 end_reason=rejected)照 lib/status 写「被驳回」,不写「已取消」 + statusCode: turn.phase === 'cancelled' ? (turn.rejected ? 'rejected' : 'cancelled') : turn.phase === 'suspended' ? 'suspended' // 助手反问:这一轮结束了,在等用户回答 : turn.asked && turn.phase === 'done' ? 'waiting' @@ -729,6 +730,7 @@ function Remedies({ conversationId, turnId, last, onRephrase, readOnly }: { // 中断、取消、没核对上在流里不出红框(它们不是故障),为什么停、停了意味着什么在这里说 const note = kind === 'suspended' || kind === 'unknown' ? turn.failure + : kind === 'cancelled' && turn.rejected ? { title: '', reason: REJECTED_TEXT.chatReason, hint: turn.rejected.sub } : kind === 'cancelled' ? { title: '', reason: turn.run ? '你已停止本轮,对应的运行也已一并取消。' : '你在搭建工作流时停止了本轮。' } : null diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 5769acb3..74073354 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -205,7 +205,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const aimed = focus && !goneFromCanvas(focus) ? focus : null const canvasHref = hrefFor(aimed) // 还在往前走的叫「打开」(接着看实时的、去处理),停下的叫「回放」 - const replayable = code === 'succeeded' || code === 'failed' || code === 'cancelled' + const replayable = code === 'succeeded' || code === 'failed' || code === 'cancelled' || code === 'rejected' const dup = !!run.workflow_id && duplicateNames(workflows.map((w) => ({ id: w.id, name: w.name }))).has(run.workflow_name) // ---- 动作 ---- @@ -730,7 +730,10 @@ export function RunDetailView({ runId, onChange, onDeleted }: { /** 事件之外的事实(查到的状态、有没有审批)和事件推出的相位合成一个显示码 */ function displayCode(run: Run, phase: RunPhase, pending: boolean | undefined): StatusCode { switch (phase) { - case 'running': case 'queued': case 'waiting': case 'succeeded': case 'failed': case 'cancelled': + case 'cancelled': + // 被驳回只看服务端的 end_reason(05-已知遗留 (a)) + return run.end_reason === 'rejected' ? 'rejected' : 'cancelled' + case 'running': case 'queued': case 'waiting': case 'succeeded': case 'failed': return phase case 'suspended': // 后端写的是 interrupted:服务重启打断、没有审批,叫「已挂起 · 可续跑」 diff --git a/frontend/src/pages/runs/RunList.tsx b/frontend/src/pages/runs/RunList.tsx index 01e9e699..0958caf6 100644 --- a/frontend/src/pages/runs/RunList.tsx +++ b/frontend/src/pages/runs/RunList.tsx @@ -7,7 +7,8 @@ import { formatCost, formatDateTime, formatDay, formatSpan, formatTime, formatTokens, parseServerTime, shortId, } from '../../lib/format' import { - ORIGIN_LABEL, ORIGIN_TEXT, RUN_CLASS_LABEL, TASK_KIND_LABEL, TASK_LOG_TEXT, UNSAVED_HINT, UNSAVED_NAME, isUnsaved, originLabel, + ORIGIN_LABEL, ORIGIN_TEXT, REJECTED_TEXT, RUN_CLASS_LABEL, TASK_KIND_LABEL, TASK_LOG_TEXT, UNSAVED_HINT, UNSAVED_NAME, isUnsaved, + originLabel, runName, } from '../../lib/terms' import { summarizeRun } from '../../run/decode' @@ -466,6 +467,13 @@ function RowContext({ run, code, approval, now }: { const reason = cancelReason(run.error) return
已取消{reason ? ` · ${reason}` : ''}
} + case 'rejected': + // 人的驳回(end_reason=rejected):报错原文就是给人看的那一句(「「付款」未确认(被驳回),没有发送:…」) + return ( +
+ {REJECTED_TEXT.title}{run.error ? ` · ${run.error}` : ''} +
+ ) default: { const summary = summarizeRun(run.input, run.output) return summary diff --git a/frontend/src/pages/runs/TracePane.tsx b/frontend/src/pages/runs/TracePane.tsx index 8c6523a0..73dbbbdb 100644 --- a/frontend/src/pages/runs/TracePane.tsx +++ b/frontend/src/pages/runs/TracePane.tsx @@ -11,7 +11,7 @@ import { explainRunError } from '../../lib/explain' import { NONE, formatCost, formatDateTime, formatNumber, formatOffset, formatSpan, formatTokens, shortId, } from '../../lib/format' -import { COST_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' +import { COST_TEXT, REJECTED_TEXT, runClassLabel, nodeTypeLabel, runCostText } from '../../lib/terms' import { topology, type GraphLike } from '../../run/derive' import { isActivePhase, isSettled, liveAt, project, type NodeState, type Projection, type RunPhase, type Trace, @@ -449,6 +449,7 @@ function momentsOf( const failed = trace.phase === 'failed' ? trace.failedNodeId : undefined const why = trace.phase === 'failed' ? explainRunError((failed && trace.nodes[failed]?.error) || error).title + : code === 'rejected' ? error?.trim() || undefined : trace.phase === 'cancelled' ? cancelReason(error) ?? undefined : trace.phase === 'suspended' ? error?.trim() || undefined : undefined out.push({ @@ -458,6 +459,7 @@ function momentsOf( text: trace.phase === 'succeeded' ? '运行完成' : failed ? `失败于${name(failed)}` : trace.phase === 'failed' ? '运行失败' + : code === 'rejected' ? REJECTED_TEXT.title : trace.phase === 'cancelled' ? '运行取消' : '运行挂起', sub: why, }) diff --git a/frontend/src/pages/runs/model.ts b/frontend/src/pages/runs/model.ts index 61674490..9236665c 100644 --- a/frontend/src/pages/runs/model.ts +++ b/frontend/src/pages/runs/model.ts @@ -49,8 +49,8 @@ export const SEGMENT_CODES: StatusCode[] = RUN_STATUS_ORDER * 打断后没有审批——要看待审批列表才分得开。approvals 还没加载时传 null,按 * 等待审批算(最常见的来源),不会在加载的那一瞬间闪成「已挂起」。 */ -export function runCode(run: Pick, approvals: Approval[] | null): StatusCode { - if (run.status !== 'interrupted') return resolveStatus(run.status) +export function runCode(run: Pick, approvals: Approval[] | null): StatusCode { + if (run.status !== 'interrupted') return resolveStatus(run.status, { endReason: run.end_reason }) return resolveStatus(run.status, { pendingApproval: approvals == null ? undefined : hasPendingApproval(approvals, run.id), }) diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 427e24bc..185c3d52 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, FILE_TEXT, + VERIFY_TRUNCATED_TEXT, BP_OP_TEXT, TEMPLATE_LABEL, bpPathText, FILE_TEXT, REJECTED_TEXT, claimTally, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' @@ -2673,6 +2673,12 @@ export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOpt // 用户主动停下的,不是出错:收成中性的"已取消",不画红色的失败。谁停的、放弃等 // 审批时一并关了几条,写在副标题上——多人用时「怎么没了」得有个交代 settle(endingOf(event, awaiting)!) + if (d.reason === 'rejected') { + // 人的驳回(05-已知遗留 (a)):同样不是出错,标题写「被驳回」,副标题写谁在哪一步驳回、备注 + out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'cancelled', title: REJECTED_TEXT.title, + sub: REJECTED_TEXT.sub(d), ...(d.node_id ? { nodeId: String(d.node_id) } : {}) }) + break + } // 说法和画布胶囊的那一句一致:「张工 放弃了这次运行,1 条待审批一并关闭」 const who = typeof d.actor === 'string' && d.actor.trim() ? d.actor.trim() : '' const said = String(d.message ?? '').trim() diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 6ed4255c..7983e5bf 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { ApiError, api, streamCopilot, streamRun } from '../api/client' import type { CopilotOp, FixKind, RunFinal } from '../run/decode' import { copilotContext, copilotLook, decodeRun, looksOf } from '../run/decode' -import { CHAT_BLUEPRINT_TEXT, LOOK_TEXT } from '../lib/terms' +import { CHAT_BLUEPRINT_TEXT, LOOK_TEXT, REJECTED_TEXT } from '../lib/terms' import type { LookEvent } from '../typesApp' import { errorMessage, humanizeError, isNetworkError } from '../lib/errors' import { guardReview, hasEvidence } from '../lib/evidence' @@ -87,6 +87,11 @@ export interface Failure { fixFirst?: true } +/** 被驳回的那一句:事件流里来的按 run.cancelled 的谁、哪一步、备注拼(REJECTED_TEXT.sub);核对状态时只有报错原文 */ +export interface Rejected { + sub: string +} + /** 限定这一问只查哪些数据源。名字跟着存:库删了之后,这一轮查的是谁还得说得出来 */ export interface ScopeSource { id: string @@ -127,6 +132,8 @@ export interface TurnMeta { issuance?: Record noQuery?: boolean outcome?: 'cancelled' | 'suspended' + /** 运行被人驳回结束(outcome 是 cancelled):副标题那一句,刷新之后照样写「被驳回」,不退回成「你已停止本轮」 */ + rejected?: Rejected failure?: Failure attempts?: Attempt[] /** 这一轮从开问到交付的墙钟毫秒 */ @@ -180,6 +187,11 @@ export interface ChatTurn { /** 一行错误文案(兼容老数据和流式头部)。结构化的在 failure 里 */ error: string failure?: Failure | null + /** + * 这一轮的运行被人驳回结束(服务端 cancelled + end_reason=rejected,05-已知遗留 (a))。phase 照旧是 cancelled(不是故障、 + * 不给继续运行),说法另写「被驳回」和谁、在哪一步、备注 + */ + rejected?: Rejected | null startedAt: number /** 交付、失败、取消或中断的时刻。计时器停在这里 */ endedAt?: number @@ -440,6 +452,7 @@ function buildMeta(t: ChatTurn): TurnMeta { issuance: slimIssuance(t.output?._issuance ?? t.rawOutput?._issuance) ?? t.meta?.issuance, noQuery: t.noQuery || undefined, outcome: t.phase === 'cancelled' ? 'cancelled' : t.phase === 'suspended' ? 'suspended' : undefined, + rejected: t.phase === 'cancelled' && t.rejected ? t.rejected : undefined, failure: t.failure ?? undefined, attempts: t.attempts?.length ? t.attempts : undefined, ms: t.endedAt ? t.endedAt - t.startedAt : t.meta?.ms, @@ -678,6 +691,7 @@ function restoreTurn(t: ConversationTurn): ChatTurn { output, error: phase === 'error' ? (failure ? lineOf(failure) : t.status === 'running' ? buildInterrupted.title : '') : '', failure: phase === 'error' ? (t.status === 'running' ? buildInterrupted : failure) : phase === 'suspended' ? SUSPENDED : null, + rejected: phase === 'cancelled' ? meta?.rejected ?? null : null, startedAt: Date.parse(t.created_at ?? '') || Date.now(), endedAt: meta?.ms != null ? (Date.parse(t.created_at ?? '') || 0) + meta.ms : undefined, lastSeq: 0, @@ -1043,7 +1057,10 @@ export const useChat = create((set, get) => ({ } if (run.status === 'cancelled') { if (cur.phase !== 'done' && cur.phase !== 'cancelled') { - conclude({ phase: 'cancelled', status: PHASE_TEXT.cancelled, failure: null, error: '' }, '已取消') + // 被驳回只看服务端的 end_reason:核对时手上只有运行记录,副标题用报错原文(它就是给人看的那句) + const rejected = run.end_reason === 'rejected' ? { sub: run.error || REJECTED_TEXT.title } : null + conclude({ phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, failure: null, + error: '', rejected }, '已取消') } return } @@ -1673,15 +1690,18 @@ function watch( // 服务在关停:这一轮会停在断点上。停在审批上的仍然是在等人 if (d.code === 'server_shutdown' && current()?.phase !== 'waiting') suspend() break - case 'run.cancelled': + case 'run.cancelled': { ended = true batch.now() + // 人的驳回(reason=rejected)也收成 cancelled:不是故障、不给继续运行,说法写「被驳回」和谁、哪一步、备注 + const rejected = d.reason === 'rejected' ? { sub: REJECTED_TEXT.sub(d) } : null patch(() => ({ - phase: 'cancelled', status: PHASE_TEXT.cancelled, cancel: null, endedAt: Date.now(), - runStatus: 'cancelled', final: { status: 'cancelled' }, + phase: 'cancelled', status: rejected ? REJECTED_TEXT.title : PHASE_TEXT.cancelled, cancel: null, + endedAt: Date.now(), runStatus: 'cancelled', final: { status: 'cancelled' }, rejected, })) void flow.persist({ status: 'error', error: '已取消' }) break + } case 'run.failed': { ended = true batch.now() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0bd204f6..8381edd5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4471,3 +4471,13 @@ export interface DataSourceName { kind?: string enabled?: boolean } + +// --------------------------------------------------------------------------- +// 驳回 ≠ 失败(docs/design/next-round/05-已知遗留.md (a),总方案 X9) + +/** 运行为什么结束在 cancelled(服务端 Run.end_reason):rejected 人工驳回,stopped 手动停止。其余终态、老运行为空 */ +export type RunEndReason = 'rejected' | 'stopped' + +export interface Run { + end_reason?: RunEndReason | null +} diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 98dd2172..f079d849 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -2724,5 +2724,32 @@ await section('读入附件 file.extracted:读入多少字、全文多少字 && odd[0]?.title === '读入附件' && odd[2]?.title === '附件抽不出文字', odd.map((s) => `${s.title}|${s.sub ?? ''}`).join(' ; ')) }) +await section('人的驳回 run.cancelled reason=rejected(05-已知遗留 (a)):终态是「被驳回」,副标题写谁、哪一步、备注;手动停止照旧「已取消」', async () => { + const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: seq, data }) + const head = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'pay', { label: '付款' }), + E(3, 'human.requested', 'pay', { title: '确认发送:付款' }), E(4, 'human.resolved', 'pay', { approved: false, note: '金额不对', actor: '张工' }), + E(5, 'node.failed', 'pay', { error: '「付款」未确认(被驳回),没有发送:金额不对' })] + const ended = (data) => flatten(mod.decodeRun([...head, E(6, 'run.cancelled', null, data)])) + .find((s) => s.kind === 'lifecycle' && s.id === 'rc-6') + const rejected = ended({ reason: 'rejected', node_id: 'pay', label: '付款', kind: 'action_confirm', decision: 'reject', + actor: '张工', note: '金额不对' }) + check('标题写「被驳回」,不写「已取消」「失败」', rejected?.title === '被驳回' && rejected?.status === 'cancelled', + `${rejected?.title} / ${rejected?.status}`) + check('副标题写谁在哪一步驳回、备注', rejected?.sub === '张工 驳回了「付款」:金额不对', rejected?.sub) + check('指向驳回的那一步(点了画布取景过去)', rejected?.nodeId === 'pay', rejected?.nodeId) + const anonymous = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'reject', actor: null, note: '' }) + check('没署名、没备注:「「付款」被驳回」,不出 null、undefined', anonymous?.sub === '「付款」被驳回', anonymous?.sub) + const expired = ended({ reason: 'rejected', node_id: 'pay', label: '付款', decision: 'expired_reject', actor: null, + note: '超过时限,按设置驳回' }) + check('超时驳回:写按设置驳回,备注不重复', expired?.sub === '「付款」超过时限,按设置驳回', expired?.sub) + const phase = mod.decodePhase([...head, E(6, 'run.cancelled', null, { reason: 'rejected' })]) + check('相位收成 cancelled(不是 failed、不给继续运行)', phase === 'cancelled', phase) + const stopped = ended({ reason: 'stopped', actor: '李工' }) + check('手动停止照旧「已取消」,写谁停的', stopped?.title === '已取消' && stopped?.sub === '李工 取消了本次运行', + `${stopped?.title} / ${stopped?.sub}`) + const legacy = ended({}) + check('老事件(没有 reason)照旧「已取消」', legacy?.title === '已取消', legacy?.title) +}) + console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') process.exit(failed ? 1 : 0) From fbb671a96b3fac1d5df17d1fa3bcacb09724c6c4 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:54:30 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E8=80=85=E9=80=89?= =?UTF-8?q?=E4=BA=BA=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E9=80=89?= =?UTF-8?q?=E4=BA=BA=E6=8E=A7=E4=BB=B6=E6=8C=89=E6=B1=A0=E8=BE=B9=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E8=BE=B9=E5=8F=96=E5=80=99=E9=80=89=EF=BC=9B=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E6=B8=A0=E9=81=93=E7=9A=84=E5=AE=9E=E4=BE=8B=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=94=B9=E5=8F=AB=E3=80=8C=E6=88=91=E5=8F=91=E8=B5=B7?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E4=BE=8B=E5=AE=8C=E6=88=90=20/=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:使用者读不到名录,选人控件只能凭记忆手填用户名,填错了服务端以前也照收(05-已知遗留 (b))。服务端已按用途给候选池、 开登录时按同一个池核对(上上个提交),前端要把候选给到人手上。另外个人渠道勾「实例完成 / 失败」以前永远收不到,服务端修好之后 (驳回那个提交里的 notify_run),名字要照实写成只推自己发起的实例(04-对外集成与触达 B1)。 做了什么: - PrincipalPicker 加 source(CandidateSource:form 带 app_id+entry 或 task_id 和 field,review)。搭建者及以上照旧用整份名录; 使用者开了登录、给了 source 时边输入边按开头取候选(250 毫秒防抖),列表里显示显示名、标签悬停给用户名;加的时候按同一个池 问一次服务端,不在池里的当场说「「x」不在可选的人里」(问不到就先收下,提交时由服务端判);池要先输开头才列、列不全时照实提示。 - SchemaForm 按表单用在哪(入口、待办,同上传文件的上下文)给选人控件 source;个人应用提交审核用 purpose=review。 - typesApp:CandidateSource、Candidates、FormField 的 x-principal、FormError 的 principal 码(和服务端 FORM_ERROR_CODES 同序)。 - api.identity.candidates。termsApp 的 PRINCIPAL_TEXT 加三句。 - 设置 → 通知:范围是「仅自己」的渠道,run.finished / run.failed 写「我发起的实例完成 / 失败」(termsApp.NOTIFY_PERSONAL_EVENT_LABEL)。 - 服务端 x-principal 写法不对时的说法改成不带半角冒号接中文的写法(check-copy)。 --- backend/app/auth/directory.py | 4 +- frontend/src/api/auth.ts | 5 +- frontend/src/components/PrincipalPicker.tsx | 81 +++++++++++++++++---- frontend/src/components/SchemaForm.tsx | 14 +++- frontend/src/lib/termsApp.ts | 13 ++++ frontend/src/pages/apps/personal.tsx | 2 +- frontend/src/pages/settings/NotifyTab.tsx | 8 +- frontend/src/typesApp.ts | 29 +++++++- 8 files changed, 132 insertions(+), 24 deletions(-) diff --git a/backend/app/auth/directory.py b/backend/app/auth/directory.py index d981f9bc..fa88a6b7 100644 --- a/backend/app/auth/directory.py +++ b/backend/app/auth/directory.py @@ -119,14 +119,14 @@ POOL_SLOT = "slot:" def pool_problem(value: Any) -> str | None: """x-principal 的写法对不对:只能是 {pool: app | group:<组> | slot:<岗位>}。没问题为 None。""" if not isinstance(value, dict) or set(value) != {"pool"} or not isinstance(value.get("pool"), str): - return "的候选范围只能写 {\"pool\": \"app\"}、{\"pool\": \"group:组名\"} 或 {\"pool\": \"slot:岗位\"}" + return "的候选范围只能写成 {\"pool\": \"app\"}、{\"pool\": \"group:<组名>\"} 或 {\"pool\": \"slot:<岗位>\"}" pool = value["pool"].strip() if pool == POOL_APP: return None for prefix in (POOL_GROUP, POOL_SLOT): if pool.startswith(prefix) and pool[len(prefix):].strip(): return None - return "的候选范围只能是 app(能用这个应用的人)、group:组名 或 slot:岗位" + return "的候选范围只能是 app(能用这个应用的人)、group:<组名> 或 slot:<岗位>" def pool_of(field: Any) -> str: diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 51990394..b20eb648 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -2,7 +2,7 @@ * 身份的接口(/api/auth、/api/users、/api/groups、/api/audit),挂在 api.auth / api.identity 上。形状见 typesApp.ts。 * 开登录时:没登录的请求回 401(前端跳 /login),角色不够回 403。关登录时 /me 恒为 mode='off'。 */ -import type { AuditRow, AuthMode, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp' +import type { AuditRow, AuthMode, CandidateSource, Candidates, Directory, GroupRow, Principal, Role, UserRow } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' export const auth = { @@ -29,6 +29,9 @@ export const identity = { groups: () => get<{ items: GroupRow[] }>('/groups'), /** 选人用的名录:搭建者及以上只读(/users、/groups 只给管理员) */ directory: () => get('/directory'), + /** 使用者选人的候选:按用途给的池,只有名字(任何登录的人都能调;用不了那个应用的 403)。q 按开头筛 */ + candidates: (source: CandidateSource, q?: string, limit = 20) => + get(`/directory/candidates${qs({ ...source, q: q || undefined, limit })}`), createGroup: (body: { name: string; label?: string }) => post('/groups', body), setMembers: (id: string, members: string[]) => put(`/groups/${encodeURIComponent(id)}/members`, { members }), deleteGroup: (id: string) => del(`/groups/${encodeURIComponent(id)}`), diff --git a/frontend/src/components/PrincipalPicker.tsx b/frontend/src/components/PrincipalPicker.tsx index 229061d9..c1b68c6e 100644 --- a/frontend/src/components/PrincipalPicker.tsx +++ b/frontend/src/components/PrincipalPicker.tsx @@ -5,15 +5,18 @@ * 名录里没有的、停用了的不让添加,说清是哪一个(账号密码登录时人和组都在本系统里建,名录里没有就是填错了);单点登录的 * 人和组第一次登录后才进名录,名录里没有的照实提醒,点「仍然添加」才加上。已选的里头名录里没有的、停用了的在标签上 * 标出来,不静默放着。 - * 使用者读不到名录:照填,服务端按名字认。关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。 + * 使用者读不到名录(只读与最小可见):给了 source 的(表单里的选人控件、个人应用提交审核)边输入边按开头取候选 + * (GET /api/directory/candidates,05-已知遗留 (b)),只有这件事能选的人、只有名字;加的时候按同一个池问一次服务端,不在池里的 + * 当场说清。提交时服务端照同一个池核对(不在池里 422),这里只是提前说。没给 source 的照填,服务端按名字认。 + * 关登录时没有用户表,只能手填名字——签批记的也是各自填的署名,这里不假装能核对。 */ -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { Plus, Users, UserRound, X } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { PRINCIPAL_TEXT } from '../lib/termsApp' import { useAtLeast, useAuth, useLoginOn } from '../store/auth' -import type { Directory } from '../typesApp' +import type { CandidateSource, Candidates, Directory } from '../typesApp' import { isComposing } from './ui' export interface Principal { @@ -29,8 +32,16 @@ export interface PrincipalPickerProps { /** 只能选一个 */ single?: boolean label?: string + /** + * 使用者取候选的池(服务端按用途算):表单里的选人控件(入口、待办)、个人应用提交审核。搭建者及以上照旧用整份名录, + * 不看它 + */ + source?: CandidateSource } +/** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */ +const ASK_DELAY_MS = 250 + /** 名录只取一次,同一页上几个选择器共用;没取到(断线)下次打开再取 */ let directory: Promise | null = null function loadDirectory(): Promise { @@ -46,15 +57,21 @@ function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled return !user ? 'missing' : user.disabled ? 'disabled' : null } -export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label }: PrincipalPickerProps) { +export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) { const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '') const loginOn = useLoginOn() // 名录给搭建者及以上(使用者读不到,不去取:取了也是 403) const listable = useAtLeast('builder') && loginOn + // 使用者:有 source 就按池取候选 + const asking = !listable && loginOn && !!source + const sourceKey = source ? JSON.stringify(source) : '' const oidc = useAuth((s) => s.me?.mode === 'oidc') const [kind, setKind] = useState<'user' | 'group'>('user') const [text, setText] = useState('') const [known, setKnown] = useState(null) + const [pool, setPool] = useState(null) + /** 取到过的候选:用户名 → 显示名(标签上写显示名,悬停给用户名) */ + const shown = useRef(new Map()) /** 刚才要加、名录里对不上的那一项:soft 是单点登录下名录里还没有(可以「仍然添加」),其余不让加 */ const [refused, setRefused] = useState<{ item: Principal; message: string; soft: boolean } | null>(null) useEffect(() => { @@ -63,12 +80,38 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single void loadDirectory().then((d) => { if (alive) setKnown(d) }) return () => { alive = false } }, [listable]) + useEffect(() => { + if (!asking || !source) return + let alive = true + const timer = window.setTimeout(() => { + api.identity.candidates(source, text.trim()) + .then((c) => { + if (!alive) return + for (const u of c.users) shown.current.set(u.username, u.display_name) + setPool(c) + }) + .catch(() => { if (alive) setPool(null) }) + }, ASK_DELAY_MS) + return () => { alive = false; window.clearTimeout(timer) } + // sourceKey 代替 source:调用方每次渲染都给一个新对象 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [asking, sourceKey, text]) const has = (p: Principal) => value.some((x) => x.kind === p.kind && x.name === p.name) - const add = (p: Principal, anyway = false) => { + const add = async (p: Principal, anyway = false) => { const item = { ...p, name: p.name.trim() } if (!item.name) return if (has(item)) { setText(''); setRefused(null); return } + if (asking && source && item.kind !== 'everyone') { + // 按同一个池问一次服务端:不在池里的当场说清(提交时服务端照样拒)。问不到(断线)就先收下,提交时由服务端判 + const found = await api.identity.candidates(source, item.name, 50).catch(() => null) + const listed = found && (item.kind === 'group' ? found.groups.some((g) => g.name === item.name) + : found.users.some((u) => u.username === item.name)) + if (found && !listed) { + setRefused({ item, soft: false, message: PRINCIPAL_TEXT.notCandidate(item.name) }) + return + } + } const problem = problemOf(item, known) const soft = problem === 'missing' && oidc if (problem && !(soft && anyway)) { @@ -87,9 +130,16 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single const remove = (p: Principal) => onChange(value.filter((x) => !(x.kind === p.kind && x.name === p.name))) const listId = `${uid}-list` const errorId = `${uid}-error` - const options = !known ? [] : kind === 'user' - ? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name })) - : known.groups.map((g) => ({ value: g.name, label: g.label })) + const options = asking + ? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name })) + : pool.groups.map((g) => ({ value: g.name, label: g.label }))) + : !known ? [] : kind === 'user' + ? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name })) + : known.groups.map((g) => ({ value: g.name, label: g.label })) + /** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */ + const poolHint = asking && pool + ? (pool.needs_query && !text.trim() ? PRINCIPAL_TEXT.typeToSearch : pool.truncated ? PRINCIPAL_TEXT.truncated : null) + : null return (
@@ -102,7 +152,9 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single data-principal-problem={problem ?? undefined} style={problem ? { color: 'var(--st-waiting)', borderColor: 'var(--st-waiting)' } : undefined}> {p.kind === 'group' ? : p.kind === 'user' ? : null} - {p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : p.name} + + {p.kind === 'everyone' ? PRINCIPAL_TEXT.everyone : shown.current.get(p.name) ?? p.name} + ({PRINCIPAL_TEXT[p.kind]}) {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}} {allowEveryone && ( )} @@ -151,12 +203,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single style={{ color: refused.soft ? 'var(--st-waiting)' : 'var(--err)' }} data-principal-refused={refused.soft ? 'soft' : 'hard'}> {refused.message} {refused.soft && ( - )}
)} + {poolHint &&
{poolHint}
} {!loginOn &&
{PRINCIPAL_TEXT.manualHint}
} ) diff --git a/frontend/src/components/SchemaForm.tsx b/frontend/src/components/SchemaForm.tsx index ffa52aaa..b2b0780f 100644 --- a/frontend/src/components/SchemaForm.tsx +++ b/frontend/src/components/SchemaForm.tsx @@ -12,7 +12,7 @@ import { useEffect, useId, useState } from 'react' import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react' import clsx from 'clsx' -import type { FormError, FormField, FormSchema } from '../typesApp' +import type { CandidateSource, FormError, FormField, FormSchema } from '../typesApp' import { FORM_TEXT } from '../lib/termsApp' import { fieldOfPath, fieldOrder, fileLimitsOf, localToRfc3339, optionsOf, rfc3339ToLocal, widgetOf, @@ -88,6 +88,16 @@ export function SchemaForm({ schema, value, onChange, errors, readOnly, onSubmit ) } +/** + * 选人控件的候选从哪个池取:表单用在入口(app_id + entry)、待办(task_id)上时,按这个字段问服务端(05-已知遗留 (b)); + * 蓝图试运行之类没有应用的地方不给(搭建者用整份名录) + */ +function principalSource(context: Record | undefined, field: string): CandidateSource | undefined { + if (context?.task_id) return { purpose: 'form', task_id: context.task_id, field } + if (context?.app_id && context.entry) return { purpose: 'form', app_id: context.app_id, entry: context.entry, field } + return undefined +} + function FieldRow({ id, name, field, required, value, error, readOnly, onChange, fileContext }: { id: string; name: string; field: FormField; required: boolean; value: unknown; error?: FormError readOnly: boolean; onChange: (v: unknown) => void; fileContext?: Record @@ -143,7 +153,7 @@ function FieldRow({ id, name, field, required, value, error, readOnly, onChange,
{label}
{readOnly ?
{typeof value === 'string' && value ? value : '—'}
- : onChange(list[0]?.name || undefined)} />} {foot} diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 13c5b33e..3c24e994 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -368,6 +368,10 @@ export const PRINCIPAL_TEXT = { /** 已选的那一项不在名录里、或已停用:标在标签上 */ missingTag: '名录里没有', disabledTag: '已停用', + /** 使用者选人:候选按这件事能选的人给(服务端 auth/directory 的候选池,05-已知遗留 (b)) */ + notCandidate: (name: string) => `「${name}」不在可选的人里,请从候选中选择`, + typeToSearch: '能选的人较多:输入名字的开头查找', + truncated: '只列出了一部分:多输入几个字缩小范围', } /** 身份:登录、账户菜单、角色(§4.4 AUTH_TEXT) */ @@ -445,6 +449,15 @@ export const SIGNER_TEXT = { record: '记入留痕', } +/** + * 个人渠道(设置 → 通知,范围是「仅自己」)里实例结束的两个事件:只推自己发起、而且是登录身份发起的实例(服务端 + * notify/service.notify_run,04-对外集成与触达 B1)。以前叫「实例完成 / 失败」,勾了却永远收不到 + */ +export const NOTIFY_PERSONAL_EVENT_LABEL: Record = { + 'run.finished': '我发起的实例完成', + 'run.failed': '我发起的实例失败', +} + /** 发布弹窗按角色收起的档位(Q8:受管级发布只给管理员,服务端 api/workflows 同样拒) */ export const PUBLISH_ROLE_TEXT = { governedAdminOnly: '需要管理员:以受管级发布只给管理员,请联系管理员发布', diff --git a/frontend/src/pages/apps/personal.tsx b/frontend/src/pages/apps/personal.tsx index c5298c15..2e10036d 100644 --- a/frontend/src/pages/apps/personal.tsx +++ b/frontend/src/pages/apps/personal.tsx @@ -171,7 +171,7 @@ function ReviewDialog({ appId, onClose, onSent }: {

{T.reviewIntro}

{T.reviewTo}
- +

{T.reviewToHint}

diff --git a/frontend/src/pages/settings/NotifyTab.tsx b/frontend/src/pages/settings/NotifyTab.tsx index d30ffda5..0be8ce32 100644 --- a/frontend/src/pages/settings/NotifyTab.tsx +++ b/frontend/src/pages/settings/NotifyTab.tsx @@ -6,6 +6,7 @@ import { } from '../../components/ui' import { formatDateTime } from '../../lib/format' import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms' +import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' import { useAtLeast } from '../../store/auth' import type { NotifyChannelRow } from '../../typesApp' @@ -18,6 +19,9 @@ import type { NotifyChannelRow } from '../../typesApp' const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const const EVENTS = Object.keys(T.eventLabel) +/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */ +const eventLabel = (event: string, scope: string): string => + (scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event export function NotifyTab() { const builder = useAtLeast('builder') @@ -86,7 +90,7 @@ export function NotifyTab() {
{row.url_host} - {row.events.length > 0 && ` · ${row.events.map((e) => T.eventLabel[e] ?? e).join('、')}`} + {row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}