补齐桌面客户端对接缺口:之前 POST /tasks 只收 orchestration_plan、且缺 messages/execute/delete,
客户端无法照 spec §8 需求包直接对接。本次:
- POST /api/heicode/{sub-agile,swarm}/tasks 改收需求包 {mode,conversation_id,requirement,model_selection,roles},
Manager 翻译成 orchestration_plan(per_role/default/primary 模型),仍兼容直传 orchestration_plan。
- POST .../tasks/{id}/messages:持续对话,记录用户消息 + 回当前 display_status。
- POST .../tasks/{id}/execute:确保派发 Runtime + reconcile。
- DELETE .../tasks/{id}:停止任务。
- 重构 createAgentDeploymentFromPlan,供原始 plan 与需求包两条路径复用。
- 更新 docs/integration/heicode-desktop-unified-api.md(创建改需求包 + 新路由)。
go build ./... + go test(controller/router)全绿。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2057 lines
67 KiB
Go
2057 lines
67 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"`
|
|
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,
|
|
"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
|
|
}
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
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),
|
|
}
|
|
}
|