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

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