From 36c8f581cc2712205dc1e825136d1deb4a091792 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 18:20:52 +0800 Subject: [PATCH] =?UTF-8?q?W3-d=20=E5=A4=8D=E6=A0=B8=20H1=EF=BC=9A?= =?UTF-8?q?=E3=80=8C=E6=9C=80=E8=BF=91=E4=B8=80=E6=AC=A1=E5=B0=81=E5=AD=98?= =?UTF-8?q?=E3=80=8D=E6=8C=89=E6=9D=A5=E6=BA=90=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C=E9=97=AE=E6=95=B0=E6=8D=AE=E6=8C=89=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=87=BA=E5=85=B7=E7=9A=84=E6=AD=A3=E5=BC=8F=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E5=86=8D=E9=A1=B6=E6=9B=BF=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E7=9A=84=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核发现:问数据的答案按已治理口径正式出具的运行(origin_kind=ledger_issue,W3-b 加的自动出具缺省开)参数是从某人的 提问里绑出来的,而 apps/resolve.sealed_runs 只排除了补算(app_backfill)。于是 from=formal 的 latest_sealed 视图、 趋势、分享链接(latest,以及建 fixed 分享时取的那次)、视图数据接口、补算判断「新版本算过没有」都会换成那组参数的数, 而且没有任何提示。 做法:sealed_runs 的来源从黑名单改成白名单 SEALED_ORIGINS(定时、手动正式运行、应用入口和按需视图、入站钩子、直接调 接口,外加没有来源列之前的老运行);不算的列在 NOT_SEALED_ORIGINS,逐条写明原因。上面这几处都经 sealed_runs,改一处都 跟上。按白名单认是为了以后再加来源时默认不算、要算得先登记,不会再悄悄漏进看板。 测试(tests/test_shares.py): - 先跑一次正常的正式运行,再跑一次更新的 ledger_issue 正式运行:应用页、趋势、视图数据接口、latest 和 fixed 两种分享 都还是正常那次,分享的下载报告也不认出具的那次。去掉修复回跑,这一项失败; - ORIGIN_KINDS 每种来源都要归进 SEALED_ORIGINS 或 NOT_SEALED_ORIGINS 之一,以后新加的来源没归类先失败。 --- backend/app/apps/resolve.py | 18 ++++++++++++--- backend/tests/test_shares.py | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index fb96901d..f8963718 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -52,6 +52,18 @@ PAGE_CACHE_SIZE = 256 #: 按新口径补算的运行来源(engine/origin.py) BACKFILL_ORIGIN = "app_backfill" +#: 「最近一次封存」认哪几种来源的正式运行:latest_sealed 视图、趋势、分享链接(latest,以及建 fixed 时取的那几次)、视图 +#: 数据接口、补算判断「新版本算过没有」都经 sealed_runs,这里改一处都跟上。按白名单认,以后加的来源默认不算,要算的在这里 +#: 登记(tests/test_shares.py 核对 ORIGIN_KINDS 每种都归了类)。算的:定时、手动的正式运行、应用的入口和按需视图、入站 +#: 钩子、直接调接口;加来源这一列之前的老运行(origin_kind 为空)也算。 +SEALED_ORIGINS = ("launcher", "schedule", "app_entry", "app_view", "hook", "api") +#: 不算的,各有原因: +#: - app_backfill:按新口径补算的是现在才算的历史期,不是「最近一次」;趋势另经 trend_runs 按它对应的输入期排进去; +#: - ledger_issue:问数据的答案按已治理口径正式出具(docs/design/next-round/02-口径账本.md §5),参数是从某人的提问里 +#: 绑出来的。取到它,看板、趋势、分享链接就悄悄换成了那个人问的那组数; +#: - 其余是探索运行(问数据、画布试跑、试运行、个人应用的刷新,评测、口径对账、口径召回),本来就不是正式运行。 +NOT_SEALED_ORIGINS = (BACKFILL_ORIGIN, "ledger_issue", "chat", "canvas", "trial", "personal_app", "eval", "reconcile", + "recall") VIEW_REFUSED = "app_view_refused" DAILY_LIMIT = "app_view_daily_limit" @@ -138,8 +150,8 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra """钉住版本(同一 graph_hash)最近 n 次成功且已封存的正式运行,新的在前。 sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。 - 按新口径补算的运行(app_backfill)不算:它们是现在才算的历史期,「最近一次」不能取到它们;趋势另经 trend_runs - 按它们对应的输入期排进去。""" + 来源只认 SEALED_ORIGINS(和老运行):按新口径补算的(app_backfill)、问数据按口径出具的(ledger_issue)都不是 + 「最近一次」,原因见 NOT_SEALED_ORIGINS。""" from sqlalchemy import or_ from app.scheduler.auto_compare import sealed_now @@ -148,7 +160,7 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra return [] query = select(Run).where(Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal", Run.status == "succeeded", Run.version_hash == graph_hash, - or_(Run.origin_kind.is_(None), Run.origin_kind != BACKFILL_ORIGIN)) + or_(Run.origin_kind.is_(None), Run.origin_kind.in_(SEALED_ORIGINS))) if isinstance(origin, str) and origin.startswith("schedule:"): query = query.where(Run.schedule_id == origin[len("schedule:"):]) rows = list((await session.execute(query.order_by(Run.finished_at.desc(), Run.created_at.desc()) diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index 4eb41c99..28d110d0 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -225,6 +225,49 @@ async def test_fixed_mode_stays_on_the_runs_it_was_created_with(world): # noqa: assert moving["view"]["sources"]["latest"]["run_id"] == second.id +async def test_runs_issued_from_questions_are_never_the_latest(world): # noqa: F811 + """问数据的答案按口径正式出具的运行(ledger_issue)参数是从某人的提问里绑出来的:应用页、趋势、分享链接(latest 和建 + fixed 时取的那次)、视图数据接口的「最近一次封存」都不取它,下载报告也不认它(复核 H1)。""" + from app.engine.launch import launch_run + + svc = await auth_env.make_service() + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), [("user", "svc-portal", "observe")]) + regular = await formal(wf, "W1") + async with SessionLocal() as s: + launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={"week": "W9"}, + actor="编辑", origin_kind="ledger_issue", origin={"source_run": "提问那次"}, + expect_hash=h) + issued = await wait(launched.id) + assert issued.status == "succeeded" and issued.manifest_hash, "出具的运行成功并封存了,是更新的那一次" + admin = await auth_env.login("root") + page = (await admin.get(f"/api/apps/{app_id}/pages/home")).json() + assert page["sources"]["latest"]["run_id"] == regular.id, "看板不换成某人提问的那组参数" + points = page["tree"][1]["data"]["series"][-1]["points"] + assert [p["run_id"] for p in points] == [regular.id], "趋势里也没有它" + _, secret = await auth_env.make_key(svc, scopes=("apps.read",)) + data = (await auth_env.keyed(secret).get(f"/api/apps/{app_id}/views/latest/data")).json() + assert data["state"] == "ok" and data["run_id"] == regular.id, "视图数据接口同一个取法" + fixed = await share(app_id, mode="fixed") + latest = await share(app_id) + for made in (fixed, latest): + async with anonymous(**{HEADER: made["key"]}) as c: + body = (await c.get("/api/shares/page")).json() + assert body["view"]["sources"]["latest"]["run_id"] == regular.id, made["mode"] + refused = await c.get("/api/shares/report.html", params={"run": issued.id}) + assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared", made["mode"] + + +def test_every_run_origin_is_classified_for_latest_sealed(): + """每种运行来源都要说清算不算「最近一次封存」:以后加了来源、没在 apps/resolve 归类的,这里先失败。""" + from app.apps.resolve import NOT_SEALED_ORIGINS, SEALED_ORIGINS + from app.engine.origin import ORIGIN_KINDS + + assert not set(SEALED_ORIGINS) & set(NOT_SEALED_ORIGINS) + assert set(SEALED_ORIGINS) | set(NOT_SEALED_ORIGINS) == set(ORIGIN_KINDS) + assert "ledger_issue" in NOT_SEALED_ORIGINS and "app_backfill" in NOT_SEALED_ORIGINS + + async def test_report_download_only_for_the_shares_runs_and_masked(world, monkeypatch, tmp_path): # noqa: F811 import sqlite3