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,
|
||||
|
||||
Reference in New Issue
Block a user