diff --git a/README.md b/README.md index c710b7db..1e3c4c17 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,10 @@ SQLite 文件——上传表格的各版本库(`uploads/tables/`,发布后 **测试。** `scripts/test-postgres.sh` 用本机 docker 已有的 `postgres:16` 镜像起一个临时容器(只绑 127.0.0.1 的随机端口,跑完删掉), 在上面跑后端全量;参数原样交给 pytest。只对 SQLite 成立的测试标了 `sqlite_only`,在 PostgreSQL 上跳过。 +问数据每一轮的事件日志(`agentlab.db` 的 `chat_turn_events`:助手的操作、阶段、运行、复核、终局,供页面先回放再接实时)只为 +进行中的轮次服务:已结束(成功、失败、取消、中断)超过 14 天的轮次由调度循环每小时清一次、整轮删除;重开已结束的一轮读的是 +对话里那一行(答案、图、复核、用量、几次尝试),不受影响。助手等模型时的心跳只在实时流上发、不落库。 + --- ## 常见问题 diff --git a/backend/app/api/compare.py b/backend/app/api/compare.py index 16fbf75f..aa82f7d9 100644 --- a/backend/app/api/compare.py +++ b/backend/app/api/compare.py @@ -184,9 +184,15 @@ async def _side(run: Run, sealed: _Sealed, masks: _Masks, kinds: dict[str, Any]) for artifact, info in sealed.queries.items()} returned = _returned(sealed) - def sources(started: int) -> recompute.Sources: - return recompute.Sources(load=loader, queries=tuple(sealed.queries), outputs=_outputs_before(sealed, started), - masked=masked, returned=returned, finished=_finished_before(sealed, started)) + def sources(started: int, card_node: str = "") -> recompute.Sources: + # 节点产出、跑完的时刻按口径卡所在那一层的 id 交进去(engine/lineage.in_scope,同推演 api/whatif._sources):子工作流 + # 里的卡(「子工作流节点 id/卡」)取值链写的是子图里的 id,只看同一张子图里的节点,父图里同名的不算 + from app.engine.lineage import in_scope + + return recompute.Sources(load=loader, queries=tuple(sealed.queries), + outputs=in_scope(_outputs_before(sealed, started), card_node), + masked=masked, returned=returned, + finished=in_scope(_finished_before(sealed, started), card_node)) return engine.Side(run=run_ref(run), graph=sealed.graph, events=sealed.events, cards=cards, load=loader, masked=masked, sources=sources, dialects=kinds, inputs=dict(run.input or {})) diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 63ee57f6..540c082a 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -327,7 +327,19 @@ skip_if。这些字段按受限的 Python 表达式求值,变量直接写路 - edges 里每条边 {source, target, sourceHandle?} - branch 节点的出边必须带 sourceHandle,取值是 cases 里的 key,另外要有一条 sourceHandle="default" 兜底 - loop 节点:sourceHandle="body" 连循环体,循环体最后一个节点再连回 loop 节点;sourceHandle="done" 连循环结束后的去向 -- human 节点(approve 模式):sourceHandle 用 "approved" 和 "rejected" +- human 节点(approve 模式):批准走 sourceHandle "approved",驳回走下面的「驳回」出口 +- 「驳回」出口:会被人驳回的节点可以多接一条 sourceHandle="rejected" 的边,驳回(包括超时驳回)之后沿它往下走。 + 能接的只有:human(审批、补充输入、编辑稿件三种模式)、tool(工具调用审批、写外部系统前的动作确认)、 + code(代码执行审批)、dual_check(双盲复算的裁决);别的节点接了画布校验会报错。 + · 什么时候接:驳回之后还要做事——通知申请人(接一个通知或写说明的节点,再到成果节点)、退回修改(连回要改的上游 + 那一步,改完再到这个节点问一次)、改走另一条路(比如出一份「已退回」的成果)。驳回时这一步照常结束,下游用 + {{ nodes.关卡id.note }} 取驳回备注、{{ nodes.关卡id.actor }} 取谁驳回的;动作确认被驳回时写入不会发出 + · 不接时:补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决被驳回,运行就此结束、记为被驳回(是人的决定,不算失败); + 审批模式不接时这一路到审批为止、不再往下走(运行照常结束,没有成果)。审批写了 stop_on_reject(驳回即终止运行)就不要再接驳回出口: + 驳回时运行直接结束,那条边走不到 + · 驳回那条路必须走到成果节点,或者在一个不再往下连的节点结束(退回修改绕回上游、改完再问一次也算),否则驳回之后 + 只能一直绕下去,发布检查会报错 + · 驳回不是故障:「失败重试」「出错时继续」不管驳回,不要为了驳回之后接着走去配它们 - 一个节点连出多条普通边 = 并行执行,多条边汇入同一节点 = 等待汇聚 """ diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 82f491a0..fb232c6e 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -223,9 +223,13 @@ class _Sealed: 当「已封存」展示——只看 sealed 的消费方会把一条断掉的封存链画成绿的。""" return self.sealed and self.seal["ok"] is True - def nodes_of(self, node_type: str) -> list[str]: - return [str(n.get("id")) for n in self.graph.get("nodes") or [] - if isinstance(n, dict) and n.get("type") == node_type] + def nodes_of(self, node_type: str, *, nested: bool = False) -> list[str]: + """这次运行的图上这一类的节点;nested 为真时连子工作流里跑过的一起给(「子工作流节点 id/…」,类型照 node.started 记的)。""" + out = [str(n.get("id")) for n in self.graph.get("nodes") or [] + if isinstance(n, dict) and n.get("type") == node_type] + if nested: + out += [nid for nid, kind in self.types.items() if "/" in nid and kind == node_type] + return out def payload_of(self, node_ids: list[str]) -> dict[str, Any] | None: """这几个节点里最后跑完的那个的产出,从封存的事件指向的工件里取。""" @@ -1764,7 +1768,8 @@ def _reviews(sealed: _Sealed, report: _Report 审稿记录和计数,按它认(只认审稿记录存上了的),产出拼成 {challenge_artifact, counts, on_refuted: fail}。运行因此 失败了,被推翻的是哪几句、反方凭什么,证据面板里照样看得到。 """ - reviewers = set(sealed.nodes_of("challenge")) + # 子工作流里的反方审稿也认(「sub/chal」):它审的是子工作流里那份报告(审稿记录按全局 id 记,「sub/write」) + reviewers = set(sealed.nodes_of("challenge", nested=True)) if not reviewers: return spec: GraphSpec | None = None @@ -1843,7 +1848,8 @@ def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, A unreviewed = output.get("unreviewed") found = {"verdict": verdict, "node_id": node_id, "artifact": artifact, "on_refuted": str(output.get("on_refuted") or "degrade"), - "report_label": writer.title if writer else report.node_id, + # 子工作流里的报告(「sub/write」)不在运行的图上:写「子工作流 › 报告」,不写内部 id + "report_label": writer.title if writer else _node_label(sealed, report.node_id), "counts": _challenge_counts(output.get("counts"), claims), **({"unreviewed": unreviewed} if isinstance(unreviewed, int) and not isinstance(unreviewed, bool) and unreviewed >= 0 else {})} diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index fe66843e..f924e9db 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -516,9 +516,9 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di from app import scheduler on = scheduler.scheduler_enabled() - for r in rows: - if _event_kind(r): - states[r.id] = {**await outbox.pending_for(session, r), "scheduler_on": on} + # 一次查齐(outbox.pending_for_many),不再每行各查一次 + waiting = await outbox.pending_for_many(session, [r for r in rows if _event_kind(r)]) + states = {sid: {**state, "scheduler_on": on} for sid, state in waiting.items()} def target(r: Schedule) -> dict[str, Any]: kind, target_id = service.target_of(r) @@ -592,36 +592,70 @@ async def list_variables(tz: str | None = Query(default=None, alias="timezone"), return service.variables(tz=zone, event=event) +#: 选对象时一页最多给几个(按名字排;更多的按关键字查) +TRIGGER_OPTIONS_LIMIT = 100 + + @router.get("/trigger-options") -async def trigger_options(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: +async def trigger_options(event: str | None = None, q: str | None = None, selected: str | None = None, + limit: int = Query(default=TRIGGER_OPTIONS_LIMIT, ge=1, le=500), + session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """新建、修改事件触发任务时能选的:每种事件的对象(能不能选、为什么不能,由这里判,界面不自己判)、「只认某种来源」的 来源、「只认某个应用」的应用、去抖的缺省值、触发链的上限。 - {events: [{event, label, subject_label, objects: [{id, name, ok, reason}]}], origins: [{kind, label}], + 对象按名字排、一页最多 limit 个,more 说后面还有没有:数据源、工作流成百上千时不一次全给,界面按关键字(q,名字里含它) + 再查。event 给了只查这一种事件的对象;selected 是当前选中的那一个(修改时),总在里面,不被分页挤掉、不显示成「已不存在」。 + + {events: [{event, label, subject_label, objects: [{id, name, ok, reason}], more}], origins: [{kind, label}], apps: [{id, name}], default_debounce_s, max_debounce_s, max_depth}""" from app.db.models import App, DataSource - sources = list((await session.execute(select(DataSource).order_by(DataSource.name))).scalars()) - flows = list((await session.execute(select(Workflow).where(Workflow.is_template.is_(False)) - .order_by(Workflow.name))).scalars()) - tables = [] - for src in sources: - if (src.origin or "manual") != "upload": - continue - ok = await triggers.accumulating(session, src) - tables.append({"id": src.id, "name": src.name, "ok": ok, - "reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"}) - objects = { - "table.version": tables, - "workflow.finished": [{"id": w.id, "name": w.name, "ok": bool(w.published_version), - "reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"} - for w in flows], - "source.changed": [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in sources], - } + if event is not None and event not in triggers.EVENTS: + raise _invalid(f"不认识的事件种类:{event}", "event") + wanted = [event] if event else list(triggers.EVENTS) + words = (q or "").strip() + + async def page(model: Any, *conds: Any) -> tuple[list[Any], bool]: + query = select(model).where(*conds) + if words: + query = query.where(model.name.contains(words, autoescape=True)) + rows = list((await session.execute(query.order_by(model.name, model.id).limit(limit + 1))).scalars()) + more = len(rows) > limit + rows = rows[:limit] + if selected and all(r.id != selected for r in rows): + if (picked := (await session.execute(select(model).where(model.id == selected, *conds))).scalar_one_or_none()): + rows.append(picked) + return rows, more + + async def tables() -> tuple[list[dict[str, Any]], bool]: + # 只有上传表格有「新的一期」:按来源筛在库里做(老行没写来源的是手工接的源) + rows, more = await page(DataSource, DataSource.origin == "upload") + out = [] + for src in rows: + ok = await triggers.accumulating(session, src) + out.append({"id": src.id, "name": src.name, "ok": ok, + "reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"}) + return out, more + + async def flows() -> tuple[list[dict[str, Any]], bool]: + rows, more = await page(Workflow, Workflow.is_template.is_(False)) + return [{"id": w.id, "name": w.name, "ok": bool(w.published_version), + "reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"} + for w in rows], more + + async def sources() -> tuple[list[dict[str, Any]], bool]: + rows, more = await page(DataSource) + return [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in rows], more + + makers = {"table.version": tables, "workflow.finished": flows, "source.changed": sources} + entries = [] + for e in wanted: + objects, more = await makers[e]() + entries.append({"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e], + "objects": objects, "more": more}) apps = (await session.execute(select(App.id, App.name).where(App.status != "archived").order_by(App.name))).all() return { - "events": [{"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e], - "objects": objects[e]} for e in triggers.EVENTS], + "events": entries, "origins": [{"kind": k, "label": triggers.ORIGIN_LABEL[k]} for k in triggers.FORMAL_ORIGINS], "apps": [{"id": i, "name": n} for i, n in apps], "default_debounce_s": triggers.DEFAULT_DEBOUNCE_S, "max_debounce_s": triggers.MAX_DEBOUNCE_S, diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 1f45c938..cd260aa5 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -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")} if 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: return wanted = sorted({n for names in gates.values() for n in names}) diff --git a/backend/app/chat/job.py b/backend/app/chat/job.py index 2d04bf4d..f93b59ab 100644 --- a/backend/app/chat/job.py +++ b/backend/app/chat/job.py @@ -16,7 +16,8 @@ **事件日志**(chat/log.py;每条 {seq, type, data, ts}): turn.attempt {mode, attempts, scope, max_steps?} 这一次尝试开始了(发起、重试本轮、重新运行、跑一下、继续运行、接着收尾) - op {...} 助手的一条操作原样(含心跳、查看、final、usage) + op {...} 助手的一条操作原样(含查看、final、usage;心跳只落换了阶段的那一拍,同一阶段接着的只在实时流上发, + 见 chat/log.beat) stage {stage} 到了哪一步:build 建图、run 运行、waiting 停在审批上等人、review 复核 run {run_id, status, run_class} 发起了一次运行(页面据此接上运行自己的实时流看过程) review {result} 复核结论原样(含 usage、retry_instruction) @@ -100,6 +101,34 @@ def principal_of(snapshot: Mapping[str, Any] | None) -> Any: mode=str(s.get("mode") or "off"), via=str(s.get("via") or "session")) +async def current_principal(snapshot: Mapping[str, Any] | None) -> tuple[Any, str | None]: + """发起时的身份快照 → 接手这一刻的身份:(身份, 不能接着做的原因)。 + + 关着登录时发起的(快照里 mode 是 off、没有用户名)照快照:没有账号可核对。开着登录的按用户名读现在的账号:不在了、 + 停用了交回原因;角色、组、显示名换成现在的(降了角色就按降了之后的做);按现在的身份已经用不了问数据(使用者、管理员 + 没开问数据,同 api/deps._chat_scope)也交回原因。认证方式、是否核实过照快照。""" + from app.api.deps import Principal + from app.auth import user_can_chat + from app.auth.sessions import groups_of + from app.db.models_identity import User + + s = dict(snapshot or {}) + name = s.get("username") + if not s or str(s.get("mode") or "off") == "off" or not name: + return principal_of(snapshot), None + async with SessionLocal() as session: + user = (await session.execute(select(User).where(User.username == name))).scalar_one_or_none() + if user is None or user.disabled: + return None, f"发起这一轮的账号「{name}」已{'停用' if user is not None else '不存在'},本轮没有接着收尾" + groups = await groups_of(session, user.id) + principal = Principal(username=user.username, role=user.role, display_name=user.display_name or user.username, + user_id=user.id, groups=groups, verified=bool(s.get("verified")), + mode=str(s.get("mode") or "off"), via=str(s.get("via") or "session")) + if not principal.at_least("builder") and not (principal.at_least("user") and user_can_chat()): + return None, f"发起这一轮的账号「{name}」已经没有问数据的权限,本轮没有接着收尾" + return principal, None + + # -------------------------------------------------------------------------- # 驱动:问数据和评测不一样的那几项 # -------------------------------------------------------------------------- @@ -113,6 +142,8 @@ class ChatDriver(TurnDriver): self.execution = execution self._thinking = "" self._thinking_at = time.monotonic() + #: 最近一次落库的心跳的阶段:同一阶段接着的心跳不落库(on_op) + self._beat_phase: Any = None #: 这一轮的查看记录(存进 meta.looks) self.looks: list[dict[str, Any]] = [] #: 等不及的那次复核(还在跑):收尾之后由它把用量记进这一轮 @@ -132,6 +163,13 @@ class ChatDriver(TurnDriver): await self._flush_thinking() return await self._flush_thinking() + if kind == "heartbeat": + if op.get("phase") == self._beat_phase: + # 同一阶段接着的心跳是纯保活:只在实时流上发、不落库(chat/log.beat;以前每 3 秒落一条) + turn_log.beat(self.execution.turn_id, op) + return + # 换了阶段的那一拍(规划 → 查看 → 搭建……)说的是到了哪一步,照落:刷新、重开时过程里那一行阶段要回放得出来 + self._beat_phase = op.get("phase") await self.execution.emit("op", op) if kind == "look" and (look := record.look_event(op)) is not None: self.looks.append(look) @@ -147,6 +185,7 @@ class ChatDriver(TurnDriver): self._thinking_at = time.monotonic() async def _generate(self, instruction: str, base_graph: dict[str, Any] | None) -> tuple[list[dict[str, Any]], str | None]: + self._beat_phase = None # 每一次生成从头算:第一拍心跳照落(页面按它画「规划」那一行) try: return await super()._generate(instruction, base_graph) finally: @@ -336,6 +375,12 @@ class ChatTurnExecution(runtime.JobRun): # ---------------- 执行 ---------------- async def _run(self) -> None: + try: + await self._execute() + finally: + turn_log.forget(self.turn_id) # 进程里记的最近一拍心跳(chat/log.beat)跟着这一次执行收掉 + + async def _execute(self) -> None: from app.api.deps import set_principal from app.chat import turn as policy from app.db.models import ConversationTurn @@ -352,8 +397,13 @@ class ChatTurnExecution(runtime.JobRun): self.request = dict(job.request or {}) self.turn_seq = row.seq self.run_id = job.run_id - # 这一轮按发起的人的身份跑:answer_ops 里的 restricted()、对话归属,发起运行时的遮罩、只读都看它 - set_principal(principal_of(job.principal)) + # 这一轮按发起的人的身份跑:answer_ops 里的 restricted()、对话归属,发起运行时的遮罩、只读都看它。按这一刻的账号重新核对 + # (current_principal):服务重启后接着收尾时,快照是发起那一刻的,之后停用了、降了角色的不能照快照接着做 + principal, refused = await current_principal(job.principal) + if refused is not None: + await self._identity_refused(refused) + return + set_principal(principal) scope = [s.get("id") for s in self.request.get("scope") or [] if isinstance(s, Mapping) and s.get("id")] driver = self.driver = ChatDriver(self, question=row.question, datasource_ids=scope) driver.turn.attempts = int(job.attempt or 0) @@ -417,6 +467,16 @@ class ChatTurnExecution(runtime.JobRun): await self._end("cancelled") await self._job(status="cancelled", stage=None, finished_at=utcnow()) + async def _identity_refused(self, reason: str) -> None: + """发起这一轮的账号接手时已经停用、或者已经没有问数据的权限:照实标中断、写清原因,不再往下做(不建图、不等运行、 + 不复核)。已经发起的那次运行不动——停在审批上的照旧等人,在记录页照常看得到;这一轮不替一个已经无权的人收它的尾。""" + await self._settle() + if not self.ended: + await self.save(status="error", error=reason, + meta={"cause": {"kind": "identity", "message": reason}, **_timing(self.request)}) + await self._end("error") + await self._job(status="interrupted", stage=None, error=reason, finished_at=utcnow()) + async def _failed(self, reason: str) -> None: await self._crash_out(reason) @@ -1156,5 +1216,5 @@ async def shutdown() -> None: __all__ = ["APPROVAL_POLL_S", "AUTO_RETRY_MODES", "BUSY", "CONCURRENCY_CLASS", "ChatDriver", "ChatTurnExecution", "MODES", "REVIEW_WAIT_S", "TURN_BUSY", "TURN_FINISHED", "TURN_INVALID", "TurnRefused", "cancel", "forget", - "job_view", "late_pending", "manager", "principal_of", "principal_snapshot", "shutdown", "start", "startup", - "state", "stop_conversation", "stream_status"] + "current_principal", "job_view", "late_pending", "manager", "principal_of", "principal_snapshot", "shutdown", + "start", "startup", "state", "stop_conversation", "stream_status"] diff --git a/backend/app/chat/log.py b/backend/app/chat/log.py index 9e73c8b1..7a104110 100644 --- a/backend/app/chat/log.py +++ b/backend/app/chat/log.py @@ -11,28 +11,50 @@ 线上格式(SSE,每条一行 data: ):{seq, type, data, ts};回放完、这一轮也不在进行中了,补一条不带 seq 的 {type: stream.end, status}(任务的状态)再关;隔一拍没有新事件发一行注释心跳「: ping」(页面据此知道连接还活着)。 事件种类见 app/chat/job.py 的模块说明。 + +**助手的心跳只落换了阶段的那一拍。** 模型几秒没出声时助手发一拍 {op: heartbeat, phase, elapsed_ms}(页面据此写阶段、已等多久): +以前每 3 秒落一条,一轮几百条。现在换了阶段的那一拍(规划 → 查看 → 搭建……,说的是到了哪一步)照落,刷新、重开时过程里那一行 +阶段回放得出来;同一阶段接着的那些是纯保活,只记在本进程里(beat:每一轮最近的一拍),流上发一条不带 seq 的 {type: op, data}; +重连时流先补最近的那一拍。之后落了任何一条事件,那一拍就作废(心跳只说「上一条事件之后还在等」,重连时补旧的会让页面的进度 +退回去)。其余事件照落:第 2 波跨进程的实时流从库里按 seq 追,别的进程的保活心跳那时由流按任务状态自己合成。 + +**清理。** 已结束(成功、失败、取消、中断)超过 RETAIN_DAYS 天的轮次整轮删事件日志(purge,挂在调度循环的那一拍里,一小时最多一次): +页面重开已结束的一轮读的是 conversation_turns 那一行(答案、图、复核、用量、几次尝试都在那里),只有还在进行中的轮次才回放 +事件日志。任务行留着:last_seq 接着发号,之后在这一轮上重试、继续运行不撞号。 """ from __future__ import annotations import asyncio import contextlib +import itertools import json +import logging import time from collections import defaultdict from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from datetime import datetime, timedelta from typing import Any -from sqlalchemy import func, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import SessionLocal +logger = logging.getLogger(__name__) + #: 隔多久没有新事件就发一拍心跳(秒),也是订阅方回库里看一次的间隔 HEARTBEAT_S = 15.0 #: 一次回放最多读多少条 READ_BATCH = 500 #: 回放、实时推送完、这一轮不在进行中时的结束标记(不落库、不带 seq) STREAM_END = "stream.end" +#: 已结束的轮次的事件日志留几天 +RETAIN_DAYS = 14 +#: 清理多久最多跑一次(秒)、一次最多清几轮 +PURGE_EVERY_S = 3600.0 +PURGE_BATCH = 500 +#: 结束了的任务状态(core/job_runtime 的状态词里不再往下走的那几种) +FINISHED = ("succeeded", "failed", "cancelled", "interrupted") class _Waiters: @@ -67,6 +89,27 @@ def notify(turn_id: str) -> None: waiters.notify(turn_id) +#: 每一轮最近的一拍心跳:(第几拍, 操作)。只在本进程里,不落库(见模块说明) +_beats: dict[str, tuple[int, dict[str, Any]]] = {} +_beat_no = itertools.count(1) + + +def beat(turn_id: str, op: dict[str, Any]) -> None: + """助手的一拍心跳:记下最近的这一拍、叫醒订阅方,由流发出去(不落库、不占 seq)。""" + _beats[turn_id] = (next(_beat_no), dict(op)) + notify(turn_id) + + +def latest_beat(turn_id: str) -> dict[str, Any] | None: + got = _beats.get(turn_id) + return dict(got[1]) if got else None + + +def forget(turn_id: str) -> None: + """这一轮结束了:进程里记的那一拍清掉。""" + _beats.pop(turn_id, None) + + async def add(session: AsyncSession, turn_id: str, type_: str, data: dict[str, Any] | None = None) -> int: """在调用方的事务里加一条(不提交),交回它的 seq。 @@ -91,6 +134,8 @@ async def append(session: AsyncSession, turn_id: str, type_: str, data: dict[str """追加一条,提交,叫醒订阅方。交回它的 seq(见 add)。""" seq = await add(session, turn_id, type_, data) await session.commit() + # 落了新事件,上一拍心跳作废:重连时不再补它(心跳只说「上一条事件之后还在等」) + _beats.pop(turn_id, None) notify(turn_id) return seq @@ -124,8 +169,10 @@ async def stream(turn_id: str, after: int, *, active: Callable[[], Awaitable[str """SSE 的每一帧:先回放 seq > after 的,再推实时的;这一轮不在进行中、也没有新的了,补一条 stream.end 再结束。 active() 交回这一轮还在进行中时为 None,不在进行中时交回任务的状态(stream.end 里带的就是它)。""" - beat = HEARTBEAT_S if heartbeat is None else heartbeat + interval = HEARTBEAT_S if heartbeat is None else heartbeat last = after + #: 这条连接发过的最近一拍助手心跳(_beats 的第几拍):新连上的从 0 起,先补最近的那一拍 + sent = 0 while True: with waiters.watch(turn_id) as woke: rows = await read(turn_id, last) @@ -138,11 +185,47 @@ async def stream(turn_id: str, after: int, *, active: Callable[[], Awaitable[str if status is not None: yield frame({"type": STREAM_END, "status": status, "data": {"status": status}}) return + got = _beats.get(turn_id) + if got is not None and got[0] > sent: + sent = got[0] + # 不带 seq:页面按 seq 补齐时不算它(frontend/src/api/chatTurns.ts) + yield frame({"type": "op", "data": got[1], "ts": time.time()}) + continue try: - await asyncio.wait_for(woke.wait(), timeout=beat) + await asyncio.wait_for(woke.wait(), timeout=interval) except asyncio.TimeoutError: yield PING -__all__ = ["HEARTBEAT_S", "PING", "READ_BATCH", "STREAM_END", "add", "append", "frame", "notify", "read", "stream", "waiters", - "wire"] +async def purge(now: datetime, *, retain_days: float = RETAIN_DAYS, limit: int = PURGE_BATCH) -> list[str]: + """结束超过 retain_days 天的轮次整轮删事件日志(见模块说明),交回清了哪几轮。任务行、conversation_turns 那一行不动。 + 结束的时刻按任务的 finished_at,没记的按 updated_at。""" + from app.db.models_chat import ChatTurnEvent, ChatTurnJob + + cutoff = now - timedelta(days=retain_days) + async with SessionLocal() as session: + ended = select(ChatTurnJob.id).where(ChatTurnJob.status.in_(FINISHED), + func.coalesce(ChatTurnJob.finished_at, ChatTurnJob.updated_at) < cutoff) + turns = list((await session.execute(select(ChatTurnEvent.turn_id).where(ChatTurnEvent.turn_id.in_(ended)) + .distinct().limit(limit))).scalars()) + if turns: + await session.execute(delete(ChatTurnEvent).where(ChatTurnEvent.turn_id.in_(turns)) + .execution_options(synchronize_session=False)) + await session.commit() + return turns + + +_last_purge = 0.0 + + +async def purge_tick(now: datetime) -> list[str]: + """调度循环每一拍调(scheduler/loop.process_due):一小时最多清一次。""" + global _last_purge + if time.monotonic() - _last_purge < PURGE_EVERY_S and _last_purge: + return [] + _last_purge = time.monotonic() + return await purge(now) + + +__all__ = ["FINISHED", "HEARTBEAT_S", "PING", "READ_BATCH", "RETAIN_DAYS", "STREAM_END", "add", "append", "beat", "forget", + "frame", "latest_beat", "notify", "purge", "purge_tick", "read", "stream", "waiters", "wire"] diff --git a/backend/app/data/source_changes.py b/backend/app/data/source_changes.py index 51856bf0..6c3dd76f 100644 --- a/backend/app/data/source_changes.py +++ b/backend/app/data/source_changes.py @@ -157,7 +157,10 @@ async def record(session: AsyncSession, source: Any, change: NewChange, detected found.last_seen_at = now found.seen_count = int(found.seen_count or 0) + 1 if found.status == "open": - found.affects, found.before, found.after = change.affects, change.before, change.after + # 影响按这一次刷新;表结构变了之后口径试算的情况(ledger/recall 记的 probes)不是探查算的,留着 + probes = (found.affects or {}).get("probes") if isinstance(found.affects, dict) else None + found.affects = {**(change.affects or {}), "probes": probes} if probes else change.affects + found.before, found.after = change.before, change.after if SEVERITY_RANK[change.severity] > SEVERITY_RANK.get(found.severity, 0): found.severity = change.severity return found, False @@ -228,7 +231,8 @@ def change_out(row: SourceChange) -> dict[str, Any]: "severity": row.severity, "summary": summary(row), "detected_at": _iso(row.detected_at), "last_seen_at": _iso(row.last_seen_at), "seen_count": row.seen_count or 1, "detected_by": dict(row.detected_by or {}), "basis": row.basis, "before": row.before, "after": row.after, - "affects": row.affects or {"catalog": [], "templates": [], "rules_touched": False}, + # 缺的几项补上空的:只记了口径试算情况(ledger/recall 的 probes)的那种没有 catalog、templates + "affects": {"catalog": [], "templates": [], "rules_touched": False, **(row.affects or {})}, "struct_version_before": row.struct_version_before, "struct_version_after": row.struct_version_after, "status": row.status, "resolution": row.resolution, } diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 0d1f81a7..dd3ae597 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -9387,6 +9387,30 @@ def one_row(sql: str, dialect: str | None = None) -> bool: return _aggregating(root) +def ordered(sql: str, dialect: str | None = None) -> bool: + """这条查询最外层带 ORDER BY(一段 SELECT 或 UNION 整体的):结果的行序由查询定,按行号取的那一格重跑时还是那一行(排序键 + 相同的几行之间的先后另说,按位置认)。认不出的为假。""" + from sqlglot import exp + + read = _read(dialect) + try: + root, _ = _parse(_mysql_dashes(sql) if read == "mysql" and isinstance(sql, str) else sql, read) + except Exception: # noqa: BLE001 - 解析不了就是认不出 + return False + if not isinstance(root, (exp.Select, exp.Union)): + return False + order = root.args.get("order") + return order is not None and bool(order.expressions) + + +def row_stable(sql: str, row: int, dialect: str | None = None) -> bool: + """按行号取这条查询的第 row 行,重跑、核对时还是不是同一行:最外层带 ORDER BY 的按位置认;一定只出一行的(one_row)第 0 行 + 认得住;别的(多行、又没有 ORDER BY)行序不定——库可以按任何顺序交回这些行,第 2 行重跑时可能是另一行。""" + if ordered(sql, dialect): + return True + return row == 0 and one_row(sql, dialect) + + def output_names(sql: str, dialect: str | None = None, *, template: bool = False) -> list[str] | None: """最外层选择列表各项的输出名(按位置;同 _result_columns,不展开 *:有 * 的为 None)。template 为真时 sql 是口径 模板,按哨兵渲染之后认。解析不了、不是一段 SELECT 的为 None。""" diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index 49839bf1..bbc278ae 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -183,8 +183,9 @@ class Side: 没有的,就是读不出来或者被改过 - load:工件 id → 内容,只认封存范围内的;文件不在返回 None,内容和哈希对不上抛 ValueError - masked:查询快照 → 要遮的列(小写):查询当时记下的加数据源现在设的 - - sources:口径卡那次执行开始的事件序号 → 推演重算还原输入要用的 Sources(节点产出、跑完的时刻都按那一刻)。 - 不给就按 load、masked 拼一个最小的:nodes.* 这类容器输入还原不出来 + - sources:(口径卡那次执行开始的事件序号, 卡在这次运行里的节点 id) → 推演重算还原输入要用的 Sources(节点产出、跑完的 + 时刻都按那一刻,按卡所在那一层的 id 记:子工作流里的卡只看同一张子图里的节点)。不给就按 load、masked 拼一个最小的: + nodes.* 这类容器输入还原不出来 - dialects:数据源名 → 类型(sqlite / postgres …),规范化 SQL 时按它的方言解析 - inputs:运行输入(Run.input) """ @@ -195,7 +196,7 @@ class Side: cards: list[tuple[str, str, dict[str, Any]]] = field(default_factory=list) load: Callable[[str], Any] = lambda _: None masked: Mapping[str, frozenset[str]] = field(default_factory=dict) - sources: Callable[[int], recompute.Sources] | None = None + sources: Callable[[int, str], recompute.Sources] | None = None dialects: Mapping[str, str | None] = field(default_factory=dict) inputs: dict[str, Any] = field(default_factory=dict) @@ -454,7 +455,7 @@ def _plan_card(side: Side, card: _Card, queries: list[_Query]) -> None: """按推演重算的规则还原卡上每个指标的输入:落在哪份快照上、是不是遮罩列、容器的完整值。""" if card.content is None: return - sources = side.sources(card.started) if side.sources is not None else recompute.Sources( + sources = side.sources(card.started, card.node_id) if side.sources is not None else recompute.Sources( load=side.load, queries=tuple(dict.fromkeys(q.artifact for q in queries)), masked=side.masked) store = recompute._Store(sources) card.sources = sources diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index bfc48df8..25dd3c17 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -136,7 +136,8 @@ def _wrap( "attempt": attempt + 1, "ts": time.time(), } - return {**updates, "trail": [trail]} + # 节点自己交回的 trail(子工作流里走了驳回出口的那几步,multi._rejected_steps)排在这一步前面 + return {**updates, "trail": [*(updates.get("trail") or []), trail]} except GraphBubbleUp: # interrupt() / Command 靠异常向上传递控制流,不是执行失败: # 必须原样放行,否则人工介入会被当成节点报错吞掉。 @@ -230,7 +231,10 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, # 节点在驳回之前已经算出的(NodeRejected.exit_updates:双盲复算的出具缺口、台账条目)照样交出;驳回的那几个键在上面 payload = {**(extra.get("payload") or {}), **error.exit_payload()} 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: 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), @@ -244,7 +248,7 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, outcome="rejected", **said, to=to, **({"evidence": ledger} if ledger else {})) 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(), - "outcome": "rejected", "label": node.title, **said, "to": to}]} + "outcome": "rejected", "label": run_ctx.global_label(node.title), **said, "to": to}]} if ledger: updates["evidence"] = ledger if extra.get("usage"): diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index fb85a203..24bbac71 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -43,9 +43,24 @@ class RunContext: """这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。""" return f"{self.prefix}{node_id}" + @property + def root_ids(self) -> frozenset[str]: + """这次运行最外层图上的节点 id:最外层就是这张图的,子工作流里是 multi.run_subgraph 交下来的。""" + given = self.extra.get(ROOT_IDS_KEY) + return frozenset(given) if given is not None else frozenset(n.id for n in self.spec.nodes) + + 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 放) PREFIX_KEY = "node_prefix" +#: 子工作流里的节点名前缀(「子工作流 › 」,同上):RunContext.global_label +LABEL_PREFIX_KEY = "node_label_prefix" +#: 最外层图上的节点 id(同上,只在子工作流里有):RunContext.root_ids +ROOT_IDS_KEY = "root_node_ids" #: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给 #: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都 #: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发 diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 4b8f21ce..f44c98ad 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -811,8 +811,10 @@ class _Review: # 没能反查的句数:没派出去、派出去却没查成的(unreviewed 里各有原因),运行时长上限将至没开始的,加上触顶时没能给出 # 结论的。都记为无法判断,可「无法判断 2 句」既可能是查了、数据不够,也可能是根本没查:审批卡要分开说 missed = len(unreviewed) + overdue + sum(skipped for _, skipped in hit.values()) - artifact = await self._store({"report": self.report_id, "doc_artifact": self.doc_artifact, "claims": claims, - "model": model}) + # 报告按这次运行里的 id 记(子工作流里的写「子工作流节点 id/报告」,同 report.checked、报告文档上的 node_id): + # 证据面板、可验证报告按它认审的是哪份报告 + artifact = await self._store({"report": self.ctx.run.global_id(self.report_id), "doc_artifact": self.doc_artifact, + "claims": claims, "model": model}) for limit, (touched, skipped) in hit.items(): words = self._limit_words(limit) message = f"已达上限({words}),{skipped} 句未反查" if limit == "daily_max_usd" else \ @@ -872,7 +874,8 @@ class _Review: async def _store(self, record: dict[str, Any]) -> str | None: try: return await artifact_store.put_json(record, kind="challenge", run_id=self.ctx.run.run_id, - node_id=self.ctx.node.id, meta={"report": self.report_id}) + node_id=self.ctx.run.global_id(self.ctx.node.id), + meta={"report": self.ctx.run.global_id(self.report_id)}) except Exception as e: # noqa: BLE001 - 记录写失败不毁掉运行:结论照常处置,只是理由和查询点不开 self.ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", message=f"反方审稿记录保存失败({describe_exception(e)}),审稿的理由和查询将无法查看") diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index b112a39f..cf2639ee 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -227,7 +227,8 @@ def _reviewed_reports(state: GraphState, ctx: NodeContext) -> list[dict[str, Any or not isinstance(result.get("stats"), dict): continue item: dict[str, Any] = { - "node_id": rid, "label": node.title, + # 按这次运行里的 id 记(子工作流里的写「子工作流节点 id/报告」):审批卡据此打开证据面板(?report=) + "node_id": ctx.run.global_id(rid), "label": node.title, "stats": {k: v for k, v in result["stats"].items() if isinstance(v, int) and not isinstance(v, bool)}, "claims": result.get("claims") or "off", } diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 80b20064..c2d5a152 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -14,7 +14,9 @@ from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal 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, ROOT_IDS_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer, +) from app.engine.lineage import whole_ref from app.engine.state import GraphState, message_text, template_context from app.engine.toolcalls import ( @@ -691,8 +693,17 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context: # 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀 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), + # 最外层图上的节点 id:子图里的写入认升级前按子图里的 id 记的幂等键时,排除父图同名节点记的那一条(tools._run_write) + ROOT_IDS_KEY: ctx.run.extra.get(ROOT_IDS_KEY) or frozenset(n.id for n in ctx.run.spec.nodes)}, ) + # 这个子工作流节点之前几次执行里,子图各节点跑过的记录(交回父图 trail 的,见下):子图从它们接着数第几次执行 + # (engine/evidence.next_exec)。以前每次都从空的 trail 起步,循环里第二轮的子图节点又记成第 1 次:台账分不出是哪一次, + # 写外部系统的幂等键(按第几次执行算)和第一轮撞上,第二轮的写入交回第一轮的回执、不发送 + prefix = f"{ctx.node.id}/" + seed = [{**t, "node_id": str(t.get("node_id"))[len(prefix):]} for t in state.get("trail") or [] + if isinstance(t, dict) and str(t.get("node_id") or "").startswith(prefix)] ctx.emit(EventType.LOG, level="info", message=f"进入子工作流「{workflow.name}」({len(sub_spec.nodes)} 个节点)") @@ -710,7 +721,7 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "output": {}, "messages": [], "usage": {}, - "trail": [], + "trail": seed, "loops": {}, }, {"recursion_limit": limit}, @@ -731,6 +742,9 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raise output = result_state.get("output") or {} + # 这一次执行新跑的那几步(trail 只追加:前面是交进去的 seed) + ran = [t for t in (result_state.get("trail") or [])[len(seed):] if isinstance(t, dict)] + result_state = {**result_state, "trail": ran} result = { "output": output, "workflow": workflow.name, @@ -753,6 +767,12 @@ 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)]: updates["evidence"] = ledger + if ran: + # 子图这一次跑过的每一步交回父图的 trail,节点 id 接上前缀(「子工作流节点 id/…」):同一个子工作流节点下一次执行时 + # 从这里接着数(上面的 seed);走了驳回出口的那几步(outcome=rejected)运行收尾据此在终态事件上照实写「途经驳回」 + # (runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」被驳回、转到哪(名字、转到哪几步 + # 在子图里已经按这次运行的说法记好:global_label、global_id)。父图自己的节点按自己的 id 数,不受影响 + updates["trail"] = [{**t, "node_id": f"{prefix}{t.get('node_id')}"} for t in ran] var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: output} diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 7c299fcb..f2f61386 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -274,9 +274,14 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ from app.process import actions exec_no = next_exec(state, ctx.node.id) + # 幂等键按这次运行里的节点 id 算(子工作流里的写「sub/pay」):以前按子图里的 id(pay)算,父图里同名的节点、套同一个 + # 工作流的两个子工作流节点撞同一个键,后发的那一笔交回先发的回执、不发送。升级前停在子图里的运行按子图里的 id 记过, + # 恢复时整个子图重跑:照旧认那个键(legacy_node_id),已经生效的不重发;父图里有同名节点时不认(那一条可能是它的) + node_id = ctx.run.global_id(ctx.node.id) + legacy = ctx.node.id if node_id != ctx.node.id and ctx.node.id not in ctx.run.root_ids else None try: - prep = await actions.prepare(run_id=ctx.run.run_id, node_id=ctx.node.id, exec_no=exec_no, - capability=capability, args=args) + prep = await actions.prepare(run_id=ctx.run.run_id, node_id=node_id, exec_no=exec_no, + capability=capability, args=args, legacy_node_id=legacy) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e if prep.receipt is not None: @@ -317,8 +322,8 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ # 外部系统会把审批人当成申请人;无人值守的写入没有人确认,不写 act on_behalf = {"sub": prep.run.get("started_by"), "act": confirmer} try: - result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=ctx.node.id, - exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf) + result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=node_id, + exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf, confirm_node=ctx.node.id) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e return _write_updates(ctx, result) diff --git a/backend/app/engine/replay.py b/backend/app/engine/replay.py index 3d4d1078..e95e2446 100644 --- a/backend/app/engine/replay.py +++ b/backend/app/engine/replay.py @@ -75,4 +75,32 @@ async def ask(ctx: NodeContext, payload: dict[str, Any], **request: Any) -> Any: 答复真正到达的那次执行里出现。 """ 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"} diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 98f349ee..26b10939 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -206,6 +206,8 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list elif etype == EventType.TOOL_END and tool and tool in failed_at: recovered.add(tool) + #: 报过的节点失败 (节点 id, 原话):子工作流节点跟着子图里那一步失败时认得出是同一件事 + failures: list[tuple[str, str]] = [] for e in rows: etype = str(_event_field(e, "type", "")) data = _event_field(e, "data") or {} @@ -224,11 +226,18 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list # 失败」;也不进 RETRYABLE——重跑只会把同一件事再问一遍人。答案没走完是缺口,记 DEGRADED(没有成果时 # empty_output 另按 BROKEN 说) err = str(data.get("error") or "").strip()[:200] - add("node_rejected", f"节点「{titles.get(node_id) or node_id}」被驳回:{err}", DEGRADED) + if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): + continue # 子工作流里那一步被驳回、子工作流节点跟着收尾:同一件事,只报那一步(同下面的执行失败) + failures.append((str(node_id), err)) + add("node_rejected", f"节点「{_step_name(str(node_id), titles)}」被驳回:{err}", DEGRADED) elif etype == EventType.NODE_FAILED: err = str(data.get("error") or "").strip()[:200] - add("node_failed", f"节点「{titles.get(node_id) or node_id}」执行失败:{err}", BROKEN) + if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): + # 子工作流里那一步失败了,子工作流节点(「sub」)跟着失败、原话相同:同一件事,只报更具体的那一步(上面已报) + continue + failures.append((str(node_id), err)) + add("node_failed", f"节点「{_step_name(str(node_id), titles)}」执行失败:{err}", BROKEN) elif etype == EventType.RUN_FAILED: err = str(data.get("error") or "").strip()[:200] @@ -264,6 +273,15 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list return signals +def _step_name(node_id: str, titles: Mapping[str, str]) -> str: + """失败的那一步给人看的名字。子工作流里的(「子工作流节点 id/…」)一层层接上 node.started 记的名字:「复核子流程 › 算一下」, + 不写内部 id;没记名字的那一层照写 id 的那一段。""" + if "/" not in node_id: + return titles.get(node_id) or node_id + parts = node_id.split("/") + return " › ".join(titles.get("/".join(parts[:i + 1])) or parts[i] for i in range(len(parts))) + + def worst(signals: Iterable[Signal]) -> str: """一组信号里最重的那档。没有信号时返回空串。""" ranked = sorted(signals, key=lambda s: _SEVERITY_RANK.get(s.severity, 1), reverse=True) diff --git a/backend/app/ledger/labels.py b/backend/app/ledger/labels.py index e876f6c2..f53861e4 100644 --- a/backend/app/ledger/labels.py +++ b/backend/app/ledger/labels.py @@ -47,6 +47,9 @@ METRIC_GONE = "指标集里没有这个指标,没有比对" UNFINISHED = "比对没有做完" #: related 的几种说明 FORMULA_DIFFERS = "取数和口径等价,但指标的算法和口径卡上的不同" +#: 指标按「同一行」认来自口径,可那一行重跑时不一定还是它:多行结果、查询又没有 ORDER BY(sql_semantics.row_stable) +ROW_ORDER_UNSTABLE = ("指标取的是多行结果里按行号的一格,查询没有 ORDER BY,行序不定:重新执行时这一行可能是另一行," + "不能认定对应口径的同一格") INPUTS_ELSEWHERE = "指标有的输入不来自查询(运行输入、整形或代码),不能算来自口径" MIXED_CALIBERS = "指标的几个输入分别对上了不同的口径" BINDING_CONFLICT = "指标的几个输入对上了同一份口径,但参数不一致" @@ -143,6 +146,15 @@ def cell_label(artifact: str, column: Any, queries: Mapping[str, Mapping[str, An return {**_unknown(str(query.get("reason") or UNFINISHED)), "query": artifact} +def _row_stable(query: Mapping[str, Any], item: Mapping[str, Any]) -> bool: + """这个输入取的那一行重跑时还认得住(match 记的 ordered / one_row,sql_semantics.row_stable 的认法):最外层带 ORDER BY, + 或者一定只出一行、取的是第 0 行。老的比对记录没记 ordered 的照旧(当时不判)。""" + if "ordered" not in query: + return True + where = item.get("locator") if isinstance(item.get("locator"), dict) else {} + return bool(query.get("ordered")) or (where.get("row") in (0, None) and bool(query.get("one_row"))) + + def _leaf_token(node_id: Any, row: Any, column: Any) -> str: """把指标式里的一个输入换成「口径的哪个取数节点、第几行、哪一列」:两边的式子按这个换完再逐字比。""" return f"⟦{node_id}|{row}|{column}⟧" @@ -307,6 +319,10 @@ def metric_label(card: Mapping[str, Any] | None, metric_id: Any, queries: Mappin return {"kind": "related", "related": nearby, "reason": MIXED_CALIBERS, **queried} related = next((q for q in queries_found if q.get("status") == "related"), None) return {**_related(related), **queried} if related is not None else {"kind": "reasoning", **queried} + if unstable := [i for i, q in zip(from_queries, queries_found) if not _row_stable(q, i)]: + # 按「同一行」认来自口径(_leaf_token 记的是行号):多行结果又没有 ORDER BY 的,重跑时第几行不一定是哪一行, + # 照实标相关、写明行序不定,不认成来自口径 + return {"kind": "related", "related": nearby, "reason": ROW_ORDER_UNSTABLE, **queried} reason = FORMULA_DIFFERS for key in sorted(common): chosen = [next(c for c in _proven(q) if c.get("caliber") == key) for q in queries_found] diff --git a/backend/app/ledger/match.py b/backend/app/ledger/match.py index f5d72135..3a4b12c4 100644 --- a/backend/app/ledger/match.py +++ b/backend/app/ledger/match.py @@ -98,6 +98,8 @@ SNAPSHOT_TAMPERED = "查询快照与哈希不一致,疑似被修改,不比 SNAPSHOT_MISSING = "查询快照读不出来,不比对" NO_SQL = "查询快照里没有 SQL,不比对" PARSE_FAILED = "这次的 SQL 无法解析,没有比对" +#: 指标按「同一行」认来自口径,可那一行重跑时不一定还是它(说法在 labels,这里给调用方一个名字) +ROW_ORDER_UNSTABLE = ledger_labels.ROW_ORDER_UNSTABLE MERGED = "合并查询的格不和口径比对(它的数来自各个输入的查询)" COMPOSED_UNPROVEN = "这条查询出自助手选用的口径,但和口径的取数没能证明一致(可能被改过)" MORE_CANDIDATES = "另有 {n} 份相关口径没有比对(每条查询最多比 {max} 份)" @@ -546,7 +548,7 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: 等价)/ none(没有相关的口径)/ skipped(没比完:超时、解析失败、快照读不出)。composed 是 composed_nodes 的结果, 不给就没有助手选用的。referenced 是报告引用了哪条查询的哪几列(labels.referenced_columns:{查询快照: {列名}}): 给了的,整条比不等价时按引用的列投影再比(_projected);不给就只整条比。纯计算,从不抛异常。评测逐题比 SQL 也调它。""" - from app.data.sql_semantics import match_template, normalize_sql, one_row + from app.data.sql_semantics import match_template, normalize_sql, one_row, ordered out: list[dict[str, Any]] = [] for query in queries: @@ -567,6 +569,9 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: if normalize_sql(query.sql, query.dialect).parse_error: out.append({**row, "status": "skipped", "reason": PARSE_FAILED, "candidates": []}) continue + # 按行号取的那一格重跑时还是不是同一行(sql_semantics.row_stable):指标标签按「同一行」认来自口径时要看它 + row["ordered"] = ordered(query.sql, query.dialect) + row["one_row"] = one_row(query.sql, query.dialect) candidates: list[dict[str, Any]] = [] for caliber, fetch in pool[:MAX_CANDIDATES]: if deadline is not None and time.monotonic() > deadline: @@ -600,7 +605,6 @@ def compare_queries(queries: list[Query], calibers: list[Caliber], *, deadline: if wanted is not None and len(wanted) >= 2 and status == "related": # 整条(按全部引用列投影)没证明:被引用的几列逐格各比一次,每个数按它自己那一格的结论标(labels.cell_label) row["cells"] = _cells(query, wanted, pool[:MAX_CANDIDATES], candidates, chosen, deadline) - row["one_row"] = one_row(query.sql, query.dialect) out.append(row) return out diff --git a/backend/app/ledger/recall.py b/backend/app/ledger/recall.py index 3227c66f..0a9738cd 100644 --- a/backend/app/ledger/recall.py +++ b/backend/app/ledger/recall.py @@ -364,15 +364,48 @@ def recall_card(pairs: Sequence[Pair]) -> tuple[dict[str, Any], list[dict[str, A _metric(f"new_{k}", f"{name} · 新版 v{pair.new.version}", new_expr, nm), _metric(f"diff_{k}", f"{name} · 两版之差", f"({new_expr}) - ({old_expr})", nm)] # 旧版的式子(定义,不是数)一并记下:问数回答当时的值要按它找到答案里对应的那一格(original_values) - meta.append({"k": k, "card": pair.old.node_id, "metric": m["id"], "name": name, - "unit": str(nm.get("unit") or ""), "decimals": nm.get("decimals"), "format": nm.get("format"), - "expression": str(m.get("expression") or "")}) + item = {"k": k, "card": pair.old.node_id, "metric": m["id"], "name": name, + "unit": str(nm.get("unit") or ""), "decimals": nm.get("decimals"), "format": nm.get("format"), + "expression": str(m.get("expression") or "")} + if not (_rows_stable(pair.old, m) and _rows_stable(pair.new, nm)): + # 两版都按行号取多行结果里的一格、查询又没有 ORDER BY:两次查询交回的行序可能不同,第几行不一定是同一行, + # 按位置比出来的「变了 / 没变」都不可信(compare 照实记行序不定) + item["row_order"] = "unstable" + meta.append(item) k += 1 node = derive._node(derive.RECALL_CARD, "metrics", "召回卡", caliber="口径召回", caliber_version="v1", on_missing="null", metrics=metrics) return node, meta +def _rows_stable(pinned: Any, metric: Mapping[str, Any]) -> bool: + """这个指标按行号取的每一格(cell(nodes.<取数>, 行, '列')),在那一版的取数 SQL 上重跑时还是同一行(sql_semantics.row_stable)。 + 行号、SQL 认不出来的照旧当认得住(以前就是按位置比的,不凭猜测改结论)。""" + import ast + + from app.data.sql_semantics import row_stable + from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression + + sql_of = {str(n.get("id")): ((n.get("data") or {}).get("config") or {}).get("args", {}).get("sql") + for n in pinned.fetches if isinstance(n, dict)} + try: + leaves = leaf_refs(parse_expression(str(metric.get("expression") or ""))[0]) + except ExpressionError: + return True + for leaf in leaves: + parts = cell_parts(str(leaf)) + if parts is None or not parts[0].startswith("nodes."): + continue + sql = sql_of.get(parts[0].removeprefix("nodes.")) + try: + row = ast.literal_eval(parts[1].strip()) + except (ValueError, SyntaxError): + continue + if isinstance(sql, str) and isinstance(row, int) and not row_stable(sql, row): + return False + return True + + def build_graph(group: Group, pairs: Sequence[Pair]) -> tuple[Any, list[dict[str, Any]]]: """一组的召回图(derive.recall_graph)和比较说明。同样的输入永远得到同一张图。""" from app.ledger import derive @@ -606,11 +639,14 @@ def compare(meta: Sequence[Mapping[str, Any]], values: Mapping[str, Mapping[str, for item in meta: k = item.get("k") old, new, diff = (values.get(f"{p}_{k}") or {} for p in ("old", "new", "diff")) - changed = str(old.get("rendered")) != str(new.get("rendered")) + unstable = item.get("row_order") == "unstable" + # 行序不定的(recall_card 记的):按位置比不出变没变,changed 为空,照实记 row_order,不假装对上 + changed = None if unstable else str(old.get("rendered")) != str(new.get("rendered")) out.append({"k": k, "card": item.get("card"), "metric": item.get("metric"), "name": item.get("name"), "unit": item.get("unit"), "old": old.get("value"), "new": new.get("value"), "diff": diff.get("value"), "old_text": old.get("rendered"), "new_text": new.get("rendered"), - "diff_text": diff.get("rendered"), "changed": changed}) + "diff_text": diff.get("rendered"), "changed": changed, + **({"row_order": "unstable"} if unstable else {})}) return out @@ -809,6 +845,7 @@ async def task_payload(session: Any, task: Any, principal: Any) -> dict[str, Any cells[side] = {"value": None if masked else value, "text": "已遮罩" if masked else text, "metric": f"{side}_{item['k']}", "masked": masked} metrics.append({"k": item["k"], "name": item["name"], "unit": item["unit"], "changed": item["changed"], + **({"row_order": item["row_order"]} if item.get("row_order") else {}), "card": item["card"], "metric": item["metric"], **cells}) answers = [] for use in _uses_of(origin, _owner_of_key(key)): @@ -1489,11 +1526,16 @@ async def _probe_calibers(change: Mapping[str, Any]) -> list[str]: if answer is not None: latest, inputs_from = dict(answer.binding), "answer" for card in caliber.cards[:1]: + pinned = None try: pinned = await derive.load_pinned(session, caliber.workflow_id, caliber.version, card) names = {str(f.get("name")) for f in pinned.entry_fields} - inputs = compose.check_inputs(pinned, {k: v for k, v in (latest or {}).items() if k in names}) - except (derive.NotDerivable, compose.ComposeRefused): + given = {k: v for k, v in (latest or {}).items() if k in names} + inputs = compose.check_inputs(pinned, given) + except (derive.NotDerivable, compose.ComposeRefused) as e: + # 凑不齐参数(这一版没有正式运行、也没有等价的问数回答,必填的入口字段又没有缺省值)或者派生不了:不试算, + # 照实记一条缺哪几个参数——日志里一条,变化记录的影响里一条(变化页照它写),不再一声不响地跳过 + await _probe_skipped(change, caliber, pinned, latest, inputs_from, e.message) continue probe = derive.caliber_probe(pinned) origin = {"probe": 1, "workflow_id": caliber.workflow_id, "version": caliber.version, @@ -1513,6 +1555,46 @@ async def _probe_calibers(change: Mapping[str, Any]) -> list[str]: return launched +#: 变化记录 affects 里试算情况的键(data/source_changes 再次探查到同一条变化时保留它) +PROBES_KEY = "probes" + + +def missing_inputs(pinned: Any, given: Mapping[str, Any] | None) -> list[str]: + """口径入口里必填、又没给值也没有缺省值的字段(按入口字段的先后)。""" + out = [] + for f in pinned.entry_fields if pinned is not None else (): + name = str(f.get("name") or "").strip() + if not name or not f.get("required") or f.get("default") not in (None, ""): + continue + if (given or {}).get(name) in (None, ""): + out.append(name) + return out + + +async def _probe_skipped(change: Mapping[str, Any], caliber: Any, pinned: Any, given: Mapping[str, Any] | None, + inputs_from: str, reason: str) -> None: + """表结构变了、这一版口径没能试算:记一条日志,写进变化记录的 affects.probes(同一版只记一条,换成最新的说法)。""" + from app.db.models_sources import SourceChange + + missing = missing_inputs(pinned, given) + name = getattr(pinned, "workflow_name", None) or caliber.workflow_id + said = f"缺少参数 {'、'.join(missing)}" if missing else reason + logger.info("表结构变了之后口径「%s」v%s 没有试算:%s(参数来自%s)", name, caliber.version, said, + {"formal": "最近一次正式运行", "answer": "等价的问数回答", "defaults": "入口的缺省值"}.get(inputs_from, inputs_from)) + note = {"workflow_id": caliber.workflow_id, "version": caliber.version, "name": str(name), "outcome": "skipped", + "missing": missing, "reason": reason, "inputs_from": inputs_from} + async with SessionLocal() as session: + row = (await session.execute(select(SourceChange).where(SourceChange.id == change.get("id")))).scalar_one_or_none() + if row is None: + return + affects = dict(row.affects or {}) + kept = [p for p in affects.get(PROBES_KEY) or [] if isinstance(p, dict) + and (p.get("workflow_id"), p.get("version")) != (caliber.workflow_id, caliber.version)] + affects[PROBES_KEY] = [*kept, note] + row.affects = affects + await session.commit() + + async def _settle_probe(run: Run) -> str: """试算运行结束:算出来了只记结论;没算出来给那一版口径的发布人开 recall 待办(reason=structure)。""" from app.apps import versions as app_versions diff --git a/backend/app/ledger/reconcile.py b/backend/app/ledger/reconcile.py index ea114515..e6c281d6 100644 --- a/backend/app/ledger/reconcile.py +++ b/backend/app/ledger/reconcile.py @@ -296,15 +296,37 @@ def tail_nodes(plan: Plan, *, card_id: str, fetch_id: str) -> list[dict[str, Any sql=align_sql(plan.keys, plan.pairs, plan.key_values))) for i, row in enumerate(plan.key_values): where = ",".join(f"{ours}={v}" for (ours, _), v in zip(plan.keys, row)) + # 先核对第 i 行就是这一组的维度:重跑时这次的结果缺了一行、某个维度多出一行(口径那边重复),合并结果会错位, + # 按位置取到的是别的维度的数。对不上的记空值(对账卡 on_missing=null),不拿别的维度的数冒充 + guard = _row_guard(i, plan.keys, row) for j, pair in enumerate(plan.pairs): - metrics += _four(f"{i}_{j}", f"{where} · {pair.name or pair.ours}", - f"cell(nodes.{ALIGN_ID}, {i}, 'ours_{j}')", f"cell(nodes.{ALIGN_ID}, {i}, 'cal_{j}')", - pair) + ours = f"cell(nodes.{ALIGN_ID}, {i}, 'ours_{j}')" + theirs = f"cell(nodes.{ALIGN_ID}, {i}, 'cal_{j}')" + if guard: + ours, theirs = f"({ours} if {guard} else None)", f"({theirs} if {guard} else None)" + metrics += _four(f"{i}_{j}", f"{where} · {pair.name or pair.ours}", ours, theirs, pair) nodes.append(_node(RECONCILE_CARD, "metrics", "对账卡", caliber="口径对账", caliber_version="v1", on_missing="null", metrics=metrics)) return nodes +def _row_guard(i: int, keys: list[tuple[str, str]], values: tuple[Any, ...]) -> str: + """对齐结果第 i 行是不是这一组(维度列的值都对得上)的条件式;维度值是空值的那一列不核对(表达式里没有空值的写法)。""" + import ast + + from app.engine.expressions import _literal + + parts = [f"cell(nodes.{_align_id()}, {i}, {ours!r}) == {ast.unparse(_literal(v))}" + for (ours, _), v in zip(keys, values) if v is not None and not isinstance(v, (list, dict))] + return " and ".join(parts) + + +def _align_id() -> str: + from app.ledger.derive import ALIGN_ID + + return ALIGN_ID + + def _numeric(rows: list[Any], index: int) -> bool: values = [r[index] for r in rows if isinstance(r, (list, tuple)) and index < len(r) and r[index] is not None] return bool(values) and all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values) @@ -328,9 +350,11 @@ def _plain_cell(expression: str, fetch: str) -> str | None: def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str], caliber_columns: list[str] | None, metrics: list[dict[str, Any]], fetch: str, card: str, fallback: bool, chosen: Any, - referenced: list[tuple[int, str]], projected: Any = None) -> Plan: + referenced: list[tuple[int, str]], projected: Any = None, cells: Any = None, + caliber: str | None = None) -> Plan: """配列、(多行时)定行和键:交回 Plan,配不了的抛 ReconcileRefused(见模块说明)。projected 是比对候选的 projection - (按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。纯计算。""" + (按引用的列投影过的:{ours: [这次留下的列], caliber: [口径留下的列]},按位置一一对应)。cells 是这条查询逐格比的结论 + (ledger/match._cells),caliber 是这份口径的键:要人选对照时按它预选(_suggested)。纯计算。""" def check_column(column: str) -> None: if column not in ours_columns: raise ReconcileRefused(422, PAIR_COLUMN.format(column=column)) @@ -343,7 +367,8 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str numeric = [c for i, c in enumerate(ours_columns) if c.lower() not in hidden and _numeric(ours_rows, i)] options = {"options": {"columns": numeric, "metrics": [{"id": str(m.get("id")), "name": str(m.get("name") or m.get("id")), - "unit": str(m.get("unit") or "")} for m in metrics if m.get("id")]}} + "unit": str(m.get("unit") or "")} for m in metrics if m.get("id")], + "suggested": _suggested(cells, caliber, fetch, numeric, metrics)}} if multi: return _plan_rows(ours_columns, ours_rows, hidden, caliber_columns, referenced, check_column) if chosen is not None: @@ -387,6 +412,40 @@ def plan_pairs(*, ours_columns: list[str], ours_rows: list[Any], hidden: set[str return Plan(pairs=pairs) +def _suggested(cells: Any, caliber: str | None, fetch: str, columns: list[str], + metrics: list[dict[str, Any]]) -> list[dict[str, str]]: + """要人选对照时预选哪几对:[{ours, metric}]。按逐格比的结论(ledger/match._cells)——这次结果的这一列单独比,和这份口径 + 取数节点的某一列等价(逐格证明了,projection.caliber 是那一列),口径卡上只读这一列这一格的指标就是它最可能的对照。只认 + 证明了的:只是「相关」的那一格比到的是第一个试的列,不是最像的,不替人猜;一个口径列对上几个指标的也不预选。界面照它 + 预选、人可以改。""" + from app.engine.expressions import ExpressionError, cell_parts, leaf_refs, parse_expression + + if not isinstance(cells, dict) or not caliber: + return [] + reads: dict[str, list[str]] = {} + for m in metrics: + try: + leaves = leaf_refs(parse_expression(str(m.get("expression") or ""))[0]) + except ExpressionError: + continue + cols = {_plain_cell(str(leaf), fetch) for leaf in leaves if cell_parts(str(leaf)) is not None} + if len(leaves) == 1 and len(cols) == 1 and None not in cols and m.get("id"): + reads.setdefault(str(next(iter(cols))).lower(), []).append(str(m["id"])) + out: list[dict[str, str]] = [] + for col in columns: + cell = cells.get(col) + cands = cell.get("candidates") if isinstance(cell, dict) else None + proven = next((c for c in cands or [] if isinstance(c, dict) and c.get("status") == "proven" + and c.get("caliber") == caliber and c.get("node_id") == fetch), None) + theirs = ((proven or {}).get("projection") or {}).get("caliber") if proven else None + if not isinstance(theirs, list) or len(theirs) != 1 or not isinstance(theirs[0], str): + continue + hits = reads.get(theirs[0].lower()) or [] + if len(hits) == 1: + out.append({"ours": col, "metric": hits[0]}) + return out + + def _projected_pairs(projected: Any, ours_columns: list[str]) -> list[tuple[str, str]] | None: """比对候选的 projection → [(这次的列, 口径的列)],按位置一一对应;不是投影过的、两边列数不一样、这次的列不在结果里的 为 None(照旧按整条的位置配)。""" @@ -561,7 +620,8 @@ async def reconcile(run_id: str, req: Request, *, principal: Any) -> Run: caliber_columns=_caliber_columns(template, inputs, list(pinned.entry_fields), kind, schema), metrics=pinned.metrics, fetch=str(fetch["id"]), card=pinned.card_title, fallback=bool((cand.get("diff") or {}).get("fallback")), chosen=req.pairs, - referenced=_referenced(sealed, req.query), projected=cand.get("projection")) + referenced=_referenced(sealed, req.query), projected=cand.get("projection"), + cells=query.get("cells"), caliber=key) graph = build(pinned, sql=snap["sql"], tool=f"{QUERY_PREFIX}{source}", plan=plan, inputs=inputs, fetch=str(fetch["id"])) diff --git a/backend/app/process/actions.py b/backend/app/process/actions.py index 87ab7fca..14b4658b 100644 --- a/backend/app/process/actions.py +++ b/backend/app/process/actions.py @@ -236,6 +236,8 @@ class Prepared: run: dict[str, Any] = field(default_factory=dict) #: 按参数定义收好类型的参数(typed_args):哈希、预览按它算,send 发的也是它 args: dict[str, Any] = field(default_factory=dict) + #: 记录上写的节点 id:认出升级前那种键(legacy_node_id)时照它记,同一个键下的记录 node_id 一致,下一次还认得出 + node_id: str | None = None @property def idem_key(self) -> str: @@ -247,8 +249,11 @@ async def latest(session: AsyncSession, key: str) -> ActionRecord | None: .order_by(ActionRecord.attempt.desc()).limit(1)) -async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, args: dict[str, Any]) -> Prepared: - """节点每次执行都先走这里:按 action_key 查记录,决定发不发、第几次发、走哪个环境。 +async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, args: dict[str, Any], + legacy_node_id: str | None = None) -> Prepared: + """节点每次执行都先走这里:按 action_key 查记录,决定发不发、第几次发、走哪个环境。node_id 是这次运行里的节点 id + (子工作流里的带前缀);legacy_node_id 是升级前子工作流里按子图里的 id 记的那种(tools._run_write 交进来),按它也没查到 + 才算头一次。 sent / unknown 抛 ActionPending(不发);failed 且没有人核实为「重新发送」的抛 ActionFailed。先查记录再收类型:已生效 (演练过)的交回存下的回执、已发出 / 结果未知的挂起,都和这一次渲染出来的参数无关——发出之后能力的参数定义改了、手上的 @@ -263,13 +268,21 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a run = await session.get(Run, run_id) endpoint = await _endpoint(session, capability) prev = await latest(session, key) + if prev is None and legacy_node_id: + # 升级前子工作流里的写入按子图里的节点 id 记幂等键(node_id 也记成它):恢复时整个子图重跑,照那个键认出已经 + # 记下的那一笔,接着用它的键(不重发、第几次发送接着数)。新记的子图写入 node_id 带前缀,认不到这里来 + old_key = action_key(run_id, legacy_node_id, exec_no) + old = await latest(session, old_key) + if old is not None and old.node_id == legacy_node_id: + key, prev, node_id = old_key, old, legacy_node_id facts = {"app_id": run.app_id, "workflow_id": run.workflow_id, "workflow_version": run.version, "graph_hash": run.version_hash, "started_by": run.started_by} if run is not None else {} endpoint_name = endpoint.name if endpoint is not None else None if prev is not None and prev.state in ("confirmed", "simulated"): # 交回的是生效的那一次:指纹、预览、环境、第几次都照记录,不按这一次的参数重算 return Prepared(key=key, attempt=prev.attempt, payload_hash=prev.payload_hash, preview=prev.preview, - environment=prev.environment, endpoint=endpoint_name, receipt=_receipt_of(prev), run=facts) + environment=prev.environment, endpoint=endpoint_name, receipt=_receipt_of(prev), run=facts, + node_id=node_id) if prev is not None and prev.state in ("sent", "unknown"): raise ActionPending(prev.id) formal = run is not None and run.run_class == "formal" @@ -290,15 +303,18 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a args = typed_args(capability, args) return Prepared(key=key, attempt=attempt, payload_hash=payload_hash(name, args), preview=masked(args, masked_paths(capability)), environment=environment, endpoint=endpoint_name, - run=facts, args=args) + run=facts, args=args, node_id=node_id) async def send(prep: Prepared, capability: Any, *, run_id: str, node_id: str, exec_no: int, - emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None) -> dict[str, Any]: + emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None, + confirm_node: str | None = None) -> dict[str, Any]: """先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。发的是 prepare 收好类型的 那一份参数(prep.args),和确认卡上的哈希是同一份。 - on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。""" + on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。 + 记录上的节点 id 照 prepare 认定的(prep.node_id);confirm_node 是动作确认待办上记的节点 id(确认卡上的,子工作流里是 + 子图里的 id),不给就按 node_id 找。""" from app.capabilities.registry import endpoint_request, masked_paths name = capability.name @@ -306,11 +322,12 @@ async def send(prep: Prepared, capability: Any, *, run_id: str, node_id: str, ex rec = await session.scalar(select(ActionRecord).where(ActionRecord.idem_key == prep.idem_key)) if rec is None: rec = ActionRecord( - action_key=prep.key, attempt=prep.attempt, idem_key=prep.idem_key, run_id=run_id, node_id=node_id, + action_key=prep.key, attempt=prep.attempt, idem_key=prep.idem_key, run_id=run_id, + node_id=prep.node_id or node_id, exec_no=exec_no, capability=name, capability_version=getattr(capability, "version", None), endpoint_id=getattr(capability, "endpoint_id", None), environment=prep.environment, payload_hash=prep.payload_hash, preview=prep.preview, state="pending", - confirm_task_id=await _confirm_task(session, run_id, node_id), + confirm_task_id=await _confirm_task(session, run_id, confirm_node or node_id), app_id=prep.run.get("app_id"), workflow_id=prep.run.get("workflow_id"), workflow_version=prep.run.get("workflow_version"), graph_hash=prep.run.get("graph_hash")) session.add(rec) diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index 59948cb6..6d231434 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -141,16 +141,19 @@ async def process_due(now: datetime | None = None) -> None: """流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查; 上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉 (auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写);每天到点发待办汇总、 - 访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍)。 + 访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍); + 已结束超过保留期的问数据轮次整轮删事件日志(chat/log.purge_tick,每小时一次)。 各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。""" from app.auth import keys + from app.chat import log as chat_log from app.files import store as files_store from app.notify import digest from app.process import actions, tasks for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick), ("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick), - ("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due)): + ("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due), + ("已结束的问数据轮次的事件日志清理", chat_log.purge_tick)): try: done = await step(now) if isinstance(done, list) and done: diff --git a/backend/app/scheduler/outbox.py b/backend/app/scheduler/outbox.py index 366051a5..722147e0 100644 --- a/backend/app/scheduler/outbox.py +++ b/backend/app/scheduler/outbox.py @@ -273,12 +273,10 @@ async def route(now: datetime | None = None) -> int: .limit(ROUTE_BATCH))).scalars()) if not events: return 0 - rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.target_kind).where( - Schedule.trigger_kind == "event", Schedule.enabled.is_(True)))).all() - listeners = [(sid, trigger) for sid, trigger, kind in rows if (kind or "workflow") == "workflow"] + listening = await listeners(session, events) routed = 0 for ev in events: - matched = [sid for sid, trigger in listeners if triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)] + matched = [sid for sid, trigger in listening if triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)] try: async with SessionLocal() as session: # 条件 UPDATE 认领:两拍撞在一起(以后多开了进程)只有一拍改得动这一行 @@ -298,6 +296,28 @@ async def route(now: datetime | None = None) -> int: return routed +def _listening_to(pairs: Any) -> Any: + """触发条件监听着这几对(事件种类, 对象)的事件触发任务的 WHERE 条件:按触发条件里的事件种类和对象筛(SQLAlchemy 的通用 + JSON 访问,各库都能用),不把全部事件触发任务读出来。pairs 是 {事件种类: {对象 id…}}。""" + from sqlalchemy import and_, false, or_ + + conds = [and_(Schedule.trigger["event"].as_string() == kind, + Schedule.trigger[triggers.SUBJECT_KEY[kind]].as_string().in_(sorted(subjects))) + for kind, subjects in pairs.items() if kind in triggers.SUBJECT_KEY and subjects] + return or_(*conds) if conds else false() + + +async def listeners(session: Any, events: list[TriggerEvent]) -> list[tuple[str, dict[str, Any]]]: + """这几条事件可能派给的任务:启用着的、指向工作流的事件触发任务里,触发条件的事件种类和对象对得上的 [(任务 id, 触发条件)]。 + 过滤条件(应用、来源、期次……)由 triggers.matches 逐条再判。""" + pairs: dict[str, set[str]] = {} + for ev in events: + pairs.setdefault(ev.event, set()).add(ev.subject_id) + rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.target_kind).where( + Schedule.trigger_kind == "event", Schedule.enabled.is_(True), _listening_to(pairs)))).all() + return [(sid, trigger) for sid, trigger, kind in rows if (kind or "workflow") == "workflow"] + + async def fire_waiting(now: datetime | None = None) -> list[ScheduleFire]: """有等着的事件的任务,各把到点的去抖窗口处理完。""" now = now or utcnow() @@ -382,6 +402,7 @@ async def _drain(schedule_id: str, now: datetime) -> list[ScheduleFire]: missed = [w for w in due if (now - w.due).total_seconds() > limit] timely = [w for w in due if w not in missed] outputs = {id(w): await _outputs_of(session, w.events[-1]) for w in timely} + await _clear_of_earlier_fires(schedule_id, timely) out: list[ScheduleFire] = [] if missed: fire = await _skip_missed(schedule_id, missed, tz, grace) @@ -396,6 +417,32 @@ async def _drain(schedule_id: str, now: datetime) -> list[ScheduleFire]: return out +async def _clear_of_earlier_fires(schedule_id: str, wins: list[_Window]) -> None: + """要发起的窗口的结束时刻(fire_at)和这个任务已经有的另一次事件触发撞在同一刻(别的期次的窗口早一拍发了,结束时刻相同) + 时往后挪 1 微秒,直到不撞:service.fire_once 按 (任务, fire_at, kind) 认「已经处理过」,撞上了就交回已有的那一条,这个 + 窗口的几条事件会被记到别人的触发上、不再发起。已有的那次触发里有这个窗口的事件(两拍撞在一起处理了同一个窗口)的不挪: + 那就是同一次,交回已有的那一条是对的。""" + from app.scheduler import service + + if not wins: + return + lo, hi = min(w.due for w in wins), max(w.due for w in wins) + timedelta(seconds=1) + async with SessionLocal() as session: + rows = (await session.execute(select(ScheduleFire.fire_at, ScheduleFire.inputs).where( + ScheduleFire.schedule_id == schedule_id, ScheduleFire.kind == "event", + ScheduleFire.fire_at >= lo, ScheduleFire.fire_at <= hi))).all() + taken: dict[datetime, set[str]] = {} + for at, inputs in rows: + brief = (inputs or {}).get("event") if isinstance(inputs, dict) else None + ids = brief.get("event_ids") if isinstance(brief, dict) else None + taken.setdefault(service.as_utc(at), set()).update(str(i) for i in ids or []) + for w in sorted(wins, key=lambda w: w.due): + mine = {ev.id for ev in w.events} + while w.due in taken and not taken[w.due] & mine: + w.due += timedelta(microseconds=1) + taken.setdefault(w.due, set()).update(mine) + + def _event_fire(events: list[TriggerEvent], tz: str, outputs: dict[str, Any]) -> EventFire: """几条合成一次的事件:内容取最新的那一条,深度取最深的那一条加 1。""" latest = events[-1] @@ -491,17 +538,57 @@ async def _clean(now: datetime) -> None: async def pending_for(session: Any, schedule: Schedule) -> dict[str, Any]: - """一个事件触发任务现在有几条事件在等:events 还没派发的(调度关着时一直留在这里)、waiting 已经派给它、在去抖窗口里的; - due_at 是窗口结束、会合成一次触发的时刻(没有在等的为空)。""" - trigger = schedule.trigger if isinstance(schedule.trigger, dict) else {} - subject, event = triggers.subject_of(trigger), trigger.get("event") or "" - rows = (await session.execute(select(TriggerEvent.payload).where( - TriggerEvent.status == "pending", TriggerEvent.event == event, TriggerEvent.subject_id == subject) - .limit(ROUTE_BATCH))).scalars() if subject else [] - unrouted = sum(1 for payload in rows if triggers.matches(trigger, event, subject, payload)) - waiting, first = (await session.execute(select(func.count(), func.min(TriggerDelivery.occurred_at)).where( - TriggerDelivery.schedule_id == schedule.id, TriggerDelivery.status == "pending"))).one() + """一个事件触发任务现在有几条事件在等(见 pending_for_many)。""" + return (await pending_for_many(session, [schedule]))[schedule.id] + + +async def pending_for_many(session: Any, schedules: list[Schedule]) -> dict[str, dict[str, Any]]: + """几个事件触发任务各有几条事件在等,一次查齐(定时任务列表每行都要,不再每行各查一次):任务 id → {events, waiting, due_at}。 + + events 是还没派发的(调度关着时一直留在这里);waiting 是已经派给它、在去抖窗口里的;due_at 是最早那个窗口结束、会合成 + 一次触发的时刻(没有在等的为空)。等着的时候任务改了条件、已经不合条件的(下一拍会标 dropped、不再派发)不算:窗口按剩下的 + 最早一条起算,和派发时(_drain)切的窗口一样。待派发数按任务分组一条 SQL 算;窗口要看事件内容,等着的那几条一次读出来。""" from app.scheduler import service - due = service.as_utc(first) + timedelta(seconds=triggers.debounce_of(trigger)) if first is not None else None - return {"events": unrouted, "waiting": int(waiting or 0), "due_at": due.isoformat() if due else None} + rows = [s for s in schedules if s is not None] + out = {s.id: {"events": 0, "waiting": 0, "due_at": None} for s in rows} + if not rows: + return out + trig = {s.id: s.trigger if isinstance(s.trigger, dict) else {} for s in rows} + ids = list(out) + counts = dict((await session.execute(select(TriggerDelivery.schedule_id, func.count()).where( + TriggerDelivery.schedule_id.in_(ids), TriggerDelivery.status == "pending") + .group_by(TriggerDelivery.schedule_id))).all()) + waiting = (await session.execute( + select(TriggerDelivery.schedule_id, TriggerDelivery.id, TriggerDelivery.created_at, TriggerEvent) + .join(TriggerEvent, TriggerEvent.id == TriggerDelivery.event_id) + .where(TriggerDelivery.schedule_id.in_([i for i in ids if counts.get(i)]), TriggerDelivery.status == "pending") + .order_by(TriggerDelivery.occurred_at, TriggerEvent.created_at).limit(DRAIN_BATCH))).all() if counts else [] + by: dict[str, list[tuple[str, TriggerEvent, datetime]]] = {} + stale: dict[str, int] = {} + for sid, did, routed, ev in waiting: + if triggers.matches(trig[sid], ev.event, ev.subject_id, ev.payload): + by.setdefault(sid, []).append((did, ev, service.as_utc(routed))) + else: + stale[sid] = stale.get(sid, 0) + 1 + for sid in ids: + out[sid]["waiting"] = max(int(counts.get(sid) or 0) - stale.get(sid, 0), 0) + wins = windows(by.get(sid, []), triggers.debounce_of(trig[sid])) + if wins: + out[sid]["due_at"] = min(w.due for w in wins).isoformat() + pairs: dict[str, set[str]] = {} + for sid in ids: + if subject := triggers.subject_of(trig[sid]): + pairs.setdefault(str(trig[sid].get("event") or ""), set()).add(subject) + if pairs: + from sqlalchemy import and_, or_ + + cond = or_(*[and_(TriggerEvent.event == kind, TriggerEvent.subject_id.in_(sorted(subs))) + for kind, subs in pairs.items()]) + unrouted = (await session.execute(select(TriggerEvent.event, TriggerEvent.subject_id, TriggerEvent.payload) + .where(TriggerEvent.status == "pending", cond).limit(ROUTE_BATCH))).all() + for kind, subject, payload in unrouted: + for sid in ids: + if triggers.matches(trig[sid], kind, subject, payload): + out[sid]["events"] += 1 + return out diff --git a/backend/app/scheduler/triggers.py b/backend/app/scheduler/triggers.py index 1a667cf3..00ad81a5 100644 --- a/backend/app/scheduler/triggers.py +++ b/backend/app/scheduler/triggers.py @@ -248,29 +248,40 @@ async def find_cycle(session: Any, *, schedule_id: str | None, trigger: dict[str start, target = subject_of(trigger), target_workflow_id if start == target: return [start, start] - edges: dict[str, set[str]] = {} - rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind) - .where(Schedule.trigger_kind == "event"))).all() - for sid, other, workflow_id, kind in rows: - if sid == schedule_id or (kind or "workflow") != "workflow" or not isinstance(other, dict): - continue - if other.get("event") == "workflow.finished" and subject_of(other) and workflow_id: - edges.setdefault(subject_of(other), set()).add(workflow_id) - # 从 B 出发找回 A 的路(广度优先,取最短的一条说给人听) - parent: dict[str, str | None] = {target: None} - queue = [target] - while queue: - node = queue.pop(0) - for nxt in sorted(edges.get(node, ())): - if nxt in parent: + + async def edges_from(nodes: list[str]) -> dict[str, set[str]]: + """这几个工作流跑完会触发哪些工作流:只读「上游工作流运行成功结束」、对象是它们的那几个任务(按触发条件里的事件种类和 + 对象筛,SQLAlchemy 的通用 JSON 访问),不把全部事件触发任务读出来。""" + rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind) + .where(Schedule.trigger_kind == "event", + Schedule.trigger["event"].as_string() == "workflow.finished", + Schedule.trigger["workflow_id"].as_string().in_(nodes)))).all() + out: dict[str, set[str]] = {} + for sid, other, workflow_id, kind in rows: + if sid == schedule_id or (kind or "workflow") != "workflow" or not isinstance(other, dict): continue - parent[nxt] = node - if nxt == start: - back = [start] # A ← … ← B,倒着记 - while back[-1] != target: - back.append(parent[back[-1]] or target) - return [start, *reversed(back)] - queue.append(nxt) + if other.get("event") == "workflow.finished" and subject_of(other) and workflow_id: + out.setdefault(subject_of(other), set()).add(workflow_id) + return out + + # 从 B 出发找回 A 的路(广度优先,一层查一次,取最短的一条说给人听) + parent: dict[str, str | None] = {target: None} + frontier = [target] + while frontier: + edges = await edges_from(frontier) + nxt_level: list[str] = [] + for node in frontier: + for nxt in sorted(edges.get(node, ())): + if nxt in parent: + continue + parent[nxt] = node + if nxt == start: + back = [start] # A ← … ← B,倒着记 + while back[-1] != target: + back.append(parent[back[-1]] or target) + return [start, *reversed(back)] + nxt_level.append(nxt) + frontier = nxt_level return None diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index c911b118..7c32d96f 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -77,6 +77,8 @@ RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点 REJECTED_HEADLINE = "未出具(被驳回)" REJECTED_STATUS = "被驳回" REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看" +#: 照驳回那条路成功结束的(驳回出口):标题、状态后面接这一句 +VIA_REJECTED = "(途经驳回)" # -------------------------------------------------------------------------- @@ -118,6 +120,9 @@ class Notice: error_node: str | None = None #: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步 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 #: 事件触发的那一次(fire_kind=event):事件的说法(scheduler/triggers.event_text),不含出口字段的值、变化的内容 trigger_text: str | None = None @@ -130,6 +135,11 @@ class Notice: def rejected(self) -> bool: 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 def never_started(self) -> bool: """失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。""" @@ -160,10 +170,18 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]: headline = REJECTED_HEADLINE else: headline = _HEADLINE.get(notice.event, notice.event) + if notice.via_rejection: + headline += VIA_REJECTED title = f"定时任务「{notice.schedule_name}」{headline}" lines: list[tuple[str, str]] = [] if notice.status: 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: gaps = f"(缺口 {notice.gaps} 条)" if notice.gaps is not None else "" 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 "已挂起,可继续运行" if notice.rejected: return REJECTED_STATUS + if notice.via_rejection: + return f"{_STATUS_LABEL['succeeded']}{VIA_REJECTED}" 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)) 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, + 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")), 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: event = fire.inputs.get("event") if isinstance(fire.inputs, dict) else None text = event.get("text") if isinstance(event, dict) else None diff --git a/backend/tests/fixtures/compare_runs.py b/backend/tests/fixtures/compare_runs.py index 9e0d3944..455f28d5 100644 --- a/backend/tests/fixtures/compare_runs.py +++ b/backend/tests/fixtures/compare_runs.py @@ -187,7 +187,7 @@ class Fake: return artifact def side(self): - def sources(started): + def sources(started, card_node=""): outputs = {} for seq, etype, nid, data in self.events: if seq < started and etype == "node.finished" and data.get("artifact"): diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 896b1dac..1b8768ea 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -1431,3 +1431,56 @@ def test_values_the_agent_found_are_masked_in_the_rationale_when_the_report_fill rationale=doc, usage={}, masked_values=seen.pooled()) masked = challenge_mod._masked_rationale(outcome, secrets, given=[]) assert masked.result["rationale"] == "号码 已遮罩 的客户在华南。" + + +# -------------------------------------------------------------------------- +# 子工作流里的反方审稿 +# -------------------------------------------------------------------------- + + +async def _in_subworkflow(spec: dict) -> dict: + """把 spec 发布成子工作流,父图:开始 → 子工作流 → 成果。""" + from tests import ledger_samples as S + + sub_id = await S.publish("周报子流程", graph=spec, level="draft") + return chain(node("start", "input"), node("sub", "subgraph", "周报子流程", workflow_id=sub_id, workflow_version=1), + node("top", "output", fields=[{"name": "周报", "value": "{{ nodes.sub.output.周报 }}"}])) + + +async def test_a_challenge_inside_a_subworkflow_names_the_report_by_its_run_id(monkeypatch, shop): + """子工作流里的报告在父运行里按全局 id(「sub/write」)记(report.checked、报告文档):审稿记录也按它记,证据面板点开 + 子图里那份报告的句子时认得出审它的那次审稿,不再因为记的是子图里的 id(write)对不上、什么都不显示。""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + Models(monkeypatch, shop) + row = await finish(await _in_subworkflow(graph(shop, contract=False)), run_class="exploratory") + assert row.status == "succeeded", row.error + out = await output_of(row.id, "sub/chal") + record = artifact_store.load(out["challenge_artifact"]) + [checked] = await events(row.id, "report.checked", "sub/write") + assert (record["report"], record["doc_artifact"]) == ("sub/write", checked.data["doc_artifact"]) + doc = artifact_store.load(checked.data["doc_artifact"]) + south = unit_of(doc, SOUTH) + seg = next(s for _, u in iter_units(doc) if u["id"] == south + for s in u.get("segments") or [] if s.get("kind") != "structural") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}?report=sub/write") + assert res.status_code == 200, res.text + challenge = res.json()["unit"].get("challenge") or {} + assert (challenge.get("verdict"), challenge.get("node_id")) == ("refuted", "sub/chal") + + +async def test_an_approval_inside_a_subworkflow_points_at_the_report_by_its_run_id(monkeypatch, shop): + """子工作流里「报告 → 反方审稿 → 人工审批」:审批卡上的报告按这次运行里的 id 记(「sub/write」),审批卡据此打开证据面板, + 反方审稿的概况照样带上。""" + Models(monkeypatch, shop) + row = await finish(await _in_subworkflow(gated(shop)), run_class="exploratory", + statuses=("interrupted", "failed", "succeeded")) + assert row.status == "interrupted", row.error + async with SessionLocal() as session: + approval = (await session.execute(select(Approval).where(Approval.run_id == row.id))).scalar_one() + [report] = approval.payload["reports"] + assert report["node_id"] == "sub/write" + assert report["challenge"]["counts"]["refuted"] == 1 diff --git a/backend/tests/test_chat_turns.py b/backend/tests/test_chat_turns.py index fff8502e..f5f851b3 100644 --- a/backend/tests/test_chat_turns.py +++ b/backend/tests/test_chat_turns.py @@ -239,7 +239,8 @@ async def test_the_stream_replays_then_follows_and_reconnects_by_seq(client, mon first: list[dict[str, Any]] = [] stream = turn_log.stream(turn_id, 0, active=active, heartbeat=0.2) async for frame in stream: - if frame.startswith("data: "): + # 助手的心跳不带 seq、不落库(chat/log.beat):按 seq 补齐时不算它,同页面 + if frame.startswith("data: ") and "seq" in json.loads(frame[6:]): first.append(json.loads(frame[6:])) if any(e.get("type") == "stage" for e in first): break @@ -253,7 +254,9 @@ async def test_the_stream_replays_then_follows_and_reconnects_by_seq(client, mon if frame == turn_log.PING: pings += 1 continue - rest.append(json.loads(frame[6:])) + event = json.loads(frame[6:]) + if "seq" in event or event.get("type") == "stream.end": + rest.append(event) assert rest[-1]["type"] == "stream.end" and rest[-1]["status"] == "succeeded" got = [e["seq"] for e in first + rest[:-1]] assert got == list(range(1, len(got) + 1)), "补齐、不重复" @@ -330,8 +333,9 @@ async def test_stop_while_running_cancels_the_run(client, monkeypatch, source): # -------------------------------------------------------------------------- -async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: str = "ask") -> str: - """造一轮「上次进程没了时还在跑」的:那一行 running、任务 running(不在这个进程里)。""" +async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: str = "ask", + principal: dict[str, Any] | None = None) -> str: + """造一轮「上次进程没了时还在跑」的:那一行 running、任务 running(不在这个进程里)。principal 是发起时的身份快照。""" async with SessionLocal() as session: if run is not None: session.add(run) @@ -341,7 +345,7 @@ async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: session.add(turn) await session.flush() session.add(ChatTurnJob(id=turn.id, conversation_id=conversation_id, status="running", stage=stage, mode=mode, - run_id=run.id if run else None, request={"started_ms": 0}, principal={})) + run_id=run.id if run else None, request={"started_ms": 0}, principal=principal or {})) await session.commit() return turn.id @@ -935,3 +939,219 @@ async def test_a_stop_that_lands_while_launching_still_cancels_the_run(client, m assert (await session.get(Run, job.run_id)).status == "cancelled", "这次运行跟着取消了,没有没人管的运行" row = await _row(turn_id) assert row.status == "error" and row.error == "已取消" and row.meta["runId"] == job.run_id + + +# -------------------------------------------------------------------------- +# 事件日志:心跳不落库、已结束的轮次按期清理 +# -------------------------------------------------------------------------- + + +async def test_heartbeats_go_out_on_the_stream_but_are_not_stored(client, monkeypatch, source): + """心跳(模型几秒没出声时助手发的一拍 heartbeat 操作):换了阶段的那一拍照落(刷新后过程里那一行阶段回放得出来),同一阶段 + 接着的是纯保活,只在实时流上发、不落库,重连时流自己补最近的一拍;别的事件(思考、操作、阶段……)照旧。以前每 3 秒落一条, + 一轮下来几百条心跳。""" + from app.api import copilot + + gate = asyncio.Event() + es.use_copilot(monkeypatch, Gated(source["name"], gate)) + monkeypatch.setattr(copilot, "_HEARTBEAT_SECONDS", 0.05) # use_copilot 把心跳调成了一小时一拍 + es.use_report(monkeypatch) + conversation_id = await _conversation(client) + turn_id = await _ask(client, conversation_id, datasource_ids=[source["id"]]) + await _stage(turn_id, "build") + + async def active() -> str | None: + return await chat_job.stream_status(turn_id) + + beats: list[dict[str, Any]] = [] + stream = turn_log.stream(turn_id, 0, active=active, heartbeat=0.2) + async for frame in stream: + if frame.startswith("data: "): + event = json.loads(frame[6:]) + if event.get("type") == "op" and event["data"].get("op") == "heartbeat": + beats.append(event) + if any("seq" not in b for b in beats): + break + await stream.aclose() + assert [b for b in beats if "seq" not in b], "同一阶段接着的心跳不占 seq:重连按 seq 补齐时不会多出来、也不会漏" + # 重连(从最后一条落库的事件接着读):流先补一拍最近的心跳,页面接着知道还在等模型 + stored = await _events(turn_id) + again = turn_log.stream(turn_id, stored[-1]["seq"], active=active, heartbeat=0.2) + first = await again.__anext__() + await again.aclose() + assert first.startswith("data: ") and json.loads(first[6:])["data"]["op"] == "heartbeat" + + gate.set() + await _done(turn_id) + events = await _events(turn_id) + stored = [e for e in events if e["type"] == "op" and e["data"].get("op") == "heartbeat"] + # 只落换了阶段的那一拍(规划、查看……各一拍),同一阶段接着的几十拍都不落 + phases = [e["data"]["phase"] for e in stored] + assert phases and phases[0] == "planning" and all(a != b for a, b in zip(phases, phases[1:])), phases + assert [e["seq"] for e in events] == list(range(1, len(events) + 1)) + assert turn_log.latest_beat(turn_id) is None, "这一轮结束了,进程里记的那一拍跟着清掉" + + +async def test_a_later_event_supersedes_the_last_heartbeat(): + """心跳只说「上一条事件之后还在等」:之后落了新事件,重连时不再补那一拍(不然页面的进度会退回去)。""" + async with SessionLocal() as session: + conversation = Conversation(kind="chat", title="虚构") + session.add(conversation) + await session.flush() + turn = ConversationTurn(conversation_id=conversation.id, question="q", status="running") + session.add(turn) + await session.flush() + session.add(ChatTurnJob(id=turn.id, conversation_id=conversation.id, status="running")) + await session.commit() + turn_id = turn.id + turn_log.beat(turn_id, {"op": "heartbeat", "phase": "planning", "elapsed_ms": 3000}) + assert turn_log.latest_beat(turn_id)["phase"] == "planning" + async with SessionLocal() as session: + await turn_log.append(session, turn_id, "stage", {"stage": "run"}) + assert turn_log.latest_beat(turn_id) is None + turn_log.forget(turn_id) + + +async def _finished_turn(*, days_ago: float, status: str = "succeeded", events: int = 3) -> str: + from datetime import timedelta + + from app.db.base import utcnow + + async with SessionLocal() as session: + conversation = Conversation(kind="chat", title="虚构的旧会话") + session.add(conversation) + await session.flush() + turn = ConversationTurn(conversation_id=conversation.id, question="虚构的问题", answer="虚构的回答", + status="done" if status == "succeeded" else "error", meta={"outcome": "done"}) + session.add(turn) + await session.flush() + when = utcnow() - timedelta(days=days_ago) + session.add(ChatTurnJob(id=turn.id, conversation_id=conversation.id, status=status, last_seq=events, + finished_at=None if status in ("queued", "running") else when)) + for seq in range(1, events + 1): + session.add(ChatTurnEvent(turn_id=turn.id, seq=seq, type="op", ts=0.0, data={"op": "thinking"})) + await session.commit() + return turn.id + + +async def test_finished_turns_lose_their_event_log_after_the_retention_period(): + """已结束超过保留期的轮次整轮删事件日志:页面重开已结束的一轮读的是 conversation_turns 那一行(答案、图、复核、用量都在 + 那里),不读事件日志;任务行留着,last_seq 接着发号,之后再在这一轮上重试、继续运行不撞号。还在跑的、刚结束的不动。""" + from app.db.base import utcnow + + old = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1) + stuck = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1, status="interrupted") + recent = await _finished_turn(days_ago=1) + live = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1, status="running") + purged = await turn_log.purge(utcnow()) + assert set(purged) >= {old, stuck} and not {recent, live} & set(purged) + assert await _events(old) == [] and await _events(stuck) == [] + assert len(await _events(recent)) == 3 and len(await _events(live)) == 3 + async with SessionLocal() as session: + job = await session.get(ChatTurnJob, old) + row = await session.get(ConversationTurn, old) + assert job is not None and job.last_seq == 3, "任务行留着:seq 接着往下发" + assert row.answer == "虚构的回答", "那一行不动" + async with SessionLocal() as session: + assert await turn_log.add(session, old, "turn.attempt", {"mode": "retry"}) == 4 + await session.rollback() + + +async def test_the_cleanup_runs_in_the_background_tick(monkeypatch): + """清理挂在调度循环的那一拍里(scheduler/loop.process_due),一小时最多清一次。""" + from app.db.base import utcnow + from app.scheduler import loop + + old = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 2) + monkeypatch.setattr(turn_log, "_last_purge", 0.0) + await loop.process_due(utcnow()) + assert await _events(old) == [] + again = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 2) + await loop.process_due(utcnow()) + assert len(await _events(again)) == 3, "一小时内不再清第二次" + + +# -------------------------------------------------------------------------- +# 服务重启后接着收尾:按接手时的账号重新核对 +# -------------------------------------------------------------------------- + + +async def _snapshot_of(name: str) -> dict[str, Any]: + from app.db.models_identity import User + + async with SessionLocal() as session: + user = (await session.execute(select(User).where(User.username == name))).scalar_one() + return {"username": user.username, "role": user.role, "display_name": user.display_name, "user_id": user.id, + "groups": [], "verified": True, "mode": "local", "via": "session"} + + +async def _set_user(name: str, **values: Any) -> None: + from sqlalchemy import update as sa_update + + from app.db.models_identity import User + + async with SessionLocal() as session: + await session.execute(sa_update(User).where(User.username == name).values(**values)) + await session.commit() + + +def _finished_run(by: str) -> Run: + return Run(status="succeeded", output={"答案": "华东 300。"}, graph={"nodes": [], "edges": []}, run_class="exploratory", + origin_kind="chat", started_by=by) + + +async def test_resuming_after_a_restart_checks_the_account_again(people, monkeypatch, source): + """服务重启后接着收尾一轮(已发起运行的接着等、复核),按接手这一刻的账号重新核对,不再照发起时的身份快照接着做: + 发起之后账号被停用的,照实标中断、写清原因;那一行不再往下改答案。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + mine = await _conversation(people["uma"], "uma 的对话") + turn_id = await _stranded(mine, run=_finished_run("uma"), stage="review", principal=await _snapshot_of("uma")) + await _set_user("uma", disabled=True) + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "error" and row.meta["cause"]["kind"] == "identity", row.meta + assert row.error == "发起这一轮的账号「uma」已停用,本轮没有接着收尾" and not row.answer + async with SessionLocal() as session: + assert (await session.get(ChatTurnJob, turn_id)).status == "interrupted" + assert (await _events(turn_id))[-1]["data"]["outcome"] == "error" + + +async def test_a_downgraded_account_without_chat_is_interrupted(people, monkeypatch, source): + """发起时是搭建者,之后被降成使用者、管理员又没给使用者开问数据:已经没有权限了,照实标中断。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + theirs = await _conversation(people["bob"], "bob 的对话") + turn_id = await _stranded(theirs, run=_finished_run("bob"), stage="review", principal=await _snapshot_of("bob")) + await _set_user("bob", role="user") + assert (await people["admin"].put("/api/auth/settings", json={"user_can_chat": False})).status_code == 200 + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "error" and row.meta["cause"]["kind"] == "identity" + assert row.error == "发起这一轮的账号「bob」已经没有问数据的权限,本轮没有接着收尾" + + +async def test_a_downgraded_account_that_can_still_chat_finishes_as_who_they_are_now(people, monkeypatch, source): + """降了角色、但仍然用得了问数据(使用者开着问数据):照常收尾,按现在的身份(角色、组)接着做,不按快照里的搭建者。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + seen: list[Any] = [] + real = chat_job.ChatDriver.drive + + async def spy(self, *a, **k): + from app.api.deps import current_principal + + seen.append(current_principal()) + return await real(self, *a, **k) + + monkeypatch.setattr(chat_job.ChatDriver, "drive", spy) + theirs = await _conversation(people["bob"], "bob 的对话") + turn_id = await _stranded(theirs, run=_finished_run("bob"), stage="review", principal=await _snapshot_of("bob")) + await _set_user("bob", role="user") + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "done" and row.answer == "华东 300。" + assert seen and seen[-1].username == "bob" and seen[-1].role == "user" diff --git a/backend/tests/test_compare_api.py b/backend/tests/test_compare_api.py index 541f87b7..41fa1b8f 100644 --- a/backend/tests/test_compare_api.py +++ b/backend/tests/test_compare_api.py @@ -476,3 +476,79 @@ async def test_real_runs_split_data_and_parameter_changes(client, engine_up, ord body = await get(client, f"/api/runs/{third}/compare/base") assert (body["base_run_id"], body["reason"]) == (second, "same_workflow") json.dumps(body, allow_nan=False) + + +# -------------------------------------------------------------------------- +# 子工作流里的口径卡:还原输入按卡所在那一层找产出 +# -------------------------------------------------------------------------- + + +def sub_card_graph(): + """子工作流:取数 → 原样解析 → 口径卡(东区金额读单元格;区域数读解析出来的整份结果的行,要从解析节点当时的产出还原)。""" + return chain( + node("s", "input", "开始"), + node("fetch", "tool", "区域取数", tool=f"db_query__{E2E_SOURCE}", args={"sql": E2E_SQL.replace( + "{{ input.start }}", "2026-08-31").replace("{{ input.end }}", "2026-09-06")}), + node("parse", "transform", "原样解析", mode="json", template="{{ nodes.fetch }}", assign_to="rows"), + node("card", "metrics", "子口径卡", caliber="周报口径", caliber_version="v1", metrics=[ + {"id": "east", "name": "东区金额", "unit": "元", "decimals": 1, "format": "thousands", + "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "regions", "name": "区域数", "unit": "个", "decimals": 0, "format": "plain", + "expression": "len(nodes.parse.rows)"}]), + node("o", "output", "出口", fields=[{"name": "指标", "value": "{{ nodes.card.text }}"}]), + ) + + +async def test_cards_inside_a_subworkflow_restore_inputs_from_their_own_layer(client, engine_up, orders): + """子工作流里的卡(这次运行里的 id「sub/card」)取值链写的是子图里的 id(nodes.parse):对比还原容器输入时要找「sub/parse」 + 的产出,不是父图里的 parse。父图里另有同名的 fetch、parse(查的是另一条 SQL),按层找才不会取错。""" + from tests import ledger_samples as S + + path, source_id = orders + sub_id = await S.publish("区域子流程", graph=sub_card_graph(), level="draft") + workflow = await new_workflow("子流程周报") + + def graph(): + return chain( + node("start", "input", "开始"), + # 父图里同名的取数、解析节点:一行,和子图里那份三行的结果不一样 + node("fetch", "tool", "父图取数", tool=f"db_query__{E2E_SOURCE}", + args={"sql": "SELECT COUNT(*) AS n FROM orders"}), + node("parse", "transform", "父图解析", mode="json", template="{{ nodes.fetch }}"), + node("sub", "subgraph", "区域子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", "成果", fields=[{"name": "指标", "value": "{{ nodes.sub.output.指标 }}"}]), + ) + + async def finished() -> str: + run = await run_manager.start(graph=graph(), input_payload={}, workflow_id=workflow, workflow_name="子流程周报") + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + assert row.status == "succeeded", row.error + return run.id + raise AssertionError(f"运行 {run.id} 没有结束") + + first = await finished() + db = sqlite3.connect(path) + db.execute("UPDATE orders SET amount = 1500.5 WHERE day = '2026-09-01'") + db.commit() + db.close() + await engines.invalidate(source_id) + second = await finished() + + body = await get(client, f"/api/runs/{second}/compare?base={first}") + metrics = {m["metric_id"] if "metric_id" in m else m["id"]: m for m in body["metrics"]} + east, regions = metrics["east"], metrics["regions"] + assert (east["classification"], east["base"], east["target"]) == ("data_only", 1200.5, 1500.5) + # 区域数:从子图里那次取数的产出还原(三行),两次一样 + assert (regions["base"], regions["target"]) == (3, 3) + assert regions["classification"] == "unchanged", regions + problems = [i.get("problem") for i in regions.get("inputs") or [] if i.get("problem")] + assert not problems, problems + # 区域数的那份结果顺着取值链认到子图里那次取数(两次之间改了数,快照不同),不是父图里同名的 fetch(两次一样) + [cell] = east["inputs"] + [rows] = regions["inputs"] + assert (rows["base_artifact"], rows["target_artifact"]) == (cell["base_artifact"], cell["target_artifact"]) + assert rows["base_artifact"] != rows["target_artifact"] diff --git a/backend/tests/test_copilot_reject_exit_guide.py b/backend/tests/test_copilot_reject_exit_guide.py new file mode 100644 index 00000000..e9b9cee7 --- /dev/null +++ b/backend/tests/test_copilot_reject_exit_guide.py @@ -0,0 +1,84 @@ +"""助手的节点手册要教「驳回」出口(docs/design/orchestration-round/00-总方案.md §3)。 + +手册里没写的出口,助手就不会接:驳回之后要通知申请人、退回修改、改走另一条路的,画出来的图只会在驳回时整轮结束。 +这里不只查关键词,还拿校验认的那一份(会被驳回的节点类型、出口的 sourceHandle、发布检查的「走不到结束」)来对, +再照手册的写法搭一张图,校验、发布检查都过。 +""" +from __future__ import annotations + +import re + +from app.api.copilot import NODE_REFERENCE +from app.engine.schema import GATE_TYPES, REJECT_HANDLE, GraphSpec, validate_graph + + +def _reject_entry() -> str: + start = NODE_REFERENCE.index("「驳回」出口") + return NODE_REFERENCE[start:NODE_REFERENCE.index("\n- 一个节点连出多条普通边", start)] + + +def test_the_guide_names_every_node_that_can_be_rejected_and_the_handle(): + entry = _reject_entry() + assert f'sourceHandle="{REJECT_HANDLE}"' in entry + # 能接驳回出口的节点类型(schema.GATE_TYPES)一个不漏,按 type 字段写 + assert all(f"{t}" in entry for t in GATE_TYPES), [t for t in GATE_TYPES if str(t) not in entry] + # 六种关卡都点到:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决 + for kind in ("审批", "补充输入", "编辑稿件", "动作确认", "代码执行审批", "工具调用审批", "裁决"): + assert kind in entry, kind + + +def test_the_guide_says_when_to_connect_it_and_what_happens_without_it(): + entry = _reject_entry() + # 什么时候接:驳回后要通知、退回修改、走另一条路 + assert "通知" in entry and "退回修改" in entry and "另一条路" in entry + # 不接时:运行结束、记被驳回(不算失败);审批模式没设驳回即终止的老规矩照实说 + assert "记为被驳回" in entry and "不算失败" in entry + assert "stop_on_reject" in entry + # 驳回那条路必须走到成果节点或结束(发布检查 reject_exit.no_end),绕回去改完再问一次也可以 + assert "成果节点" in entry and "不再往下连" in entry and "发布检查" in entry + # 驳回不是故障:失败重试、出错时继续不管它 + assert "失败重试" in entry + # 驳回之后下游取得到谁、备注 + assert "{{ nodes.关卡id.note }}" in entry + + +def test_the_guide_does_not_leak_internal_names(): + entry = _reject_entry() + for internal in ("reject_exit", "NodeRejected", "end_reason", "cancelled", "__decision__", "GATE_TYPES"): + assert internal not in entry, internal + + +def test_the_old_one_line_rule_is_replaced_not_duplicated(): + # 以前只有一句「human 节点(approve 模式):sourceHandle 用 approved 和 rejected」:驳回出口说一处,不留两种说法 + assert len(re.findall(r'sourceHandle="rejected"', NODE_REFERENCE)) == 1 + + +def _node(nid: str, ntype: str, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def test_a_graph_drawn_the_way_the_guide_says_passes_the_checks(): + """照手册写:审批批准走 approved,驳回接 sourceHandle="rejected" 到「通知申请人」再到一个成果节点;工具的确认被驳回 + 退回上游修改(连回起草,改完再问一次)。画布校验、各级发布检查都不报驳回出口的问题。""" + from app.engine.governance import lint_for_publish + + graph = { + "nodes": [_node("start", "input"), _node("draft", "llm", prompt="起草"), + _node("gate", "human", mode="approve", title="可以吗"), + _node("send", "tool", tool="calculator", args={"expression": "1+1"}, approval="always"), + _node("tell", "llm", prompt="通知申请人:{{ nodes.gate.note }}"), + _node("done", "output", fields=[{"name": "结果", "value": "{{ nodes.send.text }}"}]), + _node("back", "output", fields=[{"name": "结果", "value": "{{ nodes.tell.text }}"}])], + "edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"}, + {"source": "gate", "target": "send", "sourceHandle": "approved"}, + {"source": "gate", "target": "tell", "sourceHandle": "rejected"}, + {"source": "tell", "target": "back"}, + {"source": "send", "target": "done"}, + {"source": "send", "target": "draft", "sourceHandle": "rejected"}], + } + spec = GraphSpec.model_validate(graph) + codes = {i.code for i in validate_graph(spec).issues if i.level == "error"} + assert not {c for c in codes if c and "reject" in c}, codes + for level in ("draft", "published", "governed"): + issues = lint_for_publish(spec, level=level).issues + assert not [i for i in issues if (i.code or "").startswith("reject_exit")], level diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 01b207b7..f7f2d1f0 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -376,8 +376,10 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, approval = await pending(run.id) assert approval.mode == "input" and approval.node_id == "dual" payload = approval.payload - # 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败 - assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject"} + # 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败;reject_route + # (驳回之后去哪,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["on_reject"] == "degrade" [item] = payload["summary"] diff --git a/backend/tests/test_ledger_projection.py b/backend/tests/test_ledger_projection.py index 9fa3a257..6ab642fd 100644 --- a/backend/tests/test_ledger_projection.py +++ b/backend/tests/test_ledger_projection.py @@ -131,8 +131,9 @@ def test_without_a_projection_every_pair_is_exactly_the_old_result(): ] for sql, columns, cited in cases: row = compare(sql, columns, cited) - # 逐格比(algo 4)是另加的 cells、one_row 两个键:整条查询这一层的结论和候选照旧一字不差 - assert {k: v for k, v in row.items() if k not in ("cells", "one_row")} == compare(sql, columns, None), sql + # 逐格比(algo 4)是另加的 cells 键(one_row、ordered 两边都记:行认不认得住,指标标签要看):整条查询这一层的 + # 结论和候选照旧一字不差 + assert {k: v for k, v in row.items() if k != "cells"} == compare(sql, columns, None), sql assert ("cells" in row) == (len(cited) >= 2 and row["status"] == "related"), sql diff --git a/backend/tests/test_ledger_recall.py b/backend/tests/test_ledger_recall.py index 7e7232c7..751a7ed7 100644 --- a/backend/tests/test_ledger_recall.py +++ b/backend/tests/test_ledger_recall.py @@ -913,3 +913,44 @@ async def test_restart_resumes_the_auto_batch_and_settles_finished_runs(monkeypa await recall.resume() async with SessionLocal() as session: assert len(await recall.recall_runs(session, wf, 2)) == 2, "再重启一次:都算过了,不再发起" + + +async def test_a_probe_that_cannot_assemble_its_inputs_says_which_are_missing(engine_up, caplog): + """表结构变了、口径凑不齐参数(这一版没有正式运行、也没有等价的问数回答,必填的入口字段又没有缺省值):不试算,照实记一条 + 「缺哪几个参数」——日志里有,变化记录的影响里也有(变化页照它写),不再一声不响地跳过。之后这条变化再被探查到一次,记下的 + 不丢。""" + import logging + + from app.data import source_changes as sc + from app.db.models import DataSource + from app.db.models_sources import SourceChange + + wf = await S.publish() + async with SessionLocal() as session: + source = (await session.execute(select(DataSource).where(DataSource.name == S.CURRENT["source"]))).scalar_one() + change, _ = await sc.record(session, source, sc.NewChange(kind="column_removed", table="orders", column="amount", + severity="action", key="v3"), + sc.Detected(job_kind="introspect")) + await session.commit() + out = sc.change_out(change) + with caplog.at_level(logging.INFO, logger="app.ledger.recall"): + await recall.process_change(out) + async with SessionLocal() as session: + probes = [r for r in (await session.execute(select(Run).where(Run.origin_kind == "recall"))).scalars() + if (r.origin or {}).get("change") == out["id"]] + row = (await session.execute(select(SourceChange).where(SourceChange.id == out["id"]))).scalar_one() + assert probes == [], "凑不齐参数:不试算" + [note] = row.affects["probes"] + assert (note["workflow_id"], note["version"], note["outcome"]) == (wf, 1, "skipped") + assert note["missing"] == ["start", "end"] and "start" in note["reason"], note + assert any("start、end" in r.getMessage() for r in caplog.records), [r.getMessage() for r in caplog.records] + # 变化接口(变化页)照样带着 + assert sc.change_out(row)["affects"]["probes"] == [note] + # 同一条变化再被探查到一次:影响按新的一次刷新,试算的记录不丢 + async with SessionLocal() as session: + source = await session.get(DataSource, row.source_id) + await sc.record(session, source, sc.NewChange(kind="column_removed", table="orders", column="amount", + severity="action", key="v3"), sc.Detected(job_kind="introspect")) + await session.commit() + again = (await session.execute(select(SourceChange).where(SourceChange.id == out["id"]))).scalar_one() + assert again.affects["probes"] == [note] diff --git a/backend/tests/test_ledger_reconcile.py b/backend/tests/test_ledger_reconcile.py index 68beded9..432ad856 100644 --- a/backend/tests/test_ledger_reconcile.py +++ b/backend/tests/test_ledger_reconcile.py @@ -263,7 +263,9 @@ async def test_the_reconcile_view_gives_the_panel_what_it_draws(client, monkeypa body = {"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True} res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json=body) assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.NEEDS_PAIRS - assert res.json()["options"] == {"columns": ["gmv"], "metrics": [{"id": "gmv", "name": "销售额", "unit": "元"}]} + # suggested:逐格证明了的预选(这条查询只引用了一列,没有逐格比,空) + assert res.json()["options"] == {"columns": ["gmv"], "metrics": [{"id": "gmv", "name": "销售额", "unit": "元"}], + "suggested": []} done = await reconcile(client, row.id, {**body, "pairs": [{"ours": "gmv", "metric": "gmv"}]}) [listed] = (await ledger_of(client, row.id))["reconciled"] assert listed["run_id"] == done.id and listed["card"] == "reconcile" @@ -280,3 +282,18 @@ async def test_multi_row_reconcile_groups_carry_the_row_labels(client, monkeypat assert [g["label"] for g in listed["groups"]] == ["区域=east · gmv", "区域=west · gmv"] assert listed["groups"][1] == {"label": "区域=west · gmv", "ours": "ours_1_0", "caliber": "cal_1_0", "diff": "diff_1_0", "rel": "rel_1_0"} + + +async def test_a_related_query_prefills_the_pairing_from_the_cells_that_were_proven(client, monkeypatch): + """要人选对照时,按逐格比过的结论预选:这次的 gmv 那一列单独比是和口径取数的 gmv 等价的(逐格证明了),口径卡上读这一列的 + 指标就是它最可能的对照,options.suggested 给出来,界面预选上、人可以改;单数那一列没有证明,不替人猜。""" + wf = await S.publish(graph=S.caliber_graph(expression="cell(nodes.fetch, 0, 'gmv') * 1")) + sql = ("SELECT SUM(amount) AS gmv, COUNT(*) AS 单数 FROM orders WHERE status = 'paid' " + "AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08'") + row = await S.explore(monkeypatch, sql, text="本周销售额 [[v:Q1.r0.gmv]],共 [[v:Q1.r0.单数]] 单。") + [action] = [a for a in (await ledger_of(client, row.id))["actions"] if a["kind"] == "reconcile"] + body = {"query": action["query"], "workflow_id": wf, "version": 1, "inputs": CHECKED, "confirm": True} + res = await client.post(f"/api/ledger/answers/{row.id}/reconcile", json=body) + assert res.status_code == 409 and res.json()["code"] == ledger_reconcile.NEEDS_PAIRS, res.text + options = res.json()["options"] + assert options["suggested"] == [{"ours": "gmv", "metric": "gmv"}], options diff --git a/backend/tests/test_ledger_row_order.py b/backend/tests/test_ledger_row_order.py new file mode 100644 index 00000000..17bad01d --- /dev/null +++ b/backend/tests/test_ledger_row_order.py @@ -0,0 +1,119 @@ +"""按行号取多行查询里的一格,重跑、核对时行序不一定一样(next-round 验收遗留): + +- 认法(data/sql_semantics.row_stable):最外层带 ORDER BY 的按位置认;一定只出一行的第 0 行认得住;别的(多行、又没有 + ORDER BY)行序不定——库可以按任何顺序交回这些行; +- 口径账本的指标标签:答案的一格和口径卡的一格按「同一行」认来自口径,只在行认得住时成立;行序不定的照实标「相关」、 + 写明行序不定,不再标成来自口径; +- 口径召回:两版都按行号取一格、查询又行序不定的指标,照实记「行序不定」(changed 为空),不按位置硬比; +- 对账:多行结果按维度对齐之后,每一组对账指标先核对那一行的维度是不是它(重跑时缺了一行、多了一行,后面的行会错位), + 对不上的记空值,不拿别的维度的数冒充。 +数据是虚构店铺库。 +""" +from __future__ import annotations + +import pytest + +from app.data.sql_semantics import ordered, row_stable +from app.engine.runner import run_manager +from app.ledger import match as ledger_match +from app.ledger import recall as ledger_recall +from app.ledger import reconcile as ledger_reconcile +from tests import ledger_samples as S + +UNORDERED = ("SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status = 'paid' " + "AND paid_at >= '{{ input.start }}' AND paid_at < '{{ input.end }}' GROUP BY region") +UNORDERED_ALL = ("SELECT region AS 区域, SUM(amount) AS gmv FROM orders WHERE status = 'paid' " + "AND paid_at >= '2026-09-01' AND paid_at < '2026-09-08' GROUP BY region") + + +def test_which_rows_survive_a_rerun(): + assert ordered("SELECT a, b FROM t ORDER BY a") and not ordered("SELECT a, b FROM t") + assert row_stable("SELECT a, SUM(b) FROM t GROUP BY a ORDER BY a", 3) + assert row_stable("SELECT SUM(b) FROM t", 0) and not row_stable("SELECT SUM(b) FROM t", 1) + assert not row_stable("SELECT a, SUM(b) FROM t GROUP BY a", 0) + assert not row_stable("这不是 SQL", 0) + + +@pytest.fixture +async def shop(tmp_path): + await run_manager.setup() + await S.fresh_source(tmp_path / "shop.db") + yield + await ledger_match.drain() + await run_manager.shutdown() + + +async def _label(monkeypatch, caliber_sql: str, answer_sql: str, row: int) -> dict: + from tests.test_ledger_match import matched, only_label + + await S.publish(graph=S.caliber_graph(caliber_sql, expression=f"cell(nodes.fetch, {row}, 'gmv')")) + run = await S.explore(monkeypatch, answer_sql, text="这一区销售额 [[m:gmv]]。", + card_expression=f"cell(nodes.q, {row}, 'gmv')") + recorded, _ = await matched(run.id) + return only_label(recorded.content) + + +async def test_an_unordered_multi_row_cell_is_not_claimed_to_come_from_the_caliber(shop, monkeypatch): + label = await _label(monkeypatch, UNORDERED, UNORDERED_ALL, 1) + assert label["kind"] == "related" and label["reason"] == ledger_match.ROW_ORDER_UNSTABLE, label + + +async def test_with_order_by_the_same_row_still_comes_from_the_caliber(shop, monkeypatch): + label = await _label(monkeypatch, UNORDERED + " ORDER BY region", UNORDERED_ALL + " ORDER BY region", 1) + assert label["kind"] == "caliber", label + + +def _pinned(sql: str, expression: str, version: int): + from app.ledger.derive import Pinned + + graph = S.caliber_graph(sql, expression=expression) + card = next(n for n in graph["nodes"] if n["id"] == "card") + fetch = next(n for n in graph["nodes"] if n["id"] == "fetch") + return Pinned(workflow_id="wf", workflow_name="周销售", version=version, node_id="card", level="governed", + published=True, retired=False, card=card, fetches=(fetch,), reads=(("fetch", S.tool(), ("gmv",)),), + entry_fields=(), defaults={}) + + +def test_recall_marks_unordered_row_comparisons_instead_of_comparing_by_position(): + pair = ledger_recall.Pair(old=_pinned(UNORDERED, "cell(nodes.fetch, 1, 'gmv')", 1), + new=_pinned(UNORDERED + " AND 1 = 1", "cell(nodes.fetch, 1, 'gmv')", 2)) + _, meta = ledger_recall.recall_card([pair]) + assert meta[0]["row_order"] == "unstable" + values = {"old_0": {"value": 1.0, "rendered": "1"}, "new_0": {"value": 2.0, "rendered": "2"}, + "diff_0": {"value": 1.0, "rendered": "1"}} + [item] = ledger_recall.compare(meta, values) + assert item["changed"] is None and item["row_order"] == "unstable" + # 带 ORDER BY 的照常比 + pair = ledger_recall.Pair(old=_pinned(UNORDERED + " ORDER BY region", "cell(nodes.fetch, 1, 'gmv')", 1), + new=_pinned(UNORDERED + " ORDER BY region", "cell(nodes.fetch, 1, 'gmv')", 2)) + _, meta = ledger_recall.recall_card([pair]) + assert "row_order" not in meta[0] + [item] = ledger_recall.compare(meta, values) + assert item["changed"] is True + + +def test_reconcile_checks_each_aligned_row_is_the_one_it_stands_for(): + plan = ledger_reconcile.Plan(pairs=[ledger_reconcile.Pair(ours="gmv", column="gmv")], rows=[0, 1], + keys=[("区域", "区域")], key_values=[("east",), ("west",)]) + nodes = ledger_reconcile.tail_nodes(plan, card_id="card", fetch_id="fetch") + card = next(n for n in nodes if n["id"] == "reconcile") + metrics = {m["id"]: m["expression"] for m in card["data"]["config"]["metrics"]} + from app.engine.expressions import eval_expression + + def run(rows): + table = {"columns": ["区域", "ours_0", "cal_0"], "rows": rows} + return {mid: _eval(eval_expression, expr, table) for mid, expr in metrics.items() if mid.startswith("ours_")} + + # 重跑时东区那一行没了:西区挪到了第 0 行。以前东区那一组拿的是西区的数 + got = run([["west", 58.0, 20.0]]) + assert got["ours_0_0"] is None, "东区对不上:记空值,不拿西区的数冒充" + assert run([["east", 163.0, 150.5], ["west", 58.0, 20.0]]) == {"ours_0_0": 163.0, "ours_1_0": 58.0} + + +def _eval(fn, expression, table): + from app.engine.expressions import CellError, ExpressionError + + try: + return fn(expression, {"nodes": {"align": table}}) + except (CellError, ExpressionError, IndexError, KeyError, TypeError): + return None diff --git a/backend/tests/test_reject_exit_wording.py b/backend/tests/test_reject_exit_wording.py new file mode 100644 index 00000000..5f072208 --- /dev/null +++ b/backend/tests/test_reject_exit_wording.py @@ -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] == ["复核子流程 › 把关"] diff --git a/backend/tests/test_review_subgraph.py b/backend/tests/test_review_subgraph.py new file mode 100644 index 00000000..6ea79e4a --- /dev/null +++ b/backend/tests/test_review_subgraph.py @@ -0,0 +1,105 @@ +"""复核规则(engine/review.scan)对子工作流里失败的那一步只报一条:子图里那一步一条 node.failed、子工作流节点又一条,原话 +相同,以前合成两条「执行失败」,问数据页的复核提示里同一件事写两遍。保留更具体的那一步,名字写清在哪个子工作流里 +(「子工作流 › 那一步」,不写内部 id)。图和数据都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.core.events import EventType +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import review as rv +from app.engine.runner import run_manager +from tests import ledger_samples as S + +OK = {"结果": "x"} + + +def ev(etype, **data): + return {"type": str(etype), "node_id": data.pop("node_id", ""), "data": data} + + +def test_a_failure_inside_a_subworkflow_is_reported_once_with_where_it_happened(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_STARTED, node_id="sub/calc", label="算一下"), + ev(EventType.NODE_FAILED, node_id="sub/calc", error="表达式写错了"), + ev(EventType.NODE_FAILED, node_id="sub", error="表达式写错了"), + ], OK) + [signal] = [s for s in signals if s.kind == "node_failed"] + assert signal.detail == "节点「复核子流程 › 算一下」执行失败:表达式写错了" + assert "sub/" not in signal.detail and signal.severity == rv.BROKEN + + +def test_nested_two_levels_and_different_errors_stay_apart(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="a", label="外层"), + ev(EventType.NODE_STARTED, node_id="a/b", label="内层"), + ev(EventType.NODE_STARTED, node_id="a/b/c", label="取数"), + ev(EventType.NODE_FAILED, node_id="a/b/c", error="连不上"), + ev(EventType.NODE_FAILED, node_id="a/b", error="连不上"), + ev(EventType.NODE_FAILED, node_id="a", error="连不上"), + # 父图里另一个节点原话不同的失败照报 + ev(EventType.NODE_STARTED, node_id="other", label="另一步"), + ev(EventType.NODE_FAILED, node_id="other", error="超时"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_failed"] == [ + "节点「外层 › 内层 › 取数」执行失败:连不上", "节点「另一步」执行失败:超时"] + + +def test_a_rejection_inside_a_subworkflow_is_reported_once_too(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_STARTED, node_id="sub/gate", label="把关"), + ev(EventType.NODE_FAILED, node_id="sub/gate", error="人工驳回:不对", reason="rejected"), + ev(EventType.NODE_FAILED, node_id="sub", error="人工驳回:不对", reason="rejected"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_rejected"] == ["节点「复核子流程 › 把关」被驳回:人工驳回:不对"] + + +def test_a_subworkflow_failing_on_its_own_still_reports(): + """子工作流节点自己失败(没选工作流、版本不存在):子图里没有哪一步失败,照报这一条。""" + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_FAILED, node_id="sub", error="「子工作流」节点尚未选择要嵌套的工作流"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_failed"] == [ + "节点「复核子流程」执行失败:「子工作流」节点尚未选择要嵌套的工作流"] + + +@pytest.fixture +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}} + + +async def test_a_real_run_failing_inside_a_subworkflow_gives_one_signal(engine_up): + inner = S.chain(node("s", "input"), node("calc", "transform", "算一下", mode="expression", expression="1 / 0"), + node("o", "output", fields=[{"name": "r", "value": "{{ nodes.calc.text }}"}])) + 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={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("failed", "succeeded") and not run_manager.is_active(run.id): + break + assert row.status == "failed" + async with SessionLocal() as session: + rows = list((await session.execute(select(RunEvent).where(RunEvent.run_id == run.id) + .order_by(RunEvent.seq))).scalars()) + events = [{"type": r.type, "node_id": r.node_id, "data": r.data} for r in rows] + failed = [s for s in rv.scan(events, {}) if s.kind == "node_failed"] + assert len(failed) == 1, [s.detail for s in failed] + assert failed[0].detail.startswith("节点「复核子流程 › 算一下」执行失败:") diff --git a/backend/tests/test_schedule_events_scale.py b/backend/tests/test_schedule_events_scale.py new file mode 100644 index 00000000..547701a0 --- /dev/null +++ b/backend/tests/test_schedule_events_scale.py @@ -0,0 +1,171 @@ +"""事件触发的规模(scheduler/outbox.py、scheduler/triggers.py、api/schedules.py): + +- 定时任务列表里每个事件触发任务「有几条在等」一次查齐(outbox.pending_for_many:一条 SQL 按任务分组算待派发数),不再 + 每行各查一次; +- 每一拍派发只读监听着这几种事件、这几个对象的任务(按触发条件里的事件种类、对象筛,用 SQLAlchemy 的通用 JSON 访问), + 不再把全部事件触发任务读出来;保存时查环只顺着「上游工作流运行成功结束」那几条边往下找; +- 选对象(trigger-options)按事件、关键字查、分页:返回 more 说还有没有,当前选中的那一个总在里面; +- 去抖窗口最早那一条因为任务改了条件被标 dropped 时,「什么时候合成触发」按剩下的最早一条算;一个窗口的结束时刻和这个任务 + 已经有的另一次触发撞在同一刻(别的期次、早一拍发的)时往后挪,那几条事件照常发起,不并进已有的触发。 +工作流、表格、数据源都是虚构的。 +""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import timedelta + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event as sa_event +from sqlalchemy import select + +from app.db.base import SessionLocal, engine, utcnow +from app.db.models import Schedule, ScheduleFire, Workflow +from app.db.models_triggers import TriggerDelivery +from app.scheduler import outbox +from tests.test_schedule_events import ( # noqa: F401 - engine_up 是本模块也要的 autouse 夹具 + deliveries, engine_up, event_schedule, fires, put_event, settle, workflow, +) + + +@contextmanager +def counted(): + """数这一段里发了几条 SQL。""" + seen: list[str] = [] + + def hook(conn, cursor, statement, *_a): # noqa: ANN001 + seen.append(statement) + + target = engine.sync_engine + sa_event.listen(target, "before_cursor_execute", hook) + try: + yield seen + finally: + sa_event.remove(target, "before_cursor_execute", hook) + + +@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 + + +async def _many(n: int) -> tuple[str, list[str]]: + upstream = await workflow("门店日报") + downstream = await workflow("区域汇总") + ids = [await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 600}, + name=f"汇总{i}") for i in range(n)] + return upstream, ids + + +async def test_waiting_counts_for_many_schedules_in_one_go(): + upstream, ids = await _many(3) + t0 = utcnow().replace(microsecond=0) + for i in range(2): + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": f"r{i}"}, t0 + timedelta(seconds=i)) + await outbox.route(t0 + timedelta(seconds=5)) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r9"}, t0 + timedelta(seconds=9)) + async with SessionLocal() as session: + rows = list((await session.execute(select(Schedule).where(Schedule.id.in_(ids)))).scalars()) + one = {r.id: await outbox.pending_for(session, r) for r in rows} + with counted() as sql: + many = await outbox.pending_for_many(session, rows) + assert many == one + assert all(v == {"events": 1, "waiting": 2, "due_at": (t0 + timedelta(seconds=600)).isoformat()} for v in many.values()) + assert len(sql) <= 3, sql + + +async def test_the_schedule_list_does_not_query_once_per_row(client): + await _many(2) + with counted() as few: + assert (await client.get("/api/schedules")).status_code == 200 + await _many(5) + with counted() as more: + assert (await client.get("/api/schedules")).status_code == 200 + assert len(more) <= len(few), (len(few), len(more)) + + +async def test_a_tick_reads_only_the_schedules_listening_to_the_pending_events(): + upstream, ids = await _many(2) + other = await workflow("别的日报") + elsewhere = await event_schedule(await workflow("别的汇总"), {"event": "workflow.finished", "workflow_id": other}) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1"}, utcnow()) + async with SessionLocal() as session: + from app.db.models_triggers import TriggerEvent + + pending = list((await session.execute(select(TriggerEvent).where(TriggerEvent.status == "pending"))).scalars()) + listeners = await outbox.listeners(session, pending) + assert sorted(sid for sid, _ in listeners) == sorted(ids) and elsewhere not in {sid for sid, _ in listeners} + + +async def test_trigger_options_search_and_page(client): + names = [f"检索样例{c}-{i}" for c in "甲乙" for i in range(3)] + async with SessionLocal() as session: + rows = [Workflow(name=n, graph={"nodes": [], "edges": []}) for n in names] + session.add_all(rows) + await session.commit() + chosen = next(r.id for r in rows if r.name == "检索样例乙-2") + r = await client.get("/api/schedules/trigger-options", params={"event": "workflow.finished", "q": "检索样例甲", + "limit": 2}) + assert r.status_code == 200, r.text + [entry] = r.json()["events"] + assert entry["event"] == "workflow.finished" and entry["more"] is True + assert [o["name"] for o in entry["objects"]] == ["检索样例甲-0", "检索样例甲-1"] + r = await client.get("/api/schedules/trigger-options", params={"event": "workflow.finished", "q": "检索样例甲", + "limit": 2, "selected": chosen}) + [entry] = r.json()["events"] + assert chosen in [o["id"] for o in entry["objects"]], "当前选中的那一个总在里面(不被分页挤掉、不显示成已不存在)" + # 不带参数:照旧给全部种类,每种最多一页 + body = (await client.get("/api/schedules/trigger-options")).json() + assert {e["event"] for e in body["events"]} == {"table.version", "workflow.finished", "source.changed"} + assert all("more" in e for e in body["events"]) + + +async def test_a_dropped_earliest_event_no_longer_sets_the_window_end(): + """窗口最早那一条因为任务改了条件被标 dropped:什么时候合成触发按剩下的最早一条算(列表上的 due_at、真发起的 fire_at 一致)。""" + upstream = await workflow("门店日报") + downstream = await workflow("区域汇总") + sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}) + t0 = utcnow().replace(microsecond=0) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1", "origin_kind": "launcher"}, t0) + e2 = await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r2", + "origin_kind": "app_entry"}, t0 + timedelta(seconds=30)) + await outbox.route(t0 + timedelta(seconds=31)) + async with SessionLocal() as session: + await session.execute(Schedule.__table__.update().where(Schedule.id == sid).values( + trigger={"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60, "origin_kind": "app_entry"})) + await session.commit() + state = await outbox.pending_for(session, await session.get(Schedule, sid)) + assert state["due_at"] == (t0 + timedelta(seconds=90)).isoformat(), state + assert await outbox.dispatch_due(t0 + timedelta(seconds=61)) == [] + [fire] = await outbox.dispatch_due(t0 + timedelta(seconds=91)) + assert fire.fire_at == t0 + timedelta(seconds=90) and fire.inputs["event"]["event_ids"] == [e2] + await settle(fire.run_id) + + +async def test_a_window_ending_where_another_fire_already_is_still_fires(): + """一个窗口的结束时刻和这个任务已有的另一次触发撞在同一刻(别的期次早一拍发了):往后挪,这几条照常发起, + 不并进已有的那次触发、不被它吞掉。""" + source = "src-虚构" + downstream = await workflow("月报") + sid = await event_schedule(downstream, {"event": "table.version", "source_id": source, "debounce_s": 60}) + t0 = utcnow().replace(microsecond=0) + aug = await put_event("table.version", source, {"period": "2026-08", "table": "日报"}, t0) + [first] = await outbox.dispatch_due(t0 + timedelta(seconds=61)) + assert first.fire_at == t0 + timedelta(seconds=60) and first.inputs["event"]["event_ids"] == [aug] + await settle(first.run_id) + # 另一期的事件发生在同一刻,晚到了一拍(比如补写的):窗口结束同样是 t0+60 + sep = await put_event("table.version", source, {"period": "2026-09", "table": "日报"}, t0) + [second] = await outbox.dispatch_due(t0 + timedelta(seconds=62)) + assert second.id != first.id and second.inputs["event"]["event_ids"] == [sep] + assert second.fire_at > first.fire_at + states = {d.event_id: (d.status, d.fire_id) for d in await deliveries(sid)} + assert states[sep] == ("fired", second.id) + await settle(second.run_id) + async with SessionLocal() as session: + assert len(list((await session.execute(select(ScheduleFire).where(ScheduleFire.schedule_id == sid))).scalars())) == 2 + assert len(await fires(sid)) == 2 + assert [d.status for d in await deliveries(sid)] == ["fired", "fired"] + assert TriggerDelivery is not None diff --git a/backend/tests/test_subgraph_repeat.py b/backend/tests/test_subgraph_repeat.py new file mode 100644 index 00000000..e7ebac88 --- /dev/null +++ b/backend/tests/test_subgraph_repeat.py @@ -0,0 +1,158 @@ +"""同一个子工作流节点在一次运行里执行多次(循环体里、退回修改绕回来):子图里的节点每次执行都要分得开。 + +以前子图每次都从空状态起步,里面的节点每次都记成「第 1 次执行」: +- 台账里同一个节点(「sub/fetch」)几条查询都记 exec 1,分不出是第几次; +- 写外部系统的幂等键按 (运行, 节点 id, 第几次执行) 算,子图里的节点 id 还是子图里的(pay,不带前缀):循环第二轮的 + 写入算出同一个键,找到第一轮生效的记录,交回第一轮的回执、这一轮不发送——外部系统少收一笔,界面上只有一句「已经生效」; + 父图里同名的节点、两个子工作流节点套同一个工作流时也撞同一个键。 +现在:子图里的节点接着父运行里同一个子工作流节点之前几次数(exec 1、2…),幂等键按这次运行里的节点 id(「sub/pay」)算; +升级前停下的运行(子图里的写入按子图里的 id 记的),恢复时照旧认得出已经生效的那一笔,不重发。 +图、能力、外部系统都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import ActionRecord, Run, RunEvent +from app.engine.runner import run_manager +from app.process import actions +from tests import ledger_samples as S +from tests.fakes.fake_erp import PAY, FakeErp, endpoint_row, install + + +@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 edge(source, target, handle=None): + return {"source": source, "target": target, **({"sourceHandle": handle} if handle else {})} + + +async def _finish(graph: dict, **kwargs) -> Run: + run = await run_manager.start(graph=graph, input_payload={}, **kwargs) + for _ in range(600): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed", "interrupted") and not run_manager.is_active(run.id): + return row + raise AssertionError(f"运行 {run.id} 没跑完:{row.status}") + + +async def _events(run_id: str, kind: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +def looped(sub_id: str, *, before: list[dict] | None = None) -> dict: + """开始 → (before…) → 循环两轮,每轮跑一次子工作流 → 成果。""" + nodes = [node("start", "input"), *(before or []), + node("each", "loop", "逐项", mode="foreach", items="[1, 2]", item_var="it", max_iterations=5), + node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "{{ vars.it }}"}), + node("out", "output", fields=[{"name": "r", "value": "完成"}])] + chain = ["start", *[b["id"] for b in before or []], "each"] + edges = [edge(a, b) for a, b in zip(chain, chain[1:])] + edges += [edge("each", "sub", "body"), edge("sub", "each"), edge("each", "out", "done")] + return {"nodes": nodes, "edges": edges} + + +async def test_inner_steps_count_on_across_invocations(tmp_path): + await S.fresh_source(tmp_path / "shop.db") + inner = S.chain(node("s", "input"), node("fetch", "tool", tool=S.tool(), args={"sql": "SELECT COUNT(*) AS n FROM orders"}), + node("o", "output", fields=[{"name": "n", "value": "{{ nodes.fetch.text }}"}])) + sub_id = await S.publish("取数子流程", graph=inner, level="draft") + row = await _finish(looped(sub_id)) + assert row.status == "succeeded", row.error + finished = await _events(row.id, "node.finished", "sub/fetch") + execs = [e["exec"] for f in finished for e in f.data.get("evidence") or [] if e.get("kind") == "query"] + assert execs == [1, 2], "第二轮是子图里那一步的第 2 次执行,不再又记成第 1 次" + + +@pytest.fixture +def erp() -> FakeErp: + return FakeErp() + + +async def _unattended(monkeypatch, erp) -> None: + """付款能力设成可以无人值守写入、节点不要确认:两轮都直接发,不停下等人(和确认无关,只看幂等键分不分得开)。""" + caps = install(monkeypatch, erp, await endpoint_row(test=True), lookup=False) + caps[PAY].unattended_write = True + + +def pay(nid: str = "pay") -> dict: + return node(nid, "tool", "付款", tool=PAY, approval="never", args={"amount": "{{ input.n }}", "card": "6222020200112233"}) + + +async def test_a_write_inside_a_loop_is_sent_on_every_round(monkeypatch, erp): + await _unattended(monkeypatch, erp) + inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(), + node("o", "output", fields=[{"name": "单号", "value": "{{ nodes.pay.receipt.id }}"}])) + sub_id = await S.publish("付款子流程", graph=inner, level="draft") + row = await _finish(looped(sub_id)) + assert row.status == "succeeded", row.error + assert len(erp.writes("test")) == 2, "第二轮的写入照发,不交回第一轮的回执" + async with SessionLocal() as session: + records = list((await session.execute(select(ActionRecord).where(ActionRecord.run_id == row.id) + .order_by(ActionRecord.created_at))).scalars()) + assert [(r.node_id, r.exec_no) for r in records] == [("sub/pay", 1), ("sub/pay", 2)] + assert len({r.action_key for r in records}) == 2 + assert not [e for e in await _events(row.id, "log") if e.data.get("code") == "action_already_applied"] + + +async def test_a_namesake_in_the_parent_does_not_share_the_key(monkeypatch, erp): + """父图里也有一个 pay:子图里的 pay 不再和它撞同一个幂等键。""" + await _unattended(monkeypatch, erp) + inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(), node("o", "output", fields=[{"name": "r", "value": "x"}])) + sub_id = await S.publish("付款子流程", graph=inner, level="draft") + graph = S.chain(node("start", "input", fields=[{"name": "n"}]), pay(), + node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "7"}), + node("out", "output", fields=[{"name": "r", "value": "完成"}])) + run = await run_manager.start(graph=graph, input_payload={"n": 5}) + for _ in range(600): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and not run_manager.is_active(run.id): + break + assert row.status == "succeeded", row.error + assert len(erp.writes("test")) == 2 + async with SessionLocal() as session: + ids = sorted(r.node_id for r in (await session.execute( + select(ActionRecord).where(ActionRecord.run_id == row.id))).scalars()) + assert ids == ["pay", "sub/pay"] + + +async def test_a_write_recorded_before_the_upgrade_is_still_found(monkeypatch, erp): + """升级前子图里的写入按子图里的 id(pay)记幂等键。停在子图里的运行恢复时整个子图重跑:照旧按那个键找到已经生效的 + 记录、交回回执,不重发。父图里有同名节点时不这样认(那一条可能是父图那个节点的)。""" + caps = install(monkeypatch, erp, await endpoint_row(test=True), lookup=False) + async with SessionLocal() as session: + run = Run(graph={"nodes": [node("sub", "subgraph")], "edges": []}, status="running", run_class="exploratory") + session.add(run) + await session.flush() + legacy = actions.action_key(run.id, "pay", 1) + session.add(ActionRecord(action_key=legacy, attempt=1, idem_key=f"{legacy}:1", run_id=run.id, node_id="pay", + exec_no=1, capability=PAY, environment="test", payload_hash="0" * 64, state="confirmed", + receipt={"summary": {"id": "PAY-OLD"}})) + await session.commit() + run_id = run.id + args = {"amount": "5", "card": "6222020200112233"} + found = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args, + legacy_node_id="pay") + assert found.key == legacy and found.receipt is not None + fresh = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args) + assert fresh.key != legacy and fresh.receipt is None diff --git a/frontend/src/api/chatTurns.ts b/frontend/src/api/chatTurns.ts index e3990c9b..34bf8c3d 100644 --- a/frontend/src/api/chatTurns.ts +++ b/frontend/src/api/chatTurns.ts @@ -125,7 +125,12 @@ export function streamChatTurn( controller.abort() return } - if (typeof event.seq !== 'number' || event.seq <= lastSeq) continue // 重连时重叠的那段只要一份 + if (typeof event.seq !== 'number') { + // 助手等模型时的心跳不落库、不带 seq(服务端 chat/log.beat):照收、不动 lastSeq,重连时服务端会补最近的一拍 + if (event.type === 'op' && event.data?.op === 'heartbeat' && !closed) onEvent(event as ChatTurnEvent) + continue + } + if (event.seq <= lastSeq) continue // 重连时重叠的那段只要一份 lastSeq = event.seq got = true if (!closed) onEvent(event as ChatTurnEvent) diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index d92e3015..98faad1f 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -36,8 +36,12 @@ export const schedules = { */ eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) => get(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts), - /** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */ - triggerOptions: (opts?: RequestOptions) => get('/schedules/trigger-options', opts), + /** + * 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404。对象一页有上限 + * (more 为真时没给完):按事件种类、名字里的关键字再查;selected 是当前选中的那一个,总在结果里 + */ + triggerOptions: (find?: { event?: ScheduleEventKind; q?: string; selected?: string }, opts?: RequestOptions) => + get(`/schedules/trigger-options${qs(find)}`, opts), /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ run: (id: string) => post(`${at(id)}/run`), /** 触发历史,新的在前。before 是上一页给的 next_before */ diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 4c4d5056..55bbbfe6 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5075,6 +5075,8 @@ export const DUAL_CHECK_TEXT = { /** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */ reject: '驳回并终止', emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因', + /** 节点接了驳回出口(中断内容的 reject_route):驳回后转到那一步 */ + emptyRejectRoute: (goes: string) => `驳回后${goes}。可以在备注中说明原因`, rejected: '已驳回,本次运行终止', }, diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 9f7fb8c4..d17b8762 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -110,7 +110,7 @@ export const TASK_TEXT = { group: (name: string) => `组「${name}」`, }, 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, verified: '已登录核实', unverified: '自报署名', @@ -125,9 +125,13 @@ export const TASK_TEXT = { editedContent: '修改后的内容', approveHint: '批准:继续运行;驳回:转入「驳回」分支', inputHint: '提交:以你填写的内容继续运行;驳回:终止本次运行', + /** 驳回之后去哪(中断内容的 reject_route,见 REJECT_ROUTE_TEXT):带了的按它说,老待办没带的照上面两句 */ + approveHintRoute: (goes: string) => `批准:继续运行;驳回:${goes}`, + inputHintRoute: (goes: string) => `提交:以你填写的内容继续运行;驳回:${goes}`, doneApprove: '已批准,运行继续', doneReject: '已驳回,转入驳回分支', doneRejectStop: '已驳回,本次运行终止', + doneRejectRoute: (goes: string) => `已驳回,${goes}`, doneSubmit: '已提交,运行继续', doneVote: '已记下你的意见,等待其他人处理', formInvalid: (n: number) => `有 ${n} 项需要修改`, @@ -154,6 +158,34 @@ export const TASK_TEXT = { 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 | 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:确认时看过的就是发出去的) */ export const ACTION_CONFIRM_TEXT = { head: '确认后发送', @@ -171,6 +203,8 @@ export const ACTION_CONFIRM_TEXT = { noHash: '缺少内容哈希,无法确认发送', done: '已确认,按此内容发送', rejected: '已驳回,不发送', + /** 驳回之后去哪(reject_route):「已驳回,不发送,转到「通知申请人」」 */ + rejectedRoute: (goes: string) => `已驳回,不发送,${goes}`, } /** 核实结果未知的动作:三个选项(§2.5 A5 第 4 条) */ diff --git a/frontend/src/lib/termsLedger.ts b/frontend/src/lib/termsLedger.ts index 5be516de..a5a93452 100644 --- a/frontend/src/lib/termsLedger.ts +++ b/frontend/src/lib/termsLedger.ts @@ -87,6 +87,9 @@ export const LEDGER_UI = { maskedPlaceholder: '已遮罩,请重新填写', pairs: '选择要对照的指标', pairsHint: '认不出这次的哪一列对应口径的哪个指标,请为要对照的列选一个口径卡上的指标', + /** 有预选的:按逐格比对证明了的那几列预选上,人可以改 */ + pairsSuggested: '已按逐格比对的结论预选了能证明对应的列,请核对,可以改选', + pairSuggestedTag: '预选', pairNone: '不对照', pairEmpty: '至少选一组对照', launch: '发起对账', diff --git a/frontend/src/lib/termsRecall.ts b/frontend/src/lib/termsRecall.ts index 0cc978be..a1931a94 100644 --- a/frontend/src/lib/termsRecall.ts +++ b/frontend/src/lib/termsRecall.ts @@ -99,6 +99,8 @@ export const RECALL_TEXT = { original: '原答案当时的值', changed: '变了', same: '没变', + rowOrder: '行序不定', + rowOrderHint: '取的是多行结果里按行号的一格,查询没有排序,重新执行时这一行可能是另一行,两版按位置比不出变没变', masked: '已遮罩', missing: '—', answers: '用到这份口径的答案', diff --git a/frontend/src/lib/termsSource.ts b/frontend/src/lib/termsSource.ts index cd04b605..c10fc9be 100644 --- a/frontend/src/lib/termsSource.ts +++ b/frontend/src/lib/termsSource.ts @@ -110,6 +110,11 @@ export const SOURCE_CHANGE_TEXT = { impactNeeds: '人工确认过、还说着被删的列', impactRules: '有效记录条件用到了这一列:取值有变时,按它过滤的结果可能漏掉新状态的行', impactTemplates: (n: number) => `用到这张表的已发布模板 ${formatNumber(n)} 个`, + /** 表结构变了之后没能试算的口径:缺参数的写缺哪几个,别的写原因原话 */ + probesTitle: '没有试算的口径', + probeSkipped: (name: string, version: number, missing: string[], reason: string) => + `「${name}」v${version}:${missing.length ? `缺少参数 ${missing.join('、')},没有试算` : `没有试算(${reason})`}`, + probeFrom: { formal: '参数按最近一次正式运行取', answer: '参数按等价的问数回答取', defaults: '这一版还没有正式运行,只能用入口的缺省值' } as Record, templateImpact: { direct: '写死的 SQL 直接引用', possible: '可能涉及' } as Record, templateVersion: (v: number) => `v${v}`, breaks: (cols: string[]) => (cols.length diff --git a/frontend/src/lib/termsTrigger.ts b/frontend/src/lib/termsTrigger.ts index 2a94ad32..caeff647 100644 --- a/frontend/src/lib/termsTrigger.ts +++ b/frontend/src/lib/termsTrigger.ts @@ -43,6 +43,10 @@ export const TRIGGER_TEXT = { /** 不能选的对象:名字后面接服务端给的原因 */ subjectNotOk: (name: string, reason: string) => `${name}(${reason})`, subjectGone: (name: string) => `${name}(已不存在)`, + /** 对象太多、一页没列完:按名字查找 */ + subjectSearch: (label: string) => `按名称查找${label}`, + subjectSearchHint: '只列出了一部分,输入名称中的字查找', + subjectSearchNone: '没有名称中含这些字的', optionsLoading: '正在读取可选的对象…', optionsFailed: '无法读取可选的对象', optionsUnsupported: '服务端暂不支持事件触发,请升级服务端后再使用', diff --git a/frontend/src/pages/catalog/ChangeDetail.tsx b/frontend/src/pages/catalog/ChangeDetail.tsx index 7657db92..979ac9a2 100644 --- a/frontend/src/pages/catalog/ChangeDetail.tsx +++ b/frontend/src/pages/catalog/ChangeDetail.tsx @@ -294,7 +294,8 @@ function Facts({ change: c }: { change: SourceChange }) { /** 影响面:说到这些列的目录项(测到时和现在的状态)、用到这张表的已发布模板(写死的 SQL 会不会坏)、有效记录条件 */ function Impact({ change: c }: { change: SourceChange }) { const { catalog, templates, rules_touched: rules } = c.affects - const empty = !catalog.length && !templates.length && !rules + const probes = c.affects.probes ?? [] + const empty = !catalog.length && !templates.length && !rules && !probes.length return ( {empty &&

{T.impactNone}

} @@ -343,6 +344,21 @@ function Impact({ change: c }: { change: SourceChange }) { )} + {probes.length > 0 && ( +
+

{T.probesTitle}

+
    + {probes.map((p) => ( +
  • + {T.probeSkipped(p.name, p.version, p.missing ?? [], p.reason)} + {p.inputs_from && T.probeFrom[p.inputs_from] && ( + {T.probeFrom[p.inputs_from]} + )} +
  • + ))} +
+
+ )}
) } diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index f6e79d55..7d0d50a4 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -21,6 +21,7 @@ import { signerNow } from '../../store/auth' import { explainRunError, explainStartError } from '../../lib/explain' import { canLeave, leavePass } from '../../lib/leave' import { SIGNER_TEXT, UNSAVED_HINT, isUnsaved, runName } from '../../lib/terms' +import { rejectRouteOf } from '../../lib/termsApp' import { asView, duplicateNames, graphShape, idTail, isLiveRun, runScope, type DetailView, } from './model' @@ -347,7 +348,11 @@ export function RunDetailView({ runId, onChange, onDeleted }: { setFeedback({ kind: 'decided', approved, node: a.node_label ?? labelOf(a.node_id) ?? a.node_id, by: done?.resolved_by, at: done?.resolved_at ?? new Date().toISOString(), - terminates: approved === false && a.mode !== 'approve', + // 驳回之后去哪按中断内容的 reject_route(接了驳回出口的不终止,没接的工具 / 代码审批会终止);老审批没带的照旧按模式猜 + terminates: approved === false && (() => { + const route = rejectRouteOf(a.payload as Record | undefined) + return route ? 'ends' in route && route.ends === 'run' : a.mode !== 'approve' + })(), }) // 批完要能看着它往下跑:接上实时流,时间线继续长 d.follow(next) diff --git a/frontend/src/pages/schedules/TriggerEditor.tsx b/frontend/src/pages/schedules/TriggerEditor.tsx index d36f131b..b307e704 100644 --- a/frontend/src/pages/schedules/TriggerEditor.tsx +++ b/frontend/src/pages/schedules/TriggerEditor.tsx @@ -73,6 +73,28 @@ function useTriggerOptions(): OptionsState { return state } +/** + * 对象太多、一页没列完(服务端的 more)时按名字查:输入停一下再查,结果只换这一种事件的对象;当前选中的那一个总带上 + * (selected),不被分页挤掉、不显示成已不存在 + */ +function useObjectSearch(kind: ScheduleEventKind, selected: string, enabled: boolean) { + const [q, setQ] = useState('') + const [found, setFound] = useState<{ key: string; objects: ScheduleTriggerOptions['events'][number]['objects'] } | null>(null) + const words = q.trim() + const key = `${kind}\u0000${words}` + useEffect(() => { + if (!enabled || !words) return + const controller = new AbortController() + const timer = setTimeout(() => { + api.schedules.triggerOptions({ event: kind, q: words, selected: selected || undefined }, { signal: controller.signal }) + .then((data) => setFound({ key, objects: data.events.find((e) => e.event === kind)?.objects ?? [] })) + .catch(() => {}) + }, 300) + return () => { clearTimeout(timer); controller.abort() } + }, [enabled, kind, words, selected, key]) + return { q, setQ, objects: words && found?.key === key ? found.objects : null } +} + export function EventSection({ value, onChange, timezone, setTimezone, savedName, error, debounceError }: { value: TriggerForm onChange: (t: TriggerForm) => void @@ -91,7 +113,9 @@ export function EventSection({ value, onChange, timezone, setTimezone, savedName const kind = value.event const label = TT.subject[kind] const entry = options.status === 'ok' ? options.data.events.find((e) => e.event === kind) : undefined - const objects = entry?.objects ?? [] + const paged = !!entry?.more + const search = useObjectSearch(kind, value.subjectId, paged) + const objects = search.objects ?? entry?.objects ?? [] const listed = objects.some((o) => o.id === value.subjectId) let picker @@ -112,7 +136,10 @@ export function EventSection({ value, onChange, timezone, setTimezone, savedName aria-describedby={error ? 'schedule-trigger-subject-error' : undefined} onChange={(e) => set({ subjectId: e.target.value })}> - {value.subjectId && !listed && } + {/* 一页没列完时不在列表里不等于不存在:写保存时记下的名字,不标「已不存在」 */} + {value.subjectId && !listed && ( + + )} {objects.map((o) => (