Files
heicode-mananger/heicode/controller/agent_control_plane.go
T

2098 lines
69 KiB
Go

package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
const (
agentRiskLow = "low"
agentRiskMedium = "medium"
agentRiskHigh = "high"
agentSubModeAgile = "agile"
agentSubModeWaterfall = "waterfall"
agentResourceGit = "git"
agentResourceSK = "sk"
agentResourceProjectDoc = "project_doc"
agentResourceCloudAccount = "cloud_account"
agentResourceCloudResource = "cloud_resource"
agentGrantStatusPending = "pending"
agentGrantStatusActive = "active"
agentGrantStatusDisabled = "disabled"
agentGrantStatusRevoked = "revoked"
)
type agentBudget struct {
MaxTokens int `json:"max_tokens"`
MaxCostUSD float64 `json:"max_cost_usd"`
MaxDurationSec int `json:"max_duration_sec"`
}
type agentUserContext 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 agentBillingContext struct {
Provider string `json:"provider"`
NewAPIUserRef string `json:"newapi_user_ref"`
NewAPIGroup string `json:"newapi_group"`
QuotaRef string `json:"quota_ref"`
DefaultModelID string `json:"default_model_id"`
AllowedModelIDs []string `json:"allowed_model_ids"`
SecretRef string `json:"secret_ref"`
}
type agentAgileContext struct {
Iteration string `json:"iteration"`
Stage string `json:"stage"`
Checkpoint string `json:"checkpoint"`
AcceptanceCriteria []string `json:"acceptance_criteria"`
NextAction string `json:"next_action"`
RequiresUserApproval bool `json:"requires_user_approval"`
}
type agentRuntimeAgent struct {
Role string `json:"role"`
ModelRef string `json:"model_ref"`
InstanceCount int `json:"instance_count"`
}
type agentAgentRuntime struct {
Platform string `json:"platform"`
Agents []agentRuntimeAgent `json:"agents"`
}
// agentRepoRef matches docs/integration/orchestration-plan-contract.md (git sk_sources).
type agentRepoRef struct {
ConnectionID string `json:"connection_id"`
RepoURL string `json:"repo_url"`
Ref string `json:"ref"`
Paths []string `json:"paths"`
}
type agentSKSource struct {
Type string `json:"type"`
ArtifactID string `json:"artifact_id"`
Mime string `json:"mime"`
RepoRef agentRepoRef `json:"repo_ref"`
}
// agentRuntimeExecution mirrors docs/integration/agent-platform-api-design.md §5.0 (runtime_execution).
type agentRuntimeExecution struct {
ProfileID string `json:"profile_id"`
CloudPrincipalRefs []string `json:"cloud_principal_refs"`
NetworkPolicyRef string `json:"network_policy_ref"`
}
// agentSKAccessPolicy mirrors docs/integration/agent-platform-api-design.md §5.0 (sk_access_policy).
type agentSKAccessPolicy struct {
PolicyRef string `json:"policy_ref"`
DenySkillIDs []string `json:"deny_skill_ids"`
InheritDeploymentDefaults bool `json:"inherit_deployment_defaults"`
}
// agentResourceGrant 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 agentResourceGrant 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"`
// ResourceBindingID lets the client reference a stored ResourceBinding
// instead of inlining a secret_ref (unified spec §17.6). Manager resolves it
// to the binding's real secret_ref/resource metadata server-side.
ResourceBindingID int `json:"resource_binding_id,omitempty"`
Audit map[string]string `json:"audit"`
}
type agentAgentPlan struct {
RoleTemplate string `json:"role_template"`
Goal string `json:"goal"`
DefaultModelID string `json:"default_model_id"`
SKSources []agentSKSource `json:"sk_sources"`
RuntimeExecution agentRuntimeExecution `json:"runtime_execution"`
SKAccessPolicy agentSKAccessPolicy `json:"sk_access_policy"`
ResourceGrants []agentResourceGrant `json:"resource_grants"`
}
type agentConstraints struct {
AllowedModelIDs []string `json:"allowed_model_ids"`
}
type agentMetadata struct {
TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only.
ProjectID string `json:"project_id,omitempty"` // legacy compatibility only.
CorrelationID string `json:"correlation_id"`
RuntimeMode string `json:"runtime_mode,omitempty"`
}
type agentOrchestrationPlan struct {
IntentID string `json:"intent_id"`
TemplateHint string `json:"template_hint"`
Objective string `json:"objective"`
SubMode string `json:"sub_mode"`
RiskLevel string `json:"risk_level"`
Budget agentBudget `json:"budget"`
UserContext agentUserContext `json:"user_context"`
BillingContext agentBillingContext `json:"billing_context"`
AgileContext agentAgileContext `json:"agile_context"`
AgentRuntime agentAgentRuntime `json:"agent_runtime"`
Agents []agentAgentPlan `json:"agents"`
ResourceGrants []agentResourceGrant `json:"resource_grants"`
Constraints agentConstraints `json:"constraints"`
Metadata agentMetadata `json:"metadata"`
}
type agentDeploymentRequest struct {
Plan agentOrchestrationPlan `json:"orchestration_plan"`
}
type agentDeploymentRecord struct {
DeploymentID string `json:"deployment_id"`
// Mode is the client-facing routing mode (`sub_agile` | `swarm`), derived
// from the runtime mode so list/detail/workflow agree with capabilities
// (gap D/F). SubMode is the intra-sub_agile cadence (`agile`/`waterfall`).
Mode string `json:"mode,omitempty"`
SubMode string `json:"sub_mode"`
Status string `json:"status"`
DisplayStatus string `json:"display_status,omitempty"`
Phase string `json:"phase"`
RuntimeState string `json:"runtime_state"`
RuntimeDeploymentID string `json:"runtime_deployment_id,omitempty"`
RuntimeSwarmID string `json:"runtime_swarm_id,omitempty"`
RuntimeLastSyncAt string `json:"runtime_last_sync_at,omitempty"`
FailureReason string `json:"failure_reason"`
AgentInstances []agentAgentInstance `json:"agent_instances"`
ResourceGrantManifest agentPermissionManifest `json:"permission_manifest"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Plan agentOrchestrationPlan `json:"orchestration_plan"`
}
type agentAgentInstance struct {
InstanceID string `json:"instance_id"`
Role string `json:"role"`
Phase string `json:"phase"`
RuntimeState string `json:"runtime_state"`
FailureReason string `json:"failure_reason"`
}
type agentManifestGrant struct {
GrantID string `json:"grant_id"`
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
ResourceRef string `json:"resource_ref"`
AllowedActions []string `json:"allowed_actions"`
Constraints map[string]string `json:"constraints"`
SecretRef string `json:"secret_ref,omitempty"`
Status string `json:"status"`
}
type agentPermissionManifest struct {
UserID string `json:"user_id"`
BindingScope string `json:"binding_scope"`
AgentRole string `json:"agent_role"`
TargetAgentRef string `json:"target_agent_ref"`
ResourceGrants []agentManifestGrant `json:"resource_grants"`
}
type agentEvent 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 agentSKSnapshotResolveRequest struct {
DeploymentID string `json:"deployment_id"`
}
type agentSimulateDeploymentEventsRequest struct {
Events []string `json:"events"`
}
type agentSKSnapshot 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 (
agentMu sync.RWMutex
agentDeployments = make(map[string]agentDeploymentRecord)
agentSnapshots = make(map[string][]agentSKSnapshot)
)
// recordAgentAuditEvent persists `evt` to model.AgentAuditEvent
// (audit table) so dashboards and admin queries survive a container
// restart. The previous implementation appended to an in-process
// map[string][]agentEvent which was lost on every redeploy.
//
// `requestID` and `result` are usually unknown at the producer site
// (we're called from inside the deployment lifecycle, not from a
// request handler with a c.GetString), so they're free-form here.
// Pass "" / "ok" when you don't have specifics.
func recordAgentAuditEvent(evt agentEvent, actor, resource, requestID, result string) {
if result == "" {
result = "ok"
}
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
EventID: evt.EventID,
Event: evt.Event,
Actor: actor,
Resource: resource,
UserID: evt.UserID,
ChannelID: evt.ChannelID,
BindingScope: evt.BindingScope,
DeploymentID: evt.DeploymentID,
CorrelationID: evt.CorrelationID,
RequestID: requestID,
Result: result,
SchemaVersion: evt.SchemaVersion,
OccurredAt: parseAgentOccurredAtMs(evt.OccurredAt),
})
}
// parseAgentOccurredAtMs converts the RFC3339 string emitted by
// agentNow() into unix-ms for the DB column. Falls back to "now" so
// a malformed timestamp doesn't drop the row.
func parseAgentOccurredAtMs(s string) int64 {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UnixMilli()
}
return time.Now().UnixMilli()
}
func agentNow() string {
return time.Now().UTC().Format(time.RFC3339)
}
func agentRequestID(c *gin.Context) string {
if reqID := strings.TrimSpace(c.GetString(common.RequestIdKey)); reqID != "" {
return reqID
}
return common.GetUUID()
}
func agentTimestampMs(s string) int64 {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t.UnixMilli()
}
return time.Now().UnixMilli()
}
func marshalAgentSnapshot(v any) (string, error) {
data, err := common.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
func agentDeploymentModelToRecord(row model.AgentDeployment) (agentDeploymentRecord, error) {
var record agentDeploymentRecord
record.DeploymentID = row.DeploymentID
record.SubMode = normalizeAgentSubMode(row.SubMode)
record.Status = row.Status
record.Phase = row.Phase
record.RuntimeState = row.RuntimeState
record.RuntimeDeploymentID = row.RuntimeDeploymentID
record.RuntimeSwarmID = row.RuntimeSwarmID
record.RuntimeLastSyncAt = row.RuntimeLastSyncAtText
record.FailureReason = row.FailureReason
record.CreatedAt = row.CreatedAtText
record.UpdatedAt = row.UpdatedAtText
if row.PlanJSON != "" {
if err := common.UnmarshalJsonStr(row.PlanJSON, &record.Plan); err != nil {
return record, err
}
}
if row.AgentInstancesJSON != "" {
if err := common.UnmarshalJsonStr(row.AgentInstancesJSON, &record.AgentInstances); err != nil {
return record, err
}
}
if row.PermissionManifestJSON != "" {
if err := common.UnmarshalJsonStr(row.PermissionManifestJSON, &record.ResourceGrantManifest); err != nil {
return record, err
}
}
if record.AgentInstances == nil {
record.AgentInstances = []agentAgentInstance{}
}
if record.ResourceGrantManifest.ResourceGrants == nil {
record.ResourceGrantManifest.ResourceGrants = []agentManifestGrant{}
}
return record, nil
}
func persistAgentDeploymentRecord(record agentDeploymentRecord, req agentDeploymentRequest) error {
if model.DB == nil {
return nil
}
planJSON, err := marshalAgentSnapshot(record.Plan)
if err != nil {
return err
}
instancesJSON, err := marshalAgentSnapshot(record.AgentInstances)
if err != nil {
return err
}
manifestJSON, err := marshalAgentSnapshot(record.ResourceGrantManifest)
if err != nil {
return err
}
payloadJSON, err := marshalAgentSnapshot(req)
if err != nil {
return err
}
row := model.AgentDeployment{
DeploymentID: record.DeploymentID,
UserID: record.Plan.UserContext.UserID,
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: record.Plan.Metadata.CorrelationID,
SubMode: normalizeAgentSubMode(record.SubMode),
Status: record.Status,
Phase: record.Phase,
RuntimeState: record.RuntimeState,
RuntimeDeploymentID: record.RuntimeDeploymentID,
RuntimeSwarmID: record.RuntimeSwarmID,
RuntimeLastSyncAtText: record.RuntimeLastSyncAt,
FailureReason: record.FailureReason,
CreatedAtText: record.CreatedAt,
UpdatedAtText: record.UpdatedAt,
CreatedAtMs: agentTimestampMs(record.CreatedAt),
UpdatedAtMs: agentTimestampMs(record.UpdatedAt),
PlanJSON: planJSON,
AgentInstancesJSON: instancesJSON,
PermissionManifestJSON: manifestJSON,
PayloadJSON: payloadJSON,
}
return model.DB.Create(&row).Error
}
func updateAgentDeploymentRecord(record agentDeploymentRecord) error {
if model.DB == nil {
return nil
}
instancesJSON, err := marshalAgentSnapshot(record.AgentInstances)
if err != nil {
return err
}
manifestJSON, err := marshalAgentSnapshot(record.ResourceGrantManifest)
if err != nil {
return err
}
return model.DB.Model(&model.AgentDeployment{}).
Where("deployment_id = ?", record.DeploymentID).
Updates(map[string]any{
"status": record.Status,
"sub_mode": normalizeAgentSubMode(record.SubMode),
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"runtime_deployment_id": record.RuntimeDeploymentID,
"runtime_swarm_id": record.RuntimeSwarmID,
"runtime_last_sync_at_text": record.RuntimeLastSyncAt,
"failure_reason": record.FailureReason,
"updated_at_text": record.UpdatedAt,
"updated_at_ms": agentTimestampMs(record.UpdatedAt),
"agent_instances_json": instancesJSON,
"permission_manifest_json": manifestJSON,
}).Error
}
func normalizeAgentSubMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case agentSubModeWaterfall:
return agentSubModeWaterfall
default:
return agentSubModeAgile
}
}
func isValidAgentSubMode(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", agentSubModeAgile, agentSubModeWaterfall:
return true
default:
return false
}
}
func findAgentDeploymentRecord(deploymentID string) (agentDeploymentRecord, bool) {
agentMu.RLock()
record, ok := agentDeployments[deploymentID]
agentMu.RUnlock()
if ok {
return record, true
}
if model.DB == nil {
return agentDeploymentRecord{}, false
}
var row model.AgentDeployment
if err := model.DB.Where("deployment_id = ?", deploymentID).First(&row).Error; err != nil {
return agentDeploymentRecord{}, false
}
record, err := agentDeploymentModelToRecord(row)
if err != nil {
common.SysLog("findAgentDeploymentRecord: " + err.Error())
return agentDeploymentRecord{}, false
}
agentMu.Lock()
agentDeployments[deploymentID] = record
agentMu.Unlock()
return record, true
}
func listAgentDeploymentRecords(userID string, bindingScope string) []agentDeploymentRecord {
items := make([]agentDeploymentRecord, 0)
if model.DB != nil {
q := model.DB.Model(&model.AgentDeployment{})
if userID != "" {
q = q.Where("user_id = ?", userID)
}
var rows []model.AgentDeployment
if err := q.Order("created_at_ms desc, id desc").Limit(500).Find(&rows).Error; err == nil {
for _, row := range rows {
record, err := agentDeploymentModelToRecord(row)
if err != nil {
common.SysLog("listAgentDeploymentRecords: " + err.Error())
continue
}
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
continue
}
items = append(items, record)
}
return items
} else {
common.SysLog("listAgentDeploymentRecords: " + err.Error())
}
}
agentMu.RLock()
for _, record := range agentDeployments {
if userID != "" && record.Plan.UserContext.UserID != userID {
continue
}
if bindingScope != "" && !planHasBindingScope(record.Plan, bindingScope) {
continue
}
items = append(items, record)
}
agentMu.RUnlock()
return items
}
func agentError(c *gin.Context, code string, message string) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": message,
"error": gin.H{
"code": code,
"message": message,
"retryable": false,
"request_id": agentRequestID(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, val := 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
}
if valueLooksLikeSecret(val) {
return true
}
}
return false
}
func agentResourceTypeNeedsSecretRef(resourceType string) bool {
switch resourceType {
case agentResourceGit, agentResourceSK, agentResourceCloudAccount, agentResourceCloudResource:
return true
default:
return false
}
}
func agentRuntimePartiallySet(r agentRuntimeExecution) bool {
return strings.TrimSpace(r.ProfileID) != "" ||
len(r.CloudPrincipalRefs) > 0 ||
strings.TrimSpace(r.NetworkPolicyRef) != ""
}
func validateAgentRuntimeBindings(c *gin.Context, agent agentAgentPlan) bool {
r := agent.RuntimeExecution
if !agentRuntimePartiallySet(r) {
return true
}
if strings.TrimSpace(r.ProfileID) == "" {
agentError(c, "RUNTIME_BINDING_INVALID", "runtime_execution.profile_id is required when runtime bindings are present")
return false
}
return true
}
func skSourceDisplayRef(s agentSKSource) 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 agentSKSource) bool {
sourceType := strings.TrimSpace(source.Type)
if sourceType == "" {
return true
}
switch sourceType {
case "git":
ref := source.RepoRef
if strings.TrimSpace(ref.Ref) == "" {
agentError(c, "SK_SOURCE_UNRESOLVABLE", "git sk_sources.repo_ref.ref is required")
return false
}
if len(ref.Paths) == 0 {
agentError(c, "SK_SOURCE_UNRESOLVABLE", "git sk_sources.repo_ref.paths must not be empty")
return false
}
case "upload":
if strings.TrimSpace(source.ArtifactID) == "" {
agentError(c, "SK_SOURCE_UNRESOLVABLE", "upload sk_sources.artifact_id is required")
return false
}
default:
agentError(c, "SK_SOURCE_UNRESOLVABLE", "unsupported sk source type")
return false
}
return true
}
func validateAgentSKAccessPolicy(c *gin.Context, agent agentAgentPlan) bool {
p := agent.SKAccessPolicy
hasDeny := len(p.DenySkillIDs) > 0
if !hasDeny {
return true
}
if strings.TrimSpace(p.PolicyRef) == "" && !p.InheritDeploymentDefaults {
agentError(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 agentOrchestrationPlan) bool {
billing := plan.BillingContext
provider := strings.TrimSpace(strings.ToLower(billing.Provider))
if provider == "" {
return true
}
if provider != "newapi" {
agentError(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) == "" {
agentError(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 agentOrchestrationPlan) bool {
runtime := plan.AgentRuntime
platform := strings.TrimSpace(strings.ToLower(runtime.Platform))
if platform == "" && len(runtime.Agents) == 0 {
return true
}
if platform != "agent" {
agentError(c, "AGENT_RUNTIME_INVALID", "agent_runtime.platform must be agent when runtime context is present")
return false
}
if len(runtime.Agents) == 0 {
agentError(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 {
agentError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agents require role/model_ref/positive instance_count")
return false
}
if len(knownRoles) > 0 && !knownRoles[role] {
agentError(c, "AGENT_RUNTIME_INVALID", "agent_runtime agent role must match an orchestration agent role")
return false
}
}
return true
}
func validateResourceGrant(c *gin.Context, plan agentOrchestrationPlan, agent agentAgentPlan, grant agentResourceGrant) bool {
resourceType := strings.TrimSpace(grant.ResourceType)
switch resourceType {
case agentResourceGit, agentResourceSK, agentResourceProjectDoc, agentResourceCloudAccount, agentResourceCloudResource:
default:
agentError(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) == "" {
agentError(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 == "" {
agentError(c, "RESOURCE_GRANT_INVALID", "resource_grants.binding_scope is required")
return false
}
if planUserID != "" && grantUserID != "" && grantUserID != planUserID {
agentError(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) {
agentError(c, "RESOURCE_GRANT_INVALID", "resource_grants.target_role must match the assigned agent role")
return false
}
if len(grant.PermissionScope) == 0 {
agentError(c, "RESOURCE_GRANT_INVALID", "resource_grants.permission_scope must not be empty")
return false
}
switch strings.TrimSpace(grant.Status) {
case agentGrantStatusPending, agentGrantStatusActive, agentGrantStatusDisabled, agentGrantStatusRevoked:
default:
agentError(c, "RESOURCE_GRANT_INVALID", "resource_grants.status must be pending/active/disabled/revoked")
return false
}
if agentResourceTypeNeedsSecretRef(resourceType) && strings.TrimSpace(grant.SecretRef) == "" {
agentError(c, "RESOURCE_GRANT_SECRET_REF_REQUIRED", "resource_grants.secret_ref is required for credential-backed resources")
return false
}
if strings.TrimSpace(grant.SecretRef) != "" && !strings.HasPrefix(strings.TrimSpace(grant.SecretRef), "azkv://") {
agentError(c, "SECRET_REF_INVALID", "resource_grants.secret_ref must use azkv:// Azure Key Vault reference")
return false
}
if containsSensitiveGrantField(grant.Metadata) || containsSensitiveGrantField(grant.Constraints) || containsSensitiveGrantField(grant.Audit) {
agentError(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 agentOrchestrationPlan) bool {
if strings.TrimSpace(plan.IntentID) == "" ||
strings.TrimSpace(plan.TemplateHint) == "" ||
strings.TrimSpace(plan.Objective) == "" {
agentError(c, "POLICY_REJECTED", "intent_id/template_hint/objective is required")
return false
}
if len(plan.Agents) == 0 {
agentError(c, "POLICY_REJECTED", "at least one agent is required")
return false
}
// Upper bound on agents per orchestration plan (defense-in-depth on this
// admin-only control-plane path): a single call must not request an unbounded
// number of agents (resource-exhaustion / cost blow-up). Mirrors the team-tier
// ceiling (8); env-overridable, <=0 disables. The per-user deploy-COUNT cap lives
// on the user self-service path (HeicodeDeployAgent); this is the per-plan SIZE
// guard. (#8)
if maxPerPlan := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_PLAN", 8); maxPerPlan > 0 && len(plan.Agents) > maxPerPlan {
agentError(c, "POLICY_REJECTED", "orchestration plan exceeds the maximum of "+strconv.Itoa(maxPerPlan)+" agents")
return false
}
if strings.TrimSpace(plan.UserContext.UserID) == "" {
agentError(c, "POLICY_REJECTED", "user_context.user_id is required")
return false
}
if strings.TrimSpace(plan.Metadata.CorrelationID) == "" {
agentError(c, "POLICY_REJECTED", "metadata.correlation_id is required")
return false
}
switch plan.RiskLevel {
case agentRiskLow, agentRiskMedium, agentRiskHigh:
default:
agentError(c, "POLICY_REJECTED", "risk_level must be low/medium/high")
return false
}
if !isValidAgentSubMode(plan.SubMode) {
agentError(c, "POLICY_REJECTED", "sub_mode must be agile or waterfall")
return false
}
if plan.Budget.MaxTokens <= 0 || plan.Budget.MaxCostUSD <= 0 || plan.Budget.MaxDurationSec <= 0 {
agentError(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 {
agentError(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) == "" {
agentError(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) {
agentError(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
}
}
}
agentsByRole := make(map[string]agentAgentPlan, len(plan.Agents))
for _, agent := range plan.Agents {
agentsByRole[strings.TrimSpace(agent.RoleTemplate)] = agent
}
for _, grant := range plan.ResourceGrants {
agent, ok := agentsByRole[strings.TrimSpace(grant.TargetRole)]
if !ok {
agentError(c, "RESOURCE_GRANT_INVALID", "top-level resource_grants.target_role must match an orchestration agent role")
return false
}
if !validateResourceGrant(c, plan, agent, grant) {
return false
}
}
return true
}
func firstPlanBindingScope(plan agentOrchestrationPlan) string {
for _, grant := range plan.ResourceGrants {
if scope := strings.TrimSpace(grant.BindingScope); scope != "" {
return scope
}
}
for _, agent := range plan.Agents {
for _, grant := range agent.ResourceGrants {
if scope := strings.TrimSpace(grant.BindingScope); scope != "" {
return scope
}
}
}
return ""
}
func agentGrantResourceRef(grant agentResourceGrant) string {
for _, value := range []string{grant.Metadata["repo_url"], grant.Metadata["doc_ref"], grant.Metadata["resource_ref"], grant.BindingScope, grant.ResourceID} {
if strings.TrimSpace(value) != "" {
return value
}
}
return grant.GrantID
}
func appendAgentManifestGrant(manifest *agentPermissionManifest, grant agentResourceGrant) {
if manifest == nil || strings.TrimSpace(grant.Status) != agentGrantStatusActive {
return
}
if manifest.AgentRole == "" {
manifest.AgentRole = grant.TargetRole
}
if manifest.TargetAgentRef == "" {
manifest.TargetAgentRef = grant.TargetAgentRef
}
manifest.ResourceGrants = append(manifest.ResourceGrants, agentManifestGrant{
GrantID: grant.GrantID,
ResourceID: grant.ResourceID,
ResourceType: grant.ResourceType,
ResourceRef: agentGrantResourceRef(grant),
AllowedActions: append([]string{}, grant.PermissionScope...),
Constraints: grant.Constraints,
SecretRef: grant.SecretRef,
Status: grant.Status,
})
}
func buildAgentPermissionManifest(plan agentOrchestrationPlan) agentPermissionManifest {
manifest := agentPermissionManifest{
UserID: plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(plan),
}
for _, grant := range plan.ResourceGrants {
appendAgentManifestGrant(&manifest, grant)
}
for _, agent := range plan.Agents {
for _, grant := range agent.ResourceGrants {
appendAgentManifestGrant(&manifest, grant)
}
}
if manifest.ResourceGrants == nil {
manifest.ResourceGrants = []agentManifestGrant{}
}
return manifest
}
func buildAgentAgentInstances(plan agentOrchestrationPlan, phase string, runtimeState string) []agentAgentInstance {
instances := make([]agentAgentInstance, 0)
if len(plan.AgentRuntime.Agents) > 0 {
for _, runtimeAgent := range plan.AgentRuntime.Agents {
count := runtimeAgent.InstanceCount
if count <= 0 {
count = 1
}
for i := 0; i < count; i++ {
instances = append(instances, agentAgentInstance{
InstanceID: "agi_" + common.GetUUID()[:12],
Role: runtimeAgent.Role,
Phase: phase,
RuntimeState: runtimeState,
})
}
}
}
if len(instances) == 0 {
for _, agent := range plan.Agents {
instances = append(instances, agentAgentInstance{
InstanceID: "agi_" + common.GetUUID()[:12],
Role: agent.RoleTemplate,
Phase: phase,
RuntimeState: runtimeState,
})
}
}
return instances
}
func planHasBindingScope(plan agentOrchestrationPlan, 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
}
}
}
for _, grant := range plan.ResourceGrants {
if strings.TrimSpace(grant.BindingScope) == bindingScope {
return true
}
}
return false
}
func applyAuthenticatedManagerUserContext(c *gin.Context, plan *agentOrchestrationPlan) {
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 enforceAuthenticatedAgentUserContext(c *gin.Context, plan *agentOrchestrationPlan) bool {
if plan == nil {
return false
}
authenticatedUserID := c.GetInt("id")
if authenticatedUserID <= 0 {
agentError(c, "POLICY_REJECTED", "authenticated user is required")
return false
}
userID := strconv.Itoa(authenticatedUserID)
if requestedUserID := strings.TrimSpace(plan.UserContext.UserID); requestedUserID != "" && requestedUserID != userID {
agentError(c, "USER_CONTEXT_FORBIDDEN", "user_context.user_id must match authenticated user")
return false
}
plan.UserContext.UserID = 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)
}
}
}
for agentIdx := range plan.Agents {
for grantIdx := range plan.Agents[agentIdx].ResourceGrants {
grantUserID := strings.TrimSpace(plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID)
if grantUserID != "" && grantUserID != userID {
agentError(c, "RESOURCE_GRANT_FORBIDDEN", "resource_grants.user_id must match authenticated user")
return false
}
plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID = userID
}
}
for grantIdx := range plan.ResourceGrants {
grantUserID := strings.TrimSpace(plan.ResourceGrants[grantIdx].UserID)
if grantUserID != "" && grantUserID != userID {
agentError(c, "RESOURCE_GRANT_FORBIDDEN", "resource_grants.user_id must match authenticated user")
return false
}
plan.ResourceGrants[grantIdx].UserID = userID
}
return true
}
func agentDeploymentBelongsToAuthenticatedUser(c *gin.Context, record agentDeploymentRecord) bool {
userID := strconv.Itoa(c.GetInt("id"))
return userID != "0" && strings.TrimSpace(record.Plan.UserContext.UserID) == userID
}
func requireAuthenticatedUserAgentDeployment(c *gin.Context) (agentDeploymentRecord, bool) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return agentDeploymentRecord{}, false
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return agentDeploymentRecord{}, false
}
if !agentDeploymentBelongsToAuthenticatedUser(c, record) {
agentError(c, "DEPLOYMENT_FORBIDDEN", "deployment does not belong to authenticated user")
return agentDeploymentRecord{}, false
}
return record, true
}
func createAgentDeploymentRecord(c *gin.Context, enforceUserScope bool, runtimeMode ...string) (agentDeploymentRecord, bool) {
var req agentDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return agentDeploymentRecord{}, false
}
return createAgentDeploymentFromPlan(c, req.Plan, enforceUserScope, runtimeMode...)
}
// createAgentDeploymentFromPlan builds and persists a deployment from an
// already-constructed orchestration plan (used by both the raw-plan create
// endpoints and the client requirement-package flow §8).
func createAgentDeploymentFromPlan(c *gin.Context, plan agentOrchestrationPlan, enforceUserScope bool, runtimeMode ...string) (agentDeploymentRecord, bool) {
if enforceUserScope {
if !enforceAuthenticatedAgentUserContext(c, &plan) {
return agentDeploymentRecord{}, false
}
} else {
applyAuthenticatedManagerUserContext(c, &plan)
}
if !validateOrchestrationPlan(c, plan) {
return agentDeploymentRecord{}, false
}
plan.SubMode = normalizeAgentSubMode(plan.SubMode)
if len(runtimeMode) > 0 && strings.TrimSpace(plan.Metadata.RuntimeMode) == "" {
plan.Metadata.RuntimeMode = normalizeAgentRuntimeMode(runtimeMode[0])
}
if strings.TrimSpace(plan.Metadata.RuntimeMode) == "" {
plan.Metadata.RuntimeMode = agentRuntimeModeAgent
}
// #60 模型 key 注入(方案A):swarm 模式下,若 billing_context 未自带 secret_ref,
// 则为认证用户 mint/复用 per-user sk- 写入 Key Vault,把 azkv:// 引用放进
// billing_context.secret_ref 下发给 Swarm(对接 agent_swarm PR#43)。Key Vault
// 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
if plan.Metadata.RuntimeMode == agentRuntimeModeSwarm && strings.TrimSpace(plan.BillingContext.SecretRef) == "" {
if uid := c.GetInt("id"); uid > 0 {
if secretRef, err := provisionSwarmModelKey(uid); err != nil {
common.SysLog("provisionSwarmModelKey (swarm create): " + err.Error())
} else {
plan.BillingContext.SecretRef = secretRef
}
}
}
now := agentNow()
deploymentID := "dep_" + common.GetUUID()[:12]
record := agentDeploymentRecord{
DeploymentID: deploymentID,
SubMode: plan.SubMode,
Status: "accepted",
Phase: "pending",
RuntimeState: "queued",
FailureReason: "",
AgentInstances: buildAgentAgentInstances(plan, "pending", "queued"),
ResourceGrantManifest: buildAgentPermissionManifest(plan),
CreatedAt: now,
UpdatedAt: now,
Plan: plan,
}
event := agentEvent{
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,
}
if err := persistAgentDeploymentRecord(record, agentDeploymentRequest{Plan: plan}); err != nil {
common.SysLog("AgentCreateDeployment persist: " + err.Error())
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agent deployment")
return agentDeploymentRecord{}, false
}
agentMu.Lock()
agentDeployments[deploymentID] = record
agentMu.Unlock()
recordAgentAuditEvent(event, "agent_control_plane", deploymentID, agentRequestID(c), "ok")
return record, true
}
func writeAgentDeploymentCreateSuccess(c *gin.Context, record agentDeploymentRecord, extra gin.H) {
data := gin.H{
"deployment_id": record.DeploymentID,
"sub_mode": record.SubMode,
"status": record.Status,
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"runtime_deployment_id": record.RuntimeDeploymentID,
"runtime_swarm_id": record.RuntimeSwarmID,
"runtime_last_sync_at": record.RuntimeLastSyncAt,
"failure_reason": record.FailureReason,
"agent_instances": record.AgentInstances,
"permission_manifest": record.ResourceGrantManifest,
}
for key, value := range extra {
data[key] = value
}
common.ApiSuccess(c, data)
}
func createAgentDeployment(c *gin.Context, enforceUserScope bool) {
record, ok := createAgentDeploymentRecord(c, enforceUserScope, agentRuntimeModeAgent)
if !ok {
return
}
record = maybeDispatchAgentRuntimeCreate(c, record, "agent_deployments")
writeAgentDeploymentCreateSuccess(c, record, nil)
}
func AgentCreateUserSwarm(c *gin.Context) {
// 订阅套餐 gate:蜂群按套餐开通(管理员在套餐编辑里逐个设 SwarmEnabled,不硬编码 tier)。
// 普通用户须有任一活跃套餐开通蜂群;管理员(role>=admin)绕过,便于测试/运维。
if c.GetInt("role") < common.RoleAdminUser && !model.GetUserSwarmEnabled(c.GetInt("id")) {
agentError(c, "POLICY_REJECTED", "当前订阅套餐未开通蜂群(swarm);请升级套餐或联系管理员")
return
}
record, ok := createAgentDeploymentRecord(c, true, agentRuntimeModeSwarm)
if !ok {
return
}
record = maybeDispatchAgentRuntimeCreate(c, record, "api_swarms_adapter")
writeAgentDeploymentCreateSuccess(c, record, gin.H{
"swarm_id": firstNonEmpty(record.RuntimeSwarmID, record.DeploymentID),
"adapter": "manager-local-control-plane",
"source": "api_swarms_adapter",
})
}
func AgentCreateDeployment(c *gin.Context) {
createAgentDeployment(c, false)
}
func AgentCreateUserDeployment(c *gin.Context) {
createAgentDeployment(c, true)
}
func AgentGetDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
common.ApiSuccess(c, withDisplayStatus(record))
}
func AgentGetUserDeployment(c *gin.Context) {
record, ok := requireAuthenticatedUserAgentDeployment(c)
if !ok {
return
}
record = reconcileDeploymentFromRuntime(c.Request.Context(), record)
common.ApiSuccess(c, withDisplayStatus(record))
}
func AgentListDeployments(c *gin.Context) {
userID := strings.TrimSpace(c.Query("user_id"))
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
items := listAgentDeploymentRecords(userID, bindingScope)
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
})
}
func AgentListUserDeployments(c *gin.Context) {
userID := strconv.Itoa(c.GetInt("id"))
bindingScope := strings.TrimSpace(c.Query("binding_scope"))
items := listAgentDeploymentRecords(userID, bindingScope)
// Project the Manager-judged display_status + client-facing mode onto every
// list item so cards show the right status and mode without re-fetching each
// task (gap B/D/F). withDisplayStatus only hits the DB for completed items.
for i := range items {
items[i] = withDisplayStatus(items[i])
}
common.ApiSuccess(c, gin.H{
"items": items,
"total": len(items),
})
}
func AgentStopDeployment(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
var stopPayload struct {
Reason string `json:"reason"`
}
if c.Request != nil && c.Request.Body != nil {
_ = common.UnmarshalBodyReusable(c, &stopPayload)
}
var runtimeOK bool
record, runtimeOK = syncAgentRuntimeStop(c, record, stopPayload.Reason)
if !runtimeOK {
return
}
record.Status = "stopped"
record.Phase = "stopped"
record.RuntimeState = firstNonEmpty(record.RuntimeState, "stopped")
record.FailureReason = ""
for i := range record.AgentInstances {
record.AgentInstances[i].Phase = "stopped"
record.AgentInstances[i].RuntimeState = "stopped"
record.AgentInstances[i].FailureReason = ""
}
record.UpdatedAt = agentNow()
agentMu.Lock()
agentDeployments[deploymentID] = record
agentMu.Unlock()
if err := updateAgentDeploymentRecord(record); err != nil {
common.SysLog("AgentStopDeployment persist: " + err.Error())
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist Agent deployment")
return
}
stopEvent := agentEvent{
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: agentNow(),
}
recordAgentAuditEvent(stopEvent, "agent_control_plane", deploymentID, agentRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"status": "stopped",
"phase": record.Phase,
"runtime_state": record.RuntimeState,
})
}
func AgentStopUserDeployment(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
return
}
AgentStopDeployment(c)
}
func AgentListDeploymentEvents(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
// Audit rows are now in PostgreSQL/SQLite/MySQL — survive restarts.
rows, err := model.ListAgentAuditEventsByDeployment(deploymentID)
if err != nil {
common.SysLog("AgentListDeploymentEvents: " + err.Error())
agentError(c, "POLICY_REJECTED", "internal error")
return
}
events := make([]agentEvent, 0, len(rows))
for _, r := range rows {
events = append(events, agentAuditRowToEvent(r))
}
common.ApiSuccess(c, gin.H{
"items": events,
"total": len(events),
})
}
func AgentListUserDeploymentEvents(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
return
}
AgentListDeploymentEvents(c)
}
func normalizeAgentSimulationEvents(values []string) []string {
events := make([]string, 0, len(values))
for _, value := range values {
event := strings.TrimSpace(value)
if event == "" {
continue
}
if !strings.HasPrefix(event, "simulation.") {
event = "simulation." + event
}
events = append(events, event)
}
if len(events) == 0 {
return []string{
"simulation.deployment.started",
"simulation.agent.progress",
"simulation.deployment.completed",
}
}
return events
}
func firstAgentSimulationGrant(record agentDeploymentRecord) agentResourceGrant {
for _, agent := range record.Plan.Agents {
for _, grant := range agent.ResourceGrants {
if strings.TrimSpace(grant.SecretRef) != "" {
return grant
}
}
}
for _, grant := range record.Plan.ResourceGrants {
if strings.TrimSpace(grant.SecretRef) != "" {
return grant
}
}
for _, agent := range record.Plan.Agents {
if len(agent.ResourceGrants) > 0 {
return agent.ResourceGrants[0]
}
}
if len(record.Plan.ResourceGrants) > 0 {
return record.Plan.ResourceGrants[0]
}
return agentResourceGrant{}
}
func firstAgentSimulationRole(record agentDeploymentRecord) string {
for _, agent := range record.Plan.Agents {
if strings.TrimSpace(agent.RoleTemplate) != "" {
return strings.TrimSpace(agent.RoleTemplate)
}
}
for _, agent := range record.Plan.AgentRuntime.Agents {
if strings.TrimSpace(agent.Role) != "" {
return strings.TrimSpace(agent.Role)
}
}
return "runtime"
}
func persistAgentSimulatedCallback(record agentDeploymentRecord, payload agentCallbackEnvelope) error {
if strings.TrimSpace(payload.EventID) == "" {
payload.EventID = "evt-sim-" + common.GetUUID()[:12]
}
if strings.TrimSpace(payload.IdempotencyKey) == "" {
payload.IdempotencyKey = payload.EventID
}
if strings.TrimSpace(payload.DeploymentID) == "" {
payload.DeploymentID = record.DeploymentID
}
if strings.TrimSpace(payload.SwarmID) == "" {
payload.SwarmID = firstNonEmpty(record.RuntimeSwarmID, "sim-"+record.DeploymentID)
}
if strings.TrimSpace(payload.CorrelationID) == "" {
payload.CorrelationID = record.Plan.Metadata.CorrelationID
}
if strings.TrimSpace(payload.Source) == "" {
payload.Source = "agent-simulator"
}
if strings.TrimSpace(payload.OccurredAt) == "" {
payload.OccurredAt = agentNow()
}
payloadJSON, _ := common.Marshal(payload)
inserted, err := model.InsertAgentCallbackEvent(&model.AgentCallbackEvent{
EventID: strings.TrimSpace(payload.EventID),
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
CallbackType: "swarm-event",
EventType: strings.TrimSpace(payload.EventType),
DeploymentID: strings.TrimSpace(payload.DeploymentID),
SwarmID: strings.TrimSpace(payload.SwarmID),
AgentInstanceID: strings.TrimSpace(payload.AgentInstanceID),
TaskID: strings.TrimSpace(payload.TaskID),
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: strings.TrimSpace(payload.CorrelationID),
Source: strings.TrimSpace(payload.Source),
Result: "simulated",
PayloadJSON: string(payloadJSON),
OccurredAt: strings.TrimSpace(payload.OccurredAt),
CreatedAtMs: time.Now().UnixMilli(),
})
if err != nil {
return err
}
if !inserted {
return nil
}
if err := persistAgentArtifactFromCallback(payload, record); err != nil {
return err
}
if err := persistAgentApprovalFromCallback(payload, record); err != nil {
return err
}
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "callback." + payload.EventType,
SchemaVersion: 1,
UserID: record.Plan.UserContext.UserID,
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: record.DeploymentID,
CorrelationID: payload.CorrelationID,
OccurredAt: payload.OccurredAt,
}, "agent_simulator", record.DeploymentID, "", "simulated")
return nil
}
func persistAgentDefaultSimulationCallbacks(record agentDeploymentRecord) error {
role := firstAgentSimulationRole(record)
grant := firstAgentSimulationGrant(record)
taskID := "task-sim-" + sanitizeAgentRef(record.DeploymentID)
agentID := "agi-sim-" + sanitizeAgentRef(role)
base := func(eventType string, suffix string, payload map[string]any) agentCallbackEnvelope {
return agentCallbackEnvelope{
EventID: "evt-sim-" + sanitizeAgentRef(record.DeploymentID) + "-" + suffix,
IdempotencyKey: "idem-sim-" + sanitizeAgentRef(record.DeploymentID) + "-" + suffix,
EventType: eventType,
DeploymentID: record.DeploymentID,
SwarmID: firstNonEmpty(record.RuntimeSwarmID, "sim-"+record.DeploymentID),
AgentInstanceID: agentID,
TaskID: taskID,
CorrelationID: record.Plan.Metadata.CorrelationID,
Source: "agent-simulator",
OccurredAt: agentNow(),
Payload: payload,
}
}
callbacks := []agentCallbackEnvelope{
base("task.created", "task-created", map[string]any{
"title": "Simulated task created",
"summary": "Manager simulator created a child Agent task for ordinary sub-mode smoke testing",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.claimed", "task-claimed", map[string]any{
"title": "Simulated task claimed",
"summary": "Manager simulator marked a child Agent task as claimed",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.heartbeat", "task-heartbeat", map[string]any{
"title": "Simulated task heartbeat",
"summary": "Manager simulator received a heartbeat from the child Agent task",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.blocked", "task-blocked", map[string]any{
"title": "Simulated task blocked",
"summary": "Manager simulator marked a child Agent task as blocked before handoff",
"agent_role": role,
"severity": "warning",
"task_id": taskID,
"reason": "Waiting for downstream sub-task handoff validation",
}),
base("handoff.requested", "handoff-requested", map[string]any{
"title": "Simulated handoff requested",
"summary": "Manager simulator requested handoff between child Agent roles",
"severity": "info",
"task_id": taskID,
"from_role": role,
"to_role": firstNonEmpty(grant.TargetRole, role),
}),
base("handoff.completed", "handoff-completed", map[string]any{
"title": "Simulated handoff completed",
"summary": "Manager simulator completed handoff between child Agent roles",
"severity": "success",
"task_id": taskID,
"from_role": role,
"to_role": firstNonEmpty(grant.TargetRole, role),
}),
base("task.retried", "task-retried", map[string]any{
"title": "Simulated task retry",
"summary": "Manager simulator recorded a retry event for ordinary sub-mode timeline testing",
"agent_role": role,
"severity": "warning",
"task_id": taskID,
"attempt": 2,
}),
base("task.completed", "task-completed", map[string]any{
"title": "Simulated task completed",
"summary": "Manager simulator marked the child Agent task as completed",
"agent_role": role,
"severity": "success",
"task_id": taskID,
}),
}
artifact := base("artifact.created", "artifact", map[string]any{
"title": "Simulated test report",
"summary": "Manager simulator generated a safe artifact record for smoke testing",
"checkpoint": "artifact_ready",
"severity": "success",
})
artifact.Artifact = agentArtifactPayload{
ArtifactID: "art-sim-" + sanitizeAgentRef(record.DeploymentID),
ArtifactType: "test_report",
Title: "Simulated test report",
Summary: "Manager simulator generated a safe artifact record for smoke testing",
URI: "artifact://simulated/" + record.DeploymentID + "/test-report",
Metadata: map[string]any{
"source": "agent-simulator",
"redacted": true,
},
}
callbacks = append(callbacks, artifact)
if strings.TrimSpace(grant.SecretRef) != "" {
callbacks = append(callbacks, base("approval.requested", "approval", map[string]any{
"approval_id": "sim-appr-" + sanitizeAgentRef(record.DeploymentID),
"operation": "simulation.resource_access",
"resource_id": firstNonEmpty(grant.ResourceID, record.DeploymentID),
"resource_type": firstNonEmpty(grant.ResourceType, "custom"),
"resource_scope": firstNonEmpty(agentGrantResourceRef(grant), firstPlanBindingScope(record.Plan)),
"target_role": firstNonEmpty(grant.TargetRole, role),
"risk_level": "high",
"requires_credential": true,
"secret_ref": strings.TrimSpace(grant.SecretRef),
"ttl_seconds": 600,
"reason": "Simulated high-risk resource access for Manager smoke testing",
}))
}
callbacks = append(callbacks, base("timeline.updated", "timeline", map[string]any{
"title": "Simulated runtime timeline",
"summary": "Manager simulator generated task, artifact and approval records",
"stage": "testing",
"checkpoint": "ready_for_runtime",
"severity": "info",
"next_action": "connect_real_runtime",
}))
for _, callback := range callbacks {
if err := persistAgentSimulatedCallback(record, callback); err != nil {
return err
}
}
return nil
}
func AgentSimulateUserDeploymentEvents(c *gin.Context) {
if c.GetInt("role") < common.RoleAdminUser && !common.GetEnvOrDefaultBool("AGENT_SIMULATION_ENABLED", false) {
agentError(c, "SIMULATION_DISABLED", "simulation endpoint is admin-only unless AGENT_SIMULATION_ENABLED=true")
return
}
record, ok := requireAuthenticatedUserAgentDeployment(c)
if !ok {
return
}
var req agentSimulateDeploymentEventsRequest
if c.Request != nil && c.Request.Body != nil {
if err := c.ShouldBindJSON(&req); err != nil {
agentError(c, "POLICY_REJECTED", err.Error())
return
}
}
events := normalizeAgentSimulationEvents(req.Events)
now := agentNow()
record.Phase = "simulated"
record.RuntimeState = "simulated"
record.FailureReason = ""
record.UpdatedAt = now
for i := range record.AgentInstances {
record.AgentInstances[i].Phase = record.Phase
record.AgentInstances[i].RuntimeState = record.RuntimeState
record.AgentInstances[i].FailureReason = ""
}
agentMu.Lock()
agentDeployments[record.DeploymentID] = record
agentMu.Unlock()
if err := updateAgentDeploymentRecord(record); err != nil {
common.SysLog("AgentSimulateUserDeploymentEvents persist: " + err.Error())
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist simulated deployment state")
return
}
for _, eventName := range events {
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: eventName,
SchemaVersion: 1,
UserID: record.Plan.UserContext.UserID,
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: record.DeploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: agentNow(),
}, "agent_simulator", record.DeploymentID, agentRequestID(c), "simulated")
}
if len(req.Events) == 0 {
if err := persistAgentDefaultSimulationCallbacks(record); err != nil {
common.SysLog("persistAgentDefaultSimulationCallbacks: " + err.Error())
agentError(c, "SIMULATION_PERSIST_FAILED", "failed to persist simulated callback records")
return
}
}
common.ApiSuccess(c, gin.H{
"deployment_id": record.DeploymentID,
"simulated": true,
"events": events,
"total": len(events),
})
}
// friendlyLogMessage maps a callback event type to a user-facing message for
// the two-layer log split (unified spec §13).
func friendlyLogMessage(eventType string) string {
switch eventType {
case "deployment.status_changed":
return "任务状态已更新"
case "phase.changed":
return "进入新阶段"
case "timeline.updated":
return "进度已更新"
case "agent.started":
return "子代理已开始执行"
case "agent.completed":
return "子代理已完成"
case "agent.crashed", "agent.failed":
return "子代理执行异常"
case "artifact.created":
return "已生成交付产物"
case "approval.requested":
return "需要你审批"
case "task.completed":
return "任务已完成"
case "task.failed":
return "任务执行失败"
default:
return eventType
}
}
// buildLayeredLogs returns user-facing logs and developer debug logs derived
// from persisted callbacks (unified spec §13). user_logs are friendly summaries;
// debug_logs carry the raw runtime payload for the client debug panel.
func buildLayeredLogs(deploymentID string) ([]gin.H, []gin.H) {
callbacks, err := model.ListAgentCallbackEvents(model.ListAgentCallbackEventsFilter{
DeploymentID: deploymentID,
Limit: 500,
})
if err != nil {
common.SysLog("buildLayeredLogs: " + err.Error())
}
userLogs := make([]gin.H, 0, len(callbacks))
debugLogs := make([]gin.H, 0, len(callbacks))
for _, cb := range callbacks {
payload := callbackPayloadMap(cb)
debugLogs = append(debugLogs, gin.H{
"event_id": cb.EventID,
"event_type": cb.EventType,
"runtime_kind": firstNonEmpty(cb.Source, "agent_management"),
"runtime_deployment_id": cb.SwarmID,
"occurred_at": cb.OccurredAt,
"raw_payload": payload,
})
msg := ""
if payload != nil {
for _, key := range []string{"summary", "title", "message"} {
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
msg = strings.TrimSpace(v)
break
}
}
}
if msg == "" {
msg = friendlyLogMessage(cb.EventType)
}
userLogs = append(userLogs, gin.H{
"time": firstNonEmpty(cb.OccurredAt, strconv.FormatInt(cb.CreatedAtMs, 10)),
"level": "info",
"message": msg,
})
}
return userLogs, debugLogs
}
func AgentListDeploymentLogs(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
rows, err := model.ListAgentAuditEventsByDeployment(deploymentID)
if err != nil {
common.SysLog("AgentListDeploymentLogs: " + err.Error())
}
events := make([]agentEvent, 0, len(rows))
for _, r := range rows {
events = append(events, agentAuditRowToEvent(r))
}
items := make([]gin.H, 0, len(events)+1)
items = append(items, gin.H{
"timestamp": record.CreatedAt,
"deployment_id": deploymentID,
"stream": "control",
"source": "manager_control_plane",
"data_source": "manager_control_plane",
"level": "info",
"message": "deployment accepted by Manager control-plane placeholder",
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"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",
"source": "manager_audit",
"data_source": "manager_audit",
"level": "info",
"message": event.Event,
"phase": record.Phase,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"correlation_id": event.CorrelationID,
"redacted": true,
})
}
userLogs, debugLogs := buildLayeredLogs(deploymentID)
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"items": items,
"user_logs": userLogs,
"debug_logs": debugLogs,
"next_cursor": "",
"redacted": true,
"data_source": "manager_control_plane",
"runtime_source": "not_connected",
"total": len(items),
})
}
func AgentListUserDeploymentLogs(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
return
}
AgentListDeploymentLogs(c)
}
func AgentGetDeploymentMetrics(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "POLICY_REJECTED", "deployment_id is required")
return
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"data_source": "manager_control_plane",
"runtime_source": "not_connected",
"window": strings.TrimSpace(c.DefaultQuery("window", "15m")),
"step": strings.TrimSpace(c.DefaultQuery("step", "60s")),
"phase": record.Phase,
"status": record.Status,
"runtime_state": record.RuntimeState,
"failure_reason": record.FailureReason,
"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 AgentGetUserDeploymentMetrics(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
return
}
AgentGetDeploymentMetrics(c)
}
func AgentProjectDashboardSnapshot(c *gin.Context) {
bindingScope := strings.TrimSpace(c.Param("project_id"))
if bindingScope == "" {
agentError(c, "POLICY_REJECTED", "binding_scope is required")
return
}
active := 0
pending := 0
stopped := 0
for _, record := range listAgentDeploymentRecords("", bindingScope) {
if record.Status == "accepted" {
active++
}
if record.Phase == "pending" {
pending++
}
if record.Phase == "stopped" {
stopped++
}
}
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 AgentResolveSKSnapshots(c *gin.Context) {
var req agentSKSnapshotResolveRequest
if err := c.ShouldBindJSON(&req); err != nil {
agentError(c, "SK_SOURCE_UNRESOLVABLE", err.Error())
return
}
deploymentID := strings.TrimSpace(req.DeploymentID)
if deploymentID == "" {
agentError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
return
}
record, ok := findAgentDeploymentRecord(deploymentID)
if !ok {
agentError(c, "DEPLOYMENT_CONFLICT", "deployment not found")
return
}
snapshots := make([]agentSKSnapshot, 0)
now := agentNow()
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, agentSKSnapshot{
SnapshotID: "sks_" + common.GetUUID()[:12],
DeploymentID: deploymentID,
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
SourceType: sourceType,
SourceRef: sourceRef,
ResolvedAt: now,
})
}
}
agentMu.Lock()
agentSnapshots[deploymentID] = snapshots
agentMu.Unlock()
rows := make([]model.AgentSKSnapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
rows = append(rows, model.AgentSKSnapshot{
SnapshotID: snapshot.SnapshotID,
DeploymentID: snapshot.DeploymentID,
UserID: snapshot.UserID,
BindingScope: snapshot.BindingScope,
SourceType: snapshot.SourceType,
SourceRef: snapshot.SourceRef,
ResolvedAt: snapshot.ResolvedAt,
ResolvedAtMs: agentTimestampMs(snapshot.ResolvedAt),
})
}
if err := model.InsertAgentSKSnapshots(rows); err != nil {
common.SysLog("AgentResolveSKSnapshots persist: " + err.Error())
agentError(c, "SK_SNAPSHOT_PERSIST_FAILED", "failed to persist sk snapshots")
return
}
snapEvent := agentEvent{
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,
}
recordAgentAuditEvent(snapEvent, "agent_control_plane", deploymentID, agentRequestID(c), "ok")
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"items": snapshots,
"total": len(snapshots),
})
}
func AgentListSKSnapshots(c *gin.Context) {
deploymentID := strings.TrimSpace(c.Param("deployment_id"))
if deploymentID == "" {
agentError(c, "SK_SOURCE_UNRESOLVABLE", "deployment_id is required")
return
}
agentMu.RLock()
items := agentSnapshots[deploymentID]
agentMu.RUnlock()
if len(items) == 0 {
rows, err := model.ListAgentSKSnapshots(deploymentID)
if err != nil {
common.SysLog("AgentListSKSnapshots: " + err.Error())
agentError(c, "SK_SOURCE_UNRESOLVABLE", "failed to query sk snapshots")
return
}
items = make([]agentSKSnapshot, 0, len(rows))
for _, row := range rows {
items = append(items, agentSKSnapshot{
SnapshotID: row.SnapshotID,
DeploymentID: row.DeploymentID,
UserID: row.UserID,
BindingScope: row.BindingScope,
SourceType: row.SourceType,
SourceRef: row.SourceRef,
ResolvedAt: row.ResolvedAt,
})
}
}
common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID,
"sk_snapshots": items,
"items": items,
"total": len(items),
})
}
func AgentListUserSKSnapshots(c *gin.Context) {
if _, ok := requireAuthenticatedUserAgentDeployment(c); !ok {
return
}
AgentListSKSnapshots(c)
}
func AgentListAuditLogs(c *gin.Context) {
// Persistent audit query — survives container restarts. Filters
// are optional; the dashboard call usually narrows by user_id +
// time window. Pagination is opt-in via ?limit / ?offset.
limit := 0
if v := strings.TrimSpace(c.Query("limit")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
offset := 0
if v := strings.TrimSpace(c.Query("offset")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
rows, total, err := model.ListAgentAuditEvents(model.ListAgentAuditEventsFilter{
UserID: strings.TrimSpace(c.Query("user_id")),
BindingScope: strings.TrimSpace(c.Query("binding_scope")),
DeploymentID: strings.TrimSpace(c.Query("deployment_id")),
Limit: limit,
Offset: offset,
})
if err != nil {
common.SysLog("AgentListAuditLogs: " + err.Error())
agentError(c, "POLICY_REJECTED", "internal error")
return
}
items := make([]gin.H, 0, len(rows))
for _, r := range rows {
items = append(items, gin.H{
"actor": r.Actor,
"action": r.Event,
"resource": r.Resource,
"user_id": r.UserID,
"channel_id": r.ChannelID,
"binding_scope": r.BindingScope,
"request_id": r.RequestID,
"correlation_id": r.CorrelationID,
"result": r.Result,
"occurred_at": time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
})
}
common.ApiSuccess(c, gin.H{
"items": items,
"total": total,
})
}
// agentAuditRowToEvent reshapes a persisted audit row back into the
// in-process event struct so the deployment-detail / log endpoints
// can keep returning the same JSON shape they used to emit from the
// in-memory map. Avoids breaking any existing dashboard consumer.
func agentAuditRowToEvent(r model.AgentAuditEvent) agentEvent {
return agentEvent{
EventID: r.EventID,
Event: r.Event,
SchemaVersion: r.SchemaVersion,
UserID: r.UserID,
ChannelID: r.ChannelID,
BindingScope: r.BindingScope,
DeploymentID: r.DeploymentID,
CorrelationID: r.CorrelationID,
OccurredAt: time.UnixMilli(r.OccurredAt).UTC().Format(time.RFC3339),
}
}