- add Manager user_context, NewAPI billing_context, and Agnet agent_runtime deployment fields - move resource binding/grant scope toward user-owned binding_scope and secret_ref-only paths - document OpenBao internal access and unified heicode.xinghanlab.com routing boundaries - fix Manager session user id preservation after external auth login
932 lines
28 KiB
Go
932 lines
28 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/heicode/manager/common"
|
|
)
|
|
|
|
const (
|
|
agnetRiskLow = "low"
|
|
agnetRiskMedium = "medium"
|
|
agnetRiskHigh = "high"
|
|
|
|
agnetResourceGit = "git"
|
|
agnetResourceSK = "sk"
|
|
agnetResourceProjectDoc = "project_doc"
|
|
agnetResourceCloudAccount = "cloud_account"
|
|
agnetResourceCloudResource = "cloud_resource"
|
|
|
|
agnetGrantStatusPending = "pending"
|
|
agnetGrantStatusActive = "active"
|
|
agnetGrantStatusDisabled = "disabled"
|
|
agnetGrantStatusRevoked = "revoked"
|
|
)
|
|
|
|
type agnetBudget struct {
|
|
MaxTokens int `json:"max_tokens"`
|
|
MaxCostUSD float64 `json:"max_cost_usd"`
|
|
MaxDurationSec int `json:"max_duration_sec"`
|
|
}
|
|
|
|
type agnetUserContext struct {
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
ChannelID string `json:"channel_id"`
|
|
SubscriptionTier string `json:"subscription_tier"`
|
|
}
|
|
|
|
type agnetBillingContext struct {
|
|
Provider string `json:"provider"`
|
|
NewAPIUserRef string `json:"newapi_user_ref"`
|
|
NewAPIGroup string `json:"newapi_group"`
|
|
QuotaRef string `json:"quota_ref"`
|
|
}
|
|
|
|
type agnetRuntimeAgent struct {
|
|
Role string `json:"role"`
|
|
ModelRef string `json:"model_ref"`
|
|
InstanceCount int `json:"instance_count"`
|
|
}
|
|
|
|
type agnetAgentRuntime struct {
|
|
Platform string `json:"platform"`
|
|
Agents []agnetRuntimeAgent `json:"agents"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// agnetResourceGrant is the Manager-side resource binding envelope from docs/heicode.md P1.
|
|
// It deliberately carries only metadata, scoped permissions and secret_ref, never plaintext secrets.
|
|
type agnetResourceGrant struct {
|
|
GrantID string `json:"grant_id"`
|
|
ResourceID string `json:"resource_id"`
|
|
ResourceType string `json:"resource_type"`
|
|
UserID string `json:"user_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only.
|
|
ProjectID string `json:"project_id,omitempty"` // legacy compatibility only.
|
|
TargetRole string `json:"target_role"`
|
|
TargetAgentRef string `json:"target_agent_ref"`
|
|
PermissionScope []string `json:"permission_scope"`
|
|
Constraints map[string]string `json:"constraints"`
|
|
Metadata map[string]string `json:"metadata"`
|
|
Status string `json:"status"`
|
|
SecretRef string `json:"secret_ref"`
|
|
Audit map[string]string `json:"audit"`
|
|
}
|
|
|
|
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"`
|
|
ResourceGrants []agnetResourceGrant `json:"resource_grants"`
|
|
}
|
|
|
|
type agnetConstraints struct {
|
|
AllowedModelIDs []string `json:"allowed_model_ids"`
|
|
}
|
|
|
|
type agnetMetadata struct {
|
|
TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only.
|
|
ProjectID string `json:"project_id,omitempty"` // legacy compatibility only.
|
|
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"`
|
|
UserContext agnetUserContext `json:"user_context"`
|
|
BillingContext agnetBillingContext `json:"billing_context"`
|
|
AgentRuntime agnetAgentRuntime `json:"agent_runtime"`
|
|
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"`
|
|
UserID string `json:"user_id"`
|
|
ChannelID string `json:"channel_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
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"`
|
|
UserID string `json:"user_id"`
|
|
BindingScope string `json:"binding_scope"`
|
|
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 containsSensitiveGrantField(values map[string]string) bool {
|
|
for key := range values {
|
|
normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(key), "-", "_"))
|
|
if strings.Contains(normalized, "password") ||
|
|
strings.Contains(normalized, "token") ||
|
|
strings.Contains(normalized, "secret") ||
|
|
strings.Contains(normalized, "private_key") ||
|
|
strings.Contains(normalized, "access_key") ||
|
|
strings.Contains(normalized, "credential") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func agnetResourceTypeNeedsSecretRef(resourceType string) bool {
|
|
switch resourceType {
|
|
case agnetResourceGit, agnetResourceSK, agnetResourceCloudAccount, agnetResourceCloudResource:
|
|
return true
|
|
default:
|
|
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 set")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateBillingContext(c *gin.Context, plan agnetOrchestrationPlan) bool {
|
|
billing := plan.BillingContext
|
|
provider := strings.TrimSpace(strings.ToLower(billing.Provider))
|
|
if provider == "" {
|
|
return true
|
|
}
|
|
if provider != "newapi" {
|
|
agnetError(c, "BILLING_CONTEXT_INVALID", "billing_context.provider must be newapi when set")
|
|
return false
|
|
}
|
|
if strings.TrimSpace(plan.UserContext.ChannelID) == "" &&
|
|
strings.TrimSpace(billing.NewAPIUserRef) == "" &&
|
|
strings.TrimSpace(billing.NewAPIGroup) == "" &&
|
|
strings.TrimSpace(billing.QuotaRef) == "" {
|
|
agnetError(c, "BILLING_CONTEXT_INVALID", "newapi billing_context requires channel_id, newapi_user_ref, newapi_group, or quota_ref")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateAgentRuntimeContext(c *gin.Context, plan agnetOrchestrationPlan) bool {
|
|
runtime := plan.AgentRuntime
|
|
platform := strings.TrimSpace(strings.ToLower(runtime.Platform))
|
|
if platform == "" && len(runtime.Agents) == 0 {
|
|
return true
|
|
}
|
|
if platform != "agnet" {
|
|
agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime.platform must be agnet when runtime context is present")
|
|
return false
|
|
}
|
|
if len(runtime.Agents) == 0 {
|
|
agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime.agents must not be empty when runtime context is present")
|
|
return false
|
|
}
|
|
|
|
knownRoles := map[string]bool{}
|
|
for _, agent := range plan.Agents {
|
|
role := strings.TrimSpace(agent.RoleTemplate)
|
|
if role != "" {
|
|
knownRoles[role] = true
|
|
}
|
|
}
|
|
for _, agent := range runtime.Agents {
|
|
role := strings.TrimSpace(agent.Role)
|
|
if role == "" || strings.TrimSpace(agent.ModelRef) == "" || agent.InstanceCount <= 0 {
|
|
agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agents require role/model_ref/positive instance_count")
|
|
return false
|
|
}
|
|
if len(knownRoles) > 0 && !knownRoles[role] {
|
|
agnetError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agent role must match an orchestration agent role")
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validateResourceGrant(c *gin.Context, plan agnetOrchestrationPlan, agent agnetAgentPlan, grant agnetResourceGrant) bool {
|
|
resourceType := strings.TrimSpace(grant.ResourceType)
|
|
switch resourceType {
|
|
case agnetResourceGit, agnetResourceSK, agnetResourceProjectDoc, agnetResourceCloudAccount, agnetResourceCloudResource:
|
|
default:
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.resource_type must be git/sk/project_doc/cloud_account/cloud_resource")
|
|
return false
|
|
}
|
|
|
|
if strings.TrimSpace(grant.GrantID) == "" || strings.TrimSpace(grant.ResourceID) == "" ||
|
|
strings.TrimSpace(grant.TargetRole) == "" || strings.TrimSpace(grant.TargetAgentRef) == "" {
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants require grant_id/resource_id/target_role/target_agent_ref")
|
|
return false
|
|
}
|
|
|
|
grantUserID := strings.TrimSpace(grant.UserID)
|
|
planUserID := strings.TrimSpace(plan.UserContext.UserID)
|
|
bindingScope := strings.TrimSpace(grant.BindingScope)
|
|
if bindingScope == "" {
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.binding_scope is required")
|
|
return false
|
|
}
|
|
if planUserID != "" && grantUserID != "" && grantUserID != planUserID {
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.user_id must match orchestration user_context.user_id")
|
|
return false
|
|
}
|
|
if strings.TrimSpace(grant.TargetRole) != strings.TrimSpace(agent.RoleTemplate) {
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.target_role must match the assigned agent role")
|
|
return false
|
|
}
|
|
if len(grant.PermissionScope) == 0 {
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.permission_scope must not be empty")
|
|
return false
|
|
}
|
|
switch strings.TrimSpace(grant.Status) {
|
|
case agnetGrantStatusPending, agnetGrantStatusActive, agnetGrantStatusDisabled, agnetGrantStatusRevoked:
|
|
default:
|
|
agnetError(c, "RESOURCE_GRANT_INVALID", "resource_grants.status must be pending/active/disabled/revoked")
|
|
return false
|
|
}
|
|
if agnetResourceTypeNeedsSecretRef(resourceType) && strings.TrimSpace(grant.SecretRef) == "" {
|
|
agnetError(c, "RESOURCE_GRANT_SECRET_REF_REQUIRED", "resource_grants.secret_ref is required for credential-backed resources")
|
|
return false
|
|
}
|
|
if containsSensitiveGrantField(grant.Metadata) || containsSensitiveGrantField(grant.Constraints) || containsSensitiveGrantField(grant.Audit) {
|
|
agnetError(c, "RESOURCE_GRANT_SECRET_REJECTED", "resource_grants metadata/constraints/audit must not contain plaintext credential fields")
|
|
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.UserContext.UserID) == "" {
|
|
agnetError(c, "POLICY_REJECTED", "user_context.user_id is required")
|
|
return false
|
|
}
|
|
if strings.TrimSpace(plan.Metadata.CorrelationID) == "" {
|
|
agnetError(c, "POLICY_REJECTED", "metadata.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 !validateBillingContext(c, plan) {
|
|
return false
|
|
}
|
|
if !validateAgentRuntimeContext(c, plan) {
|
|
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
|
|
}
|
|
for _, grant := range agent.ResourceGrants {
|
|
if !validateResourceGrant(c, plan, agent, grant) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func firstPlanBindingScope(plan agnetOrchestrationPlan) string {
|
|
for _, agent := range plan.Agents {
|
|
for _, grant := range agent.ResourceGrants {
|
|
if scope := strings.TrimSpace(grant.BindingScope); scope != "" {
|
|
return scope
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func planHasBindingScope(plan agnetOrchestrationPlan, bindingScope string) bool {
|
|
bindingScope = strings.TrimSpace(bindingScope)
|
|
if bindingScope == "" {
|
|
return true
|
|
}
|
|
for _, agent := range plan.Agents {
|
|
for _, grant := range agent.ResourceGrants {
|
|
if strings.TrimSpace(grant.BindingScope) == bindingScope {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func applyAuthenticatedManagerUserContext(c *gin.Context, plan *agnetOrchestrationPlan) {
|
|
if plan == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(plan.UserContext.UserID) == "" {
|
|
if userID := c.GetInt("id"); userID > 0 {
|
|
plan.UserContext.UserID = fmt.Sprintf("%d", userID)
|
|
}
|
|
}
|
|
if strings.TrimSpace(plan.UserContext.ChannelID) == "" {
|
|
if group, ok := c.Get("group"); ok {
|
|
if channelID, ok := group.(string); ok {
|
|
plan.UserContext.ChannelID = strings.TrimSpace(channelID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
applyAuthenticatedManagerUserContext(c, &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,
|
|
UserID: plan.UserContext.UserID,
|
|
ChannelID: plan.UserContext.ChannelID,
|
|
BindingScope: firstPlanBindingScope(plan),
|
|
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) {
|
|
userID := strings.TrimSpace(c.Query("user_id"))
|
|
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
|
items := make([]agnetDeploymentRecord, 0)
|
|
|
|
agnetMu.RLock()
|
|
for _, record := range agnetDeployments {
|
|
if userID != "" && record.Plan.UserContext.UserID != userID {
|
|
continue
|
|
}
|
|
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
|
|
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,
|
|
UserID: record.Plan.UserContext.UserID,
|
|
ChannelID: record.Plan.UserContext.ChannelID,
|
|
BindingScope: firstPlanBindingScope(record.Plan),
|
|
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 AgnetListDeploymentLogs(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]
|
|
events := agnetEvents[deploymentID]
|
|
agnetMu.RUnlock()
|
|
if !ok {
|
|
agnetError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
|
|
return
|
|
}
|
|
|
|
items := make([]gin.H, 0, len(events)+1)
|
|
items = append(items, gin.H{
|
|
"timestamp": record.CreatedAt,
|
|
"deployment_id": deploymentID,
|
|
"stream": "control",
|
|
"level": "info",
|
|
"message": "deployment accepted by Manager control-plane placeholder",
|
|
"phase": record.Phase,
|
|
"correlation_id": record.Plan.Metadata.CorrelationID,
|
|
"redacted": true,
|
|
})
|
|
for _, event := range events {
|
|
items = append(items, gin.H{
|
|
"timestamp": event.OccurredAt,
|
|
"deployment_id": event.DeploymentID,
|
|
"stream": "event",
|
|
"level": "info",
|
|
"message": event.Event,
|
|
"phase": record.Phase,
|
|
"correlation_id": event.CorrelationID,
|
|
"redacted": true,
|
|
})
|
|
}
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"items": items,
|
|
"next_cursor": "",
|
|
"redacted": true,
|
|
"total": len(items),
|
|
})
|
|
}
|
|
|
|
func AgnetGetDeploymentMetrics(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, gin.H{
|
|
"deployment_id": deploymentID,
|
|
"window": strings.TrimSpace(c.DefaultQuery("window", "15m")),
|
|
"step": strings.TrimSpace(c.DefaultQuery("step", "60s")),
|
|
"phase": record.Phase,
|
|
"status": record.Status,
|
|
"resource_usage": gin.H{
|
|
"cpu_percent": 0,
|
|
"memory_bytes": 0,
|
|
"network_rx_bytes": 0,
|
|
"network_tx_bytes": 0,
|
|
"task_duration_sec": 0,
|
|
"platform_estimated": true,
|
|
},
|
|
"series": []gin.H{},
|
|
})
|
|
}
|
|
|
|
func AgnetProjectDashboardSnapshot(c *gin.Context) {
|
|
bindingScope := strings.TrimSpace(c.Param("project_id"))
|
|
if bindingScope == "" {
|
|
agnetError(c, "POLICY_REJECTED", "binding_scope is required")
|
|
return
|
|
}
|
|
|
|
active := 0
|
|
pending := 0
|
|
stopped := 0
|
|
agnetMu.RLock()
|
|
for _, record := range agnetDeployments {
|
|
if !planHasBindingScope(record.Plan, bindingScope) {
|
|
continue
|
|
}
|
|
if record.Status == "accepted" {
|
|
active++
|
|
}
|
|
if record.Phase == "pending" {
|
|
pending++
|
|
}
|
|
if record.Phase == "stopped" {
|
|
stopped++
|
|
}
|
|
}
|
|
agnetMu.RUnlock()
|
|
|
|
common.ApiSuccess(c, gin.H{
|
|
"binding_scope": bindingScope,
|
|
"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,
|
|
UserID: record.Plan.UserContext.UserID,
|
|
BindingScope: firstPlanBindingScope(record.Plan),
|
|
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,
|
|
UserID: record.Plan.UserContext.UserID,
|
|
ChannelID: record.Plan.UserContext.ChannelID,
|
|
BindingScope: firstPlanBindingScope(record.Plan),
|
|
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) {
|
|
userID := strings.TrimSpace(c.Query("user_id"))
|
|
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
|
|
items := make([]gin.H, 0)
|
|
agnetMu.RLock()
|
|
for deploymentID, events := range agnetEvents {
|
|
for _, event := range events {
|
|
if userID != "" && event.UserID != userID {
|
|
continue
|
|
}
|
|
if bindingScope != "" && event.BindingScope != bindingScope {
|
|
continue
|
|
}
|
|
items = append(items, gin.H{
|
|
"actor": "agnet_control_plane",
|
|
"action": event.Event,
|
|
"resource": deploymentID,
|
|
"user_id": event.UserID,
|
|
"channel_id": event.ChannelID,
|
|
"binding_scope": event.BindingScope,
|
|
"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),
|
|
})
|
|
}
|