记录页封存凭证条加「导出可验证报告」:已结束且已封存的运行可下载,不可用时说明原因

- api.bundle.download 取文件和服务端给的文件名(优先 RFC 5987 的 filename*,没有再看 filename),下载照 EvidencePane 的写法
- 按钮只在运行结束且已封存时可用;运行中、等待审批、接着跑(留着旧封存)、没有封存清单时禁用,悬停和读屏都说明原因;
  服务端回 409(封存核对不一致等)时提示原因
- 文案进 BUNDLE_TEXT;预览用例 dev/fixtures/bundle.tsx(已封存、运行中、等待审批、未封存、接着跑)给 check-bundle 用
This commit is contained in:
ThearchyHelios
2026-10-05 03:58:13 +08:00
parent a0016493de
commit d85b634f75
4 changed files with 153 additions and 7 deletions
+40 -2
View File
@@ -1,7 +1,45 @@
/**
* 可验证报告的接口(/api/runs/{run_id}/…)。现在是空的,可验证报告的工作包往里加,挂在 api.bundle 上。
* 可验证报告的接口(/api/runs/{run_id}/…),挂在 api.bundle 上。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*/
export const bundle = {}
import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, failure, report } from './http'
/**
* Content-Disposition 里的文件名:先看 RFC 5987 的 filename*(中文名写在这里),没有或解不开再看 filename。
* 服务端给的 filename 是纯 ASCII 的退路(agentlab-<运行号>-bundle.html),只在老客户端那边派上用场
*/
export function dispositionName(header: string | null): string | null {
if (!header) return null
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1]
if (star) {
try {
return decodeURIComponent(star.trim().replace(/^"|"$/g, ''))
} catch {
// 百分号编码坏了:退回 filename
}
}
return /filename\s*=\s*"?([^";]+)"?/i.exec(header)?.[1]?.trim() || null
}
export const bundle = {
/**
* 下载可验证报告(GET /runs/{id}/bundle.html,附件):回来的是文件本身和服务端给的文件名,没给就是 null,
* 由调用方按运行名补。运行未封存、封存核对不一致时服务端回 409,ApiError 的 message 是给人看的原因
*/
download: async (runId: string): Promise<{ blob: Blob; name: string | null }> => {
const path = `/runs/${encodeURIComponent(runId)}/bundle.html`
let res: Response
try {
res = await fetch(BASE + path, { headers: actorHeader() })
} catch (e: any) {
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
}
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
report(true)
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
},
}
+53
View File
@@ -0,0 +1,53 @@
import type { JSX } from 'react'
import { ProvenanceBar } from '../../pages/runs/Telemetry'
import type { RunPhase } from '../../run/trace'
import type { Run } from '../../types'
/**
* 可验证报告的预览用例:记录页封存凭证条上的「导出可验证报告」。scripts/check-bundle.mjs 按 ?syn=<用例名>
* 打开,用 page.route 假装 /api/runs/{id}/bundle.html,不碰真实数据。
*
* - bundle-provenance-sealed:已封存,可以导出
* - bundle-provenance-running / -waiting:还在运行、等待审批,按钮不可用并说明原因
* - bundle-provenance-unsealed:结束了却没有封存清单(加封存之前的老运行)
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希)
*/
const SEALED: Run = {
id: 'f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0',
workflow_id: 'wf-bundle-sample',
workflow_name: '门店周报',
status: 'succeeded',
input: {},
output: {},
error: null,
usage: {},
run_class: 'formal',
version: 3,
version_hash: 'a1'.repeat(32),
manifest_hash: 'c3b5'.repeat(16),
manifest_seq: 59,
started_by: '值班分析员',
created_at: '2026-09-07T01:00:00Z',
started_at: '2026-09-07T01:00:01Z',
finished_at: '2026-09-07T01:02:03Z',
}
const unsealed = (status: Run['status']): Run => ({ ...SEALED, status, manifest_hash: null, manifest_seq: null, finished_at: null })
function Frame({ run, phase }: { run: Run; phase: RunPhase }) {
return (
<div className="mx-auto max-w-3xl p-4" data-fixture="bundle-provenance">
<div className="overflow-hidden rounded-lg border bg-panel">
<ProvenanceBar run={run} eventCount={25} phase={phase} />
</div>
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'bundle-provenance-sealed': () => <Frame run={SEALED} phase="succeeded" />,
'bundle-provenance-running': () => <Frame run={unsealed('running')} phase="running" />,
'bundle-provenance-waiting': () => <Frame run={unsealed('interrupted')} phase="waiting" />,
'bundle-provenance-unsealed': () => <Frame run={{ ...unsealed('succeeded'), finished_at: SEALED.finished_at }} phase="succeeded" />,
'bundle-provenance-again': () => <Frame run={{ ...SEALED, status: 'running', finished_at: null }} phase="running" />,
}
+13 -1
View File
@@ -2748,9 +2748,21 @@ export const RUN_COMPARE_TEXT = {
pendingHint: '运行对比尚未开放,完成后可从记录页选择两次运行进行对照',
}
/** 可验证报告:导出可独立核对的报告 */
/**
* 可验证报告:把一次已封存运行的报告导出成一个 HTML 文件,收件人不连服务也能在浏览器里核对。
* 入口在记录页的封存凭证条上;页面本身的文案在服务端 bundle/verifier.js 里(它是离开系统独立打开的文件)
*/
export const BUNDLE_TEXT = {
title: '可验证报告',
export: '导出可验证报告',
hint: '导出为单个 HTML 文件:收件人不连服务,也能在浏览器中核对数字的出处和运行记录',
/** 不能导出时按钮旁说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
blockedRunning: '运行结束并封存后才能导出',
blockedWaiting: '等待审批中,运行结束并封存后才能导出',
blockedUnsealed: '这次运行没有封存清单,无法导出',
exported: (name: string) => `已导出「${name}」`,
/** 服务端没给文件名时按运行名补,规则同服务端的附件名 */
fileName: (name: string) => `${name}-可验证报告.html`,
}
/** 审批质询:审批人在批准前就待审的报告提出质询 */
+47 -4
View File
@@ -1,8 +1,8 @@
import { useState, type ReactNode } from 'react'
import { ChevronDown, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { ChevronDown, Download, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import { StatusPill, toast, useTicker } from '../../components/ui'
import { Spinner, StatusPill, toast, useTicker } from '../../components/ui'
import {
NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId,
} from '../../lib/format'
@@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status'
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
import { useRunClock } from '../../run/useRunClock'
import type { Approval, Run } from '../../types'
import { COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
import { BUNDLE_TEXT, COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
import {
LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming,
} from './model'
@@ -257,6 +257,8 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
const [verifying, setVerifying] = useState(false)
const [fresh, setFresh] = useState(false)
const [open, setOpen] = useState(false)
const [exporting, setExporting] = useState(false)
const blockedId = useId()
const sealed = !!run.manifest_hash
const doVerify = async () => {
@@ -281,6 +283,28 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
? '等待审批中,运行结束后封存'
: '本次运行没有封存清单'
// 可验证报告只给结束了、封存了的运行:没封存的事件还会变;失败后接着跑的运行留着上一次的封存,跑完会换掉清单
// 哈希,导出去的文件就和系统对不上了(规则同服务端 api/bundle.py)。封存核对不一致由服务端回 409,提示里照实说
const finished = phase === 'succeeded' || phase === 'failed' || phase === 'cancelled'
const exportBlocked = sealed && finished ? null
: phase === 'waiting' ? BUNDLE_TEXT.blockedWaiting
: !finished ? BUNDLE_TEXT.blockedRunning
: BUNDLE_TEXT.blockedUnsealed
const doExport = async () => {
setExporting(true)
try {
const { blob, name } = await api.bundle.download(run.id)
const file = name ?? BUNDLE_TEXT.fileName(runName(run))
saveFile(file, blob)
toast.ok(BUNDLE_TEXT.exported(file))
} catch (e) {
toast.error(e, { key: `bundle:${run.id}` })
} finally {
setExporting(false)
}
}
// 扫描线的时长按事件条数估:重算的就是这些事件
const scanMs = Math.min(2400, Math.max(600, eventCount * 0.8))
@@ -321,6 +345,15 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
{verify ? '重新核对' : '核对'}
</button>
)}
{/* 禁用的按钮收不到指针事件,悬停提示挂在外面那层上;读屏经 aria-describedby 读到原因 */}
<span className="inline-flex" title={exportBlocked ?? BUNDLE_TEXT.hint}>
<button type="button" className="btn btn-xs btn-ghost" disabled={!!exportBlocked || exporting}
onClick={() => void doExport()} aria-describedby={exportBlocked ? blockedId : undefined}
data-bundle-export={exportBlocked ? 'blocked' : exporting ? 'busy' : 'ready'}>
{exporting ? <Spinner size={10} /> : <Download size={11} aria-hidden />} {BUNDLE_TEXT.export}
</button>
</span>
{exportBlocked && <span id={blockedId} className="sr-only" data-bundle-blocked="">{exportBlocked}</span>}
<button
type="button"
className="btn btn-xs btn-ghost"
@@ -339,6 +372,16 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
)
}
/** 存成文件:同 EvidencePane 的下载写法(对象 URL 交给一个临时的链接点一下,稍后回收) */
function saveFile(name: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function VerifyState({ verify, verifying, fresh }: { verify: VerifyResult | null; verifying: boolean; fresh: boolean }) {
if (verifying) {
return <span className="text-dim" role="status" data-verify="running">核对中…</span>