diff --git a/apps/api/src/services/ai_agent_automation_backlog_snapshot.py b/apps/api/src/services/ai_agent_automation_backlog_snapshot.py index f6b72000c..4d04971f4 100644 --- a/apps/api/src/services/ai_agent_automation_backlog_snapshot.py +++ b/apps/api/src/services/ai_agent_automation_backlog_snapshot.py @@ -38,6 +38,8 @@ def load_latest_ai_agent_automation_backlog_snapshot( _require_schema(payload, _SCHEMA_VERSION, str(latest)) _require_read_only_boundaries(payload, str(latest)) _require_rollup_consistency(payload, str(latest)) + _require_item_approval_boundaries(payload, str(latest)) + _require_progress_summary_consistency(payload, str(latest)) return payload @@ -67,6 +69,159 @@ def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None: def _require_rollup_consistency(payload: dict[str, Any], label: str) -> None: items = payload.get("backlog_items") or [] - total = (payload.get("rollups") or {}).get("total_items") + rollups = payload.get("rollups") or {} + total = rollups.get("total_items") if total != len(items): raise ValueError(f"{label}: rollups.total_items must equal backlog_items length") + + expected_by_priority = _count_by(items, "priority") + if rollups.get("by_priority") != expected_by_priority: + raise ValueError(f"{label}: rollups.by_priority must match backlog_items") + + expected_by_status = _count_by(items, "status") + if rollups.get("by_status") != expected_by_status: + raise ValueError(f"{label}: rollups.by_status must match backlog_items") + + expected_by_gate = _count_by(items, "gate_status") + if rollups.get("by_gate_status") != expected_by_gate: + raise ValueError(f"{label}: rollups.by_gate_status must match backlog_items") + + expected_by_owner = _count_by(items, "owner_agent") + if rollups.get("by_owner_agent") != expected_by_owner: + raise ValueError(f"{label}: rollups.by_owner_agent must match backlog_items") + + +def _require_item_approval_boundaries(payload: dict[str, Any], label: str) -> None: + items = payload.get("backlog_items") or [] + missing = sorted(item.get("item_id") for item in items if not item.get("approval_boundary")) + if missing: + raise ValueError(f"{label}: backlog_items must include approval_boundary: {missing}") + + mismatched_modes = sorted( + item.get("item_id") + for item in items + if (item.get("approval_boundary") or {}).get("mode") != item.get("gate_status") + ) + if mismatched_modes: + raise ValueError(f"{label}: approval_boundary.mode must match gate_status: {mismatched_modes}") + + missing_blocked_actions = sorted( + item.get("item_id") + for item in items + if not (item.get("approval_boundary") or {}).get("blocked_actions") + ) + if missing_blocked_actions: + raise ValueError(f"{label}: approval_boundary.blocked_actions must be non-empty: {missing_blocked_actions}") + + rollup = payload.get("item_approval_boundary_rollup") or {} + if rollup.get("total_items") != len(items): + raise ValueError(f"{label}: item_approval_boundary_rollup.total_items must match backlog_items") + + by_mode: dict[str, int] = {} + for item in items: + mode = (item.get("approval_boundary") or {}).get("mode") + by_mode[mode] = by_mode.get(mode, 0) + 1 + if rollup.get("by_mode") != by_mode: + raise ValueError(f"{label}: item_approval_boundary_rollup.by_mode must match backlog_items") + + explicit_approval = sorted( + item.get("item_id") + for item in items + if (item.get("approval_boundary") or {}).get("mode") != "read_only_allowed" + ) + if sorted(rollup.get("items_requiring_explicit_approval") or []) != explicit_approval: + raise ValueError( + f"{label}: item_approval_boundary_rollup.items_requiring_explicit_approval must match backlog_items" + ) + + with_blocked_operations = sorted( + item.get("item_id") + for item in items + if (item.get("approval_boundary") or {}).get("blocked_actions") + ) + if sorted(rollup.get("items_with_blocked_operations") or []) != with_blocked_operations: + raise ValueError( + f"{label}: item_approval_boundary_rollup.items_with_blocked_operations must match backlog_items" + ) + + +def _require_progress_summary_consistency(payload: dict[str, Any], label: str) -> None: + items = payload.get("backlog_items") or [] + summary = payload.get("progress_summary") or {} + done_items = sum(1 for item in items if item.get("status") == "done") + planned_items = sum(1 for item in items if item.get("status") == "planned") + total_items = len(items) + expected_percent = _percent(done_items, total_items) + + if summary.get("total_items") != total_items: + raise ValueError(f"{label}: progress_summary.total_items must match backlog_items") + if summary.get("done_items") != done_items: + raise ValueError(f"{label}: progress_summary.done_items must match backlog_items") + if summary.get("planned_items") != planned_items: + raise ValueError(f"{label}: progress_summary.planned_items must match backlog_items") + if summary.get("overall_percent") != expected_percent: + raise ValueError(f"{label}: progress_summary.overall_percent must match deterministic formula") + + expected_priority_progress = { + priority: { + "done_items": sum(1 for item in group if item.get("status") == "done"), + "total_items": len(group), + } + for priority, group in _group_by(items, "priority").items() + } + actual_priority_progress = { + row.get("priority"): { + "done_items": row.get("done_items"), + "total_items": row.get("total_items"), + "completion_percent": row.get("completion_percent"), + } + for row in summary.get("by_priority") or [] + } + for priority, expected in expected_priority_progress.items(): + actual = actual_priority_progress.get(priority) + expected_completion = _percent(expected["done_items"], expected["total_items"]) + if actual != {**expected, "completion_percent": expected_completion}: + raise ValueError(f"{label}: progress_summary.by_priority must match backlog_items") + + expected_workstream_progress = { + workstream_id: { + "done_items": sum(1 for item in group if item.get("status") == "done"), + "total_items": len(group), + } + for workstream_id, group in _group_by(items, "workstream_id").items() + } + actual_workstream_progress = { + row.get("workstream_id"): { + "done_items": row.get("done_items"), + "total_items": row.get("total_items"), + "completion_percent": row.get("completion_percent"), + } + for row in summary.get("by_workstream") or [] + } + for workstream_id, expected in expected_workstream_progress.items(): + actual = actual_workstream_progress.get(workstream_id) + expected_completion = _percent(expected["done_items"], expected["total_items"]) + if actual != {**expected, "completion_percent": expected_completion}: + raise ValueError(f"{label}: progress_summary.by_workstream must match backlog_items") + + +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 _group_by(items: list[dict[str, Any]], key: str) -> dict[str, list[dict[str, Any]]]: + groups: dict[str, list[dict[str, Any]]] = {} + for item in items: + value = item.get(key) + groups.setdefault(value, []).append(item) + return groups + + +def _percent(done: int, total: int) -> int: + if total == 0: + return 0 + return round((done / total) * 100) diff --git a/apps/api/src/services/ai_agent_automation_inventory_snapshot.py b/apps/api/src/services/ai_agent_automation_inventory_snapshot.py index 0e78d7870..8263b2a43 100644 --- a/apps/api/src/services/ai_agent_automation_inventory_snapshot.py +++ b/apps/api/src/services/ai_agent_automation_inventory_snapshot.py @@ -36,6 +36,7 @@ def load_latest_ai_agent_automation_inventory_snapshot( raise ValueError(f"{latest}: expected JSON object") _require_schema(payload, _SCHEMA_VERSION, str(latest)) _require_read_only_boundaries(payload, str(latest)) + _require_task_approval_boundaries(payload, str(latest)) return payload @@ -61,3 +62,57 @@ def _require_read_only_boundaries(payload: dict[str, Any], label: str) -> None: allowed = sorted(flag for flag in blocked_flags if boundaries.get(flag) is not False) if allowed: raise ValueError(f"{label}: approval boundaries must remain false: {allowed}") + + +def _require_task_approval_boundaries(payload: dict[str, Any], label: str) -> None: + tasks = payload.get("tasks") or [] + missing = sorted(task.get("task_id") for task in tasks if not task.get("approval_boundary")) + if missing: + raise ValueError(f"{label}: tasks must include approval_boundary: {missing}") + + mismatched_modes = sorted( + task.get("task_id") + for task in tasks + if (task.get("approval_boundary") or {}).get("mode") != task.get("gate_status") + ) + if mismatched_modes: + raise ValueError(f"{label}: approval_boundary.mode must match gate_status: {mismatched_modes}") + + missing_blocked_actions = sorted( + task.get("task_id") + for task in tasks + if not (task.get("approval_boundary") or {}).get("blocked_actions") + ) + if missing_blocked_actions: + raise ValueError(f"{label}: approval_boundary.blocked_actions must be non-empty: {missing_blocked_actions}") + + rollup = payload.get("task_approval_boundary_rollup") or {} + if rollup.get("total_tasks") != len(tasks): + raise ValueError(f"{label}: task_approval_boundary_rollup.total_tasks must match tasks") + + by_mode: dict[str, int] = {} + for task in tasks: + mode = (task.get("approval_boundary") or {}).get("mode") + by_mode[mode] = by_mode.get(mode, 0) + 1 + if rollup.get("by_mode") != by_mode: + raise ValueError(f"{label}: task_approval_boundary_rollup.by_mode must match tasks") + + explicit_approval = sorted( + task.get("task_id") + for task in tasks + if (task.get("approval_boundary") or {}).get("mode") != "read_only_allowed" + ) + if sorted(rollup.get("tasks_requiring_explicit_approval") or []) != explicit_approval: + raise ValueError( + f"{label}: task_approval_boundary_rollup.tasks_requiring_explicit_approval must match tasks" + ) + + with_blocked_operations = sorted( + task.get("task_id") + for task in tasks + if (task.get("approval_boundary") or {}).get("blocked_actions") + ) + if sorted(rollup.get("tasks_with_blocked_operations") or []) != with_blocked_operations: + raise ValueError( + f"{label}: task_approval_boundary_rollup.tasks_with_blocked_operations must match tasks" + ) diff --git a/apps/api/tests/test_ai_agent_automation_backlog_snapshot.py b/apps/api/tests/test_ai_agent_automation_backlog_snapshot.py index 0e8711e7d..51cf1ad06 100644 --- a/apps/api/tests/test_ai_agent_automation_backlog_snapshot.py +++ b/apps/api/tests/test_ai_agent_automation_backlog_snapshot.py @@ -26,6 +26,8 @@ def test_load_latest_backlog_snapshot_reads_newest_file(tmp_path): assert loaded["generated_at"] == "2026-06-04T00:00:00+08:00" assert loaded["program_status"]["overall_completion_percent"] == 76 assert loaded["rollups"]["total_items"] == 1 + assert loaded["progress_summary"]["overall_percent"] == 0 + assert loaded["item_approval_boundary_rollup"]["total_items"] == 1 assert loaded["approval_boundaries"]["sdk_installation_allowed"] is False @@ -65,6 +67,30 @@ def test_load_backlog_snapshot_requires_total_rollup_consistency(tmp_path): load_latest_ai_agent_automation_backlog_snapshot(tmp_path) +def test_load_backlog_snapshot_requires_item_approval_boundaries(tmp_path): + snapshot = _snapshot() + del snapshot["backlog_items"][0]["approval_boundary"] + (tmp_path / "ai_agent_automation_backlog_2026-06-04.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="approval_boundary"): + load_latest_ai_agent_automation_backlog_snapshot(tmp_path) + + +def test_load_backlog_snapshot_requires_progress_summary_consistency(tmp_path): + snapshot = _snapshot() + snapshot["progress_summary"]["overall_percent"] = 99 + (tmp_path / "ai_agent_automation_backlog_2026-06-04.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="overall_percent"): + load_latest_ai_agent_automation_backlog_snapshot(tmp_path) + + def test_load_backlog_snapshot_fails_when_missing(tmp_path): with pytest.raises(FileNotFoundError): load_latest_ai_agent_automation_backlog_snapshot(tmp_path) @@ -93,6 +119,31 @@ def _snapshot( "by_gate_status": {"read_only_allowed": 1}, "by_owner_agent": {"hermes": 1}, }, + "progress_summary": { + "overall_percent": 0, + "done_items": 0, + "planned_items": 1, + "total_items": 1, + "formula": "round(done_items / total_items * 100),status=done 才計入完成。", + "by_priority": [ + { + "priority": "P1", + "completion_percent": 0, + "done_items": 0, + "total_items": 1, + } + ], + "by_workstream": [ + { + "workstream_id": "WS2", + "display_name": "自動化待辦", + "completion_percent": 0, + "done_items": 0, + "total_items": 1, + "next_task_id": "P1-303", + } + ], + }, "backlog_items": [ { "item_id": "AUTO-P1-303", @@ -109,9 +160,22 @@ def _snapshot( "risk_level": "medium", "evidence_refs": ["docs/schemas/ai_agent_automation_backlog_v1.schema.json"], "acceptance_criteria": ["API 只讀"], + "approval_boundary": { + "mode": "read_only_allowed", + "display_summary": "只讀呈現 committed snapshot,不授權任何寫入。", + "allowed_actions": ["讀取 committed snapshot", "顯示治理 UI"], + "blocked_actions": ["production_write", "paid_api_call", "destructive_operation"], + "requires_operator_approval_for": ["任何非只讀操作"], + }, "next_review": "P1-303", } ], + "item_approval_boundary_rollup": { + "total_items": 1, + "by_mode": {"read_only_allowed": 1}, + "items_requiring_explicit_approval": [], + "items_with_blocked_operations": ["AUTO-P1-303"], + }, "approval_boundaries": { "sdk_installation_allowed": False, "paid_api_call_allowed": False, 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 17e26da9d..8b625e38e 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,14 +16,23 @@ 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"] == 100 + assert data["program_status"]["overall_completion_percent"] == 70 assert data["program_status"]["read_only_mode"] is True - assert data["program_status"]["current_task_id"] == "P1-106" - assert data["program_status"]["next_task_id"] == "P1-305" - assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 21 - assert data["rollups"]["by_priority"]["P1"] == 19 - assert data["rollups"]["by_status"]["done"] == 14 - assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 18 + assert data["program_status"]["current_task_id"] == "P1-306" + assert data["program_status"]["next_task_id"] == "P1-001" + assert data["rollups"]["total_items"] == len(data["backlog_items"]) == 23 + assert data["rollups"]["by_priority"]["P1"] == 21 + assert data["rollups"]["by_status"]["done"] == 16 + assert data["rollups"]["by_gate_status"]["read_only_allowed"] == 20 + assert data["progress_summary"]["overall_percent"] == 70 + assert data["progress_summary"]["done_items"] == 16 + 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"] == [ + "AUTO-P1-004", + "AUTO-P2-004", + "AUTO-P3-001", + ] assert data["approval_boundaries"]["sdk_installation_allowed"] is False assert data["approval_boundaries"]["paid_api_call_allowed"] is False assert data["approval_boundaries"]["production_routing_allowed"] is False @@ -34,4 +43,8 @@ def test_ai_agent_automation_backlog_snapshot_endpoint_returns_committed_snapsho assert any(item["item_id"] == "AUTO-P1-104" for item in data["backlog_items"]) assert any(item["item_id"] == "AUTO-P1-105" for item in data["backlog_items"]) assert any(item["item_id"] == "AUTO-P1-106" for item in data["backlog_items"]) + assert any(item["item_id"] == "AUTO-P1-305" for item in data["backlog_items"]) + 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"] assert any(item["item_id"] == "AUTO-P3-001" for item in data["backlog_items"]) diff --git a/apps/api/tests/test_ai_agent_automation_inventory_snapshot.py b/apps/api/tests/test_ai_agent_automation_inventory_snapshot.py index 57152614f..23ddb763c 100644 --- a/apps/api/tests/test_ai_agent_automation_inventory_snapshot.py +++ b/apps/api/tests/test_ai_agent_automation_inventory_snapshot.py @@ -26,6 +26,7 @@ def test_load_latest_inventory_snapshot_reads_newest_file(tmp_path): assert loaded["generated_at"] == "2026-06-04T00:00:00+08:00" assert loaded["program_status"]["overall_completion_percent"] == 53 assert loaded["approval_boundaries"]["paid_api_call_allowed"] is False + assert loaded["task_approval_boundary_rollup"]["total_tasks"] == 1 def test_load_inventory_snapshot_requires_read_only_mode(tmp_path): @@ -52,6 +53,30 @@ def test_load_inventory_snapshot_requires_blocked_approval_boundaries(tmp_path): load_latest_ai_agent_automation_inventory_snapshot(tmp_path) +def test_load_inventory_snapshot_requires_task_approval_boundaries(tmp_path): + snapshot = _snapshot() + del snapshot["tasks"][0]["approval_boundary"] + (tmp_path / "ai_agent_automation_inventory_snapshot_2026-06-04.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="approval_boundary"): + load_latest_ai_agent_automation_inventory_snapshot(tmp_path) + + +def test_load_inventory_snapshot_requires_task_boundary_rollup_consistency(tmp_path): + snapshot = _snapshot() + snapshot["task_approval_boundary_rollup"]["by_mode"]["approval_required"] = 1 + (tmp_path / "ai_agent_automation_inventory_snapshot_2026-06-04.json").write_text( + json.dumps(snapshot), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="by_mode"): + load_latest_ai_agent_automation_inventory_snapshot(tmp_path) + + def test_load_inventory_snapshot_fails_when_missing(tmp_path): with pytest.raises(FileNotFoundError): load_latest_ai_agent_automation_inventory_snapshot(tmp_path) @@ -126,9 +151,22 @@ def _snapshot( "title": "建立靜態盤點種子", "output": "seed", "gate_status": "read_only_allowed", + "approval_boundary": { + "mode": "read_only_allowed", + "display_summary": "只讀呈現 committed snapshot,不授權任何寫入。", + "allowed_actions": ["讀取 committed snapshot", "顯示治理 UI"], + "blocked_actions": ["production_write", "paid_api_call", "destructive_operation"], + "requires_operator_approval_for": ["任何非只讀操作"], + }, "next_action": "建立只讀 API。", } ], + "task_approval_boundary_rollup": { + "total_tasks": 1, + "by_mode": {"read_only_allowed": 1}, + "tasks_requiring_explicit_approval": [], + "tasks_with_blocked_operations": ["P0-005"], + }, "evidence": [ { "evidence_id": "seed", 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 26aa1608a..6f8aa959a 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,8 +18,14 @@ 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-106" - assert data["program_status"]["next_task_id"] == "P1-305" + assert data["program_status"]["current_task_id"] == "P1-306" + assert data["program_status"]["next_task_id"] == "P1-001" + assert data["task_approval_boundary_rollup"]["total_tasks"] == len(data["tasks"]) == 26 + assert data["task_approval_boundary_rollup"]["by_mode"]["read_only_allowed"] == 24 + assert data["task_approval_boundary_rollup"]["tasks_requiring_explicit_approval"] == [ + "P0-001", + "P0-004", + ] assert data["approval_boundaries"]["sdk_installation_allowed"] is False assert data["approval_boundaries"]["paid_api_call_allowed"] is False assert data["approval_boundaries"]["production_routing_allowed"] is False @@ -31,6 +37,12 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert any(task["task_id"] == "P1-104" for task in data["tasks"]) assert any(task["task_id"] == "P1-105" for task in data["tasks"]) assert any(task["task_id"] == "P1-106" for task in data["tasks"]) + p1_305 = next(task for task in data["tasks"] if task["task_id"] == "P1-305") + assert p1_305["approval_boundary"]["mode"] == "read_only_allowed" + assert "production_write" in p1_305["approval_boundary"]["blocked_actions"] + p1_306 = next(task for task in data["tasks"] if task["task_id"] == "P1-306") + assert p1_306["approval_boundary"]["mode"] == "read_only_allowed" + assert "runtime_execution" in p1_306["approval_boundary"]["blocked_actions"] assert any(evidence["evidence_id"] == "dependency_risk_policy_api" for evidence in data["evidence"]) assert any(evidence["evidence_id"] == "dependency_drift_check_plan_api" for evidence in data["evidence"]) assert any( @@ -44,3 +56,5 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps for evidence in data["evidence"] ) assert any(evidence["evidence_id"] == "offsite_escrow_readiness_status_api" for evidence in data["evidence"]) + 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"]) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b4f5b6de5..dd0540fbf 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -2958,7 +2958,10 @@ "backlog": "待辦數", "p1Backlog": "P1 待辦", "blocked": "阻擋資產", - "critical": "高風險資產" + "critical": "高風險資產", + "backlogProgress": "待辦進度", + "taskBoundaries": "任務邊界", + "explicitApprovalTasks": "需明確批准" }, "workstreams": { "title": "工作流進度" @@ -2970,6 +2973,17 @@ "assets": { "title": "資產領域" }, + "backlogProgress": { + "title": "進度彙總", + "priorityTitle": "Priority 完成度", + "doneLabel": "完成", + "metrics": { + "overall": "整體", + "done": "完成項", + "planned": "待辦項", + "requiresApproval": "需批准" + } + }, "tasks": { "title": "任務 {done}/{total}", "statuses": { @@ -2980,6 +2994,33 @@ "done": "完成", "deferred": "延後", "rejected": "否決" + }, + "boundaryLabels": { + "mode": "模式", + "allowed": "允許", + "blocked": "禁止", + "requiresApproval": "需批准" + }, + "boundaryActions": { + "production_write": "生產寫入", + "paid_api_call": "付費 API", + "shadow_or_canary": "Shadow / Canary", + "destructive_operation": "破壞性操作", + "runtime_execution": "執行期操作", + "auto_merge": "自動合併", + "auto_approval": "自動批准", + "secret_plaintext_collection": "收集 secret 明文", + "unapproved_deploy": "未批准部署", + "unapproved_external_call": "未批准外部呼叫", + "provider_switch": "Provider 切換", + "production_routing_change": "生產路由變更", + "shadow_or_canary_entry": "進入 Shadow / Canary", + "call_frequency_increase": "提高呼叫頻率", + "provider_replacement": "Provider 取代", + "unclassified_operation": "未分類操作", + "runtime_gate_increase": "提高 runtime gate", + "owner_response_auto_accept": "自動採納 owner response", + "approval_auto_sign": "自動簽核" } }, "backupEvidence": { @@ -3025,7 +3066,13 @@ "create_action_required": "建立待處置", "missing_markers": "缺 marker", "redacted_only": "僅脫敏", - "read_only_allowed": "只讀允許" + "read_only_allowed": "只讀允許", + "ready_for_operator_review": "待人工審查", + "dry_run_required": "需 dry-run", + "cost_approval_required": "費用需批准", + "dependency_approval_required": "依賴需批准", + "production_change_blocked": "生產變更阻擋", + "shadow_canary_blocked": "Shadow / Canary 阻擋" } }, "offsiteEscrow": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index b4f5b6de5..dd0540fbf 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -2958,7 +2958,10 @@ "backlog": "待辦數", "p1Backlog": "P1 待辦", "blocked": "阻擋資產", - "critical": "高風險資產" + "critical": "高風險資產", + "backlogProgress": "待辦進度", + "taskBoundaries": "任務邊界", + "explicitApprovalTasks": "需明確批准" }, "workstreams": { "title": "工作流進度" @@ -2970,6 +2973,17 @@ "assets": { "title": "資產領域" }, + "backlogProgress": { + "title": "進度彙總", + "priorityTitle": "Priority 完成度", + "doneLabel": "完成", + "metrics": { + "overall": "整體", + "done": "完成項", + "planned": "待辦項", + "requiresApproval": "需批准" + } + }, "tasks": { "title": "任務 {done}/{total}", "statuses": { @@ -2980,6 +2994,33 @@ "done": "完成", "deferred": "延後", "rejected": "否決" + }, + "boundaryLabels": { + "mode": "模式", + "allowed": "允許", + "blocked": "禁止", + "requiresApproval": "需批准" + }, + "boundaryActions": { + "production_write": "生產寫入", + "paid_api_call": "付費 API", + "shadow_or_canary": "Shadow / Canary", + "destructive_operation": "破壞性操作", + "runtime_execution": "執行期操作", + "auto_merge": "自動合併", + "auto_approval": "自動批准", + "secret_plaintext_collection": "收集 secret 明文", + "unapproved_deploy": "未批准部署", + "unapproved_external_call": "未批准外部呼叫", + "provider_switch": "Provider 切換", + "production_routing_change": "生產路由變更", + "shadow_or_canary_entry": "進入 Shadow / Canary", + "call_frequency_increase": "提高呼叫頻率", + "provider_replacement": "Provider 取代", + "unclassified_operation": "未分類操作", + "runtime_gate_increase": "提高 runtime gate", + "owner_response_auto_accept": "自動採納 owner response", + "approval_auto_sign": "自動簽核" } }, "backupEvidence": { @@ -3025,7 +3066,13 @@ "create_action_required": "建立待處置", "missing_markers": "缺 marker", "redacted_only": "僅脫敏", - "read_only_allowed": "只讀允許" + "read_only_allowed": "只讀允許", + "ready_for_operator_review": "待人工審查", + "dry_run_required": "需 dry-run", + "cost_approval_required": "費用需批准", + "dependency_approval_required": "依賴需批准", + "production_change_blocked": "生產變更阻擋", + "shadow_canary_blocked": "Shadow / Canary 阻擋" } }, "offsiteEscrow": { 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 ebd85a812..efbe71ad2 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 @@ -357,6 +357,10 @@ export function AutomationInventoryTab() { const actionRequiredOffsiteCards = offsiteEscrow.rollups.by_readiness.action_required ?? 0 const blockedEscrowCards = offsiteEscrow.rollups.by_readiness.blocked ?? 0 const executionBlockedCards = offsiteEscrow.rollups.execution_blocked_card_ids.length + 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 + const explicitApprovalTaskCount = snapshot.task_approval_boundary_rollup.tasks_requiring_explicit_approval.length const blockedApprovals = Object.entries(snapshot.approval_boundaries) .filter(([, allowed]) => allowed === false) .map(([key]) => key) @@ -377,6 +381,14 @@ export function AutomationInventoryTab() { } } + const boundaryActionLabel = (value: string) => { + try { + return t(`tasks.boundaryActions.${value}` as never) + } catch { + return value + } + } + return (