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

@@ -109,18 +109,22 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
if pkg.Status == "" {
pkg.Status = "active"
}
adoptableInt := 0
if pkg.Adoptable {
adoptableInt = 1
}
_, err := DB.ExecContext(ctx, `
INSERT INTO packages (id, title, type, version, description, author, tier,
is_system, scope, team_id, installed_by, manifest, enabled, status,
schema_version, package_settings, source,
schema_version, package_settings, source, adoptable, adopted_from,
installed_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
pkg.Tier, pkg.IsSystem, pkg.Scope,
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
manifestJSON, pkg.Enabled, pkg.Status,
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
pkg.Source,
pkg.Source, adoptableInt, nullStrPtr(pkg.AdoptedFrom),
now.Format(timeFmt), now.Format(timeFmt),
)
if err != nil {
@@ -184,11 +188,12 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
var result []store.UserPackage
for rows.Next() {
var up store.UserPackage
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt int
var userEnabled sql.NullBool
var userSettings []byte
@@ -198,15 +203,18 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
&teamID, &installedBy,
&manifestJSON, &enabledInt, &up.Status,
&up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&up.Source, &adoptableInt, &adoptedFrom,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.IsSystem = isSystemInt != 0
up.Enabled = enabledInt != 0
up.Adoptable = adoptableInt != 0
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid {
@@ -263,22 +271,25 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
p.manifest, p.enabled, p.status,
p.schema_version, p.package_settings,
p.source, p.installed_at, p.updated_at`
p.source, p.adoptable, p.adopted_from,
p.installed_at, p.updated_at`
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt int
err := DB.QueryRowContext(ctx, query, args...).Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &adoptableInt, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -288,8 +299,10 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.Adoptable = adoptableInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil
@@ -305,25 +318,29 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
var result []store.PackageRegistration
for rows.Next() {
var pkg store.PackageRegistration
var teamID, installedBy sql.NullString
var teamID, installedBy, adoptedFrom sql.NullString
var manifestJSON string
var pkgSettings string
var enabledInt int
var isSystemInt int
var adoptableInt int
if err := rows.Scan(
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
&teamID, &installedBy,
&manifestJSON, &enabledInt, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &adoptableInt, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.IsSystem = isSystemInt != 0
pkg.Enabled = enabledInt != 0
pkg.Adoptable = adoptableInt != 0
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg)
@@ -418,6 +435,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
return err
}
// ── v0.9.4 — Package Adoption ───────────────
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
return s.scanMany(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adoptable = 1 AND p.scope = 'global' AND p.enabled = 1
ORDER BY p.title`)
}
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
return s.scanOne(ctx, `
SELECT `+pkgCols+`
FROM packages p
WHERE p.adopted_from = ? AND p.team_id = ?`, sourceID, teamID)
}
func nullStrPtr(s *string) sql.NullString {
if s == nil {
return sql.NullString{}

View File

@@ -8,6 +8,8 @@ import (
"armature/models"
"armature/store"
"github.com/google/uuid"
)
type TeamStore struct{}
@@ -399,3 +401,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin
teamID, userID)
return err
}
// ── v0.9.4 — Team Role Catalog ──
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (team_id, role) DO NOTHING`,
uuid.New().String(), teamID, role, sourcePkgID)
return err
}
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
rows, err := DB.QueryContext(ctx, `
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
WHERE team_id = ? ORDER BY role`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []store.TeamRoleCatalogEntry
for rows.Next() {
var e store.TeamRoleCatalogEntry
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
return nil, err
}
result = append(result, e)
}
return result, rows.Err()
}
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
_, err := DB.ExecContext(ctx, `
DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`,
teamID, sourcePkgID)
return err
}