Files
synapbus/internal/plugin/host.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

136 lines
3.9 KiB
Go

package plugin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
)
// --- Ambient service interfaces, defined here so this package stays
// --- independent of any specific core implementation. Core packages supply
// --- concrete implementations; tests use in-package nop stubs.
// Owner identifies the human user who owns the default agent.
// Used by the failure-notification path.
type Owner struct {
ID int64
Username string
Email string
}
// Messenger is the subset of the messaging API that plugins need.
type Messenger interface {
SendDM(ctx context.Context, toAgent, body string, priority int) error
}
// Channels is the subset of the channels API that plugins need.
type Channels interface {
Post(ctx context.Context, channel, body string) (int64, error)
}
// Attachments is the subset of the attachment store.
type Attachments interface {
Put(ctx context.Context, r []byte, mime string) (hash string, err error)
Get(ctx context.Context, hash string) ([]byte, string, error)
}
// Search is a read-only view of the core search index.
type Search interface {
Query(ctx context.Context, text string, limit int) ([]SearchHit, error)
}
type SearchHit struct {
MessageID int64
Score float64
Excerpt string
}
// ErrSecretNotFound is returned when a scoped secret lookup fails.
// Cross-plugin access also returns this error to avoid leaking scope info.
var ErrSecretNotFound = errors.New("secret not found")
// Secrets is a plugin-scoped secret accessor.
// Calling Get with a name not owned by the calling plugin returns
// ErrSecretNotFound, identical to a truly missing secret.
type Secrets interface {
Get(ctx context.Context, name string) ([]byte, error)
Set(ctx context.Context, name string, value []byte) error
}
// Events is the internal event bus.
type Events interface {
Publish(ctx context.Context, e Event) error
Subscribe(topic string, fn func(ctx context.Context, e Event) error) (cancel func())
}
// Metrics is the subset of prometheus.Registerer the plugin uses.
// Kept as any to avoid forcing a prom dep on the plugin package.
type Metrics interface {
Register(c any) error
}
// Tracer is the subset of otel/trace.Tracer used by plugins.
type Tracer interface {
Start(ctx context.Context, name string) (context.Context, TraceSpan)
}
type TraceSpan interface {
End()
SetAttribute(key string, value any)
}
// Host is the bundle of core services handed to a plugin at Init.
// Plugins MUST treat it as read-only and MUST NOT share it between
// goroutines beyond what its embedded services allow.
type Host struct {
Logger *slog.Logger
DB *sql.DB
Messenger Messenger
Channels Channels
Attachments Attachments
Search Search
Secrets Secrets
Events Events
Config json.RawMessage
DataDir string
Tracer Tracer
Metrics Metrics
DefaultOwner *Owner
// BaseURL is the externally-reachable URL of the SynapBus instance,
// useful when a plugin serves HTML that contains absolute links.
BaseURL string
}
// ExecTx runs fn inside a database transaction. Returns the fn's error or
// any begin/commit error. Rolls back automatically if fn errors.
func (h Host) ExecTx(ctx context.Context, fn func(*sql.Tx) error) error {
if h.DB == nil {
return errors.New("plugin.Host: DB is nil")
}
tx, err := h.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// ensureDataDir makes sure the per-plugin data directory exists with 0700 perms.
// Called by the registry before Init; exposed here for plugintest.
func ensureDataDir(path string) error {
if err := os.MkdirAll(path, 0o700); err != nil {
return err
}
return os.Chmod(path, 0o700)
}
// joinDataDir is a helper for constructing per-plugin sub-paths.
func joinDataDir(base, plugin string) string { return filepath.Join(base, "plugins", plugin) }