feat(adr100): project gate5 approvals into awooop
All checks were successful
CD Pipeline / tests (push) Successful in 1m35s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 4m2s
CD Pipeline / post-deploy-checks (push) Successful in 1m42s

This commit is contained in:
Your Name
2026-06-02 11:21:04 +08:00
parent a1235581ef
commit 17ba879ac6
8 changed files with 480 additions and 7 deletions

View File

@@ -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

View File

@@ -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 [],
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 閘道。",

View File

@@ -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 閘道。",

View File

@@ -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({
</div>
)}
{run && isGate5Projection && (
<div className="flex items-start gap-3 border border-[#9bb6d9] bg-[#eef5ff] p-4 text-[#1f5b9b]">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<div>
<p className="text-sm font-semibold">{t("gate5Projection.title")}</p>
<p className="mt-1 text-xs leading-5">{t("gate5Projection.detail")}</p>
<p className="mt-2 font-mono text-xs text-[#47709a]">
{t("gate5Projection.boundary")}
</p>
</div>
</div>
)}
<IncidentEvidenceHeader
projectId={run?.project_id || projectId || "awoooi"}
runId={run_id}
@@ -701,7 +733,7 @@ export default function ApprovalDecisionPage({
</dl>
</section>
{!loading && run && isWaitingApproval && !actionSuccess && (
{!loading && run && isWaitingApproval && !isGate5Projection && !actionSuccess && (
<section className="grid gap-3 md:grid-cols-2">
<button
onClick={() => setDialogDecision("approve")}

View File

@@ -63,6 +63,9 @@ interface Approval {
run_id: string;
project_id: string;
agent_id: string;
trigger_type?: string | null;
trigger_ref?: string | null;
is_shadow?: boolean | null;
created_at: string;
timeout_at: string | null;
remediation_summary?: RemediationSummary | null;
@@ -518,6 +521,17 @@ function DecisionPostureBadge() {
);
}
function Gate5ProjectionBadge() {
const t = useTranslations("awooop.approvals");
return (
<span className="mt-1 inline-flex items-center gap-1.5 border border-[#9bb6d9] bg-[#eef5ff] px-2 py-0.5 text-xs font-semibold text-[#1f5b9b]">
<GitBranch className="h-3.5 w-3.5" aria-hidden="true" />
{t("badges.gate5Projection")}
<span className="font-normal text-[#47709a]">{t("badges.executorHandoffPending")}</span>
</span>
);
}
function ApprovalRow({ approval }: { approval: Approval }) {
const formattedDate = approval.created_at
? new Date(approval.created_at).toLocaleDateString("zh-TW", {
@@ -530,6 +544,7 @@ function ApprovalRow({ approval }: { approval: Approval }) {
const remainingMs = getRemainingMs(approval.timeout_at);
const isCritical = remainingMs !== null && remainingMs <= 5 * 60 * 1000;
const isGate5Projection = approval.trigger_type === "adr100_runtime_replay_gate5";
return (
<tr
@@ -554,9 +569,12 @@ function ApprovalRow({ approval }: { approval: Approval }) {
</span>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">
{approval.agent_id || "--"}
</span>
<div className="flex min-w-[180px] flex-col items-start">
<span className="font-mono text-sm text-muted-foreground">
{approval.agent_id || "--"}
</span>
{isGate5Projection && <Gate5ProjectionBadge />}
</div>
</td>
<td className="px-4 py-3">
<DecisionPostureBadge />