feat(governance): require dry-run preview before km archive
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 3m34s
CD Pipeline / build-and-deploy (push) Successful in 4m5s
CD Pipeline / post-deploy-checks (push) Successful in 1m41s

This commit is contained in:
Your Name
2026-05-20 01:35:43 +08:00
parent 839b3ea960
commit ba904ec4a1
5 changed files with 225 additions and 28 deletions

View File

@@ -1921,9 +1921,17 @@
},
"archiveActions": {
"archive": "Archive duplicate drafts",
"preview": "Dry-run preview",
"previewing": "Previewing",
"confirm": "Confirm archive",
"confirming": "Archiving",
"archiving": "Archiving",
"failed": "Archive action failed; refresh and verify the latest dedupe plan.",
"requiresOwner": "Owner review required; backend rechecks the latest plan.",
"previewFailed": "Dry-run preview failed; refresh and verify the latest dedupe plan.",
"confirmFailed": "Archive confirmation failed; the backend may have detected a changed dedupe plan.",
"requiresOwner": "Run the dry-run preview first, then owner-confirm the archive; the backend rechecks the latest plan.",
"previewResult": "Dry run would archive {count}; writes KM: {writesKm}; writes audit: {writesAudit}",
"previewNext": "Next: only after owner confirmation will duplicate KM be soft-archived and audit / stale-ratio recheck rows be written.",
"result": "Archived {archived}; audit dispatch: {audit}",
"recheck": "Stale-ratio recheck: {status}; dispatch: {dispatch}",
"snapshot": "Current stale {stale} / total {total}; ratio {ratio}; threshold {threshold}",

View File

@@ -1922,9 +1922,17 @@
},
"archiveActions": {
"archive": "封存重複草稿",
"preview": "乾跑預覽",
"previewing": "預覽中",
"confirm": "確認封存",
"confirming": "封存中",
"archiving": "封存中",
"failed": "封存動作失敗;請重新整理後確認最新 dedupe plan。",
"requiresOwner": "需要 owner 審核;後端會重新比對最新 plan。",
"previewFailed": "乾跑預覽失敗;請重新整理後確認最新 dedupe plan。",
"confirmFailed": "確認封存失敗;後端可能偵測到 dedupe plan 已變更。",
"requiresOwner": "必須先乾跑預覽,再由 owner 確認封存;後端會重新比對最新 plan。",
"previewResult": "乾跑將封存 {count} 份;寫 KM{writesKm};寫稽核:{writesAudit}",
"previewNext": "下一步owner 確認後才會 soft archive duplicate KM 並寫入 audit / stale ratio 回測。",
"result": "已封存 {archived} 份;稽核 dispatch{audit}",
"recheck": "Stale ratio 回測:{status}dispatch{dispatch}",
"snapshot": "目前 stale {stale} / total {total}ratio {ratio};門檻 {threshold}",

View File

@@ -307,6 +307,14 @@ type KnowledgeReviewDraftArchiveResponse = {
generated_at?: string | null;
};
type KnowledgeReviewDraftArchiveAction = {
previewLoading: boolean;
confirmLoading: boolean;
previewResult: KnowledgeReviewDraftArchiveResponse | null;
result: KnowledgeReviewDraftArchiveResponse | null;
error: string | null;
};
type DriftFingerprintState = {
schema_version?: string;
namespace?: string;
@@ -800,6 +808,10 @@ function staleRatioRecheckStatusKey(status?: string | null) {
return "unknown";
}
function formatStaleRatio(value: number) {
return `${(value * 100).toFixed(1)}%`;
}
function buildWorkItems(
telemetry: Telemetry,
t: ReturnType<typeof useTranslations>
@@ -1496,11 +1508,7 @@ function KnowledgeGovernancePanel({
onArchived: () => void;
}) {
const t = useTranslations("awooop.workItems.knowledgeGovernance");
const [archiveActions, setArchiveActions] = useState<Record<string, {
loading: boolean;
result: KnowledgeReviewDraftArchiveResponse | null;
error: string | null;
}>>({});
const [archiveActions, setArchiveActions] = useState<Record<string, KnowledgeReviewDraftArchiveAction>>({});
const items = queue?.items ?? [];
const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
const dedupeGroups = dedupe?.groups ?? [];
@@ -1515,10 +1523,50 @@ function KnowledgeGovernancePanel({
const reviewCount = items.filter((item) =>
item.dispatch_status === "skipped" || item.workflow_stage === "waiting_owner_review"
).length;
const archiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => {
const previewArchiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => {
setArchiveActions((current) => ({
...current,
[group.governance_event_id]: { loading: true, result: null, error: null },
[group.governance_event_id]: {
previewLoading: true,
confirmLoading: false,
previewResult: current[group.governance_event_id]?.previewResult ?? null,
result: null,
error: null,
},
}));
const result = await postJson<KnowledgeReviewDraftArchiveResponse>(
`${API_BASE}/api/v1/ai/governance/km-review-drafts/dedupe/${encodeURIComponent(group.governance_event_id)}/archive-duplicates`,
{
canonical_entry_id: group.canonical_entry_id,
duplicate_entry_ids: group.duplicate_entry_ids,
owner: "operator_console",
owner_approved: false,
dry_run: true,
},
15000
);
setArchiveActions((current) => ({
...current,
[group.governance_event_id]: {
previewLoading: false,
confirmLoading: false,
previewResult: result,
result: null,
error: result ? null : t("archiveActions.previewFailed"),
},
}));
}, [t]);
const confirmArchiveDuplicates = useCallback(async (group: KnowledgeReviewDraftDedupeGroup) => {
setArchiveActions((current) => ({
...current,
[group.governance_event_id]: {
previewLoading: false,
confirmLoading: true,
previewResult: current[group.governance_event_id]?.previewResult ?? null,
result: null,
error: null,
},
}));
const result = await postJson<KnowledgeReviewDraftArchiveResponse>(
`${API_BASE}/api/v1/ai/governance/km-review-drafts/dedupe/${encodeURIComponent(group.governance_event_id)}/archive-duplicates`,
@@ -1534,9 +1582,11 @@ function KnowledgeGovernancePanel({
setArchiveActions((current) => ({
...current,
[group.governance_event_id]: {
loading: false,
previewLoading: false,
confirmLoading: false,
previewResult: current[group.governance_event_id]?.previewResult ?? null,
result,
error: result ? null : t("archiveActions.failed"),
error: result ? null : t("archiveActions.confirmFailed"),
},
}));
if (result?.status === "archived" || result?.status === "noop_already_archived") {
@@ -1671,7 +1721,11 @@ function KnowledgeGovernancePanel({
<div className="grid gap-2 md:grid-cols-2">
{dedupeGroups.slice(0, 4).map((group) => {
const archiveAction = archiveActions[group.governance_event_id];
const resultKey = groupArchiveStatusKey(archiveAction?.result?.status);
const previewResult = archiveAction?.previewResult ?? null;
const finalResult = archiveAction?.result ?? null;
const previewReady = previewResult?.status === "dry_run";
const previewKey = groupArchiveStatusKey(previewResult?.status);
const finalResultKey = groupArchiveStatusKey(finalResult?.status);
return (
<div
key={group.governance_event_id}
@@ -1717,14 +1771,34 @@ function KnowledgeGovernancePanel({
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => archiveDuplicates(group)}
disabled={group.duplicate_count === 0 || archiveAction?.loading}
onClick={() => previewArchiveDuplicates(group)}
disabled={
group.duplicate_count === 0 ||
archiveAction?.previewLoading ||
archiveAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{archiveAction?.previewLoading
? t("archiveActions.previewing")
: t("archiveActions.preview")}
</button>
<button
type="button"
onClick={() => confirmArchiveDuplicates(group)}
disabled={
group.duplicate_count === 0 ||
!previewReady ||
archiveAction?.previewLoading ||
archiveAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<Archive className="h-3.5 w-3.5" aria-hidden="true" />
{archiveAction?.loading
? t("archiveActions.archiving")
: t("archiveActions.archive")}
{archiveAction?.confirmLoading
? t("archiveActions.confirming")
: t("archiveActions.confirm")}
</button>
<span className="text-[11px] leading-5 text-[#77736a]">
{t("archiveActions.requiresOwner")}
@@ -1735,34 +1809,61 @@ function KnowledgeGovernancePanel({
{archiveAction.error}
</div>
) : null}
{archiveAction?.result ? (
{previewResult ? (
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[#8a5a08]">
<p className="font-semibold">
{t(`archiveActions.statuses.${previewKey}` as never)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.previewResult", {
count: previewResult.would_archive_entry_ids.length,
writesKm: String(previewResult.writes_km),
writesAudit: String(previewResult.writes_governance_audit),
})}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.previewNext")}
</p>
{previewResult.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.snapshot", {
stale: previewResult.stale_ratio_snapshot.stale_count,
total: previewResult.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(previewResult.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(previewResult.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
{finalResult ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[#17602a]">
<p className="font-semibold">
{t(`archiveActions.statuses.${resultKey}` as never)}
{t(`archiveActions.statuses.${finalResultKey}` as never)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.result", {
archived: archiveAction.result.archived_entry_ids.length,
audit: archiveAction.result.audit_dispatch_id ?? "--",
archived: finalResult.archived_entry_ids.length,
audit: finalResult.audit_dispatch_id ?? "--",
})}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.recheck", {
status: t(
`archiveActions.recheckStatuses.${staleRatioRecheckStatusKey(
archiveAction.result.stale_ratio_recheck_status
finalResult.stale_ratio_recheck_status
)}` as never
),
dispatch: archiveAction.result.stale_ratio_recheck_dispatch_id ?? "--",
dispatch: finalResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
{archiveAction.result.stale_ratio_snapshot ? (
{finalResult.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("archiveActions.snapshot", {
stale: archiveAction.result.stale_ratio_snapshot.stale_count,
total: archiveAction.result.stale_ratio_snapshot.total_count,
ratio: archiveAction.result.stale_ratio_snapshot.stale_ratio,
threshold: archiveAction.result.stale_ratio_snapshot.threshold,
stale: finalResult.stale_ratio_snapshot.stale_count,
total: finalResult.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(finalResult.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(finalResult.stale_ratio_snapshot.threshold),
})}
</p>
) : null}

View File

@@ -1,3 +1,76 @@
## 2026-05-20T95 KM duplicate archive two-step safety
**觸發**
- T93 / T94 已讓 AwoooP 可以看見 KM duplicate drafts、owner archive action、archive 後 stale ratio recheck trace。
- 但 Work Items 仍是單一「封存重複草稿」按鈕,雖然後端有 owner approval / latest plan guardOperator 仍可能誤點直接送 `dry_run=false`
**修正**
- `/awooop/work-items` 的 KM 草稿去重 action 改為兩段式:
- 第一段:`乾跑預覽`,送 `dry_run=true``owner_approved=false`
- 第二段:`確認封存`,只有 preview 成功後才解除 disabled`dry_run=false``owner_approved=true`
- Preview 結果直接顯示:
- `would_archive_entry_ids` 數量。
- `writes_km=false` / `writes_governance_audit=false`
- stale / total / ratio / threshold snapshotratio 改成百分比顯示。
- Confirm 結果保留 T94 的 archive audit dispatch 與 stale ratio recheck dispatch 顯示。
- i18n 已同步 `zh-TW` / `en`,沒有硬編碼 UI 文案。
**Local verification**
```text
pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx'
-> No ESLint warnings or errors
pnpm --dir apps/web exec tsc --noEmit --pretty false
-> ok
node -e 'JSON.parse(...)'
-> i18n json ok
pnpm --dir apps/web run build
-> expected guard: missing NEXT_PUBLIC_API_URL blocks prerender
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build
-> compiled and generated 90/90 static pages
```
**Local interactive smokelive production API bridge無寫入**
```text
Local dev:
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web dev --hostname 127.0.0.1 --port 3030
Playwright checks:
-> confirmDisabledBefore=true
-> previewVisible=true
-> confirmVisible=true
-> previewResultVisible=true
-> previewShowsNoWrites=true
-> dryRunRequestOnly=true
-> confirmEnabledAfterPreview=true
-> blockedNoWrites=true
-> pageErrors=0 / consoleErrors=0
Observed POST:
/api/v1/ai/governance/km-review-drafts/dedupe/{event}/archive-duplicates
body dry_run=true, owner_approved=false, owner=operator_console
Blocked writes:
-> 0
Screenshot:
/tmp/awoooi-t95-km-archive-two-step-local.png
```
**目前整體進度**
- AwoooP 告警可觀測鏈:約 99.1%。
- 低風險自動修復閉環:約 95%。
- 前端 AI 自動化管理介面同步:約 98.2%。
- 治理告警可讀性 / 可處置性:約 98.5%。
- AI Agent ownership 可追溯性:約 97.3%。
- KM healthcheck 派工可追蹤性:約 99.5%。
- Hermes KB growth 草稿 / owner review 閉環:約 99.1%。
- 完整 AI 自動化管理產品化:約 96.7%。
## 2026-05-20T94 KM archive 後 stale ratio recheck trace
**觸發**

View File

@@ -2333,6 +2333,13 @@ Phase 6 完成後
- Production`d283e653 feat(governance): trace km stale ratio rechecks` 已推 Gitea maindeploy marker `b7eb3f7d chore(cd): deploy d283e65 [skip ci]`Gitea runs `1888` CD、`1889` Code Review、`1890` Type Sync 全 success。Production health healthy/prod/mock_mode=false。Dry-run archive endpoint 回 `status=dry_run``writes_km=false``writes_governance_audit=false``stale_ratio_recheck_status=dry_run``stale_ratio_snapshot stale_count=1454 total_count=1966 stale_ratio=0.74 threshold=0.2 stale_days=7`dry-run 前後 duplicate total 仍為 67。Work Items Playwright smoke 顯示 nav、KM healthcheck panel、KM 草稿去重視圖、封存重複草稿按鈕、owner guard、stale ratio recheck 文案pageErrors=0 / consoleErrors=0截圖 `/tmp/awoooi-t94-km-stale-ratio-recheck.png`
- 目前進度更新:前端 AI 自動化管理介面同步約 97.9%;治理告警可讀性 / 可處置性約 98.3%AI Agent ownership 可追溯性約 97.0%KM healthcheck 派工可追蹤性約 99.4%Hermes KB growth 草稿 / owner review 閉環約 99.0%;完整 AI 自動化管理產品化約 96.4%。
**T95 KM duplicate archive two-step safety2026-05-20 台北)**
- 觸發T93 / T94 已打通 duplicate KM review drafts 的 owner archive 與 stale ratio recheck trace但 Work Items 仍是單一封存按鈕;需要把危險寫入前的 dry-run preview 做成產品流程,而不是只依賴後端 guard。
- 修正:`/awooop/work-items` 的 KM 草稿去重 action 改為兩段式。第一段 `乾跑預覽``dry_run=true``owner_approved=false`;第二段 `確認封存` 只有 preview 成功後才解除 disabled`dry_run=false``owner_approved=true`。Preview 顯示 would-archive 數量、`writes_km=false``writes_governance_audit=false`、stale ratio snapshotconfirm 結果保留 archive audit dispatch 與 stale ratio recheck dispatch。
- 邊界T95 沒有實際封存 production KMlocal smoke 用 live production API bridge 只允許 `dry_run=true` POST測試層阻擋任何非 dry-run archive request。這仍符合「高影響 KM 必須 owner review」與「破壞性動作先 dry-run」鐵律。
- Local verificationWork Items Next lint ok`tsc --noEmit` oki18n JSON parse ok未設 `NEXT_PUBLIC_API_URL` 的 build 仍被既有 guard 阻擋;`NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build` 成功產出 90/90 static pages。Local Playwright smoke 確認 confirm button preview 前 disabled、preview 後 enabled、唯一 POST 為 `dry_run=true` + `owner_approved=false``writes_km=false` / `writes_governance_audit=false` 可見、blockedWrites=0、pageErrors=0 / consoleErrors=0截圖 `/tmp/awoooi-t95-km-archive-two-step-local.png`
- 目前進度更新:前端 AI 自動化管理介面同步約 98.2%;治理告警可讀性 / 可處置性約 98.5%AI Agent ownership 可追溯性約 97.3%KM healthcheck 派工可追蹤性約 99.5%Hermes KB growth 草稿 / owner review 閉環約 99.1%;完整 AI 自動化管理產品化約 96.7%。
---
### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護commit de2d34d