Feat v0.9.4 package adoption roles #78

Merged
xcaliber merged 2 commits from feat/v0.9.4-package-adoption-roles into main 2026-04-03 16:23:43 +00:00
19 changed files with 2268 additions and 48 deletions

View File

@@ -2,6 +2,58 @@
All notable changes to Armature are documented here.
## v0.9.4 — Package Adoption + Roles
Packages can now declare `adoptable: true` in their manifest. When a team
adopts an adoptable package, a team-scoped copy is created that references
the original (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.
**Schema**
- Migration 017: `adoptable` and `adopted_from` columns on `packages`.
`team_role_catalog` table (team_id, role, source_package_id) with
unique constraint. Both Postgres and SQLite dialects.
**Store + Models**
- `PackageRegistration`: `Adoptable bool`, `AdoptedFrom *string`.
- `PackageStore`: `ListAdoptable()`, `GetByAdoptedFrom()`.
- `TeamRoleCatalogEntry` model struct.
- `TeamStore`: `AddRoleToCatalog`, `ListRoleCatalog`,
`RemoveRoleCatalogBySource`.
**Handlers**
- `POST /teams/:teamId/packages/:id/adopt` — adopt a global adoptable
package into the team. Creates team-scoped registration, clones
workflow if applicable, populates role catalog. Idempotent.
- `GET /teams/:teamId/packages/adoptable` — list available packages
with adoption status per team.
- `DELETE /teams/:teamId/packages/:id/unadopt` — remove adopted package
and clean up role catalog entries.
- `GET /teams/:teamId/roles/catalog` — list known roles for a team.
**Manifest Validation**
- `adoptable: true` parsed as `ManifestInfo.Adoptable`.
- Rejected on `library` and `test-runner` types.
- Persisted to DB on package install.
**Deprecation**
- `POST /teams/:teamId/workflows/:id/adopt` (`AdoptTeamWorkflow`) now
returns `X-Deprecated` header and logs a deprecation warning.
Use the package-level adoption endpoint instead.
**Tests**
- 11 new tests: 5 manifest validation, 3 role catalog store, 3 handler
integration (adopt success, idempotent, non-adoptable rejection).
---
## v0.9.3 — Team User Roles
Promotes the team role system from a single-role-per-member model to a

View File

@@ -105,11 +105,14 @@ Manifest `requires_roles` field (advisory). Starlark `teams` module
with `get_member_roles()` and `has_role()`. Team-admin UI with role
badge chips and assignment dropdown. 10 new tests.
**v0.9.4 — Package Adoption + Roles**
**v0.9.4 — Package Adoption + Roles** *(completed)*
`scope: adoptable` manifest field. When a team adopts an adoptable
package, the package's `requires_roles` auto-populate into the team's
role slots. Replaces `AdoptTeamWorkflow` clone mechanism.
`adoptable` manifest field + `team_role_catalog` table. When a team
adopts an adoptable package, the package's `requires_roles` auto-populate
into the team's role catalog. Adopted packages reference the original via
`adopted_from` column (shared assets, no disk duplication).
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
4 new endpoints, migration 017, 11 new tests.
**v0.9.5 — Typed Forms → SDK Primitive**
@@ -182,31 +185,59 @@ a clean visual foundation. Design doc: `docs/DESIGN-notes-v011x.md`.
---
### v0.12.x — Reference Extensions (Remaining)
### v0.12.x — Chat Reference Extension
The kernel is complete. This series proves the platform by shipping
first-party extensions that exercise every primitive. These are
**extensions, not kernel code** — they ship as `.pkg` files and can be
uninstalled.
Chat becomes the second reference extension — human-to-human messaging
built entirely on Armature's extension architecture. Where notes proved
surfaces, panels, and storage, chat proves **realtime**, **cross-package
composition**, and **extensible data models**. Chat is human-to-human
first; AI participants arrive via `llm-bridge` (v0.13.x) extending chat
through folder attributes and slot contributions.
Design doc: `docs/DESIGN-chat-v012x.md`.
| Version | Title |
|---------|-------|
| v0.12.0 | `vector-store` Library |
| v0.12.1 | `llm-bridge` Library |
| v0.12.2 | `file-share` Extension |
| v0.12.3 | `code-workspace` Extension |
| v0.12.4 | `image-gen` + `image-edit` Extensions |
| v0.12.5 | Chat System |
| v0.12.0 | UI/UX Foundation |
| v0.12.1 | Conversation Folders + Attributes |
| v0.12.2 | Reactions + Threads + Pins |
| v0.12.3 | Rich Compose + Attachments |
| v0.12.4 | @Mentions + Notifications |
| v0.12.5 | Link Previews + Message Formatting |
| v0.12.6 | Conversation Themes + Personality |
| v0.12.7 | Composability: Slots + Actions |
| v0.12.8 | Panels + Quality Gate |
---
### v0.13.x — Sidecar Tier + Polish
### v0.13.x — Reference Libraries + Extensions
Core libraries and remaining extensions. `llm-bridge` is the key
delivery — extends both notes and chat through composability primitives,
and introduces the **tool meta-tool** pattern: `sw.actions.list()` becomes
the LLM tool registry, so installing an extension = granting AI a new
capability. Zero configuration.
| Version | Title |
|---------|-------|
| v0.13.0 | Sidecar Tier |
| v0.13.1 | Native Dialog Audit |
| v0.13.2 | Stability + Migration Tooling |
| v0.13.0 | `vector-store` Library |
| v0.13.1 | `llm-bridge` Core Library |
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
| v0.13.4 | `file-share` Extension |
| v0.13.5 | `code-workspace` Extension |
| v0.13.6 | `image-gen` + `image-edit` Extensions |
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
| v0.13.8 | Integration Quality Gate |
---
### v0.14.x — Sidecar Tier + Polish
| Version | Title |
|---------|-------|
| v0.14.0 | Sidecar Tier |
| v0.14.1 | Native Dialog Audit |
| v0.14.2 | Stability + Migration Tooling |
---
@@ -219,15 +250,20 @@ Gate criteria:
- All kernel Starlark modules documented with examples
- All `api_routes` covered by OpenAPI spec
- Upgrade path tested from v0.8.0 → v1.0.0
- At least 3 reference extensions shipped (vector-store, llm-bridge, chat)
- At least 1 cross-extension composability demo (image-gen → chat:image-actions)
- Notes reference extension shipped with all v0.11.x features
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
- Chat reference extension shipped with all v0.12.x features
- `llm-bridge` extends both notes and chat through composability
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
single conversation turn
- Multi-persona context archetypes demonstrated
- Admin safety rails validated
- At least 2 panels consumed cross-package
- At least 2 slot contributions per host surface demonstrated
- Note and conversation sharing functional end-to-end
- Headless E2E green on PG + SQLite
- `armature-ca.sh` + mTLS deployment guide
- Single-binary + Docker + K8s deployment paths documented
- Notes reference extension shipped with all v0.11.x features
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
- At least 1 panel shipped and consumed cross-package (notes.reference → chat)
- Panel lifecycle test coverage in SDK test runner
---
@@ -278,9 +314,16 @@ These are candidates, not commitments. Each requires a design doc.
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
| Sidecar deferred to v0.13.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
| Sidecar deferred to v0.14.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
| UI/UX redesign as v0.11.0 | Every feature version builds on the visual foundation. Front-loading avoids double work. |
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. Front-loading avoids double work. |
| Notes as dedicated v0.11.x series (11 versions) | Complex enough to need changeset discipline. Each version independently shippable. |
| Notes before remaining reference extensions | Notes proves the full primitive surface. Other extensions build on proven patterns. |
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
| Chat human-to-human first (v0.12.x) | AI is an extension concern. Keeps chat testable and usable standalone. |
| Chat as dedicated v0.12.x series (9 versions) | Second reference extension. Proves realtime, extensible data models, cross-package composition. |
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
| llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. |

1074
docs/DESIGN-chat-v012x.md Normal file

File diff suppressed because it is too large Load Diff

171
docs/ROADMAP-v010x-shift.md Normal file
View File

@@ -0,0 +1,171 @@
# ROADMAP — v0.10.x+ Version Shift (Final)
## Summary
Four new series inserted after v0.9.x. Panels (kernel primitive), Notes
(first reference extension), Chat (second reference extension), and the
remaining reference libraries/extensions including `llm-bridge` with
the tool meta-tool pattern.
## Full Roadmap
| Series | Title | Versions | Design Doc |
|--------|-------|----------|------------|
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
| v0.14.x | Sidecar Tier + Polish | 3 | — |
| v1.0.0 | Stable Release | — | — |
---
## v0.10.x — Panels + Composable Layout
| Version | Title |
|---------|-------|
| v0.10.0 | Panel Manifest + Lifecycle |
| v0.10.1 | FloatingPanel Primitive |
| v0.10.2 | Docked Panels + Mode Transitions |
| v0.10.3 | Panel Communication Patterns |
| v0.10.4 | Reference Panel: Notes (basic) |
---
## v0.11.x — Notes Reference Extension
| Version | Title |
|---------|-------|
| v0.11.0 | UI/UX Foundation |
| v0.11.1 | Deep Folders + Navigation |
| v0.11.2 | Wikilinks + Backlinks |
| v0.11.3 | Live Preview + Rich Editing |
| v0.11.4 | Note Sharing + Permissions |
| v0.11.5 | Graph + Outline Hardening |
| v0.11.6 | Quick Switcher + Commands |
| v0.11.7 | Daily Notes + Templates |
| v0.11.8 | Transclusion + Embeds |
| v0.11.9 | Composability: Slots + Actions |
| v0.11.10 | Panel Enhancement + Quality Gate |
---
## v0.12.x — Chat Reference Extension
Human-to-human first. AI via `llm-bridge` (v0.13.x).
| Version | Title |
|---------|-------|
| v0.12.0 | UI/UX Foundation |
| v0.12.1 | Conversation Folders + Attributes |
| v0.12.2 | Reactions + Threads + Pins |
| v0.12.3 | Rich Compose + Attachments |
| v0.12.4 | @Mentions + Notifications |
| v0.12.5 | Link Previews + Message Formatting |
| v0.12.6 | Conversation Themes + Personality |
| v0.12.7 | Composability: Slots + Actions |
| v0.12.8 | Panels + Quality Gate |
---
## v0.13.x — Reference Libraries + Extensions
Core libraries and remaining extensions. `llm-bridge` is the key
delivery — extends both notes and chat through composability primitives,
and introduces the tool meta-tool pattern.
| Version | Title |
|---------|-------|
| v0.13.0 | `vector-store` Library |
| v0.13.1 | `llm-bridge` Core Library |
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
| v0.13.4 | `file-share` Extension |
| v0.13.5 | `code-workspace` Extension |
| v0.13.6 | `image-gen` + `image-edit` Extensions |
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
| v0.13.8 | Integration Quality Gate |
**v0.13.1 — `llm-bridge` Core:**
Model abstraction, provider BYOK via connections, `complete()`,
`embed()`, `classify()`. Persona CRUD (name, avatar, system_prompt,
visibility). Layered prompt architecture (6 layers: admin safety →
folder context → persona identity → conversation → tools → user).
Admin safety rails as non-overridable platform setting.
**v0.13.2 — Chat Integration:**
Contributes folder attributes (system_prompt, context_policy,
model_override, allowed_tools) to chat. Contributes "Invite AI" /
"Dismiss AI" to `chat:participant-actions`. Contributes "AI Reply" /
"AI Summarize" to `chat:message-actions`. Contributes "Ask AI" to
`chat:composer-tools`. Context archetype implementation (resident /
scoped / stateless). Session tracking, gap handling, memory extraction
on dismiss. **Tool meta-tool v1:** `sw.actions.list()` → LLM tool
definitions. AI calls extension actions, results flow back into chat.
**v0.13.3 — Notes Integration:**
Contributes "AI Summarize" / "AI Translate" / "AI Fix Grammar" to
`notes:toolbar-actions`. Contributes `/ai` to `notes:slash-commands`.
Uses `notes.get` / `notes.search` actions for context.
**v0.13.7 — Tool Meta-Tool Hardening:**
Three-tier tool scoping: admin blocklist (global), folder allowed_tools
(space-level), persona allow/deny (character-level). Tool result
rendering through `sw.renderers`. Rate limiting on tool calls. Audit
logging of tool use. Error handling (tool failure → graceful message).
**v0.13.8 — Integration Quality Gate:**
AI participant lifecycle tested end-to-end. Tool meta-tool demonstrated
with 3+ extensions. All three context archetypes tested. Persona
creation/sharing across visibility levels. Admin safety rails validated
(prompt injection resistance). Performance: completion latency measured.
---
## v0.14.x — Sidecar Tier + Polish
| Version | Title |
|---------|-------|
| v0.14.0 | Sidecar Tier |
| v0.14.1 | Native Dialog Audit |
| v0.14.2 | Stability + Migration Tooling |
---
## v1.0.0 Gate Criteria
- Notes reference extension shipped (all v0.11.x)
- Chat reference extension shipped (all v0.12.x)
- `llm-bridge` extends both notes and chat through composability
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
single conversation turn
- Notes and chat UI reviewed against design principles
- At least 2 panels consumed cross-package
- At least 2 slot contributions per host surface demonstrated
- Multi-persona context archetypes demonstrated
- Admin safety rails validated
- Note and conversation sharing functional end-to-end
- Headless E2E green on PG + SQLite
- All kernel Starlark modules documented
- All API routes covered by OpenAPI spec
- Upgrade path tested from v0.8.0 → v1.0.0
- Single-binary + Docker + K8s deployment paths documented
---
## Design Decisions Log
| Decision | Rationale |
|----------|-----------|
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. |
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
| Chat human-to-human first | AI is an extension concern. Keeps chat testable and usable standalone. |
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
| Background images via CSS filter | No server-side processing. `filter: blur() brightness() saturate()` + `opacity` handles any image. |
| Soft panel deps for notes↔chat | Runtime-resolved, no circular dependency. Both function independently. |
| llm-bridge after notes + chat | Proves the composability hooks work without being designed for a specific consumer. |

View File

@@ -0,0 +1,21 @@
-- ==========================================
-- Armature — 017 Package Adoption + Role Catalog
-- ==========================================
-- Add adoptable flag and adoption lineage to packages
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adoptable BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adopted_from TEXT;
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable) WHERE adoptable = true;
-- Team role catalog: known roles sourced from adopted packages
CREATE TABLE IF NOT EXISTS team_role_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, role)
);
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);

