fix(library): 维护卡只展示 owner_job_id 匹配的作业详情
build / build (push) Successful in 1m45s

问题:维护窗口 record.owner_job_id 指向失败/待恢复的作业 A,但页面把同 scope
最新作业(更新的成功封存 B)当窗口进度,needs_restore 被显示成“导出 · 已完成”,
需要处理的原窗口因此看起来已经结束。

改法:
- maintenanceSummary 只接受“按 owner_job_id 读到的持有作业”,id 不一致一律按
  未读取处理;进度/错误/恢复说明只来自该作业,不拿最新作业兜底。
- 新增 fetchLibraryJobDetail 读取既有只读接口 GET /api/v1/library/jobs/{id},
  明确区分 loaded / missing(404) / error(非2xx、结构不符、id 串包) / unread,
  不把读取失败吞成“不存在”。
- 恢复可用性只由“维护仍持有 + 本窗口作业未收口 + 当前 scope 等于 owner_scope”
  决定;running/pending 不并发恢复,已释放/无记录不提供恢复,跨 scope 给切换提示。
- 入口状态明确标注“维护记录,未实时探测”;held 时说明只有维护记录释放才算结束;
  held 但持有作业已完成时写“维护记录未对账”,不提供强制成功。
- load 增加请求代次守卫,晚回包不覆盖更新的窗口;作业详情返回的 id 与请求不符
  按读取失败处理。

