diff --git a/cmd/wfctl/pipeline.go b/cmd/wfctl/pipeline.go index 326f32e4..6b34b090 100644 --- a/cmd/wfctl/pipeline.go +++ b/cmd/wfctl/pipeline.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow" "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/handlers" @@ -189,20 +188,12 @@ Options: // build from config (which wires all step factories and compiles pipelines), // then look up the named pipeline from the engine's pipeline registry directly. // We deliberately skip engine.Start() so no HTTP servers or triggers are started. - app := modular.NewStdApplication(nil, logger) - eng := workflow.NewStdEngine(app, logger) - - // Register the pipeline workflow handler (required for configurePipelines to find a PipelineAdder). - eng.RegisterWorkflowHandler(handlers.NewPipelineWorkflowHandler()) - - // Load the pipeline-steps plugin (registers step.log, step.set, step.validate, etc.) - if err := eng.LoadPlugin(pluginpipeline.New()); err != nil { - return fmt.Errorf("failed to load pipeline-steps plugin: %w", err) - } - - // BuildFromConfig registers modules, compiles pipeline steps, and populates - // the engine's pipeline registry. It does NOT start the HTTP server. - if err := eng.BuildFromConfig(cfg); err != nil { + eng, err := workflow.NewEngineBuilder(). + WithLogger(logger). + WithHandler(handlers.NewPipelineWorkflowHandler()). + WithPlugin(pluginpipeline.New()). + BuildFromConfig(cfg) + if err != nil { return fmt.Errorf("failed to build engine from config: %w", err) } diff --git a/cmd/wfctl/run.go b/cmd/wfctl/run.go index 2f5a3a48..d713fc0d 100644 --- a/cmd/wfctl/run.go +++ b/cmd/wfctl/run.go @@ -9,12 +9,11 @@ import ( "os/signal" "syscall" - "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow" "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/dynamic" - "github.com/GoCodeAlone/workflow/handlers" - "github.com/GoCodeAlone/workflow/module" + + // Blank import registers default handlers and triggers with the engine builder. + _ "github.com/GoCodeAlone/workflow/setup" ) func runRun(args []string) error { @@ -57,28 +56,11 @@ func runRun(args []string) error { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) - app := modular.NewStdApplication(nil, logger) - engine := workflow.NewStdEngine(app, logger) - - engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewMessagingWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewStateMachineWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewSchedulerWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewIntegrationWorkflowHandler()) - - engine.RegisterTrigger(module.NewHTTPTrigger()) - engine.RegisterTrigger(module.NewEventTrigger()) - engine.RegisterTrigger(module.NewScheduleTrigger()) - engine.RegisterTrigger(module.NewEventBusTrigger()) - engine.RegisterTrigger(module.NewReconciliationTrigger()) - - pool := dynamic.NewInterpreterPool() - registry := dynamic.NewComponentRegistry() - loader := dynamic.NewLoader(pool, registry) - engine.SetDynamicRegistry(registry) - engine.SetDynamicLoader(loader) - - if err := engine.BuildFromConfig(cfg); err != nil { + engine, err := workflow.NewEngineBuilder(). + WithLogger(logger). + WithAllDefaults(). + BuildFromConfig(cfg) + if err != nil { return fmt.Errorf("failed to build workflow: %w", err) } diff --git a/cmd/wfctl/templates/api-service/main.go.tmpl b/cmd/wfctl/templates/api-service/main.go.tmpl index 83018dee..2d02de37 100644 --- a/cmd/wfctl/templates/api-service/main.go.tmpl +++ b/cmd/wfctl/templates/api-service/main.go.tmpl @@ -9,11 +9,9 @@ import ( "os/signal" "syscall" - "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow" "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/handlers" - "github.com/GoCodeAlone/workflow/module" + _ "github.com/GoCodeAlone/workflow/setup" ) func main() { @@ -41,16 +39,11 @@ func main() { os.Exit(1) } - app := modular.NewStdApplication(nil, logger) - engine := workflow.NewStdEngine(app, logger) - - engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewIntegrationWorkflowHandler()) - - engine.RegisterTrigger(module.NewHTTPTrigger()) - engine.RegisterTrigger(module.NewEventTrigger()) - - if err := engine.BuildFromConfig(cfg); err != nil { + engine, err := workflow.NewEngineBuilder(). + WithLogger(logger). + WithAllDefaults(). + BuildFromConfig(cfg) + if err != nil { fmt.Fprintf(os.Stderr, "failed to build workflow: %v\n", err) os.Exit(1) } diff --git a/cmd/wfctl/templates/event-processor/main.go.tmpl b/cmd/wfctl/templates/event-processor/main.go.tmpl index 23b0f2c3..5eb5175b 100644 --- a/cmd/wfctl/templates/event-processor/main.go.tmpl +++ b/cmd/wfctl/templates/event-processor/main.go.tmpl @@ -9,11 +9,9 @@ import ( "os/signal" "syscall" - "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow" "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/handlers" - "github.com/GoCodeAlone/workflow/module" + _ "github.com/GoCodeAlone/workflow/setup" ) func main() { @@ -41,18 +39,11 @@ func main() { os.Exit(1) } - app := modular.NewStdApplication(nil, logger) - engine := workflow.NewStdEngine(app, logger) - - engine.RegisterWorkflowHandler(handlers.NewMessagingWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewStateMachineWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler()) - - engine.RegisterTrigger(module.NewEventTrigger()) - engine.RegisterTrigger(module.NewEventBusTrigger()) - engine.RegisterTrigger(module.NewHTTPTrigger()) - - if err := engine.BuildFromConfig(cfg); err != nil { + engine, err := workflow.NewEngineBuilder(). + WithLogger(logger). + WithAllDefaults(). + BuildFromConfig(cfg) + if err != nil { fmt.Fprintf(os.Stderr, "failed to build workflow: %v\n", err) os.Exit(1) } diff --git a/cmd/wfctl/templates/full-stack/main.go.tmpl b/cmd/wfctl/templates/full-stack/main.go.tmpl index c4d171a0..bea44a92 100644 --- a/cmd/wfctl/templates/full-stack/main.go.tmpl +++ b/cmd/wfctl/templates/full-stack/main.go.tmpl @@ -9,11 +9,9 @@ import ( "os/signal" "syscall" - "github.com/CrisisTextLine/modular" "github.com/GoCodeAlone/workflow" "github.com/GoCodeAlone/workflow/config" - "github.com/GoCodeAlone/workflow/handlers" - "github.com/GoCodeAlone/workflow/module" + _ "github.com/GoCodeAlone/workflow/setup" ) func main() { @@ -41,16 +39,11 @@ func main() { os.Exit(1) } - app := modular.NewStdApplication(nil, logger) - engine := workflow.NewStdEngine(app, logger) - - engine.RegisterWorkflowHandler(handlers.NewHTTPWorkflowHandler()) - engine.RegisterWorkflowHandler(handlers.NewIntegrationWorkflowHandler()) - - engine.RegisterTrigger(module.NewHTTPTrigger()) - engine.RegisterTrigger(module.NewEventTrigger()) - - if err := engine.BuildFromConfig(cfg); err != nil { + engine, err := workflow.NewEngineBuilder(). + WithLogger(logger). + WithAllDefaults(). + BuildFromConfig(cfg) + if err != nil { fmt.Fprintf(os.Stderr, "failed to build workflow: %v\n", err) os.Exit(1) } diff --git a/engine_builder.go b/engine_builder.go new file mode 100644 index 00000000..00ae0337 --- /dev/null +++ b/engine_builder.go @@ -0,0 +1,273 @@ +package workflow + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/dynamic" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/plugin" +) + +// DefaultHandlerFactory is a function that returns a slice of default +// WorkflowHandler instances. It is set by the setup package via an init +// function to break the import cycle between the root workflow package +// and the handlers package. Import "github.com/GoCodeAlone/workflow/setup" +// (typically as a blank import) to register the default factories. +var DefaultHandlerFactory func() []WorkflowHandler + +// DefaultTriggerFactory is a function that returns a slice of default +// Trigger instances. It is set by the setup package. +var DefaultTriggerFactory func() []interfaces.Trigger + +// EngineBuilder provides a fluent API for constructing a fully-configured +// StdEngine. It encapsulates the boilerplate of registering workflow handlers, +// triggers, dynamic components, and plugins so that CLI tools, MCP servers, +// and other consumers can initialise an engine in a few lines. +// +// Import the setup package to register default handler and trigger factories: +// +// import ( +// "github.com/GoCodeAlone/workflow" +// _ "github.com/GoCodeAlone/workflow/setup" +// ) +// +// engine, err := workflow.NewEngineBuilder(). +// WithAllDefaults(). +// Build() +type EngineBuilder struct { + // Required dependencies — set via constructor or With* helpers. + app modular.Application + logger modular.Logger + + // Accumulator slices for deferred registration. + workflowHandlers []WorkflowHandler + triggers []interfaces.Trigger + plugins []plugin.EnginePlugin + + // Feature flags + useDynamicComponents bool + useDefaultHandlers bool + useDefaultTriggers bool + + // Optional overrides + pluginLoader *plugin.PluginLoader + configPath string + + // Track if the caller set explicit app/logger so Build can create defaults. + appSet bool + loggerSet bool +} + +// NewEngineBuilder creates a new EngineBuilder with no defaults configured. +// Call With* methods to add capabilities, then Build() to create the engine. +func NewEngineBuilder() *EngineBuilder { + return &EngineBuilder{ + workflowHandlers: make([]WorkflowHandler, 0), + triggers: make([]interfaces.Trigger, 0), + plugins: make([]plugin.EnginePlugin, 0), + } +} + +// WithApplication sets a custom modular.Application on the builder. +// If not called, Build() creates a default StdApplication. +func (b *EngineBuilder) WithApplication(app modular.Application) *EngineBuilder { + b.app = app + b.appSet = true + return b +} + +// WithLogger sets a custom logger on the builder. +// If not called, Build() creates a default slog.Logger writing to stdout. +func (b *EngineBuilder) WithLogger(logger modular.Logger) *EngineBuilder { + b.logger = logger + b.loggerSet = true + return b +} + +// WithDefaultHandlers registers all built-in workflow handlers: +// HTTP, Messaging, StateMachine, Scheduler, Integration, Pipeline, Event, Platform. +// Requires importing the setup package: import _ "github.com/GoCodeAlone/workflow/setup" +func (b *EngineBuilder) WithDefaultHandlers() *EngineBuilder { + b.useDefaultHandlers = true + return b +} + +// WithDefaultTriggers registers all built-in triggers: +// HTTP, Event, Schedule, EventBus, Reconciliation. +// Requires importing the setup package: import _ "github.com/GoCodeAlone/workflow/setup" +func (b *EngineBuilder) WithDefaultTriggers() *EngineBuilder { + b.useDefaultTriggers = true + return b +} + +// WithDynamicComponents sets up the dynamic interpreter pool, component +// registry, and loader for scripting support. +func (b *EngineBuilder) WithDynamicComponents() *EngineBuilder { + b.useDynamicComponents = true + return b +} + +// WithAllDefaults is a convenience method that enables default handlers, +// default triggers, and dynamic components. +// Requires importing the setup package: import _ "github.com/GoCodeAlone/workflow/setup" +func (b *EngineBuilder) WithAllDefaults() *EngineBuilder { + return b.WithDefaultHandlers().WithDefaultTriggers().WithDynamicComponents() +} + +// WithHandler adds a custom workflow handler to the engine. +func (b *EngineBuilder) WithHandler(handler WorkflowHandler) *EngineBuilder { + b.workflowHandlers = append(b.workflowHandlers, handler) + return b +} + +// WithTrigger adds a custom trigger to the engine. +func (b *EngineBuilder) WithTrigger(trigger interfaces.Trigger) *EngineBuilder { + b.triggers = append(b.triggers, trigger) + return b +} + +// WithPlugin adds a plugin to be loaded during Build(). +func (b *EngineBuilder) WithPlugin(p plugin.EnginePlugin) *EngineBuilder { + b.plugins = append(b.plugins, p) + return b +} + +// WithPlugins adds multiple plugins to be loaded during Build(). +func (b *EngineBuilder) WithPlugins(plugins ...plugin.EnginePlugin) *EngineBuilder { + b.plugins = append(b.plugins, plugins...) + return b +} + +// WithPluginLoader sets a custom plugin loader on the engine. +func (b *EngineBuilder) WithPluginLoader(loader *plugin.PluginLoader) *EngineBuilder { + b.pluginLoader = loader + return b +} + +// WithConfigPath stores a config file path for use with BuildAndConfigure(). +func (b *EngineBuilder) WithConfigPath(path string) *EngineBuilder { + b.configPath = path + return b +} + +// Build creates a fully-configured StdEngine from the builder's settings. +// It returns an error if any plugin fails to load. +func (b *EngineBuilder) Build() (*StdEngine, error) { + // Create defaults for app and logger if not set + if !b.loggerSet || b.logger == nil { + b.logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + } + if !b.appSet || b.app == nil { + b.app = modular.NewStdApplication(nil, b.logger) + } + + engine := NewStdEngine(b.app, b.logger) + + // Set custom plugin loader if provided + if b.pluginLoader != nil { + engine.SetPluginLoader(b.pluginLoader) + } + + // Register default handlers via factory (set in engine_builder_defaults.go) + if b.useDefaultHandlers && DefaultHandlerFactory != nil { + for _, h := range DefaultHandlerFactory() { + engine.RegisterWorkflowHandler(h) + } + } + + // Register custom handlers + for _, handler := range b.workflowHandlers { + engine.RegisterWorkflowHandler(handler) + } + + // Register default triggers via factory (set in engine_builder_defaults.go) + if b.useDefaultTriggers && DefaultTriggerFactory != nil { + for _, t := range DefaultTriggerFactory() { + engine.RegisterTrigger(t) + } + } + + // Register custom triggers + for _, trigger := range b.triggers { + engine.RegisterTrigger(trigger) + } + + // Set up dynamic components + if b.useDynamicComponents { + pool := dynamic.NewInterpreterPool() + registry := dynamic.NewComponentRegistry() + loader := dynamic.NewLoader(pool, registry) + engine.SetDynamicRegistry(registry) + engine.SetDynamicLoader(loader) + } + + // Load plugins + for _, p := range b.plugins { + if err := engine.LoadPlugin(p); err != nil { + return nil, fmt.Errorf("failed to load plugin %q: %w", p.Name(), err) + } + } + + return engine, nil +} + +// BuildFromConfig creates a fully-configured StdEngine and then loads +// and applies the configuration from the given WorkflowConfig. +func (b *EngineBuilder) BuildFromConfig(cfg *config.WorkflowConfig) (*StdEngine, error) { + engine, err := b.Build() + if err != nil { + return nil, err + } + if err := engine.BuildFromConfig(cfg); err != nil { + return nil, fmt.Errorf("failed to build from config: %w", err) + } + return engine, nil +} + +// BuildAndConfigure creates a fully-configured StdEngine and loads +// configuration from the file path set via WithConfigPath(). Returns +// an error if no config path was set or the file cannot be loaded. +func (b *EngineBuilder) BuildAndConfigure() (*StdEngine, error) { + if b.configPath == "" { + return nil, fmt.Errorf("no config path set; call WithConfigPath() first") + } + cfg, err := config.LoadFromFile(b.configPath) + if err != nil { + return nil, fmt.Errorf("failed to load config %q: %w", b.configPath, err) + } + return b.BuildFromConfig(cfg) +} + +// RunUntilSignal is a convenience method for CLI tools that creates the +// engine, loads config, starts it, and blocks until a termination signal +// (SIGINT/SIGTERM) is received. It handles graceful shutdown automatically. +func (b *EngineBuilder) RunUntilSignal(cfg *config.WorkflowConfig) error { + engine, err := b.BuildFromConfig(cfg) + if err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := engine.Start(ctx); err != nil { + return fmt.Errorf("failed to start engine: %w", err) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + cancel() + if err := engine.Stop(context.Background()); err != nil { + return fmt.Errorf("shutdown error: %w", err) + } + return nil +} diff --git a/engine_builder_test.go b/engine_builder_test.go new file mode 100644 index 00000000..e86a99eb --- /dev/null +++ b/engine_builder_test.go @@ -0,0 +1,411 @@ +package workflow + +import ( + "context" + "log/slog" + "os" + "testing" + "time" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/handlers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/mock" + "github.com/GoCodeAlone/workflow/module" +) + +func init() { + // Register default factories for tests. In production code, the + // setup package does this via a blank import. + if DefaultHandlerFactory == nil { + DefaultHandlerFactory = func() []WorkflowHandler { + return []WorkflowHandler{ + handlers.NewHTTPWorkflowHandler(), + handlers.NewMessagingWorkflowHandler(), + handlers.NewStateMachineWorkflowHandler(), + handlers.NewSchedulerWorkflowHandler(), + handlers.NewIntegrationWorkflowHandler(), + handlers.NewPipelineWorkflowHandler(), + handlers.NewEventWorkflowHandler(), + handlers.NewPlatformWorkflowHandler(), + } + } + } + if DefaultTriggerFactory == nil { + DefaultTriggerFactory = func() []interfaces.Trigger { + return []interfaces.Trigger{ + module.NewHTTPTrigger(), + module.NewEventTrigger(), + module.NewScheduleTrigger(), + module.NewEventBusTrigger(), + module.NewReconciliationTrigger(), + } + } + } +} + +func TestEngineBuilder_Build_Defaults(t *testing.T) { + // Build with no options — should create engine with default app/logger + engine, err := NewEngineBuilder().Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if engine == nil { + t.Fatal("Build() returned nil engine") + } + if engine.App() == nil { + t.Fatal("engine.App() is nil; expected default application") + } +} + +func TestEngineBuilder_Build_CustomAppAndLogger(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if engine.App() != app { + t.Error("engine.App() does not match provided application") + } +} + +func TestEngineBuilder_WithDefaultHandlers(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithDefaultHandlers(). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + // Verify that the engine can handle known workflow types + workflowTypes := []string{"http", "messaging", "statemachine", "scheduler", "integration"} + for _, wt := range workflowTypes { + found := false + for _, handler := range engine.workflowHandlers { + if handler.CanHandle(wt) { + found = true + break + } + } + if !found { + t.Errorf("expected handler for workflow type %q to be registered", wt) + } + } +} + +func TestEngineBuilder_WithDefaultTriggers(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithDefaultTriggers(). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + // There should be at least 5 triggers (HTTP, Event, Schedule, EventBus, Reconciliation) + if len(engine.triggers) < 5 { + t.Errorf("expected at least 5 triggers, got %d", len(engine.triggers)) + } + + // Verify specific trigger names + triggerNames := make(map[string]bool) + for _, trigger := range engine.triggers { + triggerNames[trigger.Name()] = true + } + expectedTriggers := []string{"trigger.http", "trigger.event", "trigger.schedule"} + for _, name := range expectedTriggers { + if !triggerNames[name] { + t.Errorf("expected trigger %q to be registered", name) + } + } +} + +func TestEngineBuilder_WithDynamicComponents(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithDynamicComponents(). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if engine.dynamicRegistry == nil { + t.Error("expected dynamicRegistry to be set") + } + if engine.dynamicLoader == nil { + t.Error("expected dynamicLoader to be set") + } +} + +func TestEngineBuilder_WithAllDefaults(t *testing.T) { + engine, err := NewEngineBuilder().WithAllDefaults().Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + // Should have handlers, triggers, and dynamic components + if len(engine.workflowHandlers) == 0 { + t.Error("expected workflow handlers to be registered") + } + if len(engine.triggers) == 0 { + t.Error("expected triggers to be registered") + } + if engine.dynamicRegistry == nil { + t.Error("expected dynamicRegistry to be set") + } + if engine.dynamicLoader == nil { + t.Error("expected dynamicLoader to be set") + } +} + +func TestEngineBuilder_WithCustomHandler(t *testing.T) { + handler := handlers.NewHTTPWorkflowHandler() + engine, err := NewEngineBuilder(). + WithHandler(handler). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if len(engine.workflowHandlers) != 1 { + t.Errorf("expected 1 handler, got %d", len(engine.workflowHandlers)) + } +} + +func TestEngineBuilder_WithCustomTrigger(t *testing.T) { + trigger := module.NewHTTPTrigger() + engine, err := NewEngineBuilder(). + WithTrigger(trigger). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if len(engine.triggers) != 1 { + t.Errorf("expected 1 trigger, got %d", len(engine.triggers)) + } +} + +func TestEngineBuilder_WithPlugins(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithPlugins(allPlugins()...). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + loadedPlugins := engine.LoadedPlugins() + if len(loadedPlugins) == 0 { + t.Error("expected at least one loaded plugin") + } +} + +func TestEngineBuilder_WithPlugin(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + // Pick just one plugin to test single-plugin loading + plugins := allPlugins() + if len(plugins) == 0 { + t.Skip("no plugins available") + } + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithPlugin(plugins[0]). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if len(engine.LoadedPlugins()) != 1 { + t.Errorf("expected 1 loaded plugin, got %d", len(engine.LoadedPlugins())) + } +} + +func TestEngineBuilder_Chaining(t *testing.T) { + // Test that all builder methods return the same builder for chaining + mockLogger := &mock.Logger{LogEntries: make([]string, 0)} + mockApp := modular.NewStdApplication(modular.NewStdConfigProvider(nil), mockLogger) + b := NewEngineBuilder() + result := b. + WithApplication(mockApp). + WithLogger(mockLogger). + WithDefaultHandlers(). + WithDefaultTriggers(). + WithDynamicComponents(). + WithAllDefaults(). + WithHandler(&builderTestHandler{canHandleType: "test"}). + WithTrigger(module.NewHTTPTrigger()). + WithPlugins(). + WithPluginLoader(nil). + WithConfigPath("test.yaml") + + if result != b { + t.Error("expected chained calls to return the same builder") + } +} + +func TestEngineBuilder_BuildFromConfig(t *testing.T) { + logger := &mock.Logger{LogEntries: make([]string, 0)} + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + } + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithAllDefaults(). + WithPlugins(allPlugins()...). + BuildFromConfig(cfg) + if err != nil { + t.Fatalf("BuildFromConfig() error: %v", err) + } + if engine == nil { + t.Fatal("BuildFromConfig() returned nil engine") + } +} + +func TestEngineBuilder_BuildAndConfigure_NoPath(t *testing.T) { + _, err := NewEngineBuilder().BuildAndConfigure() + if err == nil { + t.Fatal("expected error when no config path is set") + } + if err.Error() != "no config path set; call WithConfigPath() first" { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestEngineBuilder_BuildAndConfigure_InvalidPath(t *testing.T) { + _, err := NewEngineBuilder(). + WithConfigPath("/nonexistent/path.yaml"). + BuildAndConfigure() + if err == nil { + t.Fatal("expected error for invalid config path") + } +} + +func TestEngineBuilder_StartStop(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError})) + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + } + + engine, err := NewEngineBuilder(). + WithApplication(app). + WithLogger(logger). + WithAllDefaults(). + WithPlugins(allPlugins()...). + BuildFromConfig(cfg) + if err != nil { + t.Fatalf("BuildFromConfig() error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := engine.Start(ctx); err != nil { + t.Fatalf("Start() error: %v", err) + } + + if err := engine.Stop(ctx); err != nil { + t.Fatalf("Stop() error: %v", err) + } +} + +func TestEngineBuilder_DefaultsWithoutExplicitApp(t *testing.T) { + // Build with all defaults but no explicit app — should work + engine, err := NewEngineBuilder(). + WithAllDefaults(). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + if engine == nil { + t.Fatal("expected non-nil engine") + } + if engine.App() == nil { + t.Fatal("expected default app to be created") + } +} + +func TestEngineBuilder_CombineDefaultAndCustomHandlers(t *testing.T) { + customHandler := &builderTestHandler{canHandleType: "custom"} + + engine, err := NewEngineBuilder(). + WithDefaultHandlers(). + WithHandler(customHandler). + Build() + if err != nil { + t.Fatalf("Build() error: %v", err) + } + + // Should have the default handlers plus the custom one + foundCustom := false + for _, h := range engine.workflowHandlers { + if h.CanHandle("custom") { + foundCustom = true + break + } + } + if !foundCustom { + t.Error("custom handler not found among registered handlers") + } + + // Should also have default handlers + if !hasHandlerForType(engine, "http") { + t.Error("expected default HTTP handler to be registered") + } +} + +// builderTestHandler is a simple test double for WorkflowHandler used by builder tests. +type builderTestHandler struct { + canHandleType string +} + +func (m *builderTestHandler) CanHandle(workflowType string) bool { + return workflowType == m.canHandleType +} + +func (m *builderTestHandler) ConfigureWorkflow(_ modular.Application, _ any) error { + return nil +} + +func (m *builderTestHandler) ExecuteWorkflow(_ context.Context, _ string, _ string, _ map[string]any) (map[string]any, error) { + return nil, nil +} + +func hasHandlerForType(engine *StdEngine, workflowType string) bool { + for _, handler := range engine.workflowHandlers { + if handler.CanHandle(workflowType) { + return true + } + } + return false +} diff --git a/mcp/server.go b/mcp/server.go index ff3afcb8..1d311d1d 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -25,22 +25,53 @@ import ( // Version is the MCP server version, set at build time. var Version = "dev" +// EngineProvider is the interface that the MCP server requires from the +// workflow engine. It is kept intentionally narrow so that the mcp package +// does not import the root workflow package directly. +type EngineProvider interface { + // BuildFromConfig builds the engine from a parsed workflow config. + BuildFromConfig(cfg *config.WorkflowConfig) error + // Start starts the engine and all registered triggers. + Start(ctx context.Context) error + // Stop gracefully shuts down the engine. + Stop(ctx context.Context) error + // TriggerWorkflow dispatches a workflow execution. + TriggerWorkflow(ctx context.Context, workflowType string, action string, data map[string]any) error +} + +// ServerOption configures optional Server behaviour. +type ServerOption func(*Server) + +// WithEngine attaches a pre-built workflow engine to the MCP server, +// enabling the run_workflow tool for AI-driven workflow execution. +func WithEngine(engine EngineProvider) ServerOption { + return func(s *Server) { + s.engine = engine + } +} + // Server wraps an MCP server instance and provides workflow-engine-specific // tools and resources. type Server struct { mcpServer *server.MCPServer pluginDir string + engine EngineProvider // optional; enables execution tools when set } // NewServer creates a new MCP server with all workflow engine tools and // resources registered. pluginDir is the directory where installed plugins // reside (e.g., "data/plugins"). If set, the server will read plugin manifests // from this directory and include plugin-provided types in all type listings. -func NewServer(pluginDir string) *Server { +// Optional ServerOption values can be provided to attach an engine, etc. +func NewServer(pluginDir string, opts ...ServerOption) *Server { s := &Server{ pluginDir: pluginDir, } + for _, opt := range opts { + opt(s) + } + s.mcpServer = server.NewMCPServer( "workflow-mcp-server", Version, @@ -174,6 +205,28 @@ func (s *Server) registerTools() { ), s.handleGetConfigSkeleton, ) + + // run_workflow - only available when an engine is attached + if s.engine != nil { + s.mcpServer.AddTool( + mcp.NewTool("run_workflow", + mcp.WithDescription("Trigger a workflow execution on the attached engine. Requires the server to be started with an engine (WithEngine option). "+ + "Provide the workflow type, action, and optional data payload."), + mcp.WithString("workflow_type", + mcp.Required(), + mcp.Description("The workflow type to trigger (e.g., 'http', 'messaging', 'pipeline')"), + ), + mcp.WithString("action", + mcp.Required(), + mcp.Description("The action to perform within the workflow"), + ), + mcp.WithObject("data", + mcp.Description("Key-value data payload to pass to the workflow"), + ), + ), + s.handleRunWorkflow, + ) + } } // registerResources registers documentation and example resources. @@ -462,6 +515,47 @@ func (s *Server) handleGetConfigSkeleton(_ context.Context, req mcp.CallToolRequ return mcp.NewToolResultText(yaml), nil } +func (s *Server) handleRunWorkflow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if s.engine == nil { + return mcp.NewToolResultError("no engine attached; start the server with WithEngine option"), nil + } + + workflowType := mcp.ParseString(req, "workflow_type", "") + if workflowType == "" { + return mcp.NewToolResultError("workflow_type is required"), nil + } + + action := mcp.ParseString(req, "action", "") + if action == "" { + return mcp.NewToolResultError("action is required"), nil + } + + var data map[string]any + if rawData, ok := req.Params.Arguments["data"]; ok && rawData != nil { + if d, ok := rawData.(map[string]any); ok { + data = d + } + } + if data == nil { + data = make(map[string]any) + } + + if err := s.engine.TriggerWorkflow(ctx, workflowType, action, data); err != nil { + result := map[string]any{ + "success": false, + "error": err.Error(), + } + return marshalToolResult(result) + } + + result := map[string]any{ + "success": true, + "workflow_type": workflowType, + "action": action, + } + return marshalToolResult(result) +} + // --- Resource Handlers --- func (s *Server) handleDocsOverview(_ context.Context, _ mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { diff --git a/mcp/server_test.go b/mcp/server_test.go index b0b092d3..3081d829 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -3,10 +3,12 @@ package mcp import ( "context" "encoding/json" + "fmt" "os" "strings" "testing" + "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/schema" "github.com/mark3labs/mcp-go/mcp" ) @@ -832,3 +834,165 @@ func createTestPlugin(dir, version string) error { data := []byte(`{"name":"test-plugin","version":"` + version + `"}`) return os.WriteFile(dir+"/plugin.json", data, 0640) //nolint:gosec // G306: test helper } + +// --- Engine integration tests --- + +// mockEngine implements EngineProvider for testing. +type mockEngine struct { + triggerCalled bool + triggerType string + triggerAction string + triggerData map[string]any + triggerErr error +} + +func (m *mockEngine) BuildFromConfig(_ *config.WorkflowConfig) error { return nil } +func (m *mockEngine) Start(_ context.Context) error { return nil } +func (m *mockEngine) Stop(_ context.Context) error { return nil } +func (m *mockEngine) TriggerWorkflow(_ context.Context, workflowType string, action string, data map[string]any) error { + m.triggerCalled = true + m.triggerType = workflowType + m.triggerAction = action + m.triggerData = data + return m.triggerErr +} + +func TestNewServer_WithEngine(t *testing.T) { + engine := &mockEngine{} + srv := NewServer("", WithEngine(engine)) + if srv == nil { + t.Fatal("NewServer returned nil") + } + if srv.engine == nil { + t.Fatal("engine was not set on server") + } +} + +func TestRunWorkflow_Success(t *testing.T) { + engine := &mockEngine{} + srv := NewServer("", WithEngine(engine)) + + req := makeCallToolRequest(map[string]any{ + "workflow_type": "http", + "action": "handle_request", + "data": map[string]any{"key": "value"}, + }) + + result, err := srv.handleRunWorkflow(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["success"] != true { + t.Errorf("expected success=true, got %v", data["success"]) + } + if !engine.triggerCalled { + t.Error("expected engine.TriggerWorkflow to be called") + } + if engine.triggerType != "http" { + t.Errorf("expected workflow_type 'http', got %q", engine.triggerType) + } + if engine.triggerAction != "handle_request" { + t.Errorf("expected action 'handle_request', got %q", engine.triggerAction) + } +} + +func TestRunWorkflow_Error(t *testing.T) { + engine := &mockEngine{triggerErr: fmt.Errorf("workflow failed")} + srv := NewServer("", WithEngine(engine)) + + req := makeCallToolRequest(map[string]any{ + "workflow_type": "http", + "action": "handle_request", + }) + + result, err := srv.handleRunWorkflow(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + var data map[string]any + if err := json.Unmarshal([]byte(text), &data); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if data["success"] != false { + t.Errorf("expected success=false, got %v", data["success"]) + } + if !contains(data["error"].(string), "workflow failed") { + t.Errorf("expected error message, got %v", data["error"]) + } +} + +func TestRunWorkflow_NoEngine(t *testing.T) { + srv := NewServer("") + + req := makeCallToolRequest(map[string]any{ + "workflow_type": "http", + "action": "handle_request", + }) + + result, err := srv.handleRunWorkflow(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + text := extractText(t, result) + if !contains(text, "no engine attached") { + t.Errorf("expected 'no engine attached' error, got %q", text) + } +} + +func TestRunWorkflow_MissingParams(t *testing.T) { + engine := &mockEngine{} + srv := NewServer("", WithEngine(engine)) + + // Missing workflow_type + req := makeCallToolRequest(map[string]any{ + "action": "handle_request", + }) + result, err := srv.handleRunWorkflow(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + text := extractText(t, result) + if !contains(text, "workflow_type is required") { + t.Errorf("expected 'workflow_type is required', got %q", text) + } + + // Missing action + req = makeCallToolRequest(map[string]any{ + "workflow_type": "http", + }) + result, err = srv.handleRunWorkflow(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + text = extractText(t, result) + if !contains(text, "action is required") { + t.Errorf("expected 'action is required', got %q", text) + } +} + +func TestWithEngine_Option(t *testing.T) { + engine := &mockEngine{} + + // Server without engine should not have run_workflow tool + srvNoEngine := NewServer("") + if srvNoEngine.engine != nil { + t.Error("server without engine should have nil engine") + } + + // Server with engine should have run_workflow tool + srvWithEngine := NewServer("", WithEngine(engine)) + if srvWithEngine.engine == nil { + t.Error("server with engine should have non-nil engine") + } +} diff --git a/setup/setup.go b/setup/setup.go new file mode 100644 index 00000000..175f867f --- /dev/null +++ b/setup/setup.go @@ -0,0 +1,55 @@ +// Package setup bridges the root workflow package with the handlers and +// module packages to register default workflow handlers and triggers. +// It exists to break the import cycle that would occur if the root package +// directly imported handlers (which has test files that import the root package). +// +// Typical usage is to blank-import this package so that its init function +// registers the default handler and trigger factories with the workflow +// engine, and then build an engine using the workflow package: +// +// import ( +// "github.com/GoCodeAlone/workflow" +// _ "github.com/GoCodeAlone/workflow/setup" +// ) +// +// engine, err := workflow.NewEngineBuilder(). +// WithAllDefaults(). +// Build() +package setup + +import ( + "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/handlers" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +func init() { + workflow.DefaultHandlerFactory = DefaultHandlers + workflow.DefaultTriggerFactory = DefaultTriggers +} + +// DefaultHandlers returns all built-in workflow handlers. +func DefaultHandlers() []workflow.WorkflowHandler { + return []workflow.WorkflowHandler{ + handlers.NewHTTPWorkflowHandler(), + handlers.NewMessagingWorkflowHandler(), + handlers.NewStateMachineWorkflowHandler(), + handlers.NewSchedulerWorkflowHandler(), + handlers.NewIntegrationWorkflowHandler(), + handlers.NewPipelineWorkflowHandler(), + handlers.NewEventWorkflowHandler(), + handlers.NewPlatformWorkflowHandler(), + } +} + +// DefaultTriggers returns all built-in triggers. +func DefaultTriggers() []interfaces.Trigger { + return []interfaces.Trigger{ + module.NewHTTPTrigger(), + module.NewEventTrigger(), + module.NewScheduleTrigger(), + module.NewEventBusTrigger(), + module.NewReconciliationTrigger(), + } +} diff --git a/setup/setup_test.go b/setup/setup_test.go new file mode 100644 index 00000000..6c35a122 --- /dev/null +++ b/setup/setup_test.go @@ -0,0 +1,27 @@ +package setup + +import ( + "testing" +) + +func TestDefaultHandlers(t *testing.T) { + handlers := DefaultHandlers() + if len(handlers) == 0 { + t.Fatal("expected at least one default handler") + } + // Should have 8 built-in handlers + if len(handlers) != 8 { + t.Errorf("expected 8 default handlers, got %d", len(handlers)) + } +} + +func TestDefaultTriggers(t *testing.T) { + triggers := DefaultTriggers() + if len(triggers) == 0 { + t.Fatal("expected at least one default trigger") + } + // Should have 5 built-in triggers + if len(triggers) != 5 { + t.Errorf("expected 5 default triggers, got %d", len(triggers)) + } +}