diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index e93bc7fef..25f3fc851 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -3979,6 +3979,113 @@ def _status_chain_ansible_dry_run_only( ) +def _status_chain_ansible_apply_gate_handoff( + *, + ansible_dry_run_only: bool, + execution_section: dict[str, Any], + incident_ids: list[str], + source_id: str | None, + verification: str, +) -> dict[str, Any] | None: + """Build the owner-review handoff after Ansible check-mode succeeds.""" + + if not ansible_dry_run_only: + return None + ansible = execution_section.get("ansible") + if not isinstance(ansible, dict): + return None + + candidate_playbooks = ( + ansible.get("candidate_playbooks") + if isinstance(ansible.get("candidate_playbooks"), list) + else [] + ) + first_candidate = ( + candidate_playbooks[0] + if candidate_playbooks and isinstance(candidate_playbooks[0], dict) + else {} + ) + source_ref = source_id or (incident_ids[0] if incident_ids else "unknown") + safe_source_ref = "".join( + ch if ch.isalnum() or ch in {"-", "_"} else "-" + for ch in str(source_ref) + ).strip("-") or "unknown" + catalog_id = ( + ansible.get("latest_catalog_id") + or first_candidate.get("catalog_id") + or "ansible-candidate" + ) + check_mode_playbook = ansible.get("latest_playbook_path") or "--" + apply_playbook = ( + first_candidate.get("playbook_path") + or str(check_mode_playbook).replace("-readonly.yml", ".yml") + or "--" + ) + latest_status = str(ansible.get("latest_status") or "unknown").lower() + latest_returncode = str(ansible.get("latest_returncode") or "") + dry_run_passed = latest_status == "success" and latest_returncode in {"", "0"} + verifier_ready = str(verification).lower() in {"verified", "success", "healthy"} + + return { + "schema_version": "awooop_automation_handoff_v1", + "kind": "ansible_check_mode_apply_gate", + "status": "owner_review_required", + "source_id": source_ref, + "work_item_id": f"ansible-apply-gate:awoooi:{safe_source_ref}", + "decision_effect": "none", + "runtime_execution_authorized": False, + "writes_runtime_state": False, + "owner_review_gate": "required_before_apply", + "next_action": "owner_review_apply_gate_or_create_verifier_plan", + "asset_ids": { + "dry_run": f"ansible-check-mode:{catalog_id}", + "apply_candidate": f"ansible-apply-candidate:{catalog_id}", + "verifier": f"verifier-plan:{safe_source_ref}", + }, + "candidate": { + "catalog_id": catalog_id, + "check_mode_playbook_path": check_mode_playbook, + "apply_playbook_path": apply_playbook, + "risk_level": first_candidate.get("risk_level") or "medium", + "match_score": first_candidate.get("match_score"), + }, + "gates": [ + { + "key": "dry_run", + "status": "passed" if dry_run_passed else "warning", + "detail": ( + f"check={_safe_int(ansible.get('check_mode_total'))}; " + f"rc={ansible.get('latest_returncode') if ansible.get('latest_returncode') is not None else '--'}" + ), + }, + { + "key": "apply_gate", + "status": "blocked", + "detail": ( + f"apply={_safe_int(ansible.get('apply_total'))}; " + f"approval={ansible.get('approval_source') or '--'}" + ), + }, + { + "key": "verifier", + "status": "passed" if verifier_ready else "blocked", + "detail": f"verification={verification or 'missing'}", + }, + ], + "owner_review_checklist": [ + "確認 check-mode 沒有寫入與破壞性變更", + "確認 apply playbook 與 target selector 精準匹配", + "確認 rollback owner、維護窗口與 blast radius", + "確認 verifier plan 可在 apply 後回寫 Runs / KM / PlayBook trust", + ], + "forbidden_actions": [ + "no_direct_systemctl_without_owner_review", + "no_ansible_apply_without_approval_receipt", + "no_runtime_gate_raise_from_dry_run_only", + ], + } + + def _latest_remediation_history_item( history: dict[str, Any] | None, ) -> dict[str, Any]: @@ -4820,6 +4927,13 @@ def _build_awooop_status_chain( source_section = _status_chain_source_section(truth_chain) if source_correlation is not None: source_section["correlation"] = source_correlation + automation_handoff = _status_chain_ansible_apply_gate_handoff( + ansible_dry_run_only=ansible_dry_run_only, + execution_section=execution_section, + incident_ids=incident_ids, + source_id=source_id, + verification=str(verification), + ) blockers = [ str(item) for item in [ @@ -4874,6 +4988,7 @@ def _build_awooop_status_chain( "needs_human": needs_human, "next_step": next_step, "operator_outcome": outcome, + "automation_handoff": automation_handoff, "blockers": blockers[:8], "fetch_error": fetch_error, "evidence": { diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 7b1dcccad..d8601f915 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1803,6 +1803,23 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No chain["operator_outcome"]["execution_result"]["completion_status"] == "dry_run_completed_no_apply" ) + assert chain["automation_handoff"]["kind"] == "ansible_check_mode_apply_gate" + assert chain["automation_handoff"]["status"] == "owner_review_required" + assert chain["automation_handoff"]["runtime_execution_authorized"] is False + assert chain["automation_handoff"]["work_item_id"].startswith( + "ansible-apply-gate:awoooi:INC-20260625-977E5F" + ) + assert chain["automation_handoff"]["asset_ids"]["dry_run"] == ( + "ansible-check-mode:ansible:188-ai-web" + ) + assert chain["automation_handoff"]["candidate"]["check_mode_playbook_path"] == ( + "infra/ansible/playbooks/188-ai-web-readonly.yml" + ) + assert [gate["status"] for gate in chain["automation_handoff"]["gates"]] == [ + "passed", + "blocked", + "blocked", + ] 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 2cb04b965..9064ece75 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -10064,6 +10064,24 @@ "notification": "人工通知通道", "reason": "人工原因" }, + "applyGate": { + "title": "乾跑後套用閘門", + "runtime": "runtime={value}", + "workItem": "Work Item", + "applyCandidate": "Apply 候選", + "verifier": "Verifier 資產", + "gates": { + "dryRun": "乾跑", + "applyGate": "套用審查", + "verifier": "驗證回寫" + }, + "statuses": { + "passed": "已通過", + "blocked": "阻擋", + "warning": "需確認", + "pending": "待處理" + } + }, "toolchain": { "title": "AI Agent 證據鏈", "mcp": "MCP / 自建 MCP", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 2cb04b965..9064ece75 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -10064,6 +10064,24 @@ "notification": "人工通知通道", "reason": "人工原因" }, + "applyGate": { + "title": "乾跑後套用閘門", + "runtime": "runtime={value}", + "workItem": "Work Item", + "applyCandidate": "Apply 候選", + "verifier": "Verifier 資產", + "gates": { + "dryRun": "乾跑", + "applyGate": "套用審查", + "verifier": "驗證回寫" + }, + "statuses": { + "passed": "已通過", + "blocked": "阻擋", + "warning": "需確認", + "pending": "待處理" + } + }, "toolchain": { "title": "AI Agent 證據鏈", "mcp": "MCP / 自建 MCP", diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index efd10d814..ecacd0b6e 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -126,6 +126,37 @@ export interface AwoooPStatusChain { }>; }; }; + automation_handoff?: { + schema_version?: string; + kind?: string | null; + status?: string | null; + source_id?: string | null; + work_item_id?: string | null; + decision_effect?: string | null; + runtime_execution_authorized?: boolean | null; + writes_runtime_state?: boolean | null; + owner_review_gate?: string | null; + next_action?: string | null; + asset_ids?: { + dry_run?: string | null; + apply_candidate?: string | null; + verifier?: string | null; + }; + candidate?: { + catalog_id?: string | null; + check_mode_playbook_path?: string | null; + apply_playbook_path?: string | null; + risk_level?: string | null; + match_score?: number | null; + }; + gates?: Array<{ + key?: string | null; + status?: string | null; + detail?: string | null; + }>; + owner_review_checklist?: string[]; + forbidden_actions?: string[]; + } | null; source_refs?: { inbound_total?: number | null; outbound_total?: number | null; @@ -239,6 +270,7 @@ export function AwoooPStatusChainPanel({ const evidence = chain?.evidence ?? {}; const outcome = chain?.operator_outcome; const outcomeExecution = outcome?.execution_result; + const automationHandoff = chain?.automation_handoff; const blockers = chain?.blockers ?? []; const sourceCorrelation = chain?.source_refs?.correlation; @@ -483,6 +515,30 @@ export function AwoooPStatusChainPanel({ }), }, ]; + const handoffGates = automationHandoff?.gates ?? []; + const handoffGateLabel = (key: string | null | undefined) => { + const labels: Record = { + dry_run: t("applyGate.gates.dryRun"), + apply_gate: t("applyGate.gates.applyGate"), + verifier: t("applyGate.gates.verifier"), + }; + return labels[String(key ?? "")] ?? valueOrEmpty(key, emptyLabel); + }; + const handoffStatusLabel = (status: string | null | undefined) => { + const labels: Record = { + passed: t("applyGate.statuses.passed"), + blocked: t("applyGate.statuses.blocked"), + warning: t("applyGate.statuses.warning"), + pending: t("applyGate.statuses.pending"), + }; + 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"; + return "neutral"; + }; const drilldownItems = [ { key: "signal", @@ -681,6 +737,64 @@ export function AwoooPStatusChainPanel({ + {automationHandoff && ( +
+
+
+

{t("applyGate.title")}

+ + {t("applyGate.runtime", { + value: boolValue(automationHandoff.runtime_execution_authorized, emptyLabel), + })} + +
+
+
+ {handoffGates.map((gate) => ( +
+
+ + +
+

{handoffGateLabel(gate.key)}

+

+ {handoffStatusLabel(gate.status)} +

+
+
+

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

+
+ ))} +
+
+
+

{t("applyGate.workItem")}

+

+ {valueOrEmpty(automationHandoff.work_item_id, emptyLabel)} +

+
+
+

{t("applyGate.applyCandidate")}

+

+ {valueOrEmpty(automationHandoff.candidate?.apply_playbook_path, emptyLabel)} +

+
+
+

{t("applyGate.verifier")}

+

+ {valueOrEmpty(automationHandoff.asset_ids?.verifier, emptyLabel)} +

+
+
+
+ )} +

{t("toolchain.title")}

diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 2f613f6f7..d547deb65 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,28 @@ +## 2026-06-25|Status-chain 新增乾跑後套用閘門 handoff + +**背景**:`INC-20260625-977E5F` 類告警目前已能辨識 Ansible `check_mode` 乾跑成功、`apply_total=0`、`verifier=missing`,但 operator 在 Runs / Telegram 看見的下一步仍容易停在「需人工」或長文字。這會讓 AI 自動化看起來像只是把問題丟回人工,而不是清楚交付下一個可審核 gate。 + +**完成**: +- AwoooP status-chain 新增 `automation_handoff`,在 `ansible_check_mode_only` 時產生 `ansible_check_mode_apply_gate`。 +- handoff 固定顯示 `work_item_id`、dry-run asset、apply candidate、verifier asset、owner review checklist 與 forbidden actions。 +- gate 三段化:`dry_run=passed`、`apply_gate=blocked`、`verifier=blocked`,並固定 `runtime_execution_authorized=false`、`writes_runtime_state=false`、`decision_effect=none`。 +- `/zh-TW/awooop/runs` 共用狀態鏈元件新增 `乾跑後套用閘門` 視覺區塊,將「乾跑已過 / 套用審查阻擋 / 驗證回寫阻擋」用三張卡呈現。 +- `zh-TW` / `en` messages 同步新增 apply gate 文案。 + +**驗證**: +- `python3 -m py_compile apps/api/src/services/platform_operator_service.py` 通過。 +- `DATABASE_URL=sqlite:///tmp/awoooi-test.db pytest apps/api/tests/test_awooop_operator_timeline_labels.py -q`:`66 passed`。 +- `python3` i18n JSON parse:`I18N_JSON_OK`。 +- i18n leaf diff:`missing_en=0 extra_en=0`。 +- `pnpm --filter @awoooi/web typecheck` 通過。 + +**完成度同步**: +- AwoooP status-chain handoff 可判讀性:`67% -> 70%`。 +- AwoooP Runs 可判讀性:`68% -> 71%`。 +- 真正 AI 自動化 verified repair 成功率不提高;本段是 owner apply gate / verifier plan 的結構化投影,不是 Ansible apply。 + +**邊界**:本段不執行 Ansible apply、不重啟 `node-exporter-188`、不 SSH、不發 Telegram、不新增操作按鈕、不寫 runtime state、不開 runtime gate。 + ## 2026-06-25|Runs 資產沉澱拆出乾跑 / 套用狀態 **背景**:`INC-20260625-977E5F` 已在後端修正為 `ansible_check_mode_only`,但 Runs 頁既有 `資產沉澱` ledger 仍把 Ansible candidate、check-mode 與 apply 混在「腳本」一格,容易讓「只有乾跑」看起來像已具備可執行資產。這會延續使用者指出的核心問題:AI 看起來有動作,但 operator 仍不知道到底做到哪一步。 diff --git a/docs/workplans/2026-06-25-awoooi-product-uiux-inventory.md b/docs/workplans/2026-06-25-awoooi-product-uiux-inventory.md index 0b613aec4..7ac5f93e2 100644 --- a/docs/workplans/2026-06-25-awoooi-product-uiux-inventory.md +++ b/docs/workplans/2026-06-25-awoooi-product-uiux-inventory.md @@ -233,6 +233,23 @@ Tenants 目前已讀到: 完成度同步:AwoooP Runs 可判讀性 `65% -> 68%`;AwoooP 操作台產品化 `66% -> 67%`;真正 AI 自動化 verified repair 成功率不提高。 +### 2.5.8 Status-chain 乾跑後套用閘門 handoff + +2026-06-25 已完成 status-chain 結構化 handoff:當 `INC-20260625-977E5F` 類事件只有 Ansible `check_mode` 成功、沒有 apply、沒有 verifier 時,不再只顯示一串「需人工」文字,而是把下一關拆成可審查 gate。 + +| 項目 | 完成 | +|---|---| +| API | `awooop_status_chain_v1` 新增 `automation_handoff` | +| Handoff kind | `ansible_check_mode_apply_gate` | +| 三段 gate | `dry_run=passed`、`apply_gate=blocked`、`verifier=blocked` | +| 資產 | `work_item_id`、dry-run asset、apply candidate、verifier asset | +| Owner review | checklist 與 forbidden actions 結構化輸出 | +| UI | `/zh-TW/awooop/runs` 共用 status-chain panel 新增 `乾跑後套用閘門` 三卡區塊 | +| 驗證 | `py_compile`、`pytest apps/api/tests/test_awooop_operator_timeline_labels.py -q`:`66 passed`、i18n JSON / leaf diff、`pnpm --filter @awoooi/web typecheck` 通過 | +| 邊界 | 不執行 Ansible apply、不重啟服務、不 SSH、不發 Telegram、不新增執行按鈕、不開 runtime gate | + +完成度同步:AwoooP status-chain handoff 可判讀性 `67% -> 70%`;AwoooP Runs 可判讀性 `68% -> 71%`;真正 AI 自動化 verified repair 成功率仍不提高。 + ## 3. 頁面 UI/UX 現況盤點 2026-06-25 對正式站桌機 / mobile 抽查: