mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
驳回出口之后,待办、通知、Webhook 照实说转到哪:中断内容带上驳回之后去哪(engine/replay.reject_route:接了驳回出口写转到哪几步,没接的写运行就此结束,审批没接、没设驳回即终止的写这一路到此为止),待办卡的提示、驳回按钮、驳回后的提示、处理过的「结果」照它说,不再一律写「转入驳回分支」「本次运行终止」;超时驳回不再露出英文结论码;双盲裁决、动作确认被驳回的提示同样照它说。子工作流里的关卡走驳回出口时,名字记成「子工作流 › x」(RunContext.global_label),走过的那几步交回父图的 trail,父运行的终态事件照实带上途经驳回,实例页、通知写「「子工作流 › 把关」被驳回,转到「子工作流 › 退回」」,不写内部 id、不写成已完成。定时任务的 Webhook:照驳回那条路成功结束的,标题、状态写途经驳回,另起一行写谁被驳回、转到哪一步
This commit is contained in:
@@ -1582,6 +1582,10 @@ async def _rejected_exits(session: AsyncSession, runs: list[Run], out: dict[str,
|
|||||||
names = {str(v.get("node_id")) for v in via or [] if isinstance(v, dict) and v.get("node_id")}
|
names = {str(v.get("node_id")) for v in via or [] if isinstance(v, dict) and v.get("node_id")}
|
||||||
if names:
|
if names:
|
||||||
gates[run_id] = names
|
gates[run_id] = names
|
||||||
|
for v in via or []:
|
||||||
|
# 子工作流里的关卡(「sub/x」)不在运行的图上:名字照终态事件上记的(「子工作流 › x」),不写内部 id
|
||||||
|
if isinstance(v, dict) and v.get("node_id") and isinstance(v.get("label"), str) and v["label"]:
|
||||||
|
out[run_id].end_labels.setdefault(str(v["node_id"]), v["label"])
|
||||||
if not gates:
|
if not gates:
|
||||||
return
|
return
|
||||||
wanted = sorted({n for names in gates.values() for n in names})
|
wanted = sorted({n for names in gates.values() for n in names})
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ def _wrap(
|
|||||||
"attempt": attempt + 1,
|
"attempt": attempt + 1,
|
||||||
"ts": time.time(),
|
"ts": time.time(),
|
||||||
}
|
}
|
||||||
return {**updates, "trail": [trail]}
|
# 节点自己交回的 trail(子工作流里走了驳回出口的那几步,multi._rejected_steps)排在这一步前面
|
||||||
|
return {**updates, "trail": [*(updates.get("trail") or []), trail]}
|
||||||
except GraphBubbleUp:
|
except GraphBubbleUp:
|
||||||
# interrupt() / Command 靠异常向上传递控制流,不是执行失败:
|
# interrupt() / Command 靠异常向上传递控制流,不是执行失败:
|
||||||
# 必须原样放行,否则人工介入会被当成节点报错吞掉。
|
# 必须原样放行,否则人工介入会被当成节点报错吞掉。
|
||||||
@@ -230,7 +231,10 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected,
|
|||||||
# 节点在驳回之前已经算出的(NodeRejected.exit_updates:双盲复算的出具缺口、台账条目)照样交出;驳回的那几个键在上面
|
# 节点在驳回之前已经算出的(NodeRejected.exit_updates:双盲复算的出具缺口、台账条目)照样交出;驳回的那几个键在上面
|
||||||
payload = {**(extra.get("payload") or {}), **error.exit_payload()}
|
payload = {**(extra.get("payload") or {}), **error.exit_payload()}
|
||||||
nodes = run_ctx.spec.node_map()
|
nodes = run_ctx.spec.node_map()
|
||||||
to = [{"node_id": run_ctx.global_id(t), "label": nodes[t].title if t in nodes else t} for t in dict.fromkeys(targets)]
|
# 名字按这次运行的说法记(子工作流里的写「子工作流 › x」,RunContext.global_label):父运行的图上没有子工作流里的节点,
|
||||||
|
# 实例页、通知、待办、Webhook 都照这个名字说
|
||||||
|
to = [{"node_id": run_ctx.global_id(t), "label": run_ctx.global_label(nodes[t].title if t in nodes else t)}
|
||||||
|
for t in dict.fromkeys(targets)]
|
||||||
try:
|
try:
|
||||||
artifact_id: str | None = await put_json(payload, kind="node_output", run_id=run_ctx.run_id,
|
artifact_id: str | None = await put_json(payload, kind="node_output", run_id=run_ctx.run_id,
|
||||||
node_id=run_ctx.global_id(node.id),
|
node_id=run_ctx.global_id(node.id),
|
||||||
@@ -244,7 +248,7 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected,
|
|||||||
outcome="rejected", **said, to=to, **({"evidence": ledger} if ledger else {}))
|
outcome="rejected", **said, to=to, **({"evidence": ledger} if ledger else {}))
|
||||||
updates: dict[str, Any] = {"nodes": {node.id: payload}, "trail": [{
|
updates: dict[str, Any] = {"nodes": {node.id: payload}, "trail": [{
|
||||||
"node_id": node.id, "type": str(node.type), "duration_ms": elapsed, "attempt": attempt, "ts": time.time(),
|
"node_id": node.id, "type": str(node.type), "duration_ms": elapsed, "attempt": attempt, "ts": time.time(),
|
||||||
"outcome": "rejected", "label": node.title, **said, "to": to}]}
|
"outcome": "rejected", "label": run_ctx.global_label(node.title), **said, "to": to}]}
|
||||||
if ledger:
|
if ledger:
|
||||||
updates["evidence"] = ledger
|
updates["evidence"] = ledger
|
||||||
if extra.get("usage"):
|
if extra.get("usage"):
|
||||||
|
|||||||
@@ -43,9 +43,16 @@ class RunContext:
|
|||||||
"""这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。"""
|
"""这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。"""
|
||||||
return f"{self.prefix}{node_id}"
|
return f"{self.prefix}{node_id}"
|
||||||
|
|
||||||
|
def global_label(self, title: str) -> str:
|
||||||
|
"""这张图里的节点标题 → 这次运行里给人看的名字:最外层原样,子工作流里接上子工作流的名字(「子工作流 › x」,嵌套的
|
||||||
|
一层层接上,同 multi.run_subgraph 记在异常上的 label)。父运行的图上没有子工作流里的节点,各处照这个名字说。"""
|
||||||
|
return f"{self.extra.get(LABEL_PREFIX_KEY) or ''}{title}"
|
||||||
|
|
||||||
|
|
||||||
#: 子工作流里的节点 id 前缀(RunContext.extra 里的键,engine/nodes/multi.run_subgraph 放)
|
#: 子工作流里的节点 id 前缀(RunContext.extra 里的键,engine/nodes/multi.run_subgraph 放)
|
||||||
PREFIX_KEY = "node_prefix"
|
PREFIX_KEY = "node_prefix"
|
||||||
|
#: 子工作流里的节点名前缀(「子工作流 › 」,同上):RunContext.global_label
|
||||||
|
LABEL_PREFIX_KEY = "node_label_prefix"
|
||||||
#: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给
|
#: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给
|
||||||
#: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都
|
#: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都
|
||||||
#: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发
|
#: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from app.core.errors import describe_exception, raw_detail
|
|||||||
from app.core.events import EventType
|
from app.core.events import EventType
|
||||||
from app.db.base import SessionLocal
|
from app.db.base import SessionLocal
|
||||||
from app.db.models import Workflow
|
from app.db.models import Workflow
|
||||||
from app.engine.context import PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer
|
from app.engine.context import LABEL_PREFIX_KEY, PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer
|
||||||
from app.engine.lineage import whole_ref
|
from app.engine.lineage import whole_ref
|
||||||
from app.engine.state import GraphState, message_text, template_context
|
from app.engine.state import GraphState, message_text, template_context
|
||||||
from app.engine.toolcalls import (
|
from app.engine.toolcalls import (
|
||||||
@@ -691,7 +691,8 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|||||||
# 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context:
|
# 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context:
|
||||||
# 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀
|
# 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀
|
||||||
extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")},
|
extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")},
|
||||||
PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", STREAM_KEY: stream_writer(ctx.run)},
|
PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", LABEL_PREFIX_KEY: f"{ctx.run.global_label(ctx.node.title)} › ",
|
||||||
|
STREAM_KEY: stream_writer(ctx.run)},
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx.emit(EventType.LOG, level="info",
|
ctx.emit(EventType.LOG, level="info",
|
||||||
@@ -753,6 +754,11 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]:
|
if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]:
|
||||||
updates["evidence"] = ledger
|
updates["evidence"] = ledger
|
||||||
|
if rejected := _rejected_steps(result_state, ctx.node.id):
|
||||||
|
# 子工作流里走了驳回出口的那几步(engine/compiler._rejected_exit 记的 outcome=rejected)交回父图的 trail:运行收尾
|
||||||
|
# 据此在终态事件上照实写「途经驳回」(runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」
|
||||||
|
# 被驳回、转到哪。名字、转到哪几步在子图里已经按这次运行的说法记好(global_label、global_id),这里只接上节点 id 的前缀
|
||||||
|
updates["trail"] = rejected
|
||||||
var_name = ctx.cfg("assign_to", "")
|
var_name = ctx.cfg("assign_to", "")
|
||||||
if var_name:
|
if var_name:
|
||||||
updates["vars"] = {var_name: output}
|
updates["vars"] = {var_name: output}
|
||||||
@@ -763,6 +769,12 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
|
|||||||
_LEDGER_NODE_KEYS = ("node_id", "card")
|
_LEDGER_NODE_KEYS = ("node_id", "card")
|
||||||
|
|
||||||
|
|
||||||
|
def _rejected_steps(sub_state: dict[str, Any], node_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""子工作流的 trail 里走了驳回出口的那几步,节点 id 换成父图这一层的(「子工作流节点 id/…」)。"""
|
||||||
|
return [{**t, "node_id": f"{node_id}/{t.get('node_id')}"} for t in sub_state.get("trail") or []
|
||||||
|
if isinstance(t, dict) and t.get("outcome") == "rejected"]
|
||||||
|
|
||||||
|
|
||||||
def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]:
|
def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]:
|
||||||
"""子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。"""
|
"""子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。"""
|
||||||
out = dict(entry)
|
out = dict(entry)
|
||||||
|
|||||||
@@ -75,4 +75,32 @@ async def ask(ctx: NodeContext, payload: dict[str, Any], **request: Any) -> Any:
|
|||||||
答复真正到达的那次执行里出现。
|
答复真正到达的那次执行里出现。
|
||||||
"""
|
"""
|
||||||
await once(ctx, EventType.HUMAN_REQUESTED, **request)
|
await once(ctx, EventType.HUMAN_REQUESTED, **request)
|
||||||
return interrupt(payload)
|
route = reject_route(ctx) if isinstance(payload, dict) else None
|
||||||
|
return interrupt({**payload, "reject_route": route} if route else payload)
|
||||||
|
|
||||||
|
|
||||||
|
def reject_route(ctx: NodeContext) -> dict[str, Any] | None:
|
||||||
|
"""驳回之后去哪(总方案 §3 的驳回出口),放进中断内容:待办卡的提示、驳回之后的那句、处理过的结果照它说,不再一律写
|
||||||
|
「转入驳回分支」或「本次运行终止」。
|
||||||
|
|
||||||
|
- {"to": [{node_id, label}]}:接了驳回出口,驳回后转到这几步(节点 id、名字按这次运行的说法:子工作流里的写
|
||||||
|
「子工作流 › x」,同 compiler._rejected_exit 记在 node.finished 上的 to);
|
||||||
|
- {"ends": "run"}:运行就此结束、记为被驳回(没接驳回出口的关卡;审批设了驳回即终止时驳回出口走不到,也是这样);
|
||||||
|
- {"ends": "path"}:审批模式没接驳回出口、没设驳回即终止:这一路到审批为止,不再往下走(engine/nodes/human)。
|
||||||
|
|
||||||
|
不是关卡的节点(agent 自己调工具时的审批:驳回了 agent 照常往下想)为 None,中断内容和以前一字不差。"""
|
||||||
|
from app.engine.schema import GATE_TYPES, approve_mode, reject_targets
|
||||||
|
|
||||||
|
node, spec = ctx.node, ctx.run.spec
|
||||||
|
if node.type not in GATE_TYPES:
|
||||||
|
return None
|
||||||
|
approving = approve_mode(spec, node)
|
||||||
|
if approving and bool(ctx.cfg("stop_on_reject", False)):
|
||||||
|
return {"ends": "run"}
|
||||||
|
targets = reject_targets(spec, node)
|
||||||
|
if targets:
|
||||||
|
nodes = spec.node_map()
|
||||||
|
return {"to": [{"node_id": ctx.run.global_id(t),
|
||||||
|
"label": ctx.run.global_label(nodes[t].title if t in nodes else t)}
|
||||||
|
for t in dict.fromkeys(targets)]}
|
||||||
|
return {"ends": "path" if approving else "run"}
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点
|
|||||||
REJECTED_HEADLINE = "未出具(被驳回)"
|
REJECTED_HEADLINE = "未出具(被驳回)"
|
||||||
REJECTED_STATUS = "被驳回"
|
REJECTED_STATUS = "被驳回"
|
||||||
REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看"
|
REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看"
|
||||||
|
#: 照驳回那条路成功结束的(驳回出口):标题、状态后面接这一句
|
||||||
|
VIA_REJECTED = "(途经驳回)"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -118,6 +120,9 @@ class Notice:
|
|||||||
error_node: str | None = None
|
error_node: str | None = None
|
||||||
#: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步
|
#: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步
|
||||||
end_reason: str | None = None
|
end_reason: str | None = None
|
||||||
|
#: 照驳回那条路成功结束的运行途经的驳回出口(run.finished 的 rejected_via,engine/runner._rejected_via):标题、状态写
|
||||||
|
#: 途经驳回,另起一行写谁被驳回、转到哪一步(同实例页 apps/resolve.rejected_exit_text)。没途经驳回的为 None
|
||||||
|
rejected_via: list[dict[str, Any]] | None = None
|
||||||
next_fire: bool = False
|
next_fire: bool = False
|
||||||
#: 事件触发的那一次(fire_kind=event):事件的说法(scheduler/triggers.event_text),不含出口字段的值、变化的内容
|
#: 事件触发的那一次(fire_kind=event):事件的说法(scheduler/triggers.event_text),不含出口字段的值、变化的内容
|
||||||
trigger_text: str | None = None
|
trigger_text: str | None = None
|
||||||
@@ -130,6 +135,11 @@ class Notice:
|
|||||||
def rejected(self) -> bool:
|
def rejected(self) -> bool:
|
||||||
return self.status == "cancelled" and self.end_reason == "rejected"
|
return self.status == "cancelled" and self.end_reason == "rejected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def via_rejection(self) -> bool:
|
||||||
|
"""成功结束、途经驳回出口(审批接了驳回出口、按驳回那条路走完):状态是 succeeded,但不能只说「运行完成」。"""
|
||||||
|
return self.status == "succeeded" and bool(self.rejected_via)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def never_started(self) -> bool:
|
def never_started(self) -> bool:
|
||||||
"""失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。"""
|
"""失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。"""
|
||||||
@@ -160,10 +170,18 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]:
|
|||||||
headline = REJECTED_HEADLINE
|
headline = REJECTED_HEADLINE
|
||||||
else:
|
else:
|
||||||
headline = _HEADLINE.get(notice.event, notice.event)
|
headline = _HEADLINE.get(notice.event, notice.event)
|
||||||
|
if notice.via_rejection:
|
||||||
|
headline += VIA_REJECTED
|
||||||
title = f"定时任务「{notice.schedule_name}」{headline}"
|
title = f"定时任务「{notice.schedule_name}」{headline}"
|
||||||
lines: list[tuple[str, str]] = []
|
lines: list[tuple[str, str]] = []
|
||||||
if notice.status:
|
if notice.status:
|
||||||
lines.append(("状态", _status_label(notice)))
|
lines.append(("状态", _status_label(notice)))
|
||||||
|
if notice.via_rejection:
|
||||||
|
from app.apps.resolve import rejected_exit_text
|
||||||
|
|
||||||
|
# 一个关卡一句:「「把关」被驳回,转到「退回申请人」」(名字是搭图时起的,不带查询到的数据)
|
||||||
|
lines.append(("驳回", ";".join(f"「{str(v.get('label') or v.get('node_id') or '')[:80]}」{rejected_exit_text(v)}"
|
||||||
|
for v in notice.rejected_via or [] if isinstance(v, dict))))
|
||||||
if notice.tier:
|
if notice.tier:
|
||||||
gaps = f"(缺口 {notice.gaps} 条)" if notice.gaps is not None else ""
|
gaps = f"(缺口 {notice.gaps} 条)" if notice.gaps is not None else ""
|
||||||
lines.append(("出具档位", f"{ISSUANCE_LABEL.get(notice.tier, notice.tier)}{gaps}"))
|
lines.append(("出具档位", f"{ISSUANCE_LABEL.get(notice.tier, notice.tier)}{gaps}"))
|
||||||
@@ -244,6 +262,8 @@ def _status_label(notice: Notice) -> str:
|
|||||||
return "已挂起,等待核实" if notice.action_pending else "已挂起,可继续运行"
|
return "已挂起,等待核实" if notice.action_pending else "已挂起,可继续运行"
|
||||||
if notice.rejected:
|
if notice.rejected:
|
||||||
return REJECTED_STATUS
|
return REJECTED_STATUS
|
||||||
|
if notice.via_rejection:
|
||||||
|
return f"{_STATUS_LABEL['succeeded']}{VIA_REJECTED}"
|
||||||
return _STATUS_LABEL.get(notice.status or "", notice.status or "")
|
return _STATUS_LABEL.get(notice.status or "", notice.status or "")
|
||||||
|
|
||||||
|
|
||||||
@@ -554,12 +574,23 @@ async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run
|
|||||||
error_node=_failed_node(run, await _end_label(session, run))
|
error_node=_failed_node(run, await _end_label(session, run))
|
||||||
if run is not None and (run.status == "failed" or run.end_reason == "rejected") else None,
|
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,
|
end_reason=run.end_reason if run is not None else None,
|
||||||
|
rejected_via=await _rejected_via_of(session, run) if run is not None and run.status == "succeeded" else None,
|
||||||
# 事件触发的任务没有下一次「到点」,启用着就是下一次事件来了照常发起
|
# 事件触发的任务没有下一次「到点」,启用着就是下一次事件来了照常发起
|
||||||
next_fire=bool(schedule.enabled and (schedule.next_run_at is not None or schedule.trigger_kind == "event")),
|
next_fire=bool(schedule.enabled and (schedule.next_run_at is not None or schedule.trigger_kind == "event")),
|
||||||
trigger_text=_trigger_text(fire),
|
trigger_text=_trigger_text(fire),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _rejected_via_of(session: Any, run: Run) -> list[dict[str, Any]] | None:
|
||||||
|
"""这次运行途经的驳回出口:最后一条 run.finished 上的 rejected_via(子工作流里的那几步名字写「子工作流 › x」),没有为 None。"""
|
||||||
|
data = await session.scalar(select(RunEvent.data).where(RunEvent.run_id == run.id, RunEvent.type == "run.finished")
|
||||||
|
.order_by(RunEvent.seq.desc()).limit(1))
|
||||||
|
via = data.get("rejected_via") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(via, list):
|
||||||
|
return None
|
||||||
|
return [v for v in via if isinstance(v, dict)] or None
|
||||||
|
|
||||||
|
|
||||||
def _trigger_text(fire: ScheduleFire) -> str | None:
|
def _trigger_text(fire: ScheduleFire) -> str | None:
|
||||||
event = fire.inputs.get("event") if isinstance(fire.inputs, dict) else None
|
event = fire.inputs.get("event") if isinstance(fire.inputs, dict) else None
|
||||||
text = event.get("text") if isinstance(event, dict) else None
|
text = event.get("text") if isinstance(event, dict) else None
|
||||||
|
|||||||
@@ -376,8 +376,10 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch,
|
|||||||
approval = await pending(run.id)
|
approval = await pending(run.id)
|
||||||
assert approval.mode == "input" and approval.node_id == "dual"
|
assert approval.mode == "input" and approval.node_id == "dual"
|
||||||
payload = approval.payload
|
payload = approval.payload
|
||||||
# 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败
|
# 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败;reject_route
|
||||||
assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject"}
|
# (驳回之后去哪,engine/replay.reject_route):没接驳回出口,驳回后运行就此结束
|
||||||
|
assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject", "reject_route"}
|
||||||
|
assert payload["reject_route"] == {"ends": "run"}
|
||||||
assert payload["kind"] == "dual_check" and payload["node_id"] == "dual" and payload["mode"] == "input"
|
assert payload["kind"] == "dual_check" and payload["node_id"] == "dual" and payload["mode"] == "input"
|
||||||
assert payload["on_reject"] == "degrade"
|
assert payload["on_reject"] == "degrade"
|
||||||
[item] = payload["summary"]
|
[item] = payload["summary"]
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""驳回出口之后,待办、通知、定时任务的 Webhook 照实说「被驳回,转到 X」(docs/design/orchestration-round/00-总方案.md §3)。
|
||||||
|
|
||||||
|
实例页已经这么说(test_reject_exit.test_the_instance_says_rejected_and_where_it_went)。这里对的是另外几处:
|
||||||
|
- 待办:中断内容带着驳回之后去哪(reject_route):接了驳回出口的写转到哪几步,没接的写运行就此结束,审批模式没接、没设
|
||||||
|
驳回即终止的写这一路到此为止。待办卡的提示、驳回后的提示、处理过的结果照它说,不再一律写「转入驳回分支」「本次运行终止」;
|
||||||
|
- 子工作流里的关卡走了驳回出口:父运行的终态事件照实带上(「子工作流 › 把关」被驳回,转到「子工作流 › 退回」),实例页、
|
||||||
|
通知说这一句,不写内部 id、不写成「已完成」;
|
||||||
|
- 定时任务的 Webhook:途经驳回、照驳回那条路成功结束的,标题和状态写途经驳回,另起一行写谁被驳回、转到哪一步。
|
||||||
|
图和数据都是虚构的。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import SessionLocal
|
||||||
|
from app.db.models import Run, RunEvent, Task
|
||||||
|
from app.engine.runner import run_manager
|
||||||
|
from tests import ledger_samples as S
|
||||||
|
|
||||||
|
|
||||||
|
@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}}
|
||||||
|
|
||||||
|
|
||||||
|
START = node("start", "input", fields=[{"name": "q"}])
|
||||||
|
DONE = node("done", "output", "办结", fields=[{"name": "结果", "value": "通过了"}])
|
||||||
|
BACK = node("back", "output", "退回申请人", fields=[{"name": "结果", "value": "退回:{{ nodes.gate.note }}"}])
|
||||||
|
|
||||||
|
|
||||||
|
def gated(gate: dict, *, exit_to: dict | None = BACK) -> dict:
|
||||||
|
normal = "approved" if gate["data"]["config"].get("mode") == "approve" else "out"
|
||||||
|
nodes = [START, gate, DONE] + ([exit_to] if exit_to else [])
|
||||||
|
edges = [{"source": "start", "target": "gate"}, {"source": "gate", "target": "done", "sourceHandle": normal}]
|
||||||
|
if exit_to:
|
||||||
|
edges.append({"source": "gate", "target": exit_to["id"], "sourceHandle": "rejected"})
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
|
|
||||||
|
|
||||||
|
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 and not run_manager.is_active(run_id):
|
||||||
|
return row
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _parked(graph: dict) -> tuple[Run, dict]:
|
||||||
|
"""发起、停在关卡上,交回运行和那条待办的中断内容。"""
|
||||||
|
run = await run_manager.start(graph=graph, input_payload={"q": "x"})
|
||||||
|
await _wait(run.id, ("interrupted",))
|
||||||
|
await run_manager.wait_idle(run.id)
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
[task] = list((await session.execute(select(Task).where(Task.run_id == run.id))).scalars())
|
||||||
|
from app.db.models import Approval
|
||||||
|
|
||||||
|
approval = await session.get(Approval, task.approval_id)
|
||||||
|
return run, dict(approval.payload or {})
|
||||||
|
|
||||||
|
|
||||||
|
APPROVE = node("gate", "human", "把关", mode="approve", title="可以吗")
|
||||||
|
INPUT = node("gate", "human", "补充", mode="input", title="补一下")
|
||||||
|
TOOL = node("gate", "tool", "算一下", tool="calculator", args={"expression": "1+1"}, approval="always")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("gate", [APPROVE, INPUT, TOOL], ids=["approve", "input", "tool"])
|
||||||
|
async def test_the_task_says_where_a_rejection_goes(gate):
|
||||||
|
_, payload = await _parked(gated(gate))
|
||||||
|
assert payload["reject_route"] == {"to": [{"node_id": "back", "label": "退回申请人"}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("gate", [INPUT, TOOL], ids=["input", "tool"])
|
||||||
|
async def test_without_the_exit_the_task_says_the_run_ends(gate):
|
||||||
|
_, payload = await _parked(gated(gate, exit_to=None))
|
||||||
|
assert payload["reject_route"] == {"ends": "run"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_approval_without_the_exit_says_this_path_stops_here():
|
||||||
|
_, payload = await _parked(gated(APPROVE, exit_to=None))
|
||||||
|
assert payload["reject_route"] == {"ends": "path"}
|
||||||
|
stop = node("gate", "human", "把关", mode="approve", title="可以吗", stop_on_reject=True)
|
||||||
|
_, payload = await _parked(gated(stop))
|
||||||
|
assert payload["reject_route"] == {"ends": "run"}, "驳回即终止优先:驳回出口那条线走不到"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_agent_tool_approval_has_no_route():
|
||||||
|
"""agent 自己调工具的审批不是关卡:驳回了 agent 照常往下想,中断内容不带 reject_route。"""
|
||||||
|
from app.engine.replay import reject_route
|
||||||
|
from app.engine.schema import GraphSpec
|
||||||
|
|
||||||
|
spec = GraphSpec.model_validate(gated(node("gate", "agent", "助手", prompt="x")))
|
||||||
|
|
||||||
|
class Ctx:
|
||||||
|
class run: # noqa: N801 - 只要 spec 和 global_id
|
||||||
|
pass
|
||||||
|
|
||||||
|
ctx = Ctx()
|
||||||
|
ctx.node = spec.node_map()["gate"]
|
||||||
|
ctx.run.spec = spec
|
||||||
|
assert reject_route(ctx) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 子工作流里的关卡走了驳回出口
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _sub_rejected() -> Run:
|
||||||
|
inner = {"nodes": [node("s", "input"), node("gate", "human", "把关", mode="approve", title="可以吗"),
|
||||||
|
node("ok", "output", "通过", fields=[{"name": "r", "value": "ok"}]),
|
||||||
|
node("back", "output", "退回", fields=[{"name": "r", "value": "退回:{{ nodes.gate.note }}"}])],
|
||||||
|
"edges": [{"source": "s", "target": "gate"},
|
||||||
|
{"source": "gate", "target": "ok", "sourceHandle": "approved"},
|
||||||
|
{"source": "gate", "target": "back", "sourceHandle": "rejected"}]}
|
||||||
|
sub_id = await S.publish("复核子流程", graph=inner, level="draft")
|
||||||
|
graph = S.chain(node("start", "input"), node("sub", "subgraph", "复核子流程", workflow_id=sub_id, workflow_version=1),
|
||||||
|
node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}]))
|
||||||
|
run = await run_manager.start(graph=graph, input_payload={})
|
||||||
|
await _wait(run.id, ("interrupted",))
|
||||||
|
await run_manager.wait_idle(run.id)
|
||||||
|
await run_manager.resume(run.id, {"approved": False, "note": "不对"}, actor="张工")
|
||||||
|
return await _wait(run.id, ("cancelled", "failed", "succeeded"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_reject_exit_inside_a_subworkflow_is_named_in_the_parent():
|
||||||
|
from app.apps.resolve import _trails, instance_state
|
||||||
|
|
||||||
|
final = await _sub_rejected()
|
||||||
|
assert final.status == "succeeded", final.error
|
||||||
|
assert final.output == {"r": "退回:不对"}
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
[ended] = list((await session.execute(select(RunEvent).where(
|
||||||
|
RunEvent.run_id == final.id, RunEvent.type == "run.finished"))).scalars())
|
||||||
|
run = await session.get(Run, final.id)
|
||||||
|
state = instance_state(run, (await _trails(session, [run]))[run.id])
|
||||||
|
[via] = ended.data["rejected_via"]
|
||||||
|
assert (via["node_id"], via["label"]) == ("sub/gate", "复核子流程 › 把关")
|
||||||
|
assert via["to"] == [{"node_id": "sub/back", "label": "复核子流程 › 退回"}]
|
||||||
|
# 实例页、通知(notify_run 取的就是这一句):照实说被驳回、转到哪,不写「sub/gate」,不画成已完成
|
||||||
|
assert state == {"code": "cancelled", "text": "「复核子流程 › 把关」被驳回,转到「复核子流程 › 退回」"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_inner_task_names_the_route_with_the_subworkflow():
|
||||||
|
inner = {"nodes": [node("s", "input"), node("gate", "human", "补充", mode="input", title="补一下"),
|
||||||
|
node("back", "output", "退回", fields=[{"name": "r", "value": "x"}]),
|
||||||
|
node("ok", "output", "通过", fields=[{"name": "r", "value": "y"}])],
|
||||||
|
"edges": [{"source": "s", "target": "gate"}, {"source": "gate", "target": "ok"},
|
||||||
|
{"source": "gate", "target": "back", "sourceHandle": "rejected"}]}
|
||||||
|
sub_id = await S.publish("补充子流程", graph=inner, level="draft")
|
||||||
|
graph = S.chain(node("start", "input"), node("sub", "subgraph", "补充子流程", workflow_id=sub_id, workflow_version=1),
|
||||||
|
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}]))
|
||||||
|
_, payload = await _parked(graph)
|
||||||
|
assert payload["reject_route"] == {"to": [{"node_id": "sub/back", "label": "补充子流程 › 退回"}]}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 定时任务的 Webhook
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_webhook_says_a_run_went_through_a_rejection():
|
||||||
|
from app.scheduler.webhook import Notice, message
|
||||||
|
|
||||||
|
via = [{"node_id": "gate", "label": "把关", "kind": "approve", "decision": "reject", "actor": "张工", "note": "不对",
|
||||||
|
"to": [{"node_id": "back", "label": "退回申请人"}]}]
|
||||||
|
title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC",
|
||||||
|
status="succeeded", rejected_via=via))
|
||||||
|
assert "途经驳回" in title
|
||||||
|
said = dict(lines)
|
||||||
|
assert said["状态"] == "已完成(途经驳回)"
|
||||||
|
assert said["驳回"] == "「把关」被驳回,转到「退回申请人」"
|
||||||
|
# 没途经驳回的照旧
|
||||||
|
title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC",
|
||||||
|
status="succeeded"))
|
||||||
|
assert title == "定时任务「周报」运行完成" and dict(lines)["状态"] == "已完成" and "驳回" not in dict(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_webhook_notice_reads_the_route_from_the_end_event():
|
||||||
|
from app.scheduler.webhook import _rejected_via_of
|
||||||
|
|
||||||
|
final = await _sub_rejected()
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
run = await session.get(Run, final.id)
|
||||||
|
via = await _rejected_via_of(session, run)
|
||||||
|
assert [v["label"] for v in via] == ["复核子流程 › 把关"]
|
||||||
@@ -5075,6 +5075,8 @@ export const DUAL_CHECK_TEXT = {
|
|||||||
/** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */
|
/** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */
|
||||||
reject: '驳回并终止',
|
reject: '驳回并终止',
|
||||||
emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因',
|
emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因',
|
||||||
|
/** 节点接了驳回出口(中断内容的 reject_route):驳回后转到那一步 */
|
||||||
|
emptyRejectRoute: (goes: string) => `驳回后${goes}。可以在备注中说明原因`,
|
||||||
rejected: '已驳回,本次运行终止',
|
rejected: '已驳回,本次运行终止',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const TASK_TEXT = {
|
|||||||
group: (name: string) => `组「${name}」`,
|
group: (name: string) => `组「${name}」`,
|
||||||
},
|
},
|
||||||
assignmentStatus: { pending: '待回复', responded: '已回复', withdrawn: '已撤回' },
|
assignmentStatus: { pending: '待回复', responded: '已回复', withdrawn: '已撤回' },
|
||||||
decision: { approve: '同意', reject: '驳回', submit: '已提交', confirmed: '已生效', not_applied_resend: '重新发送',
|
decision: { approve: '同意', reject: '驳回', expired_reject: '超时驳回', submit: '已提交', confirmed: '已生效', not_applied_resend: '重新发送',
|
||||||
not_applied_abandon: '放弃' } as Record<string, string>,
|
not_applied_abandon: '放弃' } as Record<string, string>,
|
||||||
verified: '已登录核实',
|
verified: '已登录核实',
|
||||||
unverified: '自报署名',
|
unverified: '自报署名',
|
||||||
@@ -125,9 +125,13 @@ export const TASK_TEXT = {
|
|||||||
editedContent: '修改后的内容',
|
editedContent: '修改后的内容',
|
||||||
approveHint: '批准:继续运行;驳回:转入「驳回」分支',
|
approveHint: '批准:继续运行;驳回:转入「驳回」分支',
|
||||||
inputHint: '提交:以你填写的内容继续运行;驳回:终止本次运行',
|
inputHint: '提交:以你填写的内容继续运行;驳回:终止本次运行',
|
||||||
|
/** 驳回之后去哪(中断内容的 reject_route,见 REJECT_ROUTE_TEXT):带了的按它说,老待办没带的照上面两句 */
|
||||||
|
approveHintRoute: (goes: string) => `批准:继续运行;驳回:${goes}`,
|
||||||
|
inputHintRoute: (goes: string) => `提交:以你填写的内容继续运行;驳回:${goes}`,
|
||||||
doneApprove: '已批准,运行继续',
|
doneApprove: '已批准,运行继续',
|
||||||
doneReject: '已驳回,转入驳回分支',
|
doneReject: '已驳回,转入驳回分支',
|
||||||
doneRejectStop: '已驳回,本次运行终止',
|
doneRejectStop: '已驳回,本次运行终止',
|
||||||
|
doneRejectRoute: (goes: string) => `已驳回,${goes}`,
|
||||||
doneSubmit: '已提交,运行继续',
|
doneSubmit: '已提交,运行继续',
|
||||||
doneVote: '已记下你的意见,等待其他人处理',
|
doneVote: '已记下你的意见,等待其他人处理',
|
||||||
formInvalid: (n: number) => `有 ${n} 项需要修改`,
|
formInvalid: (n: number) => `有 ${n} 项需要修改`,
|
||||||
@@ -154,6 +158,34 @@ export const TASK_TEXT = {
|
|||||||
code: '代码',
|
code: '代码',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 驳回之后去哪:待办的中断内容带着 reject_route(服务端 engine/replay.reject_route,总方案 §3 的驳回出口)。
|
||||||
|
* - to:接了驳回出口,驳回后转到这几步(名字是这次运行里的说法:子工作流里的写「子工作流 › x」);
|
||||||
|
* - ends=run:没接驳回出口(审批设了驳回即终止的也是),驳回后运行就此结束、记为被驳回;
|
||||||
|
* - ends=path:审批没接驳回出口、没设驳回即终止,这一路到审批为止,不再往下走。
|
||||||
|
* 老待办没带的为 null,各处照原来的说法
|
||||||
|
*/
|
||||||
|
export type RejectRoute = { to: string[] } | { ends: 'run' | 'path' }
|
||||||
|
|
||||||
|
export function rejectRouteOf(payload: Record<string, unknown> | null | undefined): RejectRoute | null {
|
||||||
|
const raw = payload?.reject_route
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const r = raw as { to?: unknown; ends?: unknown }
|
||||||
|
if (Array.isArray(r.to)) {
|
||||||
|
const to = r.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean)
|
||||||
|
return to.length ? { to } : null
|
||||||
|
}
|
||||||
|
return r.ends === 'run' || r.ends === 'path' ? { ends: r.ends } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REJECT_ROUTE_TEXT = {
|
||||||
|
/** 「转到「通知申请人」」/「本次运行终止」/「这一路到此为止,不再往下走」 */
|
||||||
|
goes: (r: RejectRoute): string => ('to' in r ? `转到${r.to.map((t) => `「${t}」`).join('、')}`
|
||||||
|
: r.ends === 'run' ? '本次运行终止' : '这一路到此为止,不再往下走'),
|
||||||
|
/** 处理过的待办「结果」那一格:驳回之后去了哪 */
|
||||||
|
outcome: (decision: string, r: RejectRoute): string => `${decision},${REJECT_ROUTE_TEXT.goes(r)}`,
|
||||||
|
}
|
||||||
|
|
||||||
/** 动作确认的哈希卡(§3.8:确认时看过的就是发出去的) */
|
/** 动作确认的哈希卡(§3.8:确认时看过的就是发出去的) */
|
||||||
export const ACTION_CONFIRM_TEXT = {
|
export const ACTION_CONFIRM_TEXT = {
|
||||||
head: '确认后发送',
|
head: '确认后发送',
|
||||||
@@ -171,6 +203,8 @@ export const ACTION_CONFIRM_TEXT = {
|
|||||||
noHash: '缺少内容哈希,无法确认发送',
|
noHash: '缺少内容哈希,无法确认发送',
|
||||||
done: '已确认,按此内容发送',
|
done: '已确认,按此内容发送',
|
||||||
rejected: '已驳回,不发送',
|
rejected: '已驳回,不发送',
|
||||||
|
/** 驳回之后去哪(reject_route):「已驳回,不发送,转到「通知申请人」」 */
|
||||||
|
rejectedRoute: (goes: string) => `已驳回,不发送,${goes}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 核实结果未知的动作:三个选项(§2.5 A5 第 4 条) */
|
/** 核实结果未知的动作:三个选项(§2.5 A5 第 4 条) */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Spinner, useToast } from '../components/ui'
|
|||||||
import { segmentState } from '../lib/evidence'
|
import { segmentState } from '../lib/evidence'
|
||||||
import { NONE } from '../lib/format'
|
import { NONE } from '../lib/format'
|
||||||
import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms'
|
import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms'
|
||||||
|
import { REJECT_ROUTE_TEXT, TASK_TEXT, rejectRouteOf } from '../lib/termsApp'
|
||||||
import { patchValueText } from './catalogPatch'
|
import { patchValueText } from './catalogPatch'
|
||||||
import { RationaleDoc } from './ChallengeSummary'
|
import { RationaleDoc } from './ChallengeSummary'
|
||||||
import type {
|
import type {
|
||||||
@@ -421,6 +422,9 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp
|
|||||||
const summary = useMemo(() => summaryOf(payload.summary), [payload.summary])
|
const summary = useMemo(() => summaryOf(payload.summary), [payload.summary])
|
||||||
const cardId = str(payload.card_artifact) ?? ''
|
const cardId = str(payload.card_artifact) ?? ''
|
||||||
const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject)
|
const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject)
|
||||||
|
// 驳回之后去哪(中断内容的 reject_route):接了驳回出口的,驳回后转到那一步,不是「本次运行终止」
|
||||||
|
const route = rejectRouteOf(payload)
|
||||||
|
const routed = route && 'to' in route ? REJECT_ROUTE_TEXT.goes(route) : null
|
||||||
|
|
||||||
const [card, setCard] = useState<{ id: string; data?: Card | null } | null>(null)
|
const [card, setCard] = useState<{ id: string; data?: Card | null } | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -468,7 +472,7 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp
|
|||||||
}
|
}
|
||||||
// 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论
|
// 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论
|
||||||
const rejected = Object.values(value.choices).some((c) => c !== 'a')
|
const rejected = Object.values(value.choices).some((c) => c !== 'a')
|
||||||
toast(!approved ? T.rejected : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok')
|
toast(!approved ? (routed ? TASK_TEXT.doneRejectRoute(routed) : T.rejected) : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e)
|
toast.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -514,9 +518,11 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp
|
|||||||
{noteField}
|
{noteField}
|
||||||
<button type="button" className="btn btn-danger w-full justify-center" disabled={busy} aria-describedby={`${uid}-reject`}
|
<button type="button" className="btn btn-danger w-full justify-center" disabled={busy} aria-describedby={`${uid}-reject`}
|
||||||
data-dual-reject="" onClick={() => void send(false)}>
|
data-dual-reject="" onClick={() => void send(false)}>
|
||||||
{busy ? <Spinner size={11} /> : <Ban size={12} aria-hidden />} {T.reject}
|
{busy ? <Spinner size={11} /> : <Ban size={12} aria-hidden />} {routed ? TASK_TEXT.reject : T.reject}
|
||||||
</button>
|
</button>
|
||||||
<div id={`${uid}-reject`} className="text-2xs leading-relaxed text-dim">{T.emptyReject}</div>
|
<div id={`${uid}-reject`} className="text-2xs leading-relaxed text-dim">
|
||||||
|
{routed ? T.emptyRejectRoute(routed) : T.emptyReject}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, INQUIRY_TEXT, RECONCILE_TEXT, SIGNAL_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL,
|
ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, INQUIRY_TEXT, RECONCILE_TEXT, SIGNAL_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL,
|
||||||
TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName,
|
TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName,
|
||||||
} from '../lib/terms'
|
} from '../lib/terms'
|
||||||
import { REVIEW_TASK_TEXT } from '../lib/termsApp'
|
import { REJECT_ROUTE_TEXT, REVIEW_TASK_TEXT, rejectRouteOf } from '../lib/termsApp'
|
||||||
import { RECALL_TEXT } from '../lib/termsRecall'
|
import { RECALL_TEXT } from '../lib/termsRecall'
|
||||||
import { blueprintPath } from '../pages/blueprints/shared'
|
import { blueprintPath } from '../pages/blueprints/shared'
|
||||||
import type { Approval, ApprovalReport } from '../types'
|
import type { Approval, ApprovalReport } from '../types'
|
||||||
@@ -102,6 +102,9 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
|
|||||||
const runClass: 'formal' | 'exploratory' | null = task.run_class ?? (task.app_id ? 'formal'
|
const runClass: 'formal' | 'exploratory' | null = task.run_class ?? (task.app_id ? 'formal'
|
||||||
: payload.run_class === 'formal' || payload.run_class === 'exploratory' ? payload.run_class : null)
|
: payload.run_class === 'formal' || payload.run_class === 'exploratory' ? payload.run_class : null)
|
||||||
const binary = kind === 'approve' || kind === 'tool' || kind === 'code'
|
const binary = kind === 'approve' || kind === 'tool' || kind === 'code'
|
||||||
|
// 驳回之后去哪(中断内容的 reject_route):带了的照实说转到哪一步、运行终止还是这一路到此为止;老待办没带的照原来的说法
|
||||||
|
const route = rejectRouteOf(payload)
|
||||||
|
const stops = route ? 'ends' in route && route.ends === 'run' : !binary
|
||||||
const trustKey: string | undefined = kind === 'tool' && runClass !== 'formal'
|
const trustKey: string | undefined = kind === 'tool' && runClass !== 'formal'
|
||||||
? (typeof payload.trust_key === 'string' && payload.trust_key) || undefined : undefined
|
? (typeof payload.trust_key === 'string' && payload.trust_key) || undefined : undefined
|
||||||
const reports: ApprovalReport[] = Array.isArray(payload.reports)
|
const reports: ApprovalReport[] = Array.isArray(payload.reports)
|
||||||
@@ -140,7 +143,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
|
|||||||
if (binary) void respond({ decision: 'approve' }, TASK_TEXT.doneApprove)
|
if (binary) void respond({ decision: 'approve' }, TASK_TEXT.doneApprove)
|
||||||
else void respond({ decision: 'submit', values: { value: text } }, TASK_TEXT.doneSubmit)
|
else void respond({ decision: 'submit', values: { value: text } }, TASK_TEXT.doneSubmit)
|
||||||
}
|
}
|
||||||
const reject = () => void respond({ decision: 'reject' }, binary ? TASK_TEXT.doneReject : TASK_TEXT.doneRejectStop)
|
const reject = () => void respond({ decision: 'reject' }, route ? TASK_TEXT.doneRejectRoute(REJECT_ROUTE_TEXT.goes(route))
|
||||||
|
: binary ? TASK_TEXT.doneReject : TASK_TEXT.doneRejectStop)
|
||||||
|
|
||||||
/** 「始终允许」:批准这一次,并让服务端把这个工具设成门控把关(工具页的信任档跟着变,目录重拉一次) */
|
/** 「始终允许」:批准这一次,并让服务端把这个工具设成门控把关(工具页的信任档跟着变,目录重拉一次) */
|
||||||
const always = async () => {
|
const always = async () => {
|
||||||
@@ -209,7 +213,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
|
|||||||
{kind === 'action_confirm' && (
|
{kind === 'action_confirm' && (
|
||||||
<ActionConfirm task={task} canAct={canAct} busy={busy || inquiring} note={note} setNote={setNote}
|
<ActionConfirm task={task} canAct={canAct} busy={busy || inquiring} note={note} setNote={setNote}
|
||||||
onConfirm={(hash) => void respond({ decision: 'approve', payload_hash: hash }, ACTION_CONFIRM_TEXT.done)}
|
onConfirm={(hash) => void respond({ decision: 'approve', payload_hash: hash }, ACTION_CONFIRM_TEXT.done)}
|
||||||
onReject={() => void respond({ decision: 'reject' }, ACTION_CONFIRM_TEXT.rejected)} />
|
onReject={() => void respond({ decision: 'reject' }, route && 'to' in route
|
||||||
|
? ACTION_CONFIRM_TEXT.rejectedRoute(REJECT_ROUTE_TEXT.goes(route)) : ACTION_CONFIRM_TEXT.rejected)} />
|
||||||
)}
|
)}
|
||||||
{kind === 'reconcile' && (
|
{kind === 'reconcile' && (
|
||||||
<Reconcile task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
|
<Reconcile task={task} canAct={canAct} busy={busy} note={note} setNote={setNote}
|
||||||
@@ -260,7 +265,7 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
|
|||||||
<button type="button" className="btn btn-danger flex-1 justify-center" disabled={busy || inquiring}
|
<button type="button" className="btn btn-danger flex-1 justify-center" disabled={busy || inquiring}
|
||||||
data-task-reject="" onClick={reject}>
|
data-task-reject="" onClick={reject}>
|
||||||
<Ban size={12} aria-hidden />{' '}
|
<Ban size={12} aria-hidden />{' '}
|
||||||
{binary ? (trial ? TRIAL_TEXT.reject : TASK_TEXT.reject) : (trial ? TRIAL_TEXT.rejectStop : TASK_TEXT.rejectStop)}
|
{stops ? (trial ? TRIAL_TEXT.rejectStop : TASK_TEXT.rejectStop) : (trial ? TRIAL_TEXT.reject : TASK_TEXT.reject)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{inquiring && (
|
{inquiring && (
|
||||||
@@ -272,7 +277,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner =
|
|||||||
)}
|
)}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
<div className="mt-1.5 text-2xs leading-relaxed text-dim">
|
||||||
{binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
|
{route ? (binary ? TASK_TEXT.approveHintRoute : TASK_TEXT.inputHintRoute)(REJECT_ROUTE_TEXT.goes(route))
|
||||||
|
: binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */}
|
{/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */}
|
||||||
@@ -429,8 +435,14 @@ export function Due({ dueAt, now }: { dueAt: string | null; now: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */
|
/** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */
|
||||||
export function Resolved({ task }: { task: Pick<Task, 'status' | 'outcome' | 'resolved_by' | 'resolved_at' | 'people'> }) {
|
export function Resolved({ task }: {
|
||||||
const decision = typeof task.outcome?.decision === 'string' ? TASK_TEXT.decision[task.outcome.decision] ?? task.outcome.decision : null
|
task: Pick<Task, 'status' | 'outcome' | 'resolved_by' | 'resolved_at' | 'people'> & { payload?: Task['payload'] }
|
||||||
|
}) {
|
||||||
|
const raw = typeof task.outcome?.decision === 'string' ? task.outcome.decision : null
|
||||||
|
const said = raw ? TASK_TEXT.decision[raw] ?? raw : null
|
||||||
|
// 驳回了的写驳回之后去了哪(中断内容的 reject_route):「驳回,转到「通知申请人」」,不只写「驳回」
|
||||||
|
const route = raw === 'reject' || raw === 'expired_reject' ? rejectRouteOf(task.payload) : null
|
||||||
|
const decision = said && route ? REJECT_ROUTE_TEXT.outcome(said, route) : said
|
||||||
const at = task.resolved_at ? formatDateTime(task.resolved_at) : ''
|
const at = task.resolved_at ? formatDateTime(task.resolved_at) : ''
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-x-1.5 text-2xs text-dim" data-task-resolved="">
|
<div className="mb-2 flex flex-wrap items-center gap-x-1.5 text-2xs text-dim" data-task-resolved="">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
// 待办箱一行写所属实例,同标题的分得开;
|
// 待办箱一行写所属实例,同标题的分得开;
|
||||||
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到
|
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到
|
||||||
// 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b));
|
// 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b));
|
||||||
|
// - 驳回之后去哪(中断内容的 reject_route):接了驳回出口的提示、驳回后的提示、处理过的结果写「转到「x」」,子工作流里的
|
||||||
|
// 写「子工作流 › x」、不露内部 id;补充输入接了出口的按钮写「驳回」,没接出口的工具审批写「驳回并终止」;
|
||||||
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」;
|
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」;
|
||||||
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
|
// - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有
|
||||||
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
|
// 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那
|
||||||
@@ -204,6 +206,24 @@ const TASKS = {
|
|||||||
reviewer: { type: 'string', title: '复核人', 'x-widget': 'principal' } } },
|
reviewer: { type: 'string', title: '复核人', 'x-widget': 'principal' } } },
|
||||||
payload: { kind: 'human_node', message: '请指定复核人' },
|
payload: { kind: 'human_node', message: '请指定复核人' },
|
||||||
},
|
},
|
||||||
|
// 驳回之后去哪(中断内容的 reject_route,服务端 engine/replay.reject_route):接了驳回出口的写转到哪一步,没接的写运行终止
|
||||||
|
routed: {
|
||||||
|
...base, id: 'routed', approval_id: 'ap9', kind: 'approve', title: '主管审批(接了驳回出口)',
|
||||||
|
payload: { kind: 'human_node', message: '王五报销 800 元', reject_route: { to: [{ node_id: 'tell', label: '通知申请人' }] } },
|
||||||
|
},
|
||||||
|
routedInput: {
|
||||||
|
...base, id: 'routedInput', approval_id: 'ap10', kind: 'input', title: '补填用途(接了驳回出口)',
|
||||||
|
payload: { kind: 'human_node', message: '请补填用途', reject_route: { to: [{ node_id: 'sub/back', label: '复核子流程 › 退回' }] } },
|
||||||
|
},
|
||||||
|
stopTool: {
|
||||||
|
...base, id: 'stopTool', approval_id: 'ap11', kind: 'tool', title: '调用工具:erp_query',
|
||||||
|
payload: { kind: 'tool_approval', tool: 'erp_query', args: { q: 1 }, reject_route: { ends: 'run' } },
|
||||||
|
},
|
||||||
|
rejectedDone: {
|
||||||
|
...base, id: 'rejectedDone', approval_id: 'ap12', kind: 'approve', title: '主管审批(已驳回)', status: 'resolved',
|
||||||
|
can_respond: false, outcome: { decision: 'reject' }, resolved_by: 'zhangsan', resolved_at: ago(300_000),
|
||||||
|
payload: { kind: 'human_node', message: '赵六报销 50 元', reject_route: { to: [{ node_id: 'tell', label: '通知申请人' }] } },
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
|
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
|
||||||
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
|
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
|
||||||
@@ -612,6 +632,38 @@ await section('口径召回:后台的系统通知点开去待办页(不去
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await section('驳回之后去哪:提示、按钮、驳回后的提示、处理过的结果', async () => {
|
||||||
|
const state = { writes: [] }
|
||||||
|
const page = await open(state)
|
||||||
|
await page.goto(`${WEB}/apps/inbox/routed`)
|
||||||
|
await card(page).waitFor({ timeout: 15000 })
|
||||||
|
let text = await card(page).innerText()
|
||||||
|
check('接了驳回出口的审批:提示写驳回后转到哪一步', text.includes('驳回:转到「通知申请人」'), text.slice(-80))
|
||||||
|
check('……不再写「转入「驳回」分支」', !text.includes('转入「驳回」分支'))
|
||||||
|
await page.locator('[data-task-reject]').click()
|
||||||
|
check('驳回之后提示转到哪一步', await page.getByText('已驳回,转到「通知申请人」').waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||||||
|
check('驳回照约定交给待办接口', state.writes.some((w) => w.path === '/api/tasks/routed/respond' && w.body?.decision === 'reject'))
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/apps/inbox/routedInput`)
|
||||||
|
await card(page).waitFor({ timeout: 15000 })
|
||||||
|
text = await card(page).innerText()
|
||||||
|
check('补充输入接了驳回出口:按钮写「驳回」,不写「驳回并终止」', (await page.locator('[data-task-reject]').innerText()).trim() === '驳回')
|
||||||
|
check('……提示写子工作流里的那一步', text.includes('驳回:转到「复核子流程 › 退回」'), text.slice(-80))
|
||||||
|
check('……不露内部 id', !text.includes('sub/back'))
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/apps/inbox/stopTool`)
|
||||||
|
await card(page).waitFor({ timeout: 15000 })
|
||||||
|
text = await card(page).innerText()
|
||||||
|
check('没接驳回出口的工具审批:按钮写「驳回并终止」', (await page.locator('[data-task-reject]').innerText()).trim() === '驳回并终止')
|
||||||
|
check('……提示写本次运行终止', text.includes('驳回:本次运行终止'), text.slice(-80))
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/apps/inbox/rejectedDone`)
|
||||||
|
await card(page).waitFor({ timeout: 15000 })
|
||||||
|
const resolved = await page.locator('[data-task-resolved]').first().innerText()
|
||||||
|
check('处理过的:结果写驳回之后去了哪', resolved.includes('结果:驳回,转到「通知申请人」'), resolved)
|
||||||
|
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
await section('待办箱:列表和徽标', async () => {
|
await section('待办箱:列表和徽标', async () => {
|
||||||
const page = await open({ writes: [] })
|
const page = await open({ writes: [] })
|
||||||
await page.goto(`${WEB}/apps?tab=inbox`)
|
await page.goto(`${WEB}/apps?tab=inbox`)
|
||||||
|
|||||||
Reference in New Issue
Block a user