""" 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), })