forked from xiaohei/taiji-AI-PAD
== 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>
723 lines
27 KiB
Python
723 lines
27 KiB
Python
"""
|
||
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),
|
||
})
|