feat(governance): add agent market automation surfaces
Some checks failed
Ansible Lint / lint (push) Successful in 35s
CD Pipeline / tests (push) Failing after 13s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Failing after 11s

This commit is contained in:
Your Name
2026-06-04 21:40:12 +08:00
parent b9bd5e3ba8
commit cfb866d055
334 changed files with 62000 additions and 82 deletions

View File

@@ -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 contextX-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:
"""