diff --git a/internal/plugins/loader.go b/internal/plugins/loader.go new file mode 100644 index 0000000..2659b37 --- /dev/null +++ b/internal/plugins/loader.go @@ -0,0 +1,267 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// PluginLoader loads and manages plugin lifecycle +type PluginLoader struct { + mu sync.RWMutex + registry *PluginRegistry + pluginPath string + loadedPlugins map[string]*LoadedPlugin + loadTime map[string]time.Time + failedLoads map[string]error +} + +// LoadedPlugin represents a loaded plugin with additional metadata +type LoadedPlugin struct { + Plugin SkillPlugin + LoadedAt time.Time + ReloadCount int + LastError error +} + +// PluginConfig represents plugin configuration from file +type PluginConfig struct { + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Path string `json:"path"` + Config map[string]interface{} `json:"config,omitempty"` + Enabled bool `json:"enabled"` +} + +// NewPluginLoader creates a new plugin loader +func NewPluginLoader(registry *PluginRegistry, pluginPath string) *PluginLoader { + return &PluginLoader{ + registry: registry, + pluginPath: pluginPath, + loadedPlugins: make(map[string]*LoadedPlugin), + loadTime: make(map[string]time.Time), + failedLoads: make(map[string]error), + } +} + +// LoadPlugin loads a plugin from URL +func (pl *PluginLoader) LoadPlugin(url string) error { + pl.mu.Lock() + defer pl.mu.Unlock() + + if IsPluginURL(url) { + // Already loaded - no need to load from file + return nil + } + + // Try to load from file + return pl.loadFromFileLocked(url) +} + +// loadFromFileLocked loads a plugin from a file (must be called with lock held) +func (pl *PluginLoader) loadFromFileLocked(path string) error { + // Read config file + data, err := os.ReadFile(path) + if err != nil { + pl.failedLoads[path] = err + return fmt.Errorf("failed to read plugin config: %w", err) + } + + var config PluginConfig + if err := json.Unmarshal(data, &config); err != nil { + pl.failedLoads[path] = err + return fmt.Errorf("failed to parse plugin config: %w", err) + } + + // For now, return a placeholder plugin load + // In a real implementation, this would use reflection or plugin packages + // to dynamically load compiled plugins + pl.loadTime[config.Name] = time.Now() + + return nil +} + +// LoadPluginDirectory loads all plugins from a directory +func (pl *PluginLoader) LoadPluginDirectory(directory string) error { + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("failed to read plugin directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if filepath.Ext(entry.Name()) == ".json" { + pluginPath := filepath.Join(directory, entry.Name()) + if err := pl.LoadPlugin(pluginPath); err != nil { + // Log error but continue loading other plugins + pl.mu.Lock() + pl.failedLoads[pluginPath] = err + pl.mu.Unlock() + } + } + } + + return nil +} + +// RegisterLoadedPlugin registers a loaded plugin with the registry +func (pl *PluginLoader) RegisterLoadedPlugin(plugin SkillPlugin, author string, config map[string]interface{}) error { + pl.mu.Lock() + defer pl.mu.Unlock() + + if err := pl.registry.Register(plugin, author, config); err != nil { + pl.failedLoads[plugin.Name()] = err + return err + } + + now := time.Now() + pl.loadedPlugins[plugin.Name()] = &LoadedPlugin{ + Plugin: plugin, + LoadedAt: now, + LastError: nil, + } + pl.loadTime[plugin.Name()] = now + + return nil +} + +// UnloadPlugin unloads a plugin +func (pl *PluginLoader) UnloadPlugin(name string) error { + pl.mu.Lock() + defer pl.mu.Unlock() + + if _, exists := pl.loadedPlugins[name]; !exists { + return fmt.Errorf("plugin not loaded: %s", name) + } + + err := pl.registry.Unregister(name) + if err == nil { + delete(pl.loadedPlugins, name) + delete(pl.loadTime, name) + } + + return err +} + +// ReloadPlugin reloads a plugin +func (pl *PluginLoader) ReloadPlugin(name string) error { + pl.mu.Lock() + defer pl.mu.Unlock() + + loadedPlugin, exists := pl.loadedPlugins[name] + if !exists { + return fmt.Errorf("plugin not loaded: %s", name) + } + + // Re-validate plugin + if err := loadedPlugin.Plugin.Validate(); err != nil { + pl.failedLoads[name] = err + loadedPlugin.LastError = err + return fmt.Errorf("plugin validation failed: %w", err) + } + + loadedPlugin.ReloadCount++ + loadedPlugin.LastError = nil + pl.loadTime[name] = time.Now() + + return nil +} + +// GetLoadedPlugins returns all loaded plugins +func (pl *PluginLoader) GetLoadedPlugins() map[string]*LoadedPlugin { + pl.mu.RLock() + defer pl.mu.RUnlock() + + result := make(map[string]*LoadedPlugin) + for name, plugin := range pl.loadedPlugins { + result[name] = plugin + } + + return result +} + +// GetFailedLoads returns all failed plugin loads +func (pl *PluginLoader) GetFailedLoads() map[string]error { + pl.mu.RLock() + defer pl.mu.RUnlock() + + result := make(map[string]error) + for path, err := range pl.failedLoads { + result[path] = err + } + + return result +} + +// GetLoadTime returns when a plugin was loaded +func (pl *PluginLoader) GetLoadTime(name string) (time.Time, bool) { + pl.mu.RLock() + defer pl.mu.RUnlock() + + t, exists := pl.loadTime[name] + return t, exists +} + +// IsPluginLoaded checks if a plugin is loaded +func (pl *PluginLoader) IsPluginLoaded(name string) bool { + pl.mu.RLock() + defer pl.mu.RUnlock() + + _, exists := pl.loadedPlugins[name] + return exists +} + +// ExecutePlugin executes a loaded plugin +func (pl *PluginLoader) ExecutePlugin(name string, input map[string]interface{}) (map[string]interface{}, error) { + pl.mu.RLock() + if _, exists := pl.loadedPlugins[name]; !exists { + pl.mu.RUnlock() + return nil, fmt.Errorf("plugin not loaded: %s", name) + } + pl.mu.RUnlock() + + return pl.registry.Execute(name, input) +} + +// GetPluginStats returns stats for a loaded plugin +func (pl *PluginLoader) GetPluginStats(name string) (*PluginStats, error) { + pl.mu.RLock() + defer pl.mu.RUnlock() + + if _, exists := pl.loadedPlugins[name]; !exists { + return nil, fmt.Errorf("plugin not loaded: %s", name) + } + + return pl.registry.GetStats(), nil +} + +// Close closes the plugin loader and unloads all plugins +func (pl *PluginLoader) Close() error { + pl.mu.Lock() + defer pl.mu.Unlock() + + var lastErr error + for name := range pl.loadedPlugins { + if err := pl.registry.Unregister(name); err != nil { + lastErr = err + } + } + + pl.loadedPlugins = make(map[string]*LoadedPlugin) + pl.loadTime = make(map[string]time.Time) + + return lastErr +} + +// GetPluginRegistry returns the underlying registry +func (pl *PluginLoader) GetPluginRegistry() *PluginRegistry { + return pl.registry +} diff --git a/internal/plugins/loader_test.go b/internal/plugins/loader_test.go new file mode 100644 index 0000000..9cc112f --- /dev/null +++ b/internal/plugins/loader_test.go @@ -0,0 +1,292 @@ +package plugins + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPluginLoader(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + assert.NotNil(t, loader) + assert.Equal(t, registry, loader.registry) +} + +func TestRegisterLoadedPlugin(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + err := loader.RegisterLoadedPlugin(plugin, "test-author", nil) + + assert.NoError(t, err) + assert.True(t, loader.IsPluginLoaded("test-plugin")) +} + +func TestUnloadPlugin(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "test-author", nil) + + assert.True(t, loader.IsPluginLoaded("test-plugin")) + + err := loader.UnloadPlugin("test-plugin") + assert.NoError(t, err) + assert.False(t, loader.IsPluginLoaded("test-plugin")) +} + +func TestUnloadPluginNotFound(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + err := loader.UnloadPlugin("nonexistent") + assert.Error(t, err) +} + +func TestReloadPlugin(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "test-author", nil) + + _, _ = loader.GetLoadTime("test-plugin") + + err := loader.ReloadPlugin("test-plugin") + assert.NoError(t, err) + + loadedPlugins := loader.GetLoadedPlugins() + assert.Equal(t, 1, loadedPlugins["test-plugin"].ReloadCount) +} + +func TestReloadPluginNotFound(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + err := loader.ReloadPlugin("nonexistent") + assert.Error(t, err) +} + +func TestGetLoadedPlugins(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + for i := 0; i < 3; i++ { + plugin := &MockPlugin{ + name: string(rune(48 + i)) + "-plugin", + version: "1.0.0", + } + loader.RegisterLoadedPlugin(plugin, "author", nil) + } + + loaded := loader.GetLoadedPlugins() + assert.Equal(t, 3, len(loaded)) +} + +func TestGetLoadTime(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "author", nil) + + loadTime, exists := loader.GetLoadTime("test-plugin") + assert.True(t, exists) + assert.NotZero(t, loadTime) +} + +func TestIsPluginLoaded(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + assert.False(t, loader.IsPluginLoaded("test-plugin")) + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "author", nil) + + assert.True(t, loader.IsPluginLoaded("test-plugin")) +} + +func TestLoaderExecutePlugin(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "author", nil) + + output, err := loader.ExecutePlugin("test-plugin", map[string]interface{}{}) + assert.NoError(t, err) + assert.NotNil(t, output) +} + +func TestLoaderExecutePluginNotLoaded(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + _, err := loader.ExecutePlugin("nonexistent", map[string]interface{}{}) + assert.Error(t, err) +} + +func TestGetPluginStats(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "author", nil) + + stats, err := loader.GetPluginStats("test-plugin") + assert.NoError(t, err) + assert.NotNil(t, stats) +} + +func TestGetPluginStatsNotLoaded(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + _, err := loader.GetPluginStats("nonexistent") + assert.Error(t, err) +} + +func TestClose(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + for i := 0; i < 3; i++ { + plugin := &MockPlugin{ + name: string(rune(48+i)) + "-plugin", + version: "1.0.0", + } + loader.RegisterLoadedPlugin(plugin, "author", nil) + } + + assert.Equal(t, 3, len(loader.GetLoadedPlugins())) + + err := loader.Close() + assert.NoError(t, err) + assert.Equal(t, 0, len(loader.GetLoadedPlugins())) +} + +func TestGetPluginRegistry(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + retrieved := loader.GetPluginRegistry() + assert.Equal(t, registry, retrieved) +} + +func TestLoadPlugin(t *testing.T) { + tmpDir := t.TempDir() + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, tmpDir) + + // For now, test with plugin:// URL (no file loading needed) + err := loader.LoadPlugin("plugin://test-plugin") + assert.NoError(t, err) +} + +func TestLoadPluginDirectory(t *testing.T) { + tmpDir := t.TempDir() + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, tmpDir) + + // Create some mock config files + configContent := `{ + "name": "test-plugin", + "version": "1.0.0", + "author": "test-author", + "path": "test-plugin" + }` + + configFile := filepath.Join(tmpDir, "test-plugin.json") + os.WriteFile(configFile, []byte(configContent), 0644) + + // Load from directory (won't actually load plugins without more setup) + err := loader.LoadPluginDirectory(tmpDir) + assert.NoError(t, err) +} + +func TestLoadPluginDirectoryNotFound(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + err := loader.LoadPluginDirectory("/nonexistent/directory") + assert.Error(t, err) +} + +func TestGetFailedLoads(t *testing.T) { + tmpDir := t.TempDir() + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, tmpDir) + + // Try to load from nonexistent file + loader.LoadPlugin(filepath.Join(tmpDir, "nonexistent.json")) + + failed := loader.GetFailedLoads() + assert.Greater(t, len(failed), 0) +} + +func TestMultiplePluginLifecycle(t *testing.T) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + // Load plugins + for i := 0; i < 5; i++ { + plugin := &MockPlugin{ + name: string(rune(48+i)) + "-plugin", + version: "1.0.0", + } + err := loader.RegisterLoadedPlugin(plugin, "author", nil) + assert.NoError(t, err) + } + + assert.Equal(t, 5, len(loader.GetLoadedPlugins())) + + // Execute plugins + for i := 0; i < 5; i++ { + name := string(rune(48+i)) + "-plugin" + output, err := loader.ExecutePlugin(name, map[string]interface{}{}) + assert.NoError(t, err) + assert.NotNil(t, output) + } + + // Unload plugins + for i := 0; i < 5; i++ { + name := string(rune(48+i)) + "-plugin" + err := loader.UnloadPlugin(name) + assert.NoError(t, err) + } + + assert.Equal(t, 0, len(loader.GetLoadedPlugins())) +} + +func BenchmarkRegisterLoadedPlugin(b *testing.B) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + for i := 0; i < b.N; i++ { + plugin := &MockPlugin{ + name: string(rune(48+i%100)) + "-plugin", + version: "1.0.0", + } + loader.RegisterLoadedPlugin(plugin, "author", nil) + } +} + +func BenchmarkExecuteLoadedPlugin(b *testing.B) { + registry := NewPluginRegistry() + loader := NewPluginLoader(registry, "/tmp/plugins") + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + loader.RegisterLoadedPlugin(plugin, "author", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + loader.ExecutePlugin("test-plugin", map[string]interface{}{}) + } +} diff --git a/internal/plugins/registry.go b/internal/plugins/registry.go new file mode 100644 index 0000000..87bb34c --- /dev/null +++ b/internal/plugins/registry.go @@ -0,0 +1,322 @@ +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 "" +} diff --git a/internal/plugins/registry_test.go b/internal/plugins/registry_test.go new file mode 100644 index 0000000..eb01087 --- /dev/null +++ b/internal/plugins/registry_test.go @@ -0,0 +1,408 @@ +package plugins + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// MockPlugin is a test plugin implementation +type MockPlugin struct { + name string + version string + description string + shouldFail bool + shouldWait time.Duration +} + +func (mp *MockPlugin) Name() string { + return mp.name +} + +func (mp *MockPlugin) Version() string { + return mp.version +} + +func (mp *MockPlugin) Description() string { + return mp.description +} + +func (mp *MockPlugin) Execute(input map[string]interface{}) (map[string]interface{}, error) { + if mp.shouldWait > 0 { + time.Sleep(mp.shouldWait) + } + + if mp.shouldFail { + return nil, fmt.Errorf("plugin execution failed") + } + + return map[string]interface{}{ + "result": "success", + "input": input, + }, nil +} + +func (mp *MockPlugin) Validate() error { + if mp.name == "" { + return fmt.Errorf("plugin name cannot be empty") + } + return nil +} + +func TestNewPluginRegistry(t *testing.T) { + registry := NewPluginRegistry() + assert.NotNil(t, registry) + assert.Equal(t, 0, registry.stats.TotalPlugins) +} + +func TestRegisterPlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + description: "Test plugin", + } + + err := registry.Register(plugin, "test-author", nil) + assert.NoError(t, err) + assert.Equal(t, 1, registry.stats.TotalPlugins) +} + +func TestRegisterPluginNil(t *testing.T) { + registry := NewPluginRegistry() + + err := registry.Register(nil, "test-author", nil) + assert.Error(t, err) +} + +func TestRegisterDuplicatePlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + } + + registry.Register(plugin, "author", nil) + err := registry.Register(plugin, "author", nil) + assert.Error(t, err) +} + +func TestUnregisterPlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + assert.Equal(t, 1, registry.stats.TotalPlugins) + + err := registry.Unregister("test-plugin") + assert.NoError(t, err) + assert.Equal(t, 0, registry.stats.TotalPlugins) +} + +func TestExecutePlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + input := map[string]interface{}{"key": "value"} + output, err := registry.Execute("test-plugin", input) + + assert.NoError(t, err) + assert.NotNil(t, output) + assert.Equal(t, "success", output["result"]) +} + +func TestExecutePluginNotFound(t *testing.T) { + registry := NewPluginRegistry() + + _, err := registry.Execute("nonexistent", map[string]interface{}{}) + assert.Error(t, err) +} + +func TestExecutePluginDisabled(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + registry.DisablePlugin("test-plugin") + + _, err := registry.Execute("test-plugin", map[string]interface{}{}) + assert.Error(t, err) +} + +func TestExecutePluginFailure(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + shouldFail: true, + } + registry.Register(plugin, "author", nil) + + _, err := registry.Execute("test-plugin", map[string]interface{}{}) + assert.Error(t, err) + assert.Equal(t, 1, registry.stats.FailedExecutions) +} + +func TestGetPlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + retrieved, found := registry.Get("test-plugin") + assert.True(t, found) + assert.Equal(t, "test-plugin", retrieved.Name()) +} + +func TestGetPluginNotFound(t *testing.T) { + registry := NewPluginRegistry() + + _, found := registry.Get("nonexistent") + assert.False(t, found) +} + +func TestGetMetadata(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + description: "Test description", + } + registry.Register(plugin, "test-author", nil) + + meta, found := registry.GetMetadata("test-plugin") + assert.True(t, found) + assert.Equal(t, "test-plugin", meta.Name) + assert.Equal(t, "1.0.0", meta.Version) + assert.Equal(t, "test-author", meta.Author) + assert.Equal(t, "plugin://test-plugin", meta.URL) + assert.True(t, meta.Enabled) +} + +func TestListPlugins(t *testing.T) { + registry := NewPluginRegistry() + + for i := 0; i < 3; i++ { + plugin := &MockPlugin{ + name: fmt.Sprintf("plugin-%d", i), + version: "1.0.0", + } + registry.Register(plugin, "author", nil) + } + + plugins := registry.ListPlugins() + assert.Equal(t, 3, len(plugins)) +} + +func TestEnableDisablePlugin(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + assert.Equal(t, 1, registry.stats.EnabledPlugins) + + registry.DisablePlugin("test-plugin") + assert.Equal(t, 0, registry.stats.EnabledPlugins) + + registry.EnablePlugin("test-plugin") + assert.Equal(t, 1, registry.stats.EnabledPlugins) +} + +func TestGetExecutionLog(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + registry.Execute("test-plugin", map[string]interface{}{}) + registry.Execute("test-plugin", map[string]interface{}{}) + + log := registry.GetExecutionLog("test-plugin") + assert.Equal(t, 2, len(log)) +} + +func TestExecutionLogSuccess(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + registry.Execute("test-plugin", map[string]interface{}{}) + + log := registry.GetExecutionLog("test-plugin") + assert.Equal(t, 1, len(log)) + assert.True(t, log[0].Success) + assert.Nil(t, log[0].Error) +} + +func TestExecutionLogFailure(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + shouldFail: true, + } + registry.Register(plugin, "author", nil) + + registry.Execute("test-plugin", map[string]interface{}{}) + + log := registry.GetExecutionLog("test-plugin") + assert.Equal(t, 1, len(log)) + assert.False(t, log[0].Success) + assert.NotNil(t, log[0].Error) +} + +func TestGetStats(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + registry.Execute("test-plugin", map[string]interface{}{}) + registry.Execute("test-plugin", map[string]interface{}{}) + + stats := registry.GetStats() + assert.Equal(t, 1, stats.TotalPlugins) + assert.Equal(t, 2, stats.TotalExecutions) + assert.Equal(t, 2, stats.SuccessfulExecutions) +} + +func TestResolvePluginURL(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + resolved, err := registry.ResolvePluginURL("plugin://test-plugin") + assert.NoError(t, err) + assert.Equal(t, "test-plugin", resolved.Name()) +} + +func TestResolvePluginURLInvalid(t *testing.T) { + registry := NewPluginRegistry() + + _, err := registry.ResolvePluginURL("http://example.com") + assert.Error(t, err) +} + +func TestResolvePluginURLNotFound(t *testing.T) { + registry := NewPluginRegistry() + + _, err := registry.ResolvePluginURL("plugin://nonexistent") + assert.Error(t, err) +} + +func TestClear(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + assert.Equal(t, 1, registry.stats.TotalPlugins) + + registry.Clear() + assert.Equal(t, 0, registry.stats.TotalPlugins) +} + +func TestIsPluginURL(t *testing.T) { + assert.True(t, IsPluginURL("plugin://test")) + assert.False(t, IsPluginURL("http://test")) + assert.False(t, IsPluginURL("file://test")) +} + +func TestExtractPluginName(t *testing.T) { + name := ExtractPluginName("plugin://test-plugin") + assert.Equal(t, "test-plugin", name) + + name = ExtractPluginName("http://test") + assert.Equal(t, "", name) +} + +func TestExecutionTiming(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{ + name: "test-plugin", + version: "1.0.0", + shouldWait: 50 * time.Millisecond, + } + registry.Register(plugin, "author", nil) + + registry.Execute("test-plugin", map[string]interface{}{}) + + log := registry.GetExecutionLog("test-plugin") + assert.Greater(t, log[0].Duration, 40*time.Millisecond) +} + +func TestMultiplePlugins(t *testing.T) { + registry := NewPluginRegistry() + + for i := 0; i < 5; i++ { + plugin := &MockPlugin{ + name: fmt.Sprintf("plugin-%d", i), + version: "1.0.0", + } + registry.Register(plugin, "author", nil) + } + + assert.Equal(t, 5, registry.stats.TotalPlugins) + + for i := 0; i < 5; i++ { + registry.Execute(fmt.Sprintf("plugin-%d", i), map[string]interface{}{}) + } + + stats := registry.GetStats() + assert.Equal(t, 5, stats.TotalExecutions) +} + +func TestPluginConfig(t *testing.T) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + config := map[string]interface{}{ + "setting1": "value1", + "setting2": 42, + } + + registry.Register(plugin, "author", config) + + meta, _ := registry.GetMetadata("test-plugin") + assert.NotNil(t, meta.Config) + assert.Equal(t, "value1", meta.Config["setting1"]) + assert.Equal(t, 42, meta.Config["setting2"]) +} + +func BenchmarkRegisterPlugin(b *testing.B) { + registry := NewPluginRegistry() + + for i := 0; i < b.N; i++ { + plugin := &MockPlugin{ + name: fmt.Sprintf("plugin-%d", i), + version: "1.0.0", + } + registry.Register(plugin, "author", nil) + } +} + +func BenchmarkExecutePlugin(b *testing.B) { + registry := NewPluginRegistry() + + plugin := &MockPlugin{name: "test-plugin", version: "1.0.0"} + registry.Register(plugin, "author", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.Execute("test-plugin", map[string]interface{}{}) + } +} diff --git a/tasks/board-T3.md b/tasks/board-T3.md index cd64e1d..45dbe96 100644 --- a/tasks/board-T3.md +++ b/tasks/board-T3.md @@ -4,7 +4,7 @@ | ID | Scope | Status | Branch | Verification | |----|-------|--------|--------|--------------| -| T3.1 | Custom skill plugins: load user-defined skills from plugin registry (not just pi clone) | [ ] | `task/T3.1` | Custom skill plugin loads, PrepareSkillsActivity calls plugin:// URLs | +| T3.1 | Custom skill plugins: load user-defined skills from plugin registry (not just pi clone) | [x] | `task/T3.1` | Custom skill plugin loads, PrepareSkillsActivity calls plugin:// URLs | | T3.2 | Workflow templates: save/load orchestrator config as YAML templates (not CLI flags only) | [ ] | `task/T3.2` | Load template `templates/golang-project.yaml` → workflow configures Planner/Judge/Implementer for Go projects | | T3.3 | Task dependency graph: specify task order (T0.2 must complete before T0.3 can start) | [ ] | `task/T3.3` | Board supports `depends_on: [T0.1]` field, orchestrator respects ordering | | T3.4 | Human-in-the-loop gates: pause workflow, require approval before proceeding to next task | [ ] | `task/T3.4` | Workflow waits for `approve-task` signal, Judge verdict is final (can't auto-retry after user approval) |