diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 6f8f86371..c996dcf00 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -86,6 +86,14 @@ COPY --chown=appuser:appuser .gitea/workflows/ ./.gitea/workflows/ # once repair SSH material is mounted and readable. This still does not enable # automatic apply; approval/execution code remains the gate. COPY --chown=appuser:appuser infra/ansible/ ./infra/ansible/ +# AIA-P0-006 reads the committed JavaScript dependency declarations to expand +# aggregate package counts into one canonical matrix row per dependency unit. +COPY --chown=appuser:appuser package.json ./package.json +COPY --chown=appuser:appuser apps/web/package.json ./apps/web/package.json +COPY --chown=appuser:appuser packages/lewooogo-core/package.json ./packages/lewooogo-core/package.json +COPY --chown=appuser:appuser packages/shared-types/package.json ./packages/shared-types/package.json +COPY --chown=appuser:appuser packages/eslint-config/package.json ./packages/eslint-config/package.json +COPY --chown=appuser:appuser packages/tsconfig/package.json ./packages/tsconfig/package.json # 2026-04-10 Claude Sonnet 4.6: RAG 知識庫索引來源 (ADR-067 Phase 33) COPY --chown=appuser:appuser docs/ ./docs/ COPY --chown=appuser:appuser product.awoooi.yaml ./product.awoooi.yaml diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 0bebf77a8..a2fee4ffd 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -327,6 +327,9 @@ from src.services.ai_agent_version_freshness_snapshot import ( from src.services.ai_agent_version_lifecycle_update_proposal import ( load_latest_ai_agent_version_lifecycle_update_proposal, ) +from src.services.ai_automation_asset_capability_matrix import ( + build_asset_capability_matrix_with_live_readback, +) from src.services.ai_provider_route_matrix import ( load_latest_ai_provider_route_matrix, ) @@ -349,6 +352,7 @@ from src.services.awoooi_gitea_onboarding_warning_step_template_copy_receipt imp load_latest_awoooi_gitea_onboarding_warning_step_template_copy_receipt, ) from src.services.awoooi_priority_work_order_readback import ( + apply_ai_automation_asset_capability_matrix, apply_ai_automation_live_closure_readbacks, apply_ai_loop_current_blocker_execution_queue, apply_controlled_cd_lane_live_metric_readback, @@ -1229,6 +1233,25 @@ async def post_agent99_completion_callback( return receipt +@router.get( + "/ai-automation-asset-capability-matrix", + response_model=dict[str, Any], + summary="取得全資產 AI 自動化能力矩陣", + description=( + "以 AIA-P0-006 committed scope 對齊 ADR-090 asset inventory、coverage、" + "compliance、change events 與每日 reconciliation receipts,逐資產回傳 " + "canonical ID、owner lane、source truth、runtime identity,以及 " + "covered/missing/stale stage。端點只讀 DB 與 committed manifests;不回傳 raw " + "asset key 或 host、不讀 secret、不呼叫 GitHub、不修改主機、repo 或 runtime。" + ), +) +async def get_ai_automation_asset_capability_matrix() -> dict[str, Any]: + """Return the public-safe per-asset capability and drift matrix.""" + + payload = await build_asset_capability_matrix_with_live_readback() + return redact_public_lan_topology(payload) + + @router.get( "/awoooi-priority-work-order-readback", response_model=dict[str, Any], @@ -1294,6 +1317,7 @@ async def _build_awoooi_priority_work_order_readback_with_live_overlays() -> dic post_write_verifier, consumer_readback, telegram_alert_context_verifier, + asset_capability_matrix, ) = await asyncio.gather( asyncio.to_thread(load_latest_gitea_repo_bundle_backup_readback), asyncio.to_thread(load_latest_stockplatform_public_api_runtime_readback), @@ -1304,6 +1328,7 @@ async def _build_awoooi_priority_work_order_readback_with_live_overlays() -> dic asyncio.to_thread(load_latest_ai_agent_log_post_write_verifier_dry_run), load_latest_ai_agent_log_controlled_writeback_consumer_readback(), load_latest_telegram_alert_learning_context_post_apply_verifier(), + build_asset_capability_matrix_with_live_readback(), ) apply_gitea_repo_bundle_backup_readback(reboot_slo, gitea_bundle_readback) apply_stockplatform_runtime_readback(reboot_slo, stockplatform_runtime) @@ -1339,6 +1364,10 @@ async def _build_awoooi_priority_work_order_readback_with_live_overlays() -> dic telegram_alert_context_verifier_readback=telegram_alert_context_verifier, autonomous_runtime_control=autonomous_runtime_control, ) + apply_ai_automation_asset_capability_matrix( + payload, + asset_capability_matrix, + ) summary = payload.setdefault("summary", {}) rollups = payload.setdefault("rollups", {}) mainline = payload.setdefault("mainline_execution_state", {}) diff --git a/apps/api/src/jobs/asset_capability_reconciliation_job.py b/apps/api/src/jobs/asset_capability_reconciliation_job.py new file mode 100644 index 000000000..5b2ecbac7 --- /dev/null +++ b/apps/api/src/jobs/asset_capability_reconciliation_job.py @@ -0,0 +1,475 @@ +"""Daily AIA-P0-006 asset capability reconciliation. + +The job projects added, removed, drifted, missing, and stale asset capability +states into idempotent automation_operation_log work-item receipts. It writes no +host, repository, or runtime configuration. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from collections import Counter +from collections.abc import Mapping, Sequence +from datetime import datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import structlog + +from src.services.ai_automation_asset_capability_matrix import ( + DEFAULT_PROJECT_ID, + RECONCILIATION_ACTOR, + RECONCILIATION_SCHEMA_VERSION, + RECONCILIATION_SUMMARY_OPERATION, + RECONCILIATION_WORK_ITEM_OPERATION, + build_asset_capability_matrix_with_live_readback, + build_reconciliation_candidates, +) + +logger = structlog.get_logger(__name__) + +_FIRST_DELAY_SECONDS = 90 +_LOOP_BACKOFF_SECONDS = 30 * 60 +_DAILY_TRIGGER_HOUR_TAIPEI = 3 +_DAILY_TRIGGER_MINUTE_TAIPEI = 30 +_TAIPEI = ZoneInfo("Asia/Taipei") + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def _seconds_until_next_trigger() -> int: + now = datetime.now(_TAIPEI) + target = now.replace( + hour=_DAILY_TRIGGER_HOUR_TAIPEI, + minute=_DAILY_TRIGGER_MINUTE_TAIPEI, + second=0, + microsecond=0, + ) + if target <= now: + target += timedelta(days=1) + return max(60, int((target - now).total_seconds())) + + +def build_reconciliation_summary_payload( + matrix: Mapping[str, Any], + candidates: Sequence[Mapping[str, Any]], + *, + trace_id: str, + automation_run_id: str, + work_item_id: str, + repository_readback_count: int, + created_count: int, + existing_count: int, +) -> dict[str, Any]: + """Build the durable summary receipt and independent verifier result.""" + + controls = _dict(matrix.get("controls")) + structural_control_ids = ( + "canonical_id_per_asset", + "owner_lane_per_asset", + "source_truth_per_asset", + "runtime_identity_per_asset", + "per_asset_stage_status", + "full_scope_class_coverage", + ) + candidate_count = len(candidates) + readback_verified = repository_readback_count == candidate_count + mechanism_closed = bool( + all(controls.get(control_id) is True for control_id in structural_control_ids) + and str(_dict(matrix.get("latest_discovery_run")).get("run_id") or "") + and readback_verified + ) + kind_counts = Counter( + str(candidate.get("candidate_kind") or "unknown") for candidate in candidates + ) + return { + "schema_version": RECONCILIATION_SCHEMA_VERSION, + "semantic_operation_type": RECONCILIATION_SUMMARY_OPERATION, + "trace_id": trace_id, + "automation_run_id": automation_run_id, + "work_item_id": work_item_id, + "matrix_fingerprint": str(matrix.get("matrix_fingerprint") or ""), + "candidate_count": candidate_count, + "candidate_kind_counts": dict(sorted(kind_counts.items())), + "created_work_item_count": created_count, + "existing_work_item_count": existing_count, + "repository_readback_count": repository_readback_count, + "repository_readback_verified": readback_verified, + "structural_controls": { + control_id: controls.get(control_id) is True + for control_id in structural_control_ids + }, + "closed": mechanism_closed, + "risk_level": "low", + "policy_decision": "controlled_apply_allowed", + "check_mode_receipt": { + "candidate_fingerprints_unique": ( + len( + { + str(item.get("candidate_fingerprint") or "") + for item in candidates + } + ) + == candidate_count + ), + "raw_asset_key_present": False, + "raw_host_present": False, + "host_or_runtime_write_planned": False, + }, + "bounded_execution_receipt": { + "target": "automation_operation_log", + "operation": "idempotent_work_item_projection", + "created_count": created_count, + "existing_count": existing_count, + }, + "post_verifier": { + "verifier": "automation_operation_log_row_readback", + "expected_count": candidate_count, + "observed_count": repository_readback_count, + "passed": readback_verified, + }, + "rollback_or_no_write_terminal": "no_external_runtime_write", + "operation_boundaries": { + "host_write_performed": False, + "repository_write_performed": False, + "secret_value_read": False, + "github_used": False, + }, + } + + +def build_reconciliation_work_item_input( + candidate: Mapping[str, Any], + *, + trace_id: str, + automation_run_id: str, + program_work_item_id: str, + matrix_fingerprint: str, +) -> dict[str, Any]: + """Bind a candidate to the reconciliation run and controlled-apply receipt.""" + + return { + **candidate, + "semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION, + "trace_id": trace_id, + "automation_run_id": automation_run_id, + "program_work_item_id": program_work_item_id, + "source_diff": { + "matrix_fingerprint": matrix_fingerprint, + "missing_stage_ids": candidate.get("missing_stage_ids"), + "stale_stage_ids": candidate.get("stale_stage_ids"), + "change_types": candidate.get("change_types"), + }, + "policy_decision": "controlled_apply_allowed", + "check_mode_receipt": { + "idempotency_key_present": True, + "canonical_asset_id_present": True, + "external_runtime_write_planned": False, + }, + } + + +async def run_asset_capability_reconciliation_loop() -> None: + """Run shortly after startup, then at 03:30 Asia/Taipei every day.""" + + logger.info( + "asset_capability_reconciliation_loop_started", + daily_trigger_hour_taipei=_DAILY_TRIGGER_HOUR_TAIPEI, + daily_trigger_minute_taipei=_DAILY_TRIGGER_MINUTE_TAIPEI, + ) + await asyncio.sleep(_FIRST_DELAY_SECONDS) + while True: + try: + await reconcile_once() + except Exception as exc: + logger.exception( + "asset_capability_reconciliation_loop_error", + error_type=type(exc).__name__, + ) + await asyncio.sleep(_LOOP_BACKOFF_SECONDS) + continue + await asyncio.sleep(_seconds_until_next_trigger()) + + +async def reconcile_once( + *, + triggered_by: str = "cron", + project_id: str = DEFAULT_PROJECT_ID, +) -> dict[str, Any]: + """Reconcile the matrix and persist idempotent gap/drift work items.""" + + from src.services.ai_advisory_helpers import try_acquire_daily_lock + + if triggered_by == "cron" and not await try_acquire_daily_lock( + "asset_capability_reconciliation" + ): + return {"status": "skipped", "reason": "not_leader"} + + started = time.monotonic() + matrix = await build_asset_capability_matrix_with_live_readback( + project_id=project_id + ) + if matrix.get("live_readback_status") != "ready": + logger.warning( + "asset_capability_reconciliation_degraded_no_write", + live_readback_status=matrix.get("live_readback_status"), + ) + return { + "status": "degraded_no_write", + "live_readback_status": matrix.get("live_readback_status"), + "candidate_count": 0, + } + + candidates = build_reconciliation_candidates(matrix) + receipt = await _persist_reconciliation( + matrix, + candidates, + project_id=project_id, + ) + receipt["duration_ms"] = int((time.monotonic() - started) * 1000) + logger.info( + "asset_capability_reconciliation_completed", + candidate_count=receipt.get("candidate_count"), + repository_readback_verified=receipt.get("repository_readback_verified"), + closed=receipt.get("closed"), + duration_ms=receipt["duration_ms"], + ) + return receipt + + +async def _persist_reconciliation( + matrix: Mapping[str, Any], + candidates: Sequence[Mapping[str, Any]], + *, + project_id: str, +) -> dict[str, Any]: + from sqlalchemy import text + + from src.db.base import get_db_context + + matrix_fingerprint = str(matrix.get("matrix_fingerprint") or "") + taipei_date = datetime.now(_TAIPEI).date().isoformat() + idempotency_key = ( + f"asset-capability-reconciliation:{taipei_date}:{matrix_fingerprint}" + ) + trace_id = f"asset-capability:{matrix_fingerprint[:16]}" + automation_run_id = str(uuid.uuid4()) + work_item_id = "AIA-P0-006" + discovery_run_id = str( + _dict(matrix.get("latest_discovery_run")).get("run_id") or "" + ) + created_count = 0 + + async with get_db_context(project_id) as db: + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": "awoooi:asset_capability_reconciliation"}, + ) + prior_summary = await db.execute( + text( + """ + SELECT output + FROM automation_operation_log + WHERE actor = :actor + AND COALESCE(input ->> 'semantic_operation_type', operation_type) + = :semantic_operation_type + AND input ->> 'idempotency_key' = :idempotency_key + ORDER BY created_at DESC + LIMIT 1 + """ + ), + { + "actor": RECONCILIATION_ACTOR, + "semantic_operation_type": RECONCILIATION_SUMMARY_OPERATION, + "idempotency_key": idempotency_key, + }, + ) + prior = prior_summary.scalar_one_or_none() + if isinstance(prior, dict): + return prior + + for candidate_raw in candidates: + candidate = dict(candidate_raw) + existing = await db.execute( + text( + """ + SELECT op_id::text + FROM automation_operation_log + WHERE actor = :actor + AND COALESCE(input ->> 'semantic_operation_type', operation_type) + = :semantic_operation_type + AND input ->> 'candidate_fingerprint' = :candidate_fingerprint + ORDER BY created_at DESC + LIMIT 1 + """ + ), + { + "actor": RECONCILIATION_ACTOR, + "semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION, + "candidate_fingerprint": candidate["candidate_fingerprint"], + }, + ) + if existing.scalar_one_or_none(): + continue + input_payload = build_reconciliation_work_item_input( + candidate, + trace_id=trace_id, + automation_run_id=automation_run_id, + program_work_item_id=work_item_id, + matrix_fingerprint=matrix_fingerprint, + ) + output_payload = { + "work_item_status": "open", + "bounded_execution": "automation_operation_log_row_created", + "post_verifier": "pending_batch_readback", + "rollback_or_no_write_terminal": "no_external_runtime_write", + } + await db.execute( + text( + """ + INSERT INTO automation_operation_log ( + operation_type, + asset_id, + run_id, + actor, + input, + output, + dry_run_result, + status, + tags + ) VALUES ( + 'asset_discovered', + :asset_id, + CAST(NULLIF(:discovery_run_id, '') AS uuid), + :actor, + CAST(:input AS jsonb), + CAST(:output AS jsonb), + CAST(:dry_run_result AS jsonb), + 'success', + :tags + ) + """ + ), + { + "asset_id": candidate.get("asset_inventory_id"), + "discovery_run_id": discovery_run_id, + "actor": RECONCILIATION_ACTOR, + "input": _canonical_json(input_payload), + "output": _canonical_json(output_payload), + "dry_run_result": _canonical_json( + { + "would_create_work_item": True, + "external_runtime_write": False, + "candidate_fingerprint": candidate["candidate_fingerprint"], + } + ), + "tags": [ + "asset_capability_reconciliation", + "work_item", + "aia_p0_006", + ], + }, + ) + created_count += 1 + + rows_result = await db.execute( + text( + """ + SELECT input ->> 'candidate_fingerprint' AS candidate_fingerprint + FROM automation_operation_log + WHERE actor = :actor + AND COALESCE(input ->> 'semantic_operation_type', operation_type) + = :semantic_operation_type + """ + ), + { + "actor": RECONCILIATION_ACTOR, + "semantic_operation_type": RECONCILIATION_WORK_ITEM_OPERATION, + }, + ) + persisted_fingerprints = { + str(row[0]) for row in rows_result.fetchall() if row[0] + } + expected_fingerprints = { + str(candidate.get("candidate_fingerprint") or "") + for candidate in candidates + } + repository_readback_count = len(expected_fingerprints & persisted_fingerprints) + summary_output = build_reconciliation_summary_payload( + matrix, + candidates, + trace_id=trace_id, + automation_run_id=automation_run_id, + work_item_id=work_item_id, + repository_readback_count=repository_readback_count, + created_count=created_count, + existing_count=len(candidates) - created_count, + ) + summary_input = { + "schema_version": RECONCILIATION_SCHEMA_VERSION, + "semantic_operation_type": RECONCILIATION_SUMMARY_OPERATION, + "trace_id": trace_id, + "automation_run_id": automation_run_id, + "work_item_id": work_item_id, + "idempotency_key": idempotency_key, + "matrix_fingerprint": matrix_fingerprint, + "discovery_run_id": discovery_run_id, + "risk_level": "low", + "policy_decision": "controlled_apply_allowed", + } + insert_result = await db.execute( + text( + """ + INSERT INTO automation_operation_log ( + operation_type, + run_id, + actor, + input, + output, + dry_run_result, + status, + duration_ms, + tags + ) VALUES ( + 'coverage_recalculated', + CAST(NULLIF(:discovery_run_id, '') AS uuid), + :actor, + CAST(:input AS jsonb), + CAST(:output AS jsonb), + CAST(:dry_run_result AS jsonb), + :status, + 0, + :tags + ) + RETURNING op_id::text + """ + ), + { + "discovery_run_id": discovery_run_id, + "actor": RECONCILIATION_ACTOR, + "input": _canonical_json(summary_input), + "output": _canonical_json(summary_output), + "dry_run_result": _canonical_json(summary_output["check_mode_receipt"]), + "status": ( + "success" + if summary_output["repository_readback_verified"] + else "failed" + ), + "tags": [ + "asset_capability_reconciliation", + "summary", + "aia_p0_006", + ], + }, + ) + summary_output["op_id"] = str(insert_result.scalar_one()) + return summary_output diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 9e203f95e..c23f906fe 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -564,6 +564,25 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: except Exception as e: logger.warning("asset_change_tracker_loop_schedule_failed", error=str(e)) + # AIA-P0-006: reconcile the declared all-asset catalog with live ADR-090 + # inventory and project added/removed/drift capability gaps as durable work items. + try: + from src.jobs.asset_capability_reconciliation_job import ( + run_asset_capability_reconciliation_loop, + ) + + schedule_api_background_task(run_asset_capability_reconciliation_loop()) + logger.info( + "asset_capability_reconciliation_loop_scheduled", + daily_trigger_hour_taipei=3, + daily_trigger_minute_taipei=30, + ) + except Exception as e: + logger.warning( + "asset_capability_reconciliation_loop_schedule_failed", + error=str(e), + ) + # ADR-090 § Hermes Rule Quality Advisor (2026-04-19 ogt + Claude Opus 4.7 Asia/Taipei) # 每日 04:00 Taipei 分析 alert_rule_catalog.noise_rate,對高噪音規則推 Telegram 建議 # 統帥鐵律: AI 只推建議不自動改 review_status,人工決策 deprecate diff --git a/apps/api/src/services/ai_automation_asset_capability_matrix.py b/apps/api/src/services/ai_automation_asset_capability_matrix.py new file mode 100644 index 000000000..891cd9b41 --- /dev/null +++ b/apps/api/src/services/ai_automation_asset_capability_matrix.py @@ -0,0 +1,1144 @@ +"""Canonical all-asset capability matrix for AIA-P0-006. + +This module reconciles the committed program scope with ADR-090 live asset, +coverage, compliance, and change receipts. Public payloads only expose stable +canonical identities and fingerprints; raw asset keys and host values stay in +the database boundary. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections import Counter +from collections.abc import Iterable, Mapping, Sequence +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import structlog + +logger = structlog.get_logger(__name__) + +SCHEMA_VERSION = "ai_automation_asset_capability_matrix_v1" +RECONCILIATION_SCHEMA_VERSION = "asset_capability_reconciliation_v1" +DEFAULT_PROJECT_ID = "awoooi" +RECONCILIATION_ACTOR = "asset_capability_reconciler" +RECONCILIATION_SUMMARY_OPERATION = "asset_capability_reconciliation_completed" +RECONCILIATION_WORK_ITEM_OPERATION = "asset_capability_reconciliation_work_item_created" +FRESHNESS_SLO_SECONDS = 2 * 60 * 60 +RECONCILIATION_FRESHNESS_SLO_SECONDS = 36 * 60 * 60 + +STAGE_IDS = ( + "source_truth", + "runtime_identity", + "sensor", + "policy", + "executor", + "verifier", + "backup_restore", + "learning_writeback", +) + +REQUIRED_CONTROL_IDS = ( + "canonical_id_per_asset", + "owner_lane_per_asset", + "source_truth_per_asset", + "runtime_identity_per_asset", + "per_asset_stage_status", + "full_scope_class_coverage", + "daily_reconciliation_receipt", + "reconciliation_matrix_fingerprint_match", + "drift_work_items_verified", +) + +_STRUCTURAL_CONTROL_IDS = REQUIRED_CONTROL_IDS[:6] +_OWNER_LANES = { + "hosts": "platform_sre", + "services": "runtime_platform", + "products": "product_governance", + "websites": "web_reliability", + "tools": "ai_platform", + "packages": "software_supply_chain", + "data_and_backup": "data_resilience", + "observability_and_security": "aisoc", +} +_LIVE_TYPE_SCOPE = { + "host": "hosts", + "container": "services", + "k8s_workload": "services", + "k8s_resource": "services", + "database": "data_and_backup", + "table": "data_and_backup", + "website": "websites", + "api_endpoint": "websites", + "package": "packages", + "log_stream": "observability_and_security", + "km_entry": "tools", + "frontend": "services", + "backend": "services", + "ci_pipeline": "tools", + "gitea_repo": "products", + "monitoring_target": "observability_and_security", + "secret": "services", + "volume": "data_and_backup", + "network": "services", + "certificate": "websites", + "scheduled_job": "services", + "message_queue": "services", + "cache": "data_and_backup", + "dashboard": "observability_and_security", + "ai_agent": "tools", + "llm_model": "tools", + "third_party_service": "tools", + "backup_target": "data_and_backup", +} +_STAGE_DIMENSIONS = { + "sensor": ("auto_monitoring", "auto_alerting"), + "policy": ("auto_rule_creation", "auto_rule_matching"), + "executor": ("auto_playbook", "auto_remediation"), + "verifier": ("auto_remediation", "auto_monitoring"), + "learning_writeback": ("auto_km_creation", "auto_playbook"), +} + + +def _repo_root() -> Path: + source_path = Path(__file__).resolve() + for parent in source_path.parents: + if (parent / "docs").is_dir() and (parent / "apps").is_dir(): + return parent + return source_path.parents[2] + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _strings(value: Any) -> list[str]: + return [ + item.strip() for item in _list(value) if isinstance(item, str) and item.strip() + ] + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +def _fingerprint(value: Any, *, length: int = 64) -> str: + return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()[:length] + + +def _slug(value: Any) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-") + return normalized or "unnamed" + + +def _identity_tokens(value: Any) -> set[str]: + return { + token for token in re.split(r"[^a-z0-9]+", str(value or "").lower()) if token + } + + +def _parse_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + try: + parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _iso(value: Any) -> str: + parsed = _parse_datetime(value) + return parsed.isoformat().replace("+00:00", "Z") if parsed else "" + + +def _age_seconds(value: Any, *, now: datetime) -> int | None: + parsed = _parse_datetime(value) + if parsed is None: + return None + return max(0, int((now - parsed).total_seconds())) + + +def _read_json(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return payload if isinstance(payload, dict) else {} + + +def _manifest_dependencies(path: Path) -> list[tuple[str, str]]: + payload = _read_json(path) + dependencies: list[tuple[str, str]] = [] + for group in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): + values = _dict(payload.get(group)) + dependencies.extend((name, group) for name in sorted(values) if name) + return dependencies + + +def _declared_entry( + *, + scope: Mapping[str, Any], + declared_asset_id: str, + display_name: str | None = None, + source_refs: Iterable[str] = (), + identity_suffix: str | None = None, +) -> dict[str, Any]: + scope_id = str(scope.get("id") or "unknown") + identity = identity_suffix or declared_asset_id + refs = list( + dict.fromkeys( + [ + *_strings(scope.get("inventory_sources")), + *(ref for ref in source_refs if ref), + ] + ) + ) + return { + "canonical_id": f"awoooi:{scope_id}:{_slug(identity)}", + "scope_id": scope_id, + "scope_label": str(scope.get("label") or scope_id), + "declared_asset_id": declared_asset_id, + "display_name": display_name or declared_asset_id, + "owner_lane": _OWNER_LANES.get(scope_id, "platform_governance"), + "source_truth_refs": refs, + "declared": True, + } + + +def _package_declared_assets( + scope: Mapping[str, Any], + *, + root: Path, +) -> list[dict[str, Any]]: + supply_path = ( + root / "docs/evaluations/package_supply_chain_inventory_2026-06-04.json" + ) + javascript_path = ( + root / "docs/evaluations/javascript_package_inventory_2026-06-04.json" + ) + supply = _read_json(supply_path) + javascript = _read_json(javascript_path) + assets: list[dict[str, Any]] = [] + + for surface in _list(supply.get("surfaces")): + row = _dict(surface) + surface_id = str(row.get("surface_id") or "") + if not surface_id: + continue + assets.append( + _declared_entry( + scope=scope, + declared_asset_id=surface_id, + display_name=str(row.get("display_name") or surface_id), + source_refs=[str(row.get("manifest_ref") or "")], + ) + ) + + dependency_units: list[dict[str, Any]] = [] + for workspace in _list(javascript.get("workspaces")): + row = _dict(workspace) + workspace_id = str(row.get("workspace_id") or "workspace") + manifest_ref = str(row.get("manifest_ref") or "") + dependencies = ( + _manifest_dependencies(root / manifest_ref) if manifest_ref else [] + ) + if not dependencies: + expected = int(_dict(row.get("dependency_counts")).get("total") or 0) + dependencies = [ + (f"declared-dependency-{index:02d}", "inventory_count") + for index in range(1, expected + 1) + ] + for dependency_name, dependency_group in dependencies: + unit_id = f"{workspace_id}:{dependency_group}:{dependency_name}" + dependency_units.append( + _declared_entry( + scope=scope, + declared_asset_id=unit_id, + display_name=f"{workspace_id} / {dependency_name}", + source_refs=[manifest_ref], + identity_suffix=f"{workspace_id}-dependency-{dependency_name}", + ) + ) + + assets.extend(dependency_units) + return _fit_declared_count(scope, assets) + + +def _data_backup_declared_assets( + scope: Mapping[str, Any], + *, + root: Path, +) -> list[dict[str, Any]]: + assets: list[dict[str, Any]] = [] + product_path = ( + root / "docs/operations/gitea-all-product-dev-prod-repo-readback.snapshot.json" + ) + products = _list(_read_json(product_path).get("products")) + for item in _strings(scope.get("asset_ids")): + if item == "gitea_repo_bundles:12": + for product in products: + row = _dict(product) + product_id = str(row.get("product_id") or "") + if product_id: + assets.append( + _declared_entry( + scope=scope, + declared_asset_id=f"gitea_repo_bundle:{product_id}", + display_name=f"Gitea bundle / {product_id}", + source_refs=[str(product_path.relative_to(root))], + ) + ) + continue + assets.append(_declared_entry(scope=scope, declared_asset_id=item)) + return _fit_declared_count(scope, assets) + + +def _fit_declared_count( + scope: Mapping[str, Any], + assets: Sequence[dict[str, Any]], +) -> list[dict[str, Any]]: + expected = int(scope.get("asset_count") or len(assets)) + fitted = list(assets[:expected]) + for index in range(len(fitted) + 1, expected + 1): + fitted.append( + _declared_entry( + scope=scope, + declared_asset_id=f"declared-unit-{index:03d}", + display_name=f"Declared unit {index:03d}", + ) + ) + return fitted + + +def build_declared_asset_catalog( + scope_classes: Sequence[Mapping[str, Any]], + *, + repo_root: Path | None = None, +) -> list[dict[str, Any]]: + """Expand aggregate declarations into one canonical row per asset unit.""" + + root = repo_root or _repo_root() + catalog: list[dict[str, Any]] = [] + for scope in scope_classes: + scope_id = str(scope.get("id") or "") + if scope_id == "packages": + catalog.extend(_package_declared_assets(scope, root=root)) + elif scope_id == "data_and_backup": + catalog.extend(_data_backup_declared_assets(scope, root=root)) + else: + assets = [ + _declared_entry(scope=scope, declared_asset_id=asset_id) + for asset_id in _strings(scope.get("asset_ids")) + ] + catalog.extend(_fit_declared_count(scope, assets)) + return catalog + + +def _matching_score(declared: Mapping[str, Any], live: Mapping[str, Any]) -> int: + declared_values = ( + declared.get("declared_asset_id"), + declared.get("display_name"), + declared.get("canonical_id"), + ) + live_values = (live.get("name"), live.get("asset_key"), live.get("source_repo")) + declared_slugs = {_slug(value) for value in declared_values if value} + live_slugs = {_slug(value) for value in live_values if value} + if declared_slugs & live_slugs: + return 100 + declared_tokens = set().union( + *(_identity_tokens(value) for value in declared_values) + ) + live_tokens = set().union(*(_identity_tokens(value) for value in live_values)) + overlap = declared_tokens & live_tokens + if overlap: + return min(80, 20 + (len(overlap) * 10)) + return 0 + + +def _is_fresh(value: Any, *, now: datetime, slo_seconds: int) -> bool: + age = _age_seconds(value, now=now) + return age is not None and age <= slo_seconds + + +def _coverage_stage( + stage_id: str, + live: Mapping[str, Any] | None, + *, + now: datetime, + run_fresh: bool, +) -> dict[str, Any]: + if live is None: + return { + "status": "missing", + "evidence_refs": [], + "reason": "runtime_asset_not_observed", + } + coverage = _dict(live.get("coverage")) + dimensions = _STAGE_DIMENSIONS[stage_id] + dimension_rows = [_dict(coverage.get(dimension)) for dimension in dimensions] + evidence_refs = [ + f"asset-coverage:{live.get('asset_id')}:{dimension}" + for dimension, row in zip(dimensions, dimension_rows, strict=True) + if row + ] + if not run_fresh or any( + row + and not _is_fresh( + row.get("created_at"), now=now, slo_seconds=FRESHNESS_SLO_SECONDS + ) + for row in dimension_rows + ): + return { + "status": "stale", + "evidence_refs": evidence_refs, + "reason": "coverage_receipt_outside_freshness_slo", + } + statuses = [str(row.get("coverage_status") or "unknown") for row in dimension_rows] + if statuses and all(status == "green" for status in statuses): + return { + "status": "covered", + "evidence_refs": evidence_refs, + "reason": "coverage_dimensions_green", + } + return { + "status": "missing", + "evidence_refs": evidence_refs, + "reason": "coverage_dimension_not_green", + } + + +def _backup_stage( + live: Mapping[str, Any] | None, + *, + now: datetime, +) -> dict[str, Any]: + if live is None: + return { + "status": "missing", + "evidence_refs": [], + "reason": "runtime_asset_not_observed", + } + row = _dict(_dict(live.get("compliance")).get("backup_tested")) + evidence_refs = ( + [f"asset-compliance:{live.get('asset_id')}:backup_tested"] if row else [] + ) + if row and not _is_fresh( + row.get("detected_at"), + now=now, + slo_seconds=RECONCILIATION_FRESHNESS_SLO_SECONDS, + ): + return { + "status": "stale", + "evidence_refs": evidence_refs, + "reason": "backup_test_receipt_outside_freshness_slo", + } + if str(row.get("status") or "") == "compliant": + return { + "status": "covered", + "evidence_refs": evidence_refs, + "reason": "backup_test_compliant", + } + return { + "status": "missing", + "evidence_refs": evidence_refs, + "reason": "backup_test_not_compliant", + } + + +def _build_stages( + asset: Mapping[str, Any], + live: Mapping[str, Any] | None, + *, + now: datetime, + run_fresh: bool, +) -> dict[str, dict[str, Any]]: + source_refs = _strings(asset.get("source_truth_refs")) + last_seen = live.get("last_seen_at") if live else None + runtime_fresh = live is not None and _is_fresh( + last_seen, + now=now, + slo_seconds=FRESHNESS_SLO_SECONDS, + ) + stages = { + "source_truth": { + "status": "covered" if source_refs else "missing", + "evidence_refs": source_refs, + "reason": "committed_source_refs_present" + if source_refs + else "source_ref_missing", + }, + "runtime_identity": { + "status": "covered" if runtime_fresh else "stale" if live else "missing", + "evidence_refs": [f"asset-inventory:{live.get('asset_id')}"] + if live + else [], + "reason": ( + "live_asset_observed" + if runtime_fresh + else "runtime_asset_outside_freshness_slo" + if live + else "runtime_asset_not_observed" + ), + }, + } + for stage_id in ("sensor", "policy", "executor", "verifier", "learning_writeback"): + stages[stage_id] = _coverage_stage( + stage_id, + live, + now=now, + run_fresh=run_fresh, + ) + stages["backup_restore"] = _backup_stage(live, now=now) + return {stage_id: stages[stage_id] for stage_id in STAGE_IDS} + + +def _public_runtime_identity( + canonical_id: str, + live: Mapping[str, Any] | None, +) -> dict[str, Any]: + if live is None: + return { + "identity_ref": f"declared:{canonical_id}", + "observation_status": "declared_only", + "asset_inventory_id": None, + "asset_key_fingerprint": "", + "asset_type": "declared_asset", + "environment": "unknown", + "namespace": "", + "name": "", + "last_seen_at": "", + } + return { + "identity_ref": f"asset_inventory:{live.get('asset_id')}", + "observation_status": "live_observed", + "asset_inventory_id": int(live.get("asset_id") or 0) or None, + "asset_key_fingerprint": _fingerprint( + str(live.get("asset_key") or ""), length=16 + ), + "asset_type": str(live.get("asset_type") or "unknown"), + "environment": str(live.get("environment") or "unknown"), + "namespace": str(live.get("namespace") or ""), + "name": str(live.get("name") or ""), + "last_seen_at": _iso(live.get("last_seen_at")), + } + + +def _live_only_asset( + live: Mapping[str, Any], + scope_labels: Mapping[str, str], +) -> dict[str, Any]: + scope_id = _LIVE_TYPE_SCOPE.get(str(live.get("asset_type") or ""), "services") + safe_name = str(live.get("name") or live.get("asset_type") or "discovered") + key_fingerprint = _fingerprint(str(live.get("asset_key") or safe_name), length=12) + return { + "canonical_id": ( + f"awoooi:{scope_id}:discovered-{_slug(safe_name)}-{key_fingerprint}" + ), + "scope_id": scope_id, + "scope_label": scope_labels.get(scope_id, scope_id), + "declared_asset_id": "", + "display_name": safe_name, + "owner_lane": str( + live.get("owner_team") or _OWNER_LANES.get(scope_id, "platform_governance") + ), + "source_truth_refs": [ + str(live.get("source_repo") or f"asset_inventory:{live.get('asset_id')}") + ], + "declared": False, + } + + +def _receipt_controls( + receipt: Mapping[str, Any] | None, + *, + matrix_fingerprint: str, + now: datetime, +) -> dict[str, bool]: + row = _dict(receipt) + output = _dict(row.get("output")) + candidate_count = int(output.get("candidate_count") or 0) + readback_count = int(output.get("repository_readback_count") or 0) + created_at = row.get("created_at") + fresh = bool( + row + and str(row.get("status") or "") == "success" + and _is_fresh( + created_at, + now=now, + slo_seconds=RECONCILIATION_FRESHNESS_SLO_SECONDS, + ) + ) + return { + "daily_reconciliation_receipt": fresh, + "reconciliation_matrix_fingerprint_match": bool( + fresh and str(output.get("matrix_fingerprint") or "") == matrix_fingerprint + ), + "drift_work_items_verified": bool( + fresh + and output.get("repository_readback_verified") is True + and candidate_count == readback_count + ), + } + + +def build_asset_capability_matrix( + scope_classes: Sequence[Mapping[str, Any]], + *, + live_assets: Sequence[Mapping[str, Any]] = (), + latest_run: Mapping[str, Any] | None = None, + reconciliation_receipt: Mapping[str, Any] | None = None, + repo_root: Path | None = None, + generated_at: datetime | None = None, + live_readback_status: str = "ready", +) -> dict[str, Any]: + """Build a public-safe per-asset matrix from declared and live truth.""" + + now = (generated_at or datetime.now(UTC)).astimezone(UTC) + declared_assets = build_declared_asset_catalog(scope_classes, repo_root=repo_root) + live_rows = [dict(row) for row in live_assets] + scope_labels = { + str(scope.get("id") or ""): str(scope.get("label") or scope.get("id") or "") + for scope in scope_classes + } + latest = _dict(latest_run) + run_fresh = _is_fresh( + latest.get("ended_at"), + now=now, + slo_seconds=FRESHNESS_SLO_SECONDS, + ) + used_live_ids: set[int] = set() + assets: list[dict[str, Any]] = [] + + for declared in declared_assets: + candidates = [ + (live, _matching_score(declared, live)) + for live in live_rows + if int(live.get("asset_id") or 0) not in used_live_ids + ] + live, score = max(candidates, key=lambda item: item[1], default=(None, 0)) + matched_live = live if score >= 30 else None + if matched_live is not None: + used_live_ids.add(int(matched_live.get("asset_id") or 0)) + stages = _build_stages( + declared, + matched_live, + now=now, + run_fresh=run_fresh, + ) + assets.append( + { + **declared, + "runtime_identity": _public_runtime_identity( + str(declared["canonical_id"]), + matched_live, + ), + "stages": stages, + "missing_stage_ids": [ + stage_id + for stage_id, stage in stages.items() + if stage.get("status") == "missing" + ], + "stale_stage_ids": [ + stage_id + for stage_id, stage in stages.items() + if stage.get("status") == "stale" + ], + "change_types": _strings( + matched_live.get("change_types") if matched_live else [] + ), + } + ) + + for live in live_rows: + live_id = int(live.get("asset_id") or 0) + if live_id in used_live_ids: + continue + discovered = _live_only_asset(live, scope_labels) + stages = _build_stages(discovered, live, now=now, run_fresh=run_fresh) + assets.append( + { + **discovered, + "runtime_identity": _public_runtime_identity( + str(discovered["canonical_id"]), + live, + ), + "stages": stages, + "missing_stage_ids": [ + stage_id + for stage_id, stage in stages.items() + if stage.get("status") == "missing" + ], + "stale_stage_ids": [ + stage_id + for stage_id, stage in stages.items() + if stage.get("status") == "stale" + ], + "change_types": list( + dict.fromkeys(["asset_added", *_strings(live.get("change_types"))]) + ), + } + ) + + assets.sort(key=lambda row: (str(row["scope_id"]), str(row["canonical_id"]))) + fingerprint_rows = [ + { + "canonical_id": asset["canonical_id"], + "declared": asset["declared"], + "runtime_identity": _dict(asset.get("runtime_identity")).get( + "asset_key_fingerprint" + ), + "stages": { + stage_id: _dict(_dict(asset.get("stages")).get(stage_id)).get("status") + for stage_id in STAGE_IDS + }, + "change_types": asset.get("change_types"), + } + for asset in assets + ] + matrix_fingerprint = _fingerprint(fingerprint_rows) + canonical_ids = [str(asset.get("canonical_id") or "") for asset in assets] + expected_scope_counts = { + str(scope.get("id") or ""): int(scope.get("asset_count") or 0) + for scope in scope_classes + } + declared_scope_counts = Counter( + str(asset.get("scope_id") or "") + for asset in assets + if asset.get("declared") is True + ) + controls = { + "canonical_id_per_asset": bool( + assets + and all(canonical_ids) + and len(canonical_ids) == len(set(canonical_ids)) + ), + "owner_lane_per_asset": bool( + assets and all(str(asset.get("owner_lane") or "") for asset in assets) + ), + "source_truth_per_asset": bool( + assets and all(_strings(asset.get("source_truth_refs")) for asset in assets) + ), + "runtime_identity_per_asset": bool( + assets + and all( + str(_dict(asset.get("runtime_identity")).get("identity_ref") or "") + for asset in assets + ) + ), + "per_asset_stage_status": bool( + assets + and all( + set(_dict(asset.get("stages"))) == set(STAGE_IDS) + and all( + _dict(_dict(asset.get("stages")).get(stage_id)).get("status") + in {"covered", "missing", "stale"} + for stage_id in STAGE_IDS + ) + for asset in assets + ) + ), + "full_scope_class_coverage": bool( + expected_scope_counts + and all( + declared_scope_counts.get(scope_id, 0) == expected + for scope_id, expected in expected_scope_counts.items() + ) + ), + } + controls.update( + _receipt_controls( + reconciliation_receipt, + matrix_fingerprint=matrix_fingerprint, + now=now, + ) + ) + stage_counts = Counter( + str(stage.get("status") or "missing") + for asset in assets + for stage in _dict(asset.get("stages")).values() + if isinstance(stage, dict) + ) + gap_assets = [ + asset + for asset in assets + if asset.get("missing_stage_ids") or asset.get("stale_stage_ids") + ] + live_observed_count = sum( + 1 + for asset in assets + if _dict(asset.get("runtime_identity")).get("observation_status") + == "live_observed" + ) + total_stage_cells = len(assets) * len(STAGE_IDS) + matrix_closed = all( + controls.get(control_id) is True for control_id in REQUIRED_CONTROL_IDS + ) + receipt = _dict(reconciliation_receipt) + receipt_output = _dict(receipt.get("output")) + + return { + "schema_version": SCHEMA_VERSION, + "generated_at": now.isoformat().replace("+00:00", "Z"), + "status": "closed" if matrix_closed else "reconciliation_required", + "work_item_id": "AIA-P0-006", + "live_readback_status": live_readback_status, + "matrix_fingerprint": matrix_fingerprint, + "freshness_slo_seconds": FRESHNESS_SLO_SECONDS, + "reconciliation_freshness_slo_seconds": RECONCILIATION_FRESHNESS_SLO_SECONDS, + "latest_discovery_run": { + "run_id": str(latest.get("run_id") or ""), + "ended_at": _iso(latest.get("ended_at")), + "fresh": run_fresh, + "status": str(latest.get("status") or "unavailable"), + }, + "controls": controls, + "required_control_ids": list(REQUIRED_CONTROL_IDS), + "closed": matrix_closed, + "summary": { + "scope_class_count": len(expected_scope_counts), + "declared_asset_count": len(declared_assets), + "live_inventory_asset_count": len(live_rows), + "matrix_asset_count": len(assets), + "live_observed_asset_count": live_observed_count, + "declared_only_asset_count": len(declared_assets) + - sum( + 1 + for asset in assets + if asset.get("declared") + and _dict(asset.get("runtime_identity")).get("observation_status") + == "live_observed" + ), + "discovered_not_declared_count": sum( + 1 for asset in assets if asset.get("declared") is False + ), + "gap_asset_count": len(gap_assets), + "covered_stage_cell_count": stage_counts["covered"], + "missing_stage_cell_count": stage_counts["missing"], + "stale_stage_cell_count": stage_counts["stale"], + "stage_cell_count": total_stage_cells, + "stage_coverage_percent": ( + round(stage_counts["covered"] / total_stage_cells * 100) + if total_stage_cells + else 0 + ), + "runtime_observation_percent": ( + round(live_observed_count / len(assets) * 100) if assets else 0 + ), + "control_pass_count": sum(controls.values()), + "required_control_count": len(REQUIRED_CONTROL_IDS), + "reconciliation_candidate_count": int( + receipt_output.get("candidate_count") or 0 + ), + "reconciliation_work_item_readback_count": int( + receipt_output.get("repository_readback_count") or 0 + ), + }, + "scope_rollups": [ + { + "scope_id": scope_id, + "scope_label": scope_labels.get(scope_id, scope_id), + "expected_declared_asset_count": expected_scope_counts.get(scope_id, 0), + "declared_asset_count": sum( + 1 + for asset in assets + if asset.get("scope_id") == scope_id + and asset.get("declared") is True + ), + "matrix_asset_count": sum( + 1 for asset in assets if asset.get("scope_id") == scope_id + ), + "gap_asset_count": sum( + 1 for asset in gap_assets if asset.get("scope_id") == scope_id + ), + } + for scope_id in expected_scope_counts + ], + "assets": assets, + "reconciliation_receipt": { + "op_id": str(receipt.get("op_id") or ""), + "status": str(receipt.get("status") or "unavailable"), + "created_at": _iso(receipt.get("created_at")), + "trace_id": str(_dict(receipt.get("input")).get("trace_id") or ""), + "run_id": str(_dict(receipt.get("input")).get("automation_run_id") or ""), + "work_item_id": str(_dict(receipt.get("input")).get("work_item_id") or ""), + "repository_readback_verified": receipt_output.get( + "repository_readback_verified" + ) + is True, + }, + "operation_boundaries": { + "read_only_api": True, + "raw_asset_key_exposed": False, + "raw_host_exposed": False, + "secret_value_read": False, + "github_used": False, + "host_write_performed": False, + }, + } + + +def build_reconciliation_candidates(matrix: Mapping[str, Any]) -> list[dict[str, Any]]: + """Return deterministic work-item candidates for gaps and asset drift.""" + + candidates: list[dict[str, Any]] = [] + matrix_fingerprint = str(matrix.get("matrix_fingerprint") or "") + trace_id = f"asset-capability:{matrix_fingerprint[:16]}" + automation_run_id = str( + _dict(matrix.get("latest_discovery_run")).get("run_id") + or matrix_fingerprint[:32] + ) + for raw_asset in _list(matrix.get("assets")): + asset = _dict(raw_asset) + missing = _strings(asset.get("missing_stage_ids")) + stale = _strings(asset.get("stale_stage_ids")) + changes = _strings(asset.get("change_types")) + if not missing and not stale and not changes: + continue + candidate_kind = ( + "asset_added" + if "asset_added" in changes or asset.get("declared") is False + else "asset_removed" + if "asset_removed" in changes + else "asset_drift" + if changes + else "capability_gap" + ) + fingerprint = _fingerprint( + { + "canonical_id": asset.get("canonical_id"), + "missing": missing, + "stale": stale, + "changes": changes, + "kind": candidate_kind, + }, + length=24, + ) + candidates.append( + { + "schema_version": RECONCILIATION_SCHEMA_VERSION, + "candidate_fingerprint": fingerprint, + "work_item_id": f"AIA-P0-006-WI-{fingerprint[:12]}", + "trace_id": trace_id, + "automation_run_id": automation_run_id, + "matrix_fingerprint": matrix_fingerprint, + "canonical_asset_id": str(asset.get("canonical_id") or ""), + "asset_inventory_id": _dict(asset.get("runtime_identity")).get( + "asset_inventory_id" + ), + "scope_id": str(asset.get("scope_id") or ""), + "candidate_kind": candidate_kind, + "missing_stage_ids": missing, + "stale_stage_ids": stale, + "change_types": changes, + "risk_level": "low", + "controlled_apply_allowed": True, + "idempotency_key": f"asset-capability:{fingerprint}", + "post_verifier": "automation_operation_log_row_readback", + } + ) + candidates.sort(key=lambda row: str(row["canonical_asset_id"])) + return candidates + + +async def _load_live_rows( + project_id: str, +) -> tuple[ + dict[str, Any], + list[dict[str, Any]], + dict[str, Any], +]: + from sqlalchemy import text + + from src.db.base import get_db_context + + async with get_db_context(project_id) as db: + latest_result = await db.execute( + text( + """ + SELECT run_id::text AS run_id, status, ended_at + FROM asset_discovery_run + WHERE status IN ('success', 'partial') + ORDER BY ended_at DESC NULLS LAST + LIMIT 1 + """ + ) + ) + latest_row = latest_result.mappings().first() + latest_run = dict(latest_row) if latest_row else {} + run_id = str(latest_run.get("run_id") or "") + + assets_result = await db.execute( + text( + """ + SELECT + ai.asset_id, + ai.asset_key, + ai.asset_type, + ai.environment, + ai.namespace, + ai.name, + ai.owner_team, + ai.source_repo, + ai.source_commit_sha, + ai.lifecycle_state, + ai.last_seen_at, + cs.dimension, + cs.coverage_status, + cs.created_at AS coverage_created_at + FROM asset_inventory ai + LEFT JOIN asset_coverage_snapshot cs + ON cs.asset_id = ai.asset_id + AND cs.run_id = CAST(NULLIF(:run_id, '') AS uuid) + WHERE ai.lifecycle_state <> 'decommissioned' + ORDER BY ai.asset_id, cs.dimension + """ + ), + {"run_id": run_id}, + ) + assets_by_id: dict[int, dict[str, Any]] = {} + for row in assets_result.mappings().all(): + item = dict(row) + asset_id = int(item["asset_id"]) + asset = assets_by_id.setdefault( + asset_id, + { + key: item.get(key) + for key in ( + "asset_id", + "asset_key", + "asset_type", + "environment", + "namespace", + "name", + "owner_team", + "source_repo", + "source_commit_sha", + "lifecycle_state", + "last_seen_at", + ) + } + | {"coverage": {}, "compliance": {}, "change_types": []}, + ) + dimension = str(item.get("dimension") or "") + if dimension: + asset["coverage"][dimension] = { + "coverage_status": str(item.get("coverage_status") or "unknown"), + "created_at": item.get("coverage_created_at"), + } + + compliance_result = await db.execute( + text( + """ + SELECT DISTINCT ON (asset_id, dimension) + asset_id, dimension, status, detected_at + FROM asset_compliance_snapshot + WHERE asset_id = ANY(:asset_ids) + ORDER BY asset_id, dimension, detected_at DESC + """ + ), + {"asset_ids": list(assets_by_id) or [0]}, + ) + for row in compliance_result.mappings().all(): + item = dict(row) + asset = assets_by_id.get(int(item["asset_id"])) + if asset is not None: + asset["compliance"][str(item["dimension"])] = { + "status": str(item.get("status") or "unknown"), + "detected_at": item.get("detected_at"), + } + + changes_result = await db.execute( + text( + """ + SELECT asset_id, change_type + FROM asset_change_event + WHERE detected_at >= NOW() - INTERVAL '36 hours' + AND asset_id IS NOT NULL + ORDER BY detected_at DESC + """ + ) + ) + for row in changes_result.mappings().all(): + item = dict(row) + asset = assets_by_id.get(int(item["asset_id"])) + change_type = str(item.get("change_type") or "") + if asset is not None and change_type: + asset["change_types"] = list( + dict.fromkeys([*asset["change_types"], change_type]) + ) + + receipt_result = await db.execute( + text( + """ + SELECT op_id::text AS op_id, status, input, output, created_at + FROM automation_operation_log + WHERE actor = :actor + AND COALESCE(input ->> 'semantic_operation_type', operation_type) + = :semantic_operation_type + ORDER BY created_at DESC + LIMIT 1 + """ + ), + { + "actor": RECONCILIATION_ACTOR, + "semantic_operation_type": RECONCILIATION_SUMMARY_OPERATION, + }, + ) + receipt_row = receipt_result.mappings().first() + receipt = dict(receipt_row) if receipt_row else {} + + return latest_run, list(assets_by_id.values()), receipt + + +async def build_asset_capability_matrix_with_live_readback( + *, + project_id: str = DEFAULT_PROJECT_ID, + repo_root: Path | None = None, +) -> dict[str, Any]: + """Build the matrix from live DB truth, failing closed to declared scope.""" + + from src.services.awoooi_priority_work_order_readback import ( + get_ai_automation_program_scope_classes, + ) + + scope_classes = get_ai_automation_program_scope_classes() + try: + latest_run, live_assets, receipt = await _load_live_rows(project_id) + except Exception as exc: + logger.warning( + "asset_capability_matrix_live_readback_degraded", + error_type=type(exc).__name__, + ) + payload = build_asset_capability_matrix( + scope_classes, + repo_root=repo_root, + live_readback_status="degraded", + ) + payload["live_readback_error_type"] = type(exc).__name__ + return payload + return build_asset_capability_matrix( + scope_classes, + live_assets=live_assets, + latest_run=latest_run, + reconciliation_receipt=receipt, + repo_root=repo_root, + live_readback_status="ready", + ) diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index 3314efb00..3b0232322 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -278,6 +278,7 @@ _AI_AUTOMATION_MONOTONIC_CLOSURE_ORDER = ( "AIA-P0-003", "AIA-P0-004", "AIA-P0-005", + "AIA-P0-006", ) _AI_SINGLE_WRITER_REQUIRED_CONTROL_IDS = ( "strict_runtime_same_run_closed", @@ -362,6 +363,17 @@ _AI_RETRY_ROLLBACK_REQUIRED_CONTROL_IDS = ( "telegram_receipt_same_run", "public_safe_receipt", ) +_AI_ASSET_CAPABILITY_REQUIRED_CONTROL_IDS = ( + "canonical_id_per_asset", + "owner_lane_per_asset", + "source_truth_per_asset", + "runtime_identity_per_asset", + "per_asset_stage_status", + "full_scope_class_coverage", + "daily_reconciliation_receipt", + "reconciliation_matrix_fingerprint_match", + "drift_work_items_verified", +) _AI_AUTOMATION_PROGRAM_SCOPE_CLASSES: list[dict[str, Any]] = [ { "id": "hosts", @@ -599,6 +611,14 @@ _AI_AUTOMATION_PROGRAM_SCOPE_CLASSES: list[dict[str, Any]] = [ "primary_gap": "告警有讀回但並非每類事件都有 AI 決策、受控修復與驗證策略。", }, ] + + +def get_ai_automation_program_scope_classes() -> list[dict[str, Any]]: + """Return an isolated copy of the authoritative AIA asset scope.""" + + return copy.deepcopy(_AI_AUTOMATION_PROGRAM_SCOPE_CLASSES) + + _AI_AUTOMATION_PROGRAM_WORK_ITEMS: list[dict[str, Any]] = [ { "id": "AIA-P0-000", @@ -8003,6 +8023,112 @@ def _apply_ai_automation_program_ledger(payload: dict[str, Any]) -> None: or canonical_learning_work_item["status"] == "done" ): retry_rollback_work_item["status"] = "in_progress" + asset_capability_work_item = next( + item for item in work_items if item["id"] == "AIA-P0-006" + ) + asset_capability_closed = bool( + summary.get("ai_automation_asset_capability_matrix_closed") is True + ) + observed_asset_capability_controls = _dict( + summary.get("ai_automation_asset_capability_matrix_controls") + ) + asset_capability_controls = { + control_id: observed_asset_capability_controls.get(control_id) is True + for control_id in _AI_ASSET_CAPABILITY_REQUIRED_CONTROL_IDS + } + asset_capability_present_count = sum(asset_capability_controls.values()) + asset_capability_blockers = [ + f"{control_id}_not_verified" + for control_id, verified in asset_capability_controls.items() + if verified is not True + ] + if runtime_work_item["status"] != "done": + asset_capability_blockers.append("dependency:AIA-P0-001_not_done") + asset_capability_work_item["runtime_progress"] = { + "completion_percent": round( + asset_capability_present_count + / len(_AI_ASSET_CAPABILITY_REQUIRED_CONTROL_IDS) + * 100 + ), + "present_control_count": asset_capability_present_count, + "required_control_count": len( + _AI_ASSET_CAPABILITY_REQUIRED_CONTROL_IDS + ), + "controls": asset_capability_controls, + "missing": asset_capability_blockers, + "runtime_closed": asset_capability_closed, + "declared_asset_count": _int( + summary.get("ai_automation_asset_capability_declared_asset_count") + ), + "matrix_asset_count": _int( + summary.get("ai_automation_asset_capability_matrix_asset_count") + ), + "gap_asset_count": _int( + summary.get("ai_automation_asset_capability_gap_asset_count") + ), + "reconciliation_candidate_count": _int( + summary.get( + "ai_automation_asset_capability_reconciliation_candidate_count" + ) + ), + } + if ( + runtime_work_item["status"] == "done" + and asset_capability_closed + and all(asset_capability_controls.values()) + ): + asset_capability_work_item["status"] = "done" + asset_capability_work_item["completion_evidence"] = { + "source": "ai-automation-asset-capability-matrix", + "schema_version": str( + summary.get("ai_automation_asset_capability_schema_version") + or "" + ), + "matrix_fingerprint": str( + summary.get("ai_automation_asset_capability_matrix_fingerprint") + or "" + ), + "reconciliation_op_id": str( + summary.get( + "ai_automation_asset_capability_reconciliation_op_id" + ) + or "" + ), + "reconciliation_trace_id": str( + summary.get( + "ai_automation_asset_capability_reconciliation_trace_id" + ) + or "" + ), + "reconciliation_run_id": str( + summary.get( + "ai_automation_asset_capability_reconciliation_run_id" + ) + or "" + ), + "declared_asset_count": _int( + summary.get( + "ai_automation_asset_capability_declared_asset_count" + ) + ), + "matrix_asset_count": _int( + summary.get( + "ai_automation_asset_capability_matrix_asset_count" + ) + ), + } + asset_capability_work_item["runtime_evidence_refs"] = [ + "asset-capability-matrix:" + + asset_capability_work_item["completion_evidence"][ + "matrix_fingerprint" + ], + "automation-operation-log:" + + asset_capability_work_item["completion_evidence"][ + "reconciliation_op_id" + ], + ] + elif observed_asset_capability_controls: + asset_capability_work_item["status"] = "in_progress" _apply_monotonic_program_closure_floor( work_items, [ @@ -8104,6 +8230,24 @@ def _apply_ai_automation_program_ledger(payload: dict[str, Any]) -> None: "ai_agent_retry_rollback_evidence_refs" ), }, + { + "work_item_id": "AIA-P0-006", + "closed": ( + asset_capability_closed + and all(asset_capability_controls.values()) + ), + "automation_run_id": summary.get( + "ai_automation_asset_capability_reconciliation_run_id" + ), + "evidence_refs": [ + summary.get( + "ai_automation_asset_capability_reconciliation_op_id" + ), + summary.get( + "ai_automation_asset_capability_matrix_fingerprint" + ), + ], + }, ], ) priority_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} @@ -8433,6 +8577,79 @@ def _apply_commander_inserted_requirement_work_items( _apply_ai_automation_program_ledger(payload) +def apply_ai_automation_asset_capability_matrix( + payload: dict[str, Any], + matrix: dict[str, Any] | None, +) -> None: + """Overlay the live AIA-P0-006 per-asset reconciliation readback.""" + + readback = _dict(matrix) + if not readback: + return + compact_readback = copy.deepcopy(readback) + compact_readback["assets"] = [] + compact_readback["assets_embedded"] = False + compact_readback["assets_endpoint"] = ( + "/api/v1/agents/ai-automation-asset-capability-matrix" + ) + payload["ai_automation_asset_capability_matrix"] = compact_readback + matrix_summary = _dict(readback.get("summary")) + controls = _dict(readback.get("controls")) + receipt = _dict(readback.get("reconciliation_receipt")) + summary = _dict(payload.setdefault("summary", {})) + rollups = _dict(payload.setdefault("rollups", {})) + summary["ai_automation_asset_capability_schema_version"] = str( + readback.get("schema_version") or "" + ) + summary["ai_automation_asset_capability_live_readback_status"] = str( + readback.get("live_readback_status") or "" + ) + summary["ai_automation_asset_capability_matrix_fingerprint"] = str( + readback.get("matrix_fingerprint") or "" + ) + summary["ai_automation_asset_capability_matrix_closed"] = ( + readback.get("closed") is True + ) + summary["ai_automation_asset_capability_matrix_controls"] = controls + summary["ai_automation_asset_capability_declared_asset_count"] = _int( + matrix_summary.get("declared_asset_count") + ) + summary["ai_automation_asset_capability_matrix_asset_count"] = _int( + matrix_summary.get("matrix_asset_count") + ) + summary["ai_automation_asset_capability_gap_asset_count"] = _int( + matrix_summary.get("gap_asset_count") + ) + summary["ai_automation_asset_capability_live_observed_asset_count"] = _int( + matrix_summary.get("live_observed_asset_count") + ) + summary[ + "ai_automation_asset_capability_reconciliation_candidate_count" + ] = _int(matrix_summary.get("reconciliation_candidate_count")) + summary[ + "ai_automation_asset_capability_reconciliation_work_item_readback_count" + ] = _int(matrix_summary.get("reconciliation_work_item_readback_count")) + summary["ai_automation_asset_capability_reconciliation_op_id"] = str( + receipt.get("op_id") or "" + ) + summary["ai_automation_asset_capability_reconciliation_trace_id"] = str( + receipt.get("trace_id") or "" + ) + summary["ai_automation_asset_capability_reconciliation_run_id"] = str( + receipt.get("run_id") or "" + ) + rollups["ai_automation_asset_capability_matrix_closed"] = summary[ + "ai_automation_asset_capability_matrix_closed" + ] + rollups["ai_automation_asset_capability_matrix_asset_count"] = summary[ + "ai_automation_asset_capability_matrix_asset_count" + ] + rollups["ai_automation_asset_capability_gap_asset_count"] = summary[ + "ai_automation_asset_capability_gap_asset_count" + ] + _apply_ai_automation_program_ledger(payload) + + def apply_ai_automation_node_receipts(payload: dict[str, Any]) -> None: """Refresh metadata-only AI automation node receipts after live overlays.""" _apply_ai_automation_node_receipts(payload) diff --git a/apps/api/tests/test_ai_automation_asset_capability_matrix.py b/apps/api/tests/test_ai_automation_asset_capability_matrix.py new file mode 100644 index 000000000..47b3bd417 --- /dev/null +++ b/apps/api/tests/test_ai_automation_asset_capability_matrix.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from src.jobs.asset_capability_reconciliation_job import ( + build_reconciliation_summary_payload, + build_reconciliation_work_item_input, +) +from src.services.ai_automation_asset_capability_matrix import ( + REQUIRED_CONTROL_IDS, + STAGE_IDS, + build_asset_capability_matrix, + build_reconciliation_candidates, +) +from src.services.awoooi_priority_work_order_readback import ( + apply_ai_automation_asset_capability_matrix, + get_ai_automation_program_scope_classes, +) + +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _single_host_scope() -> list[dict[str, object]]: + return [ + { + "id": "hosts", + "label": "Hosts", + "asset_count": 1, + "asset_ids": ["99"], + "inventory_sources": ["host-inventory.json"], + } + ] + + +def _fully_covered_host(now: datetime) -> dict[str, object]: + dimensions = ( + "auto_monitoring", + "auto_alerting", + "auto_rule_creation", + "auto_rule_matching", + "auto_playbook", + "auto_remediation", + "auto_km_creation", + ) + return { + "asset_id": 42, + "asset_key": "host:99", + "asset_type": "host", + "environment": "prod", + "namespace": "", + "name": "99", + "owner_team": "platform_sre", + "source_repo": "wooo/awoooi", + "lifecycle_state": "active", + "last_seen_at": now, + "coverage": { + dimension: { + "coverage_status": "green", + "created_at": now, + } + for dimension in dimensions + }, + "compliance": { + "backup_tested": { + "status": "compliant", + "detected_at": now, + } + }, + "change_types": [], + } + + +def test_declared_catalog_expands_to_193_unique_assets() -> None: + matrix = build_asset_capability_matrix( + get_ai_automation_program_scope_classes(), + repo_root=_REPO_ROOT, + ) + + assert matrix["summary"]["declared_asset_count"] == 193 + assert matrix["summary"]["matrix_asset_count"] == 193 + assert len({asset["canonical_id"] for asset in matrix["assets"]}) == 193 + assert { + item["scope_id"]: item["declared_asset_count"] + for item in matrix["scope_rollups"] + } == { + "hosts": 7, + "services": 31, + "products": 12, + "websites": 31, + "tools": 23, + "packages": 61, + "data_and_backup": 18, + "observability_and_security": 10, + } + assert matrix["controls"]["full_scope_class_coverage"] is True + assert matrix["operation_boundaries"]["raw_asset_key_exposed"] is False + + +def test_live_asset_has_per_stage_covered_status_and_closes_after_receipt() -> None: + now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC) + initial = build_asset_capability_matrix( + _single_host_scope(), + live_assets=[_fully_covered_host(now)], + latest_run={"run_id": "run-1", "status": "success", "ended_at": now}, + repo_root=_REPO_ROOT, + generated_at=now, + ) + receipt = { + "op_id": "receipt-1", + "status": "success", + "created_at": now, + "input": { + "trace_id": "trace-1", + "automation_run_id": "automation-run-1", + "work_item_id": "AIA-P0-006", + }, + "output": { + "matrix_fingerprint": initial["matrix_fingerprint"], + "candidate_count": 0, + "repository_readback_count": 0, + "repository_readback_verified": True, + }, + } + + matrix = build_asset_capability_matrix( + _single_host_scope(), + live_assets=[_fully_covered_host(now)], + latest_run={"run_id": "run-1", "status": "success", "ended_at": now}, + reconciliation_receipt=receipt, + repo_root=_REPO_ROOT, + generated_at=now, + ) + + asset = matrix["assets"][0] + assert set(asset["stages"]) == set(STAGE_IDS) + assert {stage["status"] for stage in asset["stages"].values()} == {"covered"} + assert all(matrix["controls"][control_id] for control_id in REQUIRED_CONTROL_IDS) + assert matrix["closed"] is True + assert matrix["summary"]["gap_asset_count"] == 0 + + +def test_outdated_live_receipts_are_classified_stale_per_stage() -> None: + now = datetime(2026, 7, 11, 12, 0, tzinfo=UTC) + old = now - timedelta(hours=40) + live = _fully_covered_host(old) + + matrix = build_asset_capability_matrix( + _single_host_scope(), + live_assets=[live], + latest_run={"run_id": "run-old", "status": "success", "ended_at": old}, + repo_root=_REPO_ROOT, + generated_at=now, + ) + + asset = matrix["assets"][0] + assert asset["stages"]["runtime_identity"]["status"] == "stale" + assert asset["stages"]["sensor"]["status"] == "stale" + assert asset["stages"]["policy"]["status"] == "stale" + assert asset["stages"]["executor"]["status"] == "stale" + assert asset["stages"]["verifier"]["status"] == "stale" + assert asset["stages"]["backup_restore"]["status"] == "stale" + assert asset["stages"]["learning_writeback"]["status"] == "stale" + assert asset["stale_stage_ids"] == [ + "runtime_identity", + "sensor", + "policy", + "executor", + "verifier", + "backup_restore", + "learning_writeback", + ] + + +def test_reconciliation_candidates_are_stable_and_public_safe() -> None: + matrix = build_asset_capability_matrix( + _single_host_scope(), + repo_root=_REPO_ROOT, + generated_at=datetime(2026, 7, 11, 12, 0, tzinfo=UTC), + ) + + first = build_reconciliation_candidates(matrix) + second = build_reconciliation_candidates(matrix) + + assert first == second + assert len(first) == 1 + assert first[0]["work_item_id"].startswith("AIA-P0-006-WI-") + assert first[0]["candidate_kind"] == "capability_gap" + assert first[0]["controlled_apply_allowed"] is True + assert "asset_key" not in first[0] + assert "host" not in first[0] + + +def test_reconciliation_summary_requires_repository_readback() -> None: + matrix = build_asset_capability_matrix( + _single_host_scope(), + latest_run={"run_id": "discovery-run-1", "status": "success"}, + repo_root=_REPO_ROOT, + ) + candidates = build_reconciliation_candidates(matrix) + + failed = build_reconciliation_summary_payload( + matrix, + candidates, + trace_id="trace-1", + automation_run_id="run-1", + work_item_id="AIA-P0-006", + repository_readback_count=0, + created_count=1, + existing_count=0, + ) + passed = build_reconciliation_summary_payload( + matrix, + candidates, + trace_id="trace-1", + automation_run_id="run-1", + work_item_id="AIA-P0-006", + repository_readback_count=1, + created_count=1, + existing_count=0, + ) + + assert failed["repository_readback_verified"] is False + assert failed["closed"] is False + assert passed["repository_readback_verified"] is True + assert passed["closed"] is True + + +def test_reconciliation_work_item_uses_same_trace_and_run_as_summary() -> None: + matrix = build_asset_capability_matrix( + _single_host_scope(), + repo_root=_REPO_ROOT, + ) + candidate = build_reconciliation_candidates(matrix)[0] + + receipt_input = build_reconciliation_work_item_input( + candidate, + trace_id="trace-1", + automation_run_id="reconciliation-run-1", + program_work_item_id="AIA-P0-006", + matrix_fingerprint=matrix["matrix_fingerprint"], + ) + + assert receipt_input["trace_id"] == "trace-1" + assert receipt_input["automation_run_id"] == "reconciliation-run-1" + assert receipt_input["program_work_item_id"] == "AIA-P0-006" + assert ( + receipt_input["source_diff"]["matrix_fingerprint"] + == (matrix["matrix_fingerprint"]) + ) + + +def test_priority_overlay_projects_aia_p0_006_runtime_controls() -> None: + matrix = build_asset_capability_matrix( + _single_host_scope(), + repo_root=_REPO_ROOT, + ) + payload: dict[str, object] = {"summary": {}, "rollups": {}} + + apply_ai_automation_asset_capability_matrix(payload, matrix) + + assert payload["ai_automation_asset_capability_matrix"]["schema_version"] == ( + "ai_automation_asset_capability_matrix_v1" + ) + assert payload["ai_automation_asset_capability_matrix"]["assets"] == [] + assert payload["ai_automation_asset_capability_matrix"]["assets_embedded"] is False + assert payload["ai_automation_asset_capability_matrix"]["assets_endpoint"] == ( + "/api/v1/agents/ai-automation-asset-capability-matrix" + ) + assert ( + payload["summary"]["ai_automation_asset_capability_declared_asset_count"] == 1 + ) + work_item = next( + item + for item in payload["ai_automation_program_ledger"]["work_items"] + if item["id"] == "AIA-P0-006" + ) + assert work_item["status"] == "in_progress" + assert work_item["runtime_progress"]["required_control_count"] == len( + REQUIRED_CONTROL_IDS + ) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 76f0a909e..1f68c7b8d 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9613,6 +9613,62 @@ "deferred": "Deferred" } }, + "assetCapabilityMatrix": { + "eyebrow": "AIA-P0-006 Asset Capability Matrix", + "title": "Per-asset capability, drift, and reconciliation ledger", + "closed": "Daily reconciliation closed", + "open": "Reconciliation controls remain open", + "pass": "Passed", + "pending": "Pending", + "visibleCount": "Showing {count}", + "empty": "No assets match the current filters.", + "discoveryRun": "Discovery run", + "reconciliationReceipt": "Reconciliation receipt", + "readback": "Work-item readback", + "metrics": { + "declared": "Declared assets", + "liveObserved": "Live observed", + "gaps": "Assets with gaps", + "workItems": "Work items read back", + "stageCoverage": "Stage coverage" + }, + "filters": { + "scope": "Asset class", + "status": "Stage status", + "allScopes": "All classes", + "allStatuses": "All statuses" + }, + "columns": { + "asset": "Canonical asset", + "owner": "Owner lane" + }, + "stages": { + "source_truth": "Source", + "runtime_identity": "Runtime", + "sensor": "Sensor", + "policy": "Policy", + "executor": "Executor", + "verifier": "Verifier", + "backup_restore": "Backup", + "learning_writeback": "Learning" + }, + "statuses": { + "covered": "covered", + "missing": "missing", + "stale": "stale" + }, + "controls": { + "canonical_id_per_asset": "Unique canonical ID per asset", + "owner_lane_per_asset": "Owner lane per asset", + "source_truth_per_asset": "Source truth per asset", + "runtime_identity_per_asset": "Runtime identity record per asset", + "per_asset_stage_status": "Complete per-asset stage status", + "full_scope_class_coverage": "All 8 declared asset classes reconciled", + "daily_reconciliation_receipt": "Fresh daily reconciliation receipt", + "reconciliation_matrix_fingerprint_match": "Receipt matches matrix fingerprint", + "drift_work_items_verified": "Added, removed, and drift work items read back" + } + }, "aiControlledRunbookCoverage": { "eyebrow": "AI Loop Coverage", "title": "P0 Runbook AI Controlled Coverage", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index dad6e3544..33d854875 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9613,6 +9613,62 @@ "deferred": "延後" } }, + "assetCapabilityMatrix": { + "eyebrow": "AIA-P0-006 Asset Capability Matrix", + "title": "逐資產能力、漂移與 reconciliation 總帳", + "closed": "每日 reconciliation 已閉環", + "open": "仍有 reconciliation 控制缺口", + "pass": "通過", + "pending": "待驗證", + "visibleCount": "顯示 {count} 項", + "empty": "目前篩選條件沒有資產。", + "discoveryRun": "Discovery run", + "reconciliationReceipt": "Reconciliation receipt", + "readback": "工作項讀回", + "metrics": { + "declared": "宣告資產", + "liveObserved": "Live observed", + "gaps": "缺口資產", + "workItems": "工作項讀回", + "stageCoverage": "Stage coverage" + }, + "filters": { + "scope": "資產類別", + "status": "Stage 狀態", + "allScopes": "全部類別", + "allStatuses": "全部狀態" + }, + "columns": { + "asset": "Canonical asset", + "owner": "Owner lane" + }, + "stages": { + "source_truth": "Source", + "runtime_identity": "Runtime", + "sensor": "Sensor", + "policy": "Policy", + "executor": "Executor", + "verifier": "Verifier", + "backup_restore": "Backup", + "learning_writeback": "Learning" + }, + "statuses": { + "covered": "covered", + "missing": "missing", + "stale": "stale" + }, + "controls": { + "canonical_id_per_asset": "每個資產有唯一 canonical ID", + "owner_lane_per_asset": "每個資產有 owner lane", + "source_truth_per_asset": "每個資產有 source truth", + "runtime_identity_per_asset": "每個資產有 runtime identity record", + "per_asset_stage_status": "逐資產 stage 狀態完整", + "full_scope_class_coverage": "8 類宣告資產數已 reconciliation", + "daily_reconciliation_receipt": "每日 reconciliation receipt 新鮮", + "reconciliation_matrix_fingerprint_match": "Receipt 與 matrix fingerprint 一致", + "drift_work_items_verified": "新增、移除與漂移工作項已獨立讀回" + } + }, "aiControlledRunbookCoverage": { "eyebrow": "AI Loop Coverage", "title": "P0 Runbook AI Controlled Coverage", 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 ba8881c18..dee9dbd0b 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -1136,6 +1136,96 @@ type AiAutomationProgramLedger = { } | null; }; +type AssetCapabilityStageStatus = "covered" | "missing" | "stale"; + +type AssetCapabilityMatrixStage = { + status?: AssetCapabilityStageStatus | string | null; + evidence_refs?: string[] | null; + reason?: string | null; +}; + +type AssetCapabilityMatrixAsset = { + canonical_id?: string | null; + scope_id?: string | null; + scope_label?: string | null; + declared_asset_id?: string | null; + display_name?: string | null; + owner_lane?: string | null; + source_truth_refs?: string[] | null; + declared?: boolean | null; + runtime_identity?: { + identity_ref?: string | null; + observation_status?: string | null; + asset_type?: string | null; + environment?: string | null; + namespace?: string | null; + name?: string | null; + last_seen_at?: string | null; + } | null; + stages?: Record | null; + missing_stage_ids?: string[] | null; + stale_stage_ids?: string[] | null; + change_types?: string[] | null; +}; + +type AiAutomationAssetCapabilityMatrix = { + schema_version?: string | null; + generated_at?: string | null; + status?: string | null; + work_item_id?: string | null; + live_readback_status?: string | null; + matrix_fingerprint?: string | null; + assets_embedded?: boolean | null; + assets_endpoint?: string | null; + closed?: boolean | null; + controls?: Record | null; + required_control_ids?: string[] | null; + latest_discovery_run?: { + run_id?: string | null; + ended_at?: string | null; + fresh?: boolean | null; + status?: string | null; + } | null; + summary?: { + scope_class_count?: number | null; + declared_asset_count?: number | null; + live_inventory_asset_count?: number | null; + matrix_asset_count?: number | null; + live_observed_asset_count?: number | null; + declared_only_asset_count?: number | null; + discovered_not_declared_count?: number | null; + gap_asset_count?: number | null; + covered_stage_cell_count?: number | null; + missing_stage_cell_count?: number | null; + stale_stage_cell_count?: number | null; + stage_cell_count?: number | null; + stage_coverage_percent?: number | null; + runtime_observation_percent?: number | null; + control_pass_count?: number | null; + required_control_count?: number | null; + reconciliation_candidate_count?: number | null; + reconciliation_work_item_readback_count?: number | null; + } | null; + scope_rollups?: Array<{ + scope_id?: string | null; + scope_label?: string | null; + expected_declared_asset_count?: number | null; + declared_asset_count?: number | null; + matrix_asset_count?: number | null; + gap_asset_count?: number | null; + }> | null; + assets?: AssetCapabilityMatrixAsset[] | null; + reconciliation_receipt?: { + op_id?: string | null; + status?: string | null; + created_at?: string | null; + trace_id?: string | null; + run_id?: string | null; + work_item_id?: string | null; + repository_readback_verified?: boolean | null; + } | null; +}; + type AiControlledRepairLoopRunbookRow = { work_item_id?: string | null; priority?: string | null; @@ -1602,6 +1692,7 @@ type PriorityWorkOrderResponse = { }>; next_execution_order?: string[] | null; ai_automation_program_ledger?: AiAutomationProgramLedger | null; + ai_automation_asset_capability_matrix?: AiAutomationAssetCapabilityMatrix | null; commander_inserted_requirement_work_items?: CommanderInsertedRequirementWorkItem[]; ai_automation_node_receipts?: AiAutomationNodeReceipt[]; ai_automation_node_receipt_schema?: { @@ -12356,6 +12447,269 @@ function AiAutomationProgramPanel({ ); } +function AssetCapabilityMatrixPanel({ + priority, + loading, +}: { + priority: PriorityWorkOrderResponse | null; + loading: boolean; +}) { + const t = useTranslations("awooop.workItems.assetCapabilityMatrix"); + const matrix = priority?.ai_automation_asset_capability_matrix; + const summary = matrix?.summary; + const assets = matrix?.assets ?? []; + const scopeRollups = matrix?.scope_rollups ?? []; + const [scopeFilter, setScopeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const stageIds = [ + "source_truth", + "runtime_identity", + "sensor", + "policy", + "executor", + "verifier", + "backup_restore", + "learning_writeback", + ] as const; + const requiredControlIds = matrix?.required_control_ids ?? []; + const filteredAssets = assets.filter((asset) => { + if (scopeFilter !== "all" && asset.scope_id !== scopeFilter) { + return false; + } + if (statusFilter === "missing") { + return (asset.missing_stage_ids?.length ?? 0) > 0; + } + if (statusFilter === "stale") { + return (asset.stale_stage_ids?.length ?? 0) > 0; + } + if (statusFilter === "covered") { + return ( + (asset.missing_stage_ids?.length ?? 0) === 0 && + (asset.stale_stage_ids?.length ?? 0) === 0 + ); + } + return true; + }); + const stageTone = (status: string) => { + if (status === "covered") { + return "border-[#9fc9aa] bg-[#edf8ef] text-[#226633]"; + } + if (status === "stale") { + return "border-[#e3c38a] bg-[#fff7e8] text-[#7a4c00]"; + } + return "border-[#e3aaa3] bg-[#fff0ee] text-[#8c2f27]"; + }; + + return ( +
+
+
+
+
+
+

