feat(governance): require dry-run preview before km archive
This commit is contained in:
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,76 @@
|
||||
## 2026-05-20|T95 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 guard,Operator 仍可能誤點直接送 `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 snapshot,ratio 改成百分比顯示。
|
||||
- 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 smoke(live 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-20|T94 KM archive 後 stale ratio recheck trace
|
||||
|
||||
**觸發**:
|
||||
|
||||
@@ -2333,6 +2333,13 @@ Phase 6 完成後
|
||||
- Production:`d283e653 feat(governance): trace km stale ratio rechecks` 已推 Gitea main;deploy 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 safety(2026-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 snapshot;confirm 結果保留 archive audit dispatch 與 stale ratio recheck dispatch。
|
||||
- 邊界:T95 沒有實際封存 production KM;local smoke 用 live production API bridge 只允許 `dry_run=true` POST,測試層阻擋任何非 dry-run archive request。這仍符合「高影響 KM 必須 owner review」與「破壞性動作先 dry-run」鐵律。
|
||||
- Local verification:Work Items Next lint ok;`tsc --noEmit` ok;i18n 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)
|
||||
|
||||
Reference in New Issue
Block a user