diff --git a/apps/api/src/services/repair_candidate_service.py b/apps/api/src/services/repair_candidate_service.py index ec9e2737..605e0e6a 100644 --- a/apps/api/src/services/repair_candidate_service.py +++ b/apps/api/src/services/repair_candidate_service.py @@ -841,6 +841,98 @@ class RepairCandidateService: "assign_km_and_playbook_trust_writeback_owner", "rerun_repair_candidate_gate_after_owner_release", ], + "controlled_automation_closure": { + "schema_version": "repair_candidate_controlled_automation_closure_v1", + "status": "owner_review_ready_no_write_rehearsal", + "runtime_write_gate": "closed_until_owner_release", + "no_write_rehearsal": { + "rehearsal_id": ( + f"repair-rehearsal:{project_id}:{incident_id}:{lane}" + if incident_id + else f"repair-rehearsal:{project_id}:unbound:{lane}" + ), + "status": "ready_for_owner_review", + "route_id": route or "--", + "input_refs": { + "source_work_item_id": work_item_id or None, + "evidence_refs": list(evidence_refs), + "coverage_key": coverage_gap.get("coverage_key"), + }, + "planned_steps": [ + "render_repair_command_without_execution", + "render_rollback_command_without_execution", + "check_route_allowlist_and_target_selector", + "produce_verifier_rehearsal_plan", + ], + "writes_runtime_state": False, + "executes_command": False, + }, + "verifier_writeback_plan": { + "verifier_id": ( + f"verifier-plan:{project_id}:{incident_id}:{lane}" + if incident_id + else f"verifier-plan:{project_id}:unbound:{lane}" + ), + "status": "planned_not_executed", + "checks": list(verifier_plan), + "result_states": ["healthy", "degraded", "failed", "rollback_required"], + "writes_after_execution": [ + "incident_timeline", + "auto_repair_execution_result", + "km_draft", + "playbook_trust_event", + ], + "writes_runtime_state": False, + }, + "automation_asset_ledger": [ + { + "asset_type": "KM", + "asset_id": ( + f"km-draft:{project_id}:{incident_id}:{lane}" + if incident_id + else f"km-draft:{project_id}:unbound:{lane}" + ), + "owner": "Hermes", + "status": "draft_required_after_verifier", + "next_action": "prepare_km_draft_from_incident_evidence", + }, + { + "asset_type": "PlayBook", + "asset_id": ( + f"playbook-draft:{project_id}:{incident_id}:{lane}" + if incident_id + else f"playbook-draft:{project_id}:unbound:{lane}" + ), + "owner": "OpenClaw", + "status": "owner_review_ready", + "next_action": "review_service_specific_repair_steps", + }, + { + "asset_type": "ScriptOrAnsible", + "asset_id": route or "--", + "owner": "Executor lane", + "status": "route_allowlist_review_required", + "next_action": "confirm_check_mode_and_blast_radius", + }, + { + "asset_type": "Verifier", + "asset_id": ( + f"verifier-plan:{project_id}:{incident_id}:{lane}" + if incident_id + else f"verifier-plan:{project_id}:unbound:{lane}" + ), + "owner": "PostExecutionVerifier", + "status": "planned_not_executed", + "next_action": "owner_review_post_apply_verifier", + }, + ], + "operator_next_actions": [ + "review_no_write_rehearsal_inputs", + "approve_or_reject_owner_release", + "fill_blast_radius_maintenance_window_and_rollback_owner", + "promote_to_controlled_apply_only_after_all_gates_pass", + ], + }, } def _promotion_contract_field( diff --git a/apps/api/tests/test_repair_candidate_service.py b/apps/api/tests/test_repair_candidate_service.py index b2b76f1e..47cfbe2e 100644 --- a/apps/api/tests/test_repair_candidate_service.py +++ b/apps/api/tests/test_repair_candidate_service.py @@ -359,6 +359,20 @@ async def test_candidate_blocked_observe_only_prompts_repair_playbook_draft() -> assert "maintenance_window" in promotion_contract["blocked_fields"] assert promotion_contract["runtime_execution_authorized"] is False assert promotion_contract["runtime_write_allowed"] is False + closure = promotion_contract["controlled_automation_closure"] + assert closure["schema_version"] == "repair_candidate_controlled_automation_closure_v1" + assert closure["status"] == "owner_review_ready_no_write_rehearsal" + assert closure["runtime_write_gate"] == "closed_until_owner_release" + assert closure["no_write_rehearsal"]["status"] == "ready_for_owner_review" + assert closure["no_write_rehearsal"]["executes_command"] is False + assert closure["verifier_writeback_plan"]["status"] == "planned_not_executed" + assert "incident_timeline" in closure["verifier_writeback_plan"]["writes_after_execution"] + assert [item["asset_type"] for item in closure["automation_asset_ledger"]] == [ + "KM", + "PlayBook", + "ScriptOrAnsible", + "Verifier", + ] assert result.metadata["repair_candidate_promotion_contract"] == promotion_contract assert "promotion=6/11" in result.metadata["repair_candidate_promotion_summary"] assert "runtime=false" in result.metadata["repair_candidate_promotion_summary"] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 581f7387..de809cde 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9807,6 +9807,22 @@ "blast_radius": "影響範圍", "km_writeback_owner": "KM owner", "playbook_trust_owner": "PlayBook trust owner" + }, + "closure": { + "title": "受控自動化閉環", + "subtitle": "AI 已把下一步拆成無寫入演練、Verifier 與資產沉澱;這裡只呈現可審查的閉環,不代表已執行命令。", + "rehearsal": { + "title": "無寫入演練", + "meta": "已規劃 {steps} 個演練步驟" + }, + "verifier": { + "title": "Verifier 回寫", + "meta": "已規劃 {checks} 個驗證檢查" + }, + "writeback": { + "title": "資產沉澱", + "meta": "已建立 {assets} 個資產槽位" + } } }, "flow": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 581f7387..de809cde 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9807,6 +9807,22 @@ "blast_radius": "影響範圍", "km_writeback_owner": "KM owner", "playbook_trust_owner": "PlayBook trust owner" + }, + "closure": { + "title": "受控自動化閉環", + "subtitle": "AI 已把下一步拆成無寫入演練、Verifier 與資產沉澱;這裡只呈現可審查的閉環,不代表已執行命令。", + "rehearsal": { + "title": "無寫入演練", + "meta": "已規劃 {steps} 個演練步驟" + }, + "verifier": { + "title": "Verifier 回寫", + "meta": "已規劃 {checks} 個驗證檢查" + }, + "writeback": { + "title": "資產沉澱", + "meta": "已建立 {assets} 個資產槽位" + } } }, "flow": { diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 64bebba4..c93f408e 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1121,6 +1121,15 @@ function promotionStatusTone(status: string | null | undefined): WorkStatus { return "blocked"; } +function automationClosureTone(status: string | null | undefined): WorkStatus { + const normalized = String(status ?? "").toLowerCase(); + if (normalized.includes("ready")) return "in_progress"; + if (normalized.includes("planned") || normalized.includes("draft")) return "watching"; + if (normalized.includes("blocked") || normalized.includes("failed")) return "blocked"; + if (normalized.includes("executed") || normalized.includes("healthy")) return "live"; + return "watching"; +} + function closureGateTone(status: string | null | undefined): WorkStatus { const normalized = String(status ?? "").toLowerCase(); if (normalized === "passed" || normalized === "ready") return "live"; @@ -4203,6 +4212,48 @@ function RepairCandidateDraftPanel({ ) .filter((field): field is string => Boolean(field)) .slice(0, 8); + const controlledClosure = promotionContract?.controlled_automation_closure; + const noWriteRehearsal = controlledClosure?.no_write_rehearsal; + const verifierWritebackPlan = controlledClosure?.verifier_writeback_plan; + const controlledClosureCards = [ + { + key: "rehearsal", + icon: Activity, + title: t("promotion.closure.rehearsal.title"), + status: noWriteRehearsal?.status, + primary: noWriteRehearsal?.rehearsal_id, + detail: noWriteRehearsal?.route_id, + meta: t("promotion.closure.rehearsal.meta", { + steps: noWriteRehearsal?.planned_steps?.length ?? 0, + }), + }, + { + key: "verifier", + icon: SearchCheck, + title: t("promotion.closure.verifier.title"), + status: verifierWritebackPlan?.status, + primary: verifierWritebackPlan?.verifier_id, + detail: verifierWritebackPlan?.result_states?.slice(0, 3).join(" / "), + meta: t("promotion.closure.verifier.meta", { + checks: verifierWritebackPlan?.checks?.length ?? 0, + }), + }, + { + key: "writeback", + icon: Database, + title: t("promotion.closure.writeback.title"), + status: controlledClosure?.status, + primary: controlledClosure?.runtime_write_gate, + detail: controlledClosure?.automation_asset_ledger + ?.map((asset) => asset.asset_type) + .filter(Boolean) + .slice(0, 4) + .join(" / "), + meta: t("promotion.closure.writeback.meta", { + assets: controlledClosure?.automation_asset_ledger?.length ?? 0, + }), + }, + ]; const closureReadiness = chain?.automation_handoff?.closure_readiness; const closureAvailable = Boolean(!promotionAvailable && closureReadiness); const closureReady = toCount(closureReadiness?.ready_count); @@ -4382,6 +4433,69 @@ function RepairCandidateDraftPanel({ ) : null} + + {controlledClosure ? ( +
{t("promotion.closure.title")}
++ {t("promotion.closure.subtitle")} +
+{card.title}
++ {card.primary ?? "--"} +
++ {card.detail ?? "--"} +
++ {card.meta} +
++ {asset.asset_type ?? "--"} +
++ {asset.asset_id ?? "--"} +
++ {asset.owner ?? "--"} · {asset.status ?? "--"} +
+