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>
98 lines
2.4 KiB
Go
98 lines
2.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Restarter triggers a graceful restart of the host process.
|
|
// The interface keeps the plugin package independent of cloudflare/tableflip;
|
|
// the cmd/ layer provides a tableflip-backed implementation, while tests
|
|
// and non-restart paths use NoopRestarter.
|
|
type Restarter interface {
|
|
// TriggerRestart initiates a graceful restart. The method returns
|
|
// immediately; the actual restart happens asynchronously. Returns an
|
|
// error only if the request could not be queued.
|
|
TriggerRestart() error
|
|
}
|
|
|
|
// NoopRestarter does nothing; useful for tests and for CLI tools that do not
|
|
// run a long-lived HTTP server.
|
|
type NoopRestarter struct {
|
|
mu sync.Mutex
|
|
called int
|
|
}
|
|
|
|
func (n *NoopRestarter) TriggerRestart() error {
|
|
n.mu.Lock()
|
|
n.called++
|
|
n.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (n *NoopRestarter) CallCount() int {
|
|
n.mu.Lock()
|
|
defer n.mu.Unlock()
|
|
return n.called
|
|
}
|
|
|
|
// SignalRestarter sends SIGHUP to the current process. Useful when the real
|
|
// restart is implemented elsewhere (e.g. by a supervisor, tableflip upgrader,
|
|
// or systemd socket-activated re-exec) and we just need to raise the signal.
|
|
type SignalRestarter struct{}
|
|
|
|
func (SignalRestarter) TriggerRestart() error {
|
|
proc, err := os.FindProcess(os.Getpid())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return proc.Signal(syscall.SIGHUP)
|
|
}
|
|
|
|
// WatchSignals blocks until the given signals fire, invokes fn, and returns.
|
|
// If the context cancels first, it returns ctx.Err().
|
|
//
|
|
// Typical use from main.go:
|
|
//
|
|
// plugin.WatchSignals(ctx, func(sig os.Signal) {
|
|
// if sig == syscall.SIGHUP { upg.Upgrade() }
|
|
// }, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
|
|
func WatchSignals(ctx context.Context, fn func(os.Signal), sigs ...os.Signal) error {
|
|
if len(sigs) == 0 {
|
|
return errors.New("WatchSignals: at least one signal required")
|
|
}
|
|
ch := make(chan os.Signal, 1)
|
|
signal.Notify(ch, sigs...)
|
|
defer signal.Stop(ch)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case s := <-ch:
|
|
fn(s)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// DrainAndExit is a helper for the graceful-restart path. It waits up to
|
|
// timeout for fn to return, then exits with the given code.
|
|
func DrainAndExit(timeout time.Duration, fn func(), code int) {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
fn()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
slog.Info("drain complete")
|
|
case <-time.After(timeout):
|
|
slog.Warn("drain timed out", "timeout", timeout)
|
|
}
|
|
os.Exit(code)
|
|
}
|