Files
taiji-AI-PAD/services/mcp-server/app/routes/agnet_stub.py
T
chenchenandClaude Opus 4.7 610fde5d03 feat(mcp-server): Heicode integration + register transaction hardening
== Heicode integration (~41 endpoints across 5 modules) ==
- §2 ResourceBinding (5 endpoints) — resources.py / resource_grants.py
- §4 NewAPI metadata proxy (4 endpoints) — heicode_proxy.py + heicode_client.py
- §5 Agnet platform stub (12 endpoints, in-memory mock) — agnet_stub.py
- §6 Task orchestration (5 endpoints + 3 extension endpoints) — heicode_tasks.py
  6.1-6.5: intent / list / get / answer / messages
  6.6-6.8: execution / delivery / audit?tab=... (Slice 8/9/10)
- §7 SSE single channel + approvals (4 endpoints + 5 event types) —
  heicode_events.py + event_bus.py
- §7.8.1 internal billing-provider PUT endpoint — auth.py (routes)

== Schema changes ==
- migrations/026 heicode_tasks (orchestration state)
- migrations/027 users.billing_provider (litellm | newapi switch)
- migrations/028 heicode_approvals (high-risk approval queue)

== Register transaction hardening (P0 + P1 + P2) ==
routes/auth.py register():
- Pre-existing P0: failed register returned IntegrityError str verbatim
  (leaking SQL params + ~50 plaintext LiteLLM keys per attempt).
  Now logs exc_info, returns {code: REGISTER_FAILED, message: ...}.
- Pre-existing P0: model dedupe — two ModelProvider rows with overlapping
  supported_models (e.g. taiji/gpt-4o-mini in both taiji and azure providers)
  collide on uq_tenant_model. seen_models set deduplicates within the loop.
- New P1: track created_litellm_keys; on any failure call delete_key() for
  each — prevents remote orphan keys when DB rollback fires.
- New P1: replace verify_code with peek_verification_code at the start;
  only call verify_code (which consumes) after commit succeeds. Failed
  registrations no longer burn the user's one-shot code.
- New P2: narrow inner `except (LiteLLMClientError, Exception)` to just
  LiteLLMClientError so SQLAlchemy errors bubble to the outer rollback
  instead of being silently swallowed into a half-allocated 200 response.
- New P2: same narrowing on outer `except (AgentManagerError, Exception)`.

== Auth middleware ==
- app/auth.py: allow /api/auth/internal/billing-provider and
  /api/auth/internal/approvals to bypass user JWT (service-token auth
  via HEICODE_INTERNAL_SERVICE_TOKEN, validated in-route).

== Docs ==
- Heicode-接口契约文档.md v2.2 (41 endpoints + SSE schema + 6.6-6.8)
- Heicode-对接进度与待办.md (through §7.14 SSE + 7.8.2 delivery回执)
- Heicode-完整调用流程图.md (sequence + routing diagrams)
- Agent-Manager-Heicode对接需求文档.md
- HEICODE_API_INTEGRATION.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:43:10 +08:00

