Implements the compile-in plugin system designed in spec 019:
- internal/plugin/ (~1,300 LOC):
* Tiny Plugin interface + 10 optional HasX capability sub-interfaces
* Host struct with Logger / DB / Messenger / Channels / Attachments /
Search / Secrets / Events / Config / DataDir / Tracer / Metrics /
DefaultOwner / BaseURL
* Registry with panic-on-duplicate, stability-level tracking, capability
indexing (MCP tools, actions, panels, channel types, routes, event
subscribers, CLI commands)
* Migrator: per-plugin SHA-256-checksum'd migration chain, namespaced
plugin_<name>_* table enforcement, idempotent re-apply
* Three-phase lifecycle (Migrate → Init → Start) with panic-safe
wrappers around every plugin call; failure per plugin isolated,
core continues
* YAML config loader that preserves unknown top-level keys on round-trip
* Status store exposing /api/plugins/status JSON
* Restart helpers (Noop + SignalRestarter); graceful reload is
in-process for the demo
- internal/plugin/plugintest/ (~345 LOC):
* NopHost(t) with in-memory modernc.org/sqlite
* Run(t, plugin) full-lifecycle smoke helper
* Assertions: HasTool, HasAction, HasPanel, HasChannelType,
HasMigration, PluginStarted, PluginFailed
* ScopedSecrets that returns ErrSecretNotFound for cross-plugin
reads (satisfies SC-006)
- internal/plugins/demo/ (canonical showcase):
* Plugin that exercises every HasX capability (migrations, actions,
HTTP routes, web panel, lifecycle, config schema, stability)
* Own SQL migration creating plugin_demo_notes
* Embedded HTML panel that fetches notes via JS
* 4 unit tests covering smoke, full capability registration, action
handlers, and config-driven max_notes limit
- cmd/plugindemo/ (~290 LOC):
* Demo HTTP server wiring registry to chi
* Mounts /api/plugins/status, /api/admin/plugins/{name}/{enable,disable},
/api/actions/{name}, /api/plugins/<name>/* (per-plugin REST),
/ui/plugins/<name>/ (per-plugin UI)
* SIGHUP-triggered config reload + registry rebuild + mux swap
* SIGTERM/SIGINT graceful shutdown
- test/integration/ (~357 LOC, build-tag "integration"):
* 6 end-to-end tests against a spawned plugindemo binary
* Enable/disable round-trip with data preservation
* SIGHUP reload timing (measured 41 ms — SC-008 target is 2 s)
* Action-404 on disabled plugin, panel-404 on disabled plugin
* REST endpoints + UI panel reachable
Contract deviation: admin toggle endpoints moved from
/api/plugins/{name}/{enable,disable} to /api/admin/plugins/{name}/{...}
to avoid URL collision with chi per-plugin route mounts. rest.md updated.
Scope deferred to next session (mechanical follow-ups):
- Port internal/wiki/ to internal/plugins/wiki/
- Squash 26 migrations to schema/000_initial.sql
- Backup scripts for live kubic instance
- Remaining 9 plugin extractions
- Boundary-lint static analyzer
- Wire into cmd/synapbus/main.go
All unit + integration tests green. Chrome UI smoke test passes.
autonomous_summary.md carries the full verification record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
5.8 KiB
Go
222 lines
5.8 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// Registry is the core-owned catalog of compiled-in plugins.
|
|
// One Registry is constructed per process from (list of plugins, config).
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
|
|
plugins map[string]Plugin
|
|
enabled map[string]bool
|
|
configs map[string]json.RawMessage
|
|
order []string
|
|
statuses *StatusStore
|
|
|
|
// Capability indexes — populated during Init.
|
|
mcpTools map[string]MCPTool
|
|
actions map[string]ActionRegistration
|
|
panels []PanelManifest
|
|
panelHandlers map[string]http.Handler
|
|
channelTypes map[string]ChannelTypeDef
|
|
eventSubs []HasEventHook
|
|
cliCommands []CLICommand
|
|
routeMounts []routeMount
|
|
}
|
|
|
|
type routeMount struct {
|
|
Plugin string
|
|
Setup func(Router)
|
|
}
|
|
|
|
// NewRegistry builds a registry from an explicit plugin list and a config.
|
|
// Returns an error if any plugin name is invalid or duplicated.
|
|
func NewRegistry(plugins []Plugin, cfg *FileConfig) (*Registry, error) {
|
|
r := &Registry{
|
|
plugins: map[string]Plugin{},
|
|
enabled: map[string]bool{},
|
|
configs: map[string]json.RawMessage{},
|
|
statuses: NewStatusStore(),
|
|
mcpTools: map[string]MCPTool{},
|
|
actions: map[string]ActionRegistration{},
|
|
panelHandlers: map[string]http.Handler{},
|
|
channelTypes: map[string]ChannelTypeDef{},
|
|
}
|
|
for _, p := range plugins {
|
|
name := p.Name()
|
|
if !ValidateName(name) {
|
|
return nil, fmt.Errorf("plugin %q: name must match ^[a-z][a-z0-9_]{1,31}$", name)
|
|
}
|
|
if _, exists := r.plugins[name]; exists {
|
|
return nil, fmt.Errorf("plugin %q: duplicate registration", name)
|
|
}
|
|
r.plugins[name] = p
|
|
r.order = append(r.order, name)
|
|
if cfg != nil {
|
|
r.enabled[name] = cfg.IsEnabled(name)
|
|
r.configs[name] = cfg.ConfigFor(name)
|
|
} else {
|
|
r.configs[name] = json.RawMessage("{}")
|
|
}
|
|
stab := "stable"
|
|
if hs, ok := p.(HasStability); ok {
|
|
if s := hs.Stability(); s != "" {
|
|
stab = s
|
|
}
|
|
}
|
|
st := StatusRegistered
|
|
if !r.enabled[name] {
|
|
st = StatusDisabled
|
|
}
|
|
r.statuses.Set(name, StatusEntry{
|
|
Name: name, Version: p.Version(), Stability: stab,
|
|
Enabled: r.enabled[name], Status: st,
|
|
})
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// Status returns the status store (for /api/plugins/status).
|
|
func (r *Registry) Status() *StatusStore { return r.statuses }
|
|
|
|
// All returns plugin names in registration order.
|
|
func (r *Registry) All() []string {
|
|
out := make([]string, len(r.order))
|
|
copy(out, r.order)
|
|
return out
|
|
}
|
|
|
|
// Enabled returns the names of enabled plugins in registration order.
|
|
func (r *Registry) Enabled() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := []string{}
|
|
for _, n := range r.order {
|
|
if r.enabled[n] {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Plugin returns the named plugin, or nil.
|
|
func (r *Registry) Plugin(name string) Plugin {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.plugins[name]
|
|
}
|
|
|
|
// --- Capability accessors ---
|
|
|
|
// MCPTool returns the registered MCP tool with the given name.
|
|
func (r *Registry) MCPTool(name string) (MCPTool, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
t, ok := r.mcpTools[name]
|
|
return t, ok
|
|
}
|
|
|
|
// Action returns the registered action with the given name.
|
|
func (r *Registry) Action(name string) (ActionRegistration, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
a, ok := r.actions[name]
|
|
return a, ok
|
|
}
|
|
|
|
// Actions returns a sorted list of all registered action names.
|
|
func (r *Registry) Actions() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]string, 0, len(r.actions))
|
|
for k := range r.actions {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// MCPTools returns a sorted list of all registered MCP tool names.
|
|
func (r *Registry) MCPTools() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]string, 0, len(r.mcpTools))
|
|
for k := range r.mcpTools {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// Panels returns all registered panel manifests in registration order.
|
|
func (r *Registry) Panels() []PanelManifest {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]PanelManifest, len(r.panels))
|
|
copy(out, r.panels)
|
|
return out
|
|
}
|
|
|
|
// PanelHandler returns the HTTP handler for a plugin's UI panel, or nil.
|
|
func (r *Registry) PanelHandler(plugin string) http.Handler {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.panelHandlers[plugin]
|
|
}
|
|
|
|
// ChannelType returns the registered channel type, or zero value + false.
|
|
func (r *Registry) ChannelType(name string) (ChannelTypeDef, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
t, ok := r.channelTypes[name]
|
|
return t, ok
|
|
}
|
|
|
|
// EventSubscribers returns all plugins that opted in to event delivery.
|
|
// Intended for the event bus wiring.
|
|
func (r *Registry) EventSubscribers() []HasEventHook {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]HasEventHook, len(r.eventSubs))
|
|
copy(out, r.eventSubs)
|
|
return out
|
|
}
|
|
|
|
// CLICommands returns all registered CLI commands across plugins.
|
|
func (r *Registry) CLICommands() []CLICommand {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]CLICommand, len(r.cliCommands))
|
|
copy(out, r.cliCommands)
|
|
return out
|
|
}
|
|
|
|
// RouteMounts returns registered route-mount configs; the caller is expected
|
|
// to hand each one a chi.Router scoped to `/api/plugins/<name>/`.
|
|
func (r *Registry) RouteMounts() []routeMount {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]routeMount, len(r.routeMounts))
|
|
copy(out, r.routeMounts)
|
|
return out
|
|
}
|
|
|
|
// CallAction invokes a registered action with args, returning its result.
|
|
// Returns a "not registered" error if the action name is unknown.
|
|
// Note: scope enforcement is the responsibility of the caller (the MCP bridge),
|
|
// which has access to the token context.
|
|
func (r *Registry) CallAction(ctx context.Context, name string, args map[string]any) (any, error) {
|
|
a, ok := r.Action(name)
|
|
if !ok {
|
|
return nil, fmt.Errorf("action %q: not registered", name)
|
|
}
|
|
return a.Handler(ctx, args)
|
|
}
|