diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 0f996878f6..a931fa7910 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -563,6 +563,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus }) }) + // Workspace blueprints + r.Route("/api/blueprints", func(r chi.Router) { + r.Post("/export", h.ExportBlueprint) + r.Post("/preview", h.PreviewBlueprint) + r.Post("/apply", h.ApplyBlueprint) + }) + // Dashboard — workspace-wide token + run-time rollups for the // "/{slug}/dashboard" page. Optional ?project_id filter scopes // the rollup to a single project. diff --git a/server/internal/blueprint/manifest.go b/server/internal/blueprint/manifest.go new file mode 100644 index 0000000000..97c3ee7ca8 --- /dev/null +++ b/server/internal/blueprint/manifest.go @@ -0,0 +1,394 @@ +package blueprint + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" +) + +const SchemaVersion = "multica.workspace_blueprint/v1" + +type Manifest struct { + Schema string `json:"schema"` + Name string `json:"name"` + ExportedAt string `json:"exported_at"` + Squads []Squad `json:"squads"` + Agents []Agent `json:"agents"` + Skills []Skill `json:"skills"` + Warnings []Warning `json:"warnings,omitempty"` +} + +type Warning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Squad struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + LeaderRef string `json:"leader_ref"` + Members []SquadMember `json:"members"` +} + +type SquadMember struct { + Ref string `json:"ref"` + Role string `json:"role,omitempty"` +} + +type Agent struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + Runtime Runtime `json:"runtime"` + Visibility string `json:"visibility"` + MaxConcurrentTasks int32 `json:"max_concurrent_tasks"` + CustomEnvSchema []EnvVar `json:"custom_env_schema,omitempty"` + CustomArgs []string `json:"custom_args,omitempty"` + MCPConfigRedacted bool `json:"mcp_config_redacted,omitempty"` + SkillRefs []string `json:"skill_refs,omitempty"` +} + +type Runtime struct { + Mode string `json:"mode"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` +} + +type EnvVar struct { + Name string `json:"name"` + Required bool `json:"required"` + Secret bool `json:"secret"` +} + +type Skill struct { + Ref string `json:"ref"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Content string `json:"content,omitempty"` + Config json.RawMessage `json:"config"` + Files []SkillFile `json:"files,omitempty"` +} + +type SkillFile struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type Source struct { + Name string + ExportedAt time.Time + Squads []SourceSquad + SquadMembers map[string][]SourceSquadMember + Agents []SourceAgent + AgentSkillIDs map[string][]string + Skills []SourceSkill + SkillFiles map[string][]SourceSkillFile +} + +type SourceSquad struct { + ID string + Name string + Description string + Instructions string + AvatarURL *string + LeaderID string +} + +type SourceSquadMember struct { + MemberType string + MemberID string + Role string +} + +type SourceAgent struct { + ID string + Name string + Description string + Instructions string + AvatarURL *string + RuntimeID string + RuntimeMode string + RuntimeProvider string + RuntimeConfig json.RawMessage + Visibility string + MaxConcurrentTasks int32 + Model string + ThinkingLevel string + CustomEnv map[string]string + CustomArgs []string + MCPConfig json.RawMessage +} + +type SourceSkill struct { + ID string + Name string + Description string + Content string + Config json.RawMessage +} + +type SourceSkillFile struct { + Path string + Content string +} + +func BuildManifest(src Source) (Manifest, error) { + exportedAt := src.ExportedAt + if exportedAt.IsZero() { + exportedAt = time.Now().UTC() + } + + name := strings.TrimSpace(src.Name) + if name == "" { + name = "Workspace Blueprint" + } + + squadRefs := refsFor(src.Squads, "squad", func(s SourceSquad) string { return s.ID }, func(s SourceSquad) string { return s.Name }) + agentRefs := refsFor(src.Agents, "agent", func(a SourceAgent) string { return a.ID }, func(a SourceAgent) string { return a.Name }) + skillRefs := refsFor(src.Skills, "skill", func(s SourceSkill) string { return s.ID }, func(s SourceSkill) string { return s.Name }) + + manifest := Manifest{ + Schema: SchemaVersion, + Name: name, + ExportedAt: exportedAt.UTC().Format(time.RFC3339), + Squads: make([]Squad, 0, len(src.Squads)), + Agents: make([]Agent, 0, len(src.Agents)), + Skills: make([]Skill, 0, len(src.Skills)), + } + + for _, squad := range src.Squads { + leaderRef := agentRefs[squad.LeaderID] + members := make([]SquadMember, 0, len(src.SquadMembers[squad.ID])) + for _, member := range src.SquadMembers[squad.ID] { + if member.MemberType != "agent" { + continue + } + ref := agentRefs[member.MemberID] + if ref == "" { + continue + } + members = append(members, SquadMember{ + Ref: ref, + Role: member.Role, + }) + } + manifest.Squads = append(manifest.Squads, Squad{ + Ref: squadRefs[squad.ID], + Name: squad.Name, + Description: squad.Description, + Instructions: squad.Instructions, + AvatarURL: squad.AvatarURL, + LeaderRef: leaderRef, + Members: members, + }) + } + + for _, agent := range src.Agents { + skillRefsForAgent := make([]string, 0, len(src.AgentSkillIDs[agent.ID])) + for _, skillID := range src.AgentSkillIDs[agent.ID] { + if ref := skillRefs[skillID]; ref != "" { + skillRefsForAgent = append(skillRefsForAgent, ref) + } + } + sort.Strings(skillRefsForAgent) + + manifest.Agents = append(manifest.Agents, Agent{ + Ref: agentRefs[agent.ID], + Name: agent.Name, + Description: agent.Description, + Instructions: agent.Instructions, + AvatarURL: agent.AvatarURL, + Runtime: Runtime{ + Mode: agent.RuntimeMode, + Provider: agent.RuntimeProvider, + Model: agent.Model, + ThinkingLevel: agent.ThinkingLevel, + }, + Visibility: agent.Visibility, + MaxConcurrentTasks: agent.MaxConcurrentTasks, + CustomEnvSchema: envSchema(agent.CustomEnv), + CustomArgs: append([]string(nil), agent.CustomArgs...), + MCPConfigRedacted: hasJSONObject(agent.MCPConfig), + SkillRefs: skillRefsForAgent, + }) + } + + for _, skill := range src.Skills { + files := make([]SkillFile, 0, len(src.SkillFiles[skill.ID])) + for _, file := range src.SkillFiles[skill.ID] { + files = append(files, SkillFile{Path: file.Path, Content: file.Content}) + } + config := json.RawMessage(`{}`) + if len(strings.TrimSpace(string(skill.Config))) > 0 { + config = append(json.RawMessage(nil), skill.Config...) + } + manifest.Skills = append(manifest.Skills, Skill{ + Ref: skillRefs[skill.ID], + Name: skill.Name, + Description: skill.Description, + Content: skill.Content, + Config: config, + Files: files, + }) + } + + if err := ValidateManifest(manifest); err != nil { + return Manifest{}, err + } + return manifest, nil +} + +func ValidateManifest(manifest Manifest) error { + var errs []error + if manifest.Schema != SchemaVersion { + errs = append(errs, fmt.Errorf("schema must be %q", SchemaVersion)) + } + + seenRefs := map[string]struct{}{} + skillRefs := map[string]struct{}{} + agentRefs := map[string]struct{}{} + + checkRef := func(ref, kind string) { + if strings.TrimSpace(ref) == "" { + errs = append(errs, fmt.Errorf("%s ref is required", kind)) + return + } + if _, ok := seenRefs[ref]; ok { + errs = append(errs, fmt.Errorf("duplicate ref %q", ref)) + return + } + seenRefs[ref] = struct{}{} + } + + for _, skill := range manifest.Skills { + checkRef(skill.Ref, "skill") + skillRefs[skill.Ref] = struct{}{} + if strings.TrimSpace(skill.Name) == "" { + errs = append(errs, fmt.Errorf("skill %q name is required", skill.Ref)) + } + if len(skill.Config) > 0 && !json.Valid(skill.Config) { + errs = append(errs, fmt.Errorf("skill %q config must be valid JSON", skill.Ref)) + } + for _, file := range skill.Files { + if !safeSkillFilePath(file.Path) { + errs = append(errs, fmt.Errorf("unsafe skill file path %q in %s", file.Path, skill.Ref)) + } + } + } + + for _, agent := range manifest.Agents { + checkRef(agent.Ref, "agent") + agentRefs[agent.Ref] = struct{}{} + if strings.TrimSpace(agent.Name) == "" { + errs = append(errs, fmt.Errorf("agent %q name is required", agent.Ref)) + } + if agent.Runtime.Mode != "local" && agent.Runtime.Mode != "cloud" { + errs = append(errs, fmt.Errorf("agent %q runtime mode must be local or cloud", agent.Ref)) + } + for _, skillRef := range agent.SkillRefs { + if _, ok := skillRefs[skillRef]; !ok { + errs = append(errs, fmt.Errorf("agent %q references missing skill %q", agent.Ref, skillRef)) + } + } + } + + for _, squad := range manifest.Squads { + checkRef(squad.Ref, "squad") + if strings.TrimSpace(squad.Name) == "" { + errs = append(errs, fmt.Errorf("squad %q name is required", squad.Ref)) + } + if squad.LeaderRef == "" { + errs = append(errs, fmt.Errorf("squad %q leader_ref is required", squad.Ref)) + } else if _, ok := agentRefs[squad.LeaderRef]; !ok { + errs = append(errs, fmt.Errorf("squad %q references missing leader %q", squad.Ref, squad.LeaderRef)) + } + for _, member := range squad.Members { + if _, ok := agentRefs[member.Ref]; !ok { + errs = append(errs, fmt.Errorf("squad %q references missing member %q", squad.Ref, member.Ref)) + } + } + } + + return errors.Join(errs...) +} + +func refsFor[T any](items []T, kind string, idFn func(T) string, nameFn func(T) string) map[string]string { + refs := make(map[string]string, len(items)) + used := map[string]int{} + for _, item := range items { + id := idFn(item) + if id == "" { + continue + } + base := slugify(nameFn(item)) + if base == "" { + base = kind + } + count := used[base] + used[base] = count + 1 + if count > 0 { + base = fmt.Sprintf("%s-%d", base, count+1) + } + refs[id] = kind + "." + base + } + return refs +} + +var slugRe = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = slugRe.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +func envSchema(env map[string]string) []EnvVar { + if len(env) == 0 { + return nil + } + keys := make([]string, 0, len(env)) + for key := range env { + keys = append(keys, key) + } + sort.Strings(keys) + schema := make([]EnvVar, 0, len(keys)) + for _, key := range keys { + schema = append(schema, EnvVar{Name: key, Required: false, Secret: true}) + } + return schema +} + +func hasJSONObject(raw json.RawMessage) bool { + trimmed := strings.TrimSpace(string(raw)) + return trimmed != "" && trimmed != "null" && trimmed != "{}" +} + +func safeSkillFilePath(p string) bool { + if strings.TrimSpace(p) == "" { + return false + } + if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") { + return false + } + clean := path.Clean(p) + if clean == "." || clean != p { + return false + } + for _, part := range strings.Split(clean, "/") { + if part == ".." || part == "" { + return false + } + } + return true +} diff --git a/server/internal/blueprint/manifest_test.go b/server/internal/blueprint/manifest_test.go new file mode 100644 index 0000000000..bd82b07ee7 --- /dev/null +++ b/server/internal/blueprint/manifest_test.go @@ -0,0 +1,206 @@ +package blueprint + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestBuildManifestExportsPortableRefsAndRelationships(t *testing.T) { + exportedAt := time.Date(2026, 5, 24, 10, 30, 0, 0, time.UTC) + manifest, err := BuildManifest(Source{ + Name: "Release Squad", + ExportedAt: exportedAt, + Squads: []SourceSquad{{ + ID: "squad-db-id", + Name: "Release Squad", + Description: "Ships weekly releases", + Instructions: "Coordinate release tasks.", + LeaderID: "agent-lead-id", + }}, + SquadMembers: map[string][]SourceSquadMember{ + "squad-db-id": { + {MemberType: "agent", MemberID: "agent-lead-id", Role: "lead"}, + {MemberType: "agent", MemberID: "agent-reviewer-id", Role: "review"}, + {MemberType: "member", MemberID: "human-member-id", Role: "stakeholder"}, + }, + }, + Agents: []SourceAgent{ + { + ID: "agent-lead-id", + Name: "Release Lead", + Description: "Plans release work", + Instructions: "Own the release checklist.", + RuntimeMode: "local", + RuntimeProvider: "codex", + Visibility: "workspace", + MaxConcurrentTasks: 2, + Model: "gpt-5.4", + ThinkingLevel: "medium", + }, + { + ID: "agent-reviewer-id", + Name: "Release Reviewer", + Description: "Reviews release notes", + Instructions: "Review release notes.", + RuntimeMode: "cloud", + RuntimeProvider: "multica_agent", + Visibility: "private", + MaxConcurrentTasks: 1, + }, + }, + AgentSkillIDs: map[string][]string{ + "agent-lead-id": {"skill-runbook-id"}, + }, + Skills: []SourceSkill{{ + ID: "skill-runbook-id", + Name: "Release Runbook", + Description: "Release process", + Content: "# Release", + Config: json.RawMessage(`{"scope":"release"}`), + }}, + SkillFiles: map[string][]SourceSkillFile{ + "skill-runbook-id": { + {Path: "steps/checklist.md", Content: "- Verify changelog"}, + }, + }, + }) + if err != nil { + t.Fatalf("BuildManifest returned error: %v", err) + } + + if manifest.Schema != SchemaVersion { + t.Fatalf("schema = %q, want %q", manifest.Schema, SchemaVersion) + } + if manifest.ExportedAt != exportedAt.Format(time.RFC3339) { + t.Fatalf("exported_at = %q, want %q", manifest.ExportedAt, exportedAt.Format(time.RFC3339)) + } + if len(manifest.Squads) != 1 { + t.Fatalf("squads len = %d, want 1", len(manifest.Squads)) + } + squad := manifest.Squads[0] + if squad.Ref != "squad.release-squad" { + t.Fatalf("squad ref = %q, want portable slug ref", squad.Ref) + } + if squad.LeaderRef != "agent.release-lead" { + t.Fatalf("leader_ref = %q, want agent.release-lead", squad.LeaderRef) + } + if len(squad.Members) != 2 { + t.Fatalf("agent squad members len = %d, want 2", len(squad.Members)) + } + if squad.Members[1].Ref != "agent.release-reviewer" { + t.Fatalf("second member ref = %q, want agent.release-reviewer", squad.Members[1].Ref) + } + + if len(manifest.Agents) != 2 { + t.Fatalf("agents len = %d, want 2", len(manifest.Agents)) + } + lead := manifest.Agents[0] + if lead.Ref != "agent.release-lead" { + t.Fatalf("agent ref = %q, want agent.release-lead", lead.Ref) + } + if len(lead.SkillRefs) != 1 || lead.SkillRefs[0] != "skill.release-runbook" { + t.Fatalf("agent skill_refs = %#v, want [skill.release-runbook]", lead.SkillRefs) + } + if lead.Runtime.Provider != "codex" { + t.Fatalf("runtime provider = %q, want codex", lead.Runtime.Provider) + } + + if len(manifest.Skills) != 1 { + t.Fatalf("skills len = %d, want 1", len(manifest.Skills)) + } + skill := manifest.Skills[0] + if skill.Ref != "skill.release-runbook" { + t.Fatalf("skill ref = %q, want skill.release-runbook", skill.Ref) + } + if len(skill.Files) != 1 || skill.Files[0].Path != "steps/checklist.md" { + t.Fatalf("skill files = %#v, want steps/checklist.md", skill.Files) + } +} + +func TestBuildManifestRedactsSensitiveAgentFields(t *testing.T) { + manifest, err := BuildManifest(Source{ + Name: "Secret Squad", + Agents: []SourceAgent{{ + ID: "agent-secret-id", + Name: "Secret Agent", + RuntimeID: "runtime-db-id", + RuntimeMode: "local", + RuntimeProvider: "codex", + RuntimeConfig: json.RawMessage(`{"workdir":"/private/tmp/project","provider":"codex"}`), + Visibility: "private", + MaxConcurrentTasks: 1, + CustomEnv: map[string]string{ + "GITHUB_TOKEN": "ghp_secret", + "OPENAI_KEY": "sk-secret", + }, + CustomArgs: []string{"--safe-mode"}, + MCPConfig: json.RawMessage(`{"servers":{"github":{"env":{"GITHUB_TOKEN":"ghp_secret"}}}}`), + }}, + }) + if err != nil { + t.Fatalf("BuildManifest returned error: %v", err) + } + if len(manifest.Agents) != 1 { + t.Fatalf("agents len = %d, want 1", len(manifest.Agents)) + } + agent := manifest.Agents[0] + + encoded, err := json.Marshal(agent) + if err != nil { + t.Fatalf("marshal agent: %v", err) + } + for _, forbidden := range []string{"ghp_secret", "sk-secret", "runtime-db-id", "/private/tmp/project"} { + if containsJSON(encoded, forbidden) { + t.Fatalf("agent manifest leaked %q: %s", forbidden, encoded) + } + } + if len(agent.CustomEnvSchema) != 2 { + t.Fatalf("custom_env_schema len = %d, want 2", len(agent.CustomEnvSchema)) + } + if agent.CustomEnvSchema[0].Name != "GITHUB_TOKEN" || !agent.CustomEnvSchema[0].Secret { + t.Fatalf("first env schema = %#v, want secret GITHUB_TOKEN", agent.CustomEnvSchema[0]) + } + if !agent.MCPConfigRedacted { + t.Fatalf("mcp_config_redacted = false, want true") + } + if len(agent.CustomArgs) != 1 || agent.CustomArgs[0] != "--safe-mode" { + t.Fatalf("custom_args = %#v, want preserved non-secret args", agent.CustomArgs) + } +} + +func TestValidateManifestRejectsDuplicateRefsAndUnsafeSkillFiles(t *testing.T) { + manifest := Manifest{ + Schema: SchemaVersion, + Name: "Invalid", + Agents: []Agent{ + {Ref: "agent.same", Name: "A", Runtime: Runtime{Mode: "local"}}, + {Ref: "agent.same", Name: "B", Runtime: Runtime{Mode: "cloud"}}, + }, + Skills: []Skill{{ + Ref: "skill.bad", + Name: "Bad Skill", + Files: []SkillFile{{Path: "../secret.txt", Content: "nope"}}, + }}, + } + + err := ValidateManifest(manifest) + if err == nil { + t.Fatal("ValidateManifest returned nil, want duplicate ref/path error") + } + if !containsError(err, "duplicate ref") { + t.Fatalf("ValidateManifest error = %v, want duplicate ref", err) + } + if !containsError(err, "unsafe skill file path") { + t.Fatalf("ValidateManifest error = %v, want unsafe skill file path", err) + } +} + +func containsJSON(raw []byte, needle string) bool { + return json.Valid(raw) && strings.Contains(string(raw), needle) +} + +func containsError(err error, needle string) bool { + return err != nil && strings.Contains(err.Error(), needle) +} diff --git a/server/internal/blueprint/preview.go b/server/internal/blueprint/preview.go new file mode 100644 index 0000000000..4194c43c7a --- /dev/null +++ b/server/internal/blueprint/preview.go @@ -0,0 +1,309 @@ +package blueprint + +import ( + "fmt" + "sort" + "strings" +) + +const ( + PreviewActionCreate = "create" + PreviewActionReuse = "reuse" + PreviewActionConflict = "conflict" + + RuntimeRequirementMatched = "matched" + RuntimeRequirementMapped = "mapped" + RuntimeRequirementMissing = "missing" + RuntimeRequirementNone = "none" +) + +type Inventory struct { + Squads []ExistingResource + Agents []ExistingResource + Skills []ExistingResource + Runtimes []ExistingRuntime + RuntimeMappings []RuntimeMapping + ProvidedEnv []ProvidedEnvVar +} + +type ExistingResource struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type ExistingRuntime struct { + ID string `json:"id"` + Provider string `json:"provider"` +} + +type RuntimeMapping struct { + Provider string `json:"provider"` + RuntimeID string `json:"runtime_id"` +} + +type ProvidedEnvVar struct { + AgentRef string `json:"agent_ref"` + Name string `json:"name"` +} + +type Preview struct { + Summary PreviewSummary `json:"summary"` + Squads []ResourcePlan `json:"squads"` + Agents []AgentPlan `json:"agents"` + Skills []ResourcePlan `json:"skills"` + Errors []PreviewIssue `json:"errors,omitempty"` + Warnings []PreviewIssue `json:"warnings,omitempty"` + HasBlockingIssues bool `json:"has_blocking_issues"` +} + +type PreviewSummary struct { + Squads ResourceSummary `json:"squads"` + Agents ResourceSummary `json:"agents"` + Skills ResourceSummary `json:"skills"` +} + +type ResourceSummary struct { + Create int `json:"create"` + Reuse int `json:"reuse"` + Conflict int `json:"conflict"` +} + +type ResourcePlan struct { + Ref string `json:"ref"` + Name string `json:"name"` + Action string `json:"action"` + ExistingID string `json:"existing_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type AgentPlan struct { + ResourcePlan + Runtime RuntimeRequirement `json:"runtime"` + MissingEnv []string `json:"missing_env,omitempty"` +} + +type RuntimeRequirement struct { + Provider string `json:"provider,omitempty"` + Status string `json:"status"` + RuntimeID string `json:"runtime_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type PreviewIssue struct { + Code string `json:"code"` + Ref string `json:"ref,omitempty"` + Message string `json:"message"` +} + +func PreviewManifest(manifest Manifest, inventory Inventory) (Preview, error) { + if err := ValidateManifest(manifest); err != nil { + return Preview{}, err + } + + squadNameCounts := countNames(manifest.Squads, func(s Squad) string { return s.Name }) + agentNameCounts := countNames(manifest.Agents, func(a Agent) string { return a.Name }) + skillNameCounts := countNames(manifest.Skills, func(s Skill) string { return s.Name }) + + existingSquads := resourcesByName(inventory.Squads) + existingAgents := resourcesByName(inventory.Agents) + existingSkills := resourcesByName(inventory.Skills) + runtimesByProvider := runtimesByProvider(inventory.Runtimes) + runtimesByID := runtimesByID(inventory.Runtimes) + runtimeMappings := runtimeMappingsByProvider(inventory.RuntimeMappings) + providedEnv := providedEnvByAgentRef(inventory.ProvidedEnv) + + preview := Preview{ + Squads: make([]ResourcePlan, 0, len(manifest.Squads)), + Agents: make([]AgentPlan, 0, len(manifest.Agents)), + Skills: make([]ResourcePlan, 0, len(manifest.Skills)), + } + + for _, squad := range manifest.Squads { + plan := planResource(squad.Ref, squad.Name, existingSquads, squadNameCounts) + preview.Squads = append(preview.Squads, plan) + preview.Summary.Squads.add(plan.Action) + if plan.Action == PreviewActionConflict { + preview.addError("duplicate_squad_name", squad.Ref, plan.Reason) + } + } + + for _, skill := range manifest.Skills { + plan := planResource(skill.Ref, skill.Name, existingSkills, skillNameCounts) + preview.Skills = append(preview.Skills, plan) + preview.Summary.Skills.add(plan.Action) + if plan.Action == PreviewActionConflict { + preview.addError("duplicate_skill_name", skill.Ref, plan.Reason) + } + } + + for _, agent := range manifest.Agents { + resource := planResource(agent.Ref, agent.Name, existingAgents, agentNameCounts) + plan := AgentPlan{ + ResourcePlan: resource, + Runtime: resolveRuntimeRequirement(agent.Runtime, runtimesByProvider, runtimesByID, runtimeMappings), + MissingEnv: missingEnv(agent, providedEnv[agent.Ref]), + } + preview.Agents = append(preview.Agents, plan) + preview.Summary.Agents.add(plan.Action) + if plan.Action == PreviewActionConflict { + preview.addError("duplicate_agent_name", agent.Ref, plan.Reason) + } + if plan.Runtime.Status == RuntimeRequirementMissing { + preview.addError("missing_runtime", agent.Ref, plan.Runtime.Reason) + } + for _, envName := range plan.MissingEnv { + preview.addError("missing_env", agent.Ref, fmt.Sprintf("environment variable %q is required for import", envName)) + } + } + + preview.HasBlockingIssues = len(preview.Errors) > 0 + return preview, nil +} + +func planResource(ref, name string, existing map[string]ExistingResource, counts map[string]int) ResourcePlan { + normalized := normalizeName(name) + if counts[normalized] > 1 { + return ResourcePlan{ + Ref: ref, + Name: name, + Action: PreviewActionConflict, + Reason: fmt.Sprintf("duplicate name %q in blueprint", name), + } + } + if found, ok := existing[normalized]; ok { + return ResourcePlan{ + Ref: ref, + Name: name, + Action: PreviewActionReuse, + ExistingID: found.ID, + } + } + return ResourcePlan{ + Ref: ref, + Name: name, + Action: PreviewActionCreate, + } +} + +func resolveRuntimeRequirement(runtime Runtime, byProvider map[string]ExistingRuntime, byID map[string]ExistingRuntime, mappings map[string]string) RuntimeRequirement { + provider := strings.TrimSpace(runtime.Provider) + if provider == "" { + return RuntimeRequirement{Status: RuntimeRequirementNone} + } + if runtimeID := mappings[provider]; runtimeID != "" { + if _, ok := byID[runtimeID]; ok { + return RuntimeRequirement{ + Provider: provider, + Status: RuntimeRequirementMapped, + RuntimeID: runtimeID, + } + } + return RuntimeRequirement{ + Provider: provider, + Status: RuntimeRequirementMissing, + Reason: fmt.Sprintf("mapped runtime %q was not found in this workspace", runtimeID), + } + } + if found, ok := byProvider[provider]; ok { + return RuntimeRequirement{ + Provider: provider, + Status: RuntimeRequirementMatched, + RuntimeID: found.ID, + } + } + return RuntimeRequirement{ + Provider: provider, + Status: RuntimeRequirementMissing, + Reason: fmt.Sprintf("runtime provider %q is not available in this workspace", provider), + } +} + +func missingEnv(agent Agent, provided map[string]struct{}) []string { + if len(agent.CustomEnvSchema) == 0 { + return nil + } + missing := make([]string, 0, len(agent.CustomEnvSchema)) + for _, env := range agent.CustomEnvSchema { + if _, ok := provided[env.Name]; !ok { + missing = append(missing, env.Name) + } + } + sort.Strings(missing) + return missing +} + +func (p *Preview) addError(code, ref, message string) { + p.Errors = append(p.Errors, PreviewIssue{ + Code: code, + Ref: ref, + Message: message, + }) +} + +func (s *ResourceSummary) add(action string) { + switch action { + case PreviewActionCreate: + s.Create++ + case PreviewActionReuse: + s.Reuse++ + case PreviewActionConflict: + s.Conflict++ + } +} + +func countNames[T any](items []T, nameFn func(T) string) map[string]int { + counts := map[string]int{} + for _, item := range items { + counts[normalizeName(nameFn(item))]++ + } + return counts +} + +func resourcesByName(resources []ExistingResource) map[string]ExistingResource { + out := make(map[string]ExistingResource, len(resources)) + for _, resource := range resources { + out[normalizeName(resource.Name)] = resource + } + return out +} + +func runtimesByProvider(runtimes []ExistingRuntime) map[string]ExistingRuntime { + out := make(map[string]ExistingRuntime, len(runtimes)) + for _, runtime := range runtimes { + if _, exists := out[runtime.Provider]; !exists { + out[runtime.Provider] = runtime + } + } + return out +} + +func runtimesByID(runtimes []ExistingRuntime) map[string]ExistingRuntime { + out := make(map[string]ExistingRuntime, len(runtimes)) + for _, runtime := range runtimes { + out[runtime.ID] = runtime + } + return out +} + +func runtimeMappingsByProvider(mappings []RuntimeMapping) map[string]string { + out := make(map[string]string, len(mappings)) + for _, mapping := range mappings { + out[mapping.Provider] = mapping.RuntimeID + } + return out +} + +func providedEnvByAgentRef(envVars []ProvidedEnvVar) map[string]map[string]struct{} { + out := map[string]map[string]struct{}{} + for _, envVar := range envVars { + if out[envVar.AgentRef] == nil { + out[envVar.AgentRef] = map[string]struct{}{} + } + out[envVar.AgentRef][envVar.Name] = struct{}{} + } + return out +} + +func normalizeName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/server/internal/blueprint/preview_test.go b/server/internal/blueprint/preview_test.go new file mode 100644 index 0000000000..5ff6573a11 --- /dev/null +++ b/server/internal/blueprint/preview_test.go @@ -0,0 +1,139 @@ +package blueprint + +import "testing" + +func TestPreviewManifestClassifiesCreatesReusesAndMissingRequirements(t *testing.T) { + manifest := Manifest{ + Schema: SchemaVersion, + Name: "Release Package", + Squads: []Squad{{ + Ref: "squad.release", + Name: "Release Squad", + LeaderRef: "agent.release-lead", + Members: []SquadMember{{Ref: "agent.release-lead"}}, + }}, + Agents: []Agent{ + { + Ref: "agent.release-lead", + Name: "Release Lead", + Runtime: Runtime{Mode: "local", Provider: "codex", Model: "gpt-5.4"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + CustomEnvSchema: []EnvVar{{Name: "GITHUB_TOKEN", Secret: true}}, + SkillRefs: []string{"skill.release-runbook"}, + }, + { + Ref: "agent.release-reviewer", + Name: "Release Reviewer", + Runtime: Runtime{Mode: "cloud", Provider: "multica_agent"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + }, + }, + Skills: []Skill{{ + Ref: "skill.release-runbook", + Name: "Release Runbook", + Config: []byte(`{}`), + }}, + } + + preview, err := PreviewManifest(manifest, Inventory{ + Agents: []ExistingResource{{ID: "agent-existing-id", Name: "Release Reviewer"}}, + Skills: []ExistingResource{{ID: "skill-existing-id", Name: "Release Runbook"}}, + Runtimes: []ExistingRuntime{{ID: "runtime-codex-id", Provider: "codex"}}, + ProvidedEnv: []ProvidedEnvVar{{ + AgentRef: "agent.release-lead", + Name: "GITHUB_TOKEN", + }}, + }) + if err != nil { + t.Fatalf("PreviewManifest returned error: %v", err) + } + + if preview.Summary.Squads.Create != 1 { + t.Fatalf("squad create count = %d, want 1", preview.Summary.Squads.Create) + } + if preview.Summary.Agents.Create != 1 || preview.Summary.Agents.Reuse != 1 { + t.Fatalf("agent summary = %#v, want create=1 reuse=1", preview.Summary.Agents) + } + if preview.Summary.Skills.Reuse != 1 { + t.Fatalf("skill reuse count = %d, want 1", preview.Summary.Skills.Reuse) + } + if preview.Agents[0].Action != PreviewActionCreate { + t.Fatalf("first agent action = %q, want create", preview.Agents[0].Action) + } + if preview.Agents[0].Runtime.Status != RuntimeRequirementMatched || preview.Agents[0].Runtime.RuntimeID != "runtime-codex-id" { + t.Fatalf("first agent runtime = %#v, want matched codex runtime", preview.Agents[0].Runtime) + } + if len(preview.Agents[0].MissingEnv) != 0 { + t.Fatalf("first agent missing env = %#v, want none", preview.Agents[0].MissingEnv) + } + if preview.Agents[1].Action != PreviewActionReuse || preview.Agents[1].ExistingID != "agent-existing-id" { + t.Fatalf("second agent = %#v, want reuse existing agent", preview.Agents[1]) + } + if preview.Agents[1].Runtime.Status != RuntimeRequirementMissing { + t.Fatalf("second agent runtime status = %q, want missing", preview.Agents[1].Runtime.Status) + } +} + +func TestPreviewManifestAppliesRuntimeMappingsAndReportsEnvGaps(t *testing.T) { + manifest := Manifest{ + Schema: SchemaVersion, + Name: "Mapped Package", + Agents: []Agent{{ + Ref: "agent.mapped", + Name: "Mapped Agent", + Runtime: Runtime{Mode: "local", Provider: "codex"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + CustomEnvSchema: []EnvVar{{Name: "OPENAI_API_KEY", Secret: true}}, + }}, + } + + preview, err := PreviewManifest(manifest, Inventory{ + Runtimes: []ExistingRuntime{{ID: "runtime-claude-id", Provider: "claude"}}, + RuntimeMappings: []RuntimeMapping{{Provider: "codex", RuntimeID: "runtime-claude-id"}}, + }) + if err != nil { + t.Fatalf("PreviewManifest returned error: %v", err) + } + if preview.Agents[0].Runtime.Status != RuntimeRequirementMapped { + t.Fatalf("runtime status = %q, want mapped", preview.Agents[0].Runtime.Status) + } + if preview.Agents[0].Runtime.RuntimeID != "runtime-claude-id" { + t.Fatalf("runtime id = %q, want mapped runtime", preview.Agents[0].Runtime.RuntimeID) + } + if len(preview.Agents[0].MissingEnv) != 1 || preview.Agents[0].MissingEnv[0] != "OPENAI_API_KEY" { + t.Fatalf("missing env = %#v, want OPENAI_API_KEY", preview.Agents[0].MissingEnv) + } + if preview.HasBlockingIssues != true { + t.Fatalf("has_blocking_issues = false, want true for missing env") + } +} + +func TestPreviewManifestReportsDuplicateNamesAsConflicts(t *testing.T) { + manifest := Manifest{ + Schema: SchemaVersion, + Name: "Duplicate Package", + Agents: []Agent{ + {Ref: "agent.one", Name: "Duplicate Agent", Runtime: Runtime{Mode: "local"}, MaxConcurrentTasks: 1}, + {Ref: "agent.two", Name: "Duplicate Agent", Runtime: Runtime{Mode: "local"}, MaxConcurrentTasks: 1}, + }, + } + + preview, err := PreviewManifest(manifest, Inventory{}) + if err != nil { + t.Fatalf("PreviewManifest returned error: %v", err) + } + if preview.Summary.Agents.Conflict != 2 { + t.Fatalf("agent conflict count = %d, want 2", preview.Summary.Agents.Conflict) + } + if !preview.HasBlockingIssues { + t.Fatalf("has_blocking_issues = false, want true") + } + for _, agent := range preview.Agents { + if agent.Action != PreviewActionConflict { + t.Fatalf("agent action = %q, want conflict", agent.Action) + } + } +} diff --git a/server/internal/handler/blueprint.go b/server/internal/handler/blueprint.go new file mode 100644 index 0000000000..44209dda25 --- /dev/null +++ b/server/internal/handler/blueprint.go @@ -0,0 +1,791 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/blueprint" + agentpkg "github.com/multica-ai/multica/server/pkg/agent" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type ExportBlueprintRequest struct { + Name string `json:"name"` + SquadIDs []string `json:"squad_ids"` + AgentIDs []string `json:"agent_ids"` + SkillIDs []string `json:"skill_ids"` +} + +type PreviewBlueprintRequest struct { + Manifest blueprint.Manifest `json:"manifest"` + RuntimeMappings []blueprint.RuntimeMapping `json:"runtime_mappings"` + ProvidedEnv []blueprint.ProvidedEnvVar `json:"provided_env"` +} + +type ApplyBlueprintRequest struct { + Manifest blueprint.Manifest `json:"manifest"` + RuntimeMappings []blueprint.RuntimeMapping `json:"runtime_mappings"` + ProvidedEnv []ApplyBlueprintProvidedEnvVar `json:"provided_env"` +} + +type ApplyBlueprintProvidedEnvVar struct { + AgentRef string `json:"agent_ref"` + Name string `json:"name"` + Value string `json:"value"` +} + +type ApplyBlueprintResponse struct { + Preview blueprint.Preview `json:"preview"` + Squads []BlueprintApplyResultItem `json:"squads"` + Agents []BlueprintApplyResultItem `json:"agents"` + Skills []BlueprintApplyResultItem `json:"skills"` +} + +type BlueprintApplyResultItem struct { + Ref string `json:"ref"` + Name string `json:"name"` + Action string `json:"action"` + ID string `json:"id"` +} + +func (h *Handler) ExportBlueprint(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + if _, ok := h.workspaceMember(w, r, workspaceID); !ok { + return + } + + var req ExportBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if len(req.SquadIDs) == 0 && len(req.AgentIDs) == 0 && len(req.SkillIDs) == 0 { + writeError(w, http.StatusBadRequest, "at least one squad_id, agent_id, or skill_id is required") + return + } + + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + squadIDs, ok := parseBlueprintExportIDs(w, req.SquadIDs, "squad_ids") + if !ok { + return + } + explicitAgentIDs, ok := parseBlueprintExportIDs(w, req.AgentIDs, "agent_ids") + if !ok { + return + } + explicitSkillIDs, ok := parseBlueprintExportIDs(w, req.SkillIDs, "skill_ids") + if !ok { + return + } + + source := blueprint.Source{ + Name: req.Name, + SquadMembers: map[string][]blueprint.SourceSquadMember{}, + AgentSkillIDs: map[string][]string{}, + SkillFiles: map[string][]blueprint.SourceSkillFile{}, + } + + agentIDs := orderedIDSet{} + skillIDs := orderedIDSet{} + + for _, squadID := range squadIDs { + squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{ + ID: parseUUID(squadID), + WorkspaceID: wsUUID, + }) + if err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "squad not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load squad") + return + } + source.Squads = append(source.Squads, sourceSquadFromDB(squad)) + agentIDs.add(uuidToString(squad.LeaderID)) + + members, err := h.Queries.ListSquadMembers(r.Context(), squad.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load squad members") + return + } + memberSources := make([]blueprint.SourceSquadMember, 0, len(members)) + for _, member := range members { + memberID := uuidToString(member.MemberID) + memberSources = append(memberSources, blueprint.SourceSquadMember{ + MemberType: member.MemberType, + MemberID: memberID, + Role: member.Role, + }) + if member.MemberType == "agent" { + agentIDs.add(memberID) + } + } + source.SquadMembers[uuidToString(squad.ID)] = memberSources + } + + for _, agentID := range explicitAgentIDs { + agentIDs.add(agentID) + } + for _, skillID := range explicitSkillIDs { + skillIDs.add(skillID) + } + + userID := requestUserID(r) + actorType, actorID := h.resolveActor(r, userID, workspaceID) + for _, agentID := range agentIDs.ids { + agent, err := h.Queries.GetAgentInWorkspace(r.Context(), db.GetAgentInWorkspaceParams{ + ID: parseUUID(agentID), + WorkspaceID: wsUUID, + }) + if err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "agent not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load agent") + return + } + if !h.canAccessPrivateAgent(r.Context(), agent, actorType, actorID, workspaceID) { + writeError(w, http.StatusForbidden, "you do not have access to this agent") + return + } + + runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{ + ID: agent.RuntimeID, + WorkspaceID: wsUUID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load agent runtime") + return + } + source.Agents = append(source.Agents, sourceAgentFromDB(agent, runtime.Provider)) + + agentSkills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load agent skills") + return + } + for _, skill := range agentSkills { + skillID := uuidToString(skill.ID) + source.AgentSkillIDs[agentID] = append(source.AgentSkillIDs[agentID], skillID) + skillIDs.add(skillID) + } + } + + for _, skillID := range skillIDs.ids { + skill, err := h.Queries.GetSkillInWorkspace(r.Context(), db.GetSkillInWorkspaceParams{ + ID: parseUUID(skillID), + WorkspaceID: wsUUID, + }) + if err != nil { + if isNotFound(err) { + writeError(w, http.StatusNotFound, "skill not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load skill") + return + } + source.Skills = append(source.Skills, sourceSkillFromDB(skill)) + + files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load skill files") + return + } + fileSources := make([]blueprint.SourceSkillFile, 0, len(files)) + for _, file := range files { + fileSources = append(fileSources, blueprint.SourceSkillFile{ + Path: file.Path, + Content: file.Content, + }) + } + source.SkillFiles[skillID] = fileSources + } + + manifest, err := blueprint.BuildManifest(source) + if err != nil { + slog.Warn("blueprint export failed", "workspace_id", workspaceID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to build blueprint") + return + } + writeJSON(w, http.StatusOK, manifest) +} + +func (h *Handler) PreviewBlueprint(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok { + return + } + + var req PreviewBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := blueprint.ValidateManifest(req.Manifest); err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint manifest: "+err.Error()) + return + } + + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + for _, mapping := range req.RuntimeMappings { + if mapping.RuntimeID == "" { + continue + } + if _, ok := parseUUIDOrBadRequest(w, mapping.RuntimeID, "runtime_id"); !ok { + return + } + } + + inventory, ok := h.blueprintPreviewInventory(w, r, wsUUID, req.RuntimeMappings, req.ProvidedEnv) + if !ok { + return + } + preview, err := blueprint.PreviewManifest(req.Manifest, inventory) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint manifest: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, preview) +} + +func (h *Handler) ApplyBlueprint(w http.ResponseWriter, r *http.Request) { + workspaceID := h.resolveWorkspaceID(r) + member, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin") + if !ok { + return + } + + var req ApplyBlueprintRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := blueprint.ValidateManifest(req.Manifest); err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint manifest: "+err.Error()) + return + } + + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + for _, mapping := range req.RuntimeMappings { + if mapping.RuntimeID == "" { + continue + } + if _, ok := parseUUIDOrBadRequest(w, mapping.RuntimeID, "runtime_id"); !ok { + return + } + } + + providedEnv := providedEnvVarsFromApplyValues(req.ProvidedEnv) + inventory, ok := h.blueprintPreviewInventory(w, r, wsUUID, req.RuntimeMappings, providedEnv) + if !ok { + return + } + preview, err := blueprint.PreviewManifest(req.Manifest, inventory) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid blueprint manifest: "+err.Error()) + return + } + if preview.HasBlockingIssues { + writeJSON(w, http.StatusUnprocessableEntity, ApplyBlueprintResponse{Preview: preview}) + return + } + + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to start blueprint import") + return + } + defer tx.Rollback(r.Context()) + + resp, err := applyBlueprintInTx(r.Context(), h.Queries.WithTx(tx), member, wsUUID, req.Manifest, preview, req.ProvidedEnv) + if err != nil { + var statusErr blueprintApplyStatusError + if errors.As(err, &statusErr) { + writeError(w, statusErr.Status, statusErr.Message) + return + } + slog.Warn("blueprint apply failed", "error", err, "workspace_id", workspaceID) + writeError(w, http.StatusInternalServerError, "failed to apply blueprint") + return + } + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to apply blueprint") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +func parseBlueprintExportIDs(w http.ResponseWriter, ids []string, fieldName string) ([]string, bool) { + out := make([]string, 0, len(ids)) + for _, id := range ids { + u, ok := parseUUIDOrBadRequest(w, id, fieldName) + if !ok { + return nil, false + } + out = append(out, uuidToString(u)) + } + return out, true +} + +type orderedIDSet struct { + ids []string + seen map[string]struct{} +} + +func (s *orderedIDSet) add(id string) { + if id == "" { + return + } + if s.seen == nil { + s.seen = map[string]struct{}{} + } + if _, ok := s.seen[id]; ok { + return + } + s.seen[id] = struct{}{} + s.ids = append(s.ids, id) +} + +func sourceSquadFromDB(s db.Squad) blueprint.SourceSquad { + return blueprint.SourceSquad{ + ID: uuidToString(s.ID), + Name: s.Name, + Description: s.Description, + Instructions: s.Instructions, + AvatarURL: textToPtr(s.AvatarUrl), + LeaderID: uuidToString(s.LeaderID), + } +} + +func sourceAgentFromDB(a db.Agent, runtimeProvider string) blueprint.SourceAgent { + customEnv := map[string]string{} + if len(a.CustomEnv) > 0 { + if err := json.Unmarshal(a.CustomEnv, &customEnv); err != nil { + slog.Warn("failed to unmarshal agent custom_env for blueprint export", "agent_id", uuidToString(a.ID), "error", err) + customEnv = map[string]string{} + } + } + + customArgs := []string{} + if len(a.CustomArgs) > 0 { + if err := json.Unmarshal(a.CustomArgs, &customArgs); err != nil { + slog.Warn("failed to unmarshal agent custom_args for blueprint export", "agent_id", uuidToString(a.ID), "error", err) + customArgs = []string{} + } + } + + var mcpConfig json.RawMessage + if len(a.McpConfig) > 0 { + mcpConfig = append(json.RawMessage(nil), a.McpConfig...) + } + + return blueprint.SourceAgent{ + ID: uuidToString(a.ID), + Name: a.Name, + Description: a.Description, + Instructions: a.Instructions, + AvatarURL: textToPtr(a.AvatarUrl), + RuntimeID: uuidToString(a.RuntimeID), + RuntimeMode: a.RuntimeMode, + RuntimeProvider: runtimeProvider, + RuntimeConfig: append(json.RawMessage(nil), a.RuntimeConfig...), + Visibility: a.Visibility, + MaxConcurrentTasks: a.MaxConcurrentTasks, + Model: a.Model.String, + ThinkingLevel: a.ThinkingLevel.String, + CustomEnv: customEnv, + CustomArgs: customArgs, + MCPConfig: mcpConfig, + } +} + +func sourceSkillFromDB(s db.Skill) blueprint.SourceSkill { + config := json.RawMessage(`{}`) + if len(s.Config) > 0 { + config = append(json.RawMessage(nil), s.Config...) + } + return blueprint.SourceSkill{ + ID: uuidToString(s.ID), + Name: s.Name, + Description: s.Description, + Content: s.Content, + Config: config, + } +} + +func (h *Handler) blueprintPreviewInventory(w http.ResponseWriter, r *http.Request, workspaceID pgtype.UUID, runtimeMappings []blueprint.RuntimeMapping, providedEnv []blueprint.ProvidedEnvVar) (blueprint.Inventory, bool) { + squads, err := h.Queries.ListAllSquads(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load squads") + return blueprint.Inventory{}, false + } + agents, err := h.Queries.ListAllAgents(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load agents") + return blueprint.Inventory{}, false + } + skills, err := h.Queries.ListSkillSummariesByWorkspace(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load skills") + return blueprint.Inventory{}, false + } + runtimes, err := h.Queries.ListAgentRuntimes(r.Context(), workspaceID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load runtimes") + return blueprint.Inventory{}, false + } + + inventory := blueprint.Inventory{ + Squads: make([]blueprint.ExistingResource, 0, len(squads)), + Agents: make([]blueprint.ExistingResource, 0, len(agents)), + Skills: make([]blueprint.ExistingResource, 0, len(skills)), + Runtimes: make([]blueprint.ExistingRuntime, 0, len(runtimes)), + RuntimeMappings: runtimeMappings, + ProvidedEnv: providedEnv, + } + for _, squad := range squads { + inventory.Squads = append(inventory.Squads, blueprint.ExistingResource{ + ID: uuidToString(squad.ID), + Name: squad.Name, + }) + } + for _, agent := range agents { + inventory.Agents = append(inventory.Agents, blueprint.ExistingResource{ + ID: uuidToString(agent.ID), + Name: agent.Name, + }) + } + for _, skill := range skills { + inventory.Skills = append(inventory.Skills, blueprint.ExistingResource{ + ID: uuidToString(skill.ID), + Name: skill.Name, + }) + } + for _, runtime := range runtimes { + inventory.Runtimes = append(inventory.Runtimes, blueprint.ExistingRuntime{ + ID: uuidToString(runtime.ID), + Provider: runtime.Provider, + }) + } + return inventory, true +} + +type blueprintApplyStatusError struct { + Status int + Message string +} + +func (e blueprintApplyStatusError) Error() string { + return e.Message +} + +func applyBlueprintInTx( + ctx context.Context, + qtx *db.Queries, + member db.Member, + workspaceID pgtype.UUID, + manifest blueprint.Manifest, + preview blueprint.Preview, + providedEnv []ApplyBlueprintProvidedEnvVar, +) (ApplyBlueprintResponse, error) { + resp := ApplyBlueprintResponse{ + Preview: preview, + Squads: make([]BlueprintApplyResultItem, 0, len(manifest.Squads)), + Agents: make([]BlueprintApplyResultItem, 0, len(manifest.Agents)), + Skills: make([]BlueprintApplyResultItem, 0, len(manifest.Skills)), + } + skillPlans := blueprintResourcePlansByRef(preview.Skills) + agentPlans := blueprintAgentPlansByRef(preview.Agents) + squadPlans := blueprintResourcePlansByRef(preview.Squads) + envByAgentRef := applyEnvValuesByAgentRef(providedEnv) + + skillIDs := make(map[string]string, len(manifest.Skills)) + for _, skill := range manifest.Skills { + plan, ok := skillPlans[skill.Ref] + if !ok { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("missing preview plan for skill %q", skill.Ref)} + } + if plan.Action == blueprint.PreviewActionReuse { + skillIDs[skill.Ref] = plan.ExistingID + resp.Skills = append(resp.Skills, applyResultFromPlan(plan, plan.ExistingID)) + continue + } + + files := make([]CreateSkillFileRequest, 0, len(skill.Files)) + for _, file := range skill.Files { + files = append(files, CreateSkillFileRequest{Path: file.Path, Content: file.Content}) + } + config := any(skill.Config) + if len(skill.Config) == 0 { + config = json.RawMessage(`{}`) + } + created, err := createSkillWithFilesInTx(ctx, qtx, skillCreateInput{ + WorkspaceID: workspaceID, + CreatorID: member.UserID, + Name: skill.Name, + Description: skill.Description, + Content: skill.Content, + Config: config, + Files: files, + }) + if err != nil { + return ApplyBlueprintResponse{}, err + } + skillIDs[skill.Ref] = created.ID + resp.Skills = append(resp.Skills, applyResultFromPlan(plan, created.ID)) + } + + agentIDs := make(map[string]string, len(manifest.Agents)) + for _, bpAgent := range manifest.Agents { + plan, ok := agentPlans[bpAgent.Ref] + if !ok { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("missing preview plan for agent %q", bpAgent.Ref)} + } + if plan.Action == blueprint.PreviewActionReuse { + agentIDs[bpAgent.Ref] = plan.ExistingID + resp.Agents = append(resp.Agents, applyResultFromPlan(plan.ResourcePlan, plan.ExistingID)) + continue + } + if plan.Runtime.RuntimeID == "" { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusUnprocessableEntity, Message: fmt.Sprintf("missing runtime for agent %q", bpAgent.Ref)} + } + + runtimeID := parseUUID(plan.Runtime.RuntimeID) + runtime, err := qtx.GetAgentRuntimeForWorkspace(ctx, db.GetAgentRuntimeForWorkspaceParams{ + ID: runtimeID, + WorkspaceID: workspaceID, + }) + if err != nil { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusUnprocessableEntity, Message: fmt.Sprintf("runtime for agent %q is no longer available", bpAgent.Ref)} + } + if !canUseRuntimeForAgent(member, runtime) { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusForbidden, Message: "this runtime is private; only its owner or a workspace admin can create agents on it"} + } + if !agentpkg.IsKnownThinkingValue(runtime.Provider, bpAgent.Runtime.ThinkingLevel) { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{ + Status: http.StatusBadRequest, + Message: fmt.Sprintf("thinking_level %q is not a recognised value for runtime %q", bpAgent.Runtime.ThinkingLevel, runtime.Provider), + } + } + + customEnvRaw, err := json.Marshal(applyCustomEnvForAgent(bpAgent, envByAgentRef[bpAgent.Ref])) + if err != nil { + return ApplyBlueprintResponse{}, err + } + customArgsRaw, err := json.Marshal(bpAgent.CustomArgs) + if err != nil { + return ApplyBlueprintResponse{}, err + } + if bpAgent.CustomArgs == nil { + customArgsRaw = []byte("[]") + } + + created, err := qtx.CreateAgent(ctx, db.CreateAgentParams{ + WorkspaceID: workspaceID, + Name: sanitizeNullBytes(bpAgent.Name), + Description: sanitizeNullBytes(bpAgent.Description), + AvatarUrl: ptrToText(bpAgent.AvatarURL), + RuntimeMode: runtime.RuntimeMode, + RuntimeConfig: []byte("{}"), + RuntimeID: runtime.ID, + Visibility: blueprintAgentVisibility(bpAgent.Visibility), + MaxConcurrentTasks: blueprintAgentMaxConcurrentTasks(bpAgent.MaxConcurrentTasks), + OwnerID: member.UserID, + Instructions: sanitizeNullBytes(bpAgent.Instructions), + CustomEnv: customEnvRaw, + CustomArgs: customArgsRaw, + McpConfig: nil, + Model: pgtype.Text{String: bpAgent.Runtime.Model, Valid: bpAgent.Runtime.Model != ""}, + ThinkingLevel: pgtype.Text{String: bpAgent.Runtime.ThinkingLevel, Valid: bpAgent.Runtime.ThinkingLevel != ""}, + }) + if err != nil { + return ApplyBlueprintResponse{}, err + } + createdID := uuidToString(created.ID) + agentIDs[bpAgent.Ref] = createdID + resp.Agents = append(resp.Agents, applyResultFromPlan(plan.ResourcePlan, createdID)) + + for _, skillRef := range bpAgent.SkillRefs { + skillID := skillIDs[skillRef] + if skillID == "" { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("agent %q references missing skill %q", bpAgent.Ref, skillRef)} + } + if err := qtx.AddAgentSkill(ctx, db.AddAgentSkillParams{ + AgentID: created.ID, + SkillID: parseUUID(skillID), + }); err != nil { + return ApplyBlueprintResponse{}, err + } + } + } + + for _, squad := range manifest.Squads { + plan, ok := squadPlans[squad.Ref] + if !ok { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("missing preview plan for squad %q", squad.Ref)} + } + if plan.Action == blueprint.PreviewActionReuse { + resp.Squads = append(resp.Squads, applyResultFromPlan(plan, plan.ExistingID)) + continue + } + + leaderID := agentIDs[squad.LeaderRef] + if leaderID == "" { + return ApplyBlueprintResponse{}, blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("squad %q references missing leader %q", squad.Ref, squad.LeaderRef)} + } + created, err := qtx.CreateSquad(ctx, db.CreateSquadParams{ + WorkspaceID: workspaceID, + Name: sanitizeNullBytes(squad.Name), + Description: sanitizeNullBytes(squad.Description), + LeaderID: parseUUID(leaderID), + CreatorID: member.UserID, + AvatarUrl: ptrToText(squad.AvatarURL), + }) + if err != nil { + return ApplyBlueprintResponse{}, err + } + if squad.Instructions != "" { + created, err = qtx.UpdateSquad(ctx, db.UpdateSquadParams{ + ID: created.ID, + Name: strToText(sanitizeNullBytes(squad.Name)), + Description: strToText(sanitizeNullBytes(squad.Description)), + LeaderID: parseUUID(leaderID), + AvatarUrl: ptrToText(squad.AvatarURL), + Instructions: strToText(sanitizeNullBytes(squad.Instructions)), + }) + if err != nil { + return ApplyBlueprintResponse{}, err + } + } + + if err := addBlueprintSquadMembers(ctx, qtx, created.ID, squad, agentIDs); err != nil { + return ApplyBlueprintResponse{}, err + } + resp.Squads = append(resp.Squads, applyResultFromPlan(plan, uuidToString(created.ID))) + } + + return resp, nil +} + +func providedEnvVarsFromApplyValues(values []ApplyBlueprintProvidedEnvVar) []blueprint.ProvidedEnvVar { + out := make([]blueprint.ProvidedEnvVar, 0, len(values)) + for _, value := range values { + out = append(out, blueprint.ProvidedEnvVar{ + AgentRef: value.AgentRef, + Name: value.Name, + }) + } + return out +} + +func applyEnvValuesByAgentRef(values []ApplyBlueprintProvidedEnvVar) map[string]map[string]string { + out := map[string]map[string]string{} + for _, value := range values { + if out[value.AgentRef] == nil { + out[value.AgentRef] = map[string]string{} + } + out[value.AgentRef][value.Name] = value.Value + } + return out +} + +func applyCustomEnvForAgent(agent blueprint.Agent, provided map[string]string) map[string]string { + out := map[string]string{} + for _, env := range agent.CustomEnvSchema { + if value, ok := provided[env.Name]; ok { + out[env.Name] = value + } + } + return out +} + +func blueprintResourcePlansByRef(plans []blueprint.ResourcePlan) map[string]blueprint.ResourcePlan { + out := make(map[string]blueprint.ResourcePlan, len(plans)) + for _, plan := range plans { + out[plan.Ref] = plan + } + return out +} + +func blueprintAgentPlansByRef(plans []blueprint.AgentPlan) map[string]blueprint.AgentPlan { + out := make(map[string]blueprint.AgentPlan, len(plans)) + for _, plan := range plans { + out[plan.Ref] = plan + } + return out +} + +func applyResultFromPlan(plan blueprint.ResourcePlan, id string) BlueprintApplyResultItem { + return BlueprintApplyResultItem{ + Ref: plan.Ref, + Name: plan.Name, + Action: plan.Action, + ID: id, + } +} + +func blueprintAgentVisibility(visibility string) string { + if visibility == "" { + return "private" + } + return visibility +} + +func blueprintAgentMaxConcurrentTasks(maxConcurrentTasks int32) int32 { + if maxConcurrentTasks == 0 { + return 6 + } + return maxConcurrentTasks +} + +func addBlueprintSquadMembers(ctx context.Context, qtx *db.Queries, squadID pgtype.UUID, squad blueprint.Squad, agentIDs map[string]string) error { + seen := map[string]struct{}{} + addMember := func(ref, role string) error { + if ref == "" { + return nil + } + if _, ok := seen[ref]; ok { + return nil + } + seen[ref] = struct{}{} + agentID := agentIDs[ref] + if agentID == "" { + return blueprintApplyStatusError{Status: http.StatusBadRequest, Message: fmt.Sprintf("squad %q references missing member %q", squad.Ref, ref)} + } + if role == "" && ref == squad.LeaderRef { + role = "leader" + } + _, err := qtx.AddSquadMember(ctx, db.AddSquadMemberParams{ + SquadID: squadID, + MemberType: "agent", + MemberID: parseUUID(agentID), + Role: role, + }) + return err + } + + for _, member := range squad.Members { + if err := addMember(member.Ref, member.Role); err != nil { + return err + } + } + return addMember(squad.LeaderRef, "leader") +} diff --git a/server/internal/handler/blueprint_apply_test.go b/server/internal/handler/blueprint_apply_test.go new file mode 100644 index 0000000000..ab3019d34c --- /dev/null +++ b/server/internal/handler/blueprint_apply_test.go @@ -0,0 +1,302 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/blueprint" +) + +type applyBlueprintTestResourceResult struct { + Ref string `json:"ref"` + Name string `json:"name"` + Action string `json:"action"` + ID string `json:"id"` +} + +type applyBlueprintTestResponse struct { + Preview blueprint.Preview `json:"preview"` + Squads []applyBlueprintTestResourceResult `json:"squads"` + Agents []applyBlueprintTestResourceResult `json:"agents"` + Skills []applyBlueprintTestResourceResult `json:"skills"` +} + +func TestApplyBlueprint_CreatesResourcesAndReusesOnSecondApply(t *testing.T) { + ctx := context.Background() + manifest := blueprint.Manifest{ + Schema: blueprint.SchemaVersion, + Name: "Apply Package", + Skills: []blueprint.Skill{{ + Ref: "skill.runbook", + Name: "Apply Runbook " + t.Name(), + Description: "Imported runbook skill", + Content: "# Release", + Config: json.RawMessage(`{"scope":"release"}`), + Files: []blueprint.SkillFile{{ + Path: "steps/checklist.md", + Content: "- Verify changelog", + }}, + }}, + Agents: []blueprint.Agent{{ + Ref: "agent.lead", + Name: "Apply Release Lead " + t.Name(), + Description: "Imported release lead", + Instructions: "Run the release process.", + Runtime: blueprint.Runtime{Mode: "cloud", Provider: "codex"}, + Visibility: "workspace", + MaxConcurrentTasks: 2, + CustomEnvSchema: []blueprint.EnvVar{{ + Name: "GITHUB_TOKEN", + Required: true, + Secret: true, + }}, + CustomArgs: []string{"--safe-mode"}, + SkillRefs: []string{"skill.runbook"}, + }}, + Squads: []blueprint.Squad{{ + Ref: "squad.release", + Name: "Apply Release Squad " + t.Name(), + Description: "Imported release squad", + Instructions: "Coordinate the release.", + LeaderRef: "agent.lead", + Members: []blueprint.SquadMember{{ + Ref: "agent.lead", + Role: "lead", + }}, + }}, + } + + reqBody := map[string]any{ + "manifest": manifest, + "runtime_mappings": []map[string]string{{ + "provider": "codex", + "runtime_id": testRuntimeID, + }}, + "provided_env": []map[string]string{{ + "agent_ref": "agent.lead", + "name": "GITHUB_TOKEN", + "value": "ghp_imported", + }, { + "agent_ref": "agent.lead", + "name": "EXTRA_SECRET", + "value": "should_not_import", + }}, + } + + first := applyBlueprintForTest(t, reqBody) + if first.Preview.Summary.Skills.Create != 1 || first.Preview.Summary.Agents.Create != 1 || first.Preview.Summary.Squads.Create != 1 { + t.Fatalf("first apply summary = %#v, want create=1 for skill/agent/squad", first.Preview.Summary) + } + if len(first.Skills) != 1 || first.Skills[0].Action != blueprint.PreviewActionCreate || first.Skills[0].ID == "" { + t.Fatalf("first apply skills = %#v, want created skill id", first.Skills) + } + if len(first.Agents) != 1 || first.Agents[0].Action != blueprint.PreviewActionCreate || first.Agents[0].ID == "" { + t.Fatalf("first apply agents = %#v, want created agent id", first.Agents) + } + if len(first.Squads) != 1 || first.Squads[0].Action != blueprint.PreviewActionCreate || first.Squads[0].ID == "" { + t.Fatalf("first apply squads = %#v, want created squad id", first.Squads) + } + + var agentID string + var customEnvRaw, customArgsRaw []byte + if err := testPool.QueryRow(ctx, ` + SELECT id, custom_env, custom_args FROM agent + WHERE workspace_id = $1 AND name = $2 + `, testWorkspaceID, manifest.Agents[0].Name).Scan(&agentID, &customEnvRaw, &customArgsRaw); err != nil { + t.Fatalf("load imported agent: %v", err) + } + if agentID != first.Agents[0].ID { + t.Fatalf("response agent id = %q, db agent id = %q", first.Agents[0].ID, agentID) + } + var customEnv map[string]string + if err := json.Unmarshal(customEnvRaw, &customEnv); err != nil { + t.Fatalf("decode imported custom_env: %v", err) + } + if customEnv["GITHUB_TOKEN"] != "ghp_imported" { + t.Fatalf("custom_env = %#v, want provided secret value", customEnv) + } + if _, ok := customEnv["EXTRA_SECRET"]; ok { + t.Fatalf("custom_env = %#v, want undeclared env values ignored", customEnv) + } + var customArgs []string + if err := json.Unmarshal(customArgsRaw, &customArgs); err != nil { + t.Fatalf("decode imported custom_args: %v", err) + } + if len(customArgs) != 1 || customArgs[0] != "--safe-mode" { + t.Fatalf("custom_args = %#v, want imported args", customArgs) + } + + var skillID string + if err := testPool.QueryRow(ctx, ` + SELECT id FROM skill + WHERE workspace_id = $1 AND name = $2 + `, testWorkspaceID, manifest.Skills[0].Name).Scan(&skillID); err != nil { + t.Fatalf("load imported skill: %v", err) + } + if skillID != first.Skills[0].ID { + t.Fatalf("response skill id = %q, db skill id = %q", first.Skills[0].ID, skillID) + } + var hasSkillFile bool + if err := testPool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM skill_file + WHERE skill_id = $1 AND path = 'steps/checklist.md' AND content = '- Verify changelog' + ) + `, skillID).Scan(&hasSkillFile); err != nil { + t.Fatalf("verify imported skill file: %v", err) + } + if !hasSkillFile { + t.Fatal("imported skill file was not stored") + } + var hasAgentSkill bool + if err := testPool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM agent_skill + WHERE agent_id = $1 AND skill_id = $2 + ) + `, agentID, skillID).Scan(&hasAgentSkill); err != nil { + t.Fatalf("verify imported agent skill link: %v", err) + } + if !hasAgentSkill { + t.Fatal("imported agent was not linked to imported skill") + } + + var squadID, leaderID, instructions string + if err := testPool.QueryRow(ctx, ` + SELECT id, leader_id, instructions FROM squad + WHERE workspace_id = $1 AND name = $2 + `, testWorkspaceID, manifest.Squads[0].Name).Scan(&squadID, &leaderID, &instructions); err != nil { + t.Fatalf("load imported squad: %v", err) + } + if squadID != first.Squads[0].ID { + t.Fatalf("response squad id = %q, db squad id = %q", first.Squads[0].ID, squadID) + } + if leaderID != agentID { + t.Fatalf("squad leader id = %q, want imported agent %q", leaderID, agentID) + } + if instructions != manifest.Squads[0].Instructions { + t.Fatalf("squad instructions = %q, want %q", instructions, manifest.Squads[0].Instructions) + } + var hasSquadMember bool + if err := testPool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM squad_member + WHERE squad_id = $1 AND member_type = 'agent' AND member_id = $2 AND role = 'lead' + ) + `, squadID, agentID).Scan(&hasSquadMember); err != nil { + t.Fatalf("verify imported squad member: %v", err) + } + if !hasSquadMember { + t.Fatal("imported squad member was not stored") + } + + second := applyBlueprintForTest(t, reqBody) + if second.Preview.Summary.Skills.Reuse != 1 || second.Preview.Summary.Agents.Reuse != 1 || second.Preview.Summary.Squads.Reuse != 1 { + t.Fatalf("second apply summary = %#v, want reuse=1 for skill/agent/squad", second.Preview.Summary) + } + if second.Skills[0].Action != blueprint.PreviewActionReuse || second.Skills[0].ID != skillID { + t.Fatalf("second apply skills = %#v, want reused skill %q", second.Skills, skillID) + } + if second.Agents[0].Action != blueprint.PreviewActionReuse || second.Agents[0].ID != agentID { + t.Fatalf("second apply agents = %#v, want reused agent %q", second.Agents, agentID) + } + if second.Squads[0].Action != blueprint.PreviewActionReuse || second.Squads[0].ID != squadID { + t.Fatalf("second apply squads = %#v, want reused squad %q", second.Squads, squadID) + } + if got := countBlueprintApplyRowsNamed(t, "skill", manifest.Skills[0].Name); got != 1 { + t.Fatalf("skill count after second apply = %d, want 1", got) + } + if got := countBlueprintApplyRowsNamed(t, "agent", manifest.Agents[0].Name); got != 1 { + t.Fatalf("agent count after second apply = %d, want 1", got) + } + if got := countBlueprintApplyRowsNamed(t, "squad", manifest.Squads[0].Name); got != 1 { + t.Fatalf("squad count after second apply = %d, want 1", got) + } +} + +func TestApplyBlueprint_ReturnsPreviewErrorsWithoutMutatingWorkspace(t *testing.T) { + manifest := blueprint.Manifest{ + Schema: blueprint.SchemaVersion, + Name: "Blocked Apply Package", + Skills: []blueprint.Skill{{ + Ref: "skill.blocked", + Name: "Blocked Apply Skill " + t.Name(), + Config: json.RawMessage(`{}`), + }}, + Agents: []blueprint.Agent{{ + Ref: "agent.blocked", + Name: "Blocked Apply Agent " + t.Name(), + Runtime: blueprint.Runtime{Mode: "cloud", Provider: "missing_provider"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + CustomEnvSchema: []blueprint.EnvVar{{Name: "MISSING_ENV", Required: true, Secret: true}}, + SkillRefs: []string{"skill.blocked"}, + }}, + } + beforeSkillCount := countBlueprintApplyRowsNamed(t, "skill", manifest.Skills[0].Name) + beforeAgentCount := countBlueprintApplyRowsNamed(t, "agent", manifest.Agents[0].Name) + + req := newRequest("POST", "/api/blueprints/apply?workspace_id="+testWorkspaceID, map[string]any{ + "manifest": manifest, + }) + w := httptest.NewRecorder() + + testHandler.ApplyBlueprint(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("ApplyBlueprint: expected 422, got %d: %s", w.Code, w.Body.String()) + } + var resp applyBlueprintTestResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode apply response: %v", err) + } + if !resp.Preview.HasBlockingIssues || len(resp.Preview.Errors) == 0 { + t.Fatalf("preview = %#v, want blocking issues", resp.Preview) + } + if got := countBlueprintApplyRowsNamed(t, "skill", manifest.Skills[0].Name); got != beforeSkillCount { + t.Fatalf("skill count after blocked apply = %d, want %d", got, beforeSkillCount) + } + if got := countBlueprintApplyRowsNamed(t, "agent", manifest.Agents[0].Name); got != beforeAgentCount { + t.Fatalf("agent count after blocked apply = %d, want %d", got, beforeAgentCount) + } +} + +func applyBlueprintForTest(t *testing.T, body any) applyBlueprintTestResponse { + t.Helper() + req := newRequest("POST", "/api/blueprints/apply?workspace_id="+testWorkspaceID, body) + w := httptest.NewRecorder() + + testHandler.ApplyBlueprint(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("ApplyBlueprint: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp applyBlueprintTestResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode apply response: %v", err) + } + return resp +} + +func countBlueprintApplyRowsNamed(t *testing.T, table, name string) int { + t.Helper() + var query string + switch table { + case "skill": + query = `SELECT count(*) FROM skill WHERE workspace_id = $1 AND name = $2` + case "agent": + query = `SELECT count(*) FROM agent WHERE workspace_id = $1 AND name = $2` + case "squad": + query = `SELECT count(*) FROM squad WHERE workspace_id = $1 AND name = $2` + default: + t.Fatalf("unsupported table %q", table) + } + var count int + if err := testPool.QueryRow(context.Background(), query, testWorkspaceID, name).Scan(&count); err != nil { + t.Fatalf("count %s rows named %q: %v", table, name, err) + } + return count +} diff --git a/server/internal/handler/blueprint_export_test.go b/server/internal/handler/blueprint_export_test.go new file mode 100644 index 0000000000..7377fc3143 --- /dev/null +++ b/server/internal/handler/blueprint_export_test.go @@ -0,0 +1,187 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/multica-ai/multica/server/internal/blueprint" +) + +func TestExportBlueprint_ExportsSelectedSquadGraphAndRedactsSecrets(t *testing.T) { + ctx := context.Background() + leaderID := insertBlueprintExportAgent(t, "Blueprint Release Lead", map[string]string{ + "GITHUB_TOKEN": "ghp_secret", + }, []byte(`{"servers":{"github":{"env":{"GITHUB_TOKEN":"ghp_secret"}}}}`)) + skillID := insertBlueprintExportSkill(t, "Blueprint Release Runbook", "# Release") + insertBlueprintExportSkillFile(t, skillID, "steps/checklist.md", "- Verify changelog") + linkBlueprintExportAgentSkill(t, leaderID, skillID) + squadID := insertBlueprintExportSquad(t, "Blueprint Release Squad", leaderID) + addBlueprintExportSquadMember(t, squadID, "agent", leaderID, "lead") + addBlueprintExportSquadMember(t, squadID, "member", testUserID, "stakeholder") + + req := newRequest("POST", "/api/blueprints/export?workspace_id="+testWorkspaceID, map[string]any{ + "name": "Release Package", + "squad_ids": []string{squadID}, + }) + w := httptest.NewRecorder() + + testHandler.ExportBlueprint(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("ExportBlueprint: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + body := w.Body.String() + + var manifest blueprint.Manifest + if err := json.Unmarshal([]byte(body), &manifest); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if manifest.Schema != blueprint.SchemaVersion { + t.Fatalf("schema = %q, want %q", manifest.Schema, blueprint.SchemaVersion) + } + if len(manifest.Squads) != 1 || manifest.Squads[0].LeaderRef == "" { + t.Fatalf("squads = %#v, want selected squad with leader_ref", manifest.Squads) + } + if len(manifest.Squads[0].Members) != 1 { + t.Fatalf("agent members len = %d, want 1 human members excluded", len(manifest.Squads[0].Members)) + } + if len(manifest.Agents) != 1 { + t.Fatalf("agents len = %d, want 1", len(manifest.Agents)) + } + if len(manifest.Agents[0].SkillRefs) != 1 { + t.Fatalf("agent skill_refs = %#v, want one runbook skill", manifest.Agents[0].SkillRefs) + } + if manifest.Agents[0].Runtime.Provider != "handler_test_runtime" { + t.Fatalf("runtime provider = %q, want handler_test_runtime", manifest.Agents[0].Runtime.Provider) + } + if len(manifest.Agents[0].CustomEnvSchema) != 1 || manifest.Agents[0].CustomEnvSchema[0].Name != "GITHUB_TOKEN" { + t.Fatalf("custom_env_schema = %#v, want redacted GITHUB_TOKEN schema", manifest.Agents[0].CustomEnvSchema) + } + if !manifest.Agents[0].MCPConfigRedacted { + t.Fatalf("mcp_config_redacted = false, want true") + } + if len(manifest.Skills) != 1 || len(manifest.Skills[0].Files) != 1 { + t.Fatalf("skills = %#v, want runbook with one file", manifest.Skills) + } + + for _, forbidden := range []string{leaderID, skillID, squadID, testWorkspaceID, testRuntimeID, "ghp_secret"} { + if strings.Contains(body, forbidden) { + t.Fatalf("manifest leaked local id or secret %q: %s", forbidden, body) + } + } + + var exists bool + if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM squad WHERE id = $1)`, squadID).Scan(&exists); err != nil { + t.Fatalf("verify squad still exists: %v", err) + } + if !exists { + t.Fatal("export should not mutate source squad") + } +} + +func insertBlueprintExportAgent(t *testing.T, name string, customEnv map[string]string, mcpConfig []byte) string { + t.Helper() + envRaw, err := json.Marshal(customEnv) + if err != nil { + t.Fatalf("marshal custom env: %v", err) + } + var agentID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO agent ( + workspace_id, name, description, runtime_mode, runtime_config, + runtime_id, visibility, max_concurrent_tasks, owner_id, + instructions, custom_env, custom_args, mcp_config, model, thinking_level + ) + VALUES ($1, $2, 'Blueprint export fixture', 'cloud', '{"workdir":"/private/tmp/should-not-export"}'::jsonb, + $3, 'private', 2, $4, 'Run the release process.', $5::jsonb, '["--safe-mode"]'::jsonb, $6, 'gpt-5.4', 'medium') + RETURNING id + `, testWorkspaceID, name, testRuntimeID, testUserID, envRaw, mcpConfig).Scan(&agentID); err != nil { + t.Fatalf("insert blueprint export agent: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID) + }) + return agentID +} + +func insertBlueprintExportSkill(t *testing.T, name, content string) string { + t.Helper() + name = name + "-" + t.Name() + var skillID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO skill (workspace_id, name, description, content, config, created_by) + VALUES ($1, $2, 'Blueprint export fixture', $3, '{"scope":"release"}'::jsonb, $4) + RETURNING id + `, testWorkspaceID, name, content, testUserID).Scan(&skillID); err != nil { + t.Fatalf("insert blueprint export skill: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID) + }) + return skillID +} + +func insertBlueprintExportSkillFile(t *testing.T, skillID, filePath, content string) { + t.Helper() + var fileID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO skill_file (skill_id, path, content) + VALUES ($1, $2, $3) + RETURNING id + `, skillID, filePath, content).Scan(&fileID); err != nil { + t.Fatalf("insert blueprint export skill file: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM skill_file WHERE id = $1`, fileID) + }) +} + +func linkBlueprintExportAgentSkill(t *testing.T, agentID, skillID string) { + t.Helper() + if _, err := testPool.Exec(context.Background(), ` + INSERT INTO agent_skill (agent_id, skill_id) + VALUES ($1, $2) + `, agentID, skillID); err != nil { + t.Fatalf("link blueprint export agent skill: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM agent_skill WHERE agent_id = $1 AND skill_id = $2`, agentID, skillID) + }) +} + +func insertBlueprintExportSquad(t *testing.T, name, leaderID string) string { + t.Helper() + name = name + "-" + t.Name() + var squadID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO squad (workspace_id, name, description, leader_id, creator_id, instructions) + VALUES ($1, $2, 'Blueprint export fixture', $3, $4, 'Coordinate the release squad.') + RETURNING id + `, testWorkspaceID, name, leaderID, testUserID).Scan(&squadID); err != nil { + t.Fatalf("insert blueprint export squad: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM squad WHERE id = $1`, squadID) + }) + return squadID +} + +func addBlueprintExportSquadMember(t *testing.T, squadID, memberType, memberID, role string) { + t.Helper() + var rowID string + if err := testPool.QueryRow(context.Background(), ` + INSERT INTO squad_member (squad_id, member_type, member_id, role) + VALUES ($1, $2, $3, $4) + RETURNING id + `, squadID, memberType, memberID, role).Scan(&rowID); err != nil { + t.Fatalf("insert blueprint export squad member: %v", err) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM squad_member WHERE id = $1`, rowID) + }) +} diff --git a/server/internal/handler/blueprint_preview_test.go b/server/internal/handler/blueprint_preview_test.go new file mode 100644 index 0000000000..3a652f7096 --- /dev/null +++ b/server/internal/handler/blueprint_preview_test.go @@ -0,0 +1,114 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/multica-ai/multica/server/internal/blueprint" +) + +func TestPreviewBlueprint_ReturnsImportPlanWithoutMutatingWorkspace(t *testing.T) { + ctx := context.Background() + existingSkillID := insertBlueprintExportSkill(t, "Preview Existing Skill", "# Existing") + existingAgentID := insertBlueprintExportAgent(t, "Preview Existing Agent", map[string]string{}, []byte(`{}`)) + + manifest := blueprint.Manifest{ + Schema: blueprint.SchemaVersion, + Name: "Preview Package", + Agents: []blueprint.Agent{ + { + Ref: "agent.new", + Name: "Preview New Agent", + Runtime: blueprint.Runtime{Mode: "local", Provider: "codex"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + CustomEnvSchema: []blueprint.EnvVar{{Name: "GITHUB_TOKEN", Secret: true}}, + SkillRefs: []string{"skill.existing"}, + }, + { + Ref: "agent.existing", + Name: "Preview Existing Agent", + Runtime: blueprint.Runtime{Mode: "cloud", Provider: "missing_provider"}, + Visibility: "workspace", + MaxConcurrentTasks: 1, + }, + }, + Skills: []blueprint.Skill{{ + Ref: "skill.existing", + Name: "Preview Existing Skill-" + t.Name(), + Config: json.RawMessage(`{}`), + }}, + } + + beforeAgents := countBlueprintPreviewAgentsNamed(t, "Preview New Agent") + + req := newRequest("POST", "/api/blueprints/preview?workspace_id="+testWorkspaceID, map[string]any{ + "manifest": manifest, + "runtime_mappings": []map[string]string{{ + "provider": "codex", + "runtime_id": testRuntimeID, + }}, + "provided_env": []map[string]string{{ + "agent_ref": "agent.new", + "name": "GITHUB_TOKEN", + }}, + }) + w := httptest.NewRecorder() + + testHandler.PreviewBlueprint(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("PreviewBlueprint: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var preview blueprint.Preview + if err := json.Unmarshal(w.Body.Bytes(), &preview); err != nil { + t.Fatalf("decode preview: %v", err) + } + if preview.Summary.Agents.Create != 1 || preview.Summary.Agents.Reuse != 1 { + t.Fatalf("agent summary = %#v, want create=1 reuse=1", preview.Summary.Agents) + } + if preview.Agents[0].Runtime.Status != blueprint.RuntimeRequirementMapped || preview.Agents[0].Runtime.RuntimeID != testRuntimeID { + t.Fatalf("new agent runtime = %#v, want mapped test runtime", preview.Agents[0].Runtime) + } + if len(preview.Agents[0].MissingEnv) != 0 { + t.Fatalf("new agent missing env = %#v, want none", preview.Agents[0].MissingEnv) + } + if preview.Agents[1].ExistingID != existingAgentID { + t.Fatalf("existing agent id = %q, want %q", preview.Agents[1].ExistingID, existingAgentID) + } + if preview.Agents[1].Runtime.Status != blueprint.RuntimeRequirementMissing { + t.Fatalf("existing agent runtime = %#v, want missing", preview.Agents[1].Runtime) + } + if preview.Skills[0].ExistingID != existingSkillID { + t.Fatalf("existing skill id = %q, want %q", preview.Skills[0].ExistingID, existingSkillID) + } + + afterAgents := countBlueprintPreviewAgentsNamed(t, "Preview New Agent") + if afterAgents != beforeAgents { + t.Fatalf("preview mutated agent table: before=%d after=%d", beforeAgents, afterAgents) + } + + var sourceSkillStillExists bool + if err := testPool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM skill WHERE id = $1)`, existingSkillID).Scan(&sourceSkillStillExists); err != nil { + t.Fatalf("verify source skill exists: %v", err) + } + if !sourceSkillStillExists { + t.Fatal("preview should not mutate source skill") + } +} + +func countBlueprintPreviewAgentsNamed(t *testing.T, name string) int { + t.Helper() + var count int + if err := testPool.QueryRow(context.Background(), ` + SELECT count(*) FROM agent + WHERE workspace_id = $1 AND name = $2 + `, testWorkspaceID, name).Scan(&count); err != nil { + t.Fatalf("count blueprint preview agents: %v", err) + } + return count +}