diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 44c1b2283..1faf1936b 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -62,6 +62,9 @@ from src.services.offsite_escrow_readiness_status import ( from src.services.runtime_surface_inventory import ( load_latest_runtime_surface_inventory, ) +from src.services.gitea_workflow_runner_health import ( + load_latest_gitea_workflow_runner_health, +) from src.services.package_supply_chain_inventory import ( load_latest_package_supply_chain_inventory, ) @@ -506,6 +509,33 @@ async def get_runtime_surface_inventory() -> dict[str, Any]: ) from exc +@router.get( + "/gitea-workflow-runner-health", + response_model=dict[str, Any], + summary="取得 Gitea 工作流程與 runner 健康合約", + description=( + "讀取最新已提交的 Gitea workflow / runner health contract;" + "此端點不呼叫 Gitea API、不修改 workflow、不重啟 runner、不停止 container、" + "不讀 Secret payload、不送 Telegram 測試通知、不觸發 deploy 或 migration。" + ), +) +async def get_gitea_workflow_runner_health() -> dict[str, Any]: + """Return the latest read-only Gitea workflow / runner health contract.""" + try: + return await asyncio.to_thread(load_latest_gitea_workflow_runner_health) + 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("gitea_workflow_runner_health_invalid", error=str(exc)) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Gitea 工作流程與 runner 健康合約快照無效", + ) from exc + + @router.get( "/backup-dr-target-inventory", response_model=dict[str, Any], diff --git a/apps/api/src/services/gitea_workflow_runner_health.py b/apps/api/src/services/gitea_workflow_runner_health.py new file mode 100644 index 000000000..3b1657eff --- /dev/null +++ b/apps/api/src/services/gitea_workflow_runner_health.py @@ -0,0 +1,260 @@ +""" +Gitea workflow / runner health contract snapshot. + +Loads the latest committed, read-only Gitea workflow and runner health +contract. This module never calls Gitea, mutates workflows, restarts runners, +stops containers, reads Secret payloads, triggers deploys, or sends +notifications. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.snapshot_paths import default_evaluations_dir + +_DEFAULT_EVALUATIONS_DIR = default_evaluations_dir(Path(__file__)) +_SNAPSHOT_PATTERN = "gitea_workflow_runner_health_*.json" +_SCHEMA_VERSION = "gitea_workflow_runner_health_v1" + + +def load_latest_gitea_workflow_runner_health( + evaluations_dir: Path | None = None, +) -> dict[str, Any]: + """Load the newest committed Gitea workflow / runner health snapshot.""" + directory = evaluations_dir or _DEFAULT_EVALUATIONS_DIR + candidates = sorted(directory.glob(_SNAPSHOT_PATTERN)) + if not candidates: + raise FileNotFoundError(f"no Gitea workflow / runner health snapshots found in {directory}") + + latest = candidates[-1] + with latest.open(encoding="utf-8") as handle: + payload = json.load(handle) + + if not isinstance(payload, dict): + raise ValueError(f"{latest}: expected JSON object") + _require_schema(payload, _SCHEMA_VERSION, str(latest)) + _require_read_only_boundaries(payload, str(latest)) + _require_operation_boundaries(payload, str(latest)) + _require_rollup_consistency(payload, str(latest)) + _require_workflow_evidence(payload, str(latest)) + _require_runner_contracts(payload, str(latest)) + _require_notification_contracts(payload, str(latest)) + _require_operator_denials(payload, str(latest)) + _require_no_plaintext_secret_payload_keys(payload, str(latest)) + return payload + + +def _require_schema(payload: dict[str, Any], expected: str, label: str) -> None: + actual = payload.get("schema_version") + if actual != expected: + raise ValueError(f"{label}: expected schema_version={expected}, got {actual!r}") + + +def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None: + program_status = payload.get("program_status") or {} + if program_status.get("read_only_mode") is not True: + raise ValueError(f"{label}: program_status.read_only_mode must be true") + + approval_boundaries = payload.get("approval_boundaries") or {} + allowed = sorted(key for key, value in approval_boundaries.items() if value is not False) + if allowed: + raise ValueError(f"{label}: approval boundaries must remain false: {allowed}") + + +def _require_operation_boundaries(payload: dict[str, Any], label: str) -> None: + boundaries = payload.get("operation_boundaries") or {} + if boundaries.get("read_only_api_allowed") is not True: + raise ValueError(f"{label}: read_only_api_allowed must be true") + + blocked_flags = { + "workflow_modification_allowed", + "runner_restart_allowed", + "runner_container_stop_allowed", + "runner_label_change_allowed", + "runner_registration_allowed", + "secret_read_allowed", + "secret_plaintext_allowed", + "notification_send_allowed", + "schedule_enable_allowed", + "gitea_api_write_allowed", + "deploy_trigger_allowed", + "migration_trigger_allowed", + } + allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) + if allowed: + raise ValueError(f"{label}: operation boundaries must remain false: {allowed}") + + +def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: + workflows = payload.get("workflow_records") or [] + runners = payload.get("runner_contracts") or [] + notifications = payload.get("notification_contracts") or [] + rollups = payload.get("rollups") or {} + + if rollups.get("total_workflows") != len(workflows): + raise ValueError(f"{label}: rollups.total_workflows must match workflow_records") + if rollups.get("by_workflow_status") != _count_by(workflows, "status"): + raise ValueError(f"{label}: rollups.by_workflow_status must match workflow_records") + if rollups.get("by_runner_evidence_status") != _count_by(workflows, "runner_evidence_status"): + raise ValueError(f"{label}: rollups.by_runner_evidence_status must match workflow_records") + + if rollups.get("workflows_with_schedule") != _count_where(workflows, lambda row: "schedule" in row.get("triggers", [])): + raise ValueError(f"{label}: rollups.workflows_with_schedule must match workflow_records") + if rollups.get("workflows_with_workflow_dispatch") != _count_where( + workflows, + lambda row: "workflow_dispatch" in row.get("triggers", []), + ): + raise ValueError(f"{label}: rollups.workflows_with_workflow_dispatch must match workflow_records") + if rollups.get("workflows_with_notify_bridge") != _count_where( + workflows, + lambda row: (row.get("notify_bridge_calls") or 0) > 0, + ): + raise ValueError(f"{label}: rollups.workflows_with_notify_bridge must match workflow_records") + if rollups.get("workflows_with_actionable_or_failure_quiet_policy") != _count_where( + workflows, + lambda row: row.get("notification_policy") in {"actionable_only_no_success_noise", "failure_only"}, + ): + raise ValueError( + f"{label}: rollups.workflows_with_actionable_or_failure_quiet_policy must match workflow_records" + ) + + attestation_required = sorted( + row.get("workflow_id") + for row in workflows + if row.get("runner_evidence_status") in {"owner_attestation_required", "comment_ambiguous"} + ) + if sorted(rollups.get("workflow_ids_requiring_runner_attestation") or []) != attestation_required: + raise ValueError( + f"{label}: rollups.workflow_ids_requiring_runner_attestation must match workflow_records" + ) + + if rollups.get("total_runner_contracts") != len(runners): + raise ValueError(f"{label}: rollups.total_runner_contracts must match runner_contracts") + action_required_runners = sorted( + runner.get("contract_id") + for runner in runners + if runner.get("status") == "action_required" + ) + if sorted(rollups.get("runner_contracts_requiring_action") or []) != action_required_runners: + raise ValueError(f"{label}: rollups.runner_contracts_requiring_action must match runner_contracts") + + if rollups.get("notification_contracts_total") != len(notifications): + raise ValueError(f"{label}: rollups.notification_contracts_total must match notification_contracts") + quiet_contract_ids = sorted( + contract.get("contract_id") + for contract in notifications + if contract.get("policy_kind") in {"failure_only", "actionable_only"} + ) + if rollups.get("notification_contracts_quiet_success_count") != len(quiet_contract_ids): + raise ValueError( + f"{label}: rollups.notification_contracts_quiet_success_count must match notification_contracts" + ) + if sorted(rollups.get("notification_contracts_quiet_success_ids") or []) != quiet_contract_ids: + raise ValueError(f"{label}: rollups.notification_contracts_quiet_success_ids must match contracts") + + +def _require_workflow_evidence(payload: dict[str, Any], label: str) -> None: + workflows = payload.get("workflow_records") or [] + missing = sorted( + workflow.get("workflow_id") + for workflow in workflows + if not workflow.get("file_ref") + or not workflow.get("evidence_refs") + or not workflow.get("triggers") + or not workflow.get("runner_labels") + ) + if missing: + raise ValueError(f"{label}: workflow_records must include file, evidence, trigger, runner labels: {missing}") + + +def _require_runner_contracts(payload: dict[str, Any], label: str) -> None: + runners = payload.get("runner_contracts") or [] + missing = sorted( + runner.get("contract_id") + for runner in runners + if not runner.get("evidence_refs") + or not runner.get("health_contract") + or not runner.get("next_action") + ) + if missing: + raise ValueError(f"{label}: runner_contracts must include evidence, health contract, next_action: {missing}") + + +def _require_notification_contracts(payload: dict[str, Any], label: str) -> None: + contracts = payload.get("notification_contracts") or [] + missing = sorted( + contract.get("contract_id") + for contract in contracts + if not contract.get("success_noise_policy") + or not contract.get("failure_policy") + or not contract.get("evidence_refs") + ) + if missing: + raise ValueError(f"{label}: notification_contracts must include policy and evidence: {missing}") + + quiet_contracts = [ + contract + for contract in contracts + if contract.get("policy_kind") in {"failure_only", "actionable_only"} + ] + if len(quiet_contracts) < 2: + raise ValueError(f"{label}: must preserve failure-only and actionable-only notification contracts") + + noisy_quiet_contracts = sorted( + contract.get("contract_id") + for contract in quiet_contracts + if "不" not in str(contract.get("success_noise_policy")) + and "quiet" not in str(contract.get("success_noise_policy")).lower() + ) + if noisy_quiet_contracts: + raise ValueError(f"{label}: quiet notification contracts must suppress success noise: {noisy_quiet_contracts}") + + +def _require_operator_denials(payload: dict[str, Any], label: str) -> None: + contract = payload.get("operator_contract") or {} + must_not_interpret_as = set(contract.get("must_not_interpret_as") or []) + required_denials = { + "workflow 修改批准", + "runner restart / stop 批准", + "Secret 已讀取或可輸出", + "Telegram 測試通知批准", + "Gitea write token 授權", + "deploy / migration workflow 觸發批准", + } + if not required_denials.issubset(must_not_interpret_as): + raise ValueError(f"{label}: operator_contract.must_not_interpret_as is missing required denials") + + +def _require_no_plaintext_secret_payload_keys(value: Any, label: str, path: str = "$") -> None: + if isinstance(value, dict): + forbidden_key_fragments = { + "secret_value", + "token_value", + "authorization_header", + "private_key", + "webhook_secret", + "runner_token", + } + for key, nested in value.items(): + lowered = str(key).lower() + if any(fragment in lowered for fragment in forbidden_key_fragments): + raise ValueError(f"{label}: forbidden secret payload key at {path}.{key}") + _require_no_plaintext_secret_payload_keys(nested, label, f"{path}.{key}") + elif isinstance(value, list): + for index, nested in enumerate(value): + _require_no_plaintext_secret_payload_keys(nested, label, f"{path}[{index}]") + + +def _count_by(items: list[dict[str, Any]], key: str) -> dict[str, int]: + counts: dict[str, int] = {} + for item in items: + value = item.get(key) + counts[value] = counts.get(value, 0) + 1 + return counts + + +def _count_where(items: list[dict[str, Any]], predicate) -> int: + return sum(1 for item in items if predicate(item)) diff --git a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py index 018987f14..edc410aad 100644 --- a/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_backlog_snapshot_api.py @@ -16,16 +16,16 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert response.status_code == 200 data = response.json() assert data["schema_version"] == "ai_agent_automation_backlog_v1" - assert data["program_status"]["overall_completion_percent"] == 74 + assert data["program_status"]["overall_completion_percent"] == 78 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-001" - assert data["program_status"]["next_task_id"] == "P1-002" + assert data["program_status"]["current_task_id"] == "P1-002" + assert data["program_status"]["next_task_id"] == "P1-003" assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 23 assert data["rollups"]["by_priority"]["P1"] == 21 - assert data["rollups"]["by_status"]["done"] == 17 + assert data["rollups"]["by_status"]["done"] == 18 assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 20 - assert data["progress_summary"]["overall_percent"] == 74 - assert data["progress_summary"]["done_items"] == 17 + assert data["progress_summary"]["overall_percent"] == 78 + assert data["progress_summary"]["done_items"] == 18 assert data["progress_summary"]["total_items"] == 23 assert data["item_approval_boundary_rollup"]["total_items"] == 23 assert data["item_approval_boundary_rollup"]["items_requiring_explicit_approval"] == [ @@ -47,6 +47,10 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho p1_001 = next(item for item in data["backlog_items"] if item["item_id"] == "AUTO-P1-001") assert p1_001["status"] == "done" assert "runtime_surface_inventory_2026-06-05.json" in p1_001["evidence_refs"][0] + p1_002 = next(item for item in data["backlog_items"] if item["item_id"] == "AUTO-P1-002") + assert p1_002["status"] == "done" + assert p1_002["next_review"] == "P1-003" + assert "gitea_workflow_runner_health_2026-06-05.json" in p1_002["evidence_refs"][0] p1_306 = next(item for item in data["backlog_items"] if item["item_id"] == "AUTO-P1-306") assert p1_306["approval_boundary"]["mode"] == "read_only_allowed" assert "runtime_execution" in p1_306["approval_boundary"]["blocked_actions"] diff --git a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py index 5269e465c..9c5c3ee62 100644 --- a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py @@ -18,10 +18,10 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1" assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-001" - assert data["program_status"]["next_task_id"] == "P1-002" - assert data["task_approval_boundary_rollup"]["total_tasks"] == len(data["tasks"]) == 27 - assert data["task_approval_boundary_rollup"]["by_mode"]["read_only_allowed"] == 25 + assert data["program_status"]["current_task_id"] == "P1-002" + assert data["program_status"]["next_task_id"] == "P1-003" + assert data["task_approval_boundary_rollup"]["total_tasks"] == len(data["tasks"]) == 28 + assert data["task_approval_boundary_rollup"]["by_mode"]["read_only_allowed"] == 26 assert data["task_approval_boundary_rollup"]["tasks_requiring_explicit_approval"] == [ "P0-001", "P0-004", @@ -33,6 +33,10 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps p1_001 = next(task for task in data["tasks"] if task["task_id"] == "P1-001") assert p1_001["status"] == "done" assert p1_001["approval_boundary"]["mode"] == "read_only_allowed" + p1_002 = next(task for task in data["tasks"] if task["task_id"] == "P1-002") + assert p1_002["status"] == "done" + assert p1_002["approval_boundary"]["mode"] == "read_only_allowed" + assert "gitea_workflow_runner_health_2026-06-05.json" in p1_002["output"] assert any(task["task_id"] == "P1-204" for task in data["tasks"]) assert any(task["task_id"] == "P1-205" for task in data["tasks"]) assert any(task["task_id"] == "P1-206" for task in data["tasks"]) @@ -62,3 +66,4 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert any(evidence["evidence_id"] == "task_approval_boundary_ui" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "backlog_progress_summary_ui" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "runtime_surface_inventory_api" for evidence in data["evidence"]) + assert any(evidence["evidence_id"] == "gitea_workflow_runner_health_api" for evidence in data["evidence"]) diff --git a/apps/api/tests/test_gitea_workflow_runner_health.py b/apps/api/tests/test_gitea_workflow_runner_health.py new file mode 100644 index 000000000..d050286fc --- /dev/null +++ b/apps/api/tests/test_gitea_workflow_runner_health.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import json + +import pytest + +from src.services.gitea_workflow_runner_health import load_latest_gitea_workflow_runner_health + + +def test_load_latest_gitea_workflow_runner_health_reads_newest_file(tmp_path): + older = _snapshot(generated_at="2026-06-04T00:00:00+08:00", completion=40) + newer = _snapshot(generated_at="2026-06-05T00:00:00+08:00", completion=100) + (tmp_path / "gitea_workflow_runner_health_2026-06-04.json").write_text( + json.dumps(older), + encoding="utf-8", + ) + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(newer), + encoding="utf-8", + ) + + loaded = load_latest_gitea_workflow_runner_health(tmp_path) + + assert loaded["generated_at"] == "2026-06-05T00:00:00+08:00" + assert loaded["program_status"]["overall_completion_percent"] == 100 + assert loaded["rollups"]["total_workflows"] == 2 + assert loaded["operation_boundaries"]["workflow_modification_allowed"] is False + + +def test_gitea_workflow_runner_health_requires_read_only_mode(tmp_path): + snapshot = _snapshot() + snapshot["program_status"]["read_only_mode"] = False + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="read_only_mode"): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def test_gitea_workflow_runner_health_requires_blocked_operations(tmp_path): + snapshot = _snapshot() + snapshot["operation_boundaries"]["runner_restart_allowed"] = True + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="operation boundaries"): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def test_gitea_workflow_runner_health_requires_rollup_consistency(tmp_path): + snapshot = _snapshot() + snapshot["rollups"]["workflows_with_schedule"] = 99 + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="workflows_with_schedule"): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def test_gitea_workflow_runner_health_requires_quiet_notification_contracts(tmp_path): + snapshot = _snapshot() + snapshot["notification_contracts"][0]["success_noise_policy"] = "always send success" + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="suppress success noise"): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def test_gitea_workflow_runner_health_rejects_secret_payload_keys(tmp_path): + snapshot = _snapshot() + snapshot["latest_observations"][0]["runner_token"] = "redacted" + (tmp_path / "gitea_workflow_runner_health_2026-06-05.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="forbidden secret payload key"): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def test_gitea_workflow_runner_health_fails_when_missing(tmp_path): + with pytest.raises(FileNotFoundError): + load_latest_gitea_workflow_runner_health(tmp_path) + + +def _snapshot( + *, + generated_at: str = "2026-06-05T00:00:00+08:00", + completion: int = 100, +) -> dict: + return { + "schema_version": "gitea_workflow_runner_health_v1", + "generated_at": generated_at, + "program_status": { + "overall_completion_percent": completion, + "current_priority": "P1", + "current_task_id": "P1-002", + "next_task_id": "P1-003", + "read_only_mode": True, + }, + "source_refs": ["docs/schemas/gitea_workflow_runner_health_v1.schema.json"], + "rollups": { + "total_workflows": 2, + "by_workflow_status": {"manifest_mapped": 2}, + "by_runner_evidence_status": {"host_runner_mapped": 1, "owner_attestation_required": 1}, + "workflows_with_schedule": 1, + "workflows_with_workflow_dispatch": 1, + "workflows_with_notify_bridge": 1, + "workflows_with_actionable_or_failure_quiet_policy": 1, + "workflow_ids_requiring_runner_attestation": ["e2e_health"], + "total_runner_contracts": 1, + "runner_contracts_requiring_action": ["ubuntu_latest"], + "notification_contracts_total": 2, + "notification_contracts_quiet_success_count": 2, + "notification_contracts_quiet_success_ids": ["actionable_only", "failure_only"], + }, + "workflow_records": [ + _workflow_record( + "cd_pipeline", + "host_runner_mapped", + ["push"], + "deployment_status_exception", + 1, + ), + _workflow_record( + "e2e_health", + "owner_attestation_required", + ["workflow_dispatch", "schedule"], + "failure_only", + 0, + ), + ], + "runner_contracts": [ + { + "contract_id": "ubuntu_latest", + "display_name": "ubuntu-latest runner label", + "status": "action_required", + "risk_level": "high", + "runner_labels": ["ubuntu-latest"], + "used_by_workflows": ["e2e_health"], + "health_contract": "只讀確認 runner label 與 recent run freshness。", + "guardrail_refs": ["docs/HARD_RULES.md"], + "evidence_refs": [".gitea/workflows/e2e-health.yaml"], + "next_action": "補 owner attestation,不改 runner。", + } + ], + "notification_contracts": [ + _notification_contract("actionable_only", "actionable_only", "無 actionable 時不通知。"), + _notification_contract("failure_only", "failure_only", "健康成功不通知。"), + ], + "latest_observations": [ + { + "observation_id": "committed_only", + "status": "verified", + "summary": "只讀 committed workflow。", + "evidence_refs": ["docs/LOGBOOK.md"], + } + ], + "operator_contract": { + "display_mode": "read_only_gitea_workflow_runner_health", + "must_not_interpret_as": [ + "workflow 修改批准", + "runner restart / stop 批准", + "Secret 已讀取或可輸出", + "Telegram 測試通知批准", + "Gitea write token 授權", + "deploy / migration workflow 觸發批准", + ], + "secret_display_policy": "只顯示 redacted metadata。", + "runner_mutation_policy": "不得 restart runner。", + "notification_policy": "成功不洗版。", + }, + "operation_boundaries": { + "read_only_api_allowed": True, + "workflow_modification_allowed": False, + "runner_restart_allowed": False, + "runner_container_stop_allowed": False, + "runner_label_change_allowed": False, + "runner_registration_allowed": False, + "secret_read_allowed": False, + "secret_plaintext_allowed": False, + "notification_send_allowed": False, + "schedule_enable_allowed": False, + "gitea_api_write_allowed": False, + "deploy_trigger_allowed": False, + "migration_trigger_allowed": False, + }, + "approval_boundaries": { + "workflow_modification_authorized": False, + "runner_mutation_authorized": False, + "notification_send_authorized": False, + "secret_plaintext_allowed": False, + "runtime_execution_authorized": False, + "schedule_change_authorized": False, + "gitea_write_authorized": False, + "deploy_trigger_authorized": False, + "migration_trigger_authorized": False, + }, + } + + +def _workflow_record( + workflow_id: str, + runner_evidence_status: str, + triggers: list[str], + notification_policy: str, + notify_bridge_calls: int, +) -> dict: + return { + "workflow_id": workflow_id, + "file_ref": f".gitea/workflows/{workflow_id}.yaml", + "display_name": workflow_id, + "scope": "只讀 workflow 合約。", + "status": "manifest_mapped", + "risk_level": "medium", + "triggers": triggers, + "schedule_cadence": "每日" if "schedule" in triggers else "無定期排程", + "runner_labels": ["awoooi-host" if runner_evidence_status == "host_runner_mapped" else "ubuntu-latest"], + "runner_evidence_status": runner_evidence_status, + "job_count": 1, + "notification_policy": notification_policy, + "notify_bridge_calls": notify_bridge_calls, + "secrets_policy_status": "不讀 Secret payload。", + "evidence_refs": [f".gitea/workflows/{workflow_id}.yaml"], + "next_action": "只讀補證據。", + } + + +def _notification_contract(contract_id: str, policy_kind: str, success_noise_policy: str) -> dict: + return { + "contract_id": contract_id, + "display_name": contract_id, + "status": "preserved", + "policy_kind": policy_kind, + "success_noise_policy": success_noise_policy, + "failure_policy": "失敗才升級。", + "workflow_refs": ["e2e_health"], + "evidence_refs": [".gitea/workflows/e2e-health.yaml"], + "next_action": "保留通知政策。", + } diff --git a/apps/api/tests/test_gitea_workflow_runner_health_api.py b/apps/api/tests/test_gitea_workflow_runner_health_api.py new file mode 100644 index 000000000..d1490fdb3 --- /dev/null +++ b/apps/api/tests/test_gitea_workflow_runner_health_api.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.agents import router + + +def test_gitea_workflow_runner_health_endpoint_returns_committed_snapshot(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.get("/api/v1/agents/gitea-workflow-runner-health") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "gitea_workflow_runner_health_v1" + assert data["program_status"]["overall_completion_percent"] == 100 + assert data["program_status"]["current_task_id"] == "P1-002" + assert data["program_status"]["next_task_id"] == "P1-003" + assert data["program_status"]["read_only_mode"] is True + assert data["rollups"]["total_workflows"] == len(data["workflow_records"]) == 9 + assert data["rollups"]["workflows_with_schedule"] == 2 + assert data["rollups"]["workflows_with_workflow_dispatch"] == 7 + assert data["rollups"]["workflow_ids_requiring_runner_attestation"] == [ + "agent_market_watch", + "ansible_lint", + "cd_dev", + "code_review", + "deploy_alerts", + "e2e_health", + "run_migration", + "type_sync_check", + ] + assert data["rollups"]["runner_contracts_requiring_action"] == [ + "ubuntu_latest_gitea_runner_label" + ] + assert data["rollups"]["notification_contracts_quiet_success_count"] == 2 + assert data["operation_boundaries"]["read_only_api_allowed"] is True + assert data["operation_boundaries"]["workflow_modification_allowed"] is False + assert data["operation_boundaries"]["runner_restart_allowed"] is False + assert data["operation_boundaries"]["secret_plaintext_allowed"] is False + assert data["operation_boundaries"]["deploy_trigger_allowed"] is False + assert data["approval_boundaries"]["workflow_modification_authorized"] is False + assert data["approval_boundaries"]["runner_mutation_authorized"] is False + assert data["approval_boundaries"]["migration_trigger_authorized"] is False + cd_pipeline = next(row for row in data["workflow_records"] if row["workflow_id"] == "cd_pipeline") + assert cd_pipeline["runner_evidence_status"] == "host_runner_mapped" + assert cd_pipeline["job_count"] == 3 + e2e_health = next(row for row in data["workflow_records"] if row["workflow_id"] == "e2e_health") + assert e2e_health["notification_policy"] == "failure_only" + assert "workflow 修改批准" in data["operator_contract"]["must_not_interpret_as"] + assert "Secret 已讀取或可輸出" in data["operator_contract"]["must_not_interpret_as"] diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index dd8504aa9..bc04811f4 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3168,6 +3168,51 @@ "source_only": "僅來源", "action_required_component": "元件需處置" } + }, + "giteaHealth": { + "title": "Gitea 工作流程 / Runner 健康合約", + "source": "{generated} · {current} → {next}", + "workflowsTitle": "工作流程矩陣", + "runnersTitle": "Runner 合約", + "notificationsTitle": "通知合約", + "contractTitle": "不可誤讀合約", + "metrics": { + "workflows": "工作流程", + "schedules": "定期排程", + "dispatch": "手動觸發", + "notifyBridge": "通知橋接", + "quietPolicies": "安靜政策", + "runnerActions": "Runner 待補證據" + }, + "labels": { + "runner": "Runner", + "trigger": "觸發", + "notify": "通知", + "secret": "Secret 邊界", + "schedule": "排程", + "status": "狀態", + "policy": "政策" + }, + "values": { + "manifest_mapped": "Manifest 已映射", + "action_required": "需處置", + "blocked": "阻擋", + "host_runner_mapped": "Host runner 已映射", + "owner_attestation_required": "需 owner 證明", + "comment_ambiguous": "註解語意待釐清", + "dry_run_only": "僅 dry-run", + "prepared_not_applied_by_snapshot": "已準備未套用", + "preserved": "已保留", + "exception_documented": "例外已標記", + "failure_only": "失敗才通知", + "actionable_only": "需處置才通知", + "deployment_status_exception": "部署狀態例外", + "manual_status_exception": "手動流程例外", + "read_only_no_notify": "只讀不通知", + "verified": "已驗證", + "not_applicable": "不適用", + "actionable_only_no_success_noise": "需處置才通知,成功不洗版" + } } } }, diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index dd8504aa9..bc04811f4 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -3168,6 +3168,51 @@ "source_only": "僅來源", "action_required_component": "元件需處置" } + }, + "giteaHealth": { + "title": "Gitea 工作流程 / Runner 健康合約", + "source": "{generated} · {current} → {next}", + "workflowsTitle": "工作流程矩陣", + "runnersTitle": "Runner 合約", + "notificationsTitle": "通知合約", + "contractTitle": "不可誤讀合約", + "metrics": { + "workflows": "工作流程", + "schedules": "定期排程", + "dispatch": "手動觸發", + "notifyBridge": "通知橋接", + "quietPolicies": "安靜政策", + "runnerActions": "Runner 待補證據" + }, + "labels": { + "runner": "Runner", + "trigger": "觸發", + "notify": "通知", + "secret": "Secret 邊界", + "schedule": "排程", + "status": "狀態", + "policy": "政策" + }, + "values": { + "manifest_mapped": "Manifest 已映射", + "action_required": "需處置", + "blocked": "阻擋", + "host_runner_mapped": "Host runner 已映射", + "owner_attestation_required": "需 owner 證明", + "comment_ambiguous": "註解語意待釐清", + "dry_run_only": "僅 dry-run", + "prepared_not_applied_by_snapshot": "已準備未套用", + "preserved": "已保留", + "exception_documented": "例外已標記", + "failure_only": "失敗才通知", + "actionable_only": "需處置才通知", + "deployment_status_exception": "部署狀態例外", + "manual_status_exception": "手動流程例外", + "read_only_no_notify": "只讀不通知", + "verified": "已驗證", + "not_applicable": "不適用", + "actionable_only_no_success_noise": "需處置才通知,成功不洗版" + } } } }, diff --git a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx index 9de0c14f5..66460615f 100644 --- a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx +++ b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx @@ -16,6 +16,7 @@ import { BellRing, Boxes, Database, + GitBranch, HardDrive, Lock, PackageCheck, @@ -33,6 +34,7 @@ import { type BackupDrReadinessMatrixSnapshot, type BackupDrTargetInventorySnapshot, type BackupNotificationPolicySnapshot, + type GiteaWorkflowRunnerHealthSnapshot, type OffsiteEscrowReadinessStatusSnapshot, type RuntimeSurfaceInventorySnapshot, } from '@/lib/api-client' @@ -207,6 +209,7 @@ export function AutomationInventoryTab() { const [backupPolicy, setBackupPolicy] = useState(null) const [offsiteEscrow, setOffsiteEscrow] = useState(null) const [runtimeSurface, setRuntimeSurface] = useState(null) + const [giteaHealth, setGiteaHealth] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -220,6 +223,7 @@ export function AutomationInventoryTab() { apiClient.getBackupNotificationPolicy(), apiClient.getOffsiteEscrowReadinessStatus(), apiClient.getRuntimeSurfaceInventory(), + apiClient.getGiteaWorkflowRunnerHealth(), ] as const Promise.allSettled(requests) @@ -232,6 +236,7 @@ export function AutomationInventoryTab() { policyResult, offsiteEscrowResult, runtimeSurfaceResult, + giteaHealthResult, ] = results setSnapshot(inventoryResult.status === 'fulfilled' ? inventoryResult.value : null) @@ -241,6 +246,7 @@ export function AutomationInventoryTab() { setBackupPolicy(policyResult.status === 'fulfilled' ? policyResult.value : null) setOffsiteEscrow(offsiteEscrowResult.status === 'fulfilled' ? offsiteEscrowResult.value : null) setRuntimeSurface(runtimeSurfaceResult.status === 'fulfilled' ? runtimeSurfaceResult.value : null) + setGiteaHealth(giteaHealthResult.status === 'fulfilled' ? giteaHealthResult.value : null) setError([ inventoryResult, backlogResult, @@ -248,6 +254,7 @@ export function AutomationInventoryTab() { readinessResult, policyResult, offsiteEscrowResult, + giteaHealthResult, ].some(result => result.status === 'rejected')) }) .catch(() => setError(true)) @@ -333,6 +340,34 @@ export function AutomationInventoryTab() { .slice(0, 8) }, [runtimeSurface]) + const visibleGiteaWorkflows = useMemo(() => { + if (!giteaHealth) return [] + const priority = { comment_ambiguous: 0, owner_attestation_required: 1, host_runner_mapped: 2 } as Record + return [...giteaHealth.workflow_records] + .sort((a, b) => { + const left = priority[a.runner_evidence_status] ?? 3 + const right = priority[b.runner_evidence_status] ?? 3 + if (left !== right) return left - right + return a.workflow_id.localeCompare(b.workflow_id) + }) + }, [giteaHealth]) + + const visibleRunnerContracts = useMemo(() => { + if (!giteaHealth) return [] + const priority = { + action_required: 0, + dry_run_only: 1, + prepared_not_applied_by_snapshot: 2, + manifest_mapped: 3, + } as Record + return [...giteaHealth.runner_contracts].sort((a, b) => { + const left = priority[a.status] ?? 4 + const right = priority[b.status] ?? 4 + if (left !== right) return left - right + return a.contract_id.localeCompare(b.contract_id) + }) + }, [giteaHealth]) + if (loading) { return (
@@ -346,7 +381,7 @@ export function AutomationInventoryTab() { ) } - if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow) { + if (error || !snapshot || !backlog || !backupTargets || !backupReadiness || !backupPolicy || !offsiteEscrow || !giteaHealth) { return (
@@ -397,6 +432,8 @@ export function AutomationInventoryTab() { const runtimeSecrets = runtimeSurface?.rollups.secret_surface_ids.length ?? 0 const runtimeLiveMissing = runtimeSurface?.rollups.live_check_missing_surface_ids.length ?? 0 const runtimeBoundComponents = runtimeSurface?.rollups.source_components_with_runtime_binding ?? 0 + const giteaRunnerActions = giteaHealth.rollups.workflow_ids_requiring_runner_attestation.length + const giteaQuietPolicies = giteaHealth.rollups.notification_contracts_quiet_success_count const backlogProgressPercent = backlog.progress_summary.overall_percent const explicitApprovalItemCount = backlog.item_approval_boundary_rollup.items_requiring_explicit_approval.length const taskBoundaryCount = snapshot.task_approval_boundary_rollup.total_tasks @@ -445,6 +482,14 @@ export function AutomationInventoryTab() { } } + const giteaValueLabel = (value: string) => { + try { + return t(`giteaHealth.values.${value}` as never) + } catch { + return value + } + } + return (
@@ -998,6 +1043,134 @@ export function AutomationInventoryTab() { )} + +
+
+
+ + + {t('giteaHealth.title')} + +
+
+ {t('giteaHealth.source', { + generated: formatDateTime(giteaHealth.generated_at), + current: giteaHealth.program_status.current_task_id, + next: giteaHealth.program_status.next_task_id, + })} +
+
+ +
+ } /> + } /> + } /> + } /> + } /> + 0 ? 'warn' : 'ok'} icon={} /> +
+ +
+
+ {visibleGiteaWorkflows.map(workflow => ( +
+
+
+ + {workflow.display_name} + + +
+
+ + + + +
+
+ {workflow.scope} +
+
+ {workflow.next_action} +
+
+ + +
+
+
+ ))} +
+ +
+
+ + {t('giteaHealth.runnersTitle')} + + {visibleRunnerContracts.map(runner => ( +
+
+ + {runner.display_name} + + +
+
+ {runner.health_contract} +
+
+ + +
+
+ ))} +
+ +
+ + {t('giteaHealth.notificationsTitle')} + + {giteaHealth.notification_contracts.slice(0, 4).map(contract => ( +
+
+ + {contract.display_name} + + +
+
+ {contract.success_noise_policy} +
+
+ ))} +
+ +
+ + {t('giteaHealth.contractTitle')} + +
+ {giteaHealth.operator_contract.secret_display_policy} +
+
+ {giteaHealth.operator_contract.must_not_interpret_as.slice(0, 6).map(item => ( + + ))} +
+
+
+
+
+
+
@@ -1090,6 +1263,9 @@ export function AutomationInventoryTab() { .automation-inventory-runtime-kpi-grid, .automation-inventory-runtime-grid, .automation-inventory-runtime-surface-grid, + .automation-inventory-gitea-kpi-grid, + .automation-inventory-gitea-grid, + .automation-inventory-gitea-workflow-grid, .automation-inventory-bottom-grid, .automation-inventory-task-grid { grid-template-columns: 1fr !important; diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 603d2faca..942c106ef 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -267,6 +267,11 @@ export const apiClient = { return handleResponse(res) }, + async getGiteaWorkflowRunnerHealth() { + const res = await fetch(`${API_BASE_URL}/agents/gitea-workflow-runner-health`) + return handleResponse(res) + }, + async getBackupDrTargetInventory() { const res = await fetch(`${API_BASE_URL}/agents/backup-dr-target-inventory`) return handleResponse(res) @@ -857,6 +862,90 @@ export interface RuntimeSurfaceInventorySnapshot { approval_boundaries: Record } +export interface GiteaWorkflowRunnerHealthSnapshot { + schema_version: 'gitea_workflow_runner_health_v1' + generated_at: string + program_status: { + overall_completion_percent: number + current_priority: 'P0' | 'P1' | 'P2' | 'P3' + current_task_id: string + next_task_id: string + read_only_mode: true + } + source_refs: string[] + rollups: { + total_workflows: number + by_workflow_status: Record + by_runner_evidence_status: Record + workflows_with_schedule: number + workflows_with_workflow_dispatch: number + workflows_with_notify_bridge: number + workflows_with_actionable_or_failure_quiet_policy: number + workflow_ids_requiring_runner_attestation: string[] + total_runner_contracts: number + runner_contracts_requiring_action: string[] + notification_contracts_total: number + notification_contracts_quiet_success_count: number + notification_contracts_quiet_success_ids: string[] + } + workflow_records: Array<{ + workflow_id: string + file_ref: string + display_name: string + scope: string + status: 'manifest_mapped' | 'action_required' | 'blocked' + risk_level: 'low' | 'medium' | 'high' | 'critical' + triggers: string[] + schedule_cadence: string + runner_labels: string[] + runner_evidence_status: 'host_runner_mapped' | 'owner_attestation_required' | 'comment_ambiguous' + job_count: number + notification_policy: string + notify_bridge_calls: number + secrets_policy_status: string + evidence_refs: string[] + next_action: string + }> + runner_contracts: Array<{ + contract_id: string + display_name: string + status: 'manifest_mapped' | 'action_required' | 'dry_run_only' | 'prepared_not_applied_by_snapshot' + risk_level: 'low' | 'medium' | 'high' | 'critical' + runner_labels: string[] + used_by_workflows: string[] + health_contract: string + guardrail_refs: string[] + evidence_refs: string[] + next_action: string + }> + notification_contracts: Array<{ + contract_id: string + display_name: string + status: 'preserved' | 'exception_documented' | 'action_required' + policy_kind: 'failure_only' | 'actionable_only' | 'deployment_status_exception' | 'manual_status_exception' | 'read_only_no_notify' + success_noise_policy: string + failure_policy: string + workflow_refs: string[] + evidence_refs: string[] + next_action: string + }> + latest_observations: Array<{ + observation_id: string + status: string + summary: string + evidence_refs: string[] + }> + operator_contract: { + display_mode: 'read_only_gitea_workflow_runner_health' + must_not_interpret_as: string[] + secret_display_policy: string + runner_mutation_policy: string + notification_policy: string + } + operation_boundaries: Record + approval_boundaries: Record +} + export interface BackupDrTargetInventorySnapshot { schema_version: 'backup_dr_target_inventory_v1' generated_at: string diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 07892cb32..e4e3803c5 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -24,6 +24,40 @@ - 等 owner 依 intake form 提供脫敏 metadata;reviewer 依本 checklist 判定補件、隔離、拒收或可進 security acceptance record。 - 推送後同步另一個 AwoooP Session,提醒 P1-002 提交前接上最新 S4.9 reviewer validation 文件基線。 +## 2026-06-05|P1-002 Gitea 工作流程與 runner 健康合約本地完成 + +**背景**:接續 P1-001 執行面只讀矩陣正式上線,依工作清單推進 `P1-002`。本段只建立 committed workflow / runner health contract、只讀 API 與治理頁顯示,不修改 `.gitea/workflows/*`、不觸發 deploy / migration、不重啟 runner、不停止 container、不讀 Secret payload、不送 Telegram 測試通知。 + +**本輪完成**: +- 新增 `gitea_workflow_runner_health_v1` schema 與 `docs/evaluations/gitea_workflow_runner_health_2026-06-05.json`。 +- 新增 `GET /api/v1/agents/gitea-workflow-runner-health` 與 service guard,檢查 read-only mode、operation boundaries、rollup consistency、runner contract、failure-only / actionable-only notification contract、operator denials 與 forbidden secret payload key。 +- 治理頁 `/zh-TW/governance?tab=automation-inventory` 新增「Gitea 工作流程 / Runner 健康合約」區塊,顯示 workflow、runner contract、notification contract 與不可誤讀合約。 +- 同步 automation backlog / inventory snapshot:current `P1-002`、next `P1-003`、backlog overall `78%`、P1 `86%`、done `18/23`、inventory tasks `28`。 + +**目前數字**: +- Gitea workflows:`9`。 +- 定期排程:`2`;`workflow_dispatch`:`7`。 +- Notify bridge workflows:`6`。 +- Actionable / failure quiet workflows:`2`。 +- Runner contracts:`4`;需處置 runner contract:`1`(`ubuntu_latest_gitea_runner_label`)。 +- 仍需 runner owner attestation 的 workflow:`8`。 +- Notification contracts:`6`;quiet success contracts:`2`。 + +**本地驗證進度**: +- JSON parse 通過。 +- Gitea workflow / runner health、automation inventory、automation backlog 目標 pytest:`23 passed`。 +- Python py_compile 通過。 +- zh-TW / en i18n key 差異 `0`。 +- 前端 typecheck 通過。 +- `git diff --check`、`source-control-owner-response-guard.py`、`security-mirror-progress-guard.py` 通過。 +- 本地 API readback 通過:`GET /api/v1/agents/gitea-workflow-runner-health` 回 `gitea_workflow_runner_health_v1`、current `P1-002`、next `P1-003`、workflows `9`。 +- 本機 Next production build 與 browser smoke:受本機磁碟 `99-100%` 與 `.next` cache / chunk 缺檔阻擋;編譯階段曾到 `Compiled successfully`,但 page-data / dev runtime 受殘留 chunk `194.js`、`@swc/helpers` 與 `server-development/0.pack.gz` 缺檔影響。正式 build 與頁面驗證改由 Gitea CD / production smoke 判定。 +- 正式環境 deploy 與 production smoke:待推送後執行。 + +**邊界**: +- `workflow_modification_allowed=false`、`runner_restart_allowed=false`、`runner_container_stop_allowed=false`、`runner_label_change_allowed=false`、`runner_registration_allowed=false`、`secret_plaintext_allowed=false`、`notification_send_allowed=false`、`schedule_enable_allowed=false`、`gitea_api_write_allowed=false`、`deploy_trigger_allowed=false`、`migration_trigger_allowed=false`。 +- 下一步:推 Gitea main,等待 code-review / CD 乾淨環境驗證,再做 production API 與 desktop / mobile smoke,之後進 `P1-003` 監控合約與降噪機會。 + ## 2026-06-05|S4.9 Owner Response Intake Form **背景**:接續 S4.9 canonical owner response envelope,本段把六欄封套轉成 owner 可直接填寫的五題 intake form。平行 AwoooP Session 正在推 `P1-002 Gitea 工作流程與 runner 健康合約盤點`,本視窗避開 workflow / runner snapshot / API / UI 實作檔,只做 docs-only 規範,不送 request、不收 owner response、不改 Gitea / GitHub / refs / workflow / secret / runner / runtime。 diff --git a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md index ee3d3a953..10e22dad9 100644 --- a/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md +++ b/docs/ai/AI_AGENT_AUTOMATION_WORKLIST_2026-06-04.md @@ -10,10 +10,10 @@ |---|---:|---|---| | Agent 市場治理 | 72% | 進行中 | `agent_market_governance_snapshot_v1`、API、UI 分頁、每週觀察流程 | | Nemotron 實際整合應用 | 30% | 完整回放前仍被關卡擋下 | `blocked_needs_evidence`,下一關是 `refresh_source_evidence_then_5_record_smoke_only` | -| 工具 / 服務 / 套件 AI 自動化 | 74% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示;任務批准邊界、進度彙總與 P1-001 執行面只讀矩陣已完成,下一主線是 P1-002 Gitea 工作流程與 runner 健康合約 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、任務批准邊界、確定性進度彙總、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板、runtime_surface_inventory_v1 schema / snapshot / API / UI 已完成 | +| 工具 / 服務 / 套件 AI 自動化 | 78% | P0 已完成,P1 套件 / 供應鏈主線已完成;備份 / DR 主線已完成到異地 / escrow 準備度顯示;任務批准邊界、進度彙總、P1-001 執行面只讀矩陣與 P1-002 Gitea 工作流程 / runner 健康合約已完成,下一主線是 P1-003 監控合約與降噪機會 | 狀態分類、盤點 schema、權限矩陣、靜態盤點種子、只讀 API、UI 骨架、驗證、自動化待辦 schema / 快照 / API / 分組 UI、Backup / DR 目標盤點、準備度矩陣、備份通知政策、Backup / DR 證據 UI、復原演練批准包模板、異地 / escrow 準備度狀態、任務批准邊界、確定性進度彙總、Python 套件 / 供應鏈只讀基線、JS pnpm/npm 只讀基線、Docker build surface 只讀基線、CVE / license / drift 嚴重度政策、定期依賴漂移與外部資料來源檢查設計、依賴升級批准包模板、runtime_surface_inventory_v1 schema / snapshot / API / UI、gitea_workflow_runner_health_v1 schema / snapshot / API / UI 已完成 | | 本工作清單與分析報告 | 100% | 已完成 | 本 MD 文件 | -AI Agent 自動化工作包目前完成度:**74%**。本工作清單文件本身完成度:**100%**。 +AI Agent 自動化工作包目前完成度:**78%**。本工作清單文件本身完成度:**100%**。 完成度計算模型: @@ -867,7 +867,7 @@ UI: | ID | 狀態 | % | 負責 Agent | 任務 | 產出 | 關卡 | |---|---|---:|---|---|---|---| | P1-001 | 完成 | 100 | OpenClaw | 盤點 API / Web / Worker / K8s runtime surface | `runtime_surface_inventory_v1` / `GET /api/v1/agents/runtime-surface-inventory` / 執行面只讀矩陣 | 只讀;不得查 Secret payload、不得 rollout / restart / scale / delete | -| P1-002 | 待辦 | 0 | Hermes | 盤點 Gitea 工作流程與 runner 健康合約 | 工作流程 / runner 矩陣 | 不修改工作流程 | +| P1-002 | 完成 | 100 | Hermes | 盤點 Gitea 工作流程與 runner 健康合約 | `gitea_workflow_runner_health_v1` / `GET /api/v1/agents/gitea-workflow-runner-health` / Gitea 健康合約 UI | 只讀;不修改 workflow、不重啟 runner、不停止 container、不讀 Secret、不發通知 | | P1-003 | 待辦 | 0 | Hermes | 盤點 Prometheus / Alertmanager / SigNoz / Grafana 監控合約 | 可觀測性矩陣 | 只讀 | | P1-004 | 待辦 | 0 | OpenClaw | 盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑 | 推理路由矩陣 | 不切 provider | | P1-005 | 待辦 | 0 | OpenClaw | 偵測服務健康缺口與過期端點 | 需處置清單 | 不重啟 | @@ -1068,10 +1068,25 @@ UI: 下一步:P1-002 盤點 Gitea 工作流程與 runner 健康合約。 ``` +本次同步: + +```text +進度:78%。 +目前優先級:P1。 +目前任務:P1-002 盤點 Gitea 工作流程與 runner 健康合約。 +狀態變更:待辦 -> 完成。 +證據:gitea_workflow_runner_health_v1 schema / snapshot;GET /api/v1/agents/gitea-workflow-runner-health;治理頁 Gitea 工作流程 / Runner 健康合約區塊;automation backlog 78%;inventory tasks 28。 +目前數字:Gitea workflows 9;schedule 2;workflow_dispatch 7;notify bridge 6;actionable/failure quiet workflows 2;runner contracts 4;runner contract action_required 1;仍需 runner owner attestation 的 workflow 8;notification contracts 6;quiet success contracts 2;backlog done 18/23;overall 78%;P1 86%。 +驗證:JSON parse 通過;gitea workflow runner health / inventory / backlog 目標 pytest 23 passed;Python py_compile 通過;zh-TW / en i18n key 差異 0;web typecheck 通過;source-control-owner-response guard、security-mirror-progress guard、git diff --check 通過;本地 API readback 回 `gitea_workflow_runner_health_v1`、current `P1-002`、next `P1-003`、workflows `9`。 +正式驗證:待 push 後補 Gitea code-review / CD run、production API readback 與 production desktop / mobile smoke;本機 Next build / browser smoke 受磁碟 `99-100%` 與 `.next` cache / chunk 缺檔阻擋,改以 Gitea CD 乾淨環境作 build 判定。 +阻擋:本機磁碟空間與 `.next` cache 不完整只阻擋 local build / browser smoke;workflow modification、runner restart / stop、runner label change、runner registration、Secret payload read、notification send、schedule change、Gitea write、deploy / migration trigger 仍全部禁止。 +下一步:P1-003 盤點監控合約與降噪機會。 +``` + ## 13. 立即執行順序 -1. P1-002:盤點 Gitea 工作流程與 runner 健康合約。 -2. P1-003 / P1-004:接續盤點 Prometheus / Alertmanager / SigNoz / Grafana 監控合約,以及 AI Router / Ollama / Nemotron / Gemini provider 路徑。 +1. P1-003:盤點 Prometheus / Alertmanager / SigNoz / Grafana 監控合約與降噪機會。 +2. P1-004:盤點 AI Router / Ollama / Nemotron / Gemini provider 路徑。 3. P2 / P3 必須等 P1 服務、監控與 provider runtime surface 可見且關卡穩定後再做。 ## 14. 目前風險 diff --git a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json index 96c934690..fb78257b3 100644 --- a/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json +++ b/docs/evaluations/ai_agent_automation_backlog_2026-06-04.json @@ -1,12 +1,12 @@ { "schema_version": "ai_agent_automation_backlog_v1", - "generated_at": "2026-06-05T09:05:41+08:00", + "generated_at": "2026-06-05T10:56:16+08:00", "source_inventory_snapshot_ref": "docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json", "program_status": { - "overall_completion_percent": 74, + "overall_completion_percent": 78, "current_priority": "P1", - "current_task_id": "P1-001", - "next_task_id": "P1-002", + "current_task_id": "P1-002", + "next_task_id": "P1-003", "read_only_mode": true }, "rollups": { @@ -17,8 +17,8 @@ "P3": 1 }, "by_status": { - "done": 17, - "planned": 6 + "done": 18, + "planned": 5 }, "by_gate_status": { "read_only_allowed": 20, @@ -269,26 +269,30 @@ { "item_id": "AUTO-P1-002", "priority": "P1", - "status": "planned", + "status": "done", "workstream_id": "WS3", "source_asset_id": "gitea_actions", "source_signal_kind": "health_gap", "title": "盤點 Gitea 工作流程與 runner 健康合約", "owner_agent": "hermes", - "recommended_action": "整理 workflow、runner、failure-only notification 與每週 agent market watch cadence。", + "recommended_action": "已建立 Gitea workflow / runner health contract 只讀 snapshot、API 與治理頁;保留 failure-only / actionable-only 通知政策,不修改 workflow 或 runner。", "action_class": "observe", "gate_status": "read_only_allowed", "risk_level": "medium", "evidence_refs": [ - ".gitea/workflows/agent-market-watch.yaml", - "docs/LOGBOOK.md" + "docs/evaluations/gitea_workflow_runner_health_2026-06-05.json", + "GET /api/v1/agents/gitea-workflow-runner-health", + ".gitea/workflows/", + "scripts/ci/notify-awoooi-cicd.sh", + "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], "acceptance_criteria": [ - "不修改 workflow", - "列出 runner health contract", - "成功不通知、失敗才通知的政策被保留" + "不修改 workflow、不觸發 deploy/migration、不重啟或停止 runner", + "列出 9 個 Gitea workflow、runner label evidence status 與 notification policy", + "成功不洗版、失敗 / actionable 才升級的政策被保留,CD/review/manual status 例外另列", + "API / UI 僅顯示 committed snapshot 與不可誤讀合約" ], - "next_review": "P1-002", + "next_review": "P1-003", "approval_boundary": { "mode": "read_only_allowed", "display_summary": "只允許只讀盤點、顯示與批准包準備;不得直接執行寫入、部署、通知或外部呼叫。", @@ -1166,16 +1170,16 @@ ] }, "progress_summary": { - "overall_percent": 74, - "done_items": 17, - "planned_items": 6, + "overall_percent": 78, + "done_items": 18, + "planned_items": 5, "total_items": 23, "formula": "round(done_items / total_items * 100),只有 status=done 計入完成;planned/in_progress/blocked/deferred/rejected 不計入。", "by_priority": [ { "priority": "P1", - "completion_percent": 81, - "done_items": 17, + "completion_percent": 86, + "done_items": 18, "total_items": 21 }, { @@ -1203,10 +1207,10 @@ { "workstream_id": "WS3", "display_name": "監控自動化", - "completion_percent": 25, - "done_items": 1, + "completion_percent": 50, + "done_items": 2, "total_items": 4, - "next_task_id": "P1-002" + "next_task_id": "P1-003" }, { "workstream_id": "WS4", @@ -1246,7 +1250,7 @@ "completion_percent": 100, "done_items": 2, "total_items": 2, - "next_task_id": "P1-002" + "next_task_id": "P1-003" } ] } diff --git a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json index 8172ce2ac..51ec9ed3a 100644 --- a/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json +++ b/docs/evaluations/ai_agent_automation_inventory_snapshot_2026-06-04_static_seed.json @@ -1,11 +1,11 @@ { "schema_version": "ai_agent_automation_inventory_snapshot_v1", - "generated_at": "2026-06-05T09:05:41+08:00", + "generated_at": "2026-06-05T10:56:16+08:00", "program_status": { "overall_completion_percent": 100, "current_priority": "P1", - "current_task_id": "P1-001", - "next_task_id": "P1-002", + "current_task_id": "P1-002", + "next_task_id": "P1-003", "read_only_mode": true }, "status_taxonomy": { @@ -275,9 +275,12 @@ "owner_agent": "hermes", "risk_level": "medium", "evidence_refs": [ - ".gitea/workflows/agent-market-watch.yaml" + "docs/evaluations/gitea_workflow_runner_health_2026-06-05.json", + ".gitea/workflows/", + "scripts/ci/notify-awoooi-cicd.sh", + "scripts/ops/stop-stale-gitea-actions-jobs.sh" ], - "next_action": "P1-002 盤點 runner 健康合約與 failure-only 通知。" + "next_action": "P1-002 已完成 committed workflow / runner health contract;P1-003 盤點監控合約與降噪機會。" }, { "asset_id": "prometheus_alertmanager", @@ -469,9 +472,9 @@ { "workstream_id": "WS3", "display_name": "監控自動化", - "completion_percent": 25, + "completion_percent": 50, "status": "in_progress", - "next_task_id": "P1-002" + "next_task_id": "P1-003" }, { "workstream_id": "WS4", @@ -504,9 +507,9 @@ { "workstream_id": "WS8", "display_name": "產品 UI", - "completion_percent": 92, + "completion_percent": 94, "status": "in_progress", - "next_task_id": "P1-002" + "next_task_id": "P1-003" } ], "tasks": [ @@ -799,6 +802,38 @@ ] } }, + { + "task_id": "P1-002", + "priority": "P1", + "status": "done", + "completion_percent": 100, + "owner_agent": "hermes", + "title": "盤點 Gitea 工作流程與 runner 健康合約", + "output": "docs/evaluations/gitea_workflow_runner_health_2026-06-05.json + GET /api/v1/agents/gitea-workflow-runner-health", + "gate_status": "read_only_allowed", + "next_action": "完成 committed workflow / runner / notification contract;下一步 P1-003 盤點監控合約與降噪機會。", + "approval_boundary": { + "mode": "read_only_allowed", + "display_summary": "只允許只讀盤點、顯示與批准包準備;不得直接執行寫入、部署、通知或外部呼叫。", + "allowed_actions": [ + "讀取 committed snapshot", + "整理只讀證據", + "顯示治理 UI" + ], + "blocked_actions": [ + "production_write", + "runtime_execution", + "destructive_operation", + "secret_plaintext_collection", + "unapproved_deploy", + "unapproved_external_call" + ], + "requires_operator_approval_for": [ + "任何非只讀操作", + "任何部署、排程、通知或外部呼叫變更" + ] + } + }, { "task_id": "P1-301", "priority": "P1", @@ -1682,6 +1717,18 @@ "kind": "api", "ref": "GET /api/v1/agents/runtime-surface-inventory", "result": "只讀 API 回傳 runtime_surface_inventory_v1;不查 live K8s、不讀 Secret payload、不提供執行按鈕。" + }, + { + "evidence_id": "gitea_workflow_runner_health_snapshot", + "kind": "runtime", + "ref": "docs/evaluations/gitea_workflow_runner_health_2026-06-05.json", + "result": "P1-002 committed Gitea workflow / runner health contract 已建立,涵蓋 9 個 workflow、4 個 runner contract、6 個 notification contract。" + }, + { + "evidence_id": "gitea_workflow_runner_health_api", + "kind": "api", + "ref": "GET /api/v1/agents/gitea-workflow-runner-health", + "result": "只讀 API 回傳 gitea_workflow_runner_health_v1;不修改 workflow、不重啟 runner、不停止 container、不讀 Secret、不送通知。" } ], "approval_boundaries": { @@ -1692,10 +1739,10 @@ "destructive_operation_allowed": false }, "task_approval_boundary_rollup": { - "total_tasks": 27, + "total_tasks": 28, "by_mode": { "ready_for_operator_review": 1, - "read_only_allowed": 25, + "read_only_allowed": 26, "approval_required": 1 }, "tasks_requiring_explicit_approval": [ @@ -1712,6 +1759,7 @@ "P0-007", "P0-008", "P1-001", + "P1-002", "P1-101", "P1-102", "P1-103", diff --git a/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json new file mode 100644 index 000000000..5ceb82c8e --- /dev/null +++ b/docs/evaluations/gitea_workflow_runner_health_2026-06-05.json @@ -0,0 +1,565 @@ +{ + "schema_version": "gitea_workflow_runner_health_v1", + "generated_at": "2026-06-05T10:56:16+08:00", + "program_status": { + "overall_completion_percent": 100, + "current_priority": "P1", + "current_task_id": "P1-002", + "next_task_id": "P1-003", + "read_only_mode": true + }, + "source_refs": [ + "docs/schemas/gitea_workflow_runner_health_v1.schema.json", + ".gitea/workflows/agent-market-watch.yaml", + ".gitea/workflows/ansible-lint.yml", + ".gitea/workflows/cd-dev.yaml", + ".gitea/workflows/cd.yaml", + ".gitea/workflows/code-review.yaml", + ".gitea/workflows/deploy-alerts.yaml", + ".gitea/workflows/e2e-health.yaml", + ".gitea/workflows/run-migration.yml", + ".gitea/workflows/type-sync-check.yaml", + "scripts/ci/check-gitea-step-env-secrets.js", + "scripts/ci/cleanup-host-runner-workspace.sh", + "scripts/ci/wait-host-web-build-pressure.sh", + "scripts/ci/notify-awoooi-cicd.sh", + "scripts/setup-runner-watchdog.sh", + "scripts/ops/stop-stale-gitea-actions-jobs.sh" + ], + "rollups": { + "total_workflows": 9, + "by_workflow_status": { + "manifest_mapped": 9 + }, + "by_runner_evidence_status": { + "owner_attestation_required": 7, + "host_runner_mapped": 1, + "comment_ambiguous": 1 + }, + "workflows_with_schedule": 2, + "workflows_with_workflow_dispatch": 7, + "workflows_with_notify_bridge": 6, + "workflows_with_actionable_or_failure_quiet_policy": 2, + "workflow_ids_requiring_runner_attestation": [ + "agent_market_watch", + "ansible_lint", + "cd_dev", + "code_review", + "deploy_alerts", + "e2e_health", + "run_migration", + "type_sync_check" + ], + "total_runner_contracts": 4, + "runner_contracts_requiring_action": [ + "ubuntu_latest_gitea_runner_label" + ], + "notification_contracts_total": 6, + "notification_contracts_quiet_success_count": 2, + "notification_contracts_quiet_success_ids": [ + "agent_market_watch_actionable_only", + "e2e_health_failure_only" + ] + }, + "workflow_records": [ + { + "workflow_id": "agent_market_watch", + "file_ref": ".gitea/workflows/agent-market-watch.yaml", + "display_name": "Agent Market Watch", + "scope": "每週市場觀察與手動觀察;只產生報告、分類與推廣候選,不做 SDK/API/replay/shadow/canary 或 OpenClaw 替換批准。", + "status": "manifest_mapped", + "risk_level": "medium", + "triggers": [ + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "每週一 09:00 Asia/Taipei;cron=0 1 * * 1 UTC", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "actionable_only_no_success_noise", + "notify_bridge_calls": 0, + "secrets_policy_status": "未讀取 Secret;只由 workflow 條件與 committed script 形成只讀證據。", + "evidence_refs": [ + ".gitea/workflows/agent-market-watch.yaml", + "docs/evaluations/agent_market_watch_report_2026-06-04_watch_expanded.json" + ], + "next_action": "保留 actionable-only;下一步只補 runner label owner attestation,不啟用額外通知或 paid API。" + }, + { + "workflow_id": "ansible_lint", + "file_ref": ".gitea/workflows/ansible-lint.yml", + "display_name": "Ansible Lint", + "scope": "Ansible 檔案 lint;無通知橋接。", + "status": "manifest_mapped", + "risk_level": "low", + "triggers": [ + "push" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "read_only_no_notify", + "notify_bridge_calls": 0, + "secrets_policy_status": "未見通知或 Secret payload 使用;仍需只讀 secret-name hygiene 持續檢查。", + "evidence_refs": [ + ".gitea/workflows/ansible-lint.yml" + ], + "next_action": "補 ubuntu-latest 在 Gitea Actions 的 owner attestation,不改 workflow label。" + }, + { + "workflow_id": "cd_dev", + "file_ref": ".gitea/workflows/cd-dev.yaml", + "display_name": "CD Pipeline (Dev)", + "scope": "dev branch build / deploy;屬部署狀態通知例外,不套用 success-noise 全靜音。", + "status": "manifest_mapped", + "risk_level": "high", + "triggers": [ + "push:dev", + "workflow_dispatch" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "deployment_status_exception", + "notify_bridge_calls": 3, + "secrets_policy_status": "需維持 scripts/ci/check-gitea-step-env-secrets.js 類型的 step env / action input hygiene。", + "evidence_refs": [ + ".gitea/workflows/cd-dev.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留部署狀態例外;補 runner owner attestation 與通知降噪邊界,不直接改 dev CD。" + }, + { + "workflow_id": "cd_pipeline", + "file_ref": ".gitea/workflows/cd.yaml", + "display_name": "CD Pipeline", + "scope": "main branch code-review 後的正式測試、build、deploy、post-deploy;使用 awoooi-host runner。", + "status": "manifest_mapped", + "risk_level": "critical", + "triggers": [ + "push:main", + "workflow_dispatch" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "awoooi-host" + ], + "runner_evidence_status": "host_runner_mapped", + "job_count": 3, + "notification_policy": "deployment_status_exception", + "notify_bridge_calls": 9, + "secrets_policy_status": "正式 CD 使用 check-gitea-step-env-secrets guard;不在本 snapshot 讀取任何 Secret payload。", + "evidence_refs": [ + ".gitea/workflows/cd.yaml", + "scripts/ci/check-gitea-step-env-secrets.js", + "scripts/ci/cleanup-host-runner-workspace.sh", + "scripts/ci/wait-host-web-build-pressure.sh" + ], + "next_action": "保留 awoooi-host 合約與 post-deploy smoke;任何 CD 修改仍需獨立 review / deploy gate。" + }, + { + "workflow_id": "code_review", + "file_ref": ".gitea/workflows/code-review.yaml", + "display_name": "Code Review", + "scope": "AI code review 與 stale-main guard;不等於自動 merge 或部署批准。", + "status": "manifest_mapped", + "risk_level": "high", + "triggers": [ + "push", + "workflow_dispatch" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "manual_status_exception", + "notify_bridge_calls": 2, + "secrets_policy_status": "不讀 Secret payload;仍需 owner attestation 證明 runner 與通知 secret name parity。", + "evidence_refs": [ + ".gitea/workflows/code-review.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留 review 狀態通知;補 runner label 與 secret-name hygiene 證據。" + }, + { + "workflow_id": "deploy_alerts", + "file_ref": ".gitea/workflows/deploy-alerts.yaml", + "display_name": "Deploy Alert Rules", + "scope": "告警規則部署流程;屬人工/部署狀態例外,不在 P1-002 變更 alert rule。", + "status": "manifest_mapped", + "risk_level": "high", + "triggers": [ + "workflow_dispatch", + "push" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "manual_status_exception", + "notify_bridge_calls": 1, + "secrets_policy_status": "不讀 Secret payload;告警鏈路 E2E 需另走 ADR-025/相關 guard。", + "evidence_refs": [ + ".gitea/workflows/deploy-alerts.yaml", + "scripts/ci/notify-awoooi-cicd.sh", + "docs/HARD_RULES.md" + ], + "next_action": "P1-003 再盤點 Alertmanager / Prometheus 合約;P1-002 不改 alert rules。" + }, + { + "workflow_id": "e2e_health", + "file_ref": ".gitea/workflows/e2e-health.yaml", + "display_name": "E2E Health Check", + "scope": "每日正式 API health 檢查;失敗才升級通知。", + "status": "manifest_mapped", + "risk_level": "medium", + "triggers": [ + "workflow_dispatch", + "schedule" + ], + "schedule_cadence": "每日 00:00 Asia/Taipei;cron=0 16 * * * UTC", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "failure_only", + "notify_bridge_calls": 1, + "secrets_policy_status": "失敗通知透過 notify bridge;本 snapshot 不讀 Telegram / webhook Secret payload。", + "evidence_refs": [ + ".gitea/workflows/e2e-health.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留 failure-only;補 runner owner attestation 與最近 run 證據。" + }, + { + "workflow_id": "run_migration", + "file_ref": ".gitea/workflows/run-migration.yml", + "display_name": "run-migration", + "scope": "手動 migration workflow;不得由 P1-002 觸發。", + "status": "manifest_mapped", + "risk_level": "critical", + "triggers": [ + "workflow_dispatch" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest # 或 self-hosted runner on 110" + ], + "runner_evidence_status": "comment_ambiguous", + "job_count": 1, + "notification_policy": "manual_status_exception", + "notify_bridge_calls": 1, + "secrets_policy_status": "Migration 相關 secret / DB 權限不得由 P1-002 讀取或擴權。", + "evidence_refs": [ + ".gitea/workflows/run-migration.yml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "只讀補 runner label owner attestation 與 migration approval boundary;不觸發 workflow。" + }, + { + "workflow_id": "type_sync_check", + "file_ref": ".gitea/workflows/type-sync-check.yaml", + "display_name": "Type Sync Check", + "scope": "型別同步檢查;無通知橋接。", + "status": "manifest_mapped", + "risk_level": "low", + "triggers": [ + "push" + ], + "schedule_cadence": "無定期排程", + "runner_labels": [ + "ubuntu-latest" + ], + "runner_evidence_status": "owner_attestation_required", + "job_count": 1, + "notification_policy": "read_only_no_notify", + "notify_bridge_calls": 0, + "secrets_policy_status": "無通知橋接;仍需 runner label attestation。", + "evidence_refs": [ + ".gitea/workflows/type-sync-check.yaml" + ], + "next_action": "補 runner label owner attestation,不改 workflow。" + } + ], + "runner_contracts": [ + { + "contract_id": "awoooi_host_runner", + "display_name": "awoooi-host 正式 CD runner", + "status": "manifest_mapped", + "risk_level": "critical", + "runner_labels": [ + "awoooi-host" + ], + "used_by_workflows": [ + "cd_pipeline" + ], + "health_contract": "正式 CD tests/build/post-deploy 均使用 awoooi-host;cleanup 與 build-pressure guard 只做等候 / 清理,不代表可任意重啟 runner。", + "guardrail_refs": [ + "scripts/ci/cleanup-host-runner-workspace.sh", + "scripts/ci/wait-host-web-build-pressure.sh" + ], + "evidence_refs": [ + ".gitea/workflows/cd.yaml", + "docs/LOGBOOK.md" + ], + "next_action": "維持正式 CD runner 合約;任何 systemd / runner 變更另走人工批准。" + }, + { + "contract_id": "ubuntu_latest_gitea_runner_label", + "display_name": "ubuntu-latest Gitea runner label 對應", + "status": "action_required", + "risk_level": "high", + "runner_labels": [ + "ubuntu-latest" + ], + "used_by_workflows": [ + "agent_market_watch", + "ansible_lint", + "cd_dev", + "code_review", + "deploy_alerts", + "e2e_health", + "run_migration", + "type_sync_check" + ], + "health_contract": "多數 workflow 仍標示 ubuntu-latest;需要 Gitea runner owner 以脫敏 metadata 證明實際 runner 對應、容量與維護責任。", + "guardrail_refs": [ + "docs/HARD_RULES.md", + "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md" + ], + "evidence_refs": [ + ".gitea/workflows/agent-market-watch.yaml", + ".gitea/workflows/e2e-health.yaml", + ".gitea/workflows/run-migration.yml" + ], + "next_action": "建立 owner attestation request;不得直接把 label 改成 self-hosted 或啟用新 runner。" + }, + { + "contract_id": "runner_watchdog_systemd", + "display_name": "actions.runner systemd watchdog 草案", + "status": "prepared_not_applied_by_snapshot", + "risk_level": "medium", + "runner_labels": [ + "actions.runner.owenhytsai-awoooi.awoooi-110.service" + ], + "used_by_workflows": [ + "cd_pipeline", + "Gitea Actions host runner service" + ], + "health_contract": "setup script 只描述 WatchdogSec=300、Restart=always、StartLimitBurst=5;P1-002 不套用、不 restart、不 systemctl daemon-reload。", + "guardrail_refs": [ + "scripts/setup-runner-watchdog.sh" + ], + "evidence_refs": [ + "scripts/setup-runner-watchdog.sh", + "docs/MONITORING_COMPLETE_STRATEGY.md" + ], + "next_action": "若需套用 watchdog,另開批准包與維護窗口;本 snapshot 僅展示草案存在。" + }, + { + "contract_id": "stale_job_container_guard", + "display_name": "Gitea Actions stale job container guard", + "status": "dry_run_only", + "risk_level": "high", + "runner_labels": [ + "GITEA-ACTIONS-* docker containers" + ], + "used_by_workflows": [ + "all_gitea_actions" + ], + "health_contract": "stop-stale-gitea-actions-jobs.sh 預設 dry-run;只有 --apply 才停止 container,且仍要檢查 recent logs 與 workflow threshold。", + "guardrail_refs": [ + "scripts/ops/stop-stale-gitea-actions-jobs.sh" + ], + "evidence_refs": [ + "scripts/ops/stop-stale-gitea-actions-jobs.sh" + ], + "next_action": "維持 dry-run-only;不得由治理頁或 API 直接停止 container。" + } + ], + "notification_contracts": [ + { + "contract_id": "agent_market_watch_actionable_only", + "display_name": "Agent Market Watch actionable-only", + "status": "preserved", + "policy_kind": "actionable_only", + "success_noise_policy": "無 actionable market change 時保持 Telegram 安靜,不發成功洗版訊息。", + "failure_policy": "發現新候選、queue、失敗或需人工 review 時才產生 summary / action-required。", + "workflow_refs": [ + "agent_market_watch" + ], + "evidence_refs": [ + ".gitea/workflows/agent-market-watch.yaml" + ], + "next_action": "保留每週觀察 cadence;P1-002 不增加外部 API 或通知頻率。" + }, + { + "contract_id": "e2e_health_failure_only", + "display_name": "E2E Health failure-only", + "status": "preserved", + "policy_kind": "failure_only", + "success_noise_policy": "健康檢查成功不即時通知。", + "failure_policy": "workflow failure 才呼叫 notify bridge / Alertmanager payload。", + "workflow_refs": [ + "e2e_health" + ], + "evidence_refs": [ + ".gitea/workflows/e2e-health.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留 failure-only;後續只補最近 run readback。" + }, + { + "contract_id": "cd_pipeline_status_exception", + "display_name": "正式 CD 狀態通知例外", + "status": "exception_documented", + "policy_kind": "deployment_status_exception", + "success_noise_policy": "正式部署成功通知屬 release evidence,不套入一般備份成功靜音規則;仍不得在非部署情境洗版。", + "failure_policy": "tests/build/deploy/post-deploy 任一失敗必須升級。", + "workflow_refs": [ + "cd_pipeline" + ], + "evidence_refs": [ + ".gitea/workflows/cd.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留 release evidence;任何降噪調整另提批准包。" + }, + { + "contract_id": "dev_cd_status_exception", + "display_name": "Dev CD 狀態通知例外", + "status": "exception_documented", + "policy_kind": "deployment_status_exception", + "success_noise_policy": "dev deploy status 屬部署流程訊號;不得擴大為其他成功洗版。", + "failure_policy": "dev build/deploy failure 升級到 AwoooP / Telegram contract。", + "workflow_refs": [ + "cd_dev" + ], + "evidence_refs": [ + ".gitea/workflows/cd-dev.yaml", + "scripts/ci/notify-awoooi-cicd.sh" + ], + "next_action": "保留現況;後續評估 dev 通知是否需要降噪。" + }, + { + "contract_id": "review_and_manual_workflow_status_exception", + "display_name": "Review / manual workflow 狀態例外", + "status": "exception_documented", + "policy_kind": "manual_status_exception", + "success_noise_policy": "code-review、alert deploy、migration 的狀態訊號不自動擴張到成功洗版;手動 workflow 成功仍應看情境與 release evidence。", + "failure_policy": "review、alert deploy 或 migration failure 必須留可追蹤證據。", + "workflow_refs": [ + "code_review", + "deploy_alerts", + "run_migration" + ], + "evidence_refs": [ + ".gitea/workflows/code-review.yaml", + ".gitea/workflows/deploy-alerts.yaml", + ".gitea/workflows/run-migration.yml" + ], + "next_action": "P1-003 盤點告警流程時再處理 alert deploy;P1-002 不發通知、不觸發 migration。" + }, + { + "contract_id": "lint_and_typecheck_no_notify", + "display_name": "Lint / typecheck no-notify", + "status": "preserved", + "policy_kind": "read_only_no_notify", + "success_noise_policy": "lint / type sync 成功不通知。", + "failure_policy": "失敗只留 workflow 結果,是否升級需由 code-review / CD gate 判斷。", + "workflow_refs": [ + "ansible_lint", + "type_sync_check" + ], + "evidence_refs": [ + ".gitea/workflows/ansible-lint.yml", + ".gitea/workflows/type-sync-check.yaml" + ], + "next_action": "維持 no-notify;補 runner attestation。" + } + ], + "latest_observations": [ + { + "observation_id": "latest_gitea_runs_success_readback", + "status": "verified", + "summary": "P1-001 後續正式部署已由 Gitea code-review run 2597 與 CD run 2596 成功收斂;P1-002 只引用既有 deploy evidence,不觸發新 run。", + "evidence_refs": [ + "docs/LOGBOOK.md", + "docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md" + ] + }, + { + "observation_id": "gitea_api_unauthenticated_boundary", + "status": "action_required", + "summary": "Gitea Actions API 若無 token 會遇到 401;P1-002 以 committed workflow、HTML/DB readback 或 owner attestation 為證據,不猜測 API 狀態。", + "evidence_refs": [ + "docs/HARD_RULES.md", + "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md" + ] + }, + { + "observation_id": "workflow_secret_guard_present", + "status": "verified", + "summary": "正式 CD 已具備 check-gitea-step-env-secrets guard,可阻擋 secrets 掛在 step env / action input;P1-002 不讀任何 Secret payload。", + "evidence_refs": [ + "scripts/ci/check-gitea-step-env-secrets.js", + ".gitea/workflows/cd.yaml" + ] + } + ], + "operator_contract": { + "display_mode": "read_only_gitea_workflow_runner_health", + "must_not_interpret_as": [ + "workflow 修改批准", + "runner restart / stop 批准", + "Secret 已讀取或可輸出", + "Telegram 測試通知批准", + "排程啟用或變更批准", + "Gitea write token 授權", + "deploy / migration workflow 觸發批准" + ], + "secret_display_policy": "只允許顯示 workflow / secret-name hygiene 與 redacted metadata;不得讀 token、runner token、webhook secret、authorization header 或任何 Secret payload。", + "runner_mutation_policy": "本 snapshot 只描述 runner label、watchdog 草案與 dry-run guard;不得 systemctl restart、docker stop、修改 label、註冊 runner 或套用 watchdog。", + "notification_policy": "成功不洗版是預設治理方向;failure-only / actionable-only 合約需保留,CD/review/manual workflow 的狀態通知例外需另外標示。" + }, + "operation_boundaries": { + "read_only_api_allowed": true, + "workflow_modification_allowed": false, + "runner_restart_allowed": false, + "runner_container_stop_allowed": false, + "runner_label_change_allowed": false, + "runner_registration_allowed": false, + "secret_read_allowed": false, + "secret_plaintext_allowed": false, + "notification_send_allowed": false, + "schedule_enable_allowed": false, + "gitea_api_write_allowed": false, + "deploy_trigger_allowed": false, + "migration_trigger_allowed": false + }, + "approval_boundaries": { + "workflow_modification_authorized": false, + "runner_mutation_authorized": false, + "notification_send_authorized": false, + "secret_plaintext_allowed": false, + "runtime_execution_authorized": false, + "schedule_change_authorized": false, + "gitea_write_authorized": false, + "deploy_trigger_authorized": false, + "migration_trigger_authorized": false + } +} diff --git a/docs/schemas/gitea_workflow_runner_health_v1.schema.json b/docs/schemas/gitea_workflow_runner_health_v1.schema.json new file mode 100644 index 000000000..b8dae064c --- /dev/null +++ b/docs/schemas/gitea_workflow_runner_health_v1.schema.json @@ -0,0 +1,427 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:awoooi:gitea-workflow-runner-health-v1", + "title": "AWOOOI Gitea 工作流程與 runner 健康合約 v1", + "description": "以 repo 內 committed Gitea workflow 與 runner guard script 建立只讀健康合約;不修改 workflow、不重啟 runner、不停止容器、不讀 Secret payload、不送 Telegram 測試通知。", + "type": "object", + "required": [ + "schema_version", + "generated_at", + "program_status", + "source_refs", + "rollups", + "workflow_records", + "runner_contracts", + "notification_contracts", + "latest_observations", + "operator_contract", + "operation_boundaries", + "approval_boundaries" + ], + "properties": { + "schema_version": { + "type": "string", + "const": "gitea_workflow_runner_health_v1" + }, + "generated_at": { + "type": "string", + "minLength": 1 + }, + "program_status": { + "type": "object", + "required": [ + "overall_completion_percent", + "current_priority", + "current_task_id", + "next_task_id", + "read_only_mode" + ], + "properties": { + "overall_completion_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "current_priority": { + "type": "string", + "enum": [ + "P0", + "P1", + "P2", + "P3" + ] + }, + "current_task_id": { + "type": "string", + "minLength": 1 + }, + "next_task_id": { + "type": "string", + "minLength": 1 + }, + "read_only_mode": { + "type": "boolean", + "const": true + } + }, + "additionalProperties": false + }, + "source_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "rollups": { + "type": "object", + "additionalProperties": true + }, + "workflow_records": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/workflow_record" + } + }, + "runner_contracts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/runner_contract" + } + }, + "notification_contracts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/notification_contract" + } + }, + "latest_observations": { + "type": "array", + "items": { + "$ref": "#/$defs/observation" + } + }, + "operator_contract": { + "type": "object", + "additionalProperties": true + }, + "operation_boundaries": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "approval_boundaries": { + "type": "object", + "additionalProperties": { + "type": "boolean", + "const": false + } + } + }, + "$defs": { + "workflow_record": { + "type": "object", + "required": [ + "workflow_id", + "file_ref", + "display_name", + "scope", + "status", + "risk_level", + "triggers", + "schedule_cadence", + "runner_labels", + "runner_evidence_status", + "job_count", + "notification_policy", + "notify_bridge_calls", + "secrets_policy_status", + "evidence_refs", + "next_action" + ], + "properties": { + "workflow_id": { + "type": "string", + "minLength": 1 + }, + "file_ref": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "scope": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "manifest_mapped", + "action_required", + "blocked" + ] + }, + "risk_level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "triggers": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "schedule_cadence": { + "type": "string", + "minLength": 1 + }, + "runner_labels": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "runner_evidence_status": { + "type": "string", + "enum": [ + "host_runner_mapped", + "owner_attestation_required", + "comment_ambiguous" + ] + }, + "job_count": { + "type": "integer", + "minimum": 1 + }, + "notification_policy": { + "type": "string", + "minLength": 1 + }, + "notify_bridge_calls": { + "type": "integer", + "minimum": 0 + }, + "secrets_policy_status": { + "type": "string", + "minLength": 1 + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "next_action": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "runner_contract": { + "type": "object", + "required": [ + "contract_id", + "display_name", + "status", + "risk_level", + "runner_labels", + "used_by_workflows", + "health_contract", + "guardrail_refs", + "evidence_refs", + "next_action" + ], + "properties": { + "contract_id": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "manifest_mapped", + "action_required", + "dry_run_only", + "prepared_not_applied_by_snapshot" + ] + }, + "risk_level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "runner_labels": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "used_by_workflows": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "health_contract": { + "type": "string", + "minLength": 1 + }, + "guardrail_refs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "next_action": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "notification_contract": { + "type": "object", + "required": [ + "contract_id", + "display_name", + "status", + "policy_kind", + "success_noise_policy", + "failure_policy", + "workflow_refs", + "evidence_refs", + "next_action" + ], + "properties": { + "contract_id": { + "type": "string", + "minLength": 1 + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "preserved", + "exception_documented", + "action_required" + ] + }, + "policy_kind": { + "type": "string", + "enum": [ + "failure_only", + "actionable_only", + "deployment_status_exception", + "manual_status_exception", + "read_only_no_notify" + ] + }, + "success_noise_policy": { + "type": "string", + "minLength": 1 + }, + "failure_policy": { + "type": "string", + "minLength": 1 + }, + "workflow_refs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "evidence_refs": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "next_action": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "observation": { + "type": "object", + "required": [ + "observation_id", + "status", + "summary", + "evidence_refs" + ], + "properties": { + "observation_id": { + "type": "string", + "minLength": 1 + }, + "status": { + "type": "string", + "enum": [ + "verified", + "action_required", + "not_applicable" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index f2e630037..a5cefeb64 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -3498,3 +3498,21 @@ Phase 6 完成後 2. P1-003:盤點監控合約與降噪機會。 **裁決:** P1-001 正式上線仍只屬於 read-only runtime surface evidence。不得把 `22` 個 surfaces、`6` 個 action_required 或 `4` 個 Secret metadata surface 解讀成 live cluster query、Secret payload collection、runtime execution、rollout、restart、scale、delete、active scan、provider routing 或 production route change 授權。 + +### 2026-06-05 下午 (台北) — P1-002 Gitea 工作流程與 runner 健康合約本地完成 + +**觸發**:統帥批准繼續,要求依工作清單優先順序推進,並同步完成度、工作狀態與正式環境推版。 + +**已推進:** +- P1-002:新增 `gitea_workflow_runner_health_v1` schema 與 `docs/evaluations/gitea_workflow_runner_health_2026-06-05.json`,以 committed `.gitea/workflows/*`、`scripts/ci/*`、runner watchdog / stale-job dry-run guard 建立只讀健康合約。 +- P1-002:新增 `GET /api/v1/agents/gitea-workflow-runner-health` 只讀 API 與 service guard,強制拒絕把 snapshot 誤讀成 workflow 修改、runner restart / stop、Secret payload、Telegram 測試通知、排程變更、Gitea write token、deploy / migration workflow 觸發授權。 +- P1-002:治理頁 `/zh-TW/governance?tab=automation-inventory` 新增 Gitea 工作流程 / Runner 健康合約區塊;不新增任何執行、部署、通知、workflow 或 runner 操作按鈕。 +- 目前數字:Gitea workflows `9`;schedule `2`;workflow_dispatch `7`;notify bridge `6`;actionable / failure quiet workflows `2`;runner contracts `4`;runner contract action_required `1`;仍需 runner owner attestation 的 workflow `8`;notification contracts `6`;quiet success contracts `2`;automation backlog done `18/23`、overall `78%`、P1 `86%`;inventory tasks `28`。 +- 本地驗證:JSON parse 通過;Gitea workflow / runner health、automation inventory、automation backlog 目標 pytest `23 passed`;Python py_compile 通過;zh-TW / en i18n key 差異 `0`;web typecheck 通過;`source-control-owner-response-guard.py`、`security-mirror-progress-guard.py`、`git diff --check` 通過;本地 API readback 回 `gitea_workflow_runner_health_v1`、current `P1-002`、next `P1-003`、workflows `9`。 +- 本機 Web build / browser smoke:受本機磁碟 `99-100%` 與 `.next` cache / chunk 缺檔阻擋;編譯階段曾到 `Compiled successfully`,但 page-data / dev runtime 受殘留 chunk `194.js`、`@swc/helpers` 與 `server-development/0.pack.gz` 缺檔影響。正式 build 與頁面驗證改由 Gitea CD / production smoke 判定。 + +**下一步:** +1. 推 Gitea main,等待 code-review / CD 乾淨環境驗證,再補 production API readback 與 desktop / mobile smoke。 +2. P1-003:盤點 Prometheus / Alertmanager / SigNoz / Grafana 監控合約與降噪機會。 + +**裁決:** P1-002 只完成 read-only committed workflow / runner health contract。不得把 `ubuntu-latest` owner attestation 缺口、runner watchdog 草案、stale-job dry-run guard 或 notification contract 解讀成 workflow 修改、runner restart / stop、container stop、runner label change、runner registration、Secret payload collection、Telegram 測試通知、schedule enable、Gitea write、deploy / migration trigger 或任何 runtime execution 授權。