可验证报告页面补全:列出参与清单核对的原文供页面外自行重算,写明运行结果,自查认法与服务端一致,打印时收起面板

- 封存清单一项可展开参与核对的每一行(序号|类型|节点|内容哈希),读的人不信页面脚本也能复制出去用别的工具算;
  check-bundle 在页面之外用 node 重算这段原文,等于封存哈希
- 元信息加「运行结果」(已完成 / 已失败 / 已取消):失败的运行报告照样导得出去,读的人要知道它没跑完
- 报告节点认最后一次记下了文档的自查,和服务端、证据接口同一个认法,后面多一条没记文档的自查不再误报不通过;
  运行记录里有自查、文件里却没附带文档的报告照实报不通过
- 核对下不了结论(数据块坏了、内置 SHA-256 自检不过)时也标上核对结束,等结果的一方不必干等;打印时收起出处面板
- check-bundle:拿不到导出页面时,依赖它的几节跳过并注明原因,不再一个原因报五次
This commit is contained in:
ThearchyHelios
2026-10-05 04:04:06 +08:00
parent a651a2e7d5
commit 183814d2c1
3 changed files with 36 additions and 11 deletions
+5
View File
@@ -112,6 +112,11 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
.noscript { max-width: 920px; margin: 24px auto; padding: 0 16px; color: var(--bad); }
@media print {
.detail, .close, details > summary { display: none !important; }
.page { max-width: none; padding: 0; }
.num { text-decoration-color: currentColor !important; background: none !important; }
}
@media (max-width: 760px) {
.detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; }
}
+12 -4
View File
@@ -30,6 +30,7 @@
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' }
const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' }
const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' }
const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' }
const EVENT_LABEL = {
'run.started': '运行开始', 'run.finished': '运行结束', 'run.failed': '运行失败', 'run.cancelled': '运行取消',
'run.interrupted': '等待人工介入', 'run.resumed': '恢复运行', 'node.started': '节点开始', 'node.finished': '节点完成',
@@ -53,7 +54,8 @@
+ '它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID '
+ `${id}),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致`
+ `(接口 GET /api/runs/${id}/verify)。`,
metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaVersionHash: '版本哈希', metaStarted: '开始',
metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaStatus: '运行结果', metaVersionHash: '版本哈希',
metaStarted: '开始',
metaFinished: '结束', metaRange: '封存范围', metaTier: '出具档位', metaExported: '导出时间',
// 序号不连续(模型的流式输出占序号、不落库),后面注明范围内实际有几条。说法同记录页的封存凭证
range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`,
@@ -76,6 +78,7 @@
eventMismatch: '内容与记录的哈希不一致',
eventNotJson: '内容不是有效的 JSON',
manifestComputed: '重算结果',
manifestLines: (n) => `查看参与清单核对的 ${n} 行(每行 序号|类型|节点|内容哈希,可复制后用任意 SHA-256 工具自行计算)`,
manifestOk: '与封存清单哈希一致',
manifestMismatch: '与封存清单哈希不一致:事件被增删、改动或换了顺序',
beyondSeal: (seq) => `第 ${seq} 条事件的序号超出封存范围`,
@@ -344,7 +347,7 @@
const computed = await digest(utf8(lines.join('\n')))
const beyond = order.find((ev) => !(ev.seq <= meta.manifest_seq))
out.manifest = {
computed, count: lines.length,
computed, count: lines.length, lines,
result: computed === meta.manifest_hash && !beyond ? PASS : FAIL,
reason: computed !== meta.manifest_hash ? T.manifestMismatch : beyond ? T.beyondSeal(beyond.seq) : T.manifestOk,
}
@@ -814,6 +817,7 @@
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaWorkflow, String(meta.workflow_name || '—')],
[T.metaClass, meta.version != null ? `${runClass} v${meta.version}` : runClass],
meta.status ? [T.metaStatus, STATUS_LABEL[meta.status] || String(meta.status)] : null,
meta.version_hash ? [T.metaVersionHash, h('code', {}, String(meta.version_hash))] : null,
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
@@ -868,8 +872,12 @@
root.append(checkBlock('events', T.checkEvents, T.sumEvents(out.events.length), out.events,
(i) => `${T.chainByEvent(i.ev.seq, eventWhat(i.ev))}`))
const m = out.manifest
root.append(checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m],
() => [T.manifestComputed, ' ', h('code', {}, m.computed)]))
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m],
() => [T.manifestComputed, ' ', h('code', {}, m.computed)])
// 参与核对的原文照拼法列出来:读的人不信这一页的脚本,也能复制出去用别的工具算一遍
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(m.count)),
h('pre', { class: 'sql', 'data-manifest-lines': '' }, m.lines.join('\n'))))
root.append(manifest)
root.append(checkBlock('artifacts', T.checkArtifacts, T.sumCount(out.artifacts.length, '份'), out.artifacts,
(i) => [KIND_LABEL[i.a.kind] || i.a.kind, i.a.node_id ? ` · ${nodeLabel(i.a.node_id)}` : '', ' · ',
h('code', {}, short(i.a.id))]))
+19 -7
View File
@@ -171,9 +171,11 @@ await section('导出', async () => {
`运行 ${run.id.slice(0, 8)},${picked.reports} 份报告、${picked.cited} 个带出处的数字`)
const before = await getJson(`/runs/${run.id}/events`).catch(() => null)
const resp = await fetch(`${API}/runs/${run.id}/bundle.html`)
html = await resp.text()
check('导出接口返回 200 和 text/html', resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? ''),
`HTTP ${resp.status},${Math.round(Buffer.byteLength(html) / 1024)} KB`)
const body = await resp.text()
const ok = resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? '')
check('导出接口返回 200 和 text/html', ok, `HTTP ${resp.status},${Math.round(Buffer.byteLength(body) / 1024)} KB`)
// 只有真拿到了页面,后面几节才有东西可查;没拿到的原因上面已经报了,后面几节跳过,不重复报
html = ok && DATA_RE.test(body) ? body : ''
const disposition = resp.headers.get('content-disposition') ?? ''
const star = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1]
let name = ''
@@ -206,8 +208,14 @@ await section('导出', async () => {
let tamperable = null // 篡改要用:原始数据
const skipped = () => {
if (html) return false
note('没有拿到导出的页面(原因见上一节),这一节跳过')
return true
}
await section('断网打开', async () => {
if (!html) throw new Error('上一节没有拿到导出的页面')
if (skipped()) return
tamperable = dataOf(html)
const { ctx, page, requests, errors } = await openFile(writePage('bundle.html', html))
try {
@@ -222,6 +230,10 @@ await section('断网打开', async () => {
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha))
check('封存哈希醒目地写在页面上', await page.locator('#seal-hash').innerText() === tamperable.meta.manifest_hash)
// 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本
const lines = await page.locator('[data-manifest-lines]').textContent()
check('页面列出的清单原文在页面之外重算,等于封存哈希',
createHash('sha256').update(lines ?? '', 'utf8').digest('hex') === tamperable.meta.manifest_hash)
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(await page.locator('#seal-note').innerText())
&& /\/api\/runs\/[^/]+\/verify/.test(await page.locator('#seal-note').innerText()))
if (s.numbers) {
@@ -244,7 +256,7 @@ await section('断网打开', async () => {
})
await section('去掉 crypto.subtle', async () => {
if (!html) throw new Error('没有导出的页面')
if (skipped()) return
const { ctx, page, requests, errors } = await openFile(join(TMP, 'bundle.html'), { noSubtle: true })
try {
const s = await stateOf(page)
@@ -278,7 +290,7 @@ async function tampered(label, mutate, expectFail, keepPass = []) {
}
await section('篡改', async () => {
if (!tamperable) throw new Error('没有导出的页面')
if (skipped() || !tamperable) return
// ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上
await tampered('一条事件的内容', (data) => {
const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string')
@@ -351,7 +363,7 @@ const luminance = (rgb) => {
}
await section('深浅色与窄屏', async () => {
if (!html) throw new Error('没有导出的页面')
if (skipped()) return
for (const colorScheme of ['light', 'dark']) {
const { ctx, page } = await openFile(join(TMP, 'bundle.html'), { colorScheme, width: 390 })
try {