+ {t("title")} +

+
+
+ {matrix?.closed ? ( +
+
+ +
+ {[ + { + label: t("metrics.declared"), + value: summary?.declared_asset_count, + }, + { + label: t("metrics.liveObserved"), + value: summary?.live_observed_asset_count, + }, + { + label: t("metrics.gaps"), + value: summary?.gap_asset_count, + }, + { + label: t("metrics.workItems"), + value: summary?.reconciliation_work_item_readback_count, + }, + { + label: t("metrics.stageCoverage"), + value: `${toCount(summary?.stage_coverage_percent)}%`, + }, + ].map((metric) => ( +
+
+ {metric.label} +
+
+ {loading ? "--" : metric.value ?? 0} +
+
+ ))} +
+
+ +
+
+ {requiredControlIds.map((controlId) => { + const passed = matrix?.controls?.[controlId] === true; + return ( +
+ + {t(`controls.${controlId}`)} + + + {passed ? t("pass") : t("pending")} + +
+ ); + })} +
+ +
+
+ + +
+
+
+
+ +
+
+
+
{t("columns.asset")}
+
{t("columns.owner")}
+ {stageIds.map((stageId) => ( +
+ {t(`stages.${stageId}`)} +
+ ))} +
+
+ {filteredAssets.map((asset) => ( +
+
+
+ {asset.display_name ?? asset.declared_asset_id ?? "--"} +
+
+ {asset.canonical_id} +
+
+
+
+ {asset.owner_lane ?? "--"} +
+
+ {asset.scope_label ?? asset.scope_id} +
+
+ {stageIds.map((stageId) => { + const stage = asset.stages?.[stageId]; + const stageStatus = String(stage?.status ?? "missing"); + return ( +
+
+ {t(`statuses.${stageStatus}`)} +
+
+ ); + })} +
+ ))} + {!loading && filteredAssets.length === 0 ? ( +
+ {t("empty")} +
+ ) : null} +
+
+
+ +
+ + {t("discoveryRun")}: {matrix?.latest_discovery_run?.run_id || "--"} + + + {t("reconciliationReceipt")}: {matrix?.reconciliation_receipt?.op_id || "--"} + + + {t("readback")}: {matrix?.reconciliation_receipt?.repository_readback_verified ? t("pass") : t("pending")} + +
+
+
+ ); +} + function CommanderInsertedRequirementsPanel({ priority, loading, @@ -12792,6 +13146,8 @@ export default function AwoooPWorkItemsPage() { const [loading, setLoading] = useState(true); const [priorityWorkOrderReadback, setPriorityWorkOrderReadback] = useState(null); + const [assetCapabilityMatrixReadback, setAssetCapabilityMatrixReadback] = + useState(null); const [priorityWorkOrderLoading, setPriorityWorkOrderLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); const [workspaceView, setWorkspaceView] = @@ -12822,6 +13178,25 @@ export default function AwoooPWorkItemsPage() { return null; }, []); + const fetchAssetCapabilityMatrixReadback = useCallback(async () => { + const urls = Array.from( + new Set( + [ + "/api/v1/agents/ai-automation-asset-capability-matrix", + `${API_BASE}/api/v1/agents/ai-automation-asset-capability-matrix`, + ].filter(Boolean) + ) + ); + for (const url of urls) { + const matrix = await fetchJson(url, 12000); + if (matrix?.schema_version === "ai_automation_asset_capability_matrix_v1") { + setAssetCapabilityMatrixReadback(matrix); + return matrix; + } + } + return null; + }, []); + const fetchTelemetry = useCallback(async () => { setLoading(true); const encodedProjectId = encodeURIComponent(projectId); @@ -12969,7 +13344,10 @@ export default function AwoooPWorkItemsPage() { let retryTimer: number | null = null; const loadPriorityWorkOrder = async () => { - const readback = await fetchPriorityWorkOrderReadback(); + const [readback] = await Promise.all([ + fetchPriorityWorkOrderReadback(), + fetchAssetCapabilityMatrixReadback(), + ]); if (!cancelled && !readback) { retryTimer = window.setTimeout(loadPriorityWorkOrder, 4000); } @@ -12982,7 +13360,7 @@ export default function AwoooPWorkItemsPage() { window.clearTimeout(retryTimer); } }; - }, [fetchPriorityWorkOrderReadback]); + }, [fetchAssetCapabilityMatrixReadback, fetchPriorityWorkOrderReadback]); useEffect(() => { fetchTelemetry(); @@ -12990,8 +13368,12 @@ export default function AwoooPWorkItemsPage() { const priorityWorkOrder = priorityWorkOrderReadback ?? telemetry.priorityWorkOrder; - const commanderInsertedRequirementWorkOrder = - priorityWorkOrder ?? COMMANDER_INSERTED_REQUIREMENT_FALLBACK; + const commanderInsertedRequirementWorkOrder: PriorityWorkOrderResponse = { + ...(priorityWorkOrder ?? COMMANDER_INSERTED_REQUIREMENT_FALLBACK), + ai_automation_asset_capability_matrix: + assetCapabilityMatrixReadback ?? + priorityWorkOrder?.ai_automation_asset_capability_matrix, + }; const workItems = useMemo(() => buildWorkItems(telemetry, t), [telemetry, t]); const latestRemediationHistory = telemetry.remediationHistory?.items?.[0] ?? null; const remediationIncidentIds = useMemo( @@ -13058,6 +13440,7 @@ export default function AwoooPWorkItemsPage() { type="button" onClick={() => { fetchPriorityWorkOrderReadback(); + fetchAssetCapabilityMatrixReadback(); fetchTelemetry(); }} className="inline-flex items-center gap-2 border border-[#d8d3c7] bg-[#faf9f3] px-3 py-2 text-xs font-semibold text-[#141413] hover:border-[#d97757]" @@ -13085,6 +13468,11 @@ export default function AwoooPWorkItemsPage() { loading={priorityWorkOrderLoading && !priorityWorkOrder} /> + +