未改后端准入、网关协议或任何维护记录;未部署。
This commit is contained in:
姚章浩
2026-11-03 23:02:00 +08:00
parent bd452e85b3
commit 017765763a
4 changed files with 545 additions and 27 deletions
+117 -2
View File
@@ -104,6 +104,11 @@ const json = (body: unknown, status: number) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
let overviews: Record<Scope, Reply>
// 维护窗口与持有作业详情:默认没有窗口;需要时按测试覆盖。
let maintenanceBody: Record<string, unknown> = {}
let jobDetails: Record<string, unknown> = {}
let jobDetailFailures: Record<string, number> = {}
let recoveryCalls: { scope: string; window_job_id: string }[] = []
let container: HTMLDivElement
let root: Root
let originalFetch: typeof fetch
@@ -151,11 +156,27 @@ beforeEach(() => {
internal: reply(overviewBody('internal', emptyTarget)),
external: reply(overviewBody('external', unreadableTarget)),
}
globalThis.fetch = (async (input: RequestInfo | URL) => {
maintenanceBody = {}
jobDetails = {}
jobDetailFailures = {}
recoveryCalls = []
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.startsWith('/api/v1/library/snapshots')) return json(snapshotsBody, 200)
if (url.startsWith('/api/v1/library/captures')) return json({ jobs: [] }, 200)
if (url.startsWith('/api/v1/library/maintenance')) return json({}, 200)
if (url.startsWith('/api/v1/library/maintenance/recover')) {
const body = JSON.parse(String(init?.body ?? '{}')) as { scope: string; window_job_id: string }
recoveryCalls.push(body)
return json({ accepted: true, recovery_job: 'q-recover' }, 200)
}
if (url.startsWith('/api/v1/library/maintenance')) return json(maintenanceBody, 200)
if (url.startsWith('/api/v1/library/jobs/')) {
const id = decodeURIComponent(url.slice('/api/v1/library/jobs/'.length))
const status = jobDetailFailures[id]
if (status) return json({ error: '作业详情读取失败' }, status)
const job = jobDetails[id]
return job ? json({ available: true, job }, 200) : json({ available: false, reason: '作业不存在' }, 404)
}
if (url.startsWith('/api/v1/library/overview')) {
const scope = new URL(url, 'http://candidate.invalid').searchParams.get('scope')
const answer = overviews[scope === 'external' ? 'external' : 'internal']
@@ -264,3 +285,97 @@ describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
})
})
// 真实事故复现(2026-11-03 22:48 CST):needs_restore 的持有作业 A 被同 scope 较新的
// 成功封存 B 覆盖,页面写成“导出 · 已完成”。页面级必须按 owner_job_id 读作业,
// 并且恢复只投递到持有 scope 的那一个窗口。
describe('维护窗口按持有作业展示(页面级)', () => {
const ownerA = {
id: 'libverify-internal-A',
scope: 'internal',
kind: 'verify',
state: 'failed',
stage: 'verify',
request_digest: 'v',
queue_job_id: 'qv',
snapshot_id: '',
target_snapshot_id: '',
actor: 'admin',
error: '恢复验证失败:canary 校验不一致',
recovery: '预备份保留;按归档人工核验',
created_at: '2026-11-03T09:20:00Z',
updated_at: '2026-11-03T09:24:00Z',
finished_at: '2026-11-03T09:24:00Z',
stages: [],
}
const sealB = {
...ownerA,
id: 'libseal-lib-internal-20261103T092352',
kind: 'seal',
state: 'succeeded',
stage: 'export',
error: '',
recovery: '',
finished_at: '2026-11-03T09:23:52Z',
}
const instanceHeld = {
held: true,
instance: 'kgmaint-weknora',
instance_maintenance: {
instance: 'kgmaint-weknora',
owner_scope: 'internal',
scope: 'internal',
owner_job_id: 'libverify-internal-A',
level: 'instance',
services: ['app'],
ingress_state: 'closed',
state: 'needs_restore',
acquired_at: '2026-11-03T09:20:00Z',
updated_at: '2026-11-03T09:24:00Z',
released_at: null,
},
}
it('较新封存 B 成功不覆盖 needs_restore 窗口,恢复按 A 投递到持有 scope', async () => {
maintenanceBody = instanceHeld
jobDetails = { 'libverify-internal-A': ownerA }
overviews.internal = reply({ ...overviewBody('internal', emptyTarget), job: sealB })
await mount()
expect(text()).toContain('libverify-internal-A')
expect(text()).toContain('canary 校验不一致')
expect(text()).toContain('维护记录,未实时探测')
expect(text()).not.toContain('导出 · 已完成')
await click('按预备份恢复')
expect(recoveryCalls).toEqual([{ scope: 'internal', window_job_id: 'libverify-internal-A' }])
})
it('持有作业详情 5xx 时明确“读取失败”,不显示 B 的进度也不给强制成功', async () => {
maintenanceBody = instanceHeld
jobDetailFailures = { 'libverify-internal-A': 503 }
overviews.internal = reply({ ...overviewBody('internal', emptyTarget), job: sealB })
await mount()
expect(text()).toContain('持有作业状态读取失败')
expect(text()).toContain('HTTP 503')
expect(text()).not.toContain('导出 · 已完成')
expect(text()).not.toContain('强制成功')
})
it('持有作业 404 时写“不存在”,不退回最新作业', async () => {
maintenanceBody = instanceHeld
overviews.internal = reply({ ...overviewBody('internal', emptyTarget), job: sealB })
await mount()
expect(text()).toContain('不存在(维护记录引用了未登记的作业)')
expect(text()).not.toContain('导出 · 已完成')
})
it('切到另一个 scope 不向错误 scope 投递恢复', async () => {
maintenanceBody = instanceHeld
jobDetails = { 'libverify-internal-A': ownerA }
await mount()
await click('对外知识库')
expect(text()).toContain('维护窗口属于 internal')
expect(buttonLabels()).not.toContain('按预备份恢复')
expect(recoveryCalls).toEqual([])
})
})
+117 -16
View File
@@ -9,13 +9,16 @@ import {
enqueueLibraryRestore,
enqueueLibrarySync,
fetchLibraryCaptures,
fetchLibraryJobDetail,
fetchLibraryMaintenance,
maintenanceOwnerJobID,
fetchLibraryOverview,
fetchLibraryRestoreAvailability,
fetchLibrarySnapshots,
formatBytes,
scopeLabel,
type LibraryCaptureJob,
type LibraryJobLookup,
type LibraryJobRecord,
type LibraryMaintenanceView,
type LibraryOverview,
@@ -133,6 +136,8 @@ interface ViewProps {
onRestore: () => void
overview: LibraryOverview | null
maintenance: LibraryMaintenanceView | null
// 维护记录里 owner_job_id 对应的作业详情;缺失就是“未读取”,不能用 overview.job 顶替。
maintenanceOwnerJob?: LibraryJobLookup
now: number
onRefresh: () => void
onCapture: () => void
@@ -343,6 +348,10 @@ export interface MaintenanceSummary {
// 作业失败但维护窗口已释放:只保留失败记录,不能再说"目标保持维护"。
releasedFailure: string
recoveryNote: string
// 窗口未释放但持有作业已经是终态成功:对账缺口,页面不提供强制成功。
unreconciledNote: string
// 实例级窗口属于另一个 scope:不在此投递恢复,提示切回持有 scope。
scopeNote: string
canRecover: boolean
}
@@ -365,9 +374,22 @@ const stageLabel: Record<string, string> = {
const ingressText = (state?: string) =>
state === 'closed' ? '已关闭(排空后等待写入)' : state === 'open' ? '打开' : state ? state : '未知'
// 持有作业详情没读到时只写未读取/未知,绝不拿同 scope 最新作业顶替。
const ownerJobText = (lookup: LibraryJobLookup | undefined, ownerJobID: string) => {
const id = ownerJobID || '—'
if (lookup?.state === 'error') return `持有作业状态读取失败:${lookup.reason}(不使用其他作业推断)`
if (lookup?.state === 'missing') return `持有作业 ${id} 不存在(维护记录引用了未登记的作业)`
if (lookup?.state === 'loaded')
return `持有作业状态未读取/未知(维护记录为 ${id},读取到的是 ${lookup.job.id},不采用)`
return `持有作业状态未读取/未知(owner_job_id=${id};不使用最新作业代替)`
}
// 维护卡只消费“维护记录里的持有作业”。同 scope 最新作业可能是一次无关封存:
// 拿它当窗口进度会把 needs_restore 显示成“导出 · 已完成”(真实事故 2026-11-03)。
export function maintenanceSummary(
maintenance: LibraryMaintenanceView | null | undefined,
job: LibraryJobRecord | undefined
ownerJob?: LibraryJobLookup,
scope?: string
): MaintenanceSummary {
const record = maintenance?.instance_maintenance ?? maintenance?.maintenance ?? null
const level = !record
@@ -378,38 +400,64 @@ export function maintenanceSummary(
? '库级'
: record.level || '未知'
const held = Boolean(maintenance?.held)
const failed = job?.state === 'failed'
const ownerScope = maintenance?.instance_maintenance?.owner_scope ?? record?.scope ?? '—'
const ownerScope = maintenance?.instance_maintenance?.owner_scope ?? record?.scope ?? ''
const ownerJobID = record?.owner_job_id ?? ''
// 只有 id 与维护记录一致的作业才是本窗口的进度;其余按“未读取”处理。
const owner = ownerJob?.state === 'loaded' && ownerJob.job.id === ownerJobID ? ownerJob.job : undefined
const failed = owner?.state === 'failed'
const progress = owner
? `${stageLabel[owner.stage] ?? owner.stage ?? '—'} · ${jobStateText(owner)}`
: record
? ownerJobText(ownerJob, ownerJobID)
: '尚无作业进度'
// 实例级窗口跨 scope 共用:只有持有 scope 才能投递恢复,否则后端必然 409。
const sameScope = !scope || !ownerScope || ownerScope === scope
const scopeNote =
held && !sameScope
? `维护窗口属于 ${ownerScope},当前查看 ${scope}:页面不向另一个 scope 投递恢复,请切回 ${ownerScope} 再处理`
: ''
// 恢复只由“维护仍持有 + 本窗口作业未收口”决定,与无关的最新作业无关。
const unsettled = owner
? owner.state === 'failed' || owner.state === 'cancelled'
: record?.state === 'needs_restore' || record?.state === 'failed' || record?.state === 'held'
return {
level,
held,
owner: record ? `${ownerScope} · ${record.owner_job_id || '—'}` : '—',
owner: record ? `${ownerScope || '—'} · ${record.owner_job_id || '—'}` : '—',
state: record?.state || '—',
ingress: record ? ingressText(record.ingress_state) : '—',
services: (record?.services ?? []).join('、') || '—',
acquiredAt: record?.acquired_at ? stamp(record.acquired_at) : '—',
releasedAt: record?.released_at ? stamp(record.released_at) : '',
progress: job ? `${stageLabel[job.stage] ?? job.stage ?? '—'} · ${jobStateText(job)}` : '尚无作业进度',
progress,
// 只有仍然 held 的失败才配 held 处置说明;窗口已释放时那段文案是假的。
recoveryError: failed && held ? job?.error || '作业失败但没有留下错误明细' : '',
releasedFailure: failed && !held ? job?.error || '作业失败但没有留下错误明细' : '',
recoveryNote: job?.recovery || '',
canRecover: held && (failed || !job || job.state === 'cancelled'),
recoveryError: failed && held ? owner?.error || '作业失败但没有留下错误明细' : '',
releasedFailure: failed && !held ? owner?.error || '作业失败但没有留下错误明细' : '',
recoveryNote: owner?.recovery || '',
unreconciledNote:
held && owner && owner.state === 'succeeded'
? '持有作业已完成但维护窗口仍未释放:维护记录与作业终态未对账,页面不提供强制成功,需按窗口记录人工核验'
: '',
scopeNote,
canRecover: held && Boolean(record) && sameScope && Boolean(unsettled),
}
}
export function MaintenanceCard({
maintenance,
job,
ownerJob,
scope,
canWrite,
onRecover,
}: {
maintenance: LibraryMaintenanceView | null | undefined
job: LibraryJobRecord | undefined
// 只收“按 owner_job_id 读到的持有作业”,不是 scope 的最新作业。
ownerJob?: LibraryJobLookup
scope?: string
canWrite: boolean
onRecover: (windowJobID: string) => void
}) {
const summary = maintenanceSummary(maintenance, job)
const summary = maintenanceSummary(maintenance, ownerJob, scope)
const record = maintenance?.instance_maintenance ?? maintenance?.maintenance
// "没有窗口记录"不等于"没有维护能力":能力是配置事实,接口单独返回。
// 接口没给能力信息时也不能猜,明确写"未知"。
@@ -463,7 +511,11 @@ export function MaintenanceCard({
<b>{summary.state}</b>
</li>
<li>
<span>入口 {summary.ingress}</span>
<span>入口(维护记录,未实时探测)</span>
<b>{summary.ingress}</b>
</li>
<li>
<span>服务</span>
<b>{summary.services}</b>
</li>
<li>
@@ -479,12 +531,27 @@ export function MaintenanceCard({
进度:{summary.progress}
{summary.releasedAt ? ` · 窗口已于 ${summary.releasedAt} 释放` : ''}
</p>
{summary.scopeNote ? <p className="rel-scope-note">{summary.scopeNote}</p> : null}
{summary.held ? (
<p className="rel-scope-note">
窗口仍被持有:只有维护记录本身释放才算结束,其他作业的完成或失败不改变本窗口。
</p>
) : null}
{summary.unreconciledNote ? (
<div className="rel-scope-note">
<strong>维护记录未对账</strong>
<p>{summary.unreconciledNote}</p>
</div>
) : null}
{summary.releasedFailure ? (
<div className="rel-scope-note">
<strong>作业失败(维护窗口已释放)</strong>
<p>{summary.releasedFailure}</p>
{summary.recoveryNote ? <p>处理记录:{summary.recoveryNote}</p> : null}
<p>窗口已释放、入口{summary.ingress}:这里是历史失败记录,不再按 held 处置,也不提供强制成功。</p>
<p>
窗口已释放,维护记录中的入口状态:{summary.ingress}(未实时探测):这里是历史失败记录,不再按 held
处置,也不提供强制成功。
</p>
</div>
) : null}
{summary.recoveryError ? (
@@ -1444,6 +1511,7 @@ export function ReleaseTargetsView(props: ViewProps) {
onRestore,
now,
maintenance,
maintenanceOwnerJob,
onRefresh,
onCapture,
onRecover,
@@ -1642,7 +1710,13 @@ export function ReleaseTargetsView(props: ViewProps) {
<p className="rel-footnote">启动后先固定测试版本,再备份生产。备份失败或恢复条件不满足,不进入生产写入。</p>
</div>
<MaintenanceCard maintenance={maintenance} job={view.job} canWrite={canWrite} onRecover={onRecover} />
<MaintenanceCard
maintenance={maintenance}
ownerJob={maintenanceOwnerJob}
scope={scope}
canWrite={canWrite}
onRecover={onRecover}
/>
{active ? (
<ProgressBody view={view} now={now} onOpen={() => onDrawer(true)} />
@@ -1705,6 +1779,13 @@ export function ReleaseTargets() {
internal: null,
external: null,
})
// 维护窗口持有作业的详情:按 owner_job_id 单独读,和 scope 最新作业无关。
const [maintenanceJobs, setMaintenanceJobs] = useState<Record<Scope, LibraryJobLookup>>({
internal: { state: 'unread' },
external: { state: 'unread' },
})
// 轮询会并发多次 load:晚到的旧响应不许覆盖更新的维护窗口/作业。
const loadSeq = useRef(0)
const [scope, setScope] = useState<Scope>('internal')
const [drawerOpen, setDrawerOpen] = useState(false)
const [expandedSnapshot, setExpandedSnapshot] = useState<string | null>(null)
@@ -1712,6 +1793,7 @@ export function ReleaseTargets() {
const [now, setNow] = useState(() => Date.now())
const load = useCallback(async (signal?: AbortSignal) => {
const seq = ++loadSeq.current
setLoading(true)
try {
const [snapshots, captures, internalOverview, externalOverview, internalMaintenance, externalMaintenance] =
@@ -1723,17 +1805,35 @@ export function ReleaseTargets() {
fetchLibraryMaintenance('internal', signal),
fetchLibraryMaintenance('external', signal),
])
// 只读既有作业详情接口,按维护记录的 owner_job_id 精确取;读不到就记下来,
// 不用 overview 的最新作业兜底(那是另一件事,会把失败窗口显示成已完成)。
const ownerIDs = {
internal: maintenanceOwnerJobID(internalMaintenance),
external: maintenanceOwnerJobID(externalMaintenance),
}
const [internalOwnerJob, externalOwnerJob] = await Promise.all([
ownerIDs.internal
? fetchLibraryJobDetail(ownerIDs.internal, signal)
: Promise.resolve<LibraryJobLookup>({ state: 'unread' }),
ownerIDs.external
? fetchLibraryJobDetail(ownerIDs.external, signal)
: Promise.resolve<LibraryJobLookup>({ state: 'unread' }),
])
// 晚回包直接丢掉:不覆盖更新的窗口,也不把旧窗口的作业挂到新窗口上。
if (seq !== loadSeq.current) return
setPage(snapshots)
setJobs(captures)
setOverviews({ internal: internalOverview, external: externalOverview })
setMaintenances({ internal: internalMaintenance, external: externalMaintenance })
setMaintenanceJobs({ internal: internalOwnerJob, external: externalOwnerJob })
setNow(Date.now())
setError(null)
} catch (loadError) {
if ((loadError as Error).name === 'AbortError') return
if (seq !== loadSeq.current) return
setError(loadError instanceof Error ? loadError.message : '加载发布状态失败')
} finally {
setLoading(false)
if (seq === loadSeq.current) setLoading(false)
}
}, [])
@@ -2001,6 +2101,7 @@ export function ReleaseTargets() {
onGrantRestore={() => void grantRestore()}
onRestore={() => void startRestore()}
maintenance={maintenances[scope]}
maintenanceOwnerJob={maintenanceJobs[scope]}
now={now}
onRefresh={() => void load()}
onCapture={() => void startCapture()}
+50
View File
@@ -531,6 +531,56 @@ export async function fetchLibraryMaintenance(
return value as unknown as LibraryMaintenanceView
}
/**
* 持有维护窗口的作业 id:实例级记录优先(它才是真正的互斥来源),库级记录兜底。
* 页面只能按这个 id 去读作业详情 —— 同 scope 的最新作业可能是一次无关的封存。
*/
export function maintenanceOwnerJobID(view: LibraryMaintenanceView | null | undefined): string {
const record = view?.instance_maintenance ?? view?.maintenance ?? null
return record?.owner_job_id ?? ''
}
/**
* 持有作业详情的读取结果。失败必须分类,不能都缩成"不存在":
* - missing:接口明确 404 ⇒ 维护记录引用了一个未登记的作业;
* - error:网络/非 2xx/结构不符 ⇒ 只是"没读到",页面只能照实说未知;
* - unread:还没发出读取(例如刚切 scope)。
* 把 error 当 missing 会让"读不到"看起来像一条库内结论。
*/
export type LibraryJobLookup =
| { state: 'loaded'; job: LibraryJobRecord }
| { state: 'missing' }
| { state: 'error'; reason: string }
| { state: 'unread' }
export async function fetchLibraryJobDetail(id: string, signal?: AbortSignal): Promise<LibraryJobLookup> {
if (!id) return { state: 'unread' }
const response = await fetch(`/api/v1/library/jobs/${encodeURIComponent(id)}`, {
headers: { Accept: 'application/json' },
signal,
}).catch((error: unknown) => {
if ((error as Error)?.name === 'AbortError') throw error
return null
})
if (!response) return { state: 'error', reason: '请求失败' }
if (response.status === 404) return { state: 'missing' }
if (!response.ok) return { state: 'error', reason: `HTTP ${response.status}` }
const value = (await response.json().catch(() => null)) as unknown
const job = record(value) ? value.job : null
if (
!record(job) ||
typeof job.id !== 'string' ||
job.id === '' ||
typeof job.state !== 'string' ||
typeof job.scope !== 'string'
) {
return { state: 'error', reason: '响应结构不符合预期(缺少 job 的 id/state/scope)' }
}
// 身份不符说明拿到的不是这次请求的作业(晚回包/串包):宁可报读取失败,也不能拿去展示。
if (job.id !== id) return { state: 'error', reason: `返回的作业 ${job.id} 与请求的 ${id} 不一致` }
return { state: 'loaded', job: job as unknown as LibraryJobRecord }
}
export interface LibraryRecoveryAccepted {
accepted?: boolean
recovery_job?: string
+261 -9
View File
@@ -1,7 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { MaintenanceCard, maintenanceSummary, wikiContributionLines } from './ReleaseTargets'
import { fetchLibraryJobDetail } from './librarySnapshots'
import type { LibraryDiff, LibraryJobRecord, LibraryMaintenanceView } from './librarySnapshots'
const job: LibraryJobRecord = {
@@ -43,7 +44,7 @@ const heldInstance: LibraryMaintenanceView = {
describe('维护级别/进度/恢复错误', () => {
it('实例级持有:显示级别、入口状态、进度与恢复错误,并允许按预备份恢复', () => {
const summary = maintenanceSummary(heldInstance, job)
const summary = maintenanceSummary(heldInstance, { state: 'loaded', job })
expect(summary.level).toBe('实例级')
expect(summary.held).toBe(true)
expect(summary.ingress).toContain('已关闭')
@@ -54,9 +55,21 @@ describe('维护级别/进度/恢复错误', () => {
expect(summary.canRecover).toBe(true)
const html = renderToStaticMarkup(
<MaintenanceCard maintenance={heldInstance} job={job} canWrite onRecover={() => undefined} />
<MaintenanceCard
maintenance={heldInstance}
ownerJob={{ state: 'loaded', job }}
canWrite
onRecover={() => undefined}
/>
)
for (const text of ['目标维护', '实例级', '入口 已关闭', '按预备份恢复', '写入生产 chunks 失败']) {
for (const text of [
'目标维护',
'实例级',
'入口(维护记录,未实时探测)',
'已关闭',
'按预备份恢复',
'写入生产 chunks 失败',
]) {
expect(html).toContain(text)
}
})
@@ -67,7 +80,7 @@ describe('维护级别/进度/恢复错误', () => {
expect(summary.held).toBe(false)
expect(summary.canRecover).toBe(false)
const html = renderToStaticMarkup(
<MaintenanceCard maintenance={null} job={undefined} canWrite onRecover={() => undefined} />
<MaintenanceCard maintenance={null} ownerJob={{ state: 'unread' }} canWrite onRecover={() => undefined} />
)
expect(html).toContain('暂无维护窗口')
expect(html).not.toContain('按预备份恢复')
@@ -85,13 +98,18 @@ describe('维护级别/进度/恢复错误', () => {
ingress_state: 'open',
},
}
const summary = maintenanceSummary(released, job)
const summary = maintenanceSummary(released, { state: 'loaded', job })
expect(summary.held).toBe(false)
expect(summary.recoveryError).toBe('')
expect(summary.releasedFailure).toContain('写入生产 chunks 失败')
const html = renderToStaticMarkup(
<MaintenanceCard maintenance={released} job={job} canWrite onRecover={() => undefined} />
<MaintenanceCard
maintenance={released}
ownerJob={{ state: 'loaded', job }}
canWrite
onRecover={() => undefined}
/>
)
expect(html).not.toContain('目标保持维护')
expect(html).not.toContain('收尾没有完成')
@@ -101,7 +119,12 @@ describe('维护级别/进度/恢复错误', () => {
it('仍在持有的失败作业保留 held 诊断文案', () => {
const html = renderToStaticMarkup(
<MaintenanceCard maintenance={heldInstance} job={job} canWrite onRecover={() => undefined} />
<MaintenanceCard
maintenance={heldInstance}
ownerJob={{ state: 'loaded', job }}
canWrite
onRecover={() => undefined}
/>
)
expect(html).toContain('目标保持维护')
expect(html).toContain('收尾没有完成')
@@ -116,13 +139,242 @@ describe('维护级别/进度/恢复错误', () => {
state: 'recovered',
},
}
const summary = maintenanceSummary(released, job)
const summary = maintenanceSummary(released, { state: 'loaded', job })
expect(summary.held).toBe(false)
expect(summary.canRecover).toBe(false)
expect(summary.releasedAt).not.toBe('')
})
})
// ---- 维护窗口的作业归属(A 窗口 ≠ 该 scope 最新作业 B)----
//
// 真实事故(2026-11-03 22:48 CST):对内维护窗口 owner_job_id=libverify-…A 仍是
// needs_restore/ingress closed,但 scope 上最新作业是成功封存 B(libseal-…,completed/export)。
// 页面把 B 的“导出 · 已完成”当成窗口进度,于是需要处理的窗口看起来已经完成。
const ownerVerify: LibraryJobRecord = {
id: 'libverify-internal-A',
scope: 'internal',
kind: 'verify',
state: 'failed',
stage: 'verify',
request_digest: 'v',
queue_job_id: 'qv',
snapshot_id: '',
target_snapshot_id: '',
actor: 'admin',
error: '恢复验证失败:canary 校验不一致',
recovery: '预备份保留;按归档人工核验',
created_at: '2026-11-03T09:20:00Z',
updated_at: '2026-11-03T09:24:00Z',
finished_at: '2026-11-03T09:24:00Z',
stages: [],
}
const latestSeal: LibraryJobRecord = {
...ownerVerify,
id: 'libseal-lib-internal-B',
kind: 'seal',
state: 'succeeded',
stage: 'export',
error: '',
recovery: '',
finished_at: '2026-11-03T09:23:52Z',
}
const unrelatedFailure: LibraryJobRecord = {
...latestSeal,
id: 'libseal-lib-internal-C',
state: 'failed',
stage: 'index',
error: '封存索引导出失败:与维护窗口无关',
finished_at: '2026-11-03T09:25:00Z',
}
const needsRestore: LibraryMaintenanceView = {
held: true,
instance: 'kgmaint-weknora',
instance_maintenance: {
instance: 'kgmaint-weknora',
owner_scope: 'internal',
scope: 'internal',
owner_job_id: 'libverify-internal-A',
level: 'instance',
services: ['app'],
ingress_state: 'closed',
state: 'needs_restore',
acquired_at: '2026-11-03T09:20:00Z',
updated_at: '2026-11-03T09:24:00Z',
released_at: null,
},
}
describe('维护窗口作业归属', () => {
it('持有作业 A 失败时显示 A 的进度/错误,不显示较新封存 B 的“导出 · 已完成”', () => {
const summary = maintenanceSummary(needsRestore, { state: 'loaded', job: ownerVerify }, 'internal')
expect(summary.progress).toContain('校验')
expect(summary.progress).toContain('失败')
expect(summary.progress).not.toContain('导出')
expect(summary.recoveryError).toContain('canary 校验不一致')
expect(summary.canRecover).toBe(true)
const html = renderToStaticMarkup(
<MaintenanceCard
maintenance={needsRestore}
ownerJob={{ state: 'loaded', job: ownerVerify }}
scope="internal"
canWrite
onRecover={() => undefined}
/>
)
expect(html).not.toContain('导出 · 已完成')
expect(html).toContain('canary 校验不一致')
expect(html).toContain('按预备份恢复')
// 入口只是维护记录里的字段,页面不得声称实时探测过服务。
expect(html).toContain('维护记录,未实时探测')
})
it('调用方误把较新成功封存 B 传进来时,身份不匹配 ⇒ 明确“持有作业状态未读取”,不冒成功', () => {
const summary = maintenanceSummary(needsRestore, { state: 'loaded', job: latestSeal }, 'internal')
expect(summary.progress).not.toContain('导出')
expect(summary.progress).not.toContain('已完成')
expect(summary.progress).toContain('未读取')
expect(summary.recoveryError).toBe('')
const html = renderToStaticMarkup(
<MaintenanceCard
maintenance={needsRestore}
ownerJob={{ state: 'loaded', job: latestSeal }}
scope="internal"
canWrite
onRecover={() => undefined}
/>
)
expect(html).not.toContain('导出 · 已完成')
// 窗口本身仍是 needs_restore:需要处理的原窗口不能被隐藏。
expect(html).toContain('needs_restore')
})
it('无关的最新失败作业不把错误串进本窗口', () => {
const summary = maintenanceSummary(needsRestore, { state: 'loaded', job: unrelatedFailure }, 'internal')
expect(summary.recoveryError).toBe('')
expect(summary.progress).not.toContain('封存索引导出失败')
})
it('持有作业仍在运行 ⇒ 同窗口不允许并发恢复', () => {
const running: LibraryJobRecord = { ...ownerVerify, state: 'running', stage: 'database', finished_at: null }
const summary = maintenanceSummary(needsRestore, { state: 'loaded', job: running }, 'internal')
expect(summary.progress).toContain('执行中')
expect(summary.canRecover).toBe(false)
const html = renderToStaticMarkup(
<MaintenanceCard
maintenance={needsRestore}
ownerJob={{ state: 'loaded', job: running }}
scope="internal"
canWrite
onRecover={() => undefined}
/>
)
expect(html).not.toContain('按预备份恢复')
})
it('窗口已释放的持有作业失败只留历史记录,不提供恢复', () => {
const released: LibraryMaintenanceView = {
held: false,
instance_maintenance: {
...needsRestore.instance_maintenance!,
state: 'released',
ingress_state: 'open',
released_at: '2026-11-03T10:00:00Z',
},
}
const summary = maintenanceSummary(released, { state: 'loaded', job: ownerVerify }, 'internal')
expect(summary.held).toBe(false)
expect(summary.canRecover).toBe(false)
expect(summary.releasedFailure).toContain('canary 校验不一致')
expect(summary.releasedAt).not.toBe('')
})
it('跨 scope 查看实例级窗口:不给本 scope 投递恢复', () => {
const summary = maintenanceSummary(needsRestore, { state: 'loaded', job: ownerVerify }, 'external')
expect(summary.canRecover).toBe(false)
expect(summary.scopeNote).toContain('internal')
expect(summary.scopeNote).toContain('external')
const html = renderToStaticMarkup(
<MaintenanceCard
maintenance={needsRestore}
ownerJob={{ state: 'loaded', job: ownerVerify }}
scope="external"
canWrite
onRecover={() => undefined}
/>
)
expect(html).not.toContain('按预备份恢复')
})
it('持有作业详情读取失败/不存在时明确未知,不退回最新作业也不编造成功', () => {
const failedRead = maintenanceSummary(needsRestore, { state: 'error', reason: 'HTTP 503' }, 'internal')
expect(failedRead.progress).toContain('读取失败')
expect(failedRead.progress).toContain('HTTP 503')
expect(failedRead.recoveryError).toBe('')
const missing = maintenanceSummary(needsRestore, { state: 'missing' }, 'internal')
expect(missing.progress).toContain('不存在')
expect(missing.recoveryError).toBe('')
const unread = maintenanceSummary(needsRestore, { state: 'unread' }, 'internal')
expect(unread.progress).toContain('未读取')
// needs_restore 是已确认的维护契约:作业详情读不到也保留恢复入口,由后端按窗口校验。
expect(unread.canRecover).toBe(true)
})
})
describe('fetchLibraryJobDetail(只读作业详情)', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
it('按 owner id 读取并校验结构', async () => {
let seen = ''
globalThis.fetch = (async (url: string) => {
seen = String(url)
return new Response(JSON.stringify({ available: true, job: ownerVerify }), { status: 200 })
}) as unknown as typeof fetch
const value = await fetchLibraryJobDetail('libverify-internal-A')
expect(seen).toBe('/api/v1/library/jobs/libverify-internal-A')
expect(value.state).toBe('loaded')
expect(value.state === 'loaded' && value.job.id).toBe('libverify-internal-A')
})
it('id 里的特殊字符必须编码,且返回别的作业按读取失败处理', async () => {
let seen = ''
globalThis.fetch = (async (url: string) => {
seen = String(url)
return new Response(JSON.stringify({ available: true, job: ownerVerify }), { status: 200 })
}) as unknown as typeof fetch
const value = await fetchLibraryJobDetail('libverify/internal A')
expect(seen).toBe('/api/v1/library/jobs/libverify%2Finternal%20A')
// 串包/晚回包:返回的不是请求的那个作业,不能拿去展示。
expect(value.state).toBe('error')
})
it('404 是“作业不存在”,不是读取失败', async () => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ available: false }), { status: 404 })) as unknown as typeof fetch
expect((await fetchLibraryJobDetail('x')).state).toBe('missing')
})
it('5xx 与结构不符是“读取失败”,不能吞成不存在', async () => {
globalThis.fetch = (async () => new Response('{}', { status: 503 })) as unknown as typeof fetch
const error = await fetchLibraryJobDetail('x')
expect(error.state).toBe('error')
expect(error.state === 'error' && error.reason).toContain('503')
globalThis.fetch = (async () =>
new Response(JSON.stringify({ available: true, job: { id: 'x' } }), { status: 200 })) as unknown as typeof fetch
expect((await fetchLibraryJobDetail('x')).state).toBe('error')
})
})
describe('Wiki 贡献级差异展示', () => {
const diff: LibraryDiff = {
state: 'computed',