mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
可验证报告页面补全:列出参与清单核对的原文供页面外自行重算,写明运行结果,自查认法与服务端一致,打印时收起面板
- 封存清单一项可展开参与核对的每一行(序号|类型|节点|内容哈希),读的人不信页面脚本也能复制出去用别的工具算; check-bundle 在页面之外用 node 重算这段原文,等于封存哈希 - 元信息加「运行结果」(已完成 / 已失败 / 已取消):失败的运行报告照样导得出去,读的人要知道它没跑完 - 报告节点认最后一次记下了文档的自查,和服务端、证据接口同一个认法,后面多一条没记文档的自查不再误报不通过; 运行记录里有自查、文件里却没附带文档的报告照实报不通过 - 核对下不了结论(数据块坏了、内置 SHA-256 自检不过)时也标上核对结束,等结果的一方不必干等;打印时收起出处面板 - check-bundle:拿不到导出页面时,依赖它的几节跳过并注明原因,不再一个原因报五次
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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))]))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user