723 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Heicode P5 — Manager 侧 /api/agnet/* 本地 stub
完全按 heicode 仓库 docs/integration/agnet-platform-request-contract.md 的字段形态返回 mock 数据,
便于 cc-haha 客户端 / heicode web 前端在 agent-manager 真正落地前先联调主流程。
存储:内存 dict,重启清空。不调真实 K8s。
鉴权:复用 require_auth(与已上线 4 个登录接口同一机制)。
真实 Agnet 平台落地后,把内部 _store + 假数据生成切换成 outbound HTTP client 即可,
对前端 0 改动(路径 / 字段 / 错误码完全相同)。
12 个端点:
1. POST /api/agnet/deployments
2. GET /api/agnet/deployments
3. GET /api/agnet/deployments/{id}
4. POST /api/agnet/deployments/{id}/stop
5. GET /api/agnet/deployments/{id}/logs
6. GET /api/agnet/deployments/{id}/logs/stream (SSE)
7. GET /api/agnet/projects/{binding_scope}/dashboard-snapshot
8. GET /api/agnet/deployments/{id}/metrics
9. GET /api/agnet/deployments/{id}/events
10. GET /api/agnet/audit-logs
11. POST /api/agnet/sk-snapshots/resolve
12. GET /api/agnet/deployments/{id}/sk-snapshots
"""
from __future__ import annotations
import asyncio
import json
import secrets
import time
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import StreamingResponse
from app.auth import require_auth
from app.routes.resources import reject_sensitive_keys
from app.schemas import SuccessResponse
router = APIRouter(prefix="/api/agnet", tags=["Heicode P5 Agnet Stub"])
# ==================== 内存 store ====================
class _AgnetStubStore:
"""内存存储所有 stub 数据。多副本部署下每副本独立,仅供联调。"""
def __init__(self):
self.deployments: Dict[str, Dict[str, Any]] = {}
self.events: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → events
self.logs: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → logs
self.sk_snapshots: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → snapshots
self.audit_logs: List[Dict[str, Any]] = []
self.idempotency_cache: Dict[str, Dict[str, Any]] = {} # idempotency_key → response
self.lock = asyncio.Lock()
_store = _AgnetStubStore()
def _now_iso() -> str:
return datetime.utcnow().isoformat(timespec="microseconds") + "Z"
def _new_id(prefix: str) -> str:
return f"{prefix}_{secrets.token_hex(6)}"
def _correlation_id(request: Request, fallback_field: Optional[str] = None) -> str:
return (
request.headers.get("X-Correlation-Id")
or request.headers.get("X-Request-Id")
or fallback_field
or str(uuid.uuid4())
)
def _err(http_status: int, code: str, message: str, request_id: Optional[str] = None):
detail = {"code": code, "message": message}
if request_id:
detail["request_id"] = request_id
raise HTTPException(status_code=http_status, detail=detail)
# ==================== 共享:payload 校验 ====================
ALLOWED_PROVIDERS = {"newapi", "litellm"}
ALLOWED_RISK_LEVELS = {"low", "medium", "high"}
ALLOWED_RESOURCE_TYPES_DEPLOYMENT = {
"git", "sk", "project_doc", "cloud_account", "cloud_resource",
"model_gateway_token", # P5 新增(按 §3a)
}
DEPLOYMENT_NON_TERMINAL = {"accepted", "pending", "running", "stopping", "processing"}
DEPLOYMENT_TERMINAL = {"stopped", "completed", "failed", "cancelled"}
def _validate_orchestration_plan(op: Dict[str, Any], request_id: str) -> None:
"""完整校验 orchestration_plan 结构,按契约 §2.3 + §8。"""
if not isinstance(op, dict):
_err(400, "POLICY_REJECTED", "orchestration_plan 必须是 object", request_id)
required_fields = [
"intent_id", "template_hint", "objective", "risk_level",
"budget", "metadata", "agents",
]
for f in required_fields:
if f not in op or op[f] in (None, "", [], {}):
_err(400, "POLICY_REJECTED",
f"orchestration_plan.{f} 必填", request_id)
if op["risk_level"] not in ALLOWED_RISK_LEVELS:
_err(400, "POLICY_REJECTED",
f"risk_level 必须是 {sorted(ALLOWED_RISK_LEVELS)} 之一", request_id)
bud = op.get("budget") or {}
for f in ("max_tokens", "max_cost_usd", "max_duration_sec"):
if f not in bud:
_err(400, "POLICY_REJECTED", f"budget.{f} 必填", request_id)
metadata = op.get("metadata") or {}
if not metadata.get("correlation_id"):
_err(400, "POLICY_REJECTED",
"metadata.correlation_id 必填", request_id)
# billing_context.provider 必须是 newapi 或 litellm
bc = op.get("billing_context") or {}
provider = bc.get("provider")
if provider is not None and provider not in ALLOWED_PROVIDERS:
_err(400, "POLICY_REJECTED",
f"billing_context.provider 必须是 {sorted(ALLOWED_PROVIDERS)} 之一,收到 '{provider}'",
request_id)
# agents[] 至少 1 个
agents = op.get("agents") or []
if not isinstance(agents, list) or len(agents) == 0:
_err(400, "POLICY_REJECTED",
"orchestration_plan.agents 至少 1 项", request_id)
# 校验每个 agent
for i, agent in enumerate(agents):
if not isinstance(agent, dict):
_err(400, "POLICY_REJECTED",
f"agents[{i}] 必须是 object", request_id)
if not agent.get("role_template"):
_err(400, "POLICY_REJECTED",
f"agents[{i}].role_template 必填", request_id)
if not agent.get("goal"):
_err(400, "POLICY_REJECTED",
f"agents[{i}].goal 必填", request_id)
# default_model_id 若设置必须 ∈ allowed_model_ids
constraints = op.get("constraints") or {}
allowed = constraints.get("allowed_model_ids") or []
dmi = agent.get("default_model_id")
if dmi and allowed and dmi not in allowed:
_err(403, "MODEL_NOT_ALLOWED",
f"agents[{i}].default_model_id='{dmi}' 不在 allowed_model_ids 内",
request_id)
# resource_grants[] 校验
grants = agent.get("resource_grants") or []
for j, g in enumerate(grants):
if not isinstance(g, dict):
_err(422, "RESOURCE_GRANT_INVALID",
f"agents[{i}].resource_grants[{j}] 必须是 object", request_id)
for f in ("grant_id", "resource_id", "resource_type", "user_id",
"binding_scope", "target_role", "target_agent_ref",
"permission_scope", "status"):
if f not in g or g[f] in (None, ""):
_err(422, "RESOURCE_GRANT_INVALID",
f"agents[{i}].resource_grants[{j}].{f} 必填", request_id)
if g["resource_type"] not in ALLOWED_RESOURCE_TYPES_DEPLOYMENT:
_err(422, "RESOURCE_GRANT_INVALID",
f"resource_grants[{j}].resource_type 不合法: {g['resource_type']}",
request_id)
# 凭据型必填 secret_ref
if g["resource_type"] != "project_doc":
if not g.get("secret_ref"):
_err(422, "RESOURCE_GRANT_SECRET_REF_REQUIRED",
f"agents[{i}].resource_grants[{j}].secret_ref 必填({g['resource_type']})",
request_id)
# role 一致
if g.get("target_role") and g["target_role"] != agent["role_template"]:
_err(422, "RESOURCE_GRANT_INVALID",
f"resource_grants[{j}].target_role 必须等于 agents[{i}].role_template",
request_id)
# high risk 必须有 approval_id
if op["risk_level"] == "high":
has_approval = False
for g in grants:
if (g.get("constraints") or {}).get("approval_id"):
has_approval = True
break
if (g.get("audit") or {}).get("approval_id"):
has_approval = True
break
if not has_approval:
_err(403, "POLICY_REJECTED",
"risk_level=high 必须在 resource_grants 的 constraints 或 audit 中携带 approval_id",
request_id)
# 敏感字段递归扫描 — **仅** scan 契约 §8.3 列出的字段:metadata / constraints / audit
# 不能扫 budget(含 max_tokens)/ billing_context(含 newapi_token_or_group_quota_ref)/
# agent_runtime / orchestration_plan 顶层(避免误杀合法字段名)
if isinstance(op.get("metadata"), dict):
reject_sensitive_keys(op["metadata"], "orchestration_plan.metadata.")
for i, agent in enumerate(op.get("agents", [])):
for j, g in enumerate(agent.get("resource_grants") or []):
base = f"orchestration_plan.agents[{i}].resource_grants[{j}]"
for sub in ("metadata", "constraints", "audit"):
v = g.get(sub)
if isinstance(v, dict):
reject_sensitive_keys(v, f"{base}.{sub}.")
# ==================== 1. POST /deployments ====================
@router.post("/deployments", response_model=SuccessResponse)
async def create_deployment(
payload: Dict[str, Any],
request: Request,
principal: dict = Depends(require_auth),
):
request_id = _correlation_id(request)
idempotency_key = request.headers.get("Idempotency-Key")
# 幂等:同 key 命中直接返回缓存结果
if idempotency_key:
async with _store.lock:
cached = _store.idempotency_cache.get(idempotency_key)
if cached:
return SuccessResponse(data=cached)
op = payload.get("orchestration_plan")
if not op:
_err(400, "POLICY_REJECTED", "请求体缺少 orchestration_plan", request_id)
_validate_orchestration_plan(op, request_id)
# 创建 deployment
deployment_id = _new_id("dep")
now = _now_iso()
user_id = (op.get("user_context") or {}).get("user_id") or \
request.headers.get("X-User-Id")
binding_scope = (op.get("user_context") or {}).get("channel_id") or \
request.headers.get("X-Binding-Scope") or "default"
correlation_id_field = (op.get("metadata") or {}).get("correlation_id", request_id)
instances = []
for agent in op.get("agents", []):
instances.append({
"instance_id": _new_id("agi"),
"role": agent["role_template"],
"phase": "pending",
})
deployment = {
"deployment_id": deployment_id,
"status": "accepted",
"phase": "pending",
"agent_instances": instances,
"user_id": user_id,
"binding_scope": binding_scope,
"correlation_id": correlation_id_field,
"intent_id": op.get("intent_id"),
"risk_level": op.get("risk_level"),
"budget": op.get("budget"),
"billing_context": op.get("billing_context"),
"agent_runtime": op.get("agent_runtime"),
"resource_grants_summary": [
{
"grant_id": g.get("grant_id"),
"resource_type": g.get("resource_type"),
"binding_scope": g.get("binding_scope"),
"target_role": g.get("target_role"),
"permission_scope": g.get("permission_scope"),
"status": g.get("status"),
}
for agent in op.get("agents", [])
for g in (agent.get("resource_grants") or [])
],
"budget_consumed": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0},
"last_error": None,
"created_at": now,
"updated_at": now,
}
# seed events
events = [{
"event_id": _new_id("evt"),
"event": "deployment.accepted",
"schema_version": 1,
"user_id": user_id,
"channel_id": binding_scope,
"binding_scope": binding_scope,
"deployment_id": deployment_id,
"correlation_id": correlation_id_field,
"occurred_at": now,
}]
for ins in instances:
events.append({
"event_id": _new_id("evt"),
"event": "instance.phase_changed",
"schema_version": 1,
"user_id": user_id,
"channel_id": binding_scope,
"binding_scope": binding_scope,
"deployment_id": deployment_id,
"instance_id": ins["instance_id"],
"phase": "pending",
"correlation_id": correlation_id_field,
"occurred_at": now,
})
# seed logs(脱敏占位)
logs = [{
"log_id": _new_id("log"),
"deployment_id": deployment_id,
"agent_instance_id": instances[0]["instance_id"] if instances else None,
"stream": "system",
"level": "info",
"message": "[stub] deployment accepted, awaiting K8s bring-up",
"redacted": True,
"occurred_at": now,
}]
# audit
audit_entry = {
"audit_id": _new_id("aud"),
"actor": "manager",
"action": "deployment.accepted",
"resource": deployment_id,
"user_id": user_id,
"channel_id": binding_scope,
"binding_scope": binding_scope,
"request_id": request_id,
"correlation_id": correlation_id_field,
"result": "ok",
"occurred_at": now,
}
async with _store.lock:
_store.deployments[deployment_id] = deployment
_store.events[deployment_id] = events
_store.logs[deployment_id] = logs
_store.sk_snapshots[deployment_id] = []
_store.audit_logs.append(audit_entry)
response_data = {
"deployment_id": deployment_id,
"status": "accepted",
"agent_instances": instances,
}
if idempotency_key:
_store.idempotency_cache[idempotency_key] = response_data
return SuccessResponse(data=response_data)
# ==================== 2-4. 部署列表 / 详情 / 停止 ====================
@router.get("/deployments", response_model=SuccessResponse)
async def list_deployments(
request: Request,
principal: dict = Depends(require_auth),
user_id: Optional[str] = Query(None),
binding_scope: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
limit: int = Query(50, ge=1, le=500),
cursor: Optional[str] = Query(None),
):
async with _store.lock:
items = []
for d in _store.deployments.values():
if user_id and d.get("user_id") != user_id:
continue
if binding_scope and d.get("binding_scope") != binding_scope:
continue
if status_filter and d.get("status") != status_filter:
continue
items.append({
"deployment_id": d["deployment_id"],
"status": d["status"],
"phase": d["phase"],
"user_id": d.get("user_id"),
"binding_scope": d.get("binding_scope"),
"created_at": d["created_at"],
"updated_at": d["updated_at"],
})
items.sort(key=lambda x: x["created_at"], reverse=True)
items = items[:limit]
return SuccessResponse(data={"items": items, "total": len(items)})
@router.get("/deployments/{deployment_id}", response_model=SuccessResponse)
async def get_deployment(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
):
async with _store.lock:
d = _store.deployments.get(deployment_id)
if d is None:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
return SuccessResponse(data=d)
@router.post("/deployments/{deployment_id}/stop", response_model=SuccessResponse)
async def stop_deployment(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
payload: Optional[Dict[str, Any]] = None,
):
request_id = _correlation_id(request)
async with _store.lock:
d = _store.deployments.get(deployment_id)
if d is None:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id)
# 幂等:已 stopped 重复 stop 仍返 200
if d["status"] == "stopped":
return SuccessResponse(data={
"deployment_id": deployment_id,
"status": "stopped",
})
# 终态(completed 等)拒绝
if d["status"] in DEPLOYMENT_TERMINAL and d["status"] != "stopped":
_err(409, "DEPLOYMENT_CONFLICT",
f"部署已进入终态 {d['status']},不能停止", request_id)
d["status"] = "stopped"
d["phase"] = "stopped"
d["updated_at"] = _now_iso()
for ins in d.get("agent_instances", []):
ins["phase"] = "stopped"
# event
_store.events[deployment_id].append({
"event_id": _new_id("evt"),
"event": "deployment.stopped",
"schema_version": 1,
"user_id": d.get("user_id"),
"channel_id": d.get("binding_scope"),
"binding_scope": d.get("binding_scope"),
"deployment_id": deployment_id,
"correlation_id": d.get("correlation_id"),
"occurred_at": d["updated_at"],
})
# audit
_store.audit_logs.append({
"audit_id": _new_id("aud"),
"actor": "manager",
"action": "deployment.stop",
"resource": deployment_id,
"user_id": d.get("user_id"),
"binding_scope": d.get("binding_scope"),
"request_id": request_id,
"correlation_id": d.get("correlation_id"),
"result": "ok",
"occurred_at": d["updated_at"],
})
return SuccessResponse(data={
"deployment_id": deployment_id,
"status": "stopped",
})
# ==================== 5-6. 日志 + SSE ====================
@router.get("/deployments/{deployment_id}/logs", response_model=SuccessResponse)
async def list_logs(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
agent_instance_id: Optional[str] = Query(None),
stream: Optional[str] = Query(None),
since: Optional[str] = Query(None),
limit: int = Query(200, ge=1, le=1000),
cursor: Optional[str] = Query(None),
):
async with _store.lock:
if deployment_id not in _store.deployments:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
logs = list(_store.logs.get(deployment_id, []))
# 过滤
if agent_instance_id:
logs = [l for l in logs if l.get("agent_instance_id") == agent_instance_id]
if stream:
logs = [l for l in logs if l.get("stream") == stream]
if since:
logs = [l for l in logs if (l.get("occurred_at") or "") >= since]
return SuccessResponse(data={
"items": logs[:limit],
"next_cursor": None,
"total": len(logs),
})
@router.get("/deployments/{deployment_id}/logs/stream")
async def stream_logs(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
agent_instance_id: Optional[str] = Query(None),
):
async with _store.lock:
if deployment_id not in _store.deployments:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
async def gen():
# 发送 2 条 mock log + 1 个 heartbeat + done
for i in range(2):
line = {
"log_id": _new_id("log"),
"deployment_id": deployment_id,
"level": "info",
"message": f"[stub-stream] mock log line {i+1}",
"occurred_at": _now_iso(),
}
yield f"event: log\ndata: {json.dumps(line)}\n\n"
await asyncio.sleep(0.2)
yield f"event: heartbeat\ndata: {{}}\n\n"
await asyncio.sleep(0.2)
yield f"event: done\ndata: {{}}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
# ==================== 7-8. 监控快照 + metrics ====================
@router.get("/projects/{binding_scope}/dashboard-snapshot", response_model=SuccessResponse)
async def dashboard_snapshot(
binding_scope: str,
request: Request,
principal: dict = Depends(require_auth),
window: str = Query("1h"),
):
async with _store.lock:
deps = [d for d in _store.deployments.values() if d.get("binding_scope") == binding_scope]
phase_dist = {"pending": 0, "running": 0, "stopped": 0, "failed": 0}
for d in deps:
ph = d.get("phase", "pending")
if ph not in phase_dist:
phase_dist[ph] = 0
phase_dist[ph] += 1
return SuccessResponse(data={
"project_id": binding_scope,
"binding_scope": binding_scope,
"active_instances": sum(1 for d in deps if d.get("status") in DEPLOYMENT_NON_TERMINAL),
"phase_distribution": phase_dist,
"failure_rate_1h": 0.0,
"avg_task_duration": 0.0,
"budget": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0},
"resource_usage": {
"cpu_millicores": 0, "memory_mb": 0,
"network_rx_bytes": 0, "network_tx_bytes": 0,
},
"updated_at": _now_iso(),
})
@router.get("/deployments/{deployment_id}/metrics", response_model=SuccessResponse)
async def deployment_metrics(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
window: str = Query("15m"),
step: str = Query("60s"),
):
async with _store.lock:
if deployment_id not in _store.deployments:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
now = _now_iso()
series = [
{"metric": "tokens_used", "unit": "count", "points": [[now, 0]]},
{"metric": "cost_usd", "unit": "usd", "points": [[now, 0.0]]},
{"metric": "duration_sec", "unit": "count", "points": [[now, 0]]},
{"metric": "cpu_millicores", "unit": "millicore", "points": [[now, 0]]},
{"metric": "memory_mb", "unit": "mb", "points": [[now, 0]]},
{"metric": "restart_count", "unit": "count", "points": [[now, 0]]},
{"metric": "tool_call_count", "unit": "count", "points": [[now, 0]]},
{"metric": "error_count", "unit": "count", "points": [[now, 0]]},
{"metric": "queue_latency_ms", "unit": "ms", "points": [[now, 0]]},
]
return SuccessResponse(data={
"deployment_id": deployment_id,
"window": window,
"step": step,
"series": series,
})
# ==================== 9-10. 事件 + 审计 ====================
@router.get("/deployments/{deployment_id}/events", response_model=SuccessResponse)
async def list_events(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
since: Optional[str] = Query(None),
limit: int = Query(200, ge=1, le=1000),
cursor: Optional[str] = Query(None),
):
async with _store.lock:
if deployment_id not in _store.deployments:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
events = list(_store.events.get(deployment_id, []))
if since:
events = [e for e in events if (e.get("occurred_at") or "") >= since]
return SuccessResponse(data={
"items": events[:limit],
"next_cursor": None,
"total": len(events),
})
@router.get("/audit-logs", response_model=SuccessResponse)
async def list_audit_logs(
request: Request,
principal: dict = Depends(require_auth),
user_id: Optional[str] = Query(None),
binding_scope: Optional[str] = Query(None),
actor: Optional[str] = Query(None),
action: Optional[str] = Query(None),
since: Optional[str] = Query(None),
limit: int = Query(200, ge=1, le=1000),
cursor: Optional[str] = Query(None),
):
async with _store.lock:
items = list(_store.audit_logs)
if user_id:
items = [a for a in items if a.get("user_id") == user_id]
if binding_scope:
items = [a for a in items if a.get("binding_scope") == binding_scope]
if actor:
items = [a for a in items if a.get("actor") == actor]
if action:
items = [a for a in items if a.get("action") == action]
if since:
items = [a for a in items if (a.get("occurred_at") or "") >= since]
items.sort(key=lambda a: a.get("occurred_at", ""), reverse=True)
return SuccessResponse(data={
"items": items[:limit],
"next_cursor": None,
"total": len(items),
})
# ==================== 11-12. SK 快照 ====================
@router.post("/sk-snapshots/resolve", response_model=SuccessResponse)
async def resolve_sk_snapshot(
payload: Dict[str, Any],
request: Request,
principal: dict = Depends(require_auth),
):
request_id = _correlation_id(request)
deployment_id = payload.get("deployment_id")
if not deployment_id:
_err(400, "POLICY_REJECTED", "deployment_id 必填", request_id)
async with _store.lock:
d = _store.deployments.get(deployment_id)
if d is None:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id)
snap = {
"snapshot_id": _new_id("sks"),
"deployment_id": deployment_id,
"user_id": d.get("user_id"),
"binding_scope": d.get("binding_scope"),
"source_type": "git",
"source_ref": "main:skills/heicode/**@stub-checksum",
"artifact_ref": f"artifact://stub/{deployment_id}/sks",
"checksum": "sha256:stub-redacted",
"status": "ready",
"resolved_at": _now_iso(),
}
_store.sk_snapshots[deployment_id].append(snap)
# event
_store.events[deployment_id].append({
"event_id": _new_id("evt"),
"event": "sk_snapshot_refreshed",
"schema_version": 1,
"user_id": d.get("user_id"),
"binding_scope": d.get("binding_scope"),
"deployment_id": deployment_id,
"snapshot_id": snap["snapshot_id"],
"occurred_at": snap["resolved_at"],
})
return SuccessResponse(data={
"deployment_id": deployment_id,
"items": [snap],
"total": 1,
})
@router.get("/deployments/{deployment_id}/sk-snapshots", response_model=SuccessResponse)
async def list_sk_snapshots(
deployment_id: str,
request: Request,
principal: dict = Depends(require_auth),
user_id: Optional[str] = Query(None),
binding_scope: Optional[str] = Query(None),
source_type: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
cursor: Optional[str] = Query(None),
):
async with _store.lock:
if deployment_id not in _store.deployments:
_err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request))
items = list(_store.sk_snapshots.get(deployment_id, []))
if source_type:
items = [s for s in items if s.get("source_type") == source_type]
return SuccessResponse(data={
"items": items[:limit],
"next_cursor": None,
"total": len(items),
})