From df70a0233f0d838f92f193e10b7c399c397f2f46 Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 10:33:47 +0800 Subject: [PATCH 1/3] fix(reboot): follow scoped relink receipt safe next --- .../reboot_auto_recovery_slo_scorecard.py | 33 +++++++++++++++++++ ..._reboot_auto_recovery_slo_scorecard_api.py | 13 +++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py index a7943d7e3..68936efc8 100644 --- a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py +++ b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py @@ -127,6 +127,15 @@ _WINDOWS99_MISSING_VMX_NEXT_SAFE_ACTION = ( "restore_windows99_missing_vmx_source_for_aliases_then_rerun_no_secret_" "collector_and_scorecard_no_vm_power_change" ) +_WINDOWS99_SCOPED_RELINK_RECEIPT_FILE = ( + "windows99-vmx-source-scoped-relink-dry-run-receipt.snapshot.json" +) +_WINDOWS99_SCOPED_RELINK_RECEIPT_SCHEMA_VERSION = ( + "windows99_vmx_source_scoped_relink_dry_run_receipt_v1" +) +_WINDOWS99_SCOPED_RELINK_READY_NEXT_SAFE_ACTION = ( + "rerun_no_secret_collector_and_scorecard_no_vm_power_change" +) _WINDOWS99_POWERED_OFF_NEXT_SAFE_ACTION = ( "run_windows99_vmware_autostart_check_mode_package_for_powered_off_aliases_" "then_rerun_no_secret_collector_no_vm_power_change" @@ -1386,6 +1395,8 @@ def _windows99_collected_runtime_next_safe_action( ): return "" if _strings(metric_readback.get("windows99_missing_vmx_aliases")): + if _windows99_scoped_relink_receipt_ready(): + return _WINDOWS99_SCOPED_RELINK_READY_NEXT_SAFE_ACTION return _WINDOWS99_MISSING_VMX_NEXT_SAFE_ACTION if _strings(metric_readback.get("windows99_powered_off_aliases")): return _WINDOWS99_POWERED_OFF_NEXT_SAFE_ACTION @@ -1406,6 +1417,28 @@ def _read_json_file(path: Path) -> dict[str, Any]: return payload if isinstance(payload, dict) else {} +def _windows99_scoped_relink_receipt_ready( + operations_dir: Path | None = None, +) -> bool: + receipt = _read_json_file( + (operations_dir or _DEFAULT_OPERATIONS_DIR) + / _WINDOWS99_SCOPED_RELINK_RECEIPT_FILE + ) + aliases = _strings(receipt.get("target_vm_aliases")) + return bool( + receipt.get("schema_version") + == _WINDOWS99_SCOPED_RELINK_RECEIPT_SCHEMA_VERSION + and receipt.get("target_host_alias") == "99" + and aliases == ["111"] + and receipt.get("ready") is True + and receipt.get("reviewer_validation_passed") is True + and receipt.get("controlled_relink_dry_run_ready") is True + and receipt.get("remote_write_performed") is not True + and receipt.get("vm_power_change_performed") is not True + and receipt.get("secret_value_read") is not True + ) + + def apply_public_maintenance_runtime_readback( payload: dict[str, Any], runtime_readback: dict[str, Any], diff --git a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py index 4fe10814c..8c6541508 100644 --- a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py +++ b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py @@ -46,6 +46,9 @@ EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP = ( "restore_windows99_missing_vmx_source_for_aliases_then_rerun_no_secret_" "collector_and_scorecard_no_vm_power_change" ) +EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP = ( + "rerun_no_secret_collector_and_scorecard_no_vm_power_change" +) EXPECTED_WINDOWS99_POWERED_OFF_NEXT_STEP = ( "run_windows99_vmware_autostart_check_mode_package_for_powered_off_aliases_" "then_rerun_no_secret_collector_no_vm_power_change" @@ -1257,16 +1260,16 @@ def test_reboot_auto_recovery_slo_scorecard_promotes_collected_windows99_runtime ) assert payload["primary_blocker"] == "reboot_event_required_host_unreachable" - assert payload["safe_next_step"] == EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP - assert payload["next_safe_action"] == EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP + assert payload["safe_next_step"] == EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP + assert payload["next_safe_action"] == EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP assert payload["readback"]["safe_next_step"] == ( - EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP + EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP ) assert payload["readback"]["next_safe_action"] == ( - EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP + EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP ) assert payload["reboot_sop_progress"]["next_safe_action"] == ( - EXPECTED_WINDOWS99_MISSING_VMX_NEXT_STEP + EXPECTED_WINDOWS99_SCOPED_RELINK_READY_NEXT_STEP ) assert payload["windows99_verify_collection"][ "no_secret_collector_status" From 8b575cc81f0a160e998df0d8a10c6278f5d8ff77 Mon Sep 17 00:00:00 2001 From: AWOOOI CD Date: Fri, 10 Jul 2026 10:40:01 +0800 Subject: [PATCH 2/3] chore(cd): deploy df70a02 [skip ci] --- k8s/awoooi-prod/06-deployment-api.yaml | 4 ++-- k8s/awoooi-prod/kustomization.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index c2d4de0f8..7bc080260 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -84,12 +84,12 @@ spec: - name: AWOOOI_BUILD_COMMIT_SHA # 2026-06-29 Codex: CD rewrites this to the deployed image tag so # production deploy readback does not rely on a stale static snapshot. - value: "5dd64bc723a41fd969df17df0ae7c4f5c1b4c2fd" + value: "df70a0233f0d838f92f193e10b7c399c397f2f46" - name: AWOOOI_DESIRED_API_IMAGE_TAG # 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA. # Production readback compares runtime image truth against this # GitOps desired tag instead of doing a slow Gitea raw fetch. - value: "5dd64bc723a41fd969df17df0ae7c4f5c1b4c2fd" + value: "df70a0233f0d838f92f193e10b7c399c397f2f46" - name: DATABASE_POOL_SIZE # 2026-07-01 Codex: production role `awoooi` currently has a low # connection limit. Keep API pool conservative until DB role diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 99a57a7ba..af3b9ea6d 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -41,7 +41,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: 5dd64bc723a41fd969df17df0ae7c4f5c1b4c2fd + newTag: df70a0233f0d838f92f193e10b7c399c397f2f46 - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: 5dd64bc723a41fd969df17df0ae7c4f5c1b4c2fd + newTag: df70a0233f0d838f92f193e10b7c399c397f2f46 From 99f427a3cfd6044499bdf4b6726c9b9d9fb0f21b Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 10:42:38 +0800 Subject: [PATCH 3/3] feat(work-items): surface product governance matrix --- apps/api/src/api/v1/agents.py | 41 ++- .../product_governance_matrix_readback.py | 317 ++++++++++++++++++ ..._product_governance_matrix_readback_api.py | 172 ++++++++++ apps/web/messages/en.json | 23 ++ apps/web/messages/zh-TW.json | 23 ++ .../app/[locale]/awooop/work-items/page.tsx | 242 +++++++++++++ 6 files changed, 815 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/product_governance_matrix_readback.py create mode 100644 apps/api/tests/test_product_governance_matrix_readback_api.py diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 373441920..9c703a170 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -386,12 +386,12 @@ from src.services.dependency_upgrade_approval_package_template import ( from src.services.docker_build_surface_inventory import ( load_latest_docker_build_surface_inventory, ) -from src.services.gitea_authenticated_inventory_payload_validation import ( - validate_gitea_authenticated_inventory_payload, -) from src.services.gitea_actions_secret_variable_name_inventory import ( load_latest_gitea_actions_secret_variable_name_inventory, ) +from src.services.gitea_authenticated_inventory_payload_validation import ( + validate_gitea_authenticated_inventory_payload, +) from src.services.gitea_branch_protection_required_checks_readback import ( load_latest_gitea_branch_protection_required_checks_readback, ) @@ -461,6 +461,9 @@ from src.services.product_awoooi_manifest_standard import ( from src.services.product_code_review_gate import ( load_latest_product_code_review_gate, ) +from src.services.product_governance_matrix_readback import ( + load_latest_product_governance_matrix_readback, +) from src.services.public_redaction import redact_public_lan_topology from src.services.reboot_auto_recovery_drill_preflight import ( load_latest_reboot_auto_recovery_drill_preflight, @@ -1875,6 +1878,38 @@ async def get_gitea_private_inventory_p0_scorecard() -> dict[str, Any]: ) from exc +@router.get( + "/product-governance-matrix-readback", + response_model=dict[str, Any], + summary="取得全產品 product governance matrix 讀回", + description=( + "整合已提交的 Gitea private inventory 與 dev/prod repo truth," + "輸出每個產品的 canonical repo、main/dev branch、visibility、" + "owner readiness 與 backup evidence ref。此端點只讀 snapshot;" + "不呼叫 GitHub、不建立 repo、不改 visibility、不同步 refs、" + "不觸發 workflow、不讀 secret、不讀 raw session/SQLite。" + ), +) +async def get_product_governance_matrix_readback() -> dict[str, Any]: + """回傳全產品 Gitea source-truth governance matrix。""" + try: + payload = await asyncio.to_thread( + load_latest_product_governance_matrix_readback + ) + return redact_public_lan_topology(payload) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + logger.error("product_governance_matrix_readback_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Product governance matrix readback 快照無效", + ) from exc + + @router.post( "/gitea-private-inventory-p0-scorecard/validate-authenticated-inventory-payload", response_model=dict[str, Any], diff --git a/apps/api/src/services/product_governance_matrix_readback.py b/apps/api/src/services/product_governance_matrix_readback.py new file mode 100644 index 000000000..0dc292702 --- /dev/null +++ b/apps/api/src/services/product_governance_matrix_readback.py @@ -0,0 +1,317 @@ +"""Product governance matrix readback. + +This service promotes committed Gitea source-truth snapshots into a product +governance matrix. It is read-only: no GitHub calls, no Gitea writes, no secret +reads, no workflow dispatch, and no runtime host action. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + +from src.services.gitea_private_inventory_p0_scorecard import ( + load_latest_gitea_private_inventory_p0_scorecard, +) +from src.services.snapshot_paths import default_operations_dir + +SCHEMA_VERSION = "product_governance_matrix_readback_v1" +TZ_TAIPEI = ZoneInfo("Asia/Taipei") +_DEFAULT_OPERATIONS_DIR = default_operations_dir(Path(__file__)) +_PRIVATE_INVENTORY_FILE = "awoooi-gitea-private-inventory-p0-scorecard.snapshot.json" +_DEV_PROD_REPO_READBACK_FILE = "gitea-all-product-dev-prod-repo-readback.snapshot.json" + + +def load_latest_product_governance_matrix_readback( + operations_dir: Path | None = None, + *, + repo_bundle_backup_readback: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Load the committed product source-truth matrix.""" + directory = operations_dir or _DEFAULT_OPERATIONS_DIR + private_inventory_path = directory / _PRIVATE_INVENTORY_FILE + dev_prod_path = directory / _DEV_PROD_REPO_READBACK_FILE + private_inventory_source = _load_json_object(private_inventory_path) + dev_prod_source = _load_json_object(dev_prod_path) + private_inventory = load_latest_gitea_private_inventory_p0_scorecard(directory) + return build_product_governance_matrix_readback( + private_inventory_source=private_inventory_source, + dev_prod_repo_readback=dev_prod_source, + private_inventory_readback=private_inventory, + repo_bundle_backup_readback=repo_bundle_backup_readback, + source_refs={ + "private_inventory": f"docs/operations/{private_inventory_path.name}", + "dev_prod_repo_readback": f"docs/operations/{dev_prod_path.name}", + "repo_bundle_backup_readback": ( + "GET /api/v1/agents/gitea-repo-bundle-backup-readback" + ), + }, + ) + + +def build_product_governance_matrix_readback( + *, + private_inventory_source: dict[str, Any], + dev_prod_repo_readback: dict[str, Any], + private_inventory_readback: dict[str, Any], + repo_bundle_backup_readback: dict[str, Any] | None = None, + generated_at: str | None = None, + source_refs: dict[str, str] | None = None, +) -> dict[str, Any]: + """Build a matrix from source-control truth and optional backup evidence.""" + refs = source_refs or {} + private_rollups = _dict(private_inventory_readback.get("rollups")) + dev_prod_summary = _dict(dev_prod_repo_readback.get("summary")) + product_rows = _list(private_inventory_source.get("product_rows")) + bundle_rows = _bundle_repo_index(repo_bundle_backup_readback) + bundle_summary = _dict((repo_bundle_backup_readback or {}).get("summary")) + bundle_ready = (repo_bundle_backup_readback or {}).get("ready") is True + + products = [ + _product_row( + row, + bundle_rows=bundle_rows, + bundle_ready=bundle_ready, + bundle_ref=refs.get( + "repo_bundle_backup_readback", + "GET /api/v1/agents/gitea-repo-bundle-backup-readback", + ), + ) + for row in product_rows + ] + ready_count = sum(1 for row in products if row["source_control_ready"]) + blocker_ids = sorted( + { + blocker + for row in products + for blocker in _strings(row.get("blockers")) + } + ) + all_rows_ready = len(products) > 0 and ready_count == len(products) + status_value = ( + "product_governance_matrix_ready" + if all_rows_ready and not blocker_ids + else "product_governance_matrix_action_required" + ) + + return { + "schema_version": SCHEMA_VERSION, + "generated_at": generated_at + or datetime.now(TZ_TAIPEI).isoformat(timespec="seconds"), + "status": status_value, + "scope": "product_governance_matrix", + "source_control_authority": "gitea", + "source_refs": { + "private_inventory": refs.get( + "private_inventory", + f"docs/operations/{_PRIVATE_INVENTORY_FILE}", + ), + "dev_prod_repo_readback": refs.get( + "dev_prod_repo_readback", + f"docs/operations/{_DEV_PROD_REPO_READBACK_FILE}", + ), + "repo_bundle_backup_readback": refs.get( + "repo_bundle_backup_readback", + "GET /api/v1/agents/gitea-repo-bundle-backup-readback", + ), + }, + "summary": { + "product_count": len(products), + "ready_product_count": ready_count, + "blocked_product_count": len(products) - ready_count, + "expected_product_count": _int( + private_rollups.get("expected_product_count") + or dev_prod_summary.get("expected_product_count") + ), + "canonical_repo_count": len( + {row["canonical_repo"] for row in products if row["canonical_repo"]} + ), + "ssh_verified_product_repo_count": _int( + private_rollups.get("ssh_verified_product_repo_count") + or dev_prod_summary.get("ssh_repo_present_count") + ), + "main_branch_present_product_repo_count": _int( + private_rollups.get("main_branch_present_product_repo_count") + or dev_prod_summary.get("main_branch_present_count") + ), + "dev_branch_present_product_repo_count": _int( + private_rollups.get("dev_branch_present_product_repo_count") + or dev_prod_summary.get("dev_branch_present_count") + ), + "dev_prod_environment_split_ready_count": _int( + private_rollups.get("dev_prod_environment_split_ready_count") + or dev_prod_summary.get("environment_split_ready_count") + ), + "public_visible_product_repo_count": _int( + private_rollups.get("public_visible_product_repo_count") + or dev_prod_summary.get("public_visible_count") + ), + "private_or_auth_only_product_repo_count": _int( + private_rollups.get("private_or_auth_only_product_repo_count") + or dev_prod_summary.get("private_or_auth_only_count") + ), + "tokenless_http_404_private_or_auth_product_repo_count": _int( + private_rollups.get( + "tokenless_http_404_private_or_auth_product_repo_count" + ) + or dev_prod_summary.get("tokenless_http_404_private_or_auth_count") + ), + "missing_product_repo_count": _int( + private_rollups.get("missing_product_repo_count") + or dev_prod_summary.get("missing_repo_count") + ), + "owner_readiness_row_present_count": sum( + 1 for row in products if row["owner_readiness_row_present"] + ), + "all_products_have_source_control_ready": all_rows_ready, + "all_active_product_repos_have_owner_readiness_row": ( + private_rollups.get( + "all_active_product_repos_have_gitea_owner_readiness_row" + ) + is True + ), + "all_expected_product_repos_have_dev_and_main": ( + private_rollups.get("all_expected_product_repos_have_dev_and_main") + is True + or dev_prod_summary.get("all_expected_product_repos_have_dev_and_main") + is True + ), + "backup_bundle_ready": bundle_ready, + "backup_bundle_expected_repo_count": _int( + bundle_summary.get("expected_repo_count") + ), + "backup_evidence_linked_product_count": sum( + 1 for row in products if row["backup_evidence_ref"] + ), + "backup_bundle_verified_product_count": sum( + 1 + for row in products + if row["backup_evidence_state"] == "bundle_backup_verified" + ), + "active_blocker_count": len(blocker_ids), + }, + "products": products, + "active_blockers": blocker_ids, + "next_action": ( + "keep_product_governance_matrix_as_source_truth_for_KM_RAG_MCP_LOG_wiring" + if status_value == "product_governance_matrix_ready" + else "repair_missing_product_source_truth_before_governance_rollout" + ), + "operation_boundaries": { + "read_only_api_allowed": True, + "github_api_allowed": False, + "github_cli_allowed": False, + "gitea_repo_write_allowed": False, + "gitea_refs_sync_allowed": False, + "gitea_visibility_change_allowed": False, + "workflow_dispatch_allowed": False, + "secret_value_collection_allowed": False, + "raw_session_or_sqlite_read_allowed": False, + }, + } + + +def _product_row( + row: dict[str, Any], + *, + bundle_rows: dict[str, dict[str, Any]], + bundle_ready: bool, + bundle_ref: str, +) -> dict[str, Any]: + repo = str(row.get("gitea_repo") or "") + backup_row = bundle_rows.get(repo) + blockers = _strings(row.get("blockers")) + source_control_ready = ( + row.get("ssh_refs_readable") is True + and row.get("prod_branch_present") is True + and row.get("dev_branch_present") is True + and row.get("environment_split_ready") is True + and row.get("owner_readiness_row_present") is True + and not blockers + ) + visibility = str(row.get("visibility_readback") or "unknown") + private_or_auth_only = visibility == "private_or_auth_only" + if backup_row: + backup_state = ( + "bundle_backup_verified" + if bundle_ready and backup_row.get("ok") is True + else "bundle_backup_readback_linked" + ) + else: + backup_state = "backup_evidence_ref_only" + return { + "product_id": str(row.get("product_id") or ""), + "status": "ready" if source_control_ready else "action_required", + "canonical_repo": repo, + "source_control_authority": "gitea", + "prod_branch": str(row.get("prod_environment_branch") or "main"), + "prod_sha_short": str(row.get("prod_branch_sha") or "")[:8], + "prod_branch_present": row.get("prod_branch_present") is True, + "dev_branch": str(row.get("dev_environment_branch") or "dev"), + "dev_sha_short": str(row.get("dev_branch_sha") or "")[:8], + "dev_branch_present": row.get("dev_branch_present") is True, + "ssh_refs_readable": row.get("ssh_refs_readable") is True, + "environment_split_ready": row.get("environment_split_ready") is True, + "owner_readiness_row_present": ( + row.get("owner_readiness_row_present") is True + ), + "visibility_readback": visibility, + "public_ui_visible": row.get("public_ui_visible") is True, + "private_or_auth_only": private_or_auth_only, + "tokenless_http_status": _int(row.get("tokenless_http_status")), + "tokenless_404_is_visibility_auth": ( + private_or_auth_only and _int(row.get("tokenless_http_status")) == 404 + ), + "backup_evidence_ref": f"{bundle_ref}#repo={repo}", + "backup_evidence_state": backup_state, + "source_control_ready": source_control_ready, + "blockers": blockers, + "next_gate": str(row.get("next_gate") or ""), + } + + +def _bundle_repo_index(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + rows = _list((payload or {}).get("repos")) + indexed: dict[str, dict[str, Any]] = {} + for row in rows: + repo = str(row.get("repo") or "") + if not repo: + continue + indexed[repo] = row + return indexed + + +def _load_json_object(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + return payload + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[dict[str, Any]]: + return [row for row in value if isinstance(row, dict)] if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [str(item) for item in value if str(item)] if isinstance(value, list) else [] + + +def _int(value: Any) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float): + return int(value) + if isinstance(value, str): + try: + return int(float(value)) + except ValueError: + return 0 + return 0 diff --git a/apps/api/tests/test_product_governance_matrix_readback_api.py b/apps/api/tests/test_product_governance_matrix_readback_api.py new file mode 100644 index 000000000..935f33677 --- /dev/null +++ b/apps/api/tests/test_product_governance_matrix_readback_api.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +# ruff: noqa: E402 +import json +import os +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +from src.api.v1 import agents +from src.api.v1.agents import router +from src.services.gitea_repo_bundle_backup_readback import ( + build_gitea_repo_bundle_backup_readback, +) +from src.services.product_governance_matrix_readback import ( + build_product_governance_matrix_readback, + load_latest_product_governance_matrix_readback, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_OPERATIONS_DIR = _REPO_ROOT / "docs" / "operations" +_PRIVATE_INVENTORY_SNAPSHOT = ( + _OPERATIONS_DIR / "awoooi-gitea-private-inventory-p0-scorecard.snapshot.json" +) +_DEV_PROD_SNAPSHOT = ( + _OPERATIONS_DIR / "gitea-all-product-dev-prod-repo-readback.snapshot.json" +) + + +def _sample(name: str, value: float, **labels: str) -> dict: + merged = {"host": "188", **labels} + return {"name": name, "labels": merged, "value": value} + + +def _repo_refs() -> list[str]: + data = json.loads(_DEV_PROD_SNAPSHOT.read_text(encoding="utf-8")) + return sorted(row["gitea_repo"] for row in data["products"]) + + +def _repo_bundle_payload() -> dict: + refs = _repo_refs() + samples = [ + _sample("awoooi_gitea_bundle_expected_repo_count", len(refs)), + _sample("awoooi_gitea_bundle_expected_repo_missing_count", 0), + _sample("awoooi_gitea_bundle_failed_repo_count", 0), + _sample("awoooi_gitea_bundle_checksum_missing_count", 0), + _sample("awoooi_gitea_bundle_age_seconds", 600), + _sample("awoooi_gitea_bundle_fresh", 1), + _sample("awoooi_gitea_bundle_all_expected_ok", 1), + _sample("awoooi_gitea_bundle_sample_restore_dry_run_ok", 1), + ] + for repo in refs: + samples.extend( + [ + _sample("awoooi_gitea_bundle_repo_present", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_ok", 1, repo=repo), + _sample("awoooi_gitea_bundle_repo_head_count", 2, repo=repo), + _sample( + "awoooi_gitea_bundle_checksum_digest_present", + 1, + repo=repo, + ), + ] + ) + return build_gitea_repo_bundle_backup_readback( + metric_samples=samples, + generated_at="2026-07-10T12:00:00+08:00", + dev_prod_repo_refs=refs, + dev_prod_repo_truth_ref=( + "docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json" + ), + ) + + +def test_product_governance_matrix_loads_committed_gitea_source_truth() -> None: + payload = load_latest_product_governance_matrix_readback(_OPERATIONS_DIR) + + assert payload["schema_version"] == "product_governance_matrix_readback_v1" + assert payload["status"] == "product_governance_matrix_ready" + assert payload["source_control_authority"] == "gitea" + assert payload["summary"]["product_count"] == 12 + assert payload["summary"]["ready_product_count"] == 12 + assert payload["summary"]["canonical_repo_count"] == 12 + assert payload["summary"]["ssh_verified_product_repo_count"] == 12 + assert payload["summary"]["main_branch_present_product_repo_count"] == 12 + assert payload["summary"]["dev_branch_present_product_repo_count"] == 12 + assert payload["summary"]["private_or_auth_only_product_repo_count"] == 6 + assert payload["summary"]["backup_evidence_linked_product_count"] == 12 + assert payload["summary"]["backup_bundle_verified_product_count"] == 0 + assert payload["summary"]["active_blocker_count"] == 0 + assert payload["operation_boundaries"]["github_api_allowed"] is False + assert payload["operation_boundaries"]["secret_value_collection_allowed"] is False + + rows = {row["product_id"]: row for row in payload["products"]} + assert rows["awoooi"]["canonical_repo"] == "wooo/awoooi" + assert rows["awoooi"]["prod_branch"] == "main" + assert rows["awoooi"]["dev_branch"] == "dev" + assert rows["awoooi"]["source_control_ready"] is True + assert rows["awooogo"]["canonical_repo"] == "wooo/AwoooGo" + assert rows["awooogo"]["private_or_auth_only"] is True + assert rows["awooogo"]["tokenless_http_status"] == 404 + assert rows["awooogo"]["tokenless_404_is_visibility_auth"] is True + assert rows["awooogo"]["source_control_ready"] is True + assert rows["awooogo"]["backup_evidence_state"] == "backup_evidence_ref_only" + + +def test_product_governance_matrix_applies_bundle_backup_overlay() -> None: + payload = load_latest_product_governance_matrix_readback( + _OPERATIONS_DIR, + repo_bundle_backup_readback=_repo_bundle_payload(), + ) + + assert payload["summary"]["backup_bundle_ready"] is True + assert payload["summary"]["backup_bundle_expected_repo_count"] == 12 + assert payload["summary"]["backup_evidence_linked_product_count"] == 12 + assert payload["summary"]["backup_bundle_verified_product_count"] == 12 + assert all( + row["backup_evidence_state"] == "bundle_backup_verified" + for row in payload["products"] + ) + + +def test_product_governance_matrix_builder_blocks_missing_product_truth() -> None: + private_inventory_source = json.loads( + _PRIVATE_INVENTORY_SNAPSHOT.read_text(encoding="utf-8") + ) + dev_prod = json.loads(_DEV_PROD_SNAPSHOT.read_text(encoding="utf-8")) + private_readback = load_latest_product_governance_matrix_readback( + _OPERATIONS_DIR + ) + source_rows = private_inventory_source["product_rows"] + source_rows[0] = {**source_rows[0], "dev_branch_present": False} + + payload = build_product_governance_matrix_readback( + private_inventory_source=private_inventory_source, + dev_prod_repo_readback=dev_prod, + private_inventory_readback={ + "rollups": { + **private_readback["summary"], + "all_active_product_repos_have_gitea_owner_readiness_row": True, + "all_expected_product_repos_have_dev_and_main": False, + } + }, + generated_at="2026-07-10T12:30:00+08:00", + ) + + assert payload["status"] == "product_governance_matrix_action_required" + assert payload["summary"]["ready_product_count"] == 11 + assert payload["products"][0]["status"] == "action_required" + + +def test_product_governance_matrix_endpoint_returns_payload(monkeypatch) -> None: + expected = load_latest_product_governance_matrix_readback(_OPERATIONS_DIR) + monkeypatch.setattr( + agents, + "load_latest_product_governance_matrix_readback", + lambda: expected, + ) + + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/product-governance-matrix-readback") + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "product_governance_matrix_readback_v1" + assert data["summary"]["product_count"] == 12 + assert data["products"][0]["canonical_repo"].startswith("wooo/") diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a193ee3ee..17b2721bc 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9549,6 +9549,29 @@ "deferred": "Deferred" } }, + "productGovernance": { + "eyebrow": "Product Governance", + "title": "All-Product Source Truth Matrix", + "subtitle": "Turns committed Gitea inventory, dev/main branches, private visibility, and backup evidence refs into a product governance matrix for later KM/RAG/MCP/LOG classification.", + "status": "Status", + "authority": "Authority", + "ready": "ready", + "actionRequired": "action", + "prod": "prod", + "dev": "dev", + "privateAuth": "private/auth", + "publicVisible": "public", + "backupVerified": "backup verified", + "backupLinked": "backup linked", + "nextAction": "Next action", + "metrics": { + "products": "Products ready", + "ssh": "SSH refs", + "devMain": "dev/main", + "private": "private", + "backup": "backup evidence" + } + }, "commanderInsertedRequirements": { "eyebrow": "Mainline priority", "title": "Commander Inserted Requirement Work Items", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 37c766e21..87cf8869e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9549,6 +9549,29 @@ "deferred": "延後" } }, + "productGovernance": { + "eyebrow": "Product Governance", + "title": "全產品 Source Truth Matrix", + "subtitle": "把 committed Gitea inventory、dev/main branch、private visibility 與 backup evidence ref 收成產品治理矩陣,後續 KM/RAG/MCP/LOG 都以此分類。", + "status": "狀態", + "authority": "Authority", + "ready": "ready", + "actionRequired": "action", + "prod": "prod", + "dev": "dev", + "privateAuth": "private/auth", + "publicVisible": "public", + "backupVerified": "backup verified", + "backupLinked": "backup linked", + "nextAction": "下一步", + "metrics": { + "products": "產品 ready", + "ssh": "SSH refs", + "devMain": "dev/main", + "private": "private", + "backup": "backup evidence" + } + }, "commanderInsertedRequirements": { "eyebrow": "主線優先序", "title": "統帥插入需求工作項", 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 6e3d73b66..db4057011 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1469,6 +1469,61 @@ type SecurityCockpitRegistryReadback = { } | null; }; +type ProductGovernanceMatrixProduct = { + product_id?: string | null; + status?: string | null; + canonical_repo?: string | null; + prod_branch?: string | null; + prod_sha_short?: string | null; + dev_branch?: string | null; + dev_sha_short?: string | null; + ssh_refs_readable?: boolean | null; + environment_split_ready?: boolean | null; + owner_readiness_row_present?: boolean | null; + visibility_readback?: string | null; + public_ui_visible?: boolean | null; + private_or_auth_only?: boolean | null; + tokenless_http_status?: number | null; + tokenless_404_is_visibility_auth?: boolean | null; + backup_evidence_ref?: string | null; + backup_evidence_state?: string | null; + source_control_ready?: boolean | null; + blockers?: string[] | null; + next_gate?: string | null; +}; + +type ProductGovernanceMatrixResponse = { + schema_version?: string | null; + status?: string | null; + source_control_authority?: string | null; + source_refs?: Record | null; + summary?: { + product_count?: number | null; + ready_product_count?: number | null; + blocked_product_count?: number | null; + canonical_repo_count?: number | null; + ssh_verified_product_repo_count?: number | null; + main_branch_present_product_repo_count?: number | null; + dev_branch_present_product_repo_count?: number | null; + dev_prod_environment_split_ready_count?: number | null; + public_visible_product_repo_count?: number | null; + private_or_auth_only_product_repo_count?: number | null; + tokenless_http_404_private_or_auth_product_repo_count?: number | null; + owner_readiness_row_present_count?: number | null; + all_products_have_source_control_ready?: boolean | null; + all_active_product_repos_have_owner_readiness_row?: boolean | null; + all_expected_product_repos_have_dev_and_main?: boolean | null; + backup_bundle_ready?: boolean | null; + backup_bundle_expected_repo_count?: number | null; + backup_evidence_linked_product_count?: number | null; + backup_bundle_verified_product_count?: number | null; + active_blocker_count?: number | null; + } | null; + products?: ProductGovernanceMatrixProduct[] | null; + active_blockers?: string[] | null; + next_action?: string | null; +}; + const COMMANDER_INSERTED_REQUIREMENT_FALLBACK: PriorityWorkOrderResponse = { summary: { active_p0_state: "blocked_reboot_auto_recovery_slo_not_ready", @@ -1824,6 +1879,7 @@ type Telemetry = { aiRouteStatus: AiRouteStatusResponse | null; reportSourceHealth: ReportSourceHealthResponse | null; priorityWorkOrder: PriorityWorkOrderResponse | null; + productGovernanceMatrix: ProductGovernanceMatrixResponse | null; }; type AutomationAssetLedgerKey = "km" | "playbook" | "script" | "schedule" | "verifier"; @@ -11276,6 +11332,181 @@ function MainlineWorkProgressStrip({ ); } +function ProductGovernanceMatrixPanel({ + matrix, + loading, +}: { + matrix: ProductGovernanceMatrixResponse | null; + loading: boolean; +}) { + const t = useTranslations("awooop.workItems.productGovernance"); + const summary = matrix?.summary; + const products = (matrix?.products ?? []).slice(0, 6); + const total = toCount(summary?.product_count ?? products.length); + const ready = toCount(summary?.ready_product_count); + const sshVerified = toCount(summary?.ssh_verified_product_repo_count); + const devMainReady = toCount(summary?.dev_prod_environment_split_ready_count); + const privateRepos = toCount(summary?.private_or_auth_only_product_repo_count); + const backupLinked = toCount(summary?.backup_evidence_linked_product_count); + const activeBlockers = toCount(summary?.active_blocker_count); + const sourceReady = + summary?.all_products_have_source_control_ready === true || + (total > 0 && ready === total && activeBlockers === 0); + const metricCards = [ + { + key: "products", + label: t("metrics.products"), + value: loading ? "--" : `${ready}/${total}`, + icon: Database, + tone: sourceReady ? "border-[#bfdcc4] bg-[#f2fbf3]" : "border-[#ead5bd] bg-[#fff8ef]", + }, + { + key: "ssh", + label: t("metrics.ssh"), + value: loading ? "--" : sshVerified, + icon: GitBranch, + tone: "border-[#c9d8ea] bg-[#eef5ff]", + }, + { + key: "devmain", + label: t("metrics.devMain"), + value: loading ? "--" : devMainReady, + icon: GitPullRequest, + tone: "border-[#d8d3c7] bg-white", + }, + { + key: "private", + label: t("metrics.private"), + value: loading ? "--" : privateRepos, + icon: Lock, + tone: "border-[#e6d0d0] bg-[#fff6f6]", + }, + { + key: "backup", + label: t("metrics.backup"), + value: loading ? "--" : backupLinked, + icon: Archive, + tone: "border-[#d5d6ea] bg-[#f6f6ff]", + }, + ]; + + return ( +
+
+
+
+
+

+ {t("title")} +

+
+
+
+ {t("status")} +
+
+ {loading ? "--" : sourceReady ? t("ready") : t("actionRequired")} +
+
+
+
+ {t("authority")} +
+
+ {matrix?.source_control_authority ?? "gitea"} +
+
+
+

+ {t("subtitle")} +

+
+ +
+
+ {metricCards.map((metric) => { + const Icon = metric.icon; + return ( +
+
+ {metric.label} +
+
+ {metric.value} +
+
+ ); + })} +
+ +
+ {products.map((product) => { + const rowReady = product.source_control_ready === true; + return ( +
+
+
+ {product.product_id} +
+ + {rowReady ? t("ready") : t("actionRequired")} + +
+
+ {product.canonical_repo} +
+
+ + {t("prod")} {product.prod_branch}:{product.prod_sha_short} + + + {t("dev")} {product.dev_branch}:{product.dev_sha_short} + + + {product.private_or_auth_only ? t("privateAuth") : t("publicVisible")} + + + {product.backup_evidence_state === "bundle_backup_verified" + ? t("backupVerified") + : t("backupLinked")} + +
+
+ ); + })} +
+ +
+ {t("nextAction")}{" "} + {loading ? "--" : matrix?.next_action ?? "--"} +
+
+
+
+ ); +} + function CommanderInsertedRequirementsPanel({ priority, loading, @@ -11706,6 +11937,7 @@ export default function AwoooPWorkItemsPage() { aiRouteStatus: null, reportSourceHealth: null, priorityWorkOrder: null, + productGovernanceMatrix: null, }); const [loading, setLoading] = useState(true); const [priorityWorkOrderReadback, setPriorityWorkOrderReadback] = @@ -11786,6 +12018,8 @@ export default function AwoooPWorkItemsPage() { projectId ); const reportSourceHealthUrl = `${API_BASE}/api/v1/agents/agent-report-source-health`; + const productGovernanceMatrixUrl = + `${API_BASE}/api/v1/agents/product-governance-matrix-readback`; const priorityWorkOrderUrl = `${API_BASE}/api/v1/agents/awoooi-priority-work-order-readback`; const priorityWorkOrderPromise = fetchJson(priorityWorkOrderUrl, 12000); @@ -11819,6 +12053,7 @@ export default function AwoooPWorkItemsPage() { callbackReplies, aiRouteStatus, reportSourceHealth, + productGovernanceMatrix, priorityWorkOrder, ] = await Promise.all([ fetchJson(qualityUrl, 15000), @@ -11839,6 +12074,7 @@ export default function AwoooPWorkItemsPage() { fetchJson(callbackRepliesUrl, 12000), fetchJson(aiRouteStatusUrl, 12000), fetchJson(reportSourceHealthUrl, 12000), + fetchJson(productGovernanceMatrixUrl, 12000), priorityWorkOrderPromise, ]); @@ -11888,6 +12124,7 @@ export default function AwoooPWorkItemsPage() { incidentTimeline, aiRouteStatus, reportSourceHealth, + productGovernanceMatrix, priorityWorkOrder, }); setLastUpdated(new Date()); @@ -11991,6 +12228,11 @@ export default function AwoooPWorkItemsPage() { loading={priorityWorkOrderLoading && !priorityWorkOrder} /> + +