feat(agent99): add durable completion callback

This commit is contained in:
ogt
2026-07-11 20:45:52 +08:00
parent df26f43b77
commit e8b0244ee7
15 changed files with 965 additions and 41 deletions

View File

@@ -26,15 +26,22 @@ Phase 17 技術債修復 (2026-03-26):
"""
import asyncio
import hmac
import json
from typing import Any
from fastapi import APIRouter, BackgroundTasks, HTTPException, status
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from src.core.config import settings
from src.core.context import clear_project_context, set_project_context
from src.core.logging import get_logger
from src.core.sse import get_publisher
from src.models.agent99_completion import Agent99CompletionCallbackRequest
from src.services.agent99_completion_callback import (
record_agent99_completion_callback,
)
from src.services.agent99_enterprise_ai_automation_work_items import (
load_latest_agent99_enterprise_ai_automation_work_items,
)
@@ -1173,6 +1180,55 @@ async def get_agent99_enterprise_ai_automation_work_items() -> dict[str, Any]:
) from exc
@router.post(
"/agent99/completion-callback",
response_model=dict[str, Any],
summary="接收 Agent99 已驗證完成回寫",
description=(
"以現有 Agent99 relay token 驗證 99 主機回寫,將同一 callback/run/trace "
"以冪等方式寫入 AWOOOI operation log 與 AwoooP completed shadow run"
"並在回應前執行 durable DB readback。payload 禁止 raw log、secret 與完整檔案路徑。"
),
)
async def post_agent99_completion_callback(
payload: Agent99CompletionCallbackRequest,
x_agent99_completion_token: str | None = Header(
default=None,
alias="X-Agent99-Completion-Token",
),
) -> dict[str, Any]:
expected_token = settings.AGENT99_SRE_ALERT_RELAY_TOKEN
if not expected_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="agent99_completion_auth_not_configured",
)
if not x_agent99_completion_token or not hmac.compare_digest(
x_agent99_completion_token,
expected_token,
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="agent99_completion_auth_failed",
)
context_tokens = set_project_context(
project_id=payload.project_id,
source="agent99_completion_callback",
request_id=payload.callback_id,
)
try:
receipt = await record_agent99_completion_callback(payload)
finally:
clear_project_context(context_tokens)
if receipt.get("durable_readback") is not True:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="agent99_completion_durable_readback_failed",
)
return receipt
@router.get(
"/awoooi-priority-work-order-readback",
response_model=dict[str, Any],

View File

@@ -0,0 +1,73 @@
"""Bounded Agent99 completion callback contracts."""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Agent99TelegramReceipt(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
sent: bool = False
suppressed: bool = False
reason: str | None = Field(default=None, max_length=80)
message_format: str | None = Field(default=None, max_length=80)
visual_sent: bool = False
visual_file: str | None = Field(
default=None,
max_length=180,
pattern=r"^[^/\\]+$",
)
class Agent99ProblemReceipt(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str | None = Field(default=None, max_length=240)
occurrences: int = Field(default=0, ge=0)
verified_resolved: int = Field(default=0, ge=0)
failed: int = Field(default=0, ge=0)
degraded: int = Field(default=0, ge=0)
blocked: int = Field(default=0, ge=0)
verifying: int = Field(default=0, ge=0)
candidate_ready: int = Field(default=0, ge=0)
class Agent99CompletionCallbackRequest(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
schema_version: Literal["agent99_completion_callback_v1"]
callback_id: str = Field(min_length=3, max_length=180, pattern=r"^[A-Za-z0-9_.:-]+$")
project_id: str = Field(default="awoooi", min_length=1, max_length=64)
run_id: str = Field(min_length=3, max_length=180)
trace_id: str = Field(min_length=3, max_length=180)
work_item_id: str = Field(min_length=3, max_length=180)
alert_id: str | None = Field(default=None, max_length=180)
correlation_key: str | None = Field(default=None, max_length=240)
source: str = Field(default="agent99", max_length=120)
mode: str = Field(min_length=2, max_length=80)
outcome_state: Literal[
"resolved",
"candidate_ready",
"verifying",
"degraded",
"blocked",
"failed",
]
controlled_apply: bool = False
transport_ok: bool
verifier_name: str = Field(min_length=2, max_length=160)
verifier_passed: bool
source_event_resolved: bool
source_event_resolution_policy: str = Field(max_length=120)
duration_seconds: float = Field(ge=0, le=3600)
evidence_ref: str = Field(min_length=1, max_length=180, pattern=r"^[^/\\]+$")
alert_kind: str | None = Field(default=None, max_length=120)
alert_service: str | None = Field(default=None, max_length=160)
alert_host: str | None = Field(default=None, max_length=160)
telegram: Agent99TelegramReceipt = Field(default_factory=Agent99TelegramReceipt)
problem: Agent99ProblemReceipt = Field(default_factory=Agent99ProblemReceipt)
occurred_at: datetime

View File

@@ -0,0 +1,219 @@
"""Durable, idempotent Agent99 completion callback writeback."""
from __future__ import annotations
from typing import Any
from sqlalchemy import text
from src.db.base import get_db_context
from src.models.agent99_completion import Agent99CompletionCallbackRequest
from src.repositories.alert_operation_log_repository import (
get_alert_operation_log_repository,
)
from src.services.channel_hub import (
build_external_alert_provider_event_id,
record_external_alert_event,
)
async def _load_callback_readback(
*,
project_id: str,
provider_event_id: str,
callback_id: str,
) -> dict[str, Any] | None:
async with get_db_context(project_id) as db:
result = await db.execute(
text(
"""
SELECT
event_row.event_id::text AS event_id,
event_row.run_id::text AS run_id,
(
SELECT count(*)
FROM alert_operation_log operation_row
WHERE operation_row.context ->> 'callback_id' = :callback_id
) AS operation_count,
(
SELECT count(*)
FROM alert_operation_log operation_row
WHERE operation_row.context ->> 'callback_id' = :callback_id
AND operation_row.event_type = 'EXECUTION_COMPLETED'
) AS execution_count,
(
SELECT count(*)
FROM alert_operation_log operation_row
WHERE operation_row.context ->> 'callback_id' = :callback_id
AND operation_row.event_type = 'RESOLVED'
) AS resolved_count
FROM awooop_conversation_event event_row
WHERE event_row.project_id = :project_id
AND event_row.channel_type = 'internal'
AND event_row.provider_event_id = :provider_event_id
LIMIT 1
"""
),
{
"project_id": project_id,
"provider_event_id": provider_event_id,
"callback_id": callback_id,
},
)
row = result.mappings().first()
return dict(row) if row else None
def _operation_context(payload: Agent99CompletionCallbackRequest) -> dict[str, Any]:
return {
"schema_version": payload.schema_version,
"callback_id": payload.callback_id,
"project_id": payload.project_id,
"run_id": payload.run_id,
"trace_id": payload.trace_id,
"work_item_id": payload.work_item_id,
"alert_id": payload.alert_id,
"correlation_key": payload.correlation_key,
"source": payload.source,
"mode": payload.mode,
"outcome_state": payload.outcome_state,
"controlled_apply": payload.controlled_apply,
"transport_ok": payload.transport_ok,
"verifier_name": payload.verifier_name,
"verifier_passed": payload.verifier_passed,
"source_event_resolved": payload.source_event_resolved,
"source_event_resolution_policy": payload.source_event_resolution_policy,
"duration_seconds": payload.duration_seconds,
"evidence_ref": payload.evidence_ref,
"alert_kind": payload.alert_kind,
"alert_service": payload.alert_service,
"alert_host": payload.alert_host,
"telegram": payload.telegram.model_dump(mode="json"),
"problem": payload.problem.model_dump(mode="json"),
"occurred_at": payload.occurred_at.isoformat(),
"stores_secret": False,
"raw_log_stored": False,
}
def _owner_plane(alert_kind: str | None) -> str:
value = (alert_kind or "").lower()
if any(marker in value for marker in ("security", "wazuh", "siem", "cve")):
return "iwooos"
return "awoooi"
async def record_agent99_completion_callback(
payload: Agent99CompletionCallbackRequest,
) -> dict[str, Any]:
"""Write one callback to the AWOOOI/AwoooP truth chain and read it back."""
stage = payload.outcome_state
provider_event_id = build_external_alert_provider_event_id(
"agent99",
payload.callback_id,
stage,
)
before = await _load_callback_readback(
project_id=payload.project_id,
provider_event_id=provider_event_id,
callback_id=payload.callback_id,
)
duplicate = before is not None
terminal_resolved = bool(
payload.outcome_state == "resolved"
and payload.verifier_passed
and payload.source_event_resolved
)
severity = (
"info"
if payload.outcome_state == "resolved"
else "critical"
if payload.outcome_state == "failed"
else "warning"
)
context = _operation_context(payload)
event_id = await record_external_alert_event(
project_id=payload.project_id,
provider="agent99",
event_id=payload.callback_id,
stage=stage,
title=f"Agent99 {payload.mode} {payload.outcome_state}",
severity=severity,
namespace="agent99",
target_resource=payload.alert_service or payload.mode,
fingerprint=payload.correlation_key or payload.trace_id,
incident_id=payload.alert_id,
labels={
"agent": "agent99",
"mode": payload.mode,
"outcome_state": payload.outcome_state,
"verifier_passed": str(payload.verifier_passed).lower(),
"source_event_resolved": str(payload.source_event_resolved).lower(),
"owner_plane": _owner_plane(payload.alert_kind),
},
annotations={
"run_id": payload.run_id,
"trace_id": payload.trace_id,
"work_item_id": payload.work_item_id,
"evidence_ref": payload.evidence_ref,
},
payload=context,
is_duplicate=duplicate,
)
repository = get_alert_operation_log_repository()
if int((before or {}).get("execution_count") or 0) == 0:
await repository.append(
"EXECUTION_COMPLETED",
incident_id=payload.alert_id,
actor="agent99_completion_callback",
action_detail=f"{payload.mode}:{payload.outcome_state}",
success=payload.verifier_passed,
context=context,
)
if terminal_resolved and int((before or {}).get("resolved_count") or 0) == 0:
await repository.append(
"RESOLVED",
incident_id=payload.alert_id,
actor="agent99_completion_callback",
action_detail=f"{payload.mode}:verified_resolved",
success=True,
context=context,
)
readback = await _load_callback_readback(
project_id=payload.project_id,
provider_event_id=provider_event_id,
callback_id=payload.callback_id,
)
operation_count = int((readback or {}).get("operation_count") or 0)
execution_count = int((readback or {}).get("execution_count") or 0)
resolved_count = int((readback or {}).get("resolved_count") or 0)
durable_readback = bool(
readback
and str(readback.get("event_id") or "") == str(event_id)
and readback.get("run_id")
and execution_count > 0
and (not terminal_resolved or resolved_count > 0)
)
return {
"schema_version": "agent99_completion_callback_receipt_v1",
"ok": durable_readback,
"status": "duplicate_verified" if duplicate else "recorded_verified",
"accepted": True,
"duplicate": duplicate,
"callback_id": payload.callback_id,
"run_id": payload.run_id,
"trace_id": payload.trace_id,
"work_item_id": payload.work_item_id,
"outcome_state": payload.outcome_state,
"conversation_event_id": str(event_id),
"awooop_run_id": (readback or {}).get("run_id"),
"operation_receipt_count": operation_count,
"execution_receipt_count": execution_count,
"resolved_receipt_count": resolved_count,
"durable_readback": durable_readback,
"stores_secret": False,
"raw_log_stored": False,
}