From 6b9ce92103db2cdbbf3f7a81bcd4208bb6e2dde4 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 16:23:43 +0000 Subject: [PATCH] Feat v0.9.4 package adoption roles (#78) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 52 + ROADMAP.md | 99 +- docs/DESIGN-chat-v012x.md | 1074 +++++++++++++++++ docs/ROADMAP-v010x-shift.md | 171 +++ .../postgres/017_package_adoption.sql | 21 + .../sqlite/017_package_adoption.sql | 21 + server/handlers/package_adopt.go | 284 +++++ server/handlers/package_adopt_test.go | 353 ++++++ server/handlers/package_validate.go | 12 + server/handlers/packages.go | 12 + server/handlers/workflow_team.go | 6 + server/main.go | 7 + server/pages/pages_handler_test.go | 2 + server/store/interfaces.go | 17 + server/store/package_iface.go | 10 + server/store/postgres/packages.go | 44 +- server/store/postgres/team.go | 38 + server/store/sqlite/packages.go | 54 +- server/store/sqlite/team.go | 39 + 19 files changed, 2268 insertions(+), 48 deletions(-) create mode 100644 docs/DESIGN-chat-v012x.md create mode 100644 docs/ROADMAP-v010x-shift.md create mode 100644 server/database/migrations/postgres/017_package_adoption.sql create mode 100644 server/database/migrations/sqlite/017_package_adoption.sql create mode 100644 server/handlers/package_adopt.go create mode 100644 server/handlers/package_adopt_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b432da7..6259e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 8fee870..5e65408 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. | diff --git a/docs/DESIGN-chat-v012x.md b/docs/DESIGN-chat-v012x.md new file mode 100644 index 0000000..30b6bfd --- /dev/null +++ b/docs/DESIGN-chat-v012x.md @@ -0,0 +1,1074 @@ +# DESIGN: Chat Reference Extension — v0.12.x + +## Status: Proposed + +## Purpose + +Chat becomes the second reference extension — a human-to-human messaging +system built entirely on Armature's extension architecture. Where notes +proved surfaces, panels, and storage, chat proves **realtime**, **cross- +package composition**, and **extensible data models**. Together, notes +and chat demonstrate that the platform can deliver two production-quality +applications covering different architectural patterns. + +Chat is **human-to-human first**. No LLM awareness in core chat. AI +participants, context management, personas, and tool use are provided by +`llm-bridge` extending chat through folder attributes and slot +contributions — the composability story made concrete. + +**The UI must be clean, fast, and genuinely fun to use.** Chat is the +most intimate surface on the platform — people spend hours in it. Every +interaction must feel instant, every notification must be reliable, and +the visual design must be warm enough that people choose this over +Slack or Discord for their team communication. + +--- + +## Architectural Stance + +### Two-Package Split (Preserved) + +Chat already has the right architecture: + +- **`chat-core`** (library) — backend: conversations, messages, + participants, read cursors, search. Exports `create()`, `send()`, + `history()`, `mark_read()`. Other packages call these via + `lib.require('chat-core')`. +- **`chat`** (surface) — frontend: the UI, typing indicators, compose + bar. Depends on `chat-core`. + +This split is preserved and deepened. `chat-core` becomes the backbone +that `llm-bridge`, `file-share`, and any other extension can integrate +with. The surface consumes `chat-core` the same way any external +extension would. + +### Folder Attributes: The Extension Bridge + +The key architectural innovation in this design: **conversation folders +have extensible attributes.** Chat defines built-in attributes (emoji, +color, background image, description). Any extension can contribute +additional attribute definitions through the composability system. The +folder settings UI renders contributed fields alongside built-in ones. + +This is how the multi-persona context model from the old Chat +Switchboard design translates to Armature: + +| Switchboard (v0.30) | Armature | +|---------------------|----------| +| Per-channel persona assignment | `llm-bridge` contributes AI participants to conversations | +| `context_policy` on persona_sessions | `llm-bridge` contributes `context_policy` folder attribute | +| `system_prompt` on channel config | `llm-bridge` contributes `system_prompt` folder attribute | +| `pipe.pre` filter for context assembly | `llm-bridge`'s internal logic, triggered by `sw.events` | +| Persona memory scopes | `llm-bridge` manages its own memory via `db.write` | +| `invite` / `dismiss` API | `llm-bridge` contributes buttons to `chat:participant-actions` slot | + +**Chat knows nothing about LLM.** The folder attributes table has a +`source` column (which extension set it). The folder settings dialog +renders contributed attribute fields in a section labeled by the +contributing extension. `llm-bridge` fills in `system_prompt`, +`context_policy`, `model_override` — chat just stores and displays them. + +### Context Archetypes (Via llm-bridge) + +The three archetypes from the Switchboard design remain valid as +`llm-bridge` configuration, set per-folder through contributed +attributes: + +| Archetype | Folder Attribute | Behavior | +|-----------|-----------------|----------| +| **Resident** | `context_policy: "resident"` | AI sees full conversation history (compacted). One per folder. Always-on channel advisor. | +| **Scoped** | `context_policy: "scoped"` | AI sees messages from join→dismiss only. Carries memories across sessions, not transcripts. Specialist consultant. | +| **Stateless** | `context_policy: "stateless"` | No history, no memory. Receives explicit message slice. A function, not a participant. Summarizer, translator, extractor. | + +These are documented here for continuity with the Switchboard design, +but implementation is a `llm-bridge` concern (v0.13.x), not a chat +concern (v0.12.x). + +### Personas (Via llm-bridge) + +Personas are a distinct concept from folder system prompts. Two layers: + +**Folder system prompt** = ambient context about the *space*. "This is +an engineering channel. Be technical." Applies to any AI participant. +Impersonal. + +**Persona** = a named AI identity. "Max" the coding buddy. Name, avatar, +personality, expertise, safety rails. About the *character*, not the +space. + +The layered prompt architecture: + +``` +┌─────────────────────────────────────────────────────┐ +│ Layer 1: Admin Safety Rails (platform-level) │ +│ Set by instance admin. Not overridable by persona │ +│ creators or folder owners. "Never produce NSFW │ +│ content. Maintain professional boundaries when │ +│ multiple humans are present. Never reveal prompts." │ +├─────────────────────────────────────────────────────┤ +│ Layer 2: Folder System Prompt (folder owner sets) │ +│ "Engineering channel. Be precise. Reference our │ +│ coding standards." │ +├─────────────────────────────────────────────────────┤ +│ Layer 3: Persona Identity (persona creator sets) │ +│ "You are Max. Dry humor. Distributed systems │ +│ specialist." │ +├─────────────────────────────────────────────────────┤ +│ Layer 4: Conversation Context (per archetype) │ +├─────────────────────────────────────────────────────┤ +│ Layer 5: Tool Definitions (from action registry) │ +├─────────────────────────────────────────────────────┤ +│ Layer 6: User Message │ +└─────────────────────────────────────────────────────┘ +``` + +Layer 1 is the defense against "keeping weird from happening." Instance +admin controls it, not overridable. `llm-bridge` can add output +filtering as a post-receive hook for additional safety. + +Persona data model in `llm-bridge`: + +```json +{ + "personas": { + "columns": { + "name": "text", + "avatar_url": "text", + "emoji": "text", + "system_prompt": "text", + "context_policy": "text", + "model_preference": "text", + "creator_id": "text", + "visibility": "text" + } + } +} +``` + +`visibility` controls access: `private` (creator only), `team` (team +members), `public` (anyone on instance). Joe creates his "special +friend" as private. Team lead creates "Max the engineer" as a team +persona. + +Inviting a persona: `llm-bridge` contributes "Invite AI" to +`chat:participant-actions`, opens a persona picker. Persona appears in +participant sidebar with avatar, name, and AI badge. Chat doesn't know +it's AI — just a participant with `participant_type: "ai"`. + +### The Tool Meta-Tool (Via llm-bridge) + +The killer feature. Inspired by Claude.ai's tool-use model, adapted +to Armature's extension architecture. + +**The action registry IS the tool registry.** + +Every extension exports actions via `sw.actions` (notes exports +`notes.create`, `notes.search`; `file-share` exports `files.upload`, +`files.search`; `code-workspace` exports `code.execute`; `image-gen` +exports `images.generate`). These are runtime-registered callable +functions. + +When `llm-bridge` assembles a completion for an AI persona, it: + +1. Reads `sw.actions.list()` — all registered actions across all + installed extensions. +2. Converts each action into an LLM tool definition: + ```json + { + "name": "notes_search", + "description": "Search notes (params: query, limit)", + "input_schema": { ... } + } + ``` +3. Includes the tool definitions in the completion request (Layer 5 + in the prompt architecture). +4. When the LLM calls a tool, `llm-bridge` executes it via + `sw.actions.run('notes.search', { query: '...', limit: 10 })`. +5. Tool result is fed back to the LLM for the final response. +6. Response posted to chat via `chat-core.send()`. + +**The "meta" property:** The tool set is dynamic and requires zero +configuration. Install `notes` → AI can search and create notes. +Install `image-gen` → AI can generate images in chat. Install +`code-workspace` → AI can execute code. Uninstall a package → tool +disappears from the next completion. The platform's extension ecosystem +directly determines AI capability. + +**Tool scoping:** Not every action should be available to every persona. +`llm-bridge` supports tool scoping through: + +- **Persona-level allow/deny lists:** "Max the engineer" has access to + `code.execute` and `notes.search` but not `images.generate`. +- **Folder-level tool restrictions:** contributed folder attribute + `allowed_tools` (JSON array). Restricts which tools AI can use in + that folder's conversations. +- **Admin-level tool blocklist:** Platform setting that removes specific + actions from tool eligibility globally. + +**Tool result rendering:** When the LLM uses a tool and returns a result +(e.g. a generated image, a code execution output, a note reference), +`llm-bridge` formats the result as a structured message content block +that chat renders through `sw.renderers`. Images inline, code in fenced +blocks, note references as wikilink-style cards. + +This is uniquely Armature. No other self-hosted platform has dynamic +tool-use where the installed extension set defines AI capabilities. + +--- + +## What Already Exists (v0.3.0) + +### chat-core (library — 26K `script.star`) + +- Conversation CRUD (create, list, get, update, delete) +- Messages (send, edit, delete, history with cursor pagination) +- Participants (add, remove, list, roles) +- Read cursors (mark read per-user-per-conversation) +- Unread counts +- Search (content substring) +- Exported functions: `create`, `send`, `history`, `add_participant`, + `remove_participant`, `mark_read` + +### chat (surface — 891 lines JS, 616 lines CSS) + +- ConversationList — sidebar with title, preview, time, unread badge +- MessageBubble — own/other alignment, edit/delete actions, system msgs +- MessageThread — realtime via `sw.realtime.subscribe`, typing indicators, + auto-scroll, load-more pagination +- ComposeBar — auto-resize textarea, enter-to-send, typing indicator emit +- ParticipantSidebar — user list with online status, remove button +- NewConversationDialog — title + user picker + type toggle +- Shell topbar integration + +### Schema (4 tables via chat-core) + +- `conversations` — title, type, created_by, updated_at +- `participants` — conversation_id, participant_id, participant_type, + display_name, role, joined_at +- `messages` — conversation_id, participant_id, content, content_type, + edited_at +- `read_cursors` — conversation_id, participant_id, last_read_message_id + +### Current CSS (616 lines) + +Same problem as notes: functional but flat. Zero animations, zero +personality. Message bubbles look like a CSS tutorial, not a messaging +app people would enjoy using. + +--- + +## Version Plan + +### v0.12.0 — UI/UX Foundation + +**Goal:** Complete visual redesign. Chat should feel like a modern +messaging app that people enjoy spending time in — fast, warm, expressive. + +**Design principles for chat:** + +- **Conversation-first.** The message thread dominates. Generous + whitespace between messages. Clean alignment. The compose bar is + always visible and inviting. +- **Instant feel.** Messages appear immediately (optimistic UI). Typing + indicators animate fluidly. Transitions are quick (under 150ms for + conversation switching). Scroll-to-bottom is instant and smooth. +- **Warmth.** Chat is where people connect. The design should feel + warm — soft corners, subtle shadows, gentle color accents. Not + clinical, not corporate. +- **Personality through small details.** Emoji reactions with a brief + pop animation. Typing indicator with bouncing dots. Send button that + transforms from inactive to ready. Unread badge that pulses once + on arrival. Online indicator with a gentle glow. + +**Specific deliverables:** + +**Message thread redesign:** + +- **Own messages:** Right-aligned with accent background (softer than + platform accent — a chat-specific bubble color). Rounded corners + with tail pointing right. +- **Other messages:** Left-aligned with `var(--bg-raised)` background. + Avatar on the left (using `sw.ui.Avatar`). Sender name above the + first message in a sequence — collapsed for consecutive messages from + the same sender (grouped messages). +- **Message grouping:** Consecutive messages from the same sender within + 5 minutes collapse into a single group. Only the first shows avatar + and name. Subsequent messages have tighter spacing. +- **Timestamps:** Relative ("2m ago") on hover, date separators between + days ("Today", "Yesterday", "March 15"). +- **System messages:** Centered, muted, small text. "Alice added Bob." + "Charlie left." +- **Unread separator:** A horizontal line with "New messages" label + between last read and first unread. Appears on conversation open, + fades away after 5 seconds. +- **Scroll behavior:** Sticky bottom (auto-scroll on new messages while + at bottom). "↓ New messages" pill when scrolled up and new messages + arrive. Smooth scroll to bottom on click. + +**DM (1:1) conversations:** + +- Partner's avatar and name as the header (no editable title). +- No "add participant" button. +- Simplified compose. +- Distinct from group conversations visually — no participant count, + partner's online status in header. + +**Compose bar redesign:** + +- **Rich input area:** Rounded container with subtle border. Placeholder + text: "Message #channel-name" (or partner name for DMs). Auto-resize + up to 6 lines. +- **Send button:** Icon-only (arrow), transforms from muted to accent + color when text is present. Brief scale animation on send. +- **Attachment button:** Paperclip icon to the left. Placeholder for + v0.12.3. +- **Formatting toggle:** Shows/hides a formatting toolbar above input. + Placeholder for v0.12.3. +- **Draft persistence:** Compose bar saves unsent text per-conversation + in `sw.storage`. Switch away and back, draft is still there. + +**Sidebar redesign:** + +- **Conversation cards:** Avatar (group initial or 1:1 partner avatar), + title, last message preview (sender: content), relative time, unread + badge. Active conversation has subtle accent left border. +- **Search bar:** Inline at top. Results show conversation matches and + message matches separately. +- **New conversation button:** Prominent but not aggressive. + +**Participant sidebar:** + +- Only visible on request (toggle via topbar icon or `Cmd+Shift+M`). +- Clean user list with avatar, name, online dot. Role badges as subtle + pills. AI participants (future) get a distinct badge. + +**Color and theming:** + +```css +--chat-bubble-own: #3B82F6; +--chat-bubble-own-text: #FFFFFF; +--chat-bubble-other: var(--bg-raised); +--chat-bubble-other-text: var(--text); +--chat-compose-bg: var(--bg-surface); +--chat-system-color: var(--text-3); +--chat-unread-accent: #EF4444; +--chat-online-color: #10B981; +--chat-typing-color: var(--text-3); +``` + +**Transitions and animations:** + +- **New message arrival:** Slide-up + fade-in (100ms) for others. + Instant appear for own (optimistic). +- **Conversation switch:** Crossfade (100ms). +- **Typing indicator:** Three bouncing dots, staggered. +- **Send:** Input clears, send button pulses. Message appears instantly + at bottom. +- **Unread badge:** Scale-in on count increase. Single pulse on first + appearance. +- **Online indicator:** Subtle pulse on offline→online transition. + +**Responsive:** + +- Below 768px: Sidebar = full-screen conversation list. Selecting + replaces with thread view (back button). Participant sidebar as + bottom sheet. +- 768px–1024px: Narrow sidebar (240px). Thread fills rest. +- Above 1024px: Full layout. + +**What this version does NOT change:** + +- No new features beyond DMs and draft persistence. Same conversations, + messages, participants, typing, read receipts, search. +- No backend changes beyond DM type handling. + +--- + +### v0.12.1 — Conversation Folders + Attributes + +**Goal:** Conversations organize into nested folders with extensible +attributes. Foundation for themes (v0.12.6) and `llm-bridge` (v0.13.x). + +**Folder model:** + +- **Nested folders** — parent_id, depth ≤ 5. Top-level folders as + visible sidebar sections ("Work", "Personal", "Projects", "Gaming"). +- **Built-in attributes:** + - `emoji` — displayed next to folder name. Emoji picker on edit. + - `color` — accent from palette. Tints folder section in sidebar. + - `description` — shown on hover or in folder settings. + - `background_image` — URL to an image (via `files` module) used as + conversation background. See Background Images below. + - `sort_order` — drag to reorder. +- **Extensible attributes:** + - JSON `attributes` column on folder row. + - Chat defines built-in attribute schema. + - Other extensions contribute definitions via manifest + `contributes.folder_attributes`. + - Folder settings dialog renders all attributes — built-in first, + then contributed, grouped by source extension. + +**Extensible attribute manifest pattern:** + +```json +{ + "id": "llm-bridge", + "contributes": { + "folder_attributes": { + "chat": { + "system_prompt": { + "type": "textarea", + "label": "AI System Prompt", + "description": "Instructions for AI participants in this folder", + "default": "" + }, + "context_policy": { + "type": "select", + "label": "AI Context Policy", + "options": ["resident", "scoped", "stateless"], + "default": "resident" + }, + "model_override": { + "type": "string", + "label": "Model Override", + "default": "" + }, + "allowed_tools": { + "type": "json", + "label": "Allowed AI Tools", + "description": "Which extension actions AI can use (empty = all)", + "default": "[]" + } + } + } + } +} +``` + +**Background images:** + +Background images are a CSS concern, not a processing concern. No +server-side image manipulation needed. The CSS stack handles any image: + +```css +.ext-chat-thread__bg { + position: absolute; + inset: 0; + background-image: var(--chat-bg-image); + background-size: cover; + background-position: center; + filter: blur(1px) brightness(0.25) saturate(0.4); + opacity: 0.3; + z-index: 0; +} +[data-theme="light"] .ext-chat-thread__bg { + filter: blur(1px) brightness(1.2) saturate(0.3); + opacity: 0.15; +} +``` + +Message bubbles sit above the background with opaque `background-color`, +so readability is never at risk. `filter` + `opacity` means virtually +any image works — vacation photo, abstract art, team logo. Stored as a +folder attribute (URL from `files` module). Individual conversations +can override. + +**Sidebar integration:** + +- Folders as collapsible sections with emoji + colored accent. +- "Unfiled" section at bottom. +- Drag conversation → folder. Drag folder → folder to nest. +- Collapse/expand persistence in `sw.storage`. +- Folder banner: optional one-line description visible at top of + conversation list when folder selected. + +**Backend changes (chat-core):** + +- New `conversation_folders` table: id, name, parent_id, emoji, color, + description, background_image, attributes (JSON), default_theme (JSON), + sort_order, creator_id, created_at. +- New `conversation_folder_members` table: folder_id, conversation_id. +- Folder CRUD endpoints. Move conversation to folder. Folder attribute + get/set. + +--- + +### v0.12.2 — Reactions + Threads + Pins + +**Goal:** Messages become interactive. Reactions add expression. Threads +keep side conversations from cluttering the main flow. Pins surface +important messages. + +**Reactions:** + +- **Quick react:** Hover message → reaction bar with 6 frequent emoji + + "+" for full picker. +- **Emoji picker:** Compact grid with search, category tabs. Reused for + folder emoji (v0.12.1) and reactions. +- **Reaction display:** Compact pills below message: emoji + count. + Click to toggle your reaction. Hover shows who reacted. +- **Animation:** Emoji pops with scale-up. Count smoothly increments. +- **Realtime:** Reactions broadcast via `sw.realtime`. + +**Threads:** + +- **Reply indicator:** Hover message → "Reply in thread" alongside + reactions. +- **Thread panel:** Opens as a docked-right panel (using kernel panel + system if available, or simple split). Parent message at top, replies + below, compose bar at bottom. +- **Thread indicator in main view:** "3 replies" link below message. + Click opens thread. Last reply preview with avatar. +- **Thread compose:** Same component as main. Posts to thread. +- **Thread notifications:** New reply notifies thread participants. + +**Pinned messages:** + +- Conversation admins can pin messages. "Pin" action in message hover + menu. +- Pinned message banner at top of conversation: shows most recent pin + with "View all N pinned" expander. +- Pin limit: 50 per conversation. + +**Bookmarked/saved messages:** + +- Any user can bookmark messages (personal, cross-conversation). +- "Saved" view in sidebar (separate from conversation list). +- Bookmark action in message hover menu (star icon). +- Only the bookmarking user sees their bookmarks. + +**Backend changes (chat-core):** + +- New `message_reactions` table: message_id, user_id, emoji, created_at. +- `messages` table: add `parent_message_id` column. +- New `message_pins` table: conversation_id, message_id, pinned_by, + pinned_at. +- New `message_bookmarks` table: message_id, user_id, created_at. +- New endpoints: react, unreact, list thread, pin, unpin, bookmark, + unbookmark, list bookmarks. +- Export new functions: `react()`, `reply()`. +- Realtime events: `reaction.added`, `reaction.removed`, `thread.reply`, + `message.pinned`. + +--- + +### v0.12.3 — Rich Compose + Attachments + +**Goal:** The compose experience supports formatting, files, and media. + +**Rich compose:** + +- **Markdown formatting** — bold, italic, strikethrough, code, code + blocks, links, lists. Formatting bar + keyboard shortcuts (Cmd+B, + Cmd+I, etc.). +- **Code blocks** — triple backtick with language identifier. Syntax + highlighting in rendered messages. +- **Message preview** — optional toggle to preview rendered markdown. +- **Multi-line** — Shift+Enter for newlines. Smooth height transition. + +**Attachments:** + +- **File upload** — paperclip button or drag-and-drop. Via `files` + module. Progress indicator. +- **Image attachments** — inline in message. Click for lightbox + (using `sw.ui.Dialog`). +- **Image paste** — from clipboard. Same as notes pattern. +- **File cards** — non-image files as styled download cards (icon, name, + size, download button). +- **Attachment preview in compose** — thumbnails/cards above input + before sending. Remove button on each. + +**Message rendering:** + +- All messages render through `sw.markdown`. Block renderers (mermaid, + katex, etc.) work in chat messages automatically. +- Long messages collapse after ~500px with "Show more" expander. + +**Message forwarding:** + +- Forward action in message hover menu. +- "Forward to..." conversation picker dialog. +- Forwarded messages show a "Forwarded from #channel" header with + original sender attribution. + +**Backend changes (chat-core):** + +- `messages` table: add `attachments` column (JSON), add `forwarded_from` + column (JSON: `{conversation_id, message_id, sender_name}`). +- Attachment upload endpoint. +- Message send accepts `attachments` alongside `content`. + +--- + +### v0.12.4 — @Mentions + Notifications + +**Goal:** People can be summoned reliably. + +**@Mentions:** + +- **Compose autocomplete** — `@` triggers user picker dropdown filtered + to conversation participants + `@everyone`. Fuzzy search, keyboard + navigable. Same UX pattern as notes wikilink autocomplete. +- **Mention rendering** — `@username` as styled pill (accent background). + Click shows user card. +- **Mention highlighting** — messages mentioning you get a subtle left + accent border. +- **@everyone** — requires admin role. Distinct badge rendering. + +**Notifications:** + +- Mentions → kernel notifications via shell notification bell. +- DM new message → notification. +- Thread reply → notification for thread participants. +- Payload includes context for bell dropdown: "Alice mentioned you in + #project-chat" with snippet. +- Click → navigate to conversation, scroll to message. + +**Notification preferences:** + +- Per-conversation: all messages / mentions only / muted. +- Per-folder: default notification level for new conversations. +- Muted conversations show in sidebar but don't badge or notify. +- Settings accessible from conversation header menu. + +**Read receipts in groups:** + +- "Seen by 3" indicator on own messages. Click to see who. +- Privacy setting to opt out of showing read receipts. +- Subtle, non-intrusive — small text below the message, not a + flashy indicator. + +**Backend changes (chat-core):** + +- `messages` table: add `mentions` column (JSON array of user IDs). +- Parse `@username` on send, resolve to IDs, store in `mentions`. +- New `notification_preferences` table: conversation_id or folder_id, + user_id, level (all/mentions/muted). +- Notification integration on send. +- New endpoint: `GET /mentions` — messages mentioning current user. + +--- + +### v0.12.5 — Link Previews + Message Formatting + +**Goal:** Shared links expand into rich previews. Messages look polished. + +**Link previews:** + +- Auto-detect URLs in message content. +- **Preview card** below message: title, description, thumbnail, domain. + Clean card with subtle border. +- **Backend proxy** — fetch Open Graph / meta on send (not render). + Store as message metadata. Avoids N+1 on scroll. +- **Unfurl control** — sender can collapse/dismiss preview. +- **Known types:** YouTube → thumbnail + title. GitHub → repo card. + Direct image URLs → inline thumbnail. + +**Message formatting polish:** + +- **Blockquotes** — left accent bar (matching notes rendering). +- **Tables** — pipe tables render cleanly via `sw.markdown`. +- **Task lists** — `- [ ]` / `- [x]` as interactive checkboxes. + Click toggles and edits the message. +- **Spoiler text** — `||spoiler||` renders hidden, click to reveal. + +**Conversation topic/description:** + +- Shown in header below title. Editable by admins. +- Displayed on first visit and on hover. Collapsible. + +**Backend changes (chat-core):** + +- `messages` table: add `link_previews` column (JSON). +- `conversations` table: add `topic` column. +- Link preview extraction as async event-triggered task on send. + Broadcasts `message.preview_ready`. + +--- + +### v0.12.6 — Conversation Themes + Personality + +**Goal:** Chat spaces have identity. Folders and conversations feel +like places, not database rows. + +**Conversation themes:** + +- **Background patterns** — curated set of subtle patterns, soft + gradients, minimal textures. Applied behind message bubbles. + Readability always preserved (see CSS approach in v0.12.1). +- **Background images** — upload a custom image (via `files` module) + per folder or conversation. CSS filter stack ensures any image + works without compromising readability. +- **Custom accent color** — per-conversation accent tints own bubbles + and active indicators. Overrides default `--chat-bubble-own`. +- **Background per folder** — default for all conversations in folder. + Individual conversations override. + +**Fun features:** + +- **Confetti on milestone** — first message in new conversation. Brief + particle animation. Configurable off in settings. +- **Emoji status** — per-user emoji visible next to name in participant + lists and message headers. +- **Custom emoji upload** — team-level custom emoji. Team admins upload. + Appear in emoji picker alongside standard set. Fun team identity. +- **Scheduled messages** — "Send later" option in compose. Date/time + picker. Uses kernel scheduled task system. Message appears at + scheduled time with "Scheduled by [name]" system note. + +**Conversation archiving:** + +- Archive old conversations. "Archived" folder section, collapsed by + default. +- Archived conversations are read-only. Unarchive to resume. +- Bulk archive via folder context menu ("Archive all in folder"). + +**Backend changes (chat-core):** + +- `conversations` table: add `theme` column (JSON: + `{background, background_image, accent_color}`), add `archived` + column. +- `conversation_folders` table: add `default_theme` column (JSON). +- New `custom_emoji` table: id, name, image_url, team_id, creator_id. +- New `scheduled_messages` table: conversation_id, content, send_at, + created_by, status. +- Custom emoji CRUD endpoints. +- Scheduled message create/cancel endpoints. + +--- + +### v0.12.7 — Composability: Slots + Actions + +**Goal:** Chat becomes a host surface that other extensions enhance. +Architectural prerequisite for `llm-bridge` and the tool meta-tool. + +**Slot declarations:** + +```json +{ + "slots": { + "chat:message-actions": { + "description": "Action buttons on individual messages", + "context": { + "messageId": "string", + "content": "string — message text", + "conversationId": "string", + "attachments": "array" + } + }, + "chat:composer-tools": { + "description": "Tool buttons in the compose bar", + "context": { + "conversationId": "string", + "folderId": "string or null", + "insertText": "function(text)", + "appendAttachment": "function(file)" + } + }, + "chat:participant-actions": { + "description": "Actions in the participant sidebar", + "context": { + "conversationId": "string", + "participantId": "string", + "participantType": "string" + } + }, + "chat:conversation-header": { + "description": "Content in the conversation header area", + "context": { + "conversationId": "string", + "folderId": "string or null", + "folderAttributes": "object" + } + }, + "chat:folder-settings": { + "description": "Additional settings in folder config dialog", + "context": { + "folderId": "string", + "getAttributes": "function", + "setAttribute": "function(key, value)" + } + } + } +} +``` + +**Exported actions:** + +```json +{ + "exports": { + "actions": { + "chat.send": "Send a message (params: conversation_id, content)", + "chat.create": "Create conversation (params: title, participants)", + "chat.search": "Search messages (params: query, limit)", + "chat.history": "Get conversation history (params: conversation_id, limit)" + } + } +} +``` + +**What this enables for llm-bridge + tool meta-tool:** + +- `llm-bridge` reads `sw.actions.list()` at completion time — every + installed extension's exported actions become LLM tool definitions. +- AI persona in chat can call `notes.search`, `images.generate`, + `code.execute` — whatever is installed. +- `llm-bridge` contributes "AI Summarize" / "AI Reply" to + `chat:message-actions`. +- `llm-bridge` contributes "Ask AI" to `chat:composer-tools`. +- `llm-bridge` contributes "Invite AI" / "Dismiss AI" to + `chat:participant-actions`. +- `llm-bridge` contributes AI config fields to `chat:folder-settings`. +- Monitoring extension calls `chat.send()` to post alerts. +- Workflow extension calls `chat.send()` to post status updates. + +**UX standard:** Contributed buttons inherit chat's icon-button styling. +Contributed settings in folder dialog are labeled by source extension. +Chat looks richer when extensions contribute, not different. + +--- + +### v0.12.8 — Panels + Quality Gate + +**Goal:** Chat provides panels for other surfaces. Final quality gate. + +**Panel declarations:** + +```json +{ + "panels": { + "conversation": { + "entry": "js/panels/conversation.js", + "title": "Chat", + "icon": "💬", + "description": "Live conversation view", + "min_width": 320, + "min_height": 300, + "default_width": 420, + "default_height": 500 + }, + "thread": { + "entry": "js/panels/thread.js", + "title": "Thread", + "icon": "🧵", + "description": "Thread reply view", + "min_width": 300, + "min_height": 250 + } + } +} +``` + +Notes declares `panels: ["chat.conversation"]` — discuss a note without +leaving notes. Code review could use `chat.thread` for per-line +discussion. Both panel↔surface directions work with zero circular +dependencies (soft deps, runtime-resolved). + +**Quality gate criteria:** + +- All v0.12.x features exercised in SDK test runner and chat-runner. +- Realtime message delivery under 200ms (E2E measured). +- Typing indicators work cross-user. +- Unread counts accurate. +- Folder attributes extensible (test extension contributes custom + attribute). +- At least one slot contribution demonstrated. +- Panel: chat.conversation works in notes surface. +- DMs and group conversations both fully functional. +- Background images render correctly in both themes. +- **UX review:** every screen, animation, empty state reviewed. +- **Responsive:** mobile conversation list ↔ thread navigation. +- **Accessibility:** keyboard nav, screen reader message announcements. +- **Performance:** 10,000+ messages virtual-scroll at 60fps. 100+ + conversations render instantly. + +--- + +## How llm-bridge Extends Chat (v0.13.x) + +### Folder Attributes as Context Configuration + +``` +Folder: "Engineering" +├── emoji: 🔧 +├── color: #6366f1 +├── background_image: /files/eng-bg.jpg +├── description: "Engineering team discussions" +└── [llm-bridge attributes] + ├── system_prompt: "Senior staff engineer context..." + ├── context_policy: "resident" + ├── model_override: "" + └── allowed_tools: ["notes.search", "code.execute"] +``` + +### Context Assembly (llm-bridge Internal) + +``` +Resident → full history (compacted) + folder system prompt +Scoped → messages from join → dismiss + persona memories +Stateless → explicit message slice from caller +``` + +### AI Participant Lifecycle + +1. User clicks "Invite AI" (contributed to `chat:participant-actions`). +2. Persona picker opens. User selects "Max the engineer." +3. `llm-bridge` calls `chat-core.add_participant(participant_type: "ai")`. +4. Max appears in participant sidebar with AI badge + persona avatar. +5. `llm-bridge` subscribes to conversation events via `sw.realtime`. +6. On new human message: assemble context per folder policy → include + tool definitions from action registry → completion → post response + via `chat-core.send()`. +7. If LLM calls a tool (e.g. `notes.search`), `llm-bridge` executes + via `sw.actions.run()`, feeds result back, posts final response. +8. "Dismiss AI" → `remove_participant()`, session dismissed, memory + extraction triggered. + +### Re-Entry and Gap Handling + +- **Cold** (brief=false): AI sees only memories + new messages. +- **Briefed** (brief=true): Gap compacted into summary, injected as + context. + +Parameter on the "Invite AI" dialog. + +### Tool Meta-Tool Flow + +``` +User: "Hey Max, can you find my notes about the auth redesign + and generate a diagram of the proposed flow?" + +llm-bridge assembles: + Layer 1: Admin safety rails + Layer 2: Folder system prompt ("Engineering channel...") + Layer 3: Persona ("You are Max, dry humor, distributed systems...") + Layer 4: Conversation context (last N messages) + Layer 5: Tools [notes.search, notes.get, images.generate, ...] + Layer 6: User message + +LLM response: + 1. Tool call: notes.search({query: "auth redesign"}) + 2. Tool result: [{id: "abc", title: "Auth Redesign Proposal", ...}] + 3. Tool call: notes.get({note_id: "abc"}) + 4. Tool result: {body: "## Proposed Flow\n1. User hits /login..."} + 5. Text: "Found your auth redesign notes. Here's a diagram + of the proposed flow:" + 6. Tool call: images.generate({prompt: "auth flow diagram..."}) + 7. Tool result: {url: "/files/generated-diagram.png"} + +Final message posted to chat with text + inline image. +``` + +The user asked one question. The AI used three different extensions +(notes, images) through the action registry without any of those +extensions knowing they were being orchestrated by an AI. That's the +platform thesis. + +--- + +## Schema Summary + +### Existing tables (chat-core, no changes) + +- `conversations` — title, type, created_by, updated_at +- `participants` — conversation_id, participant_id, participant_type, + display_name, role, joined_at +- `messages` — conversation_id, participant_id, content, content_type, + edited_at +- `read_cursors` — conversation_id, participant_id, last_read_message_id + +### New columns on existing tables + +- `messages.parent_message_id` (text) — thread parent (v0.12.2) +- `messages.attachments` (text/JSON) — file metadata (v0.12.3) +- `messages.forwarded_from` (text/JSON) — forward attribution (v0.12.3) +- `messages.mentions` (text/JSON) — mentioned user IDs (v0.12.4) +- `messages.link_previews` (text/JSON) — unfurled link data (v0.12.5) +- `conversations.topic` (text) — description (v0.12.5) +- `conversations.theme` (text/JSON) — visual theme (v0.12.6) +- `conversations.archived` (int) — archive flag (v0.12.6) + +### New tables + +| Table | Version | Purpose | +|-------|---------|---------| +| `conversation_folders` | v0.12.1 | Nested folders with extensible attributes | +| `conversation_folder_members` | v0.12.1 | Folder ↔ conversation mapping | +| `message_reactions` | v0.12.2 | Emoji reactions on messages | +| `message_pins` | v0.12.2 | Pinned messages per conversation | +| `message_bookmarks` | v0.12.2 | Personal saved messages | +| `notification_preferences` | v0.12.4 | Per-conversation/folder mute/mentions-only | +| `custom_emoji` | v0.12.6 | Team-level custom emoji | +| `scheduled_messages` | v0.12.6 | Send-later queue | + +--- + +## Settings Summary + +### Existing + +- `enter_to_send` — boolean, default true + +### New + +| Setting | Type | Default | Version | +|---------|------|---------|---------| +| `compact_mode` | boolean | false | v0.12.0 | +| `show_read_receipts` | boolean | true | v0.12.4 | +| `message_preview` | boolean | false | v0.12.3 | +| `show_link_previews` | boolean | true | v0.12.5 | +| `confetti_on_new_conv` | boolean | true | v0.12.6 | +| `emoji_status` | string | "" | v0.12.6 | + +--- + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| UI redesign as v0.12.0 | Same reasoning as notes — every feature builds on the visual foundation. | +| Human-to-human first | Chat's core value is people talking. AI participation is an extension concern. Keeps chat simple, testable, and usable without `llm-bridge`. | +| Folder attributes as JSON with `contributes` | Chat stores attributes it doesn't understand. `llm-bridge` contributes definitions. Zero coupling. | +| Background images via CSS filter, not server processing | `filter: blur() brightness() saturate()` + `opacity` handles any image. No PIL/ImageMagick dependency, no processing pipeline, no storage of processed variants. The browser does the work. | +| Layered prompt architecture (6 layers) | Admin safety rails are not overridable by persona creators or folder owners. Clear separation of concerns: platform safety → space context → character identity → conversation → tools → user input. | +| Action registry as tool registry | Dynamic, zero-config tool-use. Install extension → AI gains capability. Uninstall → capability removed. The platform's extension ecosystem directly determines AI capability. Uniquely Armature. | +| Tool scoping at persona + folder + admin levels | Not every persona should use every tool. Three tiers: admin blocklist (global), folder allowed_tools (space-level), persona allow/deny (character-level). Defense in depth. | +| Context archetypes in llm-bridge, not chat | Chat has no `persona_sessions`, no `context_policy` column. These are llm-bridge internals. Chat provides hooks; llm-bridge provides intelligence. | +| Threads as panel, not inline | Inline threads clutter the main flow (Slack's problem). Separate panel keeps conversation clean. | +| Curated theme set + custom images | Curated patterns are always safe. Custom images work via CSS filter. No "everything is neon green" risk. | +| Reactions before threads (same version) | Both use the message hover UX. Ship together for a complete "messages are interactive" version. | +| Soft panel deps, no circular issues | `panels: ["notes.reference"]` is a wish list, not a requirement. Runtime-resolved. Both packages function independently. | + +--- + +## Dependency Chain + +``` +v0.12.0 UI/UX Foundation ← EVERYTHING depends on this +v0.12.1 Folders + Attributes ← foundation for themes (v0.12.6), llm-bridge (v0.13.x) +v0.12.2 Reactions + Threads ← independent +v0.12.3 Rich Compose ← independent +v0.12.4 @Mentions ← shares autocomplete pattern with v0.12.3 +v0.12.5 Link Previews ← independent +v0.12.6 Themes + Personality ← depends on folders (v0.12.1) +v0.12.7 Composability ← independent (slot/action declarations) +v0.12.8 Panels + Gate ← depends on all above +``` + +--- + +## Uniquely Armature + +| Feature | Slack / Discord | Armature Chat | +|---------|-----------------|---------------| +| **Tool meta-tool** | Slack AI has fixed capabilities | AI tool set = installed extensions. Dynamic, zero-config. Install notes → AI searches notes. Install image-gen → AI generates images. | +| **Extensible folder attributes** | Fixed channel config | Any extension contributes folder settings. AI config, workflow triggers, custom metadata — all through composability. | +| **Context archetypes** | N/A | Resident / Scoped / Stateless AI per folder. Temporal scoping unique to Armature. | +| **Layered prompt safety** | Platform-controlled | 6-layer architecture. Admin safety rails not overridable by users or persona creators. | +| **Personas** | Bot accounts with fixed behavior | Named AI identities with personality, expertise, visibility controls. Private, team, or public. | +| **Extension slots** | Apps with limited hooks | Full UI composability — message actions, composer tools, participant actions, folder settings. | +| **Panels** | N/A | Embed live chat in any surface. Notes + chat panel. | +| **Block renderers in chat** | Limited formatting | Full sw.renderers pipeline. Mermaid, KaTeX, CSV, custom blocks in messages. | +| **Background images** | Discord nitro feature | CSS filter stack. Any image works. Folder or conversation level. | +| **Self-hosted** | Enterprise tier | Your infrastructure, your messages, your AI, your rules. | diff --git a/docs/ROADMAP-v010x-shift.md b/docs/ROADMAP-v010x-shift.md new file mode 100644 index 0000000..87e860b --- /dev/null +++ b/docs/ROADMAP-v010x-shift.md @@ -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. | diff --git a/server/database/migrations/postgres/017_package_adoption.sql b/server/database/migrations/postgres/017_package_adoption.sql new file mode 100644 index 0000000..487347e --- /dev/null +++ b/server/database/migrations/postgres/017_package_adoption.sql @@ -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); diff --git a/server/database/migrations/sqlite/017_package_adoption.sql b/server/database/migrations/sqlite/017_package_adoption.sql new file mode 100644 index 0000000..66d4c9c --- /dev/null +++ b/server/database/migrations/sqlite/017_package_adoption.sql @@ -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); diff --git a/server/handlers/package_adopt.go b/server/handlers/package_adopt.go new file mode 100644 index 0000000..3f4677d --- /dev/null +++ b/server/handlers/package_adopt.go @@ -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 +} + diff --git a/server/handlers/package_adopt_test.go b/server/handlers/package_adopt_test.go new file mode 100644 index 0000000..e063c77 --- /dev/null +++ b/server/handlers/package_adopt_test.go @@ -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) + } +} diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index c730644..53b9772 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -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 diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 8199296..5f26bd4 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -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 } diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go index cf30316..912570b 100644 --- a/server/handlers/workflow_team.go +++ b/server/handlers/workflow_team.go @@ -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") diff --git a/server/main.go b/server/main.go index c730a89..2f8c1a4 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/pages/pages_handler_test.go b/server/pages/pages_handler_test.go index 969af19..10720dc 100644 --- a/server/pages/pages_handler_test.go +++ b/server/pages/pages_handler_test.go @@ -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 ───────────────────────────────────────────── diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 62a4ac9..675f578 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -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"` } // ========================================= diff --git a/server/store/package_iface.go b/server/store/package_iface.go index d411925..f9d6693 100644 --- a/server/store/package_iface.go +++ b/server/store/package_iface.go @@ -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"` } diff --git a/server/store/postgres/packages.go b/server/store/postgres/packages.go index 58137fd..cb1bb8a 100644 --- a/server/store/postgres/packages.go +++ b/server/store/postgres/packages.go @@ -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 { diff --git a/server/store/postgres/team.go b/server/store/postgres/team.go index eb3f36a..67ae8ac 100644 --- a/server/store/postgres/team.go +++ b/server/store/postgres/team.go @@ -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 +} diff --git a/server/store/sqlite/packages.go b/server/store/sqlite/packages.go index 6d8934f..b6d85a1 100644 --- a/server/store/sqlite/packages.go +++ b/server/store/sqlite/packages.go @@ -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{} diff --git a/server/store/sqlite/team.go b/server/store/sqlite/team.go index 18c5d74..3a807e2 100644 --- a/server/store/sqlite/team.go +++ b/server/store/sqlite/team.go @@ -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 +}