View File

@@ -0,0 +1,21 @@
-- ==========================================
-- Armature — 017 Package Adoption + Role Catalog (SQLite)
-- ==========================================
-- Add adoptable flag and adoption lineage to packages
ALTER TABLE packages ADD COLUMN adoptable INTEGER NOT NULL DEFAULT 0;
ALTER TABLE packages ADD COLUMN adopted_from TEXT;
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable);
-- Team role catalog: known roles sourced from adopted packages
CREATE TABLE IF NOT EXISTS team_role_catalog (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role TEXT NOT NULL,
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(team_id, role)
);
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);

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
}

View File

@@ -0,0 +1,353 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"armature/database"
"armature/store"
)
// ── Manifest: adoptable parsing ──────────────
func TestValidateManifest_Adoptable(t *testing.T) {
m := map[string]any{
"id": "adopt-wf",
"title": "Adoptable Workflow",
"type": "workflow",
"adoptable": true,
"workflow_definition": map[string]any{
"slug": "adopt-wf",
},
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.Adoptable {
t.Error("expected Adoptable to be true")
}
}
func TestValidateManifest_Adoptable_Default(t *testing.T) {
m := map[string]any{
"id": "plain-surface",
"title": "Plain Surface",
"type": "surface",
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.Adoptable {
t.Error("expected Adoptable to be false by default")
}
}
func TestValidateManifest_Adoptable_LibraryRejected(t *testing.T) {
m := map[string]any{
"id": "adopt-lib",
"title": "Adoptable Library",
"type": "library",
"adoptable": true,
"exports": []any{"module_a"},
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for adoptable library")
}
}
func TestValidateManifest_Adoptable_TestRunnerRejected(t *testing.T) {
m := map[string]any{
"id": "adopt-runner",
"title": "Adoptable Runner",
"type": "test-runner",
"adoptable": true,
}
_, err := ValidateManifest(m)
if err == nil {
t.Fatal("expected error for adoptable test-runner")
}
}
func TestValidateManifest_Adoptable_SurfaceAllowed(t *testing.T) {
m := map[string]any{
"id": "adopt-surface",
"title": "Adoptable Surface",
"type": "surface",
"adoptable": true,
}
info, err := ValidateManifest(m)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.Adoptable {
t.Error("expected Adoptable to be true for surface")
}
}
// ── Store: role catalog CRUD ──────────────
// seedMinimalPackage creates a minimal package for FK references in tests.
func seedMinimalPackage(t *testing.T, stores store.Stores, id string) {
t.Helper()
pkg := &store.PackageRegistration{
ID: id, Title: id, Type: "surface", Version: "1.0.0",
Scope: "global", Tier: "browser", Manifest: map[string]any{"id": id, "title": id},
Enabled: true, Status: "active", Source: "extension",
}
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
t.Fatalf("seed minimal package %s: %v", id, err)
}
}
func TestRoleCatalog_AddAndList(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
teamID, _, _ := seedTeamAndMember(t, stores)
pkgID := "rc-pkg-" + store.NewID()[:6]
seedMinimalPackage(t, stores, pkgID)
// Add two roles
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgID); err != nil {
t.Fatalf("AddRoleToCatalog: %v", err)
}
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgID); err != nil {
t.Fatalf("AddRoleToCatalog: %v", err)
}
roles, err := stores.Teams.ListRoleCatalog(ctx, teamID)
if err != nil {
t.Fatalf("ListRoleCatalog: %v", err)
}
if len(roles) != 2 {
t.Fatalf("expected 2 roles, got %d: %v", len(roles), roles)
}
}
func TestRoleCatalog_AddIdempotent(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
teamID, _, _ := seedTeamAndMember(t, stores)
pkgA := "rc-idem-a-" + store.NewID()[:6]
pkgB := "rc-idem-b-" + store.NewID()[:6]
seedMinimalPackage(t, stores, pkgA)
seedMinimalPackage(t, stores, pkgB)
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgA); err != nil {
t.Fatalf("first add: %v", err)
}
// Same role from different source — should be idempotent (unique on team_id, role)
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgB); err != nil {
t.Fatalf("idempotent add should not error: %v", err)
}
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
if len(roles) != 1 {
t.Errorf("expected 1 role after idempotent add, got %d", len(roles))
}
}
func TestRoleCatalog_RemoveBySource(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
teamID, _, _ := seedTeamAndMember(t, stores)
pkgRm := "rc-rm-" + store.NewID()[:6]
pkgKeep := "rc-keep-" + store.NewID()[:6]
seedMinimalPackage(t, stores, pkgRm)
seedMinimalPackage(t, stores, pkgKeep)
stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgRm)
stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgRm)
stores.Teams.AddRoleToCatalog(ctx, teamID, "admin-role", pkgKeep)
if err := stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgRm); err != nil {
t.Fatalf("RemoveRoleCatalogBySource: %v", err)
}
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
if len(roles) != 1 {
t.Fatalf("expected 1 role remaining, got %d: %v", len(roles), roles)
}
if roles[0].Role != "admin-role" {
t.Errorf("expected 'admin-role' remaining, got %q", roles[0].Role)
}
}
// ── Store: package adoption ──────────────
func seedAdoptablePackage(t *testing.T, stores store.Stores, id string, roles []string) {
t.Helper()
manifest := map[string]any{
"id": id,
"title": "Adoptable " + id,
"type": "surface",
}
if len(roles) > 0 {
rr := make([]any, len(roles))
for i, r := range roles {
rr[i] = r
}
manifest["requires_roles"] = rr
}
pkg := &store.PackageRegistration{
ID: id,
Title: "Adoptable " + id,
Type: "surface",
Version: "1.0.0",
Tier: "browser",
Scope: "global",
Manifest: manifest,
Enabled: true,
Status: "active",
Source: "extension",
Adoptable: true,
}
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
t.Fatalf("seed adoptable package: %v", err)
}
}
func TestAdoptPackage_Success(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
ctx := context.Background()
teamID, userID, _ := seedTeamAndMember(t, stores)
pkgID := "adopt-test-" + store.NewID()[:6]
seedAdoptablePackage(t, stores, pkgID, []string{"approver", "reviewer"})
// Set up handler
h := NewPackageAdoptHandler(stores)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, r := gin.CreateTestContext(w)
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
c.Set("user_id", userID)
h.AdoptPackage(c)
})
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
c.Request = req
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
// Verify roles_added
rolesAdded, ok := resp["roles_added"].([]any)
if !ok || len(rolesAdded) != 2 {
t.Errorf("expected 2 roles_added, got %v", resp["roles_added"])
}
// Verify team package was created
shortTeam := teamID
if len(shortTeam) > 8 {
shortTeam = shortTeam[:8]
}
adopted, _ := stores.Packages.Get(ctx, pkgID+"--"+shortTeam)
if adopted == nil {
t.Fatal("adopted package not found in DB")
}
if adopted.Scope != "team" {
t.Errorf("expected scope 'team', got %q", adopted.Scope)
}
if adopted.AdoptedFrom == nil || *adopted.AdoptedFrom != pkgID {
t.Errorf("expected adopted_from=%q, got %v", pkgID, adopted.AdoptedFrom)
}
// Verify role catalog populated
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
found := 0
for _, r := range roles {
if r.SourcePackageID == pkgID+"--"+shortTeam {
found++
}
}
if found != 2 {
t.Errorf("expected 2 catalog roles from adoption, got %d", found)
}
}
func TestAdoptPackage_Idempotent(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
teamID, userID, _ := seedTeamAndMember(t, stores)
pkgID := "adopt-idem-" + store.NewID()[:6]
seedAdoptablePackage(t, stores, pkgID, nil)
h := NewPackageAdoptHandler(stores)
gin.SetMode(gin.TestMode)
doAdopt := func() int {
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
c.Set("user_id", userID)
h.AdoptPackage(c)
})
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
r.ServeHTTP(w, req)
return w.Code
}
code1 := doAdopt()
if code1 != http.StatusCreated {
t.Fatalf("first adopt: expected 201, got %d", code1)
}
code2 := doAdopt()
if code2 != http.StatusOK {
t.Fatalf("second adopt: expected 200, got %d", code2)
}
}
func TestAdoptPackage_NotAdoptable(t *testing.T) {
database.RequireTestDB(t)
stores := testStores(t)
teamID, userID, _ := seedTeamAndMember(t, stores)
// Seed a non-adoptable package
pkg := &store.PackageRegistration{
ID: "no-adopt-" + store.NewID()[:6], Title: "Not Adoptable",
Type: "surface", Version: "1.0.0", Tier: "browser", Scope: "global",
Manifest: map[string]any{"id": "no-adopt", "title": "Not Adoptable"},
Enabled: true, Status: "active", Source: "extension",
Adoptable: false,
}
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
t.Fatalf("seed non-adoptable: %v", err)
}
h := NewPackageAdoptHandler(stores)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
_, r := gin.CreateTestContext(w)
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
c.Set("user_id", userID)
h.AdoptPackage(c)
})
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkg.ID+"/adopt", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for non-adoptable, got %d", w.Code)
}
}

