Files
synapbus/internal/plugin/plugin.go
T
Algis DumbrisandClaude Opus 4.7 494e8f83e9 feat(plugin): plugin framework + plugintest + demo plugin + integration tests
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>
2026-04-19 07:14:27 +03:00

167 lines
4.9 KiB
Go

// Package plugin provides the compile-in plugin framework for SynapBus.
//
// Plugins implement the minimal Plugin interface and optionally one or more
// HasX capability sub-interfaces. The registry constructs a Host for each
// plugin at Init time, applies migrations, wires capabilities, and drives
// the three-phase lifecycle (Migrate -> Init -> Start).
package plugin
import (
"context"
"encoding/json"
"net/http"
"regexp"
)
// Plugin is the minimum contract that every compiled-in plugin implements.
type Plugin interface {
// Name returns the plugin's globally unique identifier.
// Must match ^[a-z][a-z0-9_]{1,31}$.
Name() string
// Version returns the plugin's semver version.
Version() string
// Init is called once after migrations have applied. The plugin should
// register capabilities via the returned capability interfaces and may
// store the host handle for later use (e.g. in Handler funcs).
Init(ctx context.Context, host Host) error
}
// Scope controls who can invoke a bridged action.
type Scope string
const (
ScopeRead Scope = "read"
ScopeWrite Scope = "write"
ScopeAdmin Scope = "admin"
)
// Migration is a single numbered schema change owned by one plugin.
type Migration struct {
Version int // 1..N, monotonic within the plugin
Name string // "001_initial"
SQL string // single file, runs in one transaction
}
// ActionRegistration is a bridged action exposed via the core execute() tool.
type ActionRegistration struct {
Name string
Description string
InputSchema json.RawMessage
RequiredScope Scope
Handler func(ctx context.Context, args map[string]any) (any, error)
}
// MCPTool is a first-class MCP tool contributed by a plugin.
type MCPTool struct {
Name string
Description string
InputSchema json.RawMessage
Handler func(ctx context.Context, args map[string]any) (any, error)
}
// PanelManifest describes a Web UI panel contributed by a plugin.
type PanelManifest struct {
ID string // "wiki"
Title string // "Wiki"
Icon string // lucide-icon name
Route string // "/ui/plugins/wiki"
Scope string // "owner" | "member"
}
// ChannelTypeDef registers a new channel type with optional hooks.
type ChannelTypeDef struct {
Name string
OnMessage func(ctx context.Context, channelID, msgID int64) error
OnReaction func(ctx context.Context, channelID, msgID int64, reaction string) error
}
// Event is a payload delivered to plugins that implement HasEventHook.
type Event struct {
Topic string
Payload any
Meta map[string]string
}
// Router is the minimal subset of go-chi/chi.Router that plugins need.
// Declared locally to keep the plugin package dependency-free.
type Router interface {
Handle(pattern string, h http.Handler)
Method(method, pattern string, h http.Handler)
Get(pattern string, h http.HandlerFunc)
Post(pattern string, h http.HandlerFunc)
Put(pattern string, h http.HandlerFunc)
Delete(pattern string, h http.HandlerFunc)
}
// CLICommand is the minimal subset of spf13/cobra.Command that plugins need.
// Declared as a generic value so cobra does not leak into this package.
type CLICommand interface {
Use() string
Execute() error
}
// --- Optional capability interfaces ---
// HasMigrations: plugin owns a numbered chain of SQL migrations.
type HasMigrations interface {
Migrations() []Migration
}
// HasMCPTools: plugin adds first-class MCP tools.
type HasMCPTools interface {
MCPTools() []MCPTool
}
// HasActions: plugin adds bridged actions callable via the core execute() tool.
type HasActions interface {
Actions() []ActionRegistration
}
// HasHTTPRoutes: plugin mounts REST routes under /api/plugins/<name>/*.
type HasHTTPRoutes interface {
RegisterRoutes(r Router)
}
// HasWebPanels: plugin contributes one or more UI panels served under /ui/plugins/<name>/*.
type HasWebPanels interface {
WebPanels() []PanelManifest
PanelHandler() http.Handler
}
// HasCLICommands: plugin adds subcommands under `synapbus plugin <name>`.
type HasCLICommands interface {
CLICommands() []CLICommand
}
// HasChannelType: plugin defines a channel behavior.
type HasChannelType interface {
ChannelTypes() []ChannelTypeDef
}
// HasEventHook: plugin subscribes to internal events.
type HasEventHook interface {
OnEvent(ctx context.Context, e Event) error
}
// HasLifecycle: plugin runs background work and needs explicit start/shutdown.
type HasLifecycle interface {
Start(ctx context.Context) error
Shutdown(ctx context.Context) error
}
// HasConfigSchema: plugin publishes a JSON Schema for its config.
type HasConfigSchema interface {
ConfigSchema() json.RawMessage
}
// HasStability: plugin declares stability level. Default: "stable".
type HasStability interface {
Stability() string
}
// nameRE enforces the plugin name pattern. Exported via ValidateName below.
var nameRE = regexp.MustCompile(`^[a-z][a-z0-9_]{1,31}$`)
// ValidateName reports whether s is a syntactically valid plugin name.
func ValidateName(s string) bool { return nameRE.MatchString(s) }