合入期 4 整合 J4:质询回答工件在记录页有名字和说明;问答串在审批列表取失败时不再漏条目;运行对比的默认基准与定时任务自动对比用同一条规则(同一任务、同一工作流、比这次先发起的上一次成功并封存的运行),触发历史在基准不是紧挨着的上一次时写明「与上一次成功的运行对比」

This commit is contained in:
ThearchyHelios
2026-10-06 16:23:40 +08:00
17 changed files with 886 additions and 148 deletions
+55 -29
View File
@@ -1,9 +1,11 @@
"""运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / SQL 变了(engine/compare.py)。
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。这次运行是定时任务发起的,取同一
定时任务、结束得比它早的最近一次终态运行(reason: same_schedule);不是、或者同一任务还没有更早的,取同一工作流、
同一运行类别、结束得比它早的最近一次终态运行(reason: same_workflow)。只挑封存过的:没封存的拿来也比不了。
找不到 404。
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。规则在 pick_base,定时任务结束后
的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到
同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行成功
了)就不再给出(auto_compare.outdated)。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里
成功结束并封存的最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里
同样条件的最近一次(reason: same_workflow)。找不到 404。
GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template, metrics, queries, notes},每个字段
的形状见 engine/compare.py。两次运行都必须已结束、封存且封存核对一致,封存判定照搬证据接口(api/evidence._sealed):
@@ -15,16 +17,16 @@ GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template
结果里。读工件、比 SQL、重算都在线程里做:快照大的时候不挡住同一进程里别的请求。
路径挂在 /api/runs/{run_id}/… 下面;这个路由登记在 runs.router 之前,以后加 /api/runs/compare 这类固定路径也不会
被 /{run_id} 吃掉。定时任务结束后自动对比(期 4)直接调 default_base、compare_runs。
被 /{run_id} 吃掉。定时任务结束后自动对比(期 4)直接调 pick_base、compare_runs。
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import Any
from typing import Any, NamedTuple
from fastapi import APIRouter
from sqlalchemy import select
from sqlalchemy import func, select
from app.api.coded import CodedHTTPException
from app.api.evidence import _TERMINAL, RUN_NOT_FOUND, _Masks, _Sealed, _sealed, _sealed_cards
@@ -51,8 +53,9 @@ UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录
#: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」
NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比"
SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果"
NO_BASE_TEXT = ("没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、"
"更早结束并已封存的运行")
#: 找不到默认基准。运行详情的「与上一次对比」把它原样弹给人看,对比页把它跟在标题「没有默认基准」后面:自己得说清
#: 找的是什么、没找到。定时任务发起的先找同一定时任务、再找同一工作流,两档都落空才到这里,条件只写后一档的
NO_BASE_TEXT = "没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行"
TARGET = "这次运行"
BASE = "作为基准的运行"
@@ -63,8 +66,7 @@ async def compare_base(run_id: str) -> dict[str, Any]:
found = await default_base(run_id)
if found is None:
raise CodedHTTPException(404, NO_BASE_TEXT, NO_BASE)
run, reason = found
return {"base_run_id": run.id, "reason": reason, "base": run_ref(run)}
return {"base_run_id": found.run.id, "reason": found.reason, "base": run_ref(found.run)}
@router.get("/{run_id}/compare")
@@ -75,29 +77,53 @@ async def compare_with(run_id: str, base: str | None = None) -> dict[str, Any]:
return await compare_runs(run_id, base.strip())
async def default_base(run_id: str) -> tuple[Run, str] | None:
"""(默认的基准运行, 怎么选的);找不到返回 None,这次运行本身不存在 404。
class Pick(NamedTuple):
"""挑出来的基准:哪一次、怎么选的(same_schedule | same_workflow)、是不是紧挨着的上一次(同一范围里两次之间没有
隔着别的运行——失败、取消、还没结束的都算隔着)。"""
run: Run
reason: str
previous: bool
「早」按结束时间比:这次运行还没结束(还在跑、停在审批)的按开始时间。只挑终态、封存过的运行——没封存的拿来
对比只会得到 409。定时任务发起的先找同一定时任务(同一个工作流)的;它是这个任务的第一次时,退回同一工作流、
同一运行类别。没挂在已保存的工作流上的运行(画布上没保存就运行的图)没有可比的。"""
async def default_base(run_id: str) -> Pick | None:
"""这次运行默认拿哪一次来比(pick_base);找不到返回 None,这次运行本身不存在 404。"""
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None:
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
if not run.workflow_id:
return None
before = run.finished_at or run.started_at or run.created_at
query = (select(Run)
.where(Run.id != run.id, Run.workflow_id == run.workflow_id, Run.status.in_(_TERMINAL),
Run.manifest_hash.is_not(None), Run.finished_at.is_not(None), Run.finished_at < before)
.order_by(Run.finished_at.desc(), Run.created_at.desc()).limit(1))
if run.schedule_id:
found = (await session.execute(query.where(Run.schedule_id == run.schedule_id))).scalars().first()
if found is not None:
return found, "same_schedule"
found = (await session.execute(query.where(Run.run_class == run.run_class))).scalars().first()
return (found, "same_workflow") if found is not None else None
return await pick_base(session, run)
async def pick_base(session: Any, target: Run, *, schedule_only: bool = False) -> Pick | None:
"""target 的基准:比它先发起的运行里成功结束并封存的最近一次。运行详情「与上一次对比」(default_base)和定时任务
结束后的自动对比(scheduler/auto_compare.py,schedule_only=True)都调这一个函数:两个同名入口拿到的必须是同一个
基准,不然计数对不上。
- 定时任务发起的先在同一定时任务里找(same_schedule);它是这个任务的第一次、或者之前的都没成功时,退回同一工作流、
同一运行类别(same_workflow)。schedule_only 时不退:自动对比要比的是「同一定时任务的上一次」,别的运行不算
- 只挑成功结束并封存的:失败、取消的运行口径卡常常没执行,拿它当基准每个指标都成了「新增」「移除」;没封存的
拿来对比只会得到 409
- 按发起先后挑(created_at),不按结束时间:老的失败运行事后被接着运行完,结束得比后一次晚,它的上一次仍是它前面
那次;这次还没结束(还在运行、停在审批)的也一样按发起先后
- 都只看同一个工作流:没挂在已保存的工作流上的运行(画布上没保存就运行的图)没有可比的,定时任务改过工作流的,
之前那个工作流的运行也不算"""
if not target.workflow_id:
return None
scopes = [("same_schedule", Run.schedule_id == target.schedule_id)] if target.schedule_id else []
if not schedule_only:
scopes.append(("same_workflow", Run.run_class == target.run_class))
for reason, scope in scopes:
earlier = (scope, Run.workflow_id == target.workflow_id, Run.id != target.id,
Run.created_at < target.created_at)
base = (await session.execute(
select(Run).where(*earlier, Run.status == "succeeded", Run.manifest_hash.is_not(None))
.order_by(Run.created_at.desc()).limit(1))).scalars().first()
if base is None:
continue
between = await session.scalar(select(func.count()).select_from(Run).where(
*earlier, Run.created_at > base.created_at))
return Pick(base, reason, not between)
return None
async def compare_runs(run_id: str, base_id: str) -> dict[str, Any]:
+6 -3
View File
@@ -285,17 +285,20 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
被改过的同样给 null。"""
被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也
给 null:运行详情里同名的入口比的是另一次。"""
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
compares = await auto_compare.briefs(list(compared.values()))
stale = await auto_compare.outdated(session, compares)
run_ids = {f.run_id for f in fires if f.run_id} | {brief["base_run_id"] for brief in compares.values()}
# 运行现在的状态和清单哈希:run_status 照写,对比按它核对两边的封存
current = {run_id: (status, seal) for run_id, status, seal in (await session.execute(
select(Run.id, Run.status, Run.manifest_hash).where(Run.id.in_(run_ids)))).all()} if run_ids else {}
def compare_of(f: ScheduleFire) -> dict[str, Any] | None:
brief = compares.get(compared.get(f.id) or "")
if brief is None or brief["target_run_id"] != f.run_id:
artifact = compared.get(f.id) or ""
brief = compares.get(artifact)
if brief is None or brief["target_run_id"] != f.run_id or artifact in stale:
return None
for run_id, seal in ((brief["target_run_id"], brief["target_manifest"]),
(brief["base_run_id"], brief["base_manifest"])):
+34 -24
View File
@@ -6,10 +6,12 @@
- 比哪些:成功结束、封存完的运行。失败、取消的运行指标不全(口径卡常常没执行),和上一次比只会得出「移除 N 个」,
收到通知的人会以为报告删了指标
- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次(_previous)。失败的那次不当基准:
拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比后一次晚,
它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和触发历史
写明比的是哪一次
- 跟谁比:同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。规则在对比接口的 pick_base,
运行详情「与上一次对比」的默认基准用的是同一个函数:触发历史和运行详情里两个同名的入口必须比同一对运行。失败的那次
不当基准:拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比
后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和
触发历史写明比的是哪一次;中间那次事后接着运行成功了,上一次成功的就成了它,触发历史不再给当时那一份(outdated)。
这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」)
- 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后
接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希:
哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记
@@ -125,7 +127,8 @@ async def compare_fire(fire_id: str, run_id: str) -> dict[str, Any] | None:
async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
# 现成的对比函数在用到时才导入:api/compare.py 连带证据、推演接口,调度模块被导入时不必一起加载
# 挑基准、对比都用对比接口现成的函数,在用到时才导入:api/compare.py 连带证据、推演接口,调度模块被导入时不必
# 一起加载
from app.api import compare as compare_api
# 挑基准、核对封存、看有没有比过都只是读库,不占对比的空位:前面排着慢的对比时,第一次运行这类没什么可比的
@@ -136,10 +139,10 @@ async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
if target is None or target.status != "succeeded" or not target.schedule_id or not target.workflow_id \
or not await sealed_now(session, target):
return None
found = await _previous(session, target)
found = await compare_api.pick_base(session, target, schedule_only=True)
if found is None:
return None
base, previous = found
base, previous = found.run, found.previous
if not await sealed_now(session, base):
# 基准正好处在写完终态、还没封存的那一刻,或者封存没补上就停了机:拿它比只比得到旧封存里的事件
logger.warning("作为基准的运行 %s 还没有封存到它的结束事件,运行 %s 这次不比", base.id, run_id)
@@ -167,23 +170,6 @@ async def _compare(fire_id: str, run_id: str) -> dict[str, Any] | None:
return _copy(brief)
async def _previous(session: Any, target: Run) -> tuple[Run, bool] | None:
"""(基准, 是不是紧挨着的上一次):同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次。
按发起先后挑(created_at),不按结束时间;同一工作流才比得了(任务改过工作流的,之前那个工作流的运行不算)。
中间隔着别的运行(失败、取消、还没结束的)时第二项为 False。"""
earlier = (Run.schedule_id == target.schedule_id, Run.workflow_id == target.workflow_id, Run.id != target.id,
Run.created_at < target.created_at)
base = (await session.execute(
select(Run).where(*earlier, Run.status == "succeeded", Run.manifest_hash.is_not(None))
.order_by(Run.created_at.desc()).limit(1))).scalars().first()
if base is None:
return None
between = await session.scalar(select(func.count()).select_from(Run).where(
*earlier, Run.created_at > base.created_at))
return base, not between
def _slots() -> asyncio.Semaphore:
loop = asyncio.get_running_loop()
slots = _SLOTS.get(loop)
@@ -264,6 +250,30 @@ def brief_of(artifact: str) -> dict[str, Any] | None:
return _copy(brief)
async def outdated(session: Any, known: dict[str, dict[str, Any]]) -> set[str]:
"""known(对比工件 id → 摘要)里基准已经不是现在挑出来的那一次的:返回这些工件的 id。
只有基准当时就不是紧挨着的上一次(previous 为 False)的会这样:中间隔着的那几次(失败、还没结束的)事后接着运行
成功、封存了,这次之前同一定时任务上一次成功的就成了它。运行详情「与上一次对比」的默认基准(pick_base)比的是它,
触发历史要是还给当时那一份,两个同名入口比的就不是同一对运行,计数也对不上。紧挨着的上一次不会这样:两次之间插不进
新的运行(发起时间只会往后排),两边自己被继续执行、重新封存另有核对(api/schedules._fire_outs)。这类对比本来就少,
逐个现挑一遍不费什么"""
from app.api import compare as compare_api
wanted = {artifact: brief for artifact, brief in known.items() if not brief["previous"]}
if not wanted:
return set()
targets = {run.id: run for run in (await session.execute(
select(Run).where(Run.id.in_({brief["target_run_id"] for brief in wanted.values()})))).scalars()}
stale = set()
for artifact, brief in wanted.items():
target = targets.get(brief["target_run_id"])
found = await compare_api.pick_base(session, target, schedule_only=True) if target is not None else None
if found is None or found.run.id != brief["base_run_id"]:
stale.add(artifact)
return stale
async def briefs(artifacts: list[str]) -> dict[str, dict[str, Any]]:
"""几份对比工件的摘要:工件 id → 摘要,读不出来的不在里面。读盘在线程里做。"""
wanted = list(dict.fromkeys(a for a in artifacts if a))
+52 -10
View File
@@ -6,7 +6,8 @@
同一次运行回 422;运行不存在 404
- 对比只读:两次运行的 last_seq、事件、工件引用、工件文件一样不多
- 遮罩列(查询当时记下的、数据源现在设的)的值不出现在结果里
- 默认基准:同一定时任务优先,否则同一工作流、同一运行类别;只挑更早结束、封存过的终态运行
- 默认基准:同一定时任务优先,否则同一工作流、同一运行类别;只挑比这次先发起、成功结束并封存的运行,按发起先后取
最近一次(失败、取消的跳过,和定时任务的自动对比挑的是同一次,见 test_scheduler_compare.py)
- 真跑:同一条 SQL 数据变了是 data_only;只是代进去的日期不同是 params_only,图一样
"""
from __future__ import annotations
@@ -236,14 +237,15 @@ async def test_masked_columns_never_leave_the_server(client):
async def bare(workflow_id, finished, *, status="succeeded", run_class="formal", schedule_id=None, sealed=True,
resumed=False) -> str:
resumed=False, created=None) -> str:
"""只为挑基准用的运行记录:挑基准只看运行记录上的几列。resumed:结束、封存过又被接着运行(状态已回到运行中,
结束时间和封存还没清掉),只能靠状态把它挡在外面。"""
结束时间和封存还没清掉),只能靠状态把它挡在外面。created:发起时间,默认在结束前 3 分钟(发起先后和结束先后
一致);要让两者错开的用例另给。"""
done = status in ("succeeded", "failed", "cancelled") or resumed
async with SessionLocal() as session:
run = Run(workflow_id=workflow_id, workflow_name="区域周报", status=status, run_class=run_class,
schedule_id=schedule_id, started_at=finished - timedelta(minutes=2),
finished_at=finished if done else None,
schedule_id=schedule_id, created_at=created or finished - timedelta(minutes=3),
started_at=finished - timedelta(minutes=2), finished_at=finished if done else None,
manifest_hash="ab" * 32 if sealed else None, manifest_seq=1 if sealed else None)
session.add(run)
await session.commit()
@@ -279,20 +281,60 @@ async def test_the_default_base_skips_what_cannot_be_compared(client):
await bare(workflow, T0 + 2 * day, status="running", resumed=True) # 结束过、又被接着运行
await bare(workflow, T0 + 3 * day, run_class="exploratory") # 别的运行类别
await bare(await new_workflow("另一个周报"), T0 + 4 * day) # 别的工作流
await bare(workflow, T0 + 4 * day, status="failed") # 失败的(封存过)
await bare(workflow, T0 + 4 * day + timedelta(hours=1), status="cancelled") # 取消的(封存过)
current = await bare(workflow, T0 + 5 * day)
await bare(workflow, T0 + 6 * day) # 比它晚结束
await bare(workflow, T0 + 6 * day) # 比它晚发起
body = await get(client, f"/api/runs/{current}/compare/base")
assert (body["base_run_id"], body["reason"]) == (good, "same_workflow")
# 失败、取消的终态运行也算(封存过)
failed = await bare(workflow, T0 + 5 * day - timedelta(hours=1), status="failed")
assert (await get(client, f"/api/runs/{current}/compare/base"))["base_run_id"] == failed
# 第一次运行、没保存的工作流:没有可比的
body = await get(client, f"/api/runs/{good}/compare/base", 404)
assert body["code"] == "compare_no_base" and "上一次运行" in body["detail"]
assert body["code"] == "compare_no_base" and "上一次成功的运行" in body["detail"]
await get(client, f"/api/runs/{await bare(None, T0)}/compare/base", 404)
await get(client, "/api/runs/no-such-run/compare/base", 404)
async def test_the_default_base_is_the_last_successful_run(client):
"""失败、取消的不当默认基准:拿它当基准,口径卡没执行的指标都成了「新增」「移除」。同一定时任务里跳过它们,取再往前
成功的那一次(和定时任务的自动对比同一个规则);同一工作流的退路也一样;之前一次成功的都没有就是没有基准"""
workflow = await new_workflow()
day = timedelta(days=1)
first = await bare(workflow, T0, schedule_id="sched-weekly")
await bare(workflow, T0 + day, schedule_id="sched-weekly", status="failed")
await bare(workflow, T0 + 2 * day, schedule_id="sched-weekly", status="cancelled")
current = await bare(workflow, T0 + 3 * day, schedule_id="sched-weekly")
body = await get(client, f"/api/runs/{current}/compare/base")
assert (body["base_run_id"], body["reason"]) == (first, "same_schedule")
# 手动发起的:同一工作流、同一运行类别里成功的最近一次(定时的也算),中间失败的跳过
await bare(workflow, T0 + 4 * day, status="failed")
manual = await bare(workflow, T0 + 5 * day)
body = await get(client, f"/api/runs/{manual}/compare/base")
assert (body["base_run_id"], body["reason"]) == (current, "same_workflow")
# 之前只有失败、取消的:没有基准,原因写明要成功结束的
lonely = await new_workflow("门店日报")
await bare(lonely, T0, status="failed")
await bare(lonely, T0 + day, status="cancelled", schedule_id="sched-daily")
alone = await bare(lonely, T0 + 2 * day, schedule_id="sched-daily")
body = await get(client, f"/api/runs/{alone}/compare/base", 404)
assert body["code"] == "compare_no_base" and "成功结束" in body["detail"]
async def test_the_default_base_follows_launch_order(client):
"""按发起先后挑,不按结束时间:先发起、后来才结束的那次(失败后被接着运行完)仍排在这次前面;比这次晚发起的,
结束得再早也不算。这次还没结束(停在审批上)的也按发起先后"""
workflow = await new_workflow()
t = datetime(2026, 9, 1, 1, 0, tzinfo=timezone.utc)
hour, day = timedelta(hours=1), timedelta(days=1)
await bare(workflow, T0, created=t)
resumed = await bare(workflow, T0 + 10 * day, created=t + hour) # 比这次先发起,结束得最晚
current = await bare(workflow, T0 + 5 * day, created=t + 2 * hour)
later = await bare(workflow, T0 + 4 * day, created=t + 3 * hour) # 比这次晚发起,结束得比它早
body = await get(client, f"/api/runs/{current}/compare/base")
assert (body["base_run_id"], body["reason"]) == (resumed, "same_workflow")
waiting = await bare(workflow, T0 + 20 * day, status="interrupted", created=t + 4 * hour)
assert (await get(client, f"/api/runs/{waiting}/compare/base"))["base_run_id"] == later
# --------------------------------------------------------------------------
# 真跑:虚构的 SQLite 库,两次运行之间改了数、换了日期参数
# --------------------------------------------------------------------------
+73 -4
View File
@@ -4,12 +4,13 @@
- 基准是同一定时任务、同一工作流、比这次先发起的运行里成功结束并封存的最近一次:同一工作流更近的手动运行、别的
定时任务的运行都不算;失败的那一次不当基准(拿它当基准每个指标都成了「新增」);按发起先后挑,不按结束时间——
老的失败运行事后被接着跑完,结束得比后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的时,通知和触发
历史写明比的是哪一次
历史写明比的是哪一次。运行详情「与上一次对比」的默认基准(api/compare.py 的 pick_base)和这里挑的是同一次
- 整份结果连同两边的清单哈希存成工件(kind=run_compare,不挂在运行名下、不进运行的工件列表),工件 id 记进触发
记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对
不一致、意外的异常)只记日志,通知照常推送、不带对比
- 接口:Fire 多一个 compare: {base_run_id, summary, previous} 或 null,summary 同对比接口的计数,previous 说基准是不是
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行)
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行);基准不再是现在挑出来的那一次
(中间失败的那次事后被接着运行成功)时也为 null(运行详情里同名的入口比的已是另一次)
- 通知:结束的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问地址时附
对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现
@@ -320,11 +321,26 @@ async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder,
assert (body["compare"]["base_run_id"], body["compare"]["previous"]) == (first, False)
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
assert items[fire_id]["compare"] == {"base_run_id": first, "summary": SUMMARY, "previous": False}
# 运行详情「与上一次对比」的默认基准挑的是同一次(同一个函数):两个同名入口比的是同一对运行、计数一致
base = (await client.get(f"/api/runs/{target_id}/compare/base")).json()
assert (base["base_run_id"], base["reason"]) == (first, "same_schedule")
async def test_base_follows_launch_order_not_finish_time(recorder):
async def fires_of(client, sid: str) -> dict[str, dict]:
return {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
async def default_base_of(client, run_id: str) -> tuple[str, str]:
body = (await client.get(f"/api/runs/{run_id}/compare/base")).json()
return body["base_run_id"], body["reason"]
async def test_base_follows_launch_order_not_finish_time(recorder, client):
"""第 2 周失败、第 3 周照常成功之后,有人把第 2 周接着跑完:它结束得比第 3 周晚,但它的上一次仍是第 1 周。以前
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。第 3 周已经比过的那一份不受影响"""
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。
第 3 周存下的那一份不改,但触发历史不再给它:第 3 周之前同一定时任务上一次成功的已经是第 2 周,运行详情「与上一次
对比」比的是第 2 周。还给第 1 周那一份,两个同名入口就比的不是同一对运行,计数也对不上"""
workflow = await new_workflow()
sid = await new_schedule(workflow, url=None)
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
@@ -336,12 +352,65 @@ async def test_base_follows_launch_order_not_finish_time(recorder):
await webhook.notify_due()
third_artifact = (await fire_row(third_fire)).compare_artifact
assert load(third_artifact)["result"]["base"]["id"] == first
assert (await fires_of(client, sid))[third_fire]["compare"] == {"base_run_id": first, "summary": SUMMARY,
"previous": False}
assert await default_base_of(client, third) == (first, "same_schedule")
await reseal(second, week("r2", NEXT_ROWS), status="succeeded", finished=T0 + 2 * WEEK + timedelta(days=1))
await webhook.notify_due()
stored = load((await fire_row(second_fire)).compare_artifact)
assert stored["result"]["base"]["id"] == first and stored["previous"] is True
assert (await fire_row(third_fire)).compare_artifact == third_artifact
# 运行详情里的默认基准同样按发起先后:接着跑完的第 2 周,上一次仍是第 1 周
assert await default_base_of(client, second) == (first, "same_schedule")
items = await fires_of(client, sid)
assert (items[second_fire]["compare"]["base_run_id"], items[second_fire]["compare"]["previous"]) == (first, True)
# 第 3 周:运行详情比的是第 2 周了,触发历史不再给比第 1 周的那一份(不给链接、不给旧计数)
assert await default_base_of(client, third) == (second, "same_schedule")
assert items[third_fire]["compare"] is None
[listed] = [s for s in (await client.get("/api/schedules")).json()["items"] if s["id"] == sid]
assert listed["last_fire"]["id"] == third_fire and listed["last_fire"]["compare"] is None
async def test_a_stored_compare_stays_while_its_base_is_still_the_pick(recorder, client):
"""基准当时就不是紧挨着的上一次、现在仍是挑出来的那一次:照旧给出。中间那次还停着没结束、后来失败了、被删掉了,
第 3 周之前同一定时任务上一次成功的都还是第 1 周,运行详情比的也是它。删掉之后它其实成了紧挨着的上一次,previous
仍照当时记下的给:「与上一次成功的运行对比」照样属实,通知里当时也是这么说的"""
workflow = await new_workflow()
sid = await new_schedule(workflow, url=None)
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
await fire_for(sid, first, T0 - timedelta(minutes=2), notified="succeeded")
second = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="running",
seal=False)
await fire_for(sid, second, T0 + WEEK - timedelta(minutes=2), notified="running")
third = await persist(week("r3", NEXT_ROWS), workflow, finished=T0 + 2 * WEEK, schedule_id=sid)
third_fire = await fire_for(sid, third, T0 + 2 * WEEK - timedelta(minutes=2))
await webhook.notify_due()
shown = {"base_run_id": first, "summary": SUMMARY, "previous": False}
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
await set_run(second, status="failed")
await seal(second)
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
assert (await client.delete(f"/api/runs/{second}", params={"force": "true"})).status_code == 204
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
async def test_without_an_earlier_success_there_is_no_base(recorder, client):
"""同一定时任务之前只有失败的那次:不自动对比;运行详情里的默认基准也不拿它(同一工作流、同一类别里也没有成功的),
回 404 说清找不到,不给一个比出来全是「新增」的基准"""
workflow = await new_workflow()
sid = await new_schedule(workflow, url=None)
failed = await persist(failed_week("r1"), workflow, finished=T0, schedule_id=sid, status="failed")
await fire_for(sid, failed, T0 - timedelta(minutes=2), notified="failed")
target_id = await persist(week("r2", NEXT_ROWS), workflow, finished=T0 + WEEK, schedule_id=sid)
fire_id = await fire_for(sid, target_id, T0 + WEEK - timedelta(minutes=2))
await webhook.notify_due()
assert (await fire_row(fire_id)).compare_artifact is None
resp = await client.get(f"/api/runs/{target_id}/compare/base")
assert resp.status_code == 404 and resp.json()["code"] == "compare_no_base", resp.text
async def test_first_run_of_a_schedule_has_no_compare(recorder, client):
+66 -1
View File
@@ -1,5 +1,5 @@
{
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成",
"//": "审批质询预览用例与 check-inquiry 共用的夹具(frontend/src/dev/fixtures/inquiry.tsx)。报告文档和回答文档由后端 evidence.build_catalog + compose_doc 真实组装(虚构的超市、片区、金额),查询快照照质询工件接口的样子给:遮罩列的值写「已遮罩」,另带 masked。生成脚本不在仓库里:改了文档的形状,照这个思路按后端的输出重新生成。run_artifacts 是探索运行的工件列表(照 GET /api/runs/{id}/artifacts 的样子手写,质询回答的 meta 同 engine/inquiry.py 写的 {report, unit, question: 问题的前 40 字}),给运行详情「工件」页签的用例",
"runs": {
"approve": "run-inq-approve",
"formal": "run-inq-formal",
@@ -4278,5 +4278,70 @@
"masked": []
}
},
"run_artifacts": [
{
"id": "8cb677489bbaafcafd3b0f0df1939c1ca57cccaa3c065e14c4efe2991a3dd9a9",
"kind": "report_doc",
"node_id": "write",
"size": 18234,
"meta": {},
"created_at": "2026-10-05T06:30:00"
},
{
"id": "02ca8aeabca59d785d982b321d22ff889cb2da5a037cd4a080407e9044356434",
"kind": "query_snapshot",
"node_id": null,
"size": 1460,
"meta": {},
"created_at": "2026-10-05T06:44:30"
},
{
"id": "911780ed990217a2a2e7e3424a9408d23b53a524ba5d1812ec677b7d6b5b80aa",
"kind": "inquiry_answer",
"node_id": null,
"size": 2140,
"meta": {
"report": "write",
"unit": "u2",
"question": "退款率怎么算的?"
},
"created_at": "2026-10-05T06:45:00"
},
{
"id": "4ed3d62d2af13bbea40195283f3caf63462ca76bb5d4f7ddf1216d36dd5e0463",
"kind": "inquiry_answer",
"node_id": null,
"size": 2510,
"meta": {
"report": "write",
"unit": null,
"question": "枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片"
},
"created_at": "2026-10-05T06:52:00"
},
{
"id": "1c66d590eac155dec35e2215e20c6e7497e96d0b2d73894eff1dea3fb641bb51",
"kind": "inquiry_answer",
"node_id": null,
"size": 1980,
"meta": {
"report": "write",
"unit": "u2"
},
"created_at": "2026-10-05T06:58:00"
},
{
"id": "ab8f8dbfb139d3be461bd9b5f4024cc161be5588a2eea9275a47eac160bfc885",
"kind": "inquiry_answer",
"node_id": null,
"size": 2380,
"meta": {
"report": "write",
"unit": "u3",
"question": "本周枫岭片区 GMV 环比下降了多少?下降的主要原因是不是 SKU 结构的变化 "
},
"created_at": "2026-10-05T07:04:00"
}
],
"card_artifact": "757e03414694c3b3480236718d32082ba449e7712d9ce99f4aee39ffdc5c3f4e"
}
+62 -2
View File
@@ -1,7 +1,10 @@
import { useState, type JSX } from 'react'
import { useMemo, useState, type JSX } from 'react'
import { createPortal } from 'react-dom'
import { parseServerTime } from '../../lib/format'
import { ArtifactsPane, useRunArtifacts } from '../../pages/runs/ArtifactsPane'
import { EvidencePane } from '../../pages/runs/EvidencePane'
import { ApprovalCard } from '../../run/RunPanel'
import { emptyTrace } from '../../run/trace'
import type { Approval, ApprovalReport, Run } from '../../types'
import fx from './inquiry.json'
@@ -20,12 +23,18 @@ import fx from './inquiry.json'
* React 树里排在前面)和右栏;「收起浮层」按钮开关浮层
* - ?syn=inquiry-approval-steps:同一份报告先后两张审批(主管审批 → 总监审批),「处理掉这张」换下一张;卡按审批 id
* 重新挂(同右栏)。?syn=inquiry-approval-steps-same:同一个卡片实例换审批(同画布的审批浮层,不按 id 重新挂)
* - ?syn=inquiry-approval-two:一张卡审两份报告(写月报、写周报),两份报告的问答串各自挂着、共用这次运行的审批列表
*
* 运行详情的「证据」页签(pages/runs/EvidencePane,是否可问由它按运行状态判断):
* - ?syn=inquiry-run-explore:封存了的探索运行,报告上给入口,问答追加在封存之后
* - ?syn=inquiry-run-formal:封存了的正式运行,不给入口,审批时问过的照列
* - ?syn=inquiry-run-waiting:停在审批上的运行,同运行详情直接打开「证据」页签:时间线里的待审批卡和证据页签同时挂着
* 同一份报告的问答串(卡上这张审批里问的直接认,证据页签哪张审批都要对一下)
* - ?syn=inquiry-run-artifacts:运行详情的「工件」页签(pages/runs/ArtifactsPane),工件列表里有封存后追加的质询回答
* (运行期间产出的能跳到航迹,封存后追加的不能)
*
* 列表、提问、质询工件、报告文档、证据图、审批列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说无法读取。
* 列表、提问、质询工件、报告文档、证据图、审批列表、工件列表、设置都由检查脚本用 page.route 伪造;直接打开时问答串照实说
* 无法读取。
*/
const RUNS = fx.runs
@@ -115,6 +124,45 @@ function RunEvidence({ run, name }: { run: Run; name: string }) {
)
}
/**
* 停在审批上的运行,同运行详情直接打开「证据」页签:时间线(一直挂着)里的待审批卡在上,证据页签在下,两处挂着同一份
* 报告的问答串。卡上的问答串认得这张审批,证据页签不认任何审批(cardId 为空),两处要为不同的几张审批取审批列表
*/
function Waiting({ approval, run }: { approval: Approval; run: Run }) {
return (
<div className="flex min-h-screen min-w-0 flex-col" data-fixture="inquiry-run-waiting">
<div className="mx-auto w-full max-w-3xl p-4" data-fixture-card="">
<div className="overflow-hidden rounded-lg border" style={{ borderColor: 'var(--warn)' }}>
<ApprovalCard approval={approval} onResolved={() => undefined} showWorkflow={false} />
</div>
</div>
<div className="h-[80vh] min-w-0 border-t" data-fixture-evidence="">
<EvidencePane run={run} output={null} labelOf={(id) => (id ? LABELS[id] : undefined)} refreshKey="0" />
</div>
</div>
)
}
/**
* 运行详情的「工件」页签:工件列表由检查脚本按 inquiry.json 的 run_artifacts 答。航迹的起止照这次运行的开始、结束时间
* 给(同运行详情):运行期间产出的工件能跳到航迹里那一刻,封存后追加的质询回答和它的查询快照不给跳转。点过的跳转记在
* data-fixture-moment 上
*/
function RunArtifacts({ run }: { run: Run }) {
const list = useRunArtifacts(run.id, '0')
const trace = useMemo(() => ({
...emptyTrace(), phase: 'succeeded' as const,
startedAt: parseServerTime(run.started_at ?? null)?.getTime(), endedAt: parseServerTime(run.finished_at ?? null)?.getTime(),
}), [run.started_at, run.finished_at])
const [moment, setMoment] = useState<number | null>(null)
return (
<div className="h-screen min-w-0" data-fixture="inquiry-run-artifacts" data-fixture-moment={moment ?? ''}>
<ArtifactsPane list={list} graph={null} trace={trace} labelOf={(id) => (id ? LABELS[id] : undefined)}
onMoment={(at) => setMoment(at)} />
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'inquiry-approval': () => (
<Card name="inquiry-approval" approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)} />
@@ -138,6 +186,18 @@ export const cases: Record<string, () => JSX.Element> = {
),
'inquiry-approval-steps': () => <Steps name="inquiry-approval-steps" keyed />,
'inquiry-approval-steps-same': () => <Steps name="inquiry-approval-steps-same" keyed={false} />,
'inquiry-approval-two': () => (
<Card name="inquiry-approval-two" approval={{
...approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve),
payload: { kind: 'human_node', message: '请审核 9 月月报和第 39 周周报。',
reports: [reports.approve, { ...reports.approve, node_id: 'write2', label: '写周报' }] },
}} />
),
'inquiry-run-explore': () => <RunEvidence name="inquiry-run-explore" run={runOf(RUNS.explore, 'exploratory')} />,
'inquiry-run-formal': () => <RunEvidence name="inquiry-run-formal" run={runOf(RUNS.sealed, 'formal')} />,
'inquiry-run-waiting': () => (
<Waiting approval={approvalOf(fx.approval_id, RUNS.approve, 'exploratory', reports.approve)}
run={{ ...runOf(RUNS.approve, 'exploratory'), status: 'interrupted', manifest_hash: null, manifest_seq: null, finished_at: null }} />
),
'inquiry-run-artifacts': () => <RunArtifacts run={runOf(RUNS.explore, 'exploratory')} />,
}
+47 -11
View File
@@ -3085,10 +3085,16 @@ export const SCHEDULE_TEXT = {
// ---- 触发历史里的对比:运行成功结束后,服务端自动和同一定时任务上一次成功的运行比一次
/**
* 和运行详情「更多」菜单里的入口是同一件事,用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,
* 写成 getter 用到时再取
* 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),基准就是紧挨着的上一次
* 时用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取
*/
get compareLink() { return RUN_COMPARE_TEXT.menu },
/**
* 基准不是紧挨着的上一次(上一次失败了、取消了,比的是再往前成功的那一次)时链接上看得见的叫法:触屏没有悬停,
* 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项点之前还不知道基准
* 是哪一次,这时仍叫「与上一次对比」,两处叫法不同;进了对比页都写「默认基准:同一定时任务上一次成功的运行」
*/
compareLinkLastOk: '与上一次成功的运行对比',
/**
* 悬停写各类指标的个数,和 Webhook 通知里那一行同一个写法(服务端 scheduler/auto_compare.py 的 summary_text):
* 变化三分法的三类总写上,0 个也写;其余各类有才写,次序同对比页;两边都没有指标时不写一串 0 个。只有个数,
@@ -3160,7 +3166,7 @@ export const RUN_COMPARE_TEXT = {
menuSealedOnly: '运行结束并封存后才能对比',
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
menuNoWorkflow: '工作流未保存或已删除,无法对比',
menuFinding: '正在查找上一次运行…',
menuFinding: '正在查找上一次成功的运行…',
pickManually: '手动选择基准',
retry: '重试',
// 顶部的两次运行
@@ -3175,12 +3181,17 @@ export const RUN_COMPARE_TEXT = {
runFailed: '无法读取这次运行',
runMissing: '运行记录不存在',
noBase: '未选择',
/**
* 默认基准是怎么选的,照服务端(api/compare.py 的 pick_base)的规则写:只挑比本次先发起、成功结束并封存的运行。
* 上一次失败、取消了时基准是再往前成功的那一次,所以不能写成「上一次运行」
*/
reason: {
same_schedule: '默认基准:同一定时任务的上一次运行',
same_workflow: '默认基准:同一工作流、同一运行类别的上一次运行',
same_schedule: '默认基准:同一定时任务上一次成功的运行',
same_workflow: '默认基准:同一工作流、同一运行类别上一次成功的运行',
} as Record<string, string>,
pickBase: '更换基准',
pickHint: '同一工作流最近的运行,未封存的不能作为基准',
/** 默认基准只取成功结束的运行(pick_base),手动选不限:失败、取消的也能选,下拉和基准卡上写明它的状态 */
pickHint: '同一工作流最近的运行,未封存的不能作为基准;默认基准只取成功结束的运行',
pickLoading: '正在读取同一工作流的运行…',
pickFailed: '无法读取同一工作流的运行',
pickNone: '同一工作流没有其他运行',
@@ -3188,14 +3199,17 @@ export const RUN_COMPARE_TEXT = {
pickTargetGone: '本次运行的记录不存在,无法列出同一工作流的运行',
pickUnsaved: '工作流未保存或已删除,没有可对比的运行',
pickPlaceholder: '选择一次运行作为基准',
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
/**
* 下拉里的一次运行:时间 · 类别和版本 · 短 id,没有成功结束的写上状态(失败、已取消),没封存的另标。默认基准跳过的
* 正是这几种,手动挑时也得看得出哪次没成功
*/
pickOption: (time: string, cls: string, id: string, sealed: boolean, status?: string | null) =>
`${time} · ${cls} · ${id}${status ? ` · ${status}` : ''}${sealed ? '' : '(未封存)'}`,
/** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
// 页面状态
noTarget: '未指定要对比的运行',
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行',
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次成功的运行',
toRuns: '前往记录',
finding: '正在查找基准运行…',
loading: '正在对比两次运行…',
@@ -3213,7 +3227,11 @@ export const RUN_COMPARE_TEXT = {
errMissingHint: '链接可能已失效,请从记录页重新打开',
/** 本次的记录读得到、对比却回 404:缺的是基准 */
errBaseMissingHint: '可在上方选择另一次运行作为基准',
errNoBase: '未找到可作为基准的运行',
/**
* 找不到默认基准:标题只说没有默认基准,原因(服务端的 NO_BASE_TEXT:没有找到上一次成功的运行、要什么条件)跟在后面。
* 不能写成「没有可作为基准的运行」:失败、取消但封存了的运行不当默认基准,却照样能在上方手动选
*/
errNoBase: '没有默认基准',
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
/** 下拉里一次能选的都没有(只有本次、或者都没封存) */
errNoBaseNone: '同一工作流还没有其他已封存的运行',
@@ -3424,6 +3442,12 @@ export const INQUIRY_TEXT = {
/** 之前几轮审批里问的、报告后来改写过(句子编号对不上),或者对不上是哪一稿:单独一组 */
otherDoc: '报告其他版本上的质询',
otherDocHint: '提问时的报告与当前显示的版本不同,或无法确认是否相同,所以不挂到当前报告的句子下面',
/**
* 别的审批里问的、审批列表又没取到:不知道提问时审的是哪一稿,不能说成「其他版本」(审批人会以为报告换过稿)。
* 单独一组照实说没能确认,给重试
*/
unsureDoc: '无法确认提问时的报告版本',
unsureDocHint: '审批记录读取失败,暂不挂到当前报告的句子下面',
loadFailed: '无法读取之前的质询记录',
retry: '重试',
q: '问',
@@ -3491,6 +3515,18 @@ export const INQUIRY_TEXT = {
snapshotLoading: '正在读取查询结果…',
snapshotFailed: '无法读取这次查询的结果',
masked: (columns: string) => `${columns} 在数据源中设置了遮罩,不显示原值`,
// ---- 运行详情「工件」页签:回答文档按工件种类 inquiry_answer 记在运行名下(pages/runs/model.ts 的工件名和说明) ----
artifactKind: '质询回答',
/**
* 服务端在工件的 meta 里只记问题的前 40 字(engine/inquiry.py 的 QUESTION_BRIEF):满 40 字的多半截断过,末尾补省略号,
* 免得半句话读成整个问题。字数按原样数、显示时才去掉首尾空白:服务端截之前已经去过整个问题的首尾空白,摘要以空格、
* 换行结尾只会是截断截在了空白上,先去空白再数就少了一个字、漏掉省略号
*/
artifactAbout: (question: string) =>
`质询「${question.trim()}${[...question].length >= 40 ? '…' : ''}」的回答`,
/** meta 里没有问题摘要时的说法(服务端照理都会记,这里兜底):照报告文档那一句写装的是什么 */
artifactAboutNone: '审批质询的回答:逐段的片段和每个数字的出处',
}
/**
+7 -3
View File
@@ -136,6 +136,8 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
}
const t0 = trace.timed ? trace.startedAt : undefined
// 运行结束之后才写下的(封存后追加的质询回答和它查询的快照):航迹里没有那一刻,不给跳转,只写时间
const t1 = trace.timed ? trace.endedAt : undefined
let budget = all ? Infinity : FIRST_ROWS
const hidden = Math.max(0, groups.reduce((n, [, g]) => n + g.length, 0) - FIRST_ROWS)
@@ -174,7 +176,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
budget -= take.length
return (
<Group key={nodeId || '—'} nodeId={nodeId} rows={take} total={rows.length} graph={graph} trace={trace}
labelOf={labelOf} t0={t0} onOpen={setOpen} onMoment={onMoment} />
labelOf={labelOf} t0={t0} t1={t1} onOpen={setOpen} onMoment={onMoment} />
)
})}
{!all && hidden > 0 && (
@@ -190,7 +192,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
)
}
function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMoment }: {
function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onMoment }: {
nodeId: string
rows: RunArtifact[]
total: number
@@ -198,6 +200,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen
trace: Trace
labelOf: (id?: string | null) => string | undefined
t0?: number
/** 运行结束的时刻(还没结束的为空):晚于它的工件不给跳转 */
t1?: number
onOpen: (v: { id: string; title: string }) => void
onMoment?: (at: number, nodeId: string | null) => void
}) {
@@ -226,7 +230,7 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, onOpen, onMomen
const kind = artifactKindLabel(a.kind)
const attempt = Number(a.meta?.attempt)
const created = parseServerTime(a.created_at ?? null)?.getTime()
const at = t0 != null && created != null && created >= t0 ? created : null
const at = t0 != null && created != null && created >= t0 && (t1 == null || created <= t1) ? created : null
const numbered = (perKind.get(a.kind) ?? 0) > 1
const title = `${label} · ${kind}${numbered ? ` #${n}` : ''}`
return (
+16 -4
View File
@@ -1,8 +1,9 @@
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftRight, CalendarClock, RotateCw } from 'lucide-react'
import { Skeleton, useTicker } from '../../../components/ui'
import { Skeleton, StatusPill, useTicker } from '../../../components/ui'
import { formatDateTime, formatTime, shortId } from '../../../lib/format'
import { statusLabel } from '../../../lib/status'
import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms'
import type { RunWithSchedule } from '../../../types'
import { ClassChip as RunClassChip } from '../parts'
@@ -11,7 +12,8 @@ import { isGone } from './model'
/**
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
* 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。
* 运行详情;没封存的标出来(对比会因此被拒),失败、取消的标出状态。基准卡底下是换基准的下拉:同一工作流最近的运行,
* 没封存的不能选。
*/
export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: {
target: string
@@ -49,6 +51,12 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
)
}
/**
* 封存了、却没有成功结束的(失败、已取消):能手动选作基准,默认基准却跳过它(拿它当基准,口径卡没执行的指标都成了
* 「新增」「移除」)。下拉和卡上写明状态,人读懂「上一次成功的运行」之后自己挑得出成功的那一次
*/
const unsuccessful = (status: string | null | undefined) => status === 'failed' || status === 'cancelled'
function RunCard({ role, id, run, children }: {
role: 'base' | 'target'; id: string | null; run: Loaded<RunWithSchedule>; children?: ReactNode
}) {
@@ -63,6 +71,9 @@ function RunCard({ role, id, run, children }: {
{r?.schedule_id && (
<span className="chip" title={T.scheduled}><CalendarClock size={10} aria-hidden /> {T.scheduled}</span>
)}
{r && unsuccessful(r.status) && (
<span className="inline-flex" data-compare-run-status={r.status}><StatusPill status={r.status} /></span>
)}
{r && !r.manifest_hash && (
<span className="chip" title={T.unsealedHint} data-compare-unsealed=""
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
@@ -113,7 +124,8 @@ function RetryButton({ onClick }: { onClick: () => void }) {
}
/**
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因;失败、
* 取消的能选,写明状态(默认基准不取它们)。
*
* 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中
* 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的
@@ -146,7 +158,7 @@ function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onP
const list = (candidates.data ?? []).filter((r) => r.id !== target)
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="none">{T.pickNone}</p>
const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)),
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash)
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash, unsuccessful(r.status) ? statusLabel(r.status) : null)
const current = base && !list.some((r) => r.id === base) ? base : null
const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data)
: baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b))
+3 -2
View File
@@ -17,8 +17,9 @@ export const comparePath = (target: string, base?: string | null) =>
const NOTE_DELAY_MS = 300
/**
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的
* 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、
* 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到
* 对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
*
+5
View File
@@ -10,6 +10,7 @@ import {
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
} from '../../lib/status'
import { formatSpan, parseServerTime } from '../../lib/format'
import { INQUIRY_TEXT } from '../../lib/terms'
import { hasPendingApproval } from '../../store/catalog'
// -------------------------------------------------------------------------
@@ -334,6 +335,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
schema_snapshot: '表结构快照',
dual_check: '双盲复算对照',
challenge: '反方审稿记录',
inquiry_answer: INQUIRY_TEXT.artifactKind,
}
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
@@ -356,6 +358,9 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
? `双盲复算:${meta.metrics} 个指标两边的 SQL、取值与差异说明` : '双盲复算:两边的 SQL、取值与差异说明'
// 反方审稿的完整记录(工件行的 meta 里有 {report: 审的报告节点})
case 'challenge': return '反方审稿:每句结论的判断、理由和反方执行的查询'
// 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
default: return '工件'
}
}
+11 -7
View File
@@ -14,7 +14,8 @@ const PAGE = 20
/**
* 触发历史:每次触发一行,新的在前,20 条一页、「加载更多」往后接。计划时间按定时任务的时区写;跳过、发起失败
* 写原因;发起了的链到那次运行、写运行现在的状态;运行成功结束后服务端和同一任务上一次成功的运行比过的,另给
* 「与上一次对比」;发出去的通知逐条写结果。refreshKey 变了(立即运行之后)从头重取
* 「与上一次对比」(基准不是紧挨着的上一次时写「与上一次成功的运行对比」);发出去的通知逐条写结果。refreshKey 变了
* (立即运行之后)从头重取
*/
export function FireHistory({ schedule, refreshKey }: { schedule: Schedule; refreshKey: number }) {
const [items, setItems] = useState<ScheduleFire[] | null>(null)
@@ -142,14 +143,17 @@ function FireRow({ fire: f, tz, now }: { fire: ScheduleFire; tz: string; now: nu
}
/**
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行。旁边写有几个
* 指标有变化(触屏没有悬停,键盘聚焦也不显示悬停),悬停写各类指标的个数(和通知里那一行同一个写法),读屏把它当
* 说明念。基准就是紧挨着的上一次时带上来历,对比页据此写「默认基准:同一定时任务的上一次运行」;不是(上一次失败了、
* 取消了)时不带,免得说错,悬停写明比的是哪一次
* 「与上一次对比」:去对比页,本次是这次触发的运行、基准是服务端自动对比时取的同一任务上一次成功的运行——和运行详情
* 「更多」里同名入口的默认基准是同一个挑法(服务端 api/compare.py 的 pick_base),两处进去比的是同一对运行。旁边写有
* 几个指标有变化(触屏没有悬停,键盘聚焦也不显示悬停),悬停写各类指标的个数(和通知里那一行同一个写法),读屏把它
* 当说明念。
*
* 基准不是紧挨着的上一次(上一次失败了、取消了)时,链接上看得见的字就写「与上一次成功的运行对比」,悬停另写是哪一次。
* 两种都带上来历:对比页据此写「默认基准:同一定时任务上一次成功的运行」,和从运行详情进去时一字不差
*/
function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireCompare }) {
const previous = compare.previous !== false
const via: CompareVia | undefined = previous ? { base: compare.base_run_id, reason: 'same_schedule' } : undefined
const via: CompareVia = { base: compare.base_run_id, reason: 'same_schedule' }
const count = T.compareCount(compare.summary)
return (
<Link
@@ -160,7 +164,7 @@ function CompareLink({ runId, compare }: { runId: string; compare: ScheduleFireC
className="inline-flex shrink-0 items-center gap-1 rounded-sm text-2xs text-dim outline-none hover:text-fg hover:underline focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
<GitCompareArrows size={11} aria-hidden />
<span data-fire-compare-label="">{T.compareLink}</span>
<span data-fire-compare-label="">{previous ? T.compareLink : T.compareLinkLastOk}</span>
{count && (
<>
<span aria-hidden>·</span>
+45 -9
View File
@@ -308,7 +308,17 @@ export const useInquiry = create<InquiryState>((set, get) => ({
},
loadApprovalDocs: async (runId, ids) => {
if (get().approvalDocs[runId]?.status === 'loading') return
const inflight = get().approvalDocs[runId]
if (inflight?.status === 'loading') {
// 别处已经在取(同一张卡审两份报告;运行详情的待审批卡和证据页签同时挂着,两处要对的审批不同):不再另取,但这一处
// 要对的也记成取过——列表按运行取、不按审批,在路上的那一份本来就带着全部审批。只是提前返回的话,这一处的 unknown
// 在请求回来之后不变、effect 不再执行,settled 一直为假:头上写着「· N 次」,别的审批里问的那几条一直不显示
const asked = [...new Set([...inflight.asked, ...ids])]
if (asked.length > inflight.asked.length) {
set((s) => ({ approvalDocs: { ...s.approvalDocs, [runId]: { ...s.approvalDocs[runId]!, asked } } }))
}
return
}
set((s) => {
const cur = s.approvalDocs[runId]
return { approvalDocs: { ...s.approvalDocs, [runId]: {
@@ -503,6 +513,13 @@ const groupKey = (t: { unit: string | null; quote: string | null }) =>
/** 问的不是眼前这一稿报告的那几条:单独一组,排在最后 */
const OTHER = 'other'
/** 别的审批里问的、审批列表又没取到,不知道是不是眼前这一稿:也单独一组,排在最后,给重试 */
const UNSURE = 'unsure'
/**
* 不按句子挂的两组:句子编号对不上(或者不知道对不对得上),回答引用报告已有的出处时不在眼前这一稿里找;正在回答的、
* 提问框也不挂进来
*/
const offDoc = (key: string) => key === OTHER || key === UNSURE
interface Group {
key: string
@@ -600,6 +617,10 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
if (a === (cardId ?? null)) return true
return !!a && !!docArtifact && docsSlot?.map[a]?.[report] === docArtifact
}, [cardId, docArtifact, docsSlot, report])
// 审批列表没取到时,别的审批里问的、那张审批又不在之前取到的列表里:不知道是不是眼前这一稿,不能算「其他版本」。
// 取到了列表却没有那张审批的(审批记录没了)、这份报告没有报告文档(拿什么都对不上)的,重取也一样,照旧算对不上
const unsure = useCallback((x: Inquiry) => !!docArtifact && docsSlot?.status === 'error' && !!x.approval_id
&& !(x.approval_id in docsSlot.map), [docArtifact, docsSlot])
const groups = useMemo(() => {
const out = new Map<string, Group>()
@@ -609,9 +630,10 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
return out.get(k)!
}
const others: InquiryEntry[] = []
const unsures: InquiryEntry[] = []
for (const e of entries) {
if (sameDoc(e.inquiry)) add(e.inquiry.unit, e.inquiry.quote).entries.push(e)
else if (settled) others.push(e)
else if (settled) (unsure(e.inquiry) ? unsures : others).push(e)
}
// 正在回答的、没问成的、开着的提问框挂在它们问的那一句下面(按先后):那句还没有问答时另起一组
if (mine) add(mine.target.unit, mine.target.quote)
@@ -619,8 +641,11 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
if (draft) add(draft.target.unit, draft.target.quote)
const list = [...out.values()]
if (others.length) list.push({ key: OTHER, unit: null, quote: null, entries: others })
if (unsures.length) list.push({ key: UNSURE, unit: null, quote: null, entries: unsures })
return list
}, [entries, sameDoc, settled, mine, draft, failed])
}, [entries, sameDoc, unsure, settled, mine, draft, failed])
// 重取审批列表:列表按运行取,一份里各张审批都有。取的时候这两组先收起(同第一次取),取回来按结果重新归拢
const retryDocs = useCallback(() => { void loadApprovalDocs(runId, foreign) }, [loadApprovalDocs, runId, foreign])
// 提交之后提问框拿掉了,焦点接到「正在回答」那一条上;回答回来那一条换掉了,再接到回答上。焦点已经去了别处的不抢;
// 没问成(提问框带着报错重新打开)时焦点由提问框自己接。都只在问的那一处:同一张卡挂在两处时另一处不动
@@ -687,15 +712,15 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
<div className="mt-1.5 space-y-2">
{groups.map((g) => (
<div key={g.key} className="min-w-0"
data-inquiry-group={g.key === OTHER ? OTHER : g.unit ?? (g.quote ? 'quote' : 'report')}>
<GroupHead group={g} units={units} />
data-inquiry-group={offDoc(g.key) ? g.key : g.unit ?? (g.quote ? 'quote' : 'report')}>
<GroupHead group={g} units={units} onRetry={retryDocs} />
<div className="mt-1 space-y-2 border-l pl-2">
{g.entries.map((e) => (
<EntryView key={e.id} runId={runId} entry={e} groupQuote={g.unit ? null : g.quote}
otherDoc={g.key === OTHER} onLocate={g.key === OTHER ? undefined : onLocate}
otherDoc={offDoc(g.key)} onLocate={offDoc(g.key) ? undefined : onLocate}
focusRef={e.id === latest ? (el) => { lastRef.current = el } : undefined} />
))}
{g.key !== OTHER && (
{!offDoc(g.key) && (
<>
{mine && groupKey(mine.target) === g.key && (
<PendingView pending={mine} focusRef={(el) => { pendingRef.current = el }} />
@@ -730,9 +755,9 @@ export function ReportInquiry({ runId, report, docArtifact, access, cardId = nul
/**
* 一组的抬头:问的是哪一句(原文从报告文档取,取不到写「报告中的一句」)、哪段原文,或者整份报告。问的不是眼前这一稿的
* 那一组只写明这一点:句子编号对不上,不去取原文
* 那一组只写明这一点:句子编号对不上,不去取原文。审批列表没取到、不知道是哪一稿的那一组照实说,给重试(onRetry)
*/
function GroupHead({ group, units }: { group: Group; units: Map<string, string> }) {
function GroupHead({ group, units, onRetry }: { group: Group; units: Map<string, string>; onRetry: () => void }) {
if (group.key === OTHER) {
return (
<div className="min-w-0 text-2xs leading-relaxed" data-inquiry-about="">
@@ -741,6 +766,17 @@ function GroupHead({ group, units }: { group: Group; units: Map<string, string>
</div>
)
}
if (group.key === UNSURE) {
return (
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1 text-2xs leading-relaxed" data-inquiry-about="">
<span className="min-w-0 flex-1">
<span className="font-medium" style={{ color: 'var(--st-waiting)' }} data-inquiry-about-text="">{INQUIRY_TEXT.unsureDoc}</span>
<span className="block text-faint">{INQUIRY_TEXT.unsureDocHint}</span>
</span>
<button type="button" className="btn btn-xs shrink-0" data-inquiry-docs-retry="" onClick={onRetry}>{INQUIRY_TEXT.retry}</button>
</div>
)
}
const text = group.unit ? units.get(group.unit) || group.entries.find((e) => e.inquiry.quote)?.inquiry.quote || ''
: group.quote ?? ''
return (
+133 -12
View File
@@ -9,10 +9,14 @@
// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存
// 工作流的运行;查找基准期间离开运行详情(新页面画好之后、或者地址刚换新页面还没画出来时答复才回来,走过的地址里
// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录
// 不存在时不给注定失败的「重试」。
// 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」
// ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对
// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说
// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare、定时任务列表都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
// compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、
// 待审批),非 GET 一律拦掉。
// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。
//
// node scripts/check-compare.mjs
@@ -181,10 +185,32 @@ function comparison(target, base) {
const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data: '口径与数据都变', sql_changed: 'SQL 结构变',
params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' }
const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged']
const REASON = '默认基准:同一定时任务的上一次运行'
const FINDING = '正在查找上一次运行'
// 默认基准只挑比本次先发起、成功结束并封存的运行(服务端 api/compare.py 的 pick_base):说法里都是「上一次成功的运行」
const REASON = '默认基准:同一定时任务上一次成功的运行'
const REASON_WORKFLOW = '默认基准:同一工作流、同一运行类别上一次成功的运行'
const FINDING = '正在查找上一次成功的运行'
/** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */
const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行'
/** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */
const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行'
/**
* 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是
* 紧挨着的上一次(previous: false,假定 B 之后同一定时任务还有一次失败的;那一次不进 RUNS,换基准的下拉照旧)。
* 只给「两个入口拿同一个基准」那一段用:触发历史和运行详情「更多」里的同名入口,服务端用同一个函数挑基准
*/
const SCHEDULE_ID = 'sch0weekly'
const SCHEDULE = {
id: SCHEDULE_ID, name: '区域销售周报 · 每周', workflow_id: WF, workflow_name: NAME, cron: '0 9 * * 1', timezone: 'Asia/Shanghai',
run_class: 'formal', input_template: {}, enabled: true, overlap: 'skip', misfire_grace_s: 3600, webhook: null,
next_run_at: '2026-10-12T01:00:00Z', last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', created_by: '值班分析员',
created_at: day('09-01'), updated_at: day('09-01'), version: 2,
}
const FIRE_T = 'fire0target'
const FIRES = [{
id: FIRE_T, fire_at: day('10-05'), kind: 'scheduled', status: 'started', run_id: T, run_status: 'succeeded', reason: null,
notified: [], created_at: day('10-05'), compare: { base_run_id: B, previous: false, summary: SUMMARY },
}]
// ---------------------------------------------------------------- 假接口
@@ -226,7 +252,7 @@ function defaults(opts) {
if (m) {
if (opts.base) return opts.base(m[1])
return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }
: { status: 404, json: { detail: '没有找到可作为基准的运行' } }
: { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }
}
m = p.match(/^\/api\/runs\/([^/]+)\/compare$/)
if (m) {
@@ -247,6 +273,9 @@ function defaults(opts) {
if (m[2] === '/artifacts') return { json: [] }
}
if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] }
// 发起本次的定时任务和它的触发历史(只有直接打开那个定时任务的页面时才用到)
if (p === `/api/schedules/${SCHEDULE_ID}`) return { json: SCHEDULE }
if (p === `/api/schedules/${SCHEDULE_ID}/fires`) return { json: { items: FIRES, next_before: null } }
// 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么
if (p === '/api/schedules') return { json: { items: [] } }
return null
@@ -397,7 +426,7 @@ async function findingBase(answer = { json: { base_run_id: B, reason: 'same_sche
await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => {
const { page, release, ended, noted, visited, errors } = await findingBase()
check('点了之后有「正在查找上一次运行」的提示', noted)
check('点了之后有「正在查找上一次成功的运行」的提示', noted)
// 改主意去了别的页面。等那一页画好、运行详情卸下之后再放行:答复晚到也不能把人拽回对比页
await page.evaluate((to) => { history.pushState({}, '', to); dispatchEvent(new PopStateEvent('popstate')) }, AWAY)
await awaySettled(page)
@@ -476,6 +505,61 @@ await section('未封存的运行:入口不可点并说明原因', async () =>
}
})
/** 在运行详情里点「更多」→「与上一次对比」,等对比页落定 */
async function viaMenu(page) {
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[data-menu=compare]').click()
await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
await settled(page)
}
await section('默认基准的说法:上一次成功的运行', async () => {
// 服务端只挑比本次先发起、成功结束并封存的运行:上一次失败、取消了时基准是再往前成功的那一次,「上一次运行」是错的
for (const [reason, want] of [['same_schedule', REASON], ['same_workflow', REASON_WORKFLOW]]) {
const base = () => ({ json: { base_run_id: B, reason, base: refOf(B) } })
const menu = await open(`/runs/${T}`, { base })
await viaMenu(menu.page)
const fromMenu = (await maybeText(menu.page.locator('[data-compare-reason]'))).trim()
const self = await open(comparePath(T), { base })
await settled(self.page)
const fromPage = (await maybeText(self.page.locator('[data-compare-reason]'))).trim()
check(`${reason}:从运行详情进入、只带本次打开,都写「${want}」`, fromMenu === want && fromPage === want, `${fromMenu} | ${fromPage}`)
const pages = [await menu.page.locator('[data-compare-page]').innerText(), await self.page.locator('[data-compare-page]').innerText()]
check(`${reason}:页面上不出现「上一次运行」这类不分成败的说法`, pages.every((t) => !t.includes('上一次运行')))
check(`${reason}:页面没有报错`, ![...menu.errors, ...self.errors].length, [...menu.errors, ...self.errors].join(';'))
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
})
await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => {
// 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B。两处真的
// 一致由 backend/tests/test_scheduler_compare.py 守,这里守前端两个入口都原样用服务端给的基准
const menu = await open(`/runs/${T}`)
await viaMenu(menu.page)
const hist = await open(`/schedules/${SCHEDULE_ID}?tab=history`)
const link = hist.page.locator(`[data-fire-id="${FIRE_T}"] [data-fire-compare]`)
await link.waitFor({ timeout: 10000 })
const label = (await link.locator('[data-fire-compare-label]').innerText()).trim()
check('触发历史:基准不是紧挨着的上一次,链接上看得见的字写「与上一次成功的运行对比」', label === '与上一次成功的运行对比', label)
await link.click()
await hist.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
await settled(hist.page)
const [a, b] = [new URL(menu.page.url()), new URL(hist.page.url())]
check('两个入口打开的是同一对运行(本次和基准都一样)', a.searchParams.get('target') === T && b.searchParams.get('target') === T
&& a.searchParams.get('base') === B && b.searchParams.get('base') === B, `${a.search} | ${b.search}`)
const [ra, rb] = [(await maybeText(menu.page.locator('[data-compare-reason]'))).trim(),
(await maybeText(hist.page.locator('[data-compare-reason]'))).trim()]
check('两个入口写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON && rb === REASON, `${ra} | ${rb}`)
const tiles = (page) => page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) =>
els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`).join(' '))
const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)]
check('两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`)
check('没有发出写请求', ![...menu.blocked, ...hist.blocked].length, [...menu.blocked, ...hist.blocked].join(';'))
check('页面没有报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(';'))
})
await section('两次运行与摘要', async () => {
const { page, errors } = await open(comparePath(T, B))
await settled(page)
@@ -791,6 +875,41 @@ await section('换基准、互换', async () => {
`${listsBefore} → ${lists()}`)
})
await section('换基准:失败、取消的运行写明状态,默认基准只取成功结束的', async () => {
// 默认基准跳过失败、取消的运行(服务端 pick_base),手动选不限:下拉和基准卡上得看得出哪次没成功,人读懂「上一次成功的
// 运行」之后才挑得出成功的那一次。两次都封存过,都能选
const F = 'c0ffee0failed0000000000000000005'
const C = 'c0ffee0cancel0000000000000000006'
RUNS[F] = runOf(F, { status: 'failed', version: 3, created_at: day('10-01'), started_at: day('10-01'), finished_at: '2026-10-01T01:01:00Z' })
RUNS[C] = runOf(C, { status: 'cancelled', version: 3, created_at: day('10-02'), started_at: day('10-02'), finished_at: '2026-10-02T01:01:00Z' })
try {
const { page, errors } = await open(comparePath(T, F))
await settled(page)
const picker = page.locator('select[data-compare-base-picker]')
await until(async () => (await picker.locator('option').count()) > 2)
const options = Object.fromEntries(await picker.locator('option').evaluateAll((els) =>
els.map((o) => [o.value, { text: o.textContent ?? '', disabled: o.disabled }])))
check('下拉:失败的那次写「失败」,照样能选', !!options[F]?.text.includes('· 失败') && !options[F].disabled, JSON.stringify(options[F]))
check('下拉:取消的那次写「已取消」,照样能选', !!options[C]?.text.includes('· 已取消') && !options[C].disabled, JSON.stringify(options[C]))
check('下拉:成功结束的不另写状态', !!options[B] && !/失败|已取消|已完成/.test(options[B].text), options[B]?.text)
const card = page.locator('[data-compare-run="base"]')
check('下拉下面的说明写明默认基准只取成功结束的运行', (await card.innerText()).includes('默认基准只取成功结束的运行'),
(await card.innerText()).replace(/\s+/g, ' '))
const status = card.locator('[data-compare-run-status]')
check('基准卡:失败的那次标出「失败」', await status.count() === 1 && (await status.innerText()).trim() === '失败',
await status.innerText().catch(() => '没有'))
check('本次卡:成功结束的不标状态', await page.locator('[data-compare-run="target"] [data-compare-run-status]').count() === 0)
await picker.selectOption(B)
await until(async () => new URL(page.url()).searchParams.get('base') === B)
await settled(page)
check('换成成功结束的那次:基准卡不再标状态', await until(async () => await card.locator('[data-compare-run-status]').count() === 0))
check('页面没有报错', errors.length === 0, errors.join(';'))
} finally {
delete RUNS[F]
delete RUNS[C]
}
})
await section('过时的答复不盖掉新的', async () => {
let release
const gate = new Promise((r) => { release = r })
@@ -878,15 +997,18 @@ await section('出错与找不到基准', async () => {
await settled(flaky.page)
check('其他错误:给「重试」,重试后正常显示', await flaky.page.locator('tr[data-compare-metric]').count() === METRICS.length)
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选。标题只说没有默认基准:失败、取消但封存了的运行不当默认
// 基准,却照样能手动选,写成「没有可作为基准的运行」就和下面「可在上方选择」打架
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
await settled(lone.page, 'error')
const noBase = lone.page.locator('[data-compare-error="no-base"]')
check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行')
&& (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行'))
check('找不到基准:标题写「没有默认基准」,后面跟服务端的原因', (await noBase.locator('[data-compare-error-title]').innerText()).trim() === '没有默认基准'
&& (await noBase.innerText()).includes(NO_BASE_SERVER), (await noBase.innerText()).replace(/\s+/g, ' '))
check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled())
check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')),
(await noBase.innerText()).replace(/\s+/g, ' '))
check('找不到基准:不说「没有可作为基准的运行」这类和「可在上方选择」矛盾的话', !/可作为基准的运行|没有可以对比/.test(await noBase.innerText()),
(await noBase.innerText()).replace(/\s+/g, ' '))
// 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选
const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }),
list: () => ({ json: [RUNS[T], RUNS[U]] }) })
@@ -991,8 +1113,7 @@ await section('不属于已保存工作流的运行:对比页的说明前后
try {
// 只带本次:服务端找不到基准(这种运行没有可比的)
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: {
detail: '没有找到可以对比的上一次运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、更早结束并已封存的运行',
code: 'compare_no_base' } }) })
detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
await settled(lone.page, 'error')
await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000)
const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')
+193 -20
View File
@@ -1,6 +1,7 @@
// 审批质询的回归检查:报告上的入口(run/EvidenceDoc.tsx 的句末「质询」、划选原文浮出的「质询」)、问答串和提问框
// (run/Inquiry.tsx)、审批卡上的挂法(run/ApprovalReport.tsx、run/RunPanel.tsx)、运行详情「证据」页签的挂法
// (pages/runs/EvidencePane.tsx)。
// (pages/runs/EvidencePane.tsx),以及运行详情「工件」页签里质询回答的名字(pages/runs/model.ts)和航迹跳转
// (pages/runs/ArtifactsPane.tsx)。
//
// 守的是这些「看着没坏、其实用不了」的地方:悬停、键盘聚焦一句都不出「质询」(或者出了 Tab 却到不了、整份报告多出一串
// Tab 位)、没有可点内容的句子键盘走不到、划选原文不浮出入口(或者带上的原文混进了徽标和「质询」本身)、提交的请求体
@@ -8,11 +9,15 @@
// 进入审批卡时问答串没有恢复、问答串写不出问的是哪一句、回答里的数字点了不去取质询工件(或者每点一次取一次、标不出
// 那一格)、引用报告已有出处的数字点了没反应、查询的 SQL / 行数 / 截断 / 遮罩不显示、触顶和没答成不说、recorded 为假
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有),或者把没能确认的说成「报告其他版本」、不给重试;
// 工件页签里质询的回答只写英文的种类名、截断截在空格上的问题摘要不补省略号、封存后追加的工件给一个跳到运行结束之后的
// 航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
//
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
// 质询的三个接口、工件、证据图、片段接口都在这里伪造;别的写请求一律拦掉并记下来,别的 GET 答 404,不碰真实服务。
// 质询的三个接口、工件、证据图、片段接口、审批列表、运行的工件列表都在这里伪造;别的写请求一律拦掉并记下来,别的 GET
// 答 404,不碰真实服务。
//
// node scripts/check-inquiry.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-inquiry.mjs
@@ -66,8 +71,9 @@ const answerFor = (body, extra = {}) => ({
/**
* 开一个预览用例。列表按运行答夹具里的问答(state.lists 可改),提问按 state.reply 答({status, json} 或按请求体算的函数;
* state.gate 是个没兑现的 Promise 时先挂着,测「回答期间」;state.listGate 同样挂着列表)。审批列表按 state.approvals
* (运行 → 审批数组)答,设置按 state.settings 答;state.docs、state.snaps 盖过夹具里的文档、快照。setup 在打开页面之前
* 改 state(进入时就要取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
* (运行 → 审批数组)答,没有的答 404,state.approvalsGate 同样先挂着;设置按 state.settings 答;state.docs、state.snaps
* 盖过夹具里的文档、快照;运行的工件列表答夹具里的 run_artifacts(探索运行)。setup 在打开页面之前改 state(进入时就要
* 取的列表、设置得先备好);ws 接管运行的事件流。记下列表、提问、取快照、取工件、取审批列表的请求和拦下的写请求
*/
async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws, waitUntil = 'networkidle' } = {}) {
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
@@ -79,6 +85,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
reply: (body) => ({ status: 200, json: answerFor(body) }),
gate: null,
listGate: null,
approvalsGate: null,
approvals: {},
settings: {},
docs: {},
@@ -114,6 +121,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
if (path === '/api/approvals') {
const run = url.searchParams.get('run_id')
hits.approvals.push({ run, status: url.searchParams.get('status') })
if (state.approvalsGate) await state.approvalsGate
const list = state.approvals[run]
return list ? route.fulfill({ json: list }) : route.fulfill({ status: 404, json: { detail: '检查脚本没有伪造这个接口' } })
}
@@ -141,6 +149,8 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
hits.segments.push(decodeURIComponent(seg[2]))
return route.fulfill({ json: {} })
}
const arts = path.match(/^\/api\/runs\/([^/]+)\/artifacts$/)
if (arts) return route.fulfill({ json: decodeURIComponent(arts[1]) === RUNS.explore ? fx.run_artifacts : [] })
const graph = path.match(/^\/api\/runs\/([^/]+)\/evidence$/)
if (graph) {
const run = decodeURIComponent(graph[1])
@@ -822,6 +832,53 @@ await section('运行详情「证据」页签:封存了的探索运行有入
}
})
await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名;封存后追加的不给航迹跳转', async () => {
const { ctx, page, errors } = await open('inquiry-run-artifacts')
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
// 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
const open = el.querySelector('[data-action="artifact-open"]')
const spans = open ? [...open.children] : []
return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
}))
const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
check('质询的回答:种类写「质询回答」', answers.length === 4 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
check('有问题摘要的:说明写「质询「问题」的回答」', answers[0]?.about === '质询「退款率怎么算的?」的回答', answers[0]?.about)
check('问题摘要满 40 字(服务端只记前 40 字):末尾补省略号',
answers[1]?.about === '质询「枫岭区的销售额是否包含线上订单?如果包含,线上订单按下单门店还是按收货地址归到片…」的回答', answers[1]?.about)
check('没有问题摘要的:也有一句说明', answers[2]?.about === '审批质询的回答:逐段的片段和每个数字的出处', answers[2]?.about)
// 第 40 个字是空格:服务端截下来的摘要以空格结尾(截之前整个问题已经去过首尾空白),去掉空白再数只有 39 字
check('截断截在空格上的摘要:照样补省略号,不当成完整的问题',
answers[3]?.about === '质询「本周枫岭片区 GMV 环比下降了多少?下降的主要原因是不是 SKU 结构的变化…」的回答', answers[3]?.about)
// 航迹跳转:运行期间产出的报告文档能跳到航迹里那一刻;封存后追加的质询回答和它查询的快照晚于运行结束,航迹里没有
// 那一刻(以前给「T+」几十分钟、几天的跳转,点了把航迹拖到运行早就结束之后),只写时间
const moments = Object.fromEntries(await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) =>
[el.getAttribute('data-artifact-id'), el.querySelector('[data-action="artifact-moment"]')?.textContent?.trim() ?? null])))
const doc = fx.run_artifacts.find((a) => a.kind === 'report_doc')
const late = fx.run_artifacts.filter((a) => a.kind !== 'report_doc')
check('运行期间产出的报告文档:给航迹跳转', /^T\+/.test(moments[doc.id] ?? ''), moments[doc.id])
check('封存后追加的质询回答、它查询的快照:不给航迹跳转,只写时间', late.every((a) => moments[a.id] === null)
&& (await page.locator(`[data-artifact-id="${late[0].id}"] > span`).last().innerText()).trim() !== '',
JSON.stringify(late.map((a) => moments[a.id])))
await page.locator(`[data-artifact-id="${doc.id}"] [data-action="artifact-moment"]`).click()
const jumped = await page.locator('[data-fixture="inquiry-run-artifacts"]').getAttribute('data-fixture-moment')
check('……点报告文档的跳转:到它产出的那一刻', Number(jumped) === Date.parse(`${doc.created_at}Z`), jumped)
check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
const pane = await text(page.locator('[data-run-artifacts]'))
check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
rows.map((r) => r.kind).join('、'))
check('不挂在节点上的写「不属于某个节点」', (await text(page.locator('[data-artifact-group=""] h3'))).startsWith('不属于某个节点'))
// 打开一件:弹窗标题也是中文的种类名
await page.locator(`[data-artifact-id="${answers[0]?.id}"] [data-action="artifact-open"]`).click()
const dialog = page.getByRole('dialog')
await dialog.waitFor()
const head = await text(dialog.locator('h2').first())
check('打开的弹窗标题写「质询回答」,不写英文', head.includes('质询回答') && !head.includes('inquiry_answer'), head)
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
})
await section('有表格的报告:悬停句子、表格的格子都不挪动排版;表格的格子不挂「质询」', async () => {
// 报告文档换成带表格的那份(run/__tests__/evidence-doc.json):表里带出处的格子也是结论句
const ev = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
@@ -1026,12 +1083,18 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
{
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)
const { ctx, page, errors } = await open('inquiry-approval', { setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
// 审批列表取不到:别的审批里问的一律单独一组(宁可不挂,不挂错)。不知道是哪一稿,不能说成「其他版本」(审批人会以为
// 报告换过稿):写明没能确认、给重试。这里用浅色主题量那句的颜色(深色在下一段量)
const { ctx, page, errors } = await open('inquiry-approval', { theme: 'light', setup: (s) => { s.lists[RUNS.approve] = [prev, level, ...fx.items] } })
await entry(page, 12).waitFor()
const g = await groupsOf(page)
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'other'
check('审批列表取不到:别的审批里问的都单独一组', JSON.stringify(g.at(-1)) !== undefined && g.at(-1).unit === 'unsure'
&& JSON.stringify(g.at(-1).entries) === JSON.stringify(['12', '20']), JSON.stringify(g.map((x) => [x.unit, x.entries])))
check('审批列表取不到:那一组写「无法确认提问时的报告版本」,不说「其他版本」,给重试',
g.at(-1)?.about.startsWith('无法确认提问时的报告版本') && !g.at(-1)?.about.includes('其他版本')
&& await group(page, 'unsure').locator('[data-inquiry-docs-retry]').count() === 1, g.at(-1)?.about)
const unsureRatio = await ratioOf(page, '[data-inquiry-group="unsure"] [data-inquiry-about-text]')
check('审批列表取不到(浅色):那一句对比度 ≥ 4.5', unsureRatio >= 4.5, unsureRatio.toFixed(2))
check('审批列表取不到:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
@@ -1054,6 +1117,96 @@ await section('之前几轮审批里问的:报告改写过的不按句子编
}
})
/** 一处问答串:抬头写的次数、实际列出的条数 */
const tally = async (page, scope) => ({
head: (await page.locator(`${scope} [data-inquiry-count]`).getAttribute('data-inquiry-count').catch(() => null)) ?? '',
shown: await page.locator(`${scope} [data-inquiry-entry]`).count(),
})
await section('两处要对的审批不同、审批列表取不到:两处的问答都列出来,次数和条数一致,写明没能确认、重试取到后各归其位', async () => {
// 一张卡审两份报告:写月报上有 ap-x 里问的,写周报上有 ap-y 里问的,两份报告的问答串共用这次运行的审批列表。先发的那一处
// 请求还在路上时,后一处要对的审批也得记上:不然请求回来(这里是 404)之后后一处一直等着,头上写「· 1 次」,下面一条没有
const x = { ...clone(fx.items[0]), seq: 41, approval_id: 'ap-x', question: '写月报上一轮问的:退款率怎么算?', at: '2026-10-05T05:30:00Z' }
const y = { ...clone(fx.items[1]), seq: 42, report: 'write2', approval_id: 'ap-y', question: '写周报上一轮问的:新客怎么界定?',
at: '2026-10-05T05:35:00Z' }
let release
// 审批列表压着,页面等不到网络空闲:打开到 load 就往下走
const { ctx, page, hits, errors } = await open('inquiry-approval-two', { waitUntil: 'load', setup: (s) => {
s.lists[RUNS.approve] = [x, y]
s.approvalsGate = new Promise((r) => { release = r })
} })
// 两份报告的问答串都取回来了(头上写着次数),审批列表的请求还压着
await page.locator('[data-inquiry="write"] [data-inquiry-count]').waitFor()
await page.locator('[data-inquiry="write2"] [data-inquiry-count]').waitFor()
await until(() => hits.approvals.length >= 1)
await page.waitForTimeout(200)
release()
const both = await until(async () => (await tally(page, '[data-inquiry="write"]')).shown === 1
&& (await tally(page, '[data-inquiry="write2"]')).shown === 1)
const [gw, gw2] = [await groupsOf(page, '[data-inquiry="write"]'), await groupsOf(page, '[data-inquiry="write2"]')]
check('写月报:审批列表没取到,那张审批里问的单独一组(没能确认)', JSON.stringify(gw.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['41']]]),
JSON.stringify(gw.map((g) => [g.unit, g.entries])))
check('写周报:请求是另一处先发的,回来之后照样列出、单独一组', both
&& JSON.stringify(gw2.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['42']]]), JSON.stringify(gw2.map((g) => [g.unit, g.entries])))
const [tw, tw2] = [await tally(page, '[data-inquiry="write"]'), await tally(page, '[data-inquiry="write2"]')]
check('两份报告头上的次数都和列出的条数一致', tw.head === '1' && tw.shown === 1 && tw2.head === '1' && tw2.shown === 1,
JSON.stringify({ tw, tw2 }))
check('按运行只取一次审批列表(一份列表里各张审批都有)', hits.approvals.length === 1, JSON.stringify(hits.approvals))
check('页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
{
// 运行详情直接打开「证据」页签、运行停在审批上:时间线里的待审批卡只为更早那一轮(ap-inq-0)取审批列表,证据页签不认
// 任何审批,ap-inq-0 和这张卡的 ap-inq-1 都要对。卡那一处先发了请求,证据页签那一处的条目在请求回来之后也得列出来
const early = { ...clone(fx.items[0]), seq: 12, approval_id: 'ap-inq-0', question: '上一轮:退款率 3.5% 是怎么算的?', at: '2026-10-05T05:40:00Z' }
const { ctx, page, hits, state, errors } = await open('inquiry-run-waiting', { waitUntil: 'load', setup: (s) => {
s.lists[RUNS.approve] = [early, clone(fx.items[1])]
s.approvalsGate = new Promise((r) => { release = r })
} })
const card = '[data-fixture-card] [data-inquiry="write"]'
const pane = '[data-fixture-evidence] [data-inquiry="write"]'
await page.locator(`${card} [data-inquiry-count]`).waitFor()
await page.locator(`${pane} [data-inquiry-count]`).waitFor()
await until(() => hits.approvals.length >= 1)
await page.waitForTimeout(200)
release()
const listed = await until(async () => (await tally(page, card)).shown === 2 && (await tally(page, pane)).shown === 2)
const [gc, gp] = [await groupsOf(page, card), await groupsOf(page, pane)]
check('待审批卡:这张审批里问的挂句子下面,没能确认的那一轮单独一组',
JSON.stringify(gc.map((g) => [g.unit, g.entries])) === JSON.stringify([['u3', ['34']], ['unsure', ['12']]]),
JSON.stringify(gc.map((g) => [g.unit, g.entries])))
check('证据页签:两条都列出来,没能确认的单独一组(不再一直等着)', listed
&& JSON.stringify(gp.map((g) => [g.unit, g.entries])) === JSON.stringify([['unsure', ['12', '34']]]), JSON.stringify(gp.map((g) => [g.unit, g.entries])))
const [tc, tp] = [await tally(page, card), await tally(page, pane)]
check('两处头上的次数都和列出的条数一致', tc.head === '2' && tc.shown === 2 && tp.head === '2' && tp.shown === 2, JSON.stringify({ tc, tp }))
check('运行详情:按运行只取一次审批列表', hits.approvals.length === 1, JSON.stringify(hits.approvals))
// 没读到审批记录不能说成「其他版本」:卡上同一条挂在句子下面,下面证据页签却说是别的版本,审批人会以为报告换过稿
const heads = (await page.locator('[data-inquiry-group="unsure"] [data-inquiry-about-text]').allInnerTexts()).map((t) => t.trim())
check('没能确认的那一组(两处):写「无法确认提问时的报告版本」,不说「其他版本」',
heads.length === 2 && heads.every((t) => t === '无法确认提问时的报告版本'), heads.join(' | '))
check('……两处都给「重试」', await page.locator(`${card} [data-inquiry-group="unsure"] [data-inquiry-docs-retry]`).count() === 1
&& await page.locator(`${pane} [data-inquiry-group="unsure"] [data-inquiry-docs-retry]`).count() === 1)
const unsureRatio = await ratioOf(page, `${pane} [data-inquiry-group="unsure"] [data-inquiry-about-text]`)
check('……那一句(深色)对比度 ≥ 4.5', unsureRatio >= 4.5, unsureRatio.toFixed(2))
// 重试,这回取得到:这张审批审的就是眼前这一稿,更早那一轮审的是另一稿
state.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact),
approvalRow('ap-inq-0', RUNS.approve, '0'.repeat(64))]
await page.locator(`${pane} [data-inquiry-docs-retry]`).click()
const want = JSON.stringify([['u3', ['34']], ['other', ['12']]])
const placed = await until(async () => JSON.stringify((await groupsOf(page, pane)).map((g) => [g.unit, g.entries])) === want
&& JSON.stringify((await groupsOf(page, card)).map((g) => [g.unit, g.entries])) === want)
const [gc2, gp2] = [await groupsOf(page, card), await groupsOf(page, pane)]
check('重试取到之后(证据页签):这张审批里问的回到句子下面,更早那一轮算报告其他版本', placed
&& JSON.stringify(gp2.map((g) => [g.unit, g.entries])) === want, JSON.stringify(gp2.map((g) => [g.unit, g.entries])))
check('重试取到之后(待审批卡):更早那一轮也改写成报告其他版本', JSON.stringify(gc2.map((g) => [g.unit, g.entries])) === want
&& gc2.at(-1)?.about.startsWith('报告其他版本上的质询'), JSON.stringify(gc2.map((g) => [g.unit, g.about.slice(0, 12)])))
check('……重试只再取一次审批列表,两处共用', hits.approvals.length === 2, JSON.stringify(hits.approvals))
const [tc2, tp2] = [await tally(page, card), await tally(page, pane)]
check('……两处的次数仍和条数一致', tc2.head === '2' && tc2.shown === 2 && tp2.head === '2' && tp2.shown === 2, JSON.stringify({ tc2, tp2 }))
check('运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await section('回答期间另开了提问框,正在回答的那一条没问成:新写的不被覆盖', async () => {
const { ctx, page, state, errors } = await open('inquiry-approval')
await expand(page)
@@ -1273,18 +1426,23 @@ function contrast(fg, bg, base) {
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
}
/** 某个元素的字色对它所在那一块底色的对比度:底色往上找第一个不透明的,最底下是 body */
const ratioOf = (page, sel) => page.evaluate((s) => {
const el = document.querySelector(s)
if (!el) return null
let bgEl = el
let bg = 'rgba(0, 0, 0, 0)'
while (bgEl && /rgba\(0, 0, 0, 0\)|transparent/.test(bg)) {
bg = getComputedStyle(bgEl).backgroundColor
bgEl = bgEl.parentElement
}
return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor }
}, sel).then((c) => (c ? contrast(c.fg, c.bg, c.base) : 0))
/**
* 某个元素的字色对它所在那一块底色的对比度:底色往上找第一个不透明的,最底下是 body。写成函数声明:前面几段(审批列表
* 取不到的那一组)也要用
*/
function ratioOf(page, sel) {
return page.evaluate((s) => {
const el = document.querySelector(s)
if (!el) return null
let bgEl = el
let bg = 'rgba(0, 0, 0, 0)'
while (bgEl && /rgba\(0, 0, 0, 0\)|transparent/.test(bg)) {
bg = getComputedStyle(bgEl).backgroundColor
bgEl = bgEl.parentElement
}
return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor }
}, sel).then((c) => (c ? contrast(c.fg, c.bg, c.base) : 0))
}
await section('窄屏与深浅色', async () => {
for (const theme of ['dark', 'light']) {
@@ -1366,6 +1524,21 @@ await section('窄屏与深浅色', async () => {
check('390px 运行详情:页面没有报错', errors.length === 0, errors.join(';'))
await ctx.close()
}
for (const theme of ['dark', 'light']) {
// 运行详情「工件」页签:质询回答的说明可能很长(问题的前 40 字),窄屏上截断、不撑宽;种类和说明深浅色都看得清
const { ctx, page, errors } = await open('inquiry-run-artifacts', { width: 390, height: 844, theme })
const long = fx.run_artifacts.find((a) => a.kind === 'inquiry_answer' && [...(a.meta.question ?? '')].length >= 40)
const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
await page.locator(row).waitFor()
const o = await overflow(page, '[data-run-artifacts]')
const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
await shot(page, `run-artifacts-narrow-${theme}`)
check(`390px 工件页签(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
await ctx.close()
}
})
await browser.close()
+78 -7
View File
@@ -5,9 +5,10 @@
// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。运行成功结束后的自动对比:触发历史里「与上一次
// 对比」(和运行详情「更多」菜单同一个叫法)链到对比页,旁边写有几个指标有变化,悬停只写各类指标的个数,基准不是
// 紧挨着的上一次时写明是哪一次、不带「默认基准」的来历;运行详情页头的「由定时任务「X」发起」是键盘到得了的真链接
// (记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里(768、820、900 宽,探索运行多一个「提取为
// 草稿」)不被挤没,定时任务删掉之后写成纯文本。
// 紧挨着的上一次时链接上看得见的字写「与上一次成功的运行对比」、悬停写明是哪一次;两种都带「默认基准:同一定时任务
// 上一次成功的运行」的来历,和运行详情「更多」里的同名入口打开同一对运行(服务端用同一个函数挑基准)。运行详情页头的
// 「由定时任务「X」发起」是键盘到得了的真链接(记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里
// (768、820、900 宽,探索运行多一个「提取为草稿」)不被挤没,定时任务删掉之后写成纯文本。
// 还有几处时序和口径:晚到的刷新把刚切换的开关盖回去、先开触发历史再开设置时带着旧版本撞假的 409、保存途中又改了
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
@@ -267,9 +268,15 @@ await section('纯函数:服务端会拒的组合、保存回来怎么接、
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
})
/** 对比页写的默认基准来历(定时任务发起的):只挑比本次先发起、成功结束并封存的运行,服务端 api/compare.py 的 pick_base */
const REASON_SCHEDULE = '默认基准:同一定时任务上一次成功的运行'
await section('纯函数:触发历史对比的文案', async () => {
const { SCHEDULE_TEXT: T, RUN_COMPARE_TEXT: C } = await import(await load('/src/lib/terms.ts'))
check('「与上一次对比」和运行详情「更多」菜单同一个叫法', T.compareLink === C.menu && T.compareLink === '与上一次对比', T.compareLink)
check('基准不是紧挨着的上一次时的叫法:与上一次成功的运行对比', T.compareLinkLastOk === '与上一次成功的运行对比', T.compareLinkLastOk)
check('默认基准的来历照服务端的挑法写「上一次成功的运行」', C.reason.same_schedule === REASON_SCHEDULE
&& C.reason.same_workflow === '默认基准:同一工作流、同一运行类别上一次成功的运行', JSON.stringify(C.reason))
const zero = { unchanged: 0, data_only: 0, caliber_and_data: 0, sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 }
const cases = [
['有变化的几类合起来', { ...zero, data_only: 3, caliber_and_data: 1, params_only: 2, unchanged: 4 }, '6 个指标有变化'],
@@ -1462,6 +1469,11 @@ await section('详情:触发历史里的「与上一次对比」', async () =>
const f10 = row('f10').locator('[data-fire-compare]')
const t10 = (await f10.getAttribute('title').catch(() => '')) ?? ''
check('上一次失败了、基准是再往前成功的那次:悬停写明是哪一次', t10.startsWith('与上一次成功的运行(#run-f1)相比:只数据变 1 个'), t10)
// 触屏没有悬停、键盘聚焦也不显示 title:比的不是上一次这件事得写在看得见的字里
const l10 = f10.locator('[data-fire-compare-label]')
check('……链接上看得见的字写「与上一次成功的运行对比」,不只靠悬停', (await l10.innerText().catch(() => '')).trim() === '与上一次成功的运行对比'
&& await l10.isVisible(), (await l10.innerText().catch(() => '没有')).trim())
check('……紧挨着的上一次那一行照旧写「与上一次对比」', (await row('f00').locator('[data-fire-compare-label]').innerText()).trim() === '与上一次对比')
const linked = await page.locator('[data-fire-compare]').evaluateAll((els) => els.map((e) => e.closest('[data-fire-id]')?.getAttribute('data-fire-id')))
check('没有对比的(compare 为 null、老服务端不给、没有运行)不给链接', JSON.stringify(linked) === '["f00","f08","f10"]', JSON.stringify(linked))
check('运行链接照旧在', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1)
@@ -1475,21 +1487,78 @@ await section('详情:触发历史里的「与上一次对比」', async () =>
})
check('……回车打开对比页,带着本次和基准', !!url && url.searchParams.get('target') === 'run-f00'
&& url.searchParams.get('base') === 'run-f02', page.url())
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务的上一次运行」
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务上一次成功的运行」
const via = await page.evaluate(() => history.state?.usr ?? null)
check('……紧挨着的上一次:带上「同一定时任务」的来历', via?.reason === 'same_schedule' && via?.base === 'run-f02', JSON.stringify(via))
// 基准不是紧挨着的上一次:来历照样带上。对比页写的是「上一次成功的运行」,比的正是这一次,和从运行详情进去一字不差
const second = await open('/schedules/s-weekly?tab=history')
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').click()
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').focus()
const focusedLabel = await second.page.evaluate(() => document.activeElement?.querySelector('[data-fire-compare-label]')?.textContent?.trim() ?? '')
check('……键盘聚焦在那一行的链接上时,看得见的字就是「与上一次成功的运行对比」', focusedLabel === '与上一次成功的运行对比', focusedLabel)
await second.page.keyboard.press('Enter')
await until(() => new URL(second.page.url()).pathname === '/runs/compare')
const via10 = await second.page.evaluate(() => history.state?.usr ?? null)
check('……基准不是紧挨着的上一次:不带来历,对比页不说「上一次」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
&& via10 == null, JSON.stringify(via10))
const reason10 = await until(async () => (await second.page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
check('……基准不是紧挨着的上一次:带上来历,对比页写「同一定时任务上一次成功的运行」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
&& via10?.reason === 'same_schedule' && via10?.base === 'run-f12' && reason10 === REASON_SCHEDULE, `${JSON.stringify(via10)} ${reason10}`)
const real = [...writes(sent, /./), ...writes(second.sent, /./)].filter((x) => x.key !== 'POST /schedules/preview')
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
check('没有页面报错', ![...errors, ...second.errors].length, [...errors, ...second.errors].join(' | '))
})
await section('详情:触发历史和运行详情的「与上一次对比」拿同一个基准', async () => {
// f10 的上一次(f11)失败了,服务端两处都挑再往前成功的 f12:自动对比和运行详情的默认基准用的是同一个函数
// (api/compare.py 的 pick_base),基准事后变了的旧对比触发历史不再给,这些由 backend/tests/test_scheduler_compare.py
// 守。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历
const runOf = (id, at) => ({ ...RUNS[0], id, started_by: '定时任务:门店周报 · 每周', created_at: iso(at), started_at: iso(at),
finished_at: iso(at + 4200), manifest_hash: 'ab'.repeat(32), manifest_seq: 2 })
const f10 = runOf('run-f10', nextMonday9(-11))
const f12 = runOf('run-f12', nextMonday9(-13))
const ref = (r) => ({ id: r.id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class,
started_at: r.started_at, finished_at: r.finished_at, schedule_id: r.schedule_id })
const compareApi = (method, path, body, url) => {
if (method !== 'GET') return null
if (path === '/runs/run-f10/compare/base') return { json: { base_run_id: 'run-f12', reason: 'same_schedule', base: ref(f12) } }
if (path === '/runs/run-f10/compare' && url.searchParams.get('base') === 'run-f12') {
return { json: { target: ref(f10), base: ref(f12), summary: COMPARE_F10.summary, metrics: [], queries: [], notes: [],
template: { base_version: 3, target_version: 3, same_graph: true, nodes: [] } } }
}
return null
}
const stateOf = () => ({ ...freshState({ runs: [...RUNS, f10, f12] }), override: compareApi })
const landed = (page) => until(() => {
const u = new URL(page.url())
return u.pathname === '/runs/compare' ? u : null
})
const reasonOf = (page) => until(async () => (await page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
const menu = await open('/runs/run-f10', { state: stateOf() })
await menu.page.locator('[data-run-detail="run-f10"]').waitFor({ timeout: 10000 })
await menu.page.getByRole('button', { name: '更多操作' }).click()
await menu.page.locator('[data-menu=compare]').click()
const a = await landed(menu.page)
const hist = await open('/schedules/s-weekly?tab=history', { state: stateOf() })
const link = hist.page.locator('[data-fire-id="f10"] [data-fire-compare]')
await link.waitFor()
const label = (await link.locator('[data-fire-compare-label]').innerText()).trim()
await link.click()
const b = await landed(hist.page)
check('运行详情「更多」和触发历史两个入口:打开同一对运行(本次 run-f10,基准是跳过失败那次的 run-f12)',
!!a && !!b && a.searchParams.get('target') === 'run-f10' && b.searchParams.get('target') === 'run-f10'
&& a.searchParams.get('base') === 'run-f12' && b.searchParams.get('base') === 'run-f12', `${a?.search} | ${b?.search}`)
const [ra, rb] = [await reasonOf(menu.page), await reasonOf(hist.page)]
check('……两处写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON_SCHEDULE && rb === REASON_SCHEDULE, `${ra} | ${rb}`)
check('……触发历史上看得见的字说明比的是上一次成功的运行', label === '与上一次成功的运行对比', label)
const tiles = (page) => until(async () => (await page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) =>
els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`))).join(' '))
const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)]
check('……两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`)
const real = [...writes(menu.sent, /./), ...writes(hist.sent, /./)]
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
check('没有页面报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(' | '))
})
// ---------------------------------------------------------------- 运行详情
/**
@@ -1739,6 +1808,8 @@ await section('窄屏 390px 与浅色', async () => {
check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`)
check(`${theme}:「与上一次对比」和旁边的计数在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
&& await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]'))
check(`${theme}:更长的「与上一次成功的运行对比」连同计数也在屏幕里`, await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare]')
&& await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare-count]'))
const cmp = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
check(`${theme}:「与上一次对比」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp))
const cnt = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]')