View File

@@ -34,6 +34,7 @@ type ManifestInfo struct {
UserPermissions []string // user-facing permissions declared by the extension
GatePermission string // if set, checks this user permission before calling on_request
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
}
// ValidateManifest parses a manifest map and validates all required fields,
@@ -178,6 +179,11 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
}
}
// v0.9.4: adoptable — teams can adopt this package to get a team-scoped copy
if v, ok := manifest["adoptable"].(bool); ok {
info.Adoptable = v
}
info.SchemaVersion = ParseSchemaVersion(manifest)
// ── Type-specific constraints ────────────────────────────────
@@ -216,10 +222,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
if info.HasRoute {
return nil, fmt.Errorf("library packages cannot have a route")
}
if info.Adoptable {
return nil, fmt.Errorf("library packages cannot be adoptable")
}
case "test-runner":
// Test runners are surface-like packages discovered by type.
// They are not shown in navigation (extensionNavItems filters for surface/full).
// They may have a route but it's optional — the registry surface provides access.
if info.Adoptable {
return nil, fmt.Errorf("test-runner packages cannot be adoptable")
}
}
return info, nil

View File

@@ -591,6 +591,18 @@ func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *Ma
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
}
// v0.9.4: persist adoptable flag (Update doesn't cover it — separate column)
if mInfo.Adoptable {
q := `UPDATE packages SET adoptable = true WHERE id = $1`
if !database.IsPostgres() {
q = `UPDATE packages SET adoptable = 1 WHERE id = ?`
}
if _, err := database.DB.ExecContext(c.Request.Context(), q, pkgID); err != nil {
log.Printf("[packages] Failed to set adoptable for %s: %v", pkgID, err)
}
}
return preservedEnabled, nil
}

