feat(governance): add agent market automation surfaces
Some checks failed
Some checks failed
This commit is contained in:
@@ -20,12 +20,13 @@ Date: 2026-03-20
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import sentry_sdk
|
||||
import structlog
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
@@ -282,37 +283,52 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.db.base import get_db_context
|
||||
from src.core.context import clear_project_context, set_project_context
|
||||
from src.db.models import IncidentRecord
|
||||
from src.models.incident import IncidentStatus
|
||||
from src.services.incident_service import get_incident_service
|
||||
|
||||
incident_service = get_incident_service()
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(IncidentRecord).where(
|
||||
IncidentRecord.status.in_([
|
||||
IncidentStatus.INVESTIGATING,
|
||||
IncidentStatus.MITIGATING,
|
||||
])
|
||||
startup_ctx_tokens = set_project_context(
|
||||
project_id=settings.SYSTEM_NAME,
|
||||
source="startup.warmup",
|
||||
request_id="startup-warmup",
|
||||
)
|
||||
|
||||
try:
|
||||
incident_service = get_incident_service()
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
select(IncidentRecord).where(
|
||||
IncidentRecord.status.in_([
|
||||
IncidentStatus.INVESTIGATING,
|
||||
IncidentStatus.MITIGATING,
|
||||
])
|
||||
)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
restored = 0
|
||||
for record in records:
|
||||
try:
|
||||
incident = incident_service._record_to_incident(record)
|
||||
if await incident_service.save_to_working_memory(incident):
|
||||
restored += 1
|
||||
except Exception as record_error:
|
||||
# 舊資料 source 值不合法(node-exporter 等)→ 跳過
|
||||
logger.warning(
|
||||
"working_memory_warmup_record_skipped",
|
||||
incident_id=getattr(record, "incident_id", None),
|
||||
error=str(record_error),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"working_memory_warmed_up",
|
||||
restored=restored,
|
||||
total=len(records),
|
||||
startup_project_id=settings.SYSTEM_NAME,
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
restored = 0
|
||||
for record in records:
|
||||
try:
|
||||
incident = incident_service._record_to_incident(record)
|
||||
if await incident_service.save_to_working_memory(incident):
|
||||
restored += 1
|
||||
except Exception as record_error:
|
||||
# 舊資料 source 值不合法(node-exporter 等)→ 跳過
|
||||
logger.warning(
|
||||
"working_memory_warmup_record_skipped",
|
||||
incident_id=getattr(record, "incident_id", None),
|
||||
error=str(record_error),
|
||||
)
|
||||
|
||||
logger.info("working_memory_warmed_up", restored=restored, total=len(records))
|
||||
finally:
|
||||
clear_project_context(startup_ctx_tokens)
|
||||
except Exception as e:
|
||||
logger.warning("working_memory_warmup_failed", error=str(e))
|
||||
|
||||
@@ -886,27 +902,53 @@ async def request_logging_middleware(request: Request, call_next):
|
||||
"""
|
||||
import time
|
||||
|
||||
request_id = request.headers.get("X-Request-ID", "-")
|
||||
from src.core.context import clear_project_context, get_current_project_context, set_project_context
|
||||
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid4())
|
||||
project_id = (
|
||||
request.headers.get("X-Project-ID")
|
||||
or request.headers.get("X-Tenant-ID")
|
||||
or request.query_params.get("project_id")
|
||||
)
|
||||
project_id = project_id.strip() if project_id else None
|
||||
source = "request.project_id.missing"
|
||||
if project_id:
|
||||
source = "request.header_or_query"
|
||||
|
||||
context_tokens = set_project_context(
|
||||
project_id=project_id,
|
||||
source=source,
|
||||
request_id=request_id,
|
||||
)
|
||||
start_time = time.perf_counter()
|
||||
|
||||
# Bind request context for all logs in this request
|
||||
structlog.contextvars.clear_contextvars()
|
||||
current_context = get_current_project_context()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
project_id=current_context["project_id"],
|
||||
project_context_source=current_context["source"],
|
||||
)
|
||||
|
||||
log = get_logger("awoooi.http")
|
||||
log.debug("request_start")
|
||||
|
||||
response = await call_next(request)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
finally:
|
||||
clear_project_context(context_tokens)
|
||||
|
||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
||||
log.info(
|
||||
"request_complete",
|
||||
status_code=response.status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
project_id=current_context["project_id"],
|
||||
project_context_source=current_context["source"],
|
||||
has_project_context=bool(current_context["project_id"]),
|
||||
)
|
||||
|
||||
# Add request ID to response headers
|
||||
@@ -914,11 +956,41 @@ async def request_logging_middleware(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/v1/security/db-context-guard")
|
||||
async def db_context_guard() -> dict:
|
||||
"""
|
||||
Context Guard Endpoint (P1-1 runtime evidence)
|
||||
|
||||
- 未提供 project context(X-Project-ID / X-Tenant-ID / project_id query)
|
||||
時,應回傳 401,代表 RLS 已採 fail-closed
|
||||
- 有提供 context 時回傳 context snapshot,便於稽核
|
||||
"""
|
||||
from src.core.context import get_current_project_context
|
||||
from src.db.base import get_db_context
|
||||
|
||||
async with get_db_context():
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_context": get_current_project_context(),
|
||||
"source": "runtime_guard",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Exception Handlers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(_request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""Preserve intentional HTTP status responses (e.g. 401/403).
|
||||
|
||||
This is critical for P1-1 fail-closed evidence; without it, all HTTPException
|
||||
is swallowed by the generic exception handler and downgraded to 500.
|
||||
"""
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user