F02:测试封存作业只展示只读导出测试版本,不再套用备份生产/同步 Wiki/ 生产验证/生产生效,也不再写目标维护;运行和失败终态都保留真实 export 状态。同一挂载覆盖 running→failed。 F03:版本列表按每个 snapshot_id 精确查询恢复验证记录,与详情使用同一 endpoint;同 scope 最新 passed 不再给其他快照染色,未演练与读取失败分开。 封存失败:已恢复写入者且确定不可重试的捕获校验错误使用永久失败,避免 第二次 attempt 的 already exists 覆盖第一因;业务 job 的 error 在失败重试 中保留第一条,成功时清除。瞬时不可达/超时仍保留重试。 验证:Go/前端全量、隔离 PG 精确快照与首因测试、临时独立 ES 的 Current→Prepare→Verify PASS、make verify 17 项。host libraryctl 仍是旧 f9c15a8f...dirty,360 镜像未替换 host 工具,本轮只单独准备包不切换。
This commit is contained in:
@@ -1,5 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
## 开发快照 0.1.361(封存阶段真源、精确验证列表与捕获终态)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **F02 封存阶段**:测试封存作业只显示“只读导出测试版本”一个真实阶段;顶部说明改为
|
||||
只读导出、不修改目标生产,不再套用备份生产/同步 Wiki/生产验证/生产生效,也不再
|
||||
声称目标维护。运行、失败终态均保留真实 export 状态与原因。
|
||||
- **F03 列表与精确详情一致**:版本列表按每个快照的 `snapshot_id` 精确查询恢复验证
|
||||
记录,与详情使用同一 endpoint/身份;同 scope 最新 passed 不再给其他快照染色。
|
||||
未演练显示未验证,读取失败单独显示。
|
||||
- **封存失败保留第一因**:对已恢复写入者且确定不可重试的捕获校验错误
|
||||
(legacy chunk index coverage、缺失生成问题索引、缺失索引分块、空索引正文)使用
|
||||
永久失败,不再自动产生第二次 attempt 让 “already exists” 覆盖第一因;业务 job
|
||||
的 error 在失败重试中保留第一条,成功时清除。瞬时不可达/超时仍按原重试预算。
|
||||
- **F01 host 工具边界**:本机 host `libraryctl` 仍是 `f9c15a8f...dirty`,360 的
|
||||
api/worker/web 升级不会替换该二进制;封存实际仍走旧 host 工具。最终 SHA 的
|
||||
host 工具包单独准备,未切换。
|
||||
|
||||
### Tests
|
||||
|
||||
- 同一挂载封存 running→failed、失败原因、只读阶段;列表精确 snapshot 验证记录;
|
||||
PostgreSQL 精确 snapshot 查询与失败首因保留;隔离 ES Current→Prepare→Verify
|
||||
重跑 PASS(临时独立 ES,完成后删除)。
|
||||
|
||||
|
||||
## 开发快照 0.1.360(tableState 文本身份、快照统计归属与精确验证记录)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"knowledge-governance/internal/jobs"
|
||||
)
|
||||
|
||||
func TestLibraryCaptureValidationFailureIsTerminal(t *testing.T) {
|
||||
original := errors.New("LIBRARYCTL_FAILED: unverified legacy library chunk index coverage;已执行 recover 并确认写入者恢复")
|
||||
got := terminalLibraryCaptureError(original)
|
||||
terminal, code := jobs.Terminal(got)
|
||||
if !terminal || code != jobs.ErrCodeValidationFailed {
|
||||
t.Fatalf("validation failure must park immediately: terminal=%v code=%q err=%v", terminal, code, got)
|
||||
}
|
||||
if got.Error() != original.Error() {
|
||||
t.Fatalf("original cause changed: got %q want %q", got.Error(), original.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryCaptureTransientFailureStillRetries(t *testing.T) {
|
||||
for _, message := range []string{
|
||||
"library agent is unreachable",
|
||||
"context deadline exceeded",
|
||||
`LIBRARYCTL_FAILED: job "lib-internal-x" already exists with state "failed";已执行 recover 并确认写入者恢复`,
|
||||
} {
|
||||
got := terminalLibraryCaptureError(errors.New(message))
|
||||
if terminal, code := jobs.Terminal(got); terminal {
|
||||
t.Fatalf("%q must stay retryable, got terminal code=%q", message, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryCaptureOriginalFailureSurvivesRetryOverwrite(t *testing.T) {
|
||||
// 队列第二次 attempt 的 already exists 不能替换第一因;分类器保留首个
|
||||
// 校验错误为永久失败,因此队列不会产生第二次自动 attempt。
|
||||
first := errors.New("LIBRARYCTL_FAILED: unverified legacy library chunk index coverage;已执行 recover 并确认写入者恢复")
|
||||
second := errors.New(`LIBRARYCTL_FAILED: job "lib-internal-20261105T054158" already exists with state "failed";已执行 recover 并确认写入者恢复`)
|
||||
classified := terminalLibraryCaptureError(first)
|
||||
if terminal, _ := jobs.Terminal(classified); !terminal {
|
||||
t.Fatal("first validation error must terminate before retry")
|
||||
}
|
||||
if terminal, _ := jobs.Terminal(second); terminal {
|
||||
t.Fatal("second retry error must not be terminal")
|
||||
}
|
||||
if !strings.Contains(classified.Error(), "unverified legacy library chunk index coverage") {
|
||||
t.Fatalf("first cause lost: %v", classified)
|
||||
}
|
||||
}
|
||||
+24
-1
@@ -101,6 +101,29 @@ func stageSizesFrom(manifest librarysnapshot.Manifest) map[string]stageSize {
|
||||
// libraryCaptureJobType is shared with the API which enqueues it.
|
||||
const libraryCaptureJobType = "library.capture"
|
||||
|
||||
// 这些是源库内容/索引覆盖的确定性校验失败,不是网络抖动。agent 已经
|
||||
// 执行 recover 并确认写入者恢复后,继续自动重试只会复用失败 journal,
|
||||
// 用第二次“already exists”覆盖真正的第一因。只对这类错误走永久失败;
|
||||
// 不可达、超时、journal 竞争等仍保留正常重试。
|
||||
var terminalLibraryCaptureMarkers = []string{
|
||||
"unverified legacy library chunk index coverage",
|
||||
"missing library generated question index",
|
||||
"missing indexed library chunk",
|
||||
"empty library index content",
|
||||
}
|
||||
|
||||
func terminalLibraryCaptureError(err error) error {
|
||||
if err == nil || !strings.Contains(err.Error(), "已执行 recover 并确认写入者恢复") {
|
||||
return err
|
||||
}
|
||||
for _, marker := range terminalLibraryCaptureMarkers {
|
||||
if strings.Contains(err.Error(), marker) {
|
||||
return jobs.Permanent(jobs.ErrCodeValidationFailed, err)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// librarySyncJobType 是生产同步作业的队列类型;与封存严格分开,状态不共用。
|
||||
const librarySyncJobType = "library.sync"
|
||||
|
||||
@@ -365,7 +388,7 @@ func workerHandlers(store *postgresstore.Store, config appconfig.Config) (map[st
|
||||
_ = store.AdvanceLibraryJob(ctx, libraryJobID, governance.LibraryJobFailed, "export", err.Error(),
|
||||
"agent 已执行恢复检查;确认来源 healthy 后可重试同一作业号", "", "", finishedAt)
|
||||
log.Printf("library capture failed scope=%s capture_job=%s: %v", payload.Scope, payload.CaptureJob, err)
|
||||
return err
|
||||
return terminalLibraryCaptureError(err)
|
||||
}
|
||||
// 执行后复核:结果里 agent 自报的身份必须还是期望实例,否则这次封存
|
||||
// 可能来自别的栈,绝不能登记成"本实例的测试封存"。
|
||||
|
||||
@@ -247,7 +247,14 @@ func (s *Store) AdvanceLibraryJob(ctx context.Context, jobID string, state gover
|
||||
UPDATE library_jobs SET
|
||||
state=$2,
|
||||
stage = CASE WHEN $3='' THEN stage ELSE $3 END,
|
||||
error = CASE WHEN $4='' THEN error ELSE $4 END,
|
||||
-- 失败重试不得把第一因覆盖成第二次“already exists”;成功时清除旧错误,
|
||||
-- 但新的 failed 状态保留已记录的第一条失败原因。
|
||||
error = CASE
|
||||
WHEN $2='succeeded' THEN ''
|
||||
WHEN $4='' THEN error
|
||||
WHEN $2='failed' AND error<>'' THEN error
|
||||
ELSE $4
|
||||
END,
|
||||
recovery = CASE WHEN $5='' THEN recovery ELSE $5 END,
|
||||
snapshot_id = CASE WHEN $6='' THEN snapshot_id ELSE $6 END,
|
||||
target_snapshot_id = CASE WHEN $7='' THEN target_snapshot_id ELSE $7 END,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"knowledge-governance/internal/governance"
|
||||
)
|
||||
|
||||
// F02 retry regression: a second failed attempt must not overwrite the first
|
||||
// cause in the persistent business job; a later success clears it.
|
||||
func TestAdvanceLibraryJobPreservesFirstFailure(t *testing.T) {
|
||||
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires explicitly isolated kg_library_test_ PostgreSQL database")
|
||||
}
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "kg_library_test_") {
|
||||
t.Fatal("isolated database required")
|
||||
}
|
||||
cfg.MaxConns = 1
|
||||
cfg.ConnConfig.RuntimeParams["search_path"] = "pg_temp"
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err := pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE library_jobs(
|
||||
id text primary key, state text, stage text, error text, recovery text,
|
||||
snapshot_id text, target_snapshot_id text, updated_at timestamptz, finished_at timestamptz);
|
||||
INSERT INTO library_jobs(id,state,stage,error) VALUES('libseal-retry','running','export','');`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(pool)
|
||||
now := time.Now().UTC()
|
||||
if err := store.AdvanceLibraryJob(ctx, "libseal-retry", governance.LibraryJobFailed, "export", "first validation failure", "", "", "", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AdvanceLibraryJob(ctx, "libseal-retry", governance.LibraryJobFailed, "export", "second already exists", "", "", "", now.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got string
|
||||
if err := pool.QueryRow(ctx, `SELECT error FROM library_jobs WHERE id='libseal-retry'`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "first validation failure" {
|
||||
t.Fatalf("first failure overwritten: %q", got)
|
||||
}
|
||||
if err := store.AdvanceLibraryJob(ctx, "libseal-retry", governance.LibraryJobSucceeded, "export", "", "", "snap-ok", "", now.Add(2*time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT error FROM library_jobs WHERE id='libseal-retry'`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("success must clear old error, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"knowledge-governance/internal/governance"
|
||||
)
|
||||
|
||||
// F03 regression against real PostgreSQL: exact snapshot lookup must not fall
|
||||
// back to the target's newest passed record. Session temp table only.
|
||||
func TestLibraryRestoreVerificationForSnapshotExactPG(t *testing.T) {
|
||||
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires explicitly isolated kg_library_test_ PostgreSQL database")
|
||||
}
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "kg_library_test_") {
|
||||
t.Fatal("isolated database required")
|
||||
}
|
||||
cfg.MaxConns = 1
|
||||
cfg.ConnConfig.RuntimeParams["search_path"] = "pg_temp"
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TEMP TABLE library_restore_verifications(
|
||||
id text, target_id text, scope text, status text, snapshot_id text, job_id text,
|
||||
failure text, report jsonb, started_at timestamptz, finished_at timestamptz, created_at timestamptz DEFAULT now());
|
||||
INSERT INTO library_restore_verifications
|
||||
(id,target_id,scope,status,snapshot_id,job_id,failure,report,started_at,finished_at,created_at) VALUES
|
||||
('v-new','target-exact','internal','passed','snap-new','job-new','','{}',now()-interval '1 hour',now()-interval '1 hour',now()),
|
||||
('v-old','target-exact','internal','failed','snap-old','job-old','old restore mismatch','{}',now()-interval '2 hour',now()-interval '2 hour',now()-interval '2 hour'),
|
||||
('v-other','other-target','internal','passed','snap-old','job-other','','{}',now(),now(),now());`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(pool).WithLibraryTarget("target-exact")
|
||||
item, found, err := store.LibraryRestoreVerificationForSnapshot(ctx, "internal", "snap-old")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || item.JobID != "job-old" || item.Status != governance.LibraryVerificationFailed {
|
||||
t.Fatalf("exact snapshot borrowed another record: found=%v item=%+v", found, item)
|
||||
}
|
||||
if _, found, err := store.LibraryRestoreVerificationForSnapshot(ctx, "internal", "snap-missing"); err != nil || found {
|
||||
t.Fatalf("missing snapshot must be explicit no-evidence: found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ let maintenanceBody: Record<string, unknown> = {}
|
||||
let jobDetails: Record<string, unknown> = {}
|
||||
let jobDetailFailures: Record<string, number> = {}
|
||||
let recoveryCalls: { scope: string; window_job_id: string }[] = []
|
||||
let verificationBySnapshot: Record<string, unknown> = {}
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let originalFetch: typeof fetch
|
||||
@@ -160,8 +161,31 @@ beforeEach(() => {
|
||||
jobDetails = {}
|
||||
jobDetailFailures = {}
|
||||
recoveryCalls = []
|
||||
verificationBySnapshot = {}
|
||||
snapshotsBody.snapshots = []
|
||||
snapshotsBody.versions = 0
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('/api/v1/library/verifications')) {
|
||||
const snapshotID = new URL(url, 'http://candidate.invalid').searchParams.get('snapshot_id') ?? ''
|
||||
return json(
|
||||
{
|
||||
scope: 'internal',
|
||||
target: {
|
||||
instance: 'test',
|
||||
project: 'test',
|
||||
internal_kb: 'kb',
|
||||
external_kb: 'kb-external',
|
||||
index: 'idx',
|
||||
s3_bucket: 'bucket',
|
||||
tenant_id: 10000,
|
||||
database_configured: true,
|
||||
},
|
||||
snapshot_latest: verificationBySnapshot[snapshotID] ?? null,
|
||||
},
|
||||
200
|
||||
)
|
||||
}
|
||||
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/recover')) {
|
||||
@@ -274,6 +298,75 @@ describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
|
||||
expect(text()).toContain('目标已读且为空')
|
||||
})
|
||||
|
||||
it('同一挂载的封存作业运行→失败仍只显示只读导出,不切成生产步骤', async () => {
|
||||
const runningSeal = {
|
||||
id: 'libseal-ui-running',
|
||||
scope: 'internal',
|
||||
kind: 'seal',
|
||||
state: 'running',
|
||||
stage: 'export',
|
||||
request_digest: 's',
|
||||
queue_job_id: 'q-seal',
|
||||
snapshot_id: '',
|
||||
target_snapshot_id: '',
|
||||
actor: 'admin',
|
||||
error: '',
|
||||
recovery: '',
|
||||
created_at: '2026-11-05T05:41:58Z',
|
||||
updated_at: '2026-11-05T05:42:00Z',
|
||||
finished_at: null,
|
||||
stages: [
|
||||
{
|
||||
stage: 'export',
|
||||
state: 'running',
|
||||
total: null,
|
||||
completed: null,
|
||||
bytes_total: null,
|
||||
bytes_done: null,
|
||||
detail: '正在导出',
|
||||
updated_at: '2026-11-05T05:42:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
overviews.internal = reply({ ...overviewBody('internal', emptyTarget), job: runningSeal, jobs: [runningSeal] })
|
||||
await mount()
|
||||
expect(text()).toContain('只读导出测试版本')
|
||||
expect(text()).not.toContain('生产生效')
|
||||
expect(text()).not.toContain('目标在维护中')
|
||||
|
||||
const failedSeal = {
|
||||
...runningSeal,
|
||||
state: 'failed',
|
||||
error: 'unverified legacy library chunk index coverage',
|
||||
finished_at: '2026-11-05T05:42:13Z',
|
||||
stages: [{ ...runningSeal.stages[0], state: 'failed', detail: '封存校验失败' }],
|
||||
}
|
||||
overviews.internal = reply({ ...overviewBody('internal', emptyTarget), job: failedSeal, jobs: [failedSeal] })
|
||||
await click('刷新')
|
||||
expect(text()).toContain('封存校验失败')
|
||||
expect(text()).toContain('unverified legacy library chunk index coverage')
|
||||
expect(text()).not.toContain('生产生效')
|
||||
expect(text()).not.toContain('目标在维护中')
|
||||
})
|
||||
|
||||
it('版本列表按选中快照的精确验证记录显示,不借用同 scope 最新 passed', async () => {
|
||||
;(snapshotsBody as { snapshots: unknown[] }).snapshots = [snapshot]
|
||||
snapshotsBody.versions = 1
|
||||
verificationBySnapshot[snapshot.snapshot_id] = {
|
||||
id: 'libverify-internal-exact',
|
||||
scope: 'internal',
|
||||
status: 'passed',
|
||||
snapshot_id: snapshot.snapshot_id,
|
||||
job_id: 'libverify-internal-exact-job',
|
||||
finished_at: '2026-11-05T06:00:00Z',
|
||||
}
|
||||
await mount()
|
||||
await click('版本与快照')
|
||||
await click('全部归档')
|
||||
expect(text()).toContain('恢复验证通过 · libverify-internal-exact-job')
|
||||
expect(text()).not.toContain('分项完整 · 未做生产恢复验证')
|
||||
})
|
||||
|
||||
it('当前范围没有自己的 overview 时不借用别的范围分类', async () => {
|
||||
// 服务端把对内的 overview 回给了对外请求:scope 不匹配,等于没有观测。
|
||||
overviews.external = reply(overviewBody('internal', emptyTarget))
|
||||
|
||||
@@ -723,7 +723,7 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
expect(html).not.toContain('已完成 0 / 0')
|
||||
})
|
||||
|
||||
it('同步中布局显示四类数据卡与明细,且不编造百分比', () => {
|
||||
it('封存作业只显示只读导出步骤,不冒充生产备份/同步/生效或目标维护', () => {
|
||||
const html = render({
|
||||
jobs: [runningJob],
|
||||
overview: { ...overview, job: persistedSeal, jobs: [persistedSeal] },
|
||||
@@ -732,18 +732,39 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
// F06: 运行中主按钮只能查看当前任务,不能再次启动。
|
||||
expect(html).toContain('查看当前任务')
|
||||
expect(html).not.toContain('▶ 启动同步')
|
||||
expect(html).toContain('数据库 · 存储行')
|
||||
expect(html).toContain('原文件 · 图片 · 附件')
|
||||
expect(html).toContain('检索索引 · 向量')
|
||||
expect(html).toContain('Wiki · 存储行')
|
||||
expect(html).toContain('同步明细')
|
||||
// 唯一允许出现的百分比是免责声明里的「100% ≠ 作业完成」,没有任何进度主张
|
||||
expect(html.match(/[1-9]\d*%/g) ?? []).toEqual(['100%'])
|
||||
expect(html).toContain('文件传输 100% ≠ 作业完成')
|
||||
expect(html).toContain('不编造百分比')
|
||||
// 没有真实总量时只写"未知总量",不画百分比
|
||||
expect(html).toContain('未知总量')
|
||||
expect(html).toContain('只读导出测试版本')
|
||||
expect(html).toContain('只读导出测试库快照 → 不修改目标生产')
|
||||
expect(html).toContain('封存进行中(只读导出测试版本,不修改目标生产)')
|
||||
expect(html).toContain('封存范围')
|
||||
expect(html).toContain('主机侧正在暂停写入者、导出并恢复')
|
||||
expect(html).not.toContain('>备份生产<')
|
||||
expect(html).not.toContain('>同步数据与 Wiki<')
|
||||
expect(html).not.toContain('>生产验证<')
|
||||
expect(html).not.toContain('>生产生效<')
|
||||
expect(html).not.toContain('目标在维护中')
|
||||
expect(html).not.toContain('各类数据同步情况')
|
||||
expect(html).not.toContain('同步明细')
|
||||
})
|
||||
|
||||
it('失败封存保留失败阶段与原因,不切成生产步骤', () => {
|
||||
const failedSeal: LibraryJobRecord = {
|
||||
...persistedSeal,
|
||||
state: 'failed',
|
||||
error: 'unverified legacy library chunk index coverage',
|
||||
stages: [{ ...persistedSeal.stages[0], state: 'failed', detail: '封存校验失败' }],
|
||||
}
|
||||
const html = render({
|
||||
overview: { ...overview, job: failedSeal, jobs: [failedSeal] },
|
||||
})
|
||||
expect(html).toContain('只读导出测试版本')
|
||||
expect(html).toContain('封存校验失败')
|
||||
expect(html).toContain('unverified legacy library chunk index coverage')
|
||||
expect(html).toContain('测试封存失败')
|
||||
expect(html).not.toContain('目标在维护中')
|
||||
expect(html).not.toContain('>备份生产<')
|
||||
expect(html).not.toContain('>同步数据与 Wiki<')
|
||||
expect(html).not.toContain('>生产生效<')
|
||||
expect(html).not.toContain('目标在维护中')
|
||||
})
|
||||
|
||||
it('无权限时不给封存入口;不可发起时给出接口返回的真实原因与执行器身份', () => {
|
||||
|
||||
+238
-154
@@ -295,13 +295,15 @@ export function snapshotVersionLabel(refs: LibraryVersionRef[] | undefined, snap
|
||||
export function publishStateText(job?: LibraryJobRecord): string {
|
||||
if (!job) return '尚未启动,不修改生产'
|
||||
if (job.state === 'pending' || job.state === 'running') {
|
||||
return job.kind === 'restore'
|
||||
? '恢复进行中(目标在维护中)'
|
||||
: job.kind === 'baseline'
|
||||
? '建立基线进行中(只读捕获目标,不写内容)'
|
||||
: job.kind === 'verify'
|
||||
? '恢复验证进行中(目标在维护中)'
|
||||
: '同步进行中(目标在维护中)'
|
||||
return job.kind === 'seal'
|
||||
? '封存进行中(只读导出测试版本,不修改目标生产)'
|
||||
: job.kind === 'restore'
|
||||
? '恢复进行中(目标在维护中)'
|
||||
: job.kind === 'baseline'
|
||||
? '建立基线进行中(只读捕获目标,不写内容)'
|
||||
: job.kind === 'verify'
|
||||
? '恢复验证进行中(目标在维护中)'
|
||||
: '同步进行中(目标在维护中)'
|
||||
}
|
||||
if (job.state === 'succeeded') return `${jobKindText(job.kind)}已完成 · ${stamp(job.finished_at ?? job.updated_at)}`
|
||||
if (job.state === 'failed') return `${jobKindText(job.kind)}失败 · ${stamp(job.finished_at ?? job.updated_at)}`
|
||||
@@ -636,26 +638,49 @@ function StageBar({ view }: { view: ScopeView }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
const sealJob = job?.kind === 'seal' ? job : undefined
|
||||
if (job?.kind === 'seal') {
|
||||
const exportRow = job.stages.find((item) => item.stage === 'export')
|
||||
const state =
|
||||
job.state === 'succeeded'
|
||||
? 'done'
|
||||
: job.state === 'failed'
|
||||
? 'fail'
|
||||
: job.state === 'running' || job.state === 'pending'
|
||||
? 'live'
|
||||
: 'idle'
|
||||
return (
|
||||
<>
|
||||
<ol className="rel-steps" aria-label="测试库封存阶段">
|
||||
<li className={`rel-step ${state}`}>
|
||||
<span className="rel-step-mark">
|
||||
{state === 'done' ? '✓' : state === 'live' ? '●' : state === 'fail' ? '!' : '○'}
|
||||
</span>
|
||||
<strong>只读导出测试版本</strong>
|
||||
<small>
|
||||
{exportRow?.detail ||
|
||||
(state === 'done'
|
||||
? '测试库快照已导出并登记'
|
||||
: state === 'fail'
|
||||
? '封存未完成'
|
||||
: state === 'live'
|
||||
? '正在读取测试库并导出快照'
|
||||
: '未开始')}
|
||||
</small>
|
||||
</li>
|
||||
</ol>
|
||||
<p className="rel-steps-note">
|
||||
作业 {job.id} · 测试封存 · {jobStateText(job)}
|
||||
{job.error ? ` · ${job.error}` : ''};封存只读测试库,不备份、不写入、不切换目标生产。
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
// 同步、恢复、建立基线共用同一串阶段(预备份 → 数据四类 → 校验 → 登记版本)。
|
||||
// 只按"sync"找阶段会让刚跑完的恢复/基线在页面上显示成"本次作业尚未开始"。
|
||||
const writeJob = job && job.kind !== 'seal' ? job : undefined
|
||||
const writeJob = job
|
||||
|
||||
let first: { state: StepState; hint: string }
|
||||
if (sealJob) {
|
||||
const exportStage = stageOf(sealJob, 'export')
|
||||
first = {
|
||||
state: toState(sealJob.state === 'succeeded' ? 'succeeded' : sealJob.state === 'failed' ? 'failed' : 'running'),
|
||||
hint:
|
||||
sealJob.state === 'succeeded'
|
||||
? `本次作业已封存 ${stamp(sealJob.finished_at ?? exportStage?.updated_at ?? sealJob.updated_at)}`
|
||||
: sealJob.state === 'running'
|
||||
? '本次封存执行中'
|
||||
: sealJob.state === 'pending'
|
||||
? '已排队,等待 worker'
|
||||
: '本次封存未完成',
|
||||
}
|
||||
} else if (writeJob) {
|
||||
if (writeJob) {
|
||||
first =
|
||||
writeJob.kind === 'restore'
|
||||
? { state: 'done', hint: '恢复到选定快照(不经过测试封存)' }
|
||||
@@ -1121,18 +1146,24 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
<h2>
|
||||
{sealJob ? '测试库封存作业' : dataJob ? `${jobKindText(dataJob.kind)}作业` : '本次作业'}
|
||||
<span className="rel-version">
|
||||
{dataJob?.kind === 'restore'
|
||||
? `恢复到 ${dataJob.target_snapshot_id.slice(0, 12) || '—'}`
|
||||
: dataJob?.kind === 'baseline'
|
||||
? `目标基线 ${dataJob.target_snapshot_id.slice(0, 12) || '(本次捕获后登记)'}`
|
||||
: `测试封存 ${view.overview?.test_sealed?.snapshot_id.slice(0, 12) ?? '未接入'}`}{' '}
|
||||
→ 目标当前{' '}
|
||||
<b>
|
||||
{view.overview?.production.state === 'known' && view.overview.production.version
|
||||
? view.overview.production.version.version_label ||
|
||||
view.overview.production.version.snapshot_id.slice(0, 12)
|
||||
: '未知'}
|
||||
</b>
|
||||
{sealJob ? (
|
||||
<>只读导出测试库快照 → 不修改目标生产</>
|
||||
) : (
|
||||
<>
|
||||
{dataJob?.kind === 'restore'
|
||||
? `恢复到 ${dataJob.target_snapshot_id.slice(0, 12) || '—'}`
|
||||
: dataJob?.kind === 'baseline'
|
||||
? `目标基线 ${dataJob.target_snapshot_id.slice(0, 12) || '(本次捕获后登记)'}`
|
||||
: `测试封存 ${view.overview?.test_sealed?.snapshot_id.slice(0, 12) ?? '未接入'}`}{' '}
|
||||
→ 目标当前{' '}
|
||||
<b>
|
||||
{view.overview?.production.state === 'known' && view.overview.production.version
|
||||
? view.overview.production.version.version_label ||
|
||||
view.overview.production.version.snapshot_id.slice(0, 12)
|
||||
: '未知'}
|
||||
</b>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</h2>
|
||||
<span className={`rel-pill ${running ? 'blue' : job?.state === 'failed' ? 'red' : job ? 'green' : 'gray'}`}>
|
||||
@@ -1162,131 +1193,184 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rel-panel rel-datatypes">
|
||||
<header className="rel-panel-head">
|
||||
<h2>各类数据同步情况</h2>
|
||||
<span className="rel-hint">没有真实总量与完成量的项目只显示状态,不编造百分比</span>
|
||||
</header>
|
||||
<div className="rel-grid rel-grid-4">
|
||||
{cards.map((card) => (
|
||||
<article key={card.title} className="rel-datacard">
|
||||
<header>
|
||||
<h3>{card.title}</h3>
|
||||
<span
|
||||
className={`rel-pill ${card.row?.state === 'succeeded' ? 'green' : card.row?.state === 'failed' ? 'red' : card.row?.state === 'running' ? 'blue' : 'gray'}`}
|
||||
>
|
||||
{card.row
|
||||
? card.row.state === 'succeeded'
|
||||
? '已完成'
|
||||
: card.row.state === 'running'
|
||||
? '执行中'
|
||||
: card.row.state === 'failed'
|
||||
? '失败'
|
||||
: '等待'
|
||||
: '未接入'}
|
||||
</span>
|
||||
</header>
|
||||
<p className="rel-datacount">
|
||||
<strong>{card.count}</strong> · {card.axis}
|
||||
</p>
|
||||
{card.counts ? <p className="rel-datadetail">封存快照分项:{card.counts}</p> : null}
|
||||
<div className="rel-progress" aria-hidden={card.percent === null ? 'true' : undefined}>
|
||||
<span style={{ width: `${card.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<p className="rel-datadetail">
|
||||
{card.row?.detail
|
||||
? card.row.detail
|
||||
: card.row
|
||||
? card.row.state === 'running'
|
||||
? '执行中;执行器尚未提供该分项明细'
|
||||
: card.row.state === 'pending'
|
||||
? '等待执行;不能按完成展示'
|
||||
: card.row.state === 'failed'
|
||||
? '失败;没有可核对完成量'
|
||||
: card.row.state === 'succeeded'
|
||||
? card.row.total != null
|
||||
? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
|
||||
: '该分项已完成,执行器未提供可核对总量'
|
||||
: '状态未知;不按完成展示'
|
||||
: dataJob
|
||||
? '本次作业没有产生该分项阶段记录'
|
||||
: '目标同步执行链未接通,分项不会产生数据'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{sealJob ? (
|
||||
<div className="rel-panel">
|
||||
<header className="rel-panel-head">
|
||||
<h2>封存范围</h2>
|
||||
<span className="rel-pill gray">只读</span>
|
||||
</header>
|
||||
<p className="rel-scope-note">
|
||||
本作业只读取测试库并导出快照,不备份生产、不写目标、不做生产验证,也不持有目标维护窗口。
|
||||
快照完成后在“版本与快照”查看;没有真实导出总量时显示未知,不补零或 100%。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rel-panel rel-datatypes">
|
||||
<header className="rel-panel-head">
|
||||
<h2>各类数据同步情况</h2>
|
||||
<span className="rel-hint">没有真实总量与完成量的项目只显示状态,不编造百分比</span>
|
||||
</header>
|
||||
<div className="rel-grid rel-grid-4">
|
||||
{cards.map((card) => (
|
||||
<article key={card.title} className="rel-datacard">
|
||||
<header>
|
||||
<h3>{card.title}</h3>
|
||||
<span
|
||||
className={`rel-pill ${card.row?.state === 'succeeded' ? 'green' : card.row?.state === 'failed' ? 'red' : card.row?.state === 'running' ? 'blue' : 'gray'}`}
|
||||
>
|
||||
{card.row
|
||||
? card.row.state === 'succeeded'
|
||||
? '已完成'
|
||||
: card.row.state === 'running'
|
||||
? '执行中'
|
||||
: card.row.state === 'failed'
|
||||
? '失败'
|
||||
: '等待'
|
||||
: '未接入'}
|
||||
</span>
|
||||
</header>
|
||||
<p className="rel-datacount">
|
||||
<strong>{card.count}</strong> · {card.axis}
|
||||
</p>
|
||||
{card.counts ? <p className="rel-datadetail">封存快照分项:{card.counts}</p> : null}
|
||||
<div className="rel-progress" aria-hidden={card.percent === null ? 'true' : undefined}>
|
||||
<span style={{ width: `${card.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<p className="rel-datadetail">
|
||||
{card.row?.detail
|
||||
? card.row.detail
|
||||
: card.row
|
||||
? card.row.state === 'running'
|
||||
? '执行中;执行器尚未提供该分项明细'
|
||||
: card.row.state === 'pending'
|
||||
? '等待执行;不能按完成展示'
|
||||
: card.row.state === 'failed'
|
||||
? '失败;没有可核对完成量'
|
||||
: card.row.state === 'succeeded'
|
||||
? card.row.total != null
|
||||
? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
|
||||
: '该分项已完成,执行器未提供可核对总量'
|
||||
: '状态未知;不按完成展示'
|
||||
: dataJob
|
||||
? '本次作业没有产生该分项阶段记录'
|
||||
: '目标同步执行链未接通,分项不会产生数据'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rel-panel rel-details">
|
||||
<header className="rel-panel-head">
|
||||
<h2>同步明细</h2>
|
||||
<span className="rel-hint">行来自持久作业阶段,worker 重启后仍可读</span>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 / 对象</th>
|
||||
<th>阶段</th>
|
||||
<th>进度</th>
|
||||
<th>状态</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(job?.stages ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="rel-table-empty">
|
||||
尚无阶段记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
job!.stages.map((row) => (
|
||||
<tr key={row.stage}>
|
||||
<td>{row.stage}</td>
|
||||
<td>{row.stage === 'export' ? '测试封存' : row.stage}</td>
|
||||
<td>
|
||||
{row.total != null ? `${row.completed ?? '—'} / ${row.total}` : '未知总量'}
|
||||
{row.bytes_total != null
|
||||
? ` · ${formatBytes(row.bytes_done ?? 0)} / ${formatBytes(row.bytes_total)}`
|
||||
: ''}
|
||||
</td>
|
||||
<td>
|
||||
{row.state === 'succeeded'
|
||||
? '已完成'
|
||||
: row.state === 'running'
|
||||
? '执行中'
|
||||
: row.state === 'failed'
|
||||
? '失败'
|
||||
: row.state === 'skipped'
|
||||
? '已跳过'
|
||||
: '等待'}
|
||||
</td>
|
||||
<td>{row.detail || '—'}</td>
|
||||
<div className="rel-panel rel-details">
|
||||
<header className="rel-panel-head">
|
||||
<h2>同步明细</h2>
|
||||
<span className="rel-hint">行来自持久作业阶段,worker 重启后仍可读</span>
|
||||
</header>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 / 对象</th>
|
||||
<th>阶段</th>
|
||||
<th>进度</th>
|
||||
<th>状态</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer className="rel-log">
|
||||
<span>
|
||||
最近事件:
|
||||
{job?.error
|
||||
? job.error.slice(0, 120)
|
||||
: running
|
||||
? '作业执行中;无错误。'
|
||||
: job
|
||||
? `作业${jobStateText(job)};无错误。`
|
||||
: sealJob
|
||||
? '封存作业已结束。'
|
||||
: '尚无作业记录。'}
|
||||
</span>
|
||||
<span>文件传输 100% ≠ 作业完成;生产验证通过前不显示“完成”。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(job?.stages ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="rel-table-empty">
|
||||
尚无阶段记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
job!.stages.map((row) => (
|
||||
<tr key={row.stage}>
|
||||
<td>{row.stage}</td>
|
||||
<td>{row.stage === 'export' ? '测试封存' : row.stage}</td>
|
||||
<td>
|
||||
{row.total != null ? `${row.completed ?? '—'} / ${row.total}` : '未知总量'}
|
||||
{row.bytes_total != null
|
||||
? ` · ${formatBytes(row.bytes_done ?? 0)} / ${formatBytes(row.bytes_total)}`
|
||||
: ''}
|
||||
</td>
|
||||
<td>
|
||||
{row.state === 'succeeded'
|
||||
? '已完成'
|
||||
: row.state === 'running'
|
||||
? '执行中'
|
||||
: row.state === 'failed'
|
||||
? '失败'
|
||||
: row.state === 'skipped'
|
||||
? '已跳过'
|
||||
: '等待'}
|
||||
</td>
|
||||
<td>{row.detail || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<footer className="rel-log">
|
||||
<span>
|
||||
最近事件:
|
||||
{job?.error
|
||||
? job.error.slice(0, 120)
|
||||
: running
|
||||
? '作业执行中;无错误。'
|
||||
: job
|
||||
? `作业${jobStateText(job)};无错误。`
|
||||
: sealJob
|
||||
? '封存作业已结束。'
|
||||
: '尚无作业记录。'}
|
||||
</span>
|
||||
<span>文件传输 100% ≠ 作业完成;生产验证通过前不显示“完成”。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// F03: list rows must use the same exact snapshot query as the detail. The
|
||||
// scope-wide latest passed record is never allowed to color another snapshot.
|
||||
function SnapshotVerificationBadge({ scope, snapshotID }: { scope: Scope; snapshotID: string }) {
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading')
|
||||
const [record, setRecord] = useState<Awaited<ReturnType<typeof fetchSnapshotVerification>>['snapshot_latest']>(null)
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState('loading')
|
||||
setRecord(null)
|
||||
setError('')
|
||||
fetchSnapshotVerification(scope, snapshotID, controller.signal)
|
||||
.then((value) => {
|
||||
setRecord(value.snapshot_latest ?? null)
|
||||
setState('loaded')
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if ((reason as Error)?.name === 'AbortError') return
|
||||
setError(reason instanceof Error ? reason.message : '读取该快照的恢复验证记录失败')
|
||||
setState('error')
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [scope, snapshotID])
|
||||
if (state === 'loading') return <span className="rel-pill gray">读取验证记录…</span>
|
||||
if (state === 'error')
|
||||
return (
|
||||
<span className="rel-pill red" title={error}>
|
||||
验证状态读取失败
|
||||
</span>
|
||||
)
|
||||
if (!record) return <span className="rel-pill gray">未做生产恢复验证</span>
|
||||
return record.status === 'passed' ? (
|
||||
<span className="rel-pill green">恢复验证通过 · {record.job_id}</span>
|
||||
) : (
|
||||
<span className="rel-pill red">恢复验证未通过 · {record.job_id}</span>
|
||||
)
|
||||
}
|
||||
|
||||
// F03: exact snapshot evidence. This component deliberately reads
|
||||
// snapshot_latest, not latest: a passed record for another snapshot must not
|
||||
// make this one look verified.
|
||||
@@ -1506,7 +1590,7 @@ function Drawer({
|
||||
<span className={`rel-pill ${sealed ? 'green' : 'gray'}`}>{versionLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="rel-pill gray">分项完整 · 未做生产恢复验证</span>
|
||||
<SnapshotVerificationBadge scope={view.scope} snapshotID={snapshot.snapshot_id} />
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user