package handlers import ( "log" "net/http" "github.com/gin-gonic/gin" "armature/store" ) // PackageAdoptHandler manages team package adoption endpoints. type PackageAdoptHandler struct { stores store.Stores } func NewPackageAdoptHandler(s store.Stores) *PackageAdoptHandler { return &PackageAdoptHandler{stores: s} } // AdoptPackage clones a global adoptable package into the team. // POST /api/v1/teams/:teamId/packages/:id/adopt func (h *PackageAdoptHandler) AdoptPackage(c *gin.Context) { ctx := c.Request.Context() teamID := c.Param("teamId") sourceID := c.Param("id") userID := c.GetString("user_id") // Load source package src, err := h.stores.Packages.Get(ctx, sourceID) if err != nil || src == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } // Verify adoptable + global if !src.Adoptable { c.JSON(http.StatusBadRequest, gin.H{"error": "package is not adoptable"}) return } if src.Scope != "global" { c.JSON(http.StatusBadRequest, gin.H{"error": "only global packages can be adopted"}) return } // Idempotency: check if already adopted existing, err := h.stores.Packages.GetByAdoptedFrom(ctx, sourceID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check adoption"}) return } if existing != nil { // Already adopted — return the existing record roles, _ := h.stores.Teams.ListRoleCatalog(ctx, teamID) c.JSON(http.StatusOK, gin.H{ "package": existing, "roles_added": filterRolesBySource(roles, existing.ID), "already_adopted": true, }) return } // Generate adopted package ID: {sourceID}--{teamID[:8]} shortTeam := teamID if len(shortTeam) > 8 { shortTeam = shortTeam[:8] } adoptedID := sourceID + "--" + shortTeam // Create team-scoped package row referencing the original adoptedPkg := &store.PackageRegistration{ ID: adoptedID, Title: src.Title, Type: src.Type, Version: src.Version, Description: src.Description, Author: src.Author, Tier: src.Tier, IsSystem: false, Scope: "team", TeamID: &teamID, InstalledBy: &userID, Manifest: src.Manifest, Enabled: true, Status: "active", SchemaVersion: src.SchemaVersion, PackageSettings: src.PackageSettings, Source: "extension", Adoptable: false, AdoptedFrom: &sourceID, } if err := h.stores.Packages.Create(ctx, adoptedPkg); err != nil { log.Printf("[packages] adopt: failed to create adopted package %s: %v", adoptedID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt package"}) return } // Auto-populate role catalog from requires_roles var rolesAdded []string if rr, ok := src.Manifest["requires_roles"].([]any); ok { for _, v := range rr { if role, ok := v.(string); ok && role != "" { if err := h.stores.Teams.AddRoleToCatalog(ctx, teamID, role, adoptedID); err != nil { log.Printf("[packages] adopt: failed to add role %q to catalog: %v", role, err) continue } rolesAdded = append(rolesAdded, role) } } } // If source is a workflow package, clone the workflow + stages if src.Type == "workflow" { h.cloneWorkflowForAdoption(c, src, adoptedPkg) } log.Printf("[packages] adopted %s → %s for team %s (roles: %v)", sourceID, adoptedID, teamID, rolesAdded) c.JSON(http.StatusCreated, gin.H{ "package": adoptedPkg, "roles_added": rolesAdded, }) } // cloneWorkflowForAdoption copies the workflow definition from the source // package into a team-scoped workflow. Best-effort — logs errors but does // not abort the adoption. func (h *PackageAdoptHandler) cloneWorkflowForAdoption(c *gin.Context, src, adopted *store.PackageRegistration) { ctx := c.Request.Context() teamID := *adopted.TeamID // Extract workflow_definition from manifest wfDef, ok := src.Manifest["workflow_definition"].(map[string]any) if !ok { log.Printf("[packages] adopt: no workflow_definition in manifest for %s", src.ID) return } // Use the InstallWorkflowFromManifest path if available, or // fall back to looking up the workflow by package ID wfSlug, _ := wfDef["slug"].(string) if wfSlug == "" { wfSlug = src.ID } // Find the global workflow installed from this package wf, err := h.stores.Workflows.GetBySlug(ctx, nil, wfSlug) if err != nil || wf == nil { log.Printf("[packages] adopt: source workflow %q not found, skipping clone", wfSlug) return } stages, err := h.stores.Workflows.ListStages(ctx, wf.ID) if err != nil { log.Printf("[packages] adopt: failed to load stages for %s: %v", wf.ID, err) return } // Clone workflow into team scope clone := *wf clone.ID = "" clone.TeamID = &teamID clone.IsActive = false clone.Version = 0 clone.CreatedBy = c.GetString("user_id") if err := h.stores.Workflows.Create(ctx, &clone); err != nil { // Slug conflict — append short ID clone.Slug = wfSlug + "-" + store.NewID()[:6] if err2 := h.stores.Workflows.Create(ctx, &clone); err2 != nil { log.Printf("[packages] adopt: failed to clone workflow: %v", err2) return } } // Clone stages for _, st := range stages { cloneSt := st cloneSt.ID = "" cloneSt.WorkflowID = clone.ID if err := h.stores.Workflows.CreateStage(ctx, &cloneSt); err != nil { log.Printf("[packages] adopt: failed to clone stage %s: %v", st.Name, err) } } log.Printf("[packages] adopt: cloned workflow %s → %s for team %s", wf.ID, clone.ID, teamID) } // ListAdoptablePackages returns global adoptable packages, annotating which // ones have already been adopted by this team. // GET /api/v1/teams/:teamId/packages/adoptable func (h *PackageAdoptHandler) ListAdoptablePackages(c *gin.Context) { ctx := c.Request.Context() teamID := c.Param("teamId") pkgs, err := h.stores.Packages.ListAdoptable(ctx) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"}) return } type adoptableItem struct { store.PackageRegistration Adopted bool `json:"adopted"` } var result []adoptableItem for _, pkg := range pkgs { item := adoptableItem{PackageRegistration: pkg} existing, _ := h.stores.Packages.GetByAdoptedFrom(ctx, pkg.ID, teamID) item.Adopted = existing != nil result = append(result, item) } if result == nil { result = []adoptableItem{} } c.JSON(http.StatusOK, gin.H{"data": result}) } // UnadoptPackage removes a team's adopted package and cleans up role catalog. // DELETE /api/v1/teams/:teamId/packages/:id/unadopt func (h *PackageAdoptHandler) UnadoptPackage(c *gin.Context) { ctx := c.Request.Context() teamID := c.Param("teamId") pkgID := c.Param("id") pkg, err := h.stores.Packages.Get(ctx, pkgID) if err != nil || pkg == nil { c.JSON(http.StatusNotFound, gin.H{"error": "package not found"}) return } // Verify it's an adopted package belonging to this team if pkg.AdoptedFrom == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "package was not adopted"}) return } if pkg.TeamID == nil || *pkg.TeamID != teamID { c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"}) return } // Clean up role catalog entries sourced from this package if err := h.stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgID); err != nil { log.Printf("[packages] unadopt: failed to clean role catalog: %v", err) } // Delete the adopted package row if err := h.stores.Packages.Delete(ctx, pkgID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove package"}) return } log.Printf("[packages] unadopted %s from team %s", pkgID, teamID) c.JSON(http.StatusOK, gin.H{"message": "package unadopted"}) } // ListRoleCatalog returns all known roles for a team. // GET /api/v1/teams/:teamId/roles/catalog func (h *PackageAdoptHandler) ListRoleCatalog(c *gin.Context) { ctx := c.Request.Context() teamID := c.Param("teamId") roles, err := h.stores.Teams.ListRoleCatalog(ctx, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list role catalog"}) return } if roles == nil { roles = []store.TeamRoleCatalogEntry{} } c.JSON(http.StatusOK, gin.H{"data": roles}) } // filterRolesBySource returns only roles from a specific package. func filterRolesBySource(roles []store.TeamRoleCatalogEntry, pkgID string) []string { var result []string for _, r := range roles { if r.SourcePackageID == pkgID { result = append(result, r.Role) } } return result }