问题:维护窗口 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:
@@ -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
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user