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>
166 lines
5.0 KiB
Go
166 lines
5.0 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const createPluginMigrationsTable = `
|
|
CREATE TABLE IF NOT EXISTS plugin_migrations (
|
|
plugin TEXT NOT NULL,
|
|
version INTEGER NOT NULL,
|
|
name TEXT NOT NULL,
|
|
checksum TEXT NOT NULL,
|
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (plugin, version)
|
|
);`
|
|
|
|
// tablePrefixRE matches any CREATE TABLE or ALTER TABLE referring to a table
|
|
// not prefixed with plugin_<name>_ or the allow-listed plugin_migrations.
|
|
var createTableRE = regexp.MustCompile(`(?i)CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z_][a-zA-Z0-9_]*)`)
|
|
|
|
// MigrationResult is a record of what was applied for a plugin.
|
|
type MigrationResult struct {
|
|
Plugin string
|
|
Applied []int // versions newly applied this run
|
|
}
|
|
|
|
// ApplyMigrations runs all unapplied migrations for the given plugin.
|
|
// Migrations for a plugin are applied inside one transaction per version.
|
|
// A previously-applied migration whose SQL changed (checksum mismatch)
|
|
// is refused.
|
|
func ApplyMigrations(ctx context.Context, db *sql.DB, p Plugin) (MigrationResult, error) {
|
|
out := MigrationResult{Plugin: p.Name()}
|
|
hm, ok := p.(HasMigrations)
|
|
if !ok {
|
|
return out, nil
|
|
}
|
|
if _, err := db.ExecContext(ctx, createPluginMigrationsTable); err != nil {
|
|
return out, fmt.Errorf("create plugin_migrations table: %w", err)
|
|
}
|
|
// Collect already-applied versions + checksums.
|
|
applied := map[int]string{}
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT version, checksum FROM plugin_migrations WHERE plugin = ?`, p.Name())
|
|
if err != nil {
|
|
return out, fmt.Errorf("load applied migrations: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var v int
|
|
var sum string
|
|
if err := rows.Scan(&v, &sum); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
applied[v] = sum
|
|
}
|
|
rows.Close()
|
|
|
|
migs := hm.Migrations()
|
|
// Guard: enforce table prefix. Also: allow FTS virtual tables and indexes;
|
|
// reject only CREATE TABLE referencing names outside plugin_<name>_*.
|
|
for _, m := range migs {
|
|
if err := validateMigrationSQL(p.Name(), m); err != nil {
|
|
return out, err
|
|
}
|
|
}
|
|
// Sort by version for deterministic apply order.
|
|
sortMigrations(migs)
|
|
for _, m := range migs {
|
|
sum := checksumSQL(m.SQL)
|
|
if priorSum, wasApplied := applied[m.Version]; wasApplied {
|
|
if priorSum != sum {
|
|
return out, fmt.Errorf(
|
|
"plugin %q migration %d (%s): checksum mismatch (was %s, now %s). "+
|
|
"Migrations are immutable once applied",
|
|
p.Name(), m.Version, m.Name, priorSum, sum)
|
|
}
|
|
continue // already applied, no-op
|
|
}
|
|
if err := applyOne(ctx, db, p.Name(), m, sum); err != nil {
|
|
return out, err
|
|
}
|
|
out.Applied = append(out.Applied, m.Version)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func applyOne(ctx context.Context, db *sql.DB, plugin string, m Migration, checksum string) error {
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx for %s:%d: %w", plugin, m.Version, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("apply %s:%d (%s): %w", plugin, m.Version, m.Name, err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO plugin_migrations (plugin, version, name, checksum) VALUES (?, ?, ?, ?)`,
|
|
plugin, m.Version, m.Name, checksum,
|
|
); err != nil {
|
|
_ = tx.Rollback()
|
|
return fmt.Errorf("record %s:%d: %w", plugin, m.Version, err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func checksumSQL(s string) string {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(s)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func sortMigrations(ms []Migration) {
|
|
// tiny insertion sort; plugin chains are short
|
|
for i := 1; i < len(ms); i++ {
|
|
for j := i; j > 0 && ms[j-1].Version > ms[j].Version; j-- {
|
|
ms[j-1], ms[j] = ms[j], ms[j-1]
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateMigrationSQL rejects CREATE TABLE statements referring to names
|
|
// not prefixed with plugin_<name>_. Indexes, views, triggers, and virtual-
|
|
// table clauses are not enforced here; they are expected to reference
|
|
// plugin-owned tables anyway.
|
|
func validateMigrationSQL(pluginName string, m Migration) error {
|
|
prefix := "plugin_" + pluginName + "_"
|
|
for _, match := range createTableRE.FindAllStringSubmatch(m.SQL, -1) {
|
|
tbl := strings.ToLower(match[1])
|
|
// Allow the core table; no plugin should try to create it but
|
|
// we tolerate being defensive.
|
|
if tbl == "plugin_migrations" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(tbl, strings.ToLower(prefix)) {
|
|
return fmt.Errorf(
|
|
"plugin %q migration %d (%s): table %q must be prefixed %q",
|
|
pluginName, m.Version, m.Name, tbl, prefix)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppliedVersions returns the migration versions already recorded for the plugin.
|
|
func AppliedVersions(ctx context.Context, db *sql.DB, plugin string) ([]int, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT version FROM plugin_migrations WHERE plugin = ? ORDER BY version`, plugin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []int
|
|
for rows.Next() {
|
|
var v int
|
|
if err := rows.Scan(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|