Feat v0.9.4 package adoption + roles
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / test-go-pg (pull_request) Successful in 3m6s
CI/CD / build-and-deploy (pull_request) Successful in 1m41s

Packages can declare `adoptable: true` in their manifest. When a team
adopts an adoptable package, a team-scoped copy is created that references
the original via `adopted_from` (shared assets, no disk duplication).
The package's `requires_roles` auto-populate into a new `team_role_catalog`
table so team admins know which roles to assign.

Migration 017: adoptable/adopted_from columns on packages, team_role_catalog
table. 4 new endpoints (adopt, list adoptable, unadopt, role catalog).
AdoptTeamWorkflow deprecated. 11 new tests, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 16:17:53 +00:00
parent 0661e1d768
commit 166acce2e9
17 changed files with 959 additions and 24 deletions

View File

@@ -0,0 +1,284 @@
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
}