Extend agnet SK sources with repo_ref and snapshot display; add authenticated deployment sheet + API types; cockpit toolbar entry; locale strings; minor docs. Made-with: Cursor
594 lines
16 KiB
Go
594 lines
16 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/common"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
agnetRiskLow = "low"
|
|
agnetRiskMedium = "medium"
|
|
agnetRiskHigh = "high"
|
|
)
|
|
|
|
type agnetBudget struct {
|
|
MaxTokens int `json:"max_tokens"`
|
|
MaxCostUSD float64 `json:"max_cost_usd"`
|
|
MaxDurationSec int `json:"max_duration_sec"`
|
|
}
|
|
|
|
// agnetRepoRef matches docs/integration/orchestration-plan-contract.md (git sk_sources).
|
|
type agnetRepoRef struct {
|
|
ConnectionID string `json:"connection_id"`
|
|
RepoURL string `json:"repo_url"`
|
|
Ref string `json:"ref"`
|
|
Paths []string `json:"paths"`
|
|
}
|
|
|
|
type agnetSKSource struct {
|
|
Type string `json:"type"`
|
|
ArtifactID string `json:"artifact_id"`
|
|
Mime string `json:"mime"`
|
|
RepoRef agnetRepoRef `json:"repo_ref"`
|
|
}
|
|
|
|
// agnetRuntimeExecution mirrors docs/integration/agnet-platform-api-design.md §5.0 (runtime_execution).
|
|
type agnetRuntimeExecution struct {
|
|
ProfileID string `json:"profile_id"`
|
|
CloudPrincipalRefs []string `json:"cloud_principal_refs"`
|
|
NetworkPolicyRef string `json:"network_policy_ref"`
|
|
}
|
|
|
|
// agnetSKAccessPolicy mirrors docs/integration/agnet-platform-api-design.md §5.0 (sk_access_policy).
|
|
type agnetSKAccessPolicy struct {
|
|
PolicyRef string `json:"policy_ref"`
|
|
DenySkillIDs []string `json:"deny_skill_ids"`
|
|
InheritDeploymentDefaults bool `json:"inherit_deployment_defaults"`
|
|
}
|
|
|
|
type agnetAgentPlan struct {
|
|
RoleTemplate string `json:"role_template"`
|
|
Goal string `json:"goal"`
|
|
DefaultModelID string `json:"default_model_id"`
|
|
SKSources []agnetSKSource `json:"sk_sources"`
|
|
RuntimeExecution agnetRuntimeExecution `json:"runtime_execution"`
|
|
SKAccessPolicy agnetSKAccessPolicy `json:"sk_access_policy"`
|
|
}
|
|
|
|
type agnetConstraints struct {
|
|
AllowedModelIDs []string `json:"allowed_model_ids"`
|
|
}
|
|
|
|
type agnetMetadata struct {
|
|
TenantID string `json:"tenant_id"`
|
|
ProjectID string `json:"project_id"`
|
|
CorrelationID string `json:"correlation_id"`
|
|
}
|
|
|
|
type agnetOrchestrationPlan struct {
|
|
IntentID string `json:"intent_id"`
|
|
TemplateHint string `json:"template_hint"`
|
|
Objective string `json:"objective"`
|
|
RiskLevel string `json:"risk_level"`
|
|
Budget agnetBudget `json:"budget"`
|
|
Agents []agnetAgentPlan `json:"agents"`
|
|
Constraints agnetConstraints `json:"constraints"`
|
|
Metadata agnetMetadata `json:"metadata"`
|
|
}
|
|
|
|
type agnetDeploymentRequest struct {
|
|
Plan agnetOrchestrationPlan `json:"orchestration_plan"`
|
|
}
|
|
|
|
type agnetDeploymentRecord struct {
|
|
DeploymentID string `json:"deployment_id"`
|
|
Status string `json:"status"`
|
|
Phase string `json:"phase"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Plan agnetOrchestrationPlan `json:"orchestration_plan"`
|
|
}
|
|
|
|
type agnetEvent struct {
|
|
EventID string `json:"event_id"`
|
|
Event string `json:"event"`
|
|
SchemaVersion int `json:"schema_version"`
|
|
TenantID string `json:"tenant_id"`
|
|
ProjectID string `json:"project_id"`
|
|
DeploymentID string `json:"deployment_id"`
|
|
CorrelationID string `json:"correlation_id"`
|
|
OccurredAt string `json:"occurred_at"`
|
|
}
|
|
|
|
type agnetSKSnapshotResolveRequest struct {
|
|
DeploymentID string `json:"deployment_id"`
|
|
}
|
|
|
|
type agnetSKSnapshot struct {
|
|
SnapshotID string `json:"snapshot_id"`
|
|
DeploymentID string `json:"deployment_id"`
|
|
TenantID string `json:"tenant_id"`
|
|
ProjectID string `json:"project_id"`
|
|
SourceType string `json:"source_type"`
|
|
SourceRef string `json:"source_ref"`
|
|
ResolvedAt string `json:"resolved_at"`
|
|
}
|
|
|
|
var (
|
|
agnetMu sync.RWMutex
|
|
agnetDeployments = make(map[string]agnetDeploymentRecord)
|
|
agnetEvents = make(map[string][]agnetEvent)
|
|
agnetSnapshots = make(map[string][]agnetSKSnapshot)
|
|
)
|
|
|
|
func agnetNow() string {
|
|
return time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
func agnetRequestID(c *gin.Context) string {
|
|
if reqID := strings.TrimSpace(c.GetString(common.RequestIdKey)); reqID != "" {
|
|
return reqID
|
|
}
|
|
return common.GetUUID()
|
|
}
|
|
|
|
func agnetError(c *gin.Context, code string, message string) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": false,
|
|
"message": message,
|
|
"error": gin.H{
|
|
"code": code,
|
|
"message": message,
|
|
"request_id": agnetRequestID(c),
|
|
},
|
|
})
|
|
}
|
|
|
|
func containsString(values []string, target string) bool {
|
|
for _, value := range values {
|
|
if value == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func agnetRuntimePartiallySet(r agnetRuntimeExecution) bool {
|
|
return strings.TrimSpace(r.ProfileID) != "" ||
|
|
len(r.CloudPrincipalRefs) > 0 ||
|
|
strings.TrimSpace(r.NetworkPolicyRef) != ""
|
|
}
|
|
|
|
func validateAgentRuntimeBindings(c *gin.Context, agent agnetAgentPlan) bool {
|
|
r := agent.RuntimeExecution
|
|
if !agnetRuntimePartiallySet(r) {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(r.ProfileID) == "" {
|
|
agnetError(c, "RUNTIME_BINDING_INVALID", "runtime_execution.profile_id is required when runtime bindings are present")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func skSourceDisplayRef(s agnetSKSource) string {
|
|
switch strings.TrimSpace(s.Type) {
|
|
case "git":
|
|
r := s.RepoRef
|
|
ref := strings.TrimSpace(r.Ref)
|
|
pathPart := strings.Join(r.Paths, ",")
|
|
if ref != "" && pathPart != "" {
|
|
return ref + ":" + pathPart
|
|
}
|
|
if ref != "" {
|
|
return ref
|
|
}
|
|
return "git"
|
|
case "upload":
|
|
id := strings.TrimSpace(s.ArtifactID)
|
|
if id != "" {
|
|
return id
|
|
}
|
|
return "upload"
|
|
default:
|
|
return strings.TrimSpace(s.ArtifactID)
|
|
}
|
|
}
|
|
|
|
func validateSKSourceEntry(c *gin.Context, source agnetSKSource) bool {
|
|
sourceType := strings.TrimSpace(source.Type)
|
|
if sourceType == "" {
|
|
return true
|
|
}
|
|
switch sourceType {
|
|
case "git":
|
|
ref := source.RepoRef
|
|
if strings.TrimSpace(ref.Ref) == "" {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "git sk_sources.repo_ref.ref is required")
|
|
return false
|
|
}
|
|
if len(ref.Paths) == 0 {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "git sk_sources.repo_ref.paths must not be empty")
|
|
return false
|
|
}
|
|
case "upload":
|
|
if strings.TrimSpace(source.ArtifactID) == "" {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "upload sk_sources.artifact_id is required")
|
|
return false
|
|
}
|
|
default:
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "unsupported sk source type")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateAgentSKAccessPolicy(c *gin.Context, agent agnetAgentPlan) bool {
|
|
p := agent.SKAccessPolicy
|
|
hasDeny := len(p.DenySkillIDs) > 0
|
|
if !hasDeny {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(p.PolicyRef) == "" && !p.InheritDeploymentDefaults {
|
|
agnetError(c, "SK_POLICY_REJECTED", "sk_access_policy.policy_ref or inherit_deployment_defaults is required when deny_skill_ids is set")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateOrchestrationPlan(c *gin.Context, plan agnetOrchestrationPlan) bool {
|
|
if strings.TrimSpace(plan.IntentID) == "" ||
|
|
strings.TrimSpace(plan.TemplateHint) == "" ||
|
|
strings.TrimSpace(plan.Objective) == "" {
|
|
agnetError(c, "POLICY_REJECTED", "intent_id/template_hint/objective is required")
|
|
return false
|
|
}
|
|
if len(plan.Agents) == 0 {
|
|
agnetError(c, "POLICY_REJECTED", "at least one agent is required")
|
|
return false
|
|
}
|
|
if strings.TrimSpace(plan.Metadata.TenantID) == "" ||
|
|
strings.TrimSpace(plan.Metadata.ProjectID) == "" ||
|
|
strings.TrimSpace(plan.Metadata.CorrelationID) == "" {
|
|
agnetError(c, "POLICY_REJECTED", "metadata.tenant_id/project_id/correlation_id is required")
|
|
return false
|
|
}
|
|
switch plan.RiskLevel {
|
|
case agnetRiskLow, agnetRiskMedium, agnetRiskHigh:
|
|
default:
|
|
agnetError(c, "POLICY_REJECTED", "risk_level must be low/medium/high")
|
|
return false
|
|
}
|
|
if plan.Budget.MaxTokens <= 0 || plan.Budget.MaxCostUSD <= 0 || plan.Budget.MaxDurationSec <= 0 {
|
|
agnetError(c, "POLICY_REJECTED", "budget.max_tokens/max_cost_usd/max_duration_sec must be positive")
|
|
return false
|
|
}
|
|
if plan.Budget.MaxTokens > 500000 || plan.Budget.MaxCostUSD > 200 || plan.Budget.MaxDurationSec > 24*3600 {
|
|
agnetError(c, "BUDGET_EXCEEDED", "budget exceeds current platform policy limits")
|
|
return false
|
|
}
|
|
|
|
if tenantHeader := strings.TrimSpace(c.GetHeader("X-Tenant-Id")); tenantHeader != "" && tenantHeader != plan.Metadata.TenantID {
|
|
agnetError(c, "FORBIDDEN_CROSS_TENANT", "X-Tenant-Id does not match orchestration_plan metadata.tenant_id")
|
|
return false
|
|
}
|
|
|
|
allowedModels := plan.Constraints.AllowedModelIDs
|
|
for _, agent := range plan.Agents {
|
|
if strings.TrimSpace(agent.RoleTemplate) == "" || strings.TrimSpace(agent.Goal) == "" {
|
|
agnetError(c, "POLICY_REJECTED", "each agent must contain role_template and goal")
|
|
return false
|
|
}
|
|
for _, source := range agent.SKSources {
|
|
if !validateSKSourceEntry(c, source) {
|
|
return false
|
|
}
|
|
}
|
|
modelID := strings.TrimSpace(agent.DefaultModelID)
|
|
if modelID != "" && len(allowedModels) > 0 && !containsString(allowedModels, modelID) {
|
|
agnetError(c, "MODEL_NOT_ALLOWED", "agent default_model_id is outside allowed_model_ids")
|
|
return false
|
|
}
|
|
if !validateAgentRuntimeBindings(c, agent) {
|
|
return false
|
|
}
|
|
if !validateAgentSKAccessPolicy(c, agent) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func AgnetCreateDeployment(c *gin.Context) {
|
|
var req agnetDeploymentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
agnetError(c, "POLICY_REJECTED", err.Error())
|
|
return
|
|
}
|
|
plan := req.Plan
|
|
if !validateOrchestrationPlan(c, plan) {
|
|
return
|
|
}
|
|
|
|
now := agnetNow()
|
|
deploymentID := "dep_" + common.GetUUID()[:12]
|
|
record := agnetDeploymentRecord{
|
|
DeploymentID: deploymentID,
|
|
Status: "accepted",
|
|
Phase: "pending",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Plan: plan,
|
|
}
|
|
event := agnetEvent{
|
|
EventID: "evt_" + common.GetUUID()[:12],
|
|
Event: "deployment.accepted",
|
|
SchemaVersion: 1,
|
|
TenantID: plan.Metadata.TenantID,
|
|
ProjectID: plan.Metadata.ProjectID,
|
|
DeploymentID: deploymentID,
|
|
CorrelationID: plan.Metadata.CorrelationID,
|
|
OccurredAt: now,
|
|
}
|
|
|
|
agnetMu.Lock()
|
|
agnetDeployments[deploymentID] = record
|
|
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], event)
|
|
agnetMu.Unlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"status": "accepted",
|
|
"agent_instances": []gin.H{
|
|
{
|
|
"instance_id": "agi_" + common.GetUUID()[:12],
|
|
"role": plan.Agents[0].RoleTemplate,
|
|
"phase": "pending",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func AgnetGetDeployment(c *gin.Context) {
|
|
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
|
|
if deploymentID == "" {
|
|
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
|
return
|
|
}
|
|
agnetMu.RLock()
|
|
record, ok := agnetDeployments[deploymentID]
|
|
agnetMu.RUnlock()
|
|
if !ok {
|
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
|
return
|
|
}
|
|
common.ApiSuccess(c, record)
|
|
}
|
|
|
|
func AgnetListDeployments(c *gin.Context) {
|
|
tenantID := strings.TrimSpace(c.Query("tenant_id"))
|
|
projectID := strings.TrimSpace(c.Query("project_id"))
|
|
items := make([]agnetDeploymentRecord, 0)
|
|
|
|
agnetMu.RLock()
|
|
for _, record := range agnetDeployments {
|
|
if tenantID != "" && record.Plan.Metadata.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if projectID != "" && record.Plan.Metadata.ProjectID != projectID {
|
|
continue
|
|
}
|
|
items = append(items, record)
|
|
}
|
|
agnetMu.RUnlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"items": items,
|
|
"total": len(items),
|
|
})
|
|
}
|
|
|
|
func AgnetStopDeployment(c *gin.Context) {
|
|
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
|
|
if deploymentID == "" {
|
|
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
|
return
|
|
}
|
|
|
|
agnetMu.Lock()
|
|
record, ok := agnetDeployments[deploymentID]
|
|
if !ok {
|
|
agnetMu.Unlock()
|
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
|
return
|
|
}
|
|
record.Status = "stopped"
|
|
record.Phase = "stopped"
|
|
record.UpdatedAt = agnetNow()
|
|
agnetDeployments[deploymentID] = record
|
|
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
|
|
EventID: "evt_" + common.GetUUID()[:12],
|
|
Event: "instance.phase_changed",
|
|
SchemaVersion: 1,
|
|
TenantID: record.Plan.Metadata.TenantID,
|
|
ProjectID: record.Plan.Metadata.ProjectID,
|
|
DeploymentID: deploymentID,
|
|
CorrelationID: record.Plan.Metadata.CorrelationID,
|
|
OccurredAt: agnetNow(),
|
|
})
|
|
agnetMu.Unlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"status": "stopped",
|
|
})
|
|
}
|
|
|
|
func AgnetListDeploymentEvents(c *gin.Context) {
|
|
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
|
|
if deploymentID == "" {
|
|
agnetError(c, "POLICY_REJECTED", "deployment_id is required")
|
|
return
|
|
}
|
|
agnetMu.RLock()
|
|
events := agnetEvents[deploymentID]
|
|
agnetMu.RUnlock()
|
|
common.ApiSuccess(c, gin.H{
|
|
"items": events,
|
|
"total": len(events),
|
|
})
|
|
}
|
|
|
|
func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
|
projectID := strings.TrimSpace(c.Param("project_id"))
|
|
if projectID == "" {
|
|
agnetError(c, "POLICY_REJECTED", "project_id is required")
|
|
return
|
|
}
|
|
|
|
active := 0
|
|
pending := 0
|
|
stopped := 0
|
|
agnetMu.RLock()
|
|
for _, record := range agnetDeployments {
|
|
if record.Plan.Metadata.ProjectID != projectID {
|
|
continue
|
|
}
|
|
if record.Status == "accepted" {
|
|
active++
|
|
}
|
|
if record.Phase == "pending" {
|
|
pending++
|
|
}
|
|
if record.Phase == "stopped" {
|
|
stopped++
|
|
}
|
|
}
|
|
agnetMu.RUnlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"project_id": projectID,
|
|
"active_instances": active,
|
|
"phase_distribution": gin.H{"pending": pending, "stopped": stopped},
|
|
"failure_rate_1h": 0,
|
|
"avg_task_duration": 0,
|
|
})
|
|
}
|
|
|
|
func AgnetResolveSKSnapshots(c *gin.Context) {
|
|
var req agnetSKSnapshotResolveRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", err.Error())
|
|
return
|
|
}
|
|
deploymentID := strings.TrimSpace(req.DeploymentID)
|
|
if deploymentID == "" {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
|
|
return
|
|
}
|
|
|
|
agnetMu.Lock()
|
|
record, ok := agnetDeployments[deploymentID]
|
|
if !ok {
|
|
agnetMu.Unlock()
|
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
|
return
|
|
}
|
|
|
|
snapshots := make([]agnetSKSnapshot, 0)
|
|
now := agnetNow()
|
|
for _, agent := range record.Plan.Agents {
|
|
for _, source := range agent.SKSources {
|
|
sourceType := strings.TrimSpace(source.Type)
|
|
if sourceType == "" {
|
|
continue
|
|
}
|
|
sourceRef := skSourceDisplayRef(source)
|
|
if strings.TrimSpace(sourceRef) == "" {
|
|
sourceRef = "ref_" + common.GetUUID()[:8]
|
|
}
|
|
if sourceType == "git" {
|
|
sourceRef = sourceRef + "@sha_" + common.GetUUID()[:12]
|
|
}
|
|
snapshots = append(snapshots, agnetSKSnapshot{
|
|
SnapshotID: "sks_" + common.GetUUID()[:12],
|
|
DeploymentID: deploymentID,
|
|
TenantID: record.Plan.Metadata.TenantID,
|
|
ProjectID: record.Plan.Metadata.ProjectID,
|
|
SourceType: sourceType,
|
|
SourceRef: sourceRef,
|
|
ResolvedAt: now,
|
|
})
|
|
}
|
|
}
|
|
agnetSnapshots[deploymentID] = snapshots
|
|
agnetEvents[deploymentID] = append(agnetEvents[deploymentID], agnetEvent{
|
|
EventID: "evt_" + common.GetUUID()[:12],
|
|
Event: "sk_snapshot_refreshed",
|
|
SchemaVersion: 1,
|
|
TenantID: record.Plan.Metadata.TenantID,
|
|
ProjectID: record.Plan.Metadata.ProjectID,
|
|
DeploymentID: deploymentID,
|
|
CorrelationID: record.Plan.Metadata.CorrelationID,
|
|
OccurredAt: now,
|
|
})
|
|
agnetMu.Unlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"items": snapshots,
|
|
"total": len(snapshots),
|
|
})
|
|
}
|
|
|
|
func AgnetListSKSnapshots(c *gin.Context) {
|
|
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
|
|
if deploymentID == "" {
|
|
agnetError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
|
|
return
|
|
}
|
|
agnetMu.RLock()
|
|
items := agnetSnapshots[deploymentID]
|
|
agnetMu.RUnlock()
|
|
common.ApiSuccess(c, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"items": items,
|
|
"total": len(items),
|
|
})
|
|
}
|
|
|
|
func AgnetListAuditLogs(c *gin.Context) {
|
|
projectID := strings.TrimSpace(c.Query("project_id"))
|
|
items := make([]gin.H, 0)
|
|
agnetMu.RLock()
|
|
for deploymentID, events := range agnetEvents {
|
|
for _, event := range events {
|
|
if projectID != "" && event.ProjectID != projectID {
|
|
continue
|
|
}
|
|
items = append(items, gin.H{
|
|
"actor": "agnet_control_plane",
|
|
"action": event.Event,
|
|
"resource": deploymentID,
|
|
"tenant_id": event.TenantID,
|
|
"request_id": agnetRequestID(c),
|
|
"correlation_id": event.CorrelationID,
|
|
"result": "ok",
|
|
"occurred_at": event.OccurredAt,
|
|
})
|
|
}
|
|
}
|
|
agnetMu.RUnlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"items": items,
|
|
"total": len(items),
|
|
})
|
|
}
|