From 7c220fd0835e7df64c25dfa78d8ecd1218bcb1f5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 07:50:53 +0800 Subject: [PATCH] feat(awooop): expose controlled execution preflight --- .../src/services/platform_operator_service.py | 149 ++++++++++++++++ .../test_awooop_operator_timeline_labels.py | 28 +++ apps/web/messages/en.json | 25 ++- apps/web/messages/zh-TW.json | 25 ++- .../src/components/awooop/status-chain.tsx | 168 +++++++++++++++++- 5 files changed, 389 insertions(+), 6 deletions(-) diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e37181ea..4cdc480a 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -4292,6 +4292,144 @@ def _apply_gate_closure_tasks( ] +def _apply_gate_controlled_execution_preflight( + *, + source_ref: str, + safe_source_ref: str, + catalog_id: str, + check_mode_playbook: Any, + apply_playbook: Any, + dry_run_passed: bool, + owner_release_package: dict[str, Any], + verifier_package: dict[str, Any], +) -> dict[str, Any]: + """Describe the no-write route that would become executable after release gates.""" + + owner_release_approved = _safe_int( + owner_release_package.get("owner_release_approved_count") + ) + maintenance_approved = _safe_int( + owner_release_package.get("maintenance_window_approved_count") + ) + rollback_confirmed = _safe_int( + owner_release_package.get("rollback_owner_confirmed_count") + ) + verifier_ready = _safe_int(verifier_package.get("post_release_verifier_ready_count")) + route_candidate_ready = ( + dry_run_passed + and bool(catalog_id) + and str(apply_playbook or "").strip() + and str(apply_playbook or "").strip() != "--" + ) + prerequisites = [ + { + "key": "dry_run_passed", + "status": "passed" if dry_run_passed else "blocked_missing_dry_run", + "detail": f"dry_run_passed={str(dry_run_passed).lower()}", + "required": True, + }, + { + "key": "allowlisted_route_candidate", + "status": ( + "candidate_ready_no_runtime_authority" + if route_candidate_ready + else "route_missing" + ), + "detail": f"catalog={catalog_id}", + "required": True, + }, + { + "key": "owner_release_receipt", + "status": ( + "passed" if owner_release_approved > 0 else "blocked_missing_owner_release" + ), + "detail": f"approved={owner_release_approved}", + "required": True, + }, + { + "key": "maintenance_window", + "status": ( + "passed" + if maintenance_approved > 0 + else "blocked_missing_maintenance_window" + ), + "detail": f"approved={maintenance_approved}", + "required": True, + }, + { + "key": "rollback_owner", + "status": ( + "passed" if rollback_confirmed > 0 else "blocked_missing_rollback_owner" + ), + "detail": f"confirmed={rollback_confirmed}", + "required": True, + }, + { + "key": "post_apply_verifier", + "status": ( + "passed" + if verifier_ready > 0 + else "blocked_missing_post_apply_verifier" + ), + "detail": f"ready={verifier_ready}", + "required": True, + }, + { + "key": "km_playbook_writeback", + "status": "blocked_until_verified_execution", + "detail": "km_write=0; playbook_trust_write=0", + "required": True, + }, + ] + ready_count = sum( + 1 + for item in prerequisites + if item["status"] in {"passed", "candidate_ready_no_runtime_authority"} + ) + blocked_count = len(prerequisites) - ready_count + route_count = 1 if route_candidate_ready else 0 + return { + "schema_version": "awooop_controlled_execution_preflight_v1", + "status": "blocked_before_runtime_gate", + "source_id": source_ref, + "work_item_id": f"controlled-execution-gate:awoooi:{safe_source_ref}", + "runtime_execution_authorized": False, + "runtime_write_allowed": False, + "allowed_route_count": 0, + "candidate_route_count": route_count, + "ready_count": ready_count, + "total_count": len(prerequisites), + "blocked_count": blocked_count, + "next_action": "collect_owner_release_maintenance_rollback_and_verifier", + "blocked_reason": "owner_release_or_verifier_gate_missing", + "routes": [ + { + "route_id": f"ansible-allowlisted-apply:{catalog_id}", + "transport": "ansible", + "status": ( + "candidate_ready_no_runtime_authority" + if route_candidate_ready + else "route_missing" + ), + "source_asset_id": f"ansible-apply-candidate:{catalog_id}", + "check_mode_playbook_path": check_mode_playbook, + "apply_playbook_path": apply_playbook, + "allowed": False, + "blocker": "runtime_gate_closed_until_owner_release_and_verifier", + } + ], + "prerequisites": prerequisites, + "forbidden_until_released": [ + "ansible_apply", + "ssh_write", + "service_restart", + "telegram_send", + "km_writeback", + "playbook_trust_writeback", + ], + } + + def _status_chain_ansible_apply_gate_handoff( *, ansible_dry_run_only: bool, @@ -4406,6 +4544,16 @@ def _status_chain_ansible_apply_gate_handoff( owner_release_package=owner_release_package, verifier_package=verifier_package, ) + controlled_execution_preflight = _apply_gate_controlled_execution_preflight( + source_ref=str(source_ref), + safe_source_ref=safe_source_ref, + catalog_id=str(catalog_id), + check_mode_playbook=check_mode_playbook, + apply_playbook=apply_playbook, + dry_run_passed=dry_run_passed, + owner_release_package=owner_release_package, + verifier_package=verifier_package, + ) return { "schema_version": "awooop_automation_handoff_v1", @@ -4473,6 +4621,7 @@ def _status_chain_ansible_apply_gate_handoff( "owner_release_package": owner_release_package, "release_verifier_package": verifier_package, "closure_tasks": closure_tasks, + "controlled_execution_preflight": controlled_execution_preflight, }, "candidate": { "catalog_id": catalog_id, diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 60fd1cb6..e20b1db2 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1901,6 +1901,34 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No assert closure["closure_tasks"][3]["source_asset_id"] == ( "agent-result-capture-release-verifier-preflight-gate:P2-136" ) + controlled_execution = closure["controlled_execution_preflight"] + assert controlled_execution["schema_version"] == ( + "awooop_controlled_execution_preflight_v1" + ) + assert controlled_execution["status"] == "blocked_before_runtime_gate" + assert controlled_execution["runtime_execution_authorized"] is False + assert controlled_execution["runtime_write_allowed"] is False + assert controlled_execution["candidate_route_count"] == 1 + assert controlled_execution["allowed_route_count"] == 0 + assert controlled_execution["ready_count"] == 2 + assert controlled_execution["total_count"] == 7 + assert controlled_execution["blocked_count"] == 5 + assert [item["key"] for item in controlled_execution["prerequisites"]] == [ + "dry_run_passed", + "allowlisted_route_candidate", + "owner_release_receipt", + "maintenance_window", + "rollback_owner", + "post_apply_verifier", + "km_playbook_writeback", + ] + assert controlled_execution["routes"][0]["route_id"] == ( + "ansible-allowlisted-apply:ansible:188-ai-web" + ) + assert controlled_execution["routes"][0]["allowed"] is False + assert controlled_execution["routes"][0]["apply_playbook_path"] == ( + "infra/ansible/playbooks/188-ai-web.yml" + ) assert chain["execution"]["ansible"]["check_mode_total"] == 1 assert chain["execution"]["ansible"]["apply_total"] == 0 assert chain["execution"]["ansible"]["applied"] is False diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 2d41505a..aecffdd8 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -10303,6 +10303,12 @@ "closureTaskBoardTitle": "閉環任務板", "closureTaskOwner": "Owner:{owner}", "runtimeWrite": "runtime_write={value}", + "controlledExecutionPreflightTitle": "受控執行前檢", + "controlledCandidateRoutes": "候選路由", + "controlledAllowedRoutes": "允許路由", + "controlledBlockedCount": "阻擋前置", + "controlledRouteTitle": "Allowlisted route", + "controlledAllowed": "allowed={value}", "checklistTitle": "Owner 審查清單", "forbiddenTitle": "禁止動作", "gates": { @@ -10332,6 +10338,15 @@ "postApplyVerifierPreflight": "套用後 Verifier", "kmPlaybookTrustWritebackPlan": "KM / PlayBook 回寫" }, + "controlledPrerequisites": { + "dryRunPassed": "乾跑通過", + "allowlistedRouteCandidate": "允許路由候選", + "ownerReleaseReceipt": "Owner 放行回執", + "maintenanceWindow": "維護窗口", + "rollbackOwner": "Rollback Owner", + "postApplyVerifier": "套用後 Verifier", + "kmPlaybookWriteback": "KM / PlayBook 回寫" + }, "closureStatuses": { "blockedBeforeOwnerRelease": "Owner 放行前受阻", "noWriteRehearsal": "無寫入演練", @@ -10343,7 +10358,15 @@ "blockedByPolicy": "政策阻擋", "blockedBeforeRuntimeGate": "Runtime gate 前受阻", "blockedUntilVerifierPasses": "Verifier 通過前受阻", - "snapshotUnavailable": "快照不可用" + "snapshotUnavailable": "快照不可用", + "candidateReadyNoRuntimeAuthority": "候選就緒但未授權", + "routeMissing": "路由缺失", + "blockedMissingDryRun": "缺乾跑", + "blockedMissingOwnerRelease": "缺 Owner 放行", + "blockedMissingMaintenanceWindow": "缺維護窗口", + "blockedMissingRollbackOwner": "缺 Rollback Owner", + "blockedMissingPostApplyVerifier": "缺套用後 Verifier", + "blockedUntilVerifiedExecution": "驗證執行前受阻" }, "statuses": { "passed": "已通過", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 2d41505a..aecffdd8 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -10303,6 +10303,12 @@ "closureTaskBoardTitle": "閉環任務板", "closureTaskOwner": "Owner:{owner}", "runtimeWrite": "runtime_write={value}", + "controlledExecutionPreflightTitle": "受控執行前檢", + "controlledCandidateRoutes": "候選路由", + "controlledAllowedRoutes": "允許路由", + "controlledBlockedCount": "阻擋前置", + "controlledRouteTitle": "Allowlisted route", + "controlledAllowed": "allowed={value}", "checklistTitle": "Owner 審查清單", "forbiddenTitle": "禁止動作", "gates": { @@ -10332,6 +10338,15 @@ "postApplyVerifierPreflight": "套用後 Verifier", "kmPlaybookTrustWritebackPlan": "KM / PlayBook 回寫" }, + "controlledPrerequisites": { + "dryRunPassed": "乾跑通過", + "allowlistedRouteCandidate": "允許路由候選", + "ownerReleaseReceipt": "Owner 放行回執", + "maintenanceWindow": "維護窗口", + "rollbackOwner": "Rollback Owner", + "postApplyVerifier": "套用後 Verifier", + "kmPlaybookWriteback": "KM / PlayBook 回寫" + }, "closureStatuses": { "blockedBeforeOwnerRelease": "Owner 放行前受阻", "noWriteRehearsal": "無寫入演練", @@ -10343,7 +10358,15 @@ "blockedByPolicy": "政策阻擋", "blockedBeforeRuntimeGate": "Runtime gate 前受阻", "blockedUntilVerifierPasses": "Verifier 通過前受阻", - "snapshotUnavailable": "快照不可用" + "snapshotUnavailable": "快照不可用", + "candidateReadyNoRuntimeAuthority": "候選就緒但未授權", + "routeMissing": "路由缺失", + "blockedMissingDryRun": "缺乾跑", + "blockedMissingOwnerRelease": "缺 Owner 放行", + "blockedMissingMaintenanceWindow": "缺維護窗口", + "blockedMissingRollbackOwner": "缺 Rollback Owner", + "blockedMissingPostApplyVerifier": "缺套用後 Verifier", + "blockedUntilVerifiedExecution": "驗證執行前受阻" }, "statuses": { "passed": "已通過", diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index dbeaaa82..6e407ed3 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -1,6 +1,6 @@ "use client"; -import { Activity, BookOpenCheck, CheckCircle2, Link2, RadioTower, Route, ShieldAlert, TriangleAlert, Wrench } from "lucide-react"; +import { Activity, BookOpenCheck, CheckCircle2, Link2, RadioTower, Route, ShieldAlert, ShieldCheck, TriangleAlert, Wrench } from "lucide-react"; import { useTranslations } from "next-intl"; import { Link } from "@/i18n/routing"; @@ -235,6 +235,38 @@ export interface AwoooPStatusChain { next_step?: string | null; runtime_write_allowed?: boolean | null; }>; + controlled_execution_preflight?: { + schema_version?: string | null; + status?: string | null; + source_id?: string | null; + work_item_id?: string | null; + runtime_execution_authorized?: boolean | null; + runtime_write_allowed?: boolean | null; + allowed_route_count?: number | null; + candidate_route_count?: number | null; + ready_count?: number | null; + total_count?: number | null; + blocked_count?: number | null; + next_action?: string | null; + blocked_reason?: string | null; + routes?: Array<{ + route_id?: string | null; + transport?: string | null; + status?: string | null; + source_asset_id?: string | null; + check_mode_playbook_path?: string | null; + apply_playbook_path?: string | null; + allowed?: boolean | null; + blocker?: string | null; + }>; + prerequisites?: Array<{ + key?: string | null; + status?: string | null; + detail?: string | null; + required?: boolean | null; + }>; + forbidden_until_released?: string[]; + }; }; candidate?: { catalog_id?: string | null; @@ -543,6 +575,10 @@ export function AwoooPStatusChainPanel({ const closureOwnerPackage = closureReadiness?.owner_release_package; const closureVerifierPackage = closureReadiness?.release_verifier_package; const closureTasks = closureReadiness?.closure_tasks ?? []; + const controlledExecutionPreflight = closureReadiness?.controlled_execution_preflight; + const controlledExecutionRoutes = controlledExecutionPreflight?.routes ?? []; + const controlledExecutionPrerequisites = + controlledExecutionPreflight?.prerequisites ?? []; const ownerReviewChecklist = automationHandoff?.owner_review_checklist ?? []; const forbiddenActions = automationHandoff?.forbidden_actions ?? []; const sourceToolchainTone: SourceFlowTone = sourceCorrelation @@ -688,6 +724,14 @@ export function AwoooPStatusChainPanel({ blocked_before_runtime_gate: t("applyGate.closureStatuses.blockedBeforeRuntimeGate"), blocked_until_verifier_passes: t("applyGate.closureStatuses.blockedUntilVerifierPasses"), snapshot_unavailable: t("applyGate.closureStatuses.snapshotUnavailable"), + candidate_ready_no_runtime_authority: t("applyGate.closureStatuses.candidateReadyNoRuntimeAuthority"), + route_missing: t("applyGate.closureStatuses.routeMissing"), + blocked_missing_dry_run: t("applyGate.closureStatuses.blockedMissingDryRun"), + blocked_missing_owner_release: t("applyGate.closureStatuses.blockedMissingOwnerRelease"), + blocked_missing_maintenance_window: t("applyGate.closureStatuses.blockedMissingMaintenanceWindow"), + blocked_missing_rollback_owner: t("applyGate.closureStatuses.blockedMissingRollbackOwner"), + blocked_missing_post_apply_verifier: t("applyGate.closureStatuses.blockedMissingPostApplyVerifier"), + blocked_until_verified_execution: t("applyGate.closureStatuses.blockedUntilVerifiedExecution"), }; return labels[String(status ?? "")] ?? handoffStatusLabel(status); }; @@ -701,6 +745,18 @@ export function AwoooPStatusChainPanel({ }; return labels[String(key ?? "")] ?? valueOrEmpty(key, emptyLabel); }; + const controlledExecutionPrerequisiteLabel = (key: string | null | undefined) => { + const labels: Record = { + dry_run_passed: t("applyGate.controlledPrerequisites.dryRunPassed"), + allowlisted_route_candidate: t("applyGate.controlledPrerequisites.allowlistedRouteCandidate"), + owner_release_receipt: t("applyGate.controlledPrerequisites.ownerReleaseReceipt"), + maintenance_window: t("applyGate.controlledPrerequisites.maintenanceWindow"), + rollback_owner: t("applyGate.controlledPrerequisites.rollbackOwner"), + post_apply_verifier: t("applyGate.controlledPrerequisites.postApplyVerifier"), + km_playbook_writeback: t("applyGate.controlledPrerequisites.kmPlaybookWriteback"), + }; + return labels[String(key ?? "")] ?? valueOrEmpty(key, emptyLabel); + }; const handoffWorkItemHref = automationHandoff?.work_item_id ? `/awooop/work-items?project_id=awoooi&work_item_id=${encodeURIComponent(automationHandoff.work_item_id)}${automationHandoff.source_id ? `&incident_id=${encodeURIComponent(automationHandoff.source_id)}` : ""}` : null; @@ -722,9 +778,10 @@ export function AwoooPStatusChainPanel({ return labels[String(status ?? "")] ?? valueOrEmpty(status, emptyLabel); }; const handoffTone = (status: string | null | undefined): SourceFlowTone => { - if (status === "passed") return "success"; - if (status === "blocked") return "blocked"; - if (status === "warning") return "warning"; + const normalized = String(status ?? ""); + if (normalized === "passed") return "success"; + if (normalized === "warning" || normalized.includes("candidate_ready")) return "warning"; + if (normalized === "blocked" || normalized.startsWith("blocked") || normalized === "route_missing") return "blocked"; return "neutral"; }; const drilldownItems = [ @@ -1123,6 +1180,109 @@ export function AwoooPStatusChainPanel({ ))} + {controlledExecutionPreflight ? ( + <> +
+
+
+

{t("applyGate.controlledExecutionPreflightTitle")}

+

+ {valueOrEmpty(controlledExecutionPreflight.blocked_reason, emptyLabel)} +

+
+ + {closureStatusLabel(controlledExecutionPreflight.status)} + +
+
+
+ {[ + { + key: "candidate", + label: t("applyGate.controlledCandidateRoutes"), + value: controlledExecutionPreflight.candidate_route_count ?? 0, + }, + { + key: "allowed", + label: t("applyGate.controlledAllowedRoutes"), + value: controlledExecutionPreflight.allowed_route_count ?? 0, + }, + { + key: "blocked", + label: t("applyGate.controlledBlockedCount"), + value: controlledExecutionPreflight.blocked_count ?? 0, + }, + { + key: "runtime", + label: t("applyGate.runtimeWrite", { + value: boolValue(controlledExecutionPreflight.runtime_write_allowed, emptyLabel), + }), + value: boolValue(controlledExecutionPreflight.runtime_execution_authorized, emptyLabel), + }, + ].map((item) => ( +
+

{item.label}

+

+ {item.value} +

+
+ ))} +
+
+ {(controlledExecutionPrerequisites.length ? controlledExecutionPrerequisites : [{ key: emptyLabel, status: emptyLabel, detail: emptyLabel, required: true }]).map((item, index) => ( +
+
+ + +
+

{controlledExecutionPrerequisiteLabel(item.key)}

+

+ {closureStatusLabel(item.status)} +

+
+
+

+ {valueOrEmpty(item.detail, emptyLabel)} +

+
+ ))} +
+
+ {(controlledExecutionRoutes.length ? controlledExecutionRoutes : [{ route_id: emptyLabel, transport: emptyLabel, status: emptyLabel, source_asset_id: emptyLabel, check_mode_playbook_path: emptyLabel, apply_playbook_path: emptyLabel, allowed: false, blocker: emptyLabel }]).map((route, index) => ( +
+
+
+

{t("applyGate.controlledRouteTitle")}

+

+ {valueOrEmpty(route.route_id, emptyLabel)} +

+
+ + {t("applyGate.controlledAllowed", { + value: boolValue(route.allowed, emptyLabel), + })} + +
+
+ + {valueOrEmpty(route.transport, emptyLabel)} · {closureStatusLabel(route.status)} + + + {valueOrEmpty(route.apply_playbook_path, emptyLabel)} + + + {valueOrEmpty(route.blocker, emptyLabel)} + +
+
+ ))} +
+ + ) : null}