diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 4531ab2e9..bafa2e719 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -310,6 +310,9 @@ from src.services.dependency_supply_chain_drift_monitor import ( from src.services.dependency_upgrade_approval_package_template import ( load_latest_dependency_upgrade_approval_package_template, ) +from src.services.delivery_closure_workbench import ( + load_delivery_closure_workbench, +) from src.services.docker_build_surface_inventory import ( load_latest_docker_build_surface_inventory, ) @@ -799,6 +802,36 @@ async def get_awoooi_status_cleanup_dashboard() -> dict[str, Any]: ) from exc +@router.get( + "/delivery-closure-workbench", + response_model=dict[str, Any], + summary="取得交付閉環工作台彙總", + description=( + "彙總 AWOOOI 狀態清理、GitHub 私有備援、Gitea / CI-CD、Runtime surface " + "與 Backup / DR 的既有只讀快照,回傳前端工作台可直接使用的交付主線、" + "完成度、阻擋數與下一步。此端點不呼叫 GitHub / Gitea / Wazuh / K8s live API、" + "不建立 repo、不改 visibility、不同步 refs、不觸發 workflow、不執行備份或還原、" + "不讀 secret、不做 runtime write。" + ), +) +async def get_delivery_closure_workbench() -> dict[str, Any]: + """回傳 AWOOOI 交付閉環工作台只讀彙總。""" + try: + payload = await asyncio.to_thread(load_delivery_closure_workbench) + 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("delivery_closure_workbench_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="AWOOOI 交付閉環工作台彙總無效", + ) from exc + + @router.get( "/agent-12-agent-war-room", response_model=dict[str, Any], diff --git a/apps/api/src/services/delivery_closure_workbench.py b/apps/api/src/services/delivery_closure_workbench.py new file mode 100644 index 000000000..968aa0e0a --- /dev/null +++ b/apps/api/src/services/delivery_closure_workbench.py @@ -0,0 +1,289 @@ +"""Delivery closure workbench summary. + +Builds the product-facing delivery closure view from existing committed, +read-only snapshots. The summary is intentionally compact so the UI does not +need to fan out across five separate endpoints or duplicate blocker math. +""" + +from __future__ import annotations + +from typing import Any + +from src.services.awoooi_status_cleanup_dashboard import ( + load_latest_awoooi_status_cleanup_dashboard, +) +from src.services.backup_dr_readiness_matrix import ( + load_latest_backup_dr_readiness_matrix, +) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) +from src.services.github_target_private_backup_evidence_gate import ( + load_latest_github_target_private_backup_evidence_gate, +) +from src.services.runtime_surface_inventory import ( + load_latest_runtime_surface_inventory, +) + +_SCHEMA_VERSION = "delivery_closure_workbench_v1" + + +def load_delivery_closure_workbench() -> dict[str, Any]: + """Load existing delivery snapshots and return a compact workbench model.""" + status_cleanup = load_latest_awoooi_status_cleanup_dashboard() + github = load_latest_github_target_private_backup_evidence_gate() + gitea = load_latest_gitea_workflow_runner_health() + runtime = load_latest_runtime_surface_inventory() + backup = load_latest_backup_dr_readiness_matrix() + return build_delivery_closure_workbench( + status_cleanup=status_cleanup, + github=github, + gitea=gitea, + runtime=runtime, + backup=backup, + ) + + +def build_delivery_closure_workbench( + *, + status_cleanup: dict[str, Any], + github: dict[str, Any], + gitea: dict[str, Any], + runtime: dict[str, Any], + backup: dict[str, Any], +) -> dict[str, Any]: + """Build the delivery workbench response from already validated snapshots.""" + status_summary = _dict(status_cleanup.get("summary")) + github_summary = _dict(github.get("summary")) + gitea_status = _dict(gitea.get("program_status")) + gitea_rollups = _dict(gitea.get("rollups")) + runtime_status = _dict(runtime.get("program_status")) + runtime_rollups = _dict(runtime.get("rollups")) + backup_status = _dict(backup.get("program_status")) + backup_rollups = _dict(backup.get("rollups")) + + github_required = _int(github_summary.get("approval_required_target_count")) + github_verified = _int(github_summary.get("private_backup_verified_count")) + runtime_action_required = set(_strings(runtime_rollups.get("action_required_surface_ids"))) + runtime_secret_surfaces = set(_strings(runtime_rollups.get("secret_surface_ids"))) + + lanes = [ + { + "id": "release", + "source_id": "status_cleanup", + "completion_percent": _percent(status_summary.get("overall_completion_percent")), + "status": str(status_summary.get("dashboard_status") or "unknown"), + "blocker_count": _int(status_summary.get("blocked_gate_count")), + "metric": { + "kind": "blocked_gate", + "blocked": _int(status_summary.get("blocked_gate_count")), + "total": _int(status_summary.get("gate_count")), + }, + "href": "/governance?tab=automation-inventory", + "next_action": _first_string(status_cleanup.get("next_actions")), + }, + { + "id": "github", + "source_id": "github_private_backup", + "completion_percent": _percent( + (github_verified / github_required) * 100 if github_required else 0 + ), + "status": str(github.get("status") or "unknown"), + "blocker_count": _int(github_summary.get("blocked_target_count")), + "metric": { + "kind": "private_backup_verified", + "verified": github_verified, + "total": github_required, + }, + "href": "/governance?tab=automation-inventory", + "next_action": _first_target_action(github.get("targets")), + }, + { + "id": "gitea", + "source_id": "gitea_ci_cd", + "completion_percent": _percent(gitea_status.get("overall_completion_percent")), + "status": str(gitea_status.get("current_task_id") or "unknown"), + "blocker_count": len(_strings(gitea_rollups.get("runner_contracts_requiring_action"))), + "metric": { + "kind": "workflow_count", + "count": _int(gitea_rollups.get("total_workflows")), + }, + "href": "/deployments", + "next_action": _first_contract_action(gitea.get("runner_contracts")), + }, + { + "id": "runtime", + "source_id": "runtime_surface", + "completion_percent": _percent(runtime_status.get("overall_completion_percent")), + "status": str(runtime_status.get("current_task_id") or "unknown"), + "blocker_count": len(runtime_action_required | runtime_secret_surfaces), + "metric": { + "kind": "surface_count", + "total": _int(runtime_rollups.get("total_surfaces")), + }, + "href": "/governance?tab=automation-inventory", + "next_action": _first_surface_action(runtime.get("runtime_surfaces")), + }, + { + "id": "backup", + "source_id": "backup_dr", + "completion_percent": _percent(backup_status.get("overall_completion_percent")), + "status": str(backup_status.get("current_task_id") or "unknown"), + "blocker_count": len(_strings(backup_rollups.get("blocked_row_ids"))), + "metric": { + "kind": "readiness_row_count", + "rows": _int(backup_rollups.get("total_rows")), + }, + "href": "/operations", + "next_action": _first_backup_action(backup.get("readiness_rows")), + }, + ] + + for lane in lanes: + lane["tone"] = _tone(_int(lane["blocker_count"]), _int(lane["completion_percent"])) + + source_statuses = [ + _source_status("status_cleanup", status_cleanup), + _source_status("github_private_backup", github), + _source_status("gitea_ci_cd", gitea), + _source_status("runtime_surface", runtime), + _source_status("backup_dr", backup), + ] + generated_candidates = [source["generated_at"] for source in source_statuses if source["generated_at"]] + high_risk_blocker_count = sum(_int(lane["blocker_count"]) for lane in lanes) + average_completion = _percent( + sum(_int(lane["completion_percent"]) for lane in lanes) / max(len(lanes), 1) + ) + next_focus = [ + { + "lane_id": lane["id"], + "blocker_count": lane["blocker_count"], + "completion_percent": lane["completion_percent"], + "next_action": lane["next_action"], + } + for lane in lanes + if _int(lane["blocker_count"]) > 0 or _int(lane["completion_percent"]) < 80 + ][:5] + + return { + "schema_version": _SCHEMA_VERSION, + "generated_at": max(generated_candidates) if generated_candidates else "", + "status": "blocked_delivery_actions_required" if high_risk_blocker_count else "ready", + "summary": { + "source_count": len(source_statuses), + "loaded_source_count": len(source_statuses), + "average_completion_percent": average_completion, + "high_risk_blocker_count": high_risk_blocker_count, + "runtime_execution_authorized": False, + "remote_write_authorized": False, + "repo_creation_authorized": False, + "refs_sync_authorized": False, + "workflow_trigger_authorized": False, + "secret_values_collected": False, + }, + "source_statuses": source_statuses, + "lanes": lanes, + "next_focus": next_focus, + "operation_boundaries": { + "read_only_api_allowed": True, + "runtime_write_allowed": False, + "remote_write_allowed": False, + "repo_creation_allowed": False, + "visibility_change_allowed": False, + "refs_sync_allowed": False, + "workflow_trigger_allowed": False, + "secret_value_collection_allowed": False, + "backup_restore_execution_allowed": False, + "active_scan_allowed": False, + }, + } + + +def _source_status(source_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return { + "id": source_id, + "loaded": True, + "schema_version": str(payload.get("schema_version") or ""), + "generated_at": str(payload.get("generated_at") or ""), + } + + +def _tone(blocker_count: int, percent: int) -> str: + if blocker_count > 0: + return "danger" + if percent < 80: + return "warn" + return "ok" + + +def _dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _int(value: Any) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float)): + return int(value) + return 0 + + +def _percent(value: Any) -> int: + return max(0, min(100, round(float(value or 0)))) + + +def _strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if item is not None] + + +def _first_string(value: Any) -> str: + if isinstance(value, list) and value: + return str(value[0]) + return "" + + +def _first_target_action(value: Any) -> str: + if not isinstance(value, list): + return "" + for row in value: + if isinstance(row, dict) and row.get("approval_required") is True: + return str(row.get("next_action") or "") + return _first_row_action(value) + + +def _first_contract_action(value: Any) -> str: + if not isinstance(value, list): + return "" + for row in value: + if isinstance(row, dict) and row.get("status") == "action_required": + return str(row.get("next_action") or "") + return _first_row_action(value) + + +def _first_surface_action(value: Any) -> str: + if not isinstance(value, list): + return "" + for row in value: + if isinstance(row, dict) and row.get("status") != "manifest_mapped": + return str(row.get("next_action") or "") + return _first_row_action(value) + + +def _first_backup_action(value: Any) -> str: + if not isinstance(value, list): + return "" + for row in value: + if isinstance(row, dict) and row.get("overall_readiness") in {"blocked", "action_required"}: + return str(row.get("next_action") or "") + return _first_row_action(value) + + +def _first_row_action(value: Any) -> str: + if not isinstance(value, list): + return "" + for row in value: + if isinstance(row, dict) and row.get("next_action"): + return str(row["next_action"]) + return "" diff --git a/apps/api/tests/test_delivery_closure_workbench_api.py b/apps/api/tests/test_delivery_closure_workbench_api.py new file mode 100644 index 000000000..bbc762add --- /dev/null +++ b/apps/api/tests/test_delivery_closure_workbench_api.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router + + +def test_delivery_closure_workbench_endpoint_returns_product_summary(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/delivery-closure-workbench") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "delivery_closure_workbench_v1" + assert data["summary"]["source_count"] == 5 + assert data["summary"]["loaded_source_count"] == 5 + assert data["summary"]["runtime_execution_authorized"] is False + assert data["summary"]["remote_write_authorized"] is False + assert data["summary"]["repo_creation_authorized"] is False + assert data["summary"]["refs_sync_authorized"] is False + assert data["summary"]["workflow_trigger_authorized"] is False + assert data["summary"]["secret_values_collected"] is False + assert data["summary"]["average_completion_percent"] >= 0 + assert data["summary"]["high_risk_blocker_count"] > 0 + + lanes = {lane["id"]: lane for lane in data["lanes"]} + assert sorted(lanes) == ["backup", "gitea", "github", "release", "runtime"] + assert lanes["release"]["metric"]["kind"] == "blocked_gate" + assert lanes["github"]["metric"]["kind"] == "private_backup_verified" + assert lanes["gitea"]["metric"]["kind"] == "workflow_count" + assert lanes["runtime"]["metric"]["kind"] == "surface_count" + assert lanes["backup"]["metric"]["kind"] == "readiness_row_count" + assert all(0 <= lane["completion_percent"] <= 100 for lane in lanes.values()) + assert all(lane["tone"] in {"ok", "warn", "danger"} for lane in lanes.values()) + + boundaries = data["operation_boundaries"] + assert boundaries["read_only_api_allowed"] is True + assert boundaries["runtime_write_allowed"] is False + assert boundaries["remote_write_allowed"] is False + assert boundaries["repo_creation_allowed"] is False + assert boundaries["visibility_change_allowed"] is False + assert boundaries["refs_sync_allowed"] is False + assert boundaries["workflow_trigger_allowed"] is False + assert boundaries["secret_value_collection_allowed"] is False + assert boundaries["backup_restore_execution_allowed"] is False + assert boundaries["active_scan_allowed"] is False + + assert "192.168.0." not in response.text diff --git a/apps/web/src/app/[locale]/delivery/page.tsx b/apps/web/src/app/[locale]/delivery/page.tsx index c2d39761e..000f6b121 100644 --- a/apps/web/src/app/[locale]/delivery/page.tsx +++ b/apps/web/src/app/[locale]/delivery/page.tsx @@ -21,6 +21,7 @@ import { apiClient, type AwoooIStatusCleanupDashboardSnapshot, type BackupDrReadinessMatrixSnapshot, + type DeliveryClosureWorkbenchSnapshot, type GiteaWorkflowRunnerHealthSnapshot, type GithubTargetPrivateBackupEvidenceGateSnapshot, type RuntimeSurfaceInventorySnapshot, @@ -44,11 +45,14 @@ interface DeliveryLane { status: string metric: string blockerCount: number + nextAction: string href: string tone: DeliveryTone Icon: typeof Rocket } +const SOURCE_COUNT = 5 + const EMPTY_DATA: DeliveryData = { statusCleanup: null, github: null, @@ -79,6 +83,18 @@ const resolveTone = (blockerCount: number, percent: number): DeliveryTone => { return 'ok' } +const firstActionRequiredTarget = (data: GithubTargetPrivateBackupEvidenceGateSnapshot | null) => + data?.targets.find(target => target.approval_required)?.next_action ?? '' + +const firstActionRequiredRunner = (data: GiteaWorkflowRunnerHealthSnapshot | null) => + data?.runner_contracts.find(contract => contract.status === 'action_required')?.next_action ?? '' + +const firstRuntimeAction = (data: RuntimeSurfaceInventorySnapshot | null) => + data?.runtime_surfaces.find(surface => surface.status !== 'manifest_mapped')?.next_action ?? '' + +const firstBackupAction = (data: BackupDrReadinessMatrixSnapshot | null) => + data?.readiness_rows.find(row => row.overall_readiness === 'blocked' || row.overall_readiness === 'action_required')?.next_action ?? '' + function StatusPill({ tone, label }: { tone: DeliveryTone; label: string }) { return ( (null) const [data, setData] = useState(EMPTY_DATA) const [errors, setErrors] = useState([]) const [loading, setLoading] = useState(true) @@ -220,6 +237,19 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) let cancelled = false const load = async () => { setLoading(true) + setErrors([]) + try { + const summary = await apiClient.getDeliveryClosureWorkbench() + if (cancelled) return + setWorkbench(summary) + setData(EMPTY_DATA) + setErrors([]) + setLoading(false) + return + } catch { + // Summary endpoint may not exist until the API release lands; keep the page useful with legacy reads. + } + const results = await Promise.allSettled([ apiClient.getAwoooIStatusCleanupDashboard(), apiClient.getGithubTargetPrivateBackupEvidenceGate(), @@ -241,6 +271,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) .filter(({ result }) => result.status === 'rejected') .map(({ index }) => t(`sources.${index}.error`)) + setWorkbench(null) setData(nextData) setErrors(nextErrors) setLoading(false) @@ -253,6 +284,42 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) }, [t]) const lanes = useMemo(() => { + if (workbench) { + return workbench.lanes.map(lane => { + const iconMap = { + release: Rocket, + github: GitBranch, + gitea: PackageCheck, + runtime: Server, + backup: HardDrive, + } + const metric = + lane.metric.kind === 'blocked_gate' + ? t('lanes.release.metric', { blocked: lane.metric.blocked }) + : lane.metric.kind === 'private_backup_verified' + ? t('lanes.github.metric', { verified: lane.metric.verified, total: lane.metric.total }) + : lane.metric.kind === 'workflow_count' + ? t('lanes.gitea.metric', { count: lane.metric.count }) + : lane.metric.kind === 'surface_count' + ? t('lanes.runtime.metric', { total: lane.metric.total }) + : t('lanes.backup.metric', { rows: lane.metric.rows }) + + return { + id: lane.id, + title: t(`lanes.${lane.id}.title`), + description: t(`lanes.${lane.id}.description`), + percent: clampPercent(lane.completion_percent), + status: lane.status || t('states.noData'), + metric, + blockerCount: lane.blocker_count, + nextAction: lane.next_action, + href: lane.href, + tone: lane.tone, + Icon: iconMap[lane.id], + } + }) + } + const statusBlocked = Number(data.statusCleanup?.summary.blocked_gate_count ?? 0) const statusPercent = clampPercent(data.statusCleanup?.summary.overall_completion_percent) const githubRequired = data.github?.summary.approval_required_target_count ?? 0 @@ -275,6 +342,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) status: data.statusCleanup?.summary.dashboard_status ?? t('states.noData'), metric: t('lanes.release.metric', { blocked: statusBlocked }), blockerCount: statusBlocked, + nextAction: data.statusCleanup?.next_actions[0] ?? '', href: '/governance?tab=automation-inventory', tone: resolveTone(statusBlocked, statusPercent), Icon: Rocket, @@ -287,6 +355,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) status: data.github?.status ?? t('states.noData'), metric: t('lanes.github.metric', { verified: githubVerified, total: githubRequired }), blockerCount: githubBlocked, + nextAction: firstActionRequiredTarget(data.github), href: '/governance?tab=automation-inventory', tone: resolveTone(githubBlocked, githubPercent), Icon: GitBranch, @@ -299,6 +368,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) status: data.gitea?.program_status.current_task_id ?? t('states.noData'), metric: t('lanes.gitea.metric', { count: data.gitea?.rollups.total_workflows ?? 0 }), blockerCount: giteaBlocked, + nextAction: firstActionRequiredRunner(data.gitea), href: '/deployments', tone: resolveTone(giteaBlocked, giteaPercent), Icon: PackageCheck, @@ -311,6 +381,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) status: data.runtime?.program_status.current_task_id ?? t('states.noData'), metric: t('lanes.runtime.metric', { total: data.runtime?.rollups.total_surfaces ?? 0 }), blockerCount: runtimeBlocked, + nextAction: firstRuntimeAction(data.runtime), href: '/governance?tab=automation-inventory', tone: resolveTone(runtimeBlocked, runtimePercent), Icon: Server, @@ -323,17 +394,19 @@ export default function DeliveryPage({ params }: { params: { locale: string } }) status: data.backup?.program_status.current_task_id ?? t('states.noData'), metric: t('lanes.backup.metric', { rows: data.backup?.rollups.total_rows ?? 0 }), blockerCount: backupBlocked, + nextAction: firstBackupAction(data.backup), href: '/operations', tone: resolveTone(backupBlocked, backupPercent), Icon: HardDrive, }, ] - }, [data, t]) + }, [data, t, workbench]) - const loadedCount = Object.values(data).filter(Boolean).length - const highRiskBlockers = lanes.reduce((sum, lane) => sum + lane.blockerCount, 0) - const averageCompletion = clampPercent(lanes.reduce((sum, lane) => sum + lane.percent, 0) / Math.max(lanes.length, 1)) - const pageTone: DeliveryTone = highRiskBlockers > 0 ? 'danger' : loadedCount === lanes.length ? 'ok' : 'warn' + const sourceTotal = workbench?.summary.source_count ?? SOURCE_COUNT + const loadedCount = workbench?.summary.loaded_source_count ?? Object.values(data).filter(Boolean).length + const highRiskBlockers = workbench?.summary.high_risk_blocker_count ?? lanes.reduce((sum, lane) => sum + lane.blockerCount, 0) + const averageCompletion = workbench?.summary.average_completion_percent ?? clampPercent(lanes.reduce((sum, lane) => sum + lane.percent, 0) / Math.max(lanes.length, 1)) + const pageTone: DeliveryTone = highRiskBlockers > 0 ? 'danger' : loadedCount === sourceTotal ? 'ok' : 'warn' return ( @@ -378,7 +451,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } })
- + = 80 ? 'ok' : 'warn'} /> 0 ? 'danger' : 'ok'} /> @@ -423,7 +496,7 @@ export default function DeliveryPage({ params }: { params: { locale: string } })