diff --git a/apps/api/src/api/v1/platform/operator_runs.py b/apps/api/src/api/v1/platform/operator_runs.py index a3fc412e..cbc48c55 100644 --- a/apps/api/src/api/v1/platform/operator_runs.py +++ b/apps/api/src/api/v1/platform/operator_runs.py @@ -231,6 +231,9 @@ class ApprovalItem(BaseModel): run_id: UUID project_id: str agent_id: str + trigger_type: str | None = None + trigger_ref: str | None = None + is_shadow: bool | None = None created_at: datetime timeout_at: datetime | None remediation_summary: dict[str, Any] | None = None diff --git a/apps/api/src/services/adr100_remediation_service.py b/apps/api/src/services/adr100_remediation_service.py index 70f22b74..9b709a48 100644 --- a/apps/api/src/services/adr100_remediation_service.py +++ b/apps/api/src/services/adr100_remediation_service.py @@ -12,11 +12,21 @@ from __future__ import annotations import asyncio import hashlib +import json from datetime import datetime, timedelta, timezone from typing import Any, Literal, Protocol +from uuid import NAMESPACE_URL, uuid5 import structlog +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from src.db.awooop_models import ( + AwoooPRunIdempotency, + AwoooPRunState, + AwoooPRunStepJournal, +) +from src.db.base import get_db_context from src.models.approval import ( ApprovalRequestCreate, BlastRadius, @@ -47,6 +57,9 @@ _TICKET_STATUSES = {"needs_playbook_ticket"} _TICKET_ACTIONS = {"create_playbook_ticket", "promote_diagnostic_to_repair_playbook"} _RUNTIME_REPLAY_STATUSES = {"ready_for_replay"} _RUNTIME_REPLAY_ACTIONS = {"replay_with_supported_executor"} +_AWOOOP_GATE5_TRIGGER_TYPE = "adr100_runtime_replay_gate5" +_AWOOOP_GATE5_CHANNEL_TYPE = "adr100_gate5_approval" +_AWOOOP_GATE5_PROJECT_ID = "awoooi" class RemediationNotFoundError(LookupError): @@ -70,6 +83,7 @@ class Adr100RemediationService: playbook_service: Any | None = None, verifier: PostExecutionVerifier | None = None, approval_service: Any | None = None, + awooop_approval_projector: Any | None = None, timeline_service: Any | None = None, alert_operation_log_repository: Any | None = None, record_history: bool = True, @@ -80,6 +94,7 @@ class Adr100RemediationService: self._playbook_service = playbook_service self._verifier = verifier or get_post_execution_verifier() self._approval_service = approval_service + self._awooop_approval_projector = awooop_approval_projector self._timeline_service = timeline_service self._alert_operation_log_repository = alert_operation_log_repository self._record_history_enabled = record_history @@ -225,6 +240,13 @@ class Adr100RemediationService: approval_created=approval_created, fingerprint=fingerprint, ) + if payload.get("approval_kind") == _AWOOOP_GATE5_TRIGGER_TYPE: + payload["awooop_projection"] = await self._project_awooop_gate5_approval( + item=item, + incident=incident, + request=approval_request, + payload=payload, + ) payload["history"] = await self._record_approval_history(item, payload) return payload @@ -612,6 +634,46 @@ class Adr100RemediationService: ) return history + async def _project_awooop_gate5_approval( + self, + *, + item: dict[str, Any], + incident: Incident, + request: ApprovalRequestCreate, + payload: dict[str, Any], + ) -> dict[str, Any]: + projector = self._awooop_approval_projector + if projector is not None: + return await projector.project_runtime_replay_approval( + item=item, + incident=incident, + request=request, + payload=payload, + ) + + try: + return await _project_runtime_replay_approval_to_awooop( + item=item, + incident=incident, + request=request, + payload=payload, + ) + except Exception as exc: + logger.warning( + "adr100_gate5_awooop_projection_failed", + incident_id=item.get("incident_id"), + approval_id=payload.get("approval_id"), + error=str(exc), + ) + return { + "schema_version": "adr100_runtime_replay_awooop_projection_v1", + "projected": False, + "projection_mode": "approval_projection_only", + "error": str(exc), + "execution_authorized": False, + "repair_executed": False, + } + async def _record_approval_history( self, item: dict[str, Any], @@ -1110,6 +1172,177 @@ def _approval_result_payload( } +async def _project_runtime_replay_approval_to_awooop( + *, + item: dict[str, Any], + incident: Incident, + request: ApprovalRequestCreate, + payload: dict[str, Any], +) -> dict[str, Any]: + approval_id = str(payload.get("approval_id") or "") + if not approval_id: + return { + "schema_version": "adr100_runtime_replay_awooop_projection_v1", + "projected": False, + "projection_mode": "approval_projection_only", + "reason": "missing_approval_id", + "execution_authorized": False, + "repair_executed": False, + } + + project_id = _AWOOOP_GATE5_PROJECT_ID + incident_id = str(item.get("incident_id") or incident.incident_id or "") + work_item_id = str(item.get("work_item_id") or "") + auto_repair_id = str(item.get("auto_repair_id") or "") + playbook_id = str(item.get("playbook_id") or "unknown_playbook") + run_id = uuid5( + NAMESPACE_URL, + f"awooop:{_AWOOOP_GATE5_TRIGGER_TYPE}:{project_id}:{approval_id}", + ) + trigger_ref = f"adr100_gate5:{incident_id}:{approval_id}"[:256] + provider_event_id = f"adr100_gate5:{approval_id}" + now = datetime.now(timezone.utc).replace(tzinfo=None) + timeout_at = now + timedelta(hours=6) + projection_input = { + "schema_version": "adr100_runtime_replay_awooop_projection_input_v1", + "approval_id": approval_id, + "approval_kind": payload.get("approval_kind"), + "incident_id": incident_id, + "work_item_id": work_item_id, + "auto_repair_id": auto_repair_id, + "playbook_id": playbook_id, + "replay_gate_status": (payload.get("replay_gate") or {}).get("status"), + "write_route_tools": [ + str(route.get("tool_name") or "") + for route in (request.metadata or {}).get("write_routes") or [] + if isinstance(route, dict) + ], + "execution_authorized": False, + "repair_executed": False, + "projection_mode": "approval_projection_only", + } + input_json = _stable_json(projection_input) + input_hash = hashlib.sha256(input_json.encode("utf-8")).hexdigest() + projection_metadata = { + "schema_version": "adr100_runtime_replay_awooop_projection_v1", + "approval_id": approval_id, + "legacy_approval_status": (payload.get("approval") or {}).get("status"), + "incident_id": incident_id, + "work_item_id": work_item_id, + "auto_repair_id": auto_repair_id, + "playbook_id": playbook_id, + "projection_mode": "approval_projection_only", + "execution_authorized": False, + "repair_attempted": False, + "repair_executed": False, + "required_handoff": "legacy_gate5_approval_to_auto_repair_executor", + } + + async with get_db_context(project_id) as db: + run_insert = ( + pg_insert(AwoooPRunState) + .values( + run_id=run_id, + project_id=project_id, + agent_id="auto_repair_executor", + state="waiting_approval", + attempt_count=0, + max_attempts=1, + trace_id=f"gate5:{approval_id}", + trigger_type=_AWOOOP_GATE5_TRIGGER_TYPE, + trigger_ref=trigger_ref, + is_shadow=True, + input_sha256=input_hash, + step_count=1, + error_code="E-ADR100-GATE5-PROJECTION", + error_detail=_stable_json(projection_metadata), + timeout_at=timeout_at, + ) + .on_conflict_do_nothing(index_elements=[AwoooPRunState.run_id]) + .returning(AwoooPRunState.run_id) + ) + run_result = await db.execute(run_insert) + inserted = run_result.scalar_one_or_none() is not None + + idempotency_insert = ( + pg_insert(AwoooPRunIdempotency) + .values( + project_id=project_id, + channel_type=_AWOOOP_GATE5_CHANNEL_TYPE, + provider_event_id=provider_event_id, + run_id=run_id, + ) + .on_conflict_do_nothing(constraint="uix_run_idempotency_key") + ) + await db.execute(idempotency_insert) + + step_insert = ( + pg_insert(AwoooPRunStepJournal) + .values( + run_id=run_id, + project_id=project_id, + step_seq=1, + tool_name="adr100.runtime_replay_gate5.waiting_approval", + input_hash=input_hash, + compensation_json=projection_metadata, + result_status="pending", + error_code="E-ADR100-GATE5-PROJECTION", + was_blocked=True, + block_reason="approval_projection_only", + ) + .on_conflict_do_nothing(constraint="uix_run_step_seq") + ) + await db.execute(step_insert) + + state_result = await db.execute( + select(AwoooPRunState.state, AwoooPRunState.timeout_at).where( + AwoooPRunState.run_id == run_id, + AwoooPRunState.project_id == project_id, + ) + ) + state_row = state_result.one_or_none() + + state = str(state_row.state) if state_row else "unknown" + projected = state == "waiting_approval" + return { + "schema_version": "adr100_runtime_replay_awooop_projection_v1", + "projected": projected, + "inserted": inserted, + "deduplicated": not inserted, + "projection_mode": "approval_projection_only", + "run_id": str(run_id), + "project_id": project_id, + "state": state, + "timeout_at": state_row.timeout_at.isoformat() if state_row and state_row.timeout_at else None, + "trigger_type": _AWOOOP_GATE5_TRIGGER_TYPE, + "trigger_ref": trigger_ref, + "channel_type": _AWOOOP_GATE5_CHANNEL_TYPE, + "provider_event_id": provider_event_id, + "decision_endpoint_enabled": False, + "execution_authorized": False, + "repair_attempted": False, + "repair_executed": False, + "required_handoff": "legacy_gate5_approval_to_auto_repair_executor", + "step_journal": { + "step_seq": 1, + "tool_name": "adr100.runtime_replay_gate5.waiting_approval", + "result_status": "pending", + "was_blocked": True, + "block_reason": "approval_projection_only", + }, + } + + +def _stable_json(value: dict[str, Any]) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + + def _summarize_post_state(post_state: dict[str, Any]) -> dict[str, Any]: keys = sorted(post_state.keys()) return { @@ -1207,6 +1440,7 @@ def _approval_history_context(item: dict[str, Any], payload: dict[str, Any]) -> "approval_kind": payload.get("approval_kind"), "ticket_preview": payload.get("ticket_preview"), "replay_gate": payload.get("replay_gate"), + "awooop_projection": payload.get("awooop_projection"), "approval": payload.get("approval"), "approval_id": payload.get("approval_id"), "plan": payload.get("plan"), @@ -1262,6 +1496,7 @@ def _history_item(record: Any, context: dict[str, Any]) -> dict[str, Any]: post_state = context.get("post_state_summary") or {} approval = context.get("approval") or {} replay_gate = context.get("replay_gate") or {} + awooop_projection = context.get("awooop_projection") or {} return { "id": str(getattr(record, "id", "")), "incident_id": getattr(record, "incident_id", None), @@ -1299,6 +1534,9 @@ def _history_item(record: Any, context: dict[str, Any]) -> dict[str, Any]: "replay_gate": replay_gate or None, "replay_gate_status": replay_gate.get("status"), "replay_gate_next_step": replay_gate.get("next_step"), + "awooop_projection": awooop_projection or None, + "awooop_projection_run_id": awooop_projection.get("run_id"), + "awooop_projection_state": awooop_projection.get("state"), "plan": context.get("plan"), "checks": context.get("checks") or [], } diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 13419a44..94f9bcf7 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -79,6 +79,7 @@ _MAX_STEP_SUMMARY_CHARS = 128 _AI_ROUTE_STATUS_SELECT_TIMEOUT_SECONDS = 12.0 _AI_ROUTE_STATUS_CONNECTIVITY_TIMEOUT_SECONDS = 2.5 _REMEDIATION_HISTORY_LIMIT = 20 +_ADR100_GATE5_PROJECTION_TRIGGER = "adr100_runtime_replay_gate5" _CALLBACK_REPLY_CACHE_TTL_SECONDS = int( os.getenv("AWOOOP_CALLBACK_REPLY_CACHE_TTL_SECONDS", "20") ) @@ -4657,6 +4658,9 @@ async def list_approvals( "run_id": r.run_id, "project_id": r.project_id, "agent_id": r.agent_id, + "trigger_type": r.trigger_type, + "trigger_ref": r.trigger_ref, + "is_shadow": r.is_shadow, "created_at": r.created_at, "timeout_at": r.timeout_at, "remediation_summary": summary, @@ -4700,10 +4704,52 @@ async def decide_approval( status_code=status.HTTP_409_CONFLICT, detail=f"run {run_id!r} 目前狀態為 {run.state!r},無法審核(需為 waiting_approval)", ) + is_projection_only_gate5 = run.trigger_type == _ADR100_GATE5_PROJECTION_TRIGGER approval_token_jti: str | None = None new_state: str + if is_projection_only_gate5: + await _record_approval_projection_guard_step( + run_id=run_uuid, + project_id=project_id, + decision=decision, + approver_id=approver_id, + reason=reason, + ) + try: + await write_audit( + project_id=project_id, + action=f"run.approval.{decision}.blocked", + resource_type="run", + resource_id=run_id, + details={ + "approver_id": approver_id, + "decision": decision, + "reason": reason, + "new_state": "waiting_approval", + "trigger_type": _ADR100_GATE5_PROJECTION_TRIGGER, + "block_reason": "adr100_runtime_replay_gate5_projection_only", + "execution_authorized": False, + "repair_executed": False, + }, + run_id=run_id, + ) + except Exception as exc: + logger.warning( + "approval_projection_guard_audit_write_failed", + run_id=run_id, + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "adr100_runtime_replay_gate5_projection_only: " + "此 AwoooP 簽核列只投影 legacy Gate 5 approval 與狀態鏈," + "尚未接上 auto_repair_executor 執行 handoff,不能直接由平台按鈕轉成 running。" + ), + ) + if decision == "approve": token = issue_approval_token( project_id=project_id, @@ -4789,6 +4835,67 @@ async def decide_approval( } +async def _record_approval_projection_guard_step( + *, + run_id: UUID, + project_id: str, + decision: str, + approver_id: str, + reason: str | None, +) -> None: + summary = _truncate_step_summary( + "projection_only_gate5; " + f"approver={approver_id}; decision={decision}; reason={reason or '-'}" + ) + try: + async with get_db_context(project_id) as db: + max_result = await db.execute( + select(func.coalesce(func.max(AwoooPRunStepJournal.step_seq), 0)).where( + AwoooPRunStepJournal.run_id == run_id, + AwoooPRunStepJournal.project_id == project_id, + ) + ) + step_seq = int(max_result.scalar_one()) + 1 + + db.add( + AwoooPRunStepJournal( + run_id=run_id, + project_id=project_id, + step_seq=step_seq, + tool_name="operator_console.approval_projection_guard", + result_status="failed", + error_code="E-ADR100-GATE5-PROJECTION", + was_blocked=True, + block_reason=summary, + completed_at=_utc_now_naive(), + ) + ) + await db.execute( + update(AwoooPRunState) + .where( + AwoooPRunState.run_id == run_id, + AwoooPRunState.project_id == project_id, + ) + .values(step_count=AwoooPRunState.step_count + 1) + ) + + logger.info( + "approval_projection_guard_step_recorded", + run_id=str(run_id), + project_id=project_id, + decision=decision, + approver_id=approver_id, + ) + except Exception as exc: + logger.warning( + "approval_projection_guard_step_record_failed", + run_id=str(run_id), + project_id=project_id, + decision=decision, + error=str(exc), + ) + + async def _record_approval_decision_step( *, run_id: UUID, diff --git a/apps/api/tests/test_adr100_remediation_service.py b/apps/api/tests/test_adr100_remediation_service.py index 2fde51b8..2ea4f376 100644 --- a/apps/api/tests/test_adr100_remediation_service.py +++ b/apps/api/tests/test_adr100_remediation_service.py @@ -146,6 +146,48 @@ class _FakeApprovalService: return self.existing +class _FakeAwoooPApprovalProjector: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def project_runtime_replay_approval( + self, + *, + item: dict[str, Any], + incident: Incident, + request: Any, + payload: dict[str, Any], + ) -> dict[str, Any]: + self.calls.append({ + "item": item, + "incident": incident, + "request": request, + "payload": payload, + }) + return { + "schema_version": "adr100_runtime_replay_awooop_projection_v1", + "projected": True, + "inserted": True, + "deduplicated": False, + "projection_mode": "approval_projection_only", + "run_id": "11111111-1111-5111-8111-111111111111", + "project_id": "awoooi", + "state": "waiting_approval", + "trigger_type": "adr100_runtime_replay_gate5", + "trigger_ref": "adr100_gate5:INC-20260514-TEST01:00000000-0000-0000-0000-00000000a100", + "decision_endpoint_enabled": False, + "execution_authorized": False, + "repair_attempted": False, + "repair_executed": False, + "step_journal": { + "step_seq": 1, + "result_status": "pending", + "was_blocked": True, + "block_reason": "approval_projection_only", + }, + } + + class _NoopPlaybookService: async def get_recommendations(self, *_args, **_kwargs): # noqa: ANN002, ANN003 return [] @@ -218,6 +260,7 @@ def _service( state: dict[str, Any] | None = None, playbook_service: Any | None = None, approval_service: Any | None = None, + awooop_approval_projector: Any | None = None, timeline_service: Any | None = None, alert_operation_log_repository: Any | None = None, record_history: bool = False, @@ -232,6 +275,7 @@ def _service( playbook_service=playbook_service, verifier=_FakeVerifier(state or {"k8s_get_pod_status": {"phase": "Running"}}), approval_service=approval_service, + awooop_approval_projector=awooop_approval_projector, timeline_service=timeline_service, alert_operation_log_repository=alert_operation_log_repository, record_history=record_history, @@ -411,10 +455,12 @@ async def test_create_approval_request_for_runtime_replay_creates_gate5_record_o alert_repo = _FakeAlertOperationLogRepository() timeline = _FakeTimelineService() approval_service = _FakeApprovalService() + awooop_projector = _FakeAwoooPApprovalProjector() svc = _service( item=_queue_item(), playbook_service=_FakePlaybookService(_runtime_replay_playbook()), approval_service=approval_service, + awooop_approval_projector=awooop_projector, timeline_service=timeline, alert_operation_log_repository=alert_repo, record_history=True, @@ -432,8 +478,17 @@ async def test_create_approval_request_for_runtime_replay_creates_gate5_record_o assert result["writes_auto_repair_result"] is False assert result["replay_gate"]["status"] == "runtime_replay_ready" assert result["replay_gate"]["repair_executed"] is False + assert result["awooop_projection"]["projected"] is True + assert result["awooop_projection"]["projection_mode"] == "approval_projection_only" + assert result["awooop_projection"]["state"] == "waiting_approval" + assert result["awooop_projection"]["decision_endpoint_enabled"] is False + assert result["awooop_projection"]["execution_authorized"] is False + assert result["awooop_projection"]["repair_executed"] is False assert result["approval"]["status"] == "pending" assert result["plan"]["step"] == "request_runtime_replay_gate5_approval" + assert awooop_projector.calls[0]["payload"]["approval_kind"] == ( + "adr100_runtime_replay_gate5" + ) request = approval_service.requests[0] assert request.action.startswith("RUNTIME_REPLAY_GATE5:") assert request.blast_radius.data_impact == DataImpact.WRITE @@ -452,6 +507,7 @@ async def test_create_approval_request_for_runtime_replay_creates_gate5_record_o ) assert alert_repo.calls[0]["context"]["approval_kind"] == "adr100_runtime_replay_gate5" assert alert_repo.calls[0]["context"]["replay_gate"]["status"] == "runtime_replay_ready" + assert alert_repo.calls[0]["context"]["awooop_projection"]["state"] == "waiting_approval" assert timeline.calls[0]["title"] == "ADR-100 runtime replay Gate 5 approval requested" @@ -459,10 +515,12 @@ async def test_create_approval_request_for_runtime_replay_creates_gate5_record_o async def test_create_approval_request_blocks_runtime_replay_when_gate_not_ready(): approval_service = _FakeApprovalService() alert_repo = _FakeAlertOperationLogRepository() + awooop_projector = _FakeAwoooPApprovalProjector() svc = _service( item=_queue_item(), playbook_service=_FakePlaybookService(None), approval_service=approval_service, + awooop_approval_projector=awooop_projector, alert_operation_log_repository=alert_repo, record_history=True, ) @@ -475,6 +533,7 @@ async def test_create_approval_request_blocks_runtime_replay_when_gate_not_ready assert result["verification_result_preview"] == "runtime_replay_gate_blocked" assert result["replay_gate"]["status"] == "blocked_playbook_not_found" assert approval_service.requests == [] + assert awooop_projector.calls == [] assert alert_repo.calls[0]["event_type"] == "PRE_FLIGHT_FAILED" assert alert_repo.calls[0]["context"]["replay_gate"]["status"] == ( "blocked_playbook_not_found" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b8320e29..0ef1bdd6 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -4556,7 +4556,9 @@ "expiredDetail": "不得再自動恢復" }, "badges": { - "humanGate": "人工閘門" + "humanGate": "人工閘門", + "gate5Projection": "Gate 5 投影", + "executorHandoffPending": "等待 executor handoff" }, "columns": { "runId": "執行 ID", @@ -5018,6 +5020,12 @@ "title": "此執行目前不在人工審批狀態", "detail": "目前狀態為 {state}。此頁不會顯示 approve / reject,請回執行時間線檢查最新狀態。" }, + "gate5Projection": { + "title": "這是 Gate 5 投影,不是可直接執行的 AwoooP 審批", + "detail": "此 Run 只把 legacy Gate 5 approval、事件與狀態鏈投影到 AwoooP,方便追蹤流程位置;auto_repair_executor 的批准後執行 handoff 尚未接上,所以此頁不提供 approve / reject。", + "boundary": "execution_authorized=false / repair_executed=false / approval_projection_only", + "actionBlocked": "此 Gate 5 投影尚未接上 auto_repair_executor handoff,不能由平台按鈕直接核准或拒絕。" + }, "remediation": { "title": "補救試跑證據", "empty": "此執行尚未連到補救試跑歷史;核准前仍需回執行時間線檢查來源卷宗與 MCP 閘道。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index b8320e29..0ef1bdd6 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -4556,7 +4556,9 @@ "expiredDetail": "不得再自動恢復" }, "badges": { - "humanGate": "人工閘門" + "humanGate": "人工閘門", + "gate5Projection": "Gate 5 投影", + "executorHandoffPending": "等待 executor handoff" }, "columns": { "runId": "執行 ID", @@ -5018,6 +5020,12 @@ "title": "此執行目前不在人工審批狀態", "detail": "目前狀態為 {state}。此頁不會顯示 approve / reject,請回執行時間線檢查最新狀態。" }, + "gate5Projection": { + "title": "這是 Gate 5 投影,不是可直接執行的 AwoooP 審批", + "detail": "此 Run 只把 legacy Gate 5 approval、事件與狀態鏈投影到 AwoooP,方便追蹤流程位置;auto_repair_executor 的批准後執行 handoff 尚未接上,所以此頁不提供 approve / reject。", + "boundary": "execution_authorized=false / repair_executed=false / approval_projection_only", + "actionBlocked": "此 Gate 5 投影尚未接上 auto_repair_executor handoff,不能由平台按鈕直接核准或拒絕。" + }, "remediation": { "title": "補救試跑證據", "empty": "此執行尚未連到補救試跑歷史;核准前仍需回執行時間線檢查來源卷宗與 MCP 閘道。", diff --git a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx index 804e1063..ba3827c0 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx @@ -37,6 +37,7 @@ interface RunDetail { trace_id?: string | null; trigger_type?: string | null; trigger_ref?: string | null; + is_shadow?: boolean | null; cost_usd?: number | string; attempt_count?: number; max_attempts?: number; @@ -78,6 +79,7 @@ interface RunDetailResponse { } const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; +const ADR100_GATE5_PROJECTION_TRIGGER = "adr100_runtime_replay_gate5"; const ownerResponseValidationDecisionRefs: OwnerResponseValidationDecisionRef[] = [ { @@ -495,6 +497,11 @@ export default function ApprovalDecisionPage({ setDialogDecision(null); return; } + if (detail.run.trigger_type === ADR100_GATE5_PROJECTION_TRIGGER) { + setActionError(t("gate5Projection.actionBlocked")); + setDialogDecision(null); + return; + } setActionLoading(true); setActionError(null); try { @@ -507,7 +514,18 @@ export default function ApprovalDecisionPage({ reason: reason ?? null, }), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const body = await res.json(); + if (typeof body?.detail === "string" && body.detail) { + message = body.detail; + } + } catch { + // Keep the HTTP status when the API did not return a JSON error body. + } + throw new Error(message); + } setActionSuccess(t(`success.${decision}` as never)); setDialogDecision(null); setTimeout(() => router.push(timelineHref), 1200); @@ -522,6 +540,7 @@ export default function ApprovalDecisionPage({ const run = detail?.run; const latestRemediation = detail?.remediation_history?.items?.[0] ?? null; const isWaitingApproval = run?.state === "waiting_approval"; + const isGate5Projection = run?.trigger_type === ADR100_GATE5_PROJECTION_TRIGGER; const stateClass = STATE_STYLE[run?.state ?? ""] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]"; return ( @@ -614,6 +633,19 @@ export default function ApprovalDecisionPage({ )} + {run && isGate5Projection && ( +
+
+ )} + - {!loading && run && isWaitingApproval && !actionSuccess && ( + {!loading && run && isWaitingApproval && !isGate5Projection && !actionSuccess && (