mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
e7c8d2a1f3
- 页面核对加 ⑥ 运行信息:运行结果看封存范围内最后一条结束类事件的类型,运行类别看运行开始事件记下的运行参数, 出具档位认最后一条出具判定(不再信元信息指的那一条);页面显示事件里的值,元信息写得不一样算不通过、计入总览。 工作流名、版本、起止时间、节点名称绑不进封存,单列一组写明取自运行记录,封存说明里也写明要到系统里一并对照 - 报告标题旁的徽标连同正文里的数字一起算,旁边写分项:重算全部哈希的伪造只在 ⑤ 露馅,徽标不能还写着通过。 分块条目多时不通过和无法核对的都列在外面,有无法核对的显示「部分无法核对」和分项计数;⑤ 改叫「数字与取值」并分开数, 读屏分别读作数字、取值,「显示格式由服务端渲染」只附在带出处的数字上;无出处数字的说法改成未使用引用标记 - 服务端不会给出的状态算不通过:被引用的工件不在清单里、非查询快照标着遮罩、工件既没内容也没说明;报告文档导出时 已不在服务端,总览不再是绿色的通过,写「有 N 份报告未能核对」,报告处写明是服务端已没有这份文档 - 认 json.dumps 写出的裸 NaN / Infinity,没被改过的文件不再误判不通过;哈希对得上但读不懂的照实写「无法核对」, 和哈希不一致分开说 - 宽屏打开出处面板时正文让出右边一栏,表格里右对齐的数字不再被面板盖住;补上斜体(不用后行断言,较早的 Safari 也能执行) - 封存哈希、运行 ID、工作流名直接写进页面,不执行脚本时写「尚未核对」而不是一直「正在核对」;脚本挪进 head, 执行后核一遍页面顶部写出的封存哈希和数据里核对用的一致 - 成果节点和运行结束事件的规范字符串,只在封存范围内每次数据源查询都对得上无遮罩的快照(或确实没交回数据)时才给: 升级前的运行从工具快照认出查询快照,协作团队成员的查询认不出就按带遮罩算,数据源现在设了遮罩的按工具名就挡 - 封存核对不一致的 409 改成一句固定的话,不再叠冒号、让人去本来就在的记录页;失败结束的运行在页面封存说明和导出提示里 写明之后继续运行会重新封存;记录页上左边写着「已封存」却不能导出时,原因写在屏幕上 - tests/bundle_samples.py 用虚构数据真实生成九份样本页(遮罩快照、两份报告、早期运行、失败结束、两条出具判定、 服务端缺文件、裸 NaN、别的运行的文档),check-bundle 断网打开逐项断言,在上面篡改引用链、运行信息、拿掉被引用的口径卡, 量宽屏面板,看不执行脚本的样子;把核对改成恒通过、徽标不看数字等七种改坏法都会变红。pytest 补遮罩判定的三种形状、 事后设遮罩(去掉接线即变红)、页面静态内容和样本页本身
74 lines
4.7 KiB
Python
74 lines
4.7 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, _sealed
|
||
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, "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()
|
||
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,
|
||
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",
|
||
})
|