mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:01:57 +00:00
e96b0180d9
为什么:被驳回的运行导出后,可验证报告的运行结果写「已取消」,报告导出的运行状态也写「已取消」,和系统里「被驳回」两种 说法。封存里被驳回和手动停止是同一种结束事件(运行取消),分开它们的原因在那条事件的内容里,而运行取消的内容不随文件 附带(里面有驳回人和备注)——光看封存分不出来。 做了什么: - 两种导出的元信息都带 end_reason(bundle/build、report_export/build、对应的接口)。 - verifier.js:运行结果按运行记录写「被驳回」,核对的仍是「最后一条结束类事件是运行取消」,旁边写「已核对运行取消,被驳回 取自运行记录」,原因里写明这一半要去系统里对照;手动停止、老运行照旧「已取消」;文件里写被驳回、事件却是运行结束的照旧 不通过。runtime.js 的运行状态照 end_reason 写「被驳回」。 - 样本页加一份 rejected(check-bundle 样本数 17 → 18),断言写被驳回、写明取自运行记录、不提重新封存。 测试:test_bundle_rejected 用 node 载入 verifier.js 核运行信息(被驳回、手动停止、老运行、伪造);test_bundle_build 核 元信息和样本页;test_bundle_api 核两种导出接口都带 end_reason。把实现退回去回跑,被驳回、伪造两条和元信息、样本页、 接口的断言都失败(手动停止、老运行两条守的是原样,照旧通过);改完通过。
79 lines
5.2 KiB
Python
79 lines
5.2 KiB
Python
"""可验证报告:GET /api/runs/{run_id}/bundle.html,把一次已封存运行的报告导出成一个自包含的 HTML 文件。
|
||
|
||
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对每个数字的出处、运行记录和工件有没有被改过
|
||
(页面怎么核见 bundle/verifier.js,数据怎么拼见 bundle/build.py)。
|
||
|
||
只给已封存、而且封存核对一致的运行:封存判定照搬证据接口(api/evidence._sealed),不另写一套。没封存的
|
||
运行事件还会变,导出去的东西马上就过时;封存核对不一致的,导出去的页面只会把一条断掉的链画成核对通过。
|
||
另外要求运行已经结束(成功、失败、取消):失败后接着跑的运行留着上一次的封存,跑完会重新封存、换掉清单哈希,
|
||
早年的运行停在审批时也封存过——这时导出去的文件,封存哈希和系统里之后记的对不上,页面上「去系统里对照」
|
||
那一步就作废了。
|
||
只读:不追加任何事件,不改运行记录。路径挂在 /api/runs/{run_id}/… 下面,runs.py 里已经有 /{run_id}/verify,
|
||
这里叫 bundle.html,不撞名。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import APIRouter
|
||
from fastapi.responses import Response
|
||
from sqlalchemy import select
|
||
|
||
from app.api.coded import CodedHTTPException
|
||
from app.api.evidence import RUN_NOT_FOUND, _mask_fingerprint, _Masks, _sealed, certain_masks
|
||
from app.bundle.build import attachment_name, build_bundle, content_disposition, render_html
|
||
from app.core.artifact_store import read_bytes
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run, RunEvent
|
||
|
||
router = APIRouter(prefix="/api/runs", tags=["bundle"])
|
||
|
||
BUNDLE_UNSEALED = "bundle_unsealed"
|
||
BUNDLE_SEAL_BROKEN = "bundle_seal_broken"
|
||
#: 运行结束的状态:封存是最后一次的,之后不会再换(除非失败的运行又被接着跑,那是另一次封存)
|
||
_FINISHED = frozenset({"succeeded", "failed", "cancelled"})
|
||
UNSEALED = "运行尚未结束封存,结束后才能导出"
|
||
#: 原样出现在记录页的错误提示里,导出按钮旁边就是「核对」。不拼封存核对的结论原句:那句自带冒号和「不一致」
|
||
SEAL_BROKEN = "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果"
|
||
|
||
|
||
@router.get("/{run_id}/bundle.html")
|
||
async def export_bundle(run_id: str) -> Response:
|
||
"""可验证报告的 HTML 文件,作为附件下载(附件名「<运行名>-可验证报告.html」)。"""
|
||
sealed = await _sealed(run_id)
|
||
if not sealed.sealed or sealed.status not in _FINISHED:
|
||
raise CodedHTTPException(409, UNSEALED, BUNDLE_UNSEALED)
|
||
if not sealed.trusted:
|
||
raise CodedHTTPException(409, SEAL_BROKEN, BUNDLE_SEAL_BROKEN)
|
||
bound = sealed.seal["manifest_seq"]
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
if run is None: # 两次读之间被删掉了
|
||
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
|
||
info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name,
|
||
"version": run.version, "version_hash": run.version_hash, "run_class": run.run_class,
|
||
"status": run.status, "end_reason": run.end_reason, "started_at": run.started_at,
|
||
"finished_at": run.finished_at}
|
||
# 封存之后追加的事件(按需裁判、审批质询):_sealed 只留了封存范围内的,这几条另取,单列在页面上
|
||
later = [tuple(r) for r in await session.execute(
|
||
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
|
||
.where(RunEvent.run_id == run_id, RunEvent.seq > bound).order_by(RunEvent.seq))]
|
||
# 数据源现在设的遮罩:事后加的遮罩同样生效,和证据面板一个道理
|
||
masks = await _mask_fingerprint()
|
||
# 每份查询快照里确定取自遮罩列的列、这次运行查到过的遮罩值:认老文档时指标的值给不给,和报告撰写节点的写作目录
|
||
# 同一个口径(SQL 认不准时不因此算遮罩列)。要查库、按 SQL 血缘认,在这里认好交进去
|
||
sure, secrets = await certain_masks(sealed, _Masks(sealed))
|
||
exported_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
# 读工件、算哈希、base64 是同步的活,快照大的时候不该占着事件循环
|
||
page = await asyncio.to_thread(lambda: render_html(build_bundle(
|
||
info, [*sealed.events, *later], manifest_seq=bound, manifest_hash=str(sealed.manifest_hash or ""),
|
||
legacy=bool(sealed.seal.get("legacy")), graph=sealed.graph, source_masks=masks,
|
||
sure_columns=sure, sure_secrets=secrets,
|
||
read=lambda artifact: read_bytes(artifact, verify=False), exported_at=exported_at)))
|
||
return Response(page, media_type="text/html; charset=utf-8", headers={
|
||
"Content-Disposition": content_disposition(attachment_name(info), run_id=run_id),
|
||
# 导出的是这一刻的封存件,不该被哪一层缓存下来、换给下一个请求
|
||
"Cache-Control": "no-store",
|
||
})
|