View File

@@ -45,7 +45,13 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
// The global original is left untouched so other teams can also adopt it.
// POST /api/v1/teams/:teamId/workflows/:id/adopt
//
// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead.
// This endpoint will be removed in a future version.
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead")
log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId"))
ctx := c.Request.Context()
teamID := c.Param("teamId")
srcID := c.Param("id")

View File

@@ -660,6 +660,13 @@ func main() {
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
// Team package adoption
adoptH := handlers.NewPackageAdoptHandler(stores)
teamScoped.POST("/packages/:id/adopt", adoptH.AdoptPackage)
teamScoped.GET("/packages/adoptable", adoptH.ListAdoptablePackages)
teamScoped.DELETE("/packages/:id/unadopt", adoptH.UnadoptPackage)
teamScoped.GET("/roles/catalog", adoptH.ListRoleCatalog)
// Team package settings — cascade overrides
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)

View File

@@ -75,6 +75,8 @@ func (m *mockPackageStore) SetPackageSettings(context.Context, string, json.RawM
func (m *mockPackageStore) GetTeamSettings(context.Context, string, string) (json.RawMessage, error) { return nil, nil }
func (m *mockPackageStore) SetTeamSettings(context.Context, string, string, json.RawMessage) error { return nil }
func (m *mockPackageStore) DeleteTeamSettings(context.Context, string, string) error { return nil }
func (m *mockPackageStore) ListAdoptable(context.Context) ([]store.PackageRegistration, error) { return nil, nil }
func (m *mockPackageStore) GetByAdoptedFrom(context.Context, string, string) (*store.PackageRegistration, error) { return nil, nil }
// ── test helpers ─────────────────────────────────────────────

View File

@@ -208,6 +208,23 @@ type TeamStore interface {
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error
// ── v0.9.4 — Team Role Catalog ──
// AddRoleToCatalog registers a known role for the team. Idempotent.
AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error
// ListRoleCatalog returns all known roles for a team.
ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error)
// RemoveRoleCatalogBySource removes all catalog entries sourced from a package.
RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error
}
// TeamRoleCatalogEntry represents a known role sourced from an adopted package.
type TeamRoleCatalogEntry struct {
Role string `json:"role"`
SourcePackageID string `json:"source_package_id,omitempty"`
}
// =========================================

View File

@@ -93,6 +93,14 @@ type PackageStore interface {
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
// ── v0.9.4 — Package Adoption ───────────────
// ListAdoptable returns global, adoptable, enabled packages.
ListAdoptable(ctx context.Context) ([]PackageRegistration, error)
// GetByAdoptedFrom returns a team's adopted copy of a source package, or nil.
GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*PackageRegistration, error)
}
// PackageRegistration is a row from the packages table.
@@ -114,6 +122,8 @@ type PackageRegistration struct {
SchemaVersion int `json:"schema_version" db:"schema_version"`
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
Source string `json:"source" db:"source"`
Adoptable bool `json:"adoptable" db:"adoptable"`
AdoptedFrom *string `json:"adopted_from,omitempty" db:"adopted_from"`
InstalledAt string `json:"installed_at" db:"installed_at"`
UpdatedAt string `json:"updated_at" db:"updated_at"`
}

View File

@@ -107,15 +107,15 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
return DB.QueryRowContext(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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
schema_version, package_settings, source, adoptable, adopted_from)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
RETURNING installed_at, updated_at`,
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, pkg.Adoptable, nullStrPtr(pkg.AdoptedFrom),
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
}
@@ -173,7 +173,7 @@ 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 []byte
var pkgSettings []byte
var userEnabled sql.NullBool
@@ -185,13 +185,15 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
&teamID, &installedBy,
&manifestJSON, &up.Enabled, &up.Status,
&up.SchemaVersion, &pkgSettings,
&up.Source, &up.InstalledAt, &up.UpdatedAt,
&up.Source, &up.Adoptable, &adoptedFrom,
&up.InstalledAt, &up.UpdatedAt,
&userEnabled, &userSettings,
); err != nil {
return nil, err
}
up.TeamID = NullableStringPtr(teamID)
up.InstalledBy = NullableStringPtr(installedBy)
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &up.Manifest)
up.PackageSettings = json.RawMessage(pkgSettings)
if userEnabled.Valid {
@@ -250,11 +252,12 @@ 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 []byte
var pkgSettings []byte
err := DB.QueryRowContext(ctx, query, args...).Scan(
@@ -263,7 +266,8 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -273,6 +277,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
return &pkg, nil
@@ -288,7 +293,7 @@ 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 []byte
var pkgSettings []byte
if err := rows.Scan(
@@ -297,12 +302,14 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
&teamID, &installedBy,
&manifestJSON, &pkg.Enabled, &pkg.Status,
&pkg.SchemaVersion, &pkgSettings,
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
&pkg.InstalledAt, &pkg.UpdatedAt,
); err != nil {
return nil, err
}
pkg.TeamID = NullableStringPtr(teamID)
pkg.InstalledBy = NullableStringPtr(installedBy)
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
json.Unmarshal(manifestJSON, &pkg.Manifest)
pkg.PackageSettings = json.RawMessage(pkgSettings)
result = append(result, pkg)
@@ -397,6 +404,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 = true AND p.scope = 'global' AND p.enabled = true
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 = $1 AND p.team_id = $2`, sourceID, teamID)
}
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
func nullStrPtr(s *string) sql.NullString {
if s == nil {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"armature/models"
"armature/store"
)
type TeamStore struct{}
@@ -393,3 +394,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 (gen_random_uuid(), $1, $2, $3)
ON CONFLICT (team_id, role) DO NOTHING`,
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 = $1 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 = $1 AND source_package_id = $2`,
teamID, sourcePkgID)
return err
}

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
}