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>
251 lines
7.3 KiB
Go
251 lines
7.3 KiB
Go
package plugin_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/synapbus/synapbus/internal/plugin"
|
|
"github.com/synapbus/synapbus/internal/plugin/plugintest"
|
|
)
|
|
|
|
// --- helpers ---
|
|
|
|
func nopHostFactory(t *testing.T, db *sql.DB) func(string, plugin.CapabilityContext) plugin.Host {
|
|
t.Helper()
|
|
return func(name string, _ plugin.CapabilityContext) plugin.Host {
|
|
return plugin.Host{
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)).With("plugin", name),
|
|
DB: db,
|
|
Events: plugin.NewEventBus(),
|
|
Config: json.RawMessage(`{}`),
|
|
DataDir: t.TempDir(),
|
|
Secrets: plugintest.NewScopedSecrets(name),
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- test doubles ---
|
|
|
|
type capabilityPlugin struct {
|
|
name string
|
|
actions []string
|
|
onInit func(context.Context, plugin.Host) error
|
|
onStart func(context.Context) error
|
|
}
|
|
|
|
func (p *capabilityPlugin) Name() string { return p.name }
|
|
func (p *capabilityPlugin) Version() string { return "0.1.0" }
|
|
func (p *capabilityPlugin) Init(ctx context.Context, host plugin.Host) error {
|
|
if p.onInit != nil {
|
|
return p.onInit(ctx, host)
|
|
}
|
|
return nil
|
|
}
|
|
func (p *capabilityPlugin) Migrations() []plugin.Migration {
|
|
return []plugin.Migration{{
|
|
Version: 1, Name: "001_initial",
|
|
SQL: `CREATE TABLE IF NOT EXISTS plugin_` + p.name + `_t (id INTEGER);`,
|
|
}}
|
|
}
|
|
func (p *capabilityPlugin) Actions() []plugin.ActionRegistration {
|
|
out := make([]plugin.ActionRegistration, 0, len(p.actions))
|
|
for _, name := range p.actions {
|
|
n := name
|
|
out = append(out, plugin.ActionRegistration{
|
|
Name: n,
|
|
RequiredScope: plugin.ScopeRead,
|
|
Handler: func(ctx context.Context, args map[string]any) (any, error) {
|
|
return map[string]string{"from": p.name, "action": n}, nil
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
func (p *capabilityPlugin) RegisterRoutes(r plugin.Router) {
|
|
r.Get("/ping", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"plugin":"` + p.name + `","ok":true}`))
|
|
})
|
|
}
|
|
func (p *capabilityPlugin) WebPanels() []plugin.PanelManifest {
|
|
return []plugin.PanelManifest{{ID: p.name, Title: p.name, Route: "/ui/plugins/" + p.name, Scope: "member"}}
|
|
}
|
|
func (p *capabilityPlugin) PanelHandler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("<h1>" + p.name + "</h1>"))
|
|
})
|
|
}
|
|
func (p *capabilityPlugin) Start(ctx context.Context) error {
|
|
if p.onStart != nil {
|
|
return p.onStart(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
func (p *capabilityPlugin) Shutdown(ctx context.Context) error { return nil }
|
|
|
|
// --- tests ---
|
|
|
|
func TestInitAll_HappyPath(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
reg, err := plugin.NewRegistry(
|
|
[]plugin.Plugin{
|
|
&capabilityPlugin{name: "alpha", actions: []string{"alpha_say"}},
|
|
&capabilityPlugin{name: "beta", actions: []string{"beta_hello"}},
|
|
},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{
|
|
"alpha": {Enabled: true},
|
|
"beta": {Enabled: true},
|
|
}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewRegistry: %v", err)
|
|
}
|
|
if err := reg.InitAll(context.Background(), nopHostFactory(t, db)); err != nil {
|
|
t.Fatalf("InitAll: %v", err)
|
|
}
|
|
plugintest.HasAction(t, reg, "alpha_say")
|
|
plugintest.HasAction(t, reg, "beta_hello")
|
|
plugintest.PluginStarted(t, reg, "alpha")
|
|
plugintest.PluginStarted(t, reg, "beta")
|
|
plugintest.HasMigration(t, db, "alpha", 1)
|
|
plugintest.HasMigration(t, db, "beta", 1)
|
|
plugintest.HasPanel(t, reg, "alpha")
|
|
plugintest.HasPanel(t, reg, "beta")
|
|
}
|
|
|
|
func TestInitAll_FailurePerPluginIsolated(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
reg, err := plugin.NewRegistry(
|
|
[]plugin.Plugin{
|
|
&capabilityPlugin{name: "good", actions: []string{"good_hi"}},
|
|
&capabilityPlugin{
|
|
name: "bad", actions: []string{"bad_hi"},
|
|
onInit: func(context.Context, plugin.Host) error { return errors.New("boom") },
|
|
},
|
|
},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{
|
|
"good": {Enabled: true},
|
|
"bad": {Enabled: true},
|
|
}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewRegistry: %v", err)
|
|
}
|
|
_ = reg.InitAll(context.Background(), nopHostFactory(t, db))
|
|
|
|
plugintest.PluginStarted(t, reg, "good")
|
|
plugintest.HasAction(t, reg, "good_hi")
|
|
plugintest.PluginFailed(t, reg, "bad")
|
|
if _, ok := reg.Action("bad_hi"); ok {
|
|
t.Fatalf("failed plugin's action must not be registered")
|
|
}
|
|
}
|
|
|
|
func TestInitAll_PanicIsolated(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
reg, _ := plugin.NewRegistry(
|
|
[]plugin.Plugin{
|
|
&capabilityPlugin{name: "good", actions: []string{"ok"}},
|
|
&capabilityPlugin{
|
|
name: "panicky", actions: []string{"never"},
|
|
onInit: func(context.Context, plugin.Host) error { panic("bad") },
|
|
},
|
|
},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{
|
|
"good": {Enabled: true}, "panicky": {Enabled: true},
|
|
}},
|
|
)
|
|
_ = reg.InitAll(context.Background(), nopHostFactory(t, db))
|
|
plugintest.PluginStarted(t, reg, "good")
|
|
plugintest.PluginFailed(t, reg, "panicky")
|
|
}
|
|
|
|
func TestInitAll_DisabledPluginRegistersNothing(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
reg, _ := plugin.NewRegistry(
|
|
[]plugin.Plugin{&capabilityPlugin{name: "off", actions: []string{"off_hi"}}},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{"off": {Enabled: false}}},
|
|
)
|
|
_ = reg.InitAll(context.Background(), nopHostFactory(t, db))
|
|
if _, ok := reg.Action("off_hi"); ok {
|
|
t.Fatalf("disabled plugin must not register actions")
|
|
}
|
|
s, _ := reg.Status().Get("off")
|
|
if s.Status != plugin.StatusDisabled {
|
|
t.Fatalf("expected status disabled, got %q", s.Status)
|
|
}
|
|
}
|
|
|
|
func TestInitAll_RouteMountsAreRegistered(t *testing.T) {
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
reg, _ := plugin.NewRegistry(
|
|
[]plugin.Plugin{&capabilityPlugin{name: "routes", actions: []string{"r"}}},
|
|
&plugin.FileConfig{Plugins: map[string]plugin.PluginConfig{"routes": {Enabled: true}}},
|
|
)
|
|
_ = reg.InitAll(context.Background(), nopHostFactory(t, db))
|
|
mounts := reg.RouteMounts()
|
|
if len(mounts) != 1 {
|
|
t.Fatalf("expected 1 route mount, got %d", len(mounts))
|
|
}
|
|
// Serve through a stub router to verify wiring.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
|
router := newStubRouter()
|
|
for _, m := range mounts {
|
|
m.Setup(router)
|
|
}
|
|
router.routes["GET /ping"](rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// stubRouter is a tiny plugin.Router implementation for tests.
|
|
type stubRouter struct{ routes map[string]http.HandlerFunc }
|
|
|
|
func newStubRouter() *stubRouter { return &stubRouter{routes: map[string]http.HandlerFunc{}} }
|
|
|
|
func (s *stubRouter) Handle(p string, h http.Handler) {
|
|
s.routes["ANY "+p] = h.ServeHTTP
|
|
}
|
|
func (s *stubRouter) Method(m, p string, h http.Handler) {
|
|
s.routes[m+" "+p] = h.ServeHTTP
|
|
}
|
|
func (s *stubRouter) Get(p string, h http.HandlerFunc) { s.routes["GET "+p] = h }
|
|
func (s *stubRouter) Post(p string, h http.HandlerFunc) { s.routes["POST "+p] = h }
|
|
func (s *stubRouter) Put(p string, h http.HandlerFunc) { s.routes["PUT "+p] = h }
|
|
func (s *stubRouter) Delete(p string, h http.HandlerFunc) { s.routes["DELETE "+p] = h }
|