feat(adr100): project gate5 approvals into awooop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 [],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 閘道。",
|
||||
|
||||
@@ -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 閘道。",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user