Files
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

191 lines
5.2 KiB
Go

package plugin
import (
"encoding/json"
"fmt"
"os"
"go.yaml.in/yaml/v3"
)
// PluginConfig is the per-plugin YAML sub-tree.
// Config is stored as a free-form decoded value (yaml.v3 hands us back
// map[string]any); it is converted to json.RawMessage on demand via
// ConfigFor, so plugins can unmarshal into their own typed struct.
type PluginConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Config map[string]any `yaml:"config" json:"config,omitempty"`
}
// FileConfig is the root shape of synapbus.yaml. Only the plugins sub-tree
// is managed by this package; core configuration lives elsewhere and is
// preserved during round-trip rewrites.
type FileConfig struct {
Plugins map[string]PluginConfig `yaml:"plugins" json:"plugins"`
// rawRoot preserves the full YAML document so enable/disable edits
// can round-trip without losing unrelated keys.
rawRoot map[string]any `yaml:"-" json:"-"`
}
// LoadConfig reads and parses synapbus.yaml. If path is empty, the
// environment variable SYNAPBUS_CONFIG_PATH is consulted, falling back
// to ./synapbus.yaml. A non-existent file is not an error — it returns
// an empty config (all plugins default to disabled).
func LoadConfig(path string) (*FileConfig, error) {
if path == "" {
path = os.Getenv("SYNAPBUS_CONFIG_PATH")
}
if path == "" {
path = "synapbus.yaml"
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &FileConfig{Plugins: map[string]PluginConfig{}, rawRoot: map[string]any{}}, nil
}
return nil, fmt.Errorf("read config: %w", err)
}
// Decode into a raw map first so we can preserve unknown top-level keys.
var raw map[string]any
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if raw == nil {
raw = map[string]any{}
}
// Decode plugins sub-tree into strongly-typed form. yaml.v3 produces
// map[string]any for nested maps; we walk and normalize manually so
// keys always end up as strings.
out := &FileConfig{Plugins: map[string]PluginConfig{}, rawRoot: raw}
if rawPlugins, ok := raw["plugins"]; ok {
pm, ok := rawPlugins.(map[string]any)
if !ok {
// yaml.v3 uses map[any]any in some cases; normalize.
pm = coerceStringKeyed(rawPlugins)
}
for name, entry := range pm {
m, ok := entry.(map[string]any)
if !ok {
m = coerceStringKeyed(entry)
}
pc := PluginConfig{}
if v, ok := m["enabled"]; ok {
if b, ok := v.(bool); ok {
pc.Enabled = b
}
}
if v, ok := m["config"]; ok {
cm, ok := v.(map[string]any)
if !ok {
cm = coerceStringKeyed(v)
}
pc.Config = cm
}
out.Plugins[name] = pc
}
}
return out, nil
}
// coerceStringKeyed converts a value that yaml.v3 produced as map[any]any
// (due to non-string scalar keys in YAML) into map[string]any, best-effort.
// Returns nil if the value isn't a map.
func coerceStringKeyed(v any) map[string]any {
if v == nil {
return nil
}
if m, ok := v.(map[string]any); ok {
return m
}
m2, ok := v.(map[any]any)
if !ok {
return nil
}
out := map[string]any{}
for k, vv := range m2 {
ks, ok := k.(string)
if !ok {
continue
}
out[ks] = vv
}
return out
}
// IsEnabled returns whether the named plugin is enabled per the config.
// Plugins not listed are considered disabled.
func (c *FileConfig) IsEnabled(name string) bool {
if c == nil {
return false
}
pc, ok := c.Plugins[name]
return ok && pc.Enabled
}
// ConfigFor returns the raw config blob for the named plugin (never nil).
func (c *FileConfig) ConfigFor(name string) json.RawMessage {
if c == nil {
return json.RawMessage("{}")
}
pc, ok := c.Plugins[name]
if !ok || len(pc.Config) == 0 {
return json.RawMessage("{}")
}
raw, err := json.Marshal(pc.Config)
if err != nil {
return json.RawMessage("{}")
}
return raw
}
// SetEnabled flips the enabled flag for the named plugin. If the plugin is
// not present, an entry is created with Enabled=enabled.
func (c *FileConfig) SetEnabled(name string, enabled bool) {
pc := c.Plugins[name]
pc.Enabled = enabled
c.Plugins[name] = pc
// Mirror into rawRoot for round-trip serialization.
pluginsRaw, _ := c.rawRoot["plugins"].(map[string]any)
if pluginsRaw == nil {
pluginsRaw = map[string]any{}
}
entry, _ := pluginsRaw[name].(map[string]any)
if entry == nil {
entry = map[string]any{}
}
entry["enabled"] = enabled
pluginsRaw[name] = entry
c.rawRoot["plugins"] = pluginsRaw
}
// Save writes the config back to path, preserving unknown top-level keys.
func (c *FileConfig) Save(path string) error {
if path == "" {
path = os.Getenv("SYNAPBUS_CONFIG_PATH")
}
if path == "" {
path = "synapbus.yaml"
}
data, err := yaml.Marshal(c.rawRoot)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
// Atomic write: write to tmp, rename into place.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return fmt.Errorf("write tmp config: %w", err)
}
return os.Rename(tmp, path)
}
// ValidatePluginNames checks that each plugin entry in the config has a
// syntactically valid name.
func (c *FileConfig) ValidatePluginNames() error {
for name := range c.Plugins {
if !ValidateName(name) {
return fmt.Errorf("invalid plugin name %q (must match ^[a-z][a-z0-9_]{1,31}$)", name)
}
}
return nil
}