Compare commits

...

5 Commits

Author SHA1 Message Date
姚章浩 47be4ae502 fix(web): 未读到目标观测不再误报非空并标注旧观测
build / build (push) Successful in 1m46s
根因:ReleaseTargetsView 在 overview 缺失时自己补 {state:'unknown'},而
productionTargetClass 把"unknown 且没有 empty_baseline"一律当成非空。后端对
"已探测且非空"发的正是这个形状(Go struct 上 omitempty),所以页面本地默认值
与真实观测在 JSON 上无法区分:首次加载、整库状态读取失败、当前范围没有数据
三条路径都会显示"目标非空,尚无权威版本",管理员还会拿到会返回 409 的
"建立目标权威基线"入口。

修法:没有 overview 时 production 保持 null,分类走新的 no-observation 分支;
四类有效响应(known / 已读且为空 / 非空无版本 / 不可读)的判定与入口不变。
刷新失败保留上一份 overview 时标注"上次观测 · 刷新失败,可能已过期",不把旧
结果当成刚验证的状态。后端 gate、权限、预备份与 API 拒绝一律未改。

测试:把旧"三次 SSR 当刷新"的用例撤掉,改为同一挂载 + 可控 fetch 的真实行为
测试(首次读取未返回 / 空→不可读→恢复 / 刷新失败保留旧观测 / 内外切换 /
overview 属于别的范围);缺观测的三条路径另以 SSR 负例复用审核者的反例。
新增 jsdom 仅作为 vitest 的 DOM 环境(商品代码零改动,npm ci 与锁文件已核)。

验证:make verify 16 项 success(tests-web 504 passed、tsc 无错、npm audit 0);
新用例先在原实现上红(断言收到"目标非空"),修后绿。未部署、未合并、未改
候选运行栈。
2026-11-02 12:29:48 +08:00
姚章浩 6827fa922f fix(candidate): 夹具用产品探针证空并复用真实索引 mapping
build / build (push) Successful in 1m55s
首轮试跑暴露三处夹具缺陷(都在 test-only 文件里,产品代码未动):
- 目标骨架只核 knowledge_bases:最小夹具 schema 没有 tenants 表,改为按 KB 行数与
  产品 ScopeContentEmpty 的十表计数证空;
- 运行期索引曾把 GET _mapping 的结果再包一层 mappings,ES 400;
  现在直接回放原 mapping 文档(properties 层级与封存时完全一致);
- 目标指纹改在夹具分配桶名之后用同一份 env 计算,运行器再把桶名写进 API/worker env。

试跑两次均按 cleanup_empty_target_ui.py 精确清理并读回(resources 全不存在)。
2026-11-02 11:11:08 +08:00
姚章浩 c31a7df4ca style(test-scope): 可选范围只追加两条,恢复原有紧凑格式
build / build (push) Successful in 1m50s
上一笔把 scripts/test-scope.json 整体重排成缩进格式,diff 变成 196 行噪音。
这里恢复文件原本的一行一条格式,只保留新增的 TestCandidateEmptyTargetFixture /
TestCandidateAttestation 两条;内容(112 条可选)未变。
2026-11-02 11:02:52 +08:00
姚章浩 770252b55a test(candidate): 空目标候选夹具与网关审计续签
父要在真实浏览器里点"签发授权 → 启动同步",需要一个目标确实为空、来源确实存在
的候选环境。两个 test-only 入口(默认 skip,已登记 scripts/test-scope.json 的可选范围):

- TestCandidateEmptyTargetFixture:用已有 DS02/r38 夹具装配十表全非空的独立 fullshape
  测试来源并封存,登记为 test_sealed;同时用产品自己的 ScopeContentEmpty 证明目标范围
  为空(KB/tenant 骨架在、十表内容 0 行),并另建同 mapping 的运行期索引(夹具索引会在
  测试结束时删除)。目标指纹用夹具真实桶名重算,保证与 API/worker 同一份 env 一致。
- TestCandidateAttestation:签发/续签 object-gateway 审计(有效期上限 1 小时),
  四个证据摘要绑真实文件(写入者清单、只读策略、reader 拒写探测、真实排空记录)。

不改产品代码、不放宽任何准入;来源明确标 fixture-assembled-legal-fullshape,
不冒称原 C01 两份封存或 Capture/Export HTTP。
2026-11-02 11:02:38 +08:00
姚章浩 c256f2985c fix(web): 空/非空/不可读目标分开呈现并收窄建基线入口
build / build (push) Successful in 1m42s
后端 overview 早已区分 known / unknown+empty_baseline / unknown 非空 / unreachable,
页面只判断 state === 'known',把"已证空"和"读不到"都写成"未知",并在两种情况下
都给出"建立目标权威基线"入口——而该接口对空目标返回 409
library_baseline_target_empty、对读不到的目标返回 503 library_baseline_unprobeable。
按后端真实契约做最小分类:

- LibraryKnown 补 empty_baseline 与 unreachable 两个已存在的字段/状态;
- 页面按四类分别呈现:known 显示生效版本+快照摘要;空目标写明"已读且为空/首次发布/
  写前仍生成完整预备份";非空缺权威版本才指向基线入口;不可读照实说读取失败;
- baselineNeeded 从"非 known"收窄为"非空且无权威版本",不动授权、写闸、预备份、
  容量与 worker 执行准入等任何后端检查,也不放宽后端的 409。

行为测试新增四类状态、空→不可读→空刷新不残留旧状态、空目标无候选时按钮禁用;
原"非 known 一律未知"的断言随文案更正。本地门禁 16 项 success
(tests-web 498 passed,tsc -b 无错)。
2026-11-02 10:56:29 +08:00
9 changed files with 1417 additions and 17 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 开发快照(发布管理:空目标观测与基线入口)
### Fixed
- 发布管理:还没有读到生产目标状态时(首次加载、整库状态读取失败、当前范围没有数据)
不再把本地默认值当成"目标非空",也不再提供会返回 409/503 的"建立目标权威基线"入口;
有效四类响应(有权威版本 / 已读且为空 / 非空无版本 / 不可读)的呈现与入口保持不变。
- 发布管理:刷新失败但仍保留上一次目标观测时,页面标明"上次观测 · 刷新失败,可能已过期",
不把旧结果当成刚刚验证过的状态。
## 开发快照(发布 HTTP 成功闭环:真实监听 POST /library/syncs → handoff → executor → 幂等重放)
### Added
@@ -0,0 +1,366 @@
package v071
// 候选页面(空目标首次发布)的夹具准备:只在显式 opt-in 时运行,产物留在运行器登记的
// 隔离资源里,供父在真实浏览器里点"签发授权 → 启动同步"。
//
// 与已审 r39 publish-http 的区别(必须写清,避免把两件事混成一件):
// - r39 证明的是"正式 publish HTTP + 同请求幂等",它的目标是非空并先建了基线;
// - 这里只准备一个**空**目标(KB 行存在、十表内容为 0)与一份**独立 fullshape 测试来源**,
// 来源快照由本夹具装配并封存,明确标 fixture,不冒称原 C01 两份封存的云发布;
// - 不做任何发布:写入必须由父在页面上触发,交付前目标保持为空。
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/objectgateway"
postgresstore "knowledge-governance/internal/platform/postgres"
)
func TestCandidateEmptyTargetFixture(t *testing.T) {
if os.Getenv("KG_UI_CANDIDATE_PREP") != "1" {
t.Skip("requires explicit candidate fixture opt-in (KG_UI_CANDIDATE_PREP=1)")
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute)
defer cancel()
out := os.Getenv("KG_UI_PREP_OUT")
if out == "" || !filepath.IsAbs(out) {
t.Fatal("KG_UI_PREP_OUT must be an absolute path")
}
sourceArchive := os.Getenv("KG_UI_SOURCE_ARCHIVE")
govDSN := os.Getenv("KG_UI_GOVERNANCE_DSN")
runtimeIndex := os.Getenv("KG_UI_RUNTIME_INDEX")
if sourceArchive == "" || govDSN == "" || runtimeIndex == "" {
t.Fatal("KG_UI_SOURCE_ARCHIVE / KG_UI_GOVERNANCE_DSN / KG_UI_RUNTIME_INDEX are required")
}
// 目标指纹必须与 API/release worker 用同一份 env 推导(运行器给三者同一份 env 文件)。
// 它只绑数据身份(库/租户/知识库/索引/桶),换端点不改指纹。
// 桶名由夹具分配(reset 时会生成唯一桶),所以指纹必须用夹具真实桶名计算,
// 运行器随后把它写进 API/worker 的 env —— 两边因此得到同一个指纹。
fx := openWriteSetFixtureWith(t, writeSetFixtureOptions{reset: true, archiveRoot: sourceArchive, embeddingDimension: 1024})
if err := os.Setenv("LIBRARY_PRODUCTION_S3_BUCKET", fx.bucket); err != nil {
t.Fatal(err)
}
cfg, err := appconfig.Load(os.Getenv)
if err != nil {
t.Fatalf("load product config from candidate env: %v", err)
}
fingerprint := cfg.LibraryTargetIdentity()
if fingerprint == "" {
t.Fatal("candidate target fingerprint is empty: product config is incomplete")
}
// 适配层解析 embedding 模型/向量库需要这两列与两张表(与 r39 fullshape 同一装配)。
if _, err := fx.pool.Exec(ctx, `ALTER TABLE knowledge_bases ADD COLUMN embedding_model_id text, ADD COLUMN vector_store_id text;
CREATE TABLE models(id text PRIMARY KEY,name text,parameters jsonb,deleted_at timestamptz);
CREATE TABLE vector_stores(id text PRIMARY KEY,engine_type text,index_config jsonb,deleted_at timestamptz);
INSERT INTO models VALUES('candidate-model','text-embedding-v4','{"embedding_parameters":{"dimension":1024}}',NULL);
INSERT INTO vector_stores VALUES('candidate-vector','elasticsearch_v8','{}',NULL);
UPDATE knowledge_bases SET embedding_model_id='candidate-model',vector_store_id='candidate-vector';`); err != nil {
t.Fatalf("seed embedding model/vector store: %v", err)
}
// 空目标的第一条证据:产品自己的范围探针必须说"空",且逐表计数为 0。
empty, counts, err := ScopeContentEmpty(ctx, fx.pool, ProductionScope{TenantID: wsTargetTenant, KnowledgeBaseID: wsTargetKB})
if err != nil {
t.Fatalf("product scope probe on empty target: %v", err)
}
if !empty {
t.Fatalf("candidate target scope is not empty: %v", counts)
}
for table, n := range counts {
if n != 0 {
t.Fatalf("candidate target %s has %d rows in scope; the target must be empty before delivery", table, n)
}
}
var kbRows int64
if err := fx.pool.QueryRow(ctx, `SELECT count(*) FROM knowledge_bases WHERE id=$1 AND tenant_id=$2`, wsTargetKB, wsTargetTenant).Scan(&kbRows); err != nil {
t.Fatal(err)
}
if kbRows != 1 {
t.Fatalf("empty target still needs its KB skeleton: knowledge_bases rows=%d", kbRows)
}
mapping, err := fx.target(t).Search().MappingSHA256(ctx)
if err != nil {
t.Fatalf("read fixture index mapping digest: %v", err)
}
// 独立 fullshape 测试来源:声明行 → 真实 PG schema 落库并读回 → 用读回封存。
spec := r38LegalSourceSpec(t, fx)
readback, sourceStoreEvidence := r39SeedSourceStore(t, fx, spec)
source := r38SealFullShape(t, fx, ds02Spec{rows: readback, vectors: ds02Vectors, objects: ds02Objects}, mapping)
parts := r38SourcePartCounts(t, fx, source)
if len(parts) != len(ds02TargetTables) {
t.Fatalf("sealed source has %d table parts, want %d", len(parts), len(ds02TargetTables))
}
// 运行期索引:夹具索引会在测试结束时被删;用同一份 mapping 另建索引,
// 让封存里的 index_mapping_sha256 与运行期目标索引仍逐字节一致。
mappingBody, err := candidateIndexMapping(ctx, fx.searchBase, fx.index)
if err != nil {
t.Fatalf("read fixture index mapping: %v", err)
}
if err := candidateCreateIndex(ctx, fx.searchBase, runtimeIndex, mappingBody); err != nil {
t.Fatalf("create candidate runtime index: %v", err)
}
exporter, err := NewSearchExporter(SearchExporterConfig{BaseURL: fx.searchBase, Index: runtimeIndex})
if err != nil {
t.Fatal(err)
}
runtimeMapping, err := exporter.MappingSHA256(ctx)
if err != nil {
t.Fatalf("read runtime index mapping digest: %v", err)
}
if runtimeMapping != mapping {
t.Fatalf("runtime index mapping %s != sealed %s", runtimeMapping, mapping)
}
// 一次性治理库:迁移 + 网关 schema,再登记这份 test_sealed 版本。
gov, err := pgxpool.New(ctx, govDSN)
if err != nil {
t.Fatal(err)
}
defer gov.Close()
if err := postgresstore.ApplyMigrations(ctx, gov); err != nil {
t.Fatalf("apply governance migrations: %v", err)
}
if _, err := gov.Exec(ctx, objectgateway.Schema); err != nil {
t.Fatalf("apply object gateway schema: %v", err)
}
store := postgresstore.NewStore(gov).WithLibraryTarget(fingerprint)
sourceIdentity := governance.LibrarySourceIdentity{Project: os.Getenv("LIBRARY_SOURCE_PROJECT"),
App: os.Getenv("LIBRARY_SOURCE_APP"), Scope: "internal",
TenantID: wsSourceTenant, KnowledgeBaseID: wsSourceKB}
sourceID, err := sourceIdentity.ID()
if err != nil {
t.Fatal(err)
}
versionID := governance.LibraryVersionID(governance.LibraryVersionTestSealed, "internal", source)
if err := store.RecordLibraryVersion(ctx, governance.LibraryVersion{ID: versionID, Scope: "internal",
Kind: governance.LibraryVersionTestSealed, SnapshotID: source,
Evidence: map[string]any{
"source_id": sourceID, "source_identity": sourceIdentity,
"capture_job": "candidate-empty-target-fixture",
"assembly": map[string]any{"kind": "fixture-assembled-legal-fullshape",
"source_store": "r39_source schema in the isolated candidate business DB",
"note": "候选页面的独立 fullshape 测试夹具;不是 WeKnora agent 捕获,也不是原 C01 两份封存,不声称 Capture/Export HTTP 已验收",
"manifest_sha256": source, "source_parts_row_counts": parts},
},
CreatedBy: "admin", CreatedAt: time.Now().UTC()}); err != nil {
t.Fatalf("register candidate test_sealed version: %v", err)
}
candidate, found, err := store.LibrarySourceCandidate(ctx, "internal", sourceID)
if err != nil {
t.Fatal(err)
}
if !found || candidate.SnapshotID != source {
t.Fatalf("registered candidate is not readable back by source_id: found=%v snapshot=%v", found, candidate.SnapshotID)
}
// 网关的只读权威角色:角色名/口令由运行器生成并登记所有权,这里只负责创建与授权。
// 授权范围与网关真正读取的表一致(jobs / library_*),不给写权限。
readerRole, readerPassword := os.Getenv("KG_UI_READER_ROLE"), os.Getenv("KG_UI_READER_PASSWORD")
readerDSNFile := os.Getenv("KG_UI_READER_DSN_FILE")
if readerRole == "" || readerPassword == "" || !filepath.IsAbs(readerDSNFile) {
t.Fatal("KG_UI_READER_ROLE / KG_UI_READER_PASSWORD / KG_UI_READER_DSN_FILE are required")
}
quotedRole := pgx.Identifier{readerRole}.Sanitize()
if _, err := gov.Exec(ctx, "CREATE ROLE "+quotedRole+" LOGIN PASSWORD '"+readerPassword+"'"); err != nil {
t.Fatalf("create gateway reader role: %v", err)
}
if _, err := gov.Exec(ctx, "GRANT USAGE ON SCHEMA public TO "+quotedRole); err != nil {
t.Fatalf("grant reader schema usage: %v", err)
}
if _, err := gov.Exec(ctx, "GRANT SELECT ON jobs,library_jobs,library_operations,library_attempts,library_versions TO "+quotedRole); err != nil {
t.Fatalf("grant reader table select: %v", err)
}
readerURL, err := url.Parse(govDSN)
if err != nil {
t.Fatal(err)
}
readerURL.User = url.UserPassword(readerRole, readerPassword)
if err := os.WriteFile(readerDSNFile, []byte(readerURL.String()), 0o600); err != nil {
t.Fatalf("write reader DSN file: %v", err)
}
report := map[string]any{
"generated_at": time.Now().UTC().Format(time.RFC3339Nano),
"kind": "candidate-empty-target-fixture",
"target_fingerprint": fingerprint,
"target": map[string]any{"tenant_id": wsTargetTenant, "knowledge_base_id": wsTargetKB,
"database": os.Getenv("KG_LIBRARY_TEST_DATABASE_URL") != "", "empty_probe_counts": counts,
"knowledge_bases_rows": kbRows,
"note": "KB 骨架存在、十表内容为 0:这是首次发布的空目标,不是'没有目标'"},
"runtime_index": runtimeIndex,
"mapping_sha256": mapping,
"runtime_mapping_sha256": runtimeMapping,
"bucket": fx.bucket,
"s3_endpoint": os.Getenv("KG_LIBRARY_TEST_S3_ENDPOINT"),
"source": map[string]any{"snapshot": source, "source_id": sourceID,
"project": sourceIdentity.Project, "app": sourceIdentity.App,
"tenant_id": sourceIdentity.TenantID, "knowledge_base_id": sourceIdentity.KnowledgeBaseID,
"parts": parts, "assembly": "fixture-assembled-legal-fullshape",
"source_store": sourceStoreEvidence},
"version": map[string]any{"id": versionID, "kind": string(governance.LibraryVersionTestSealed),
"snapshot": source, "created_by": "admin"},
}
raw, err := json.MarshalIndent(report, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(out, raw, 0o600); err != nil {
t.Fatal(err)
}
t.Logf("candidate fixture ready: snapshot=%s source_id=%s version=%s", source, sourceID, versionID)
}
func TestCandidateAttestation(t *testing.T) {
if os.Getenv("KG_UI_ATTESTATION") != "1" {
t.Skip("requires explicit candidate attestation opt-in (KG_UI_ATTESTATION=1)")
}
// 网关每次请求都重读审计文件,所以刷新只需原子替换该文件:不用重启网关,
// 也不会与正在进行的发布互相干扰。有效期上限是 1 小时(objectgateway.AuditVerifier)。
seedRaw, err := hex.DecodeString(os.Getenv("KG_UI_ATTESTATION_SEED"))
if err != nil || len(seedRaw) != ed25519.SeedSize {
t.Fatal("KG_UI_ATTESTATION_SEED must be a 32-byte hex seed")
}
path := os.Getenv("KG_UI_AUDIT_FILE")
pubPath := os.Getenv("KG_UI_ATTESTATION_PUB_FILE")
if !filepath.IsAbs(path) || !filepath.IsAbs(pubPath) {
t.Fatal("KG_UI_AUDIT_FILE and KG_UI_ATTESTATION_PUB_FILE must be absolute paths")
}
evidence := map[string]string{}
for key, env := range map[string]string{
"writer_inventory_sha256": "KG_UI_EVIDENCE_WRITER",
"policy_evidence_sha256": "KG_UI_EVIDENCE_POLICY",
"denial_evidence_sha256": "KG_UI_EVIDENCE_DENIAL",
"drain_evidence_sha256": "KG_UI_EVIDENCE_DRAIN",
} {
value := os.Getenv(env)
if len(value) != 64 {
t.Fatalf("%s must be a 64-hex digest of a real evidence file", env)
}
evidence[key] = value
}
scope := objectgateway.Digest([]byte(os.Getenv("OBJECT_GATEWAY_PROVIDER") + "\x00" +
os.Getenv("OBJECT_GATEWAY_ENDPOINT") + "\x00" + os.Getenv("OBJECT_GATEWAY_BUCKET")))
now := time.Now().UTC()
key := ed25519.NewKeyFromSeed(seedRaw)
payload, err := json.Marshal(objectgateway.Attestation{Protocol: objectgateway.Protocol, Scope: scope,
GatewayID: os.Getenv("OBJECT_GATEWAY_ID"), IssuedAt: now.Add(-2 * time.Second), ExpiresAt: now.Add(45 * time.Minute),
WriterInventory: evidence["writer_inventory_sha256"], PolicyEvidence: evidence["policy_evidence_sha256"],
DenialEvidence: evidence["denial_evidence_sha256"], DrainEvidence: evidence["drain_evidence_sha256"]})
if err != nil {
t.Fatal(err)
}
signed, err := json.Marshal(objectgateway.SignedAttestation{Payload: payload, Signature: ed25519.Sign(key, payload)})
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
temp := path + ".new"
if err := os.WriteFile(temp, signed, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(temp, 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(temp, path); err != nil {
t.Fatal(err)
}
public := base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey))
if err := os.WriteFile(pubPath, []byte(public), 0o600); err != nil {
t.Fatalf("write gateway audit public key: %v", err)
}
t.Logf("candidate attestation refreshed: scope=%s gateway=%s expires=%s", scope, os.Getenv("OBJECT_GATEWAY_ID"), now.Add(45*time.Minute).Format(time.RFC3339))
}
func candidateIndexMapping(ctx context.Context, base, index string) (map[string]any, error) {
response, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+index+"/_mapping", nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 20 * time.Second}
res, err := client.Do(response)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("read mapping %s: HTTP %d", index, res.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(res.Body, 4<<20))
if err != nil {
return nil, err
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
mappings, ok := payload[index]
if !ok {
return nil, fmt.Errorf("mapping response does not contain index %s", index)
}
var value map[string]any
if err := json.Unmarshal(mappings, &value); err != nil {
return nil, err
}
return value, nil
}
func candidateCreateIndex(ctx context.Context, base, index string, body map[string]any) error {
client := &http.Client{Timeout: 20 * time.Second}
check, err := http.NewRequestWithContext(ctx, http.MethodHead, base+"/"+index, nil)
if err != nil {
return err
}
if res, err := client.Do(check); err == nil {
res.Body.Close()
if res.StatusCode == http.StatusOK {
return fmt.Errorf("runtime index %s already exists: refusing to reuse an unknown index", index)
}
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, base+"/"+index, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
res, err := client.Do(request)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
return fmt.Errorf("create runtime index %s: HTTP %d %s", index, res.StatusCode, detail)
}
return nil
}
+3 -1
View File
@@ -428,6 +428,8 @@
"package": "knowledge-governance/internal/releaseadapter/v071",
"test": "TestContractIndexClaimDoesNotAlterMappingIsolated",
"reason": "真实隔离PG/ES/MinIO;验证mapping哈希不变和成功后无暂态marker,默认CI无这些服务"
}
},
{"package":"knowledge-governance/internal/releaseadapter/v071","test":"TestCandidateEmptyTargetFixture","reason":"需显式一次性本机PG/ES/MinIO与运行器注入的隔离库/归档根:为候选页面装配并封存独立 fullshape 测试来源、证空目标、登记 test_sealed;普通CI跳过不是通过"},
{"package":"knowledge-governance/internal/releaseadapter/v071","test":"TestCandidateAttestation","reason":"需运行器注入一次性 ed25519 种子与四份真实证据摘要:续签 object-gateway 审计(有效期上限1h);普通CI没有注入,跳过不是通过"}
]
}
+527
View File
@@ -17,12 +17,66 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
"vitest": "5.0.3"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "7.1.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz",
"integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.4.3",
"@csstools/css-color-parser": "^4.2.6",
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz",
"integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"bidi-js": "^1.1.0",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -305,6 +359,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
"integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.2.1"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
"integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
"integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz",
"integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.2",
"@csstools/css-calc": "^3.4.3"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.2",
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
"integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.2"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz",
"integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
"integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -747,6 +954,24 @@
"node": ">=18"
}
},
"node_modules/@exodus/bytes": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
"integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1405,6 +1630,16 @@
"node": ">=6.0.0"
}
},
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -1537,12 +1772,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
"integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.1.2"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1560,6 +1823,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1602,6 +1872,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz",
"integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -1792,6 +2075,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-encoding-sniffer": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
"integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1"
},
"engines": {
"node": "^22.13.0 || >=24.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -1864,6 +2160,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1871,6 +2174,56 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "30.1.3",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
"integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^7.1.3",
"@asamuzakjp/dom-selector": "^10.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.15",
"@exodus/bytes": "^1.16.0",
"css-tree": "^3.2.1",
"data-urls": "^8.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^7.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.3",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"tough-cookie": "^6.0.2",
"undici": "^8.12.1",
"w3c-xmlserializer": "^6.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^17.2.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"canvas": "^3.2.3"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2209,6 +2562,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/micromark": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
@@ -2867,6 +3227,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz",
"integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^9.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2942,6 +3315,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
@@ -3066,6 +3449,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@@ -3112,6 +3505,19 @@
"fsevents": "~2.3.2"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -3224,6 +3630,52 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.4.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
"integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.19"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.20",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz",
"integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
"integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -3258,6 +3710,16 @@
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "8.12.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
"integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -3562,6 +4024,54 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
"integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
"integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.16.0",
"tr46": "^7.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
@@ -3575,6 +4085,23 @@
"node": ">=20.11"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.1",
"jsdom": "^30.1.3",
"prettier": "3.9.10",
"typescript": "5.9.2",
"vite": "7.3.7",
+266
View File
@@ -0,0 +1,266 @@
// @vitest-environment jsdom
//
// 同一挂载上的真实刷新/切换行为:ReleaseTargets 自己走 load() + fetch。
// 旧测试拿三次 renderToStaticMarkup 当成"刷新",那是三个互不相关的静态输入,
// 既不执行 effect 也不发请求,证明不了刷新后不残留旧状态(G1)。
// 这里只用一个挂载 + 可控 fetch,覆盖:
// - 首次读取未返回 / 请求失败 / 当前范围没有 overview:都不许说"目标非空";
// - 空 → 不可读 → 恢复(同一挂载上的真实重新读取);
// - 刷新失败保留旧观测时必须标成上次观测,恢复后清掉;
// - 对内/对外切换不串用,overview 属于别的范围时也不借用。
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import type { Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AuthContext } from './auth'
import type { LibraryOverview } from './librarySnapshots'
import { ReleaseTargets } from './ReleaseTargets'
const adminValue = {
identity: {
id: 'u-admin',
username: 'admin',
display_name: '管理员',
role: 'admin' as const,
must_change_password: false,
},
ready: true,
login: async () => null,
logout: async () => undefined,
changePassword: async () => null,
signedOut: 0,
notifySignedOut: () => undefined,
}
type Scope = 'internal' | 'external'
type Reply = { status: number; body: unknown; hold?: Promise<void> }
const emptyTarget: LibraryOverview['production'] = {
state: 'unknown',
empty_baseline: true,
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
}
const unreadableTarget: LibraryOverview['production'] = {
state: 'unreachable',
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
}
const snapshot = {
snapshot_id: 'f992d64ea45bf737a0721a465b5aef2b150ef7757157eb84f3aaf8b6972d98f9',
scope: 'internal',
knowledge_base_id: '0c84a098-d3ed-4b82-a165-e6de9691d6a8',
tenant_id: 10000,
created_at: '2026-11-02T02:00:00Z',
validation_policy: 'artifact-acceptance-v1',
format: 'weknora-library-snapshot/v1',
compatibility: {
weknora_version: 'v0.7.1',
schema_version: 74,
retriever_engine: 'elasticsearch_v8',
embedding_model: 'text-embedding-v4',
embedding_dimension: 1024,
index_mapping_sha256: 'a'.repeat(64),
},
parts: [],
logical_bytes: 0,
unique_bytes: 0,
shared_bytes: 0,
totals: { documents: 0, chunks: 0, index_documents: 0, resources: 0, wiki_pages: 0 },
}
const overviewBody = (scope: string, production: LibraryOverview['production']) => ({
scope,
test_sealed: { ...snapshot, scope },
production,
prebackup: { state: 'unknown', reason: '本次目标快照还没有生产预备份' },
diff: { state: 'unknown', reason: '生产与测试的文档级差异读取未接线', added: [], updated: [], removed: [] },
jobs: [],
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
capture_available: false,
now: '2026-11-02T02:05:00Z',
})
const snapshotsBody = {
available: true,
reason: '',
archive_root: '/tmp/archive',
snapshots: [],
versions: 0,
content_bytes: 0,
blob_file_bytes: 0,
blob_allocated_bytes: 0,
unreferenced_blobs: 0,
unreferenced_bytes: 0,
manifest_file_bytes: 0,
manifest_allocated_bytes: 0,
capture_note: '',
capture_available: false,
capture_reason: '',
}
const reply = (body: unknown, status = 200): Reply => ({ status, body })
const json = (body: unknown, status: number) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
let overviews: Record<Scope, Reply>
let container: HTMLDivElement
let root: Root
let originalFetch: typeof fetch
const mount = async () => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<AuthContext.Provider value={adminValue}>
<ReleaseTargets />
</AuthContext.Provider>
)
})
await settle()
}
// load() 是几层 await(fetch → json → setState);按微任务轮次推进到稳定。
const settle = async () => {
for (let round = 0; round < 6; round += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
}
const text = () => container.textContent ?? ''
const buttonLabels = () => Array.from(container.querySelectorAll('button')).map((node) => node.textContent ?? '')
const click = async (label: string) => {
const target = Array.from(container.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes(label)
)
if (!target) throw new Error(`没有找到按钮:${label}(现有:${buttonLabels().join(' / ')})`)
await act(async () => {
target.dispatchEvent(new window.MouseEvent('click', { bubbles: true }))
})
await settle()
}
beforeEach(() => {
originalFetch = globalThis.fetch
overviews = {
internal: reply(overviewBody('internal', emptyTarget)),
external: reply(overviewBody('external', unreadableTarget)),
}
globalThis.fetch = (async (input: RequestInfo | URL) => {
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/overview')) {
const scope = new URL(url, 'http://candidate.invalid').searchParams.get('scope')
const answer = overviews[scope === 'external' ? 'external' : 'internal']
if (answer.hold) await answer.hold
return json(answer.body, answer.status)
}
return json({ error: '测试未声明该请求' }, 404)
}) as typeof fetch
})
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount()
})
}
container?.remove()
globalThis.fetch = originalFetch
})
describe('ReleaseTargets 同一挂载上的目标观测(F1/G1)', () => {
it('首次读取未返回时只说正在读取,不说目标非空', async () => {
let release: () => void = () => undefined
const hold = new Promise<void>((resolve) => {
release = resolve
})
overviews.internal = { ...reply(overviewBody('internal', emptyTarget)), hold }
await mount()
expect(text()).toContain('正在读取目标状态')
expect(text()).not.toContain('目标非空')
expect(text()).not.toContain('目标已读且为空')
release()
await settle()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('正在读取目标状态')
})
it('空 → 不可读 → 恢复:同一挂载按每次真实读取重新分类', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
// 管理员 + 已封存时基线入口本来会出现,但空目标不该给(后端会 409)。
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', unreadableTarget))
await click('刷新')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标非空')
})
it('刷新失败保留旧观测时标成上次观测,恢复读取后清除', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('上次观测')
overviews.internal = reply({ error: '上游不可用' }, 502)
await click('刷新')
expect(text()).toContain('读取失败')
// 旧观测仍在,但不能冒充刚验证。
expect(text()).toContain('目标已读且为空')
expect(text()).toContain('上次观测 · 刷新失败,可能已过期')
expect(text()).not.toContain('目标非空')
overviews.internal = reply(overviewBody('internal', emptyTarget))
await click('刷新')
expect(text()).not.toContain('上次观测')
expect(text()).not.toContain('读取失败')
expect(text()).toContain('目标已读且为空')
})
it('内外切换不串用:对外的不可读不会被内对的空顶替', async () => {
await mount()
expect(text()).toContain('目标已读且为空')
await click('对外知识库')
expect(text()).toContain('无法读取目标整库状态')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
overviews.external = reply(overviewBody('external', emptyTarget))
await click('刷新')
expect(text()).toContain('目标已读且为空')
expect(text()).not.toContain('无法读取目标整库状态')
await click('对内知识库')
expect(text()).toContain('目标已读且为空')
})
it('当前范围没有自己的 overview 时不借用别的范围分类', async () => {
// 服务端把对内的 overview 回给了对外请求:scope 不匹配,等于没有观测。
overviews.external = reply(overviewBody('internal', emptyTarget))
await mount()
await click('对外知识库')
expect(text()).toContain('还没有读到')
expect(text()).not.toContain('目标已读且为空')
expect(text()).not.toContain('目标非空')
expect(buttonLabels()).not.toContain('建立目标权威基线(维护隔离)')
})
})
+147 -2
View File
@@ -184,8 +184,9 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
it('未接入项显示“未接入/未知”,不用 0 / 无变化冒充', () => {
const html = render()
expect(html).toContain('生产当前')
// 生产没有权威版本时必须写未知,不能拿测试快照顶替
expect(html).toContain('未知(尚未建立权威版本)')
// 生产没有权威版本时必须写"非空且尚无权威版本",不能拿测试快照顶替,
// 也不能把所有非 known 一律写成"未知"(空目标与读不到是另外两类)。
expect(html).toContain('目标非空,尚无权威版本')
expect(html).toContain('尚无记录 · 生产与测试的文档级差异读取未接线')
expect(html).toContain('未接入')
})
@@ -708,6 +709,150 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
})
})
// 后端 /api/v1/library/overview 已经区分四类生产目标状态(library_flow.go unknownLibraryTarget、
// library_baseline.go:81 的空目标 409)。页面此前只判断 state === 'known',于是"已证空"和
// "读不到"都被写成"未知"、并给出必然 409 的"建立目标权威基线"入口。
describe('生产目标四类状态:known / 空 / 非空 / 不可读', () => {
const baseline = { canStart: true, pending: false, onStart: vi.fn() }
const emptyTarget: LibraryOverview['production'] = {
state: 'unknown',
empty_baseline: true,
reason: '生产目标可读且为空:本次是首次发布,写入前仍会生成完整预备份',
}
const nonEmptyTarget: LibraryOverview['production'] = {
state: 'unknown',
reason: '目标非空且尚无权威版本',
}
const unreadableTarget: LibraryOverview['production'] = {
state: 'unreachable',
reason: '无法读取目标整库状态:请检查目标连接后重试,不能据此判定空库或建立基线',
}
const knownTarget: LibraryOverview['production'] = {
state: 'known',
version: {
id: 'v-known',
kind: 'production_effective',
snapshot_id: 'c'.repeat(64),
version_label: '生效 20261030-01',
source_job_id: 'libsync-known',
evidence: {},
created_by: 'admin',
created_at: '2026-10-30T09:00:00Z',
},
}
it('空目标:写明"已读且为空/首次发布/写前仍预备份",且不给会 409 的建基线入口', () => {
const html = render({
baseline,
overview: {
...overview,
production: emptyTarget,
// 接口在空基线上不再给"建立基线"缺项(librarySyncGateWithGrant:657-661)
gate: { sync_allowed: false, reasons: ['没有与目标快照匹配的有效发布授权'] },
},
})
expect(html).toContain('目标已读且为空')
expect(html).toContain('首次发布')
expect(html).toContain('写前仍会生成完整预备份')
// 页面不能把"已证空"写成未知,也不能引导去建一个必然被 409 拒绝的基线。
expect(html).not.toContain('未知(尚未建立权威版本)')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('非空且无生效版本:仍指向既有基线入口,不谎称空库', () => {
const html = render({
baseline,
overview: {
...overview,
production: nonEmptyTarget,
gate: {
sync_allowed: false,
reasons: [
'目标非空且没有生效版本记录:先建立目标权威基线(维护隔离下捕获整库并逐类校验),或确认目标确实是空库',
],
},
},
})
expect(html).toContain('目标非空')
expect(html).toMatch(/建立目标权威基线(维护隔离)<\/button>/)
expect(html).not.toContain('目标已读且为空')
})
it('不可读:照实说读取失败,既不认定空也不认定非空,更不引导建基线', () => {
const html = render({
baseline,
overview: {
...overview,
production: unreadableTarget,
gate: { sync_allowed: false, reasons: [unreadableTarget.reason!] },
},
})
expect(html).toContain('无法读取目标整库状态')
expect(html).not.toContain('目标已读且为空')
expect(html).not.toContain('目标非空')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('known:显示权威生效版本与快照摘要,不再写未知', () => {
const html = render({ baseline, overview: { ...overview, production: knownTarget } })
expect(html).toContain('生效 20261030-01')
expect(html).toContain(knownTarget.version!.snapshot_id.slice(0, 12))
expect(html).not.toContain('未知(尚未建立权威版本)')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
// 真正的一次挂载刷新由 ReleaseTargets.mount.test.tsx 覆盖:三次 renderToStaticMarkup
// 只是三个互不相关的静态输入,不执行 load/effect/fetch,不能算刷新证据。
// F1:没有读到 overview 时,页面自己造的 {state:'unknown'} 与后端"已探测且非空"
// 在 JSON 上长得一模一样。缺观测的三条真实路径都不能落进 nonempty 分支,
// 也不能提供会返回 409 的建基线入口。
it('没有目标观测时不冒充非空:初始加载 / 整体读取失败 / 范围不匹配', () => {
const cases: Array<[string, Partial<ViewProps>, string]> = [
['初始加载中', { loading: true, overview: null }, '正在读取目标状态'],
['初始 overview 请求失败', { error: '发布状态读取失败(HTTP 503)', overview: null }, '不能据此判定空或非空'],
[
'选中对外但只有对内 overview',
{ scope: 'external', overview: { ...overview, scope: 'internal' } },
'还没有读到',
],
]
for (const [name, overrides, expected] of cases) {
const html = render({ baseline, ...overrides })
expect(html, name).toContain(expected)
expect(html, name).not.toContain('目标非空')
expect(html, name).not.toContain('目标已读且为空')
expect(html, name).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
}
})
it('刷新失败但保留旧观测时标成上次观测,不冒充刚验证', () => {
const html = render({
baseline,
error: '发布状态读取失败(HTTP 502)',
overview: { ...overview, production: emptyTarget },
})
expect(html).toContain('目标已读且为空')
expect(html).toContain('上次观测 · 刷新失败,可能已过期')
expect(html).not.toContain('正在读取目标状态')
expect(html).not.toMatch(/建立目标权威基线(维护隔离)<\/button>/)
})
it('空目标但没有测试封存:启动同步保持禁用并说明缺候选', () => {
const html = render({
baseline,
overview: {
...overview,
production: emptyTarget,
test_sealed: undefined,
test_sealed_reason: '还没有该目标的测试封存',
},
})
expect(html).toMatch(/<button[^>]*class="rel-primary"[^>]*disabled/)
expect(html).toContain('尚无测试封存')
})
})
// E5:轮询必须覆盖"投递 → 终态读回"。
// 旧实现只按封存队列的作业判断,于是点了同步/恢复/建立基线之后页面根本不刷新,
// 看上去像"还没启动";要么反过来,一个失败作业被永远轮询下去。
+86 -13
View File
@@ -159,6 +159,32 @@ function scopeView(
const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' : '对外知识库')
// 生产目标必须按接口的四类状态原样呈现:把"已读且为空"和"读不到"都写成"未知",
// 会让人去点一个必然被拒绝的"建立基线"(后端对空目标返回 409 library_baseline_target_empty,
// 对读不到的目标返回 503 library_baseline_unprobeable)。
type ProductionTargetClass = 'known' | 'empty' | 'nonempty' | 'unreachable' | 'no-observation'
// 只有真正读到的 overview 才能分类。页面自己补的 {state:'unknown'} 与后端
// "已探测且非空"在 JSON 上无法区分,所以缺观测必须单列一类(no-observation),
// 否则首次加载、整体读取失败、当前范围没数据都会被误报成"目标非空"(F1)。
function productionTargetClass(production: LibraryOverview['production'] | null | undefined): ProductionTargetClass {
if (!production) return 'no-observation'
if (production.state === 'known' && production.version) return 'known'
if (production.state === 'unreachable') return 'unreachable'
// unknown 有两种:可读且空(空基线,首次发布合法起点)与可读但非空(缺权威版本)。
return production.empty_baseline ? 'empty' : 'nonempty'
}
// 刷新失败时页面会保留上一次的 overview:那份观测仍是当时的真实结果,
// 但已经不是刚刚验证过的状态,必须标出来,不能混作当前结论。
const STALE_NOTE = '上次观测 · 刷新失败,可能已过期'
function noObservationText(loading: boolean, error: string | null): string {
if (loading) return '正在读取目标状态(尚未确认空或非空)'
if (error) return '读取失败,还没有读到目标状态(不能据此判定空或非空)'
return '还没有读到该范围的目标状态'
}
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
function captureHint(view: ScopeView, canWrite: boolean): string {
@@ -684,6 +710,8 @@ function ChangeTable({ diff }: { diff: LibraryOverview['diff'] }) {
function ProtectionCard({
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -692,7 +720,10 @@ function ProtectionCard({
baseline,
onOpen,
}: {
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 没有目标观测时显示的原因(加载中 / 整体读取失败 / 当前范围没数据)。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -702,8 +733,11 @@ function ProtectionCard({
onOpen: () => void
}) {
const backupKnown = prebackup.state === 'known' && prebackup.version
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记)。
const baselineNeeded = production.state !== 'known' && targetSealed
// 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记),
// 读不到或还没读到观测时必须先修连接/等读取,两者都不能被引导到会 409/503 的基线入口。
const productionTarget = productionTargetClass(production)
const baselineNeeded = productionTarget === 'nonempty' && targetSealed
const staleNote = stale ? `(${STALE_NOTE})` : ''
return (
<aside className="rel-panel rel-protection">
<header className="rel-panel-head">
@@ -735,9 +769,23 @@ function ProtectionCard({
</dd>
<dt>生产当前版本</dt>
<dd>
{production.state === 'known' && production.version
? production.version.version_label || production.version.snapshot_id.slice(0, 12)
: '未知(生产还没有权威版本记录)'}
{productionTarget === 'known' && production?.version ? (
<>
{production.version.version_label || production.version.snapshot_id.slice(0, 12)}
<br />
<small>
快照 {production.version.snapshot_id.slice(0, 12)} · 登记于 {stamp(production.version.created_at)}
</small>
</>
) : productionTarget === 'empty' ? (
`目标已读且为空:首次发布还没有生效版本(写前仍会生成完整预备份)${staleNote}`
) : productionTarget === 'unreachable' ? (
`${production?.reason || '读取失败:无法确认目标是否为空'}${staleNote}`
) : productionTarget === 'nonempty' ? (
`未知(目标非空且还没有权威版本)${staleNote}`
) : (
productionNote
)}
</dd>
<dt>备份范围</dt>
<dd>数据库 / 资源 / 索引 / Wiki</dd>
@@ -784,7 +832,10 @@ function ProtectionCard({
interface ReadyProps {
view: ScopeView
diff: LibraryOverview['diff']
production: LibraryOverview['production']
production: LibraryOverview['production'] | null
// 缺观测时的说明与"旧观测"标记都由上层按 loading/error 判定,子组件只负责显示。
productionNote: string
stale: boolean
prebackup: LibraryOverview['prebackup']
gate: LibraryOverview['gate']
authorization: LibraryOverview['authorization']
@@ -801,6 +852,8 @@ function ReadyBody({
view,
diff,
production,
productionNote,
stale,
prebackup,
gate,
authorization,
@@ -819,6 +872,8 @@ function ReadyBody({
<ChangeTable diff={diff} />
<ProtectionCard
production={production}
productionNote={productionNote}
stale={stale}
prebackup={prebackup}
gate={gate}
authorization={authorization}
@@ -1368,7 +1423,13 @@ export function ReleaseTargetsView(props: ViewProps) {
const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending'
// 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。
const target = view.overview?.test_sealed
const production = view.overview?.production ?? { state: 'unknown' as const }
// 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空":
// production 保持 null,分类走 no-observation。
const production = view.overview?.production ?? null
const productionClass = productionTargetClass(production)
// 刷新失败但仍保留上一份 overview:显示可以继续,但必须标成上次观测。
const productionStale = Boolean(error) && production !== null
const productionNote = noObservationText(loading, error)
const prebackup = view.overview?.prebackup ?? { state: 'unknown' as const }
const diff = view.overview?.diff ?? {
state: 'unknown' as const,
@@ -1462,12 +1523,22 @@ export function ReleaseTargetsView(props: ViewProps) {
</div>
<p className="rel-versionline">
生产当前{' '}
{production.state === 'known' && production.version ? (
<b>{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}</b>
{productionClass === 'known' && production?.version ? (
<b>
{production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`}
{' · '}
快照 {production.version.snapshot_id.slice(0, 12)}
</b>
) : productionClass === 'empty' ? (
<b>目标已读且为空(首次发布)</b>
) : productionClass === 'unreachable' ? (
<b className="rel-unknown">读取失败:{production?.reason || '无法读取目标整库状态'}</b>
) : productionClass === 'nonempty' ? (
<b className="rel-unknown">目标非空,尚无权威版本</b>
) : (
<b className="rel-unknown">未知(尚未建立权威版本)</b>
)}{' '}
→ 拟发布{' '}
<b className="rel-unknown">{productionNote}</b>
)}
{productionStale ? <em className="rel-scope">({STALE_NOTE})</em> : null} → 拟发布{' '}
<b>{target ? `封存 ${target.snapshot_id.slice(0, 12)} · ${stamp(target.created_at)}` : '还没有测试封存'}</b> ·{' '}
{publishStateText(view.job)}
</p>
@@ -1549,6 +1620,8 @@ export function ReleaseTargetsView(props: ViewProps) {
view={view}
diff={diff}
production={production}
productionNote={productionNote}
stale={productionStale}
prebackup={prebackup}
gate={gate}
authorization={authorization}
+11 -1
View File
@@ -416,9 +416,19 @@ export interface LibraryVersionRecord {
}
export interface LibraryKnown {
state: 'known' | 'unknown'
/**
* 后端 /api/v1/library/overview 的真实状态:
* - known:有权威生效版本;
* - unknown + empty_baseline:目标可读、确实为空,是首次发布的合法起点;
* - unknown(无 empty_baseline):目标非空却没有权威版本;
* - unreachable:读不到目标,既不能判定空也不能判定非空。
*/
state: 'known' | 'unknown' | 'unreachable'
version?: LibraryVersionRecord
reason?: string
/** 目标可读且内容为空:首次发布。只证明生产范围内容表计数为 0,
* ES/对象身份与残留仍由执行侧预备份/准入核验;页面不得据此宣称"三存储已验证"。 */
empty_baseline?: boolean
/** 只在预备份上有意义:这份备份的内容是否就是当前拟发布目标。 */
bound_to_target?: boolean
}