- Add internal/plugins package for custom skill plugins
- Implement SkillPlugin interface for extensibility
- Implement PluginRegistry for plugin management
- Support plugin:// URL scheme for plugin references
- Register/unregister plugins dynamically
- Enable/disable plugin control
- Execution logging with timing metrics
- Plugin metadata tracking (version, author, config)
- PluginLoader for lifecycle management
- Load plugins from files and directories
- Reload plugins without restart
- Statistics tracking (executions, success rate)
- 48 plugin tests, all passing
Features:
- SkillPlugin interface (Name, Version, Execute, Validate, Description)
- PluginRegistry for central registration and execution
- plugin:// URL scheme for plugin references
- Dynamic loading from JSON config files
- Plugin enable/disable control
- Execution history tracking
- Timing metrics for performance monitoring
- Configuration storage per plugin
- Metadata tracking (version, author, description)
- Plugin statistics (total runs, success rate, avg time)
Registry Operations:
- Register(plugin, author, config) - register new plugin
- Unregister(name) - remove plugin
- Execute(name, input) - execute by name
- Get(name) - retrieve plugin reference
- ListPlugins() - enumerate all plugins
- EnablePlugin(name) / DisablePlugin(name)
- GetExecutionLog(name) - timing and result history
- ResolvePluginURL(url) - resolve plugin:// URLs
Loader Operations:
- RegisterLoadedPlugin() - add to registry
- UnloadPlugin() - remove from registry
- ReloadPlugin() - reinitialize without restart
- LoadPluginDirectory() - batch load from directory
- ExecutePlugin() - execute through loader
- GetLoadedPlugins() - enumerate loaded
- IsPluginLoaded() - check status
- Close() - shutdown all plugins
URL Scheme:
- plugin://plugin-name - reference custom plugin
- Enables flexible skill resolution
- Supports custom activities beyond pi clone
Plugin Metadata:
- Name, Version, Author
- Description, URL, Config
- LoadedAt timestamp, Enabled flag
- Config is arbitrary map[string]interface{}
Execution Tracking:
- Timestamp of execution
- Input and output data
- Success/failure status
- Duration measurement
- Error messages preserved
Test Coverage:
- 48 plugin tests (registry + loader)
- Plugin registration/unregistration
- Execution success and failure cases
- Enable/disable control
- Logging and timing verification
- URL resolution testing
- Directory loading tests
- Configuration persistence
- Statistics accuracy
- Concurrent safety (RWMutex)
Performance:
- Fast plugin lookup (O(1) hash map)
- Minimal overhead for execution
- Efficient logging with reuse
- Scalable to 100s of plugins
Next: T3.2 (Workflow templates)
323 lines
7.5 KiB
Go
323 lines
7.5 KiB
Go
package plugins
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SkillPlugin represents a custom skill plugin
|
|
type SkillPlugin interface {
|
|
// Name returns the plugin name
|
|
Name() string
|
|
// Version returns the plugin version
|
|
Version() string
|
|
// Execute executes the plugin with the given input
|
|
Execute(input map[string]interface{}) (map[string]interface{}, error)
|
|
// Validate validates the plugin configuration
|
|
Validate() error
|
|
// Description returns a human-readable description
|
|
Description() string
|
|
}
|
|
|
|
// PluginMetadata holds metadata about a plugin
|
|
type PluginMetadata struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Author string `json:"author"`
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Config map[string]interface{} `json:"config,omitempty"`
|
|
LoadedAt time.Time `json:"loaded_at"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// PluginRegistry manages custom skill plugins
|
|
type PluginRegistry struct {
|
|
mu sync.RWMutex
|
|
plugins map[string]SkillPlugin
|
|
metadata map[string]*PluginMetadata
|
|
executionLog map[string][]*ExecutionRecord
|
|
stats *PluginStats
|
|
}
|
|
|
|
// ExecutionRecord tracks plugin execution
|
|
type ExecutionRecord struct {
|
|
PluginName string
|
|
Timestamp time.Time
|
|
Duration time.Duration
|
|
Input map[string]interface{}
|
|
Output map[string]interface{}
|
|
Error error
|
|
Success bool
|
|
}
|
|
|
|
// PluginStats tracks plugin statistics
|
|
type PluginStats struct {
|
|
TotalExecutions int
|
|
SuccessfulExecutions int
|
|
FailedExecutions int
|
|
TotalPlugins int
|
|
EnabledPlugins int
|
|
AverageExecutionTime time.Duration
|
|
}
|
|
|
|
// NewPluginRegistry creates a new plugin registry
|
|
func NewPluginRegistry() *PluginRegistry {
|
|
return &PluginRegistry{
|
|
plugins: make(map[string]SkillPlugin),
|
|
metadata: make(map[string]*PluginMetadata),
|
|
executionLog: make(map[string][]*ExecutionRecord),
|
|
stats: &PluginStats{
|
|
TotalExecutions: 0,
|
|
SuccessfulExecutions: 0,
|
|
FailedExecutions: 0,
|
|
TotalPlugins: 0,
|
|
EnabledPlugins: 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Register registers a new plugin
|
|
func (pr *PluginRegistry) Register(plugin SkillPlugin, author string, config map[string]interface{}) error {
|
|
if plugin == nil {
|
|
return fmt.Errorf("plugin cannot be nil")
|
|
}
|
|
|
|
// Validate plugin
|
|
if err := plugin.Validate(); err != nil {
|
|
return fmt.Errorf("plugin validation failed: %w", err)
|
|
}
|
|
|
|
pr.mu.Lock()
|
|
defer pr.mu.Unlock()
|
|
|
|
name := plugin.Name()
|
|
if _, exists := pr.plugins[name]; exists {
|
|
return fmt.Errorf("plugin already registered: %s", name)
|
|
}
|
|
|
|
pr.plugins[name] = plugin
|
|
pr.metadata[name] = &PluginMetadata{
|
|
Name: name,
|
|
Version: plugin.Version(),
|
|
Author: author,
|
|
Description: plugin.Description(),
|
|
URL: fmt.Sprintf("plugin://%s", name),
|
|
Config: config,
|
|
LoadedAt: time.Now(),
|
|
Enabled: true,
|
|
}
|
|
|
|
pr.stats.TotalPlugins++
|
|
pr.stats.EnabledPlugins++
|
|
pr.executionLog[name] = make([]*ExecutionRecord, 0)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Unregister unregisters a plugin
|
|
func (pr *PluginRegistry) Unregister(name string) error {
|
|
pr.mu.Lock()
|
|
defer pr.mu.Unlock()
|
|
|
|
if _, exists := pr.plugins[name]; !exists {
|
|
return fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
delete(pr.plugins, name)
|
|
if pr.metadata[name].Enabled {
|
|
pr.stats.EnabledPlugins--
|
|
}
|
|
pr.stats.TotalPlugins--
|
|
delete(pr.metadata, name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Execute executes a plugin by name
|
|
func (pr *PluginRegistry) Execute(name string, input map[string]interface{}) (map[string]interface{}, error) {
|
|
pr.mu.RLock()
|
|
plugin, exists := pr.plugins[name]
|
|
metadata, metaExists := pr.metadata[name]
|
|
pr.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return nil, fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
if !metaExists || !metadata.Enabled {
|
|
return nil, fmt.Errorf("plugin is disabled: %s", name)
|
|
}
|
|
|
|
start := time.Now()
|
|
output, err := plugin.Execute(input)
|
|
duration := time.Since(start)
|
|
|
|
// Record execution
|
|
record := &ExecutionRecord{
|
|
PluginName: name,
|
|
Timestamp: start,
|
|
Duration: duration,
|
|
Input: input,
|
|
Output: output,
|
|
Error: err,
|
|
Success: err == nil,
|
|
}
|
|
|
|
pr.mu.Lock()
|
|
pr.executionLog[name] = append(pr.executionLog[name], record)
|
|
pr.stats.TotalExecutions++
|
|
if err == nil {
|
|
pr.stats.SuccessfulExecutions++
|
|
} else {
|
|
pr.stats.FailedExecutions++
|
|
}
|
|
pr.mu.Unlock()
|
|
|
|
return output, err
|
|
}
|
|
|
|
// Get retrieves a plugin by name
|
|
func (pr *PluginRegistry) Get(name string) (SkillPlugin, bool) {
|
|
pr.mu.RLock()
|
|
defer pr.mu.RUnlock()
|
|
|
|
plugin, exists := pr.plugins[name]
|
|
return plugin, exists
|
|
}
|
|
|
|
// GetMetadata retrieves plugin metadata
|
|
func (pr *PluginRegistry) GetMetadata(name string) (*PluginMetadata, bool) {
|
|
pr.mu.RLock()
|
|
defer pr.mu.RUnlock()
|
|
|
|
meta, exists := pr.metadata[name]
|
|
return meta, exists
|
|
}
|
|
|
|
// ListPlugins returns all registered plugins
|
|
func (pr *PluginRegistry) ListPlugins() map[string]*PluginMetadata {
|
|
pr.mu.RLock()
|
|
defer pr.mu.RUnlock()
|
|
|
|
result := make(map[string]*PluginMetadata)
|
|
for name, meta := range pr.metadata {
|
|
result[name] = meta
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// EnablePlugin enables a plugin
|
|
func (pr *PluginRegistry) EnablePlugin(name string) error {
|
|
pr.mu.Lock()
|
|
defer pr.mu.Unlock()
|
|
|
|
meta, exists := pr.metadata[name]
|
|
if !exists {
|
|
return fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
if meta.Enabled {
|
|
return fmt.Errorf("plugin already enabled: %s", name)
|
|
}
|
|
|
|
meta.Enabled = true
|
|
pr.stats.EnabledPlugins++
|
|
|
|
return nil
|
|
}
|
|
|
|
// DisablePlugin disables a plugin
|
|
func (pr *PluginRegistry) DisablePlugin(name string) error {
|
|
pr.mu.Lock()
|
|
defer pr.mu.Unlock()
|
|
|
|
meta, exists := pr.metadata[name]
|
|
if !exists {
|
|
return fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
if !meta.Enabled {
|
|
return fmt.Errorf("plugin already disabled: %s", name)
|
|
}
|
|
|
|
meta.Enabled = false
|
|
pr.stats.EnabledPlugins--
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetExecutionLog returns execution history for a plugin
|
|
func (pr *PluginRegistry) GetExecutionLog(name string) []*ExecutionRecord {
|
|
pr.mu.RLock()
|
|
defer pr.mu.RUnlock()
|
|
|
|
if log, exists := pr.executionLog[name]; exists {
|
|
result := make([]*ExecutionRecord, len(log))
|
|
copy(result, log)
|
|
return result
|
|
}
|
|
|
|
return make([]*ExecutionRecord, 0)
|
|
}
|
|
|
|
// GetStats returns registry statistics
|
|
func (pr *PluginRegistry) GetStats() *PluginStats {
|
|
pr.mu.RLock()
|
|
defer pr.mu.RUnlock()
|
|
|
|
stats := *pr.stats
|
|
if stats.TotalExecutions > 0 {
|
|
totalDuration := time.Duration(0)
|
|
for _, log := range pr.executionLog {
|
|
for _, record := range log {
|
|
totalDuration += record.Duration
|
|
}
|
|
}
|
|
stats.AverageExecutionTime = totalDuration / time.Duration(stats.TotalExecutions)
|
|
}
|
|
|
|
return &stats
|
|
}
|
|
|
|
// ResolvePluginURL resolves a plugin:// URL
|
|
func (pr *PluginRegistry) ResolvePluginURL(url string) (SkillPlugin, error) {
|
|
if len(url) < 9 || url[:9] != "plugin://" {
|
|
return nil, fmt.Errorf("invalid plugin URL: %s", url)
|
|
}
|
|
|
|
name := url[9:] // Remove "plugin://" prefix
|
|
plugin, exists := pr.Get(name)
|
|
if !exists {
|
|
return nil, fmt.Errorf("plugin not found: %s", name)
|
|
}
|
|
|
|
return plugin, nil
|
|
}
|
|
|
|
// Clear clears all plugins
|
|
func (pr *PluginRegistry) Clear() {
|
|
pr.mu.Lock()
|
|
defer pr.mu.Unlock()
|
|
|
|
pr.plugins = make(map[string]SkillPlugin)
|
|
pr.metadata = make(map[string]*PluginMetadata)
|
|
pr.executionLog = make(map[string][]*ExecutionRecord)
|
|
pr.stats = &PluginStats{}
|
|
}
|
|
|
|
// IsPluginURL checks if a URL is a plugin URL
|
|
func IsPluginURL(url string) bool {
|
|
return len(url) > 9 && url[:9] == "plugin://"
|
|
}
|
|
|
|
// ExtractPluginName extracts plugin name from plugin URL
|
|
func ExtractPluginName(url string) string {
|
|
if IsPluginURL(url) {
|
|
return url[9:]
|
|
}
|
|
return ""
|
|
}
|