From 769bf3c591e5789020cfd708322fcdb780c933f6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 23 Feb 2026 07:21:29 -0500 Subject: [PATCH 1/7] refactor: extract Trigger, EventEmitter, MetricsRecorder interfaces from module package (#84) Phase 4 engine decomposition progress: - Move Trigger interface to interfaces/ package (module.Trigger is now a type alias) - Add EventEmitter and MetricsRecorder interfaces in interfaces/ - Add TriggerRegistrar interface for registry abstraction - Create engine_module_bridge.go to isolate remaining module/ imports - Update engine.go fields to use interface types instead of concrete module types - Fix mock GetService to support interface assignment (matching real modular behavior) - Add 10 new tests for requires.plugins validation (15 test runs with subtests) Closes #84 (partial) Co-Authored-By: Claude Opus 4.6 --- engine.go | 66 +++--- engine_module_bridge.go | 93 +++++++++ engine_test.go | 360 ++++++++++++++++++++++++++++++++- interfaces/events.go | 23 +++ interfaces/trigger.go | 25 +++ module/command_handler_test.go | 1 - module/query_handler_test.go | 1 - module/trigger.go | 19 +- plugins/api/plugin.go | 14 +- 9 files changed, 538 insertions(+), 64 deletions(-) create mode 100644 engine_module_bridge.go create mode 100644 interfaces/events.go create mode 100644 interfaces/trigger.go diff --git a/engine.go b/engine.go index 44089000..3fc9d9ed 100644 --- a/engine.go +++ b/engine.go @@ -56,15 +56,19 @@ type StdEngine struct { moduleFactories map[string]ModuleFactory logger modular.Logger modules []modular.Module - triggers []module.Trigger - triggerRegistry *module.TriggerRegistry + triggers []interfaces.Trigger + triggerRegistry interfaces.TriggerRegistrar dynamicRegistry *dynamic.ComponentRegistry dynamicLoader *dynamic.Loader - eventEmitter *module.WorkflowEventEmitter + eventEmitter interfaces.EventEmitter secretsResolver *secrets.MultiResolver - stepRegistry *module.StepRegistry - pluginInstaller *plugin.PluginInstaller - configDir string // directory of the config file, for resolving relative paths + // stepRegistry is a concrete *module.StepRegistry because StepFactory and + // PipelineStep are module-level types not yet abstracted in interfaces. + // TODO(phase5): move StepFactory/PipelineStep to interfaces and change this + // field to interfaces.StepRegistrar. + stepRegistry *module.StepRegistry + pluginInstaller *plugin.PluginInstaller + configDir string // directory of the config file, for resolving relative paths // triggerTypeMap maps trigger config type keys (e.g., "http", "schedule") // to trigger names (e.g., "trigger.http", "trigger.schedule"). Populated @@ -124,10 +128,10 @@ func NewStdEngine(app modular.Application, logger modular.Logger) *StdEngine { moduleFactories: make(map[string]ModuleFactory), logger: logger, modules: make([]modular.Module, 0), - triggers: make([]module.Trigger, 0), - triggerRegistry: module.NewTriggerRegistry(), + triggers: make([]interfaces.Trigger, 0), + triggerRegistry: newTriggerRegistrar(), // bridge: returns *module.TriggerRegistry secretsResolver: secrets.NewMultiResolver(), - stepRegistry: module.NewStepRegistry(), + stepRegistry: newStepRegistry(), // bridge: returns *module.StepRegistry triggerTypeMap: make(map[string]string), triggerConfigWrappers: make(map[string]plugin.TriggerConfigWrapperFunc), } @@ -144,7 +148,7 @@ func (e *StdEngine) RegisterWorkflowHandler(handler WorkflowHandler) { } // RegisterTrigger registers a trigger with the engine -func (e *StdEngine) RegisterTrigger(trigger module.Trigger) { +func (e *StdEngine) RegisterTrigger(trigger interfaces.Trigger) { e.triggers = append(e.triggers, trigger) e.triggerRegistry.RegisterTrigger(trigger) } @@ -205,27 +209,16 @@ func (e *StdEngine) LoadPlugin(p plugin.EnginePlugin) error { schema.RegisterModuleType(typeName) } for typeName, factory := range p.StepFactories() { - stepFactory := factory - capturedType := typeName - e.stepRegistry.Register(typeName, func(name string, cfg map[string]any, app modular.Application) (module.PipelineStep, error) { - result, err := stepFactory(name, cfg, app) - if err != nil { - return nil, err - } - if step, ok := result.(module.PipelineStep); ok { - return step, nil - } - return nil, fmt.Errorf("step factory for %q returned non-PipelineStep type", capturedType) - }) + // Delegate to the bridge helper which type-asserts to module.PipelineStep + // so that engine.go need not reference that concrete type directly. + e.registerPluginSteps(typeName, factory) } // Register triggers from plugin. The factory map key is the trigger // config type (e.g., "http", "schedule") used in YAML configs. for triggerType, factory := range p.TriggerFactories() { - result := factory() - if trigger, ok := result.(module.Trigger); ok { - e.triggerTypeMap[triggerType] = trigger.Name() - e.RegisterTrigger(trigger) - } + // Delegate to the bridge helper; triggers are interfaces.Trigger values + // (module.Trigger is a type alias for interfaces.Trigger). + e.registerPluginTrigger(triggerType, factory) } // Register pipeline trigger config wrappers from plugin (optional interface). @@ -389,8 +382,8 @@ func (e *StdEngine) BuildFromConfig(cfg *config.WorkflowConfig) error { e.logger.Debug("Loaded service: " + name) } - // Initialize the workflow event emitter - e.eventEmitter = module.NewWorkflowEventEmitter(e.app) + // Initialize the workflow event emitter via bridge (avoids direct module dep). + e.eventEmitter = newEventEmitter(e.app) // Register config section for workflow e.app.RegisterConfigSection("workflow", modular.NewStdConfigProvider(cfg)) @@ -529,14 +522,9 @@ func (e *StdEngine) TriggerWorkflow(ctx context.Context, workflowType string, ac return fmt.Errorf("no handler found for workflow type: %s", workflowType) } -// recordWorkflowMetrics records workflow execution metrics if the metrics collector is available. -func (e *StdEngine) recordWorkflowMetrics(workflowType, action, status string, duration time.Duration) { - var mc *module.MetricsCollector - if err := e.app.GetService("metrics.collector", &mc); err == nil && mc != nil { - mc.RecordWorkflowExecution(workflowType, action, status) - mc.RecordWorkflowDuration(workflowType, action, duration) - } -} +// recordWorkflowMetrics is defined in engine_module_bridge.go. +// It records execution metrics via interfaces.MetricsRecorder so that engine.go +// need not reference the concrete *module.MetricsCollector type. // configureTriggers sets up all triggers from configuration func (e *StdEngine) configureTriggers(triggerConfigs map[string]any) error { @@ -577,7 +565,7 @@ func (e *StdEngine) configureTriggers(triggerConfigs map[string]any) error { // by looking up the trigger type in the engine's registry. Falls back to // matching the trigger name directly (e.g., trigger type "mock" matches // trigger name "mock.trigger" via "trigger." convention). -func (e *StdEngine) canHandleTrigger(trigger module.Trigger, triggerType string) bool { +func (e *StdEngine) canHandleTrigger(trigger interfaces.Trigger, triggerType string) bool { // Check the trigger type registry first (populated by LoadPlugin and RegisterTriggerType) if expectedName, ok := e.triggerTypeMap[triggerType]; ok { return trigger.Name() == expectedName @@ -863,7 +851,7 @@ func (e *StdEngine) LoadedPlugins() []plugin.EnginePlugin { type Engine interface { RegisterWorkflowHandler(handler WorkflowHandler) - RegisterTrigger(trigger module.Trigger) + RegisterTrigger(trigger interfaces.Trigger) AddModuleType(moduleType string, factory ModuleFactory) BuildFromConfig(cfg *config.WorkflowConfig) error Start(ctx context.Context) error diff --git a/engine_module_bridge.go b/engine_module_bridge.go new file mode 100644 index 00000000..947eca65 --- /dev/null +++ b/engine_module_bridge.go @@ -0,0 +1,93 @@ +package workflow + +// engine_module_bridge.go bridges the engine core (engine.go) with concrete +// module implementations that cannot yet be abstracted away without a larger +// refactor. This file intentionally imports the module package so that +// engine.go itself need not import it for these specific operations. +// +// Remaining blockers preventing full engine.go ↔ module decoupling: +// +// 1. module.StepRegistry / module.StepFactory / module.PipelineStep — the +// StepFactory signature returns (PipelineStep, error), and PipelineStep.Execute +// takes *PipelineContext, both of which are concrete types in the module package. +// Moving them to interfaces would require updating 70+ files in module/. Deferred. +// +// 2. module.Pipeline struct construction (configurePipelines, +// configureRoutePipelines, buildPipelineSteps) — depends on module.PipelineStep +// slices and module.ErrorStrategy constants. These functions live in engine.go +// and are candidates for moving here once StepFactory is abstracted. +// +// What HAS been cleaned up in this phase: +// - module.Trigger → interfaces.Trigger (type alias in module; engine uses interfaces) +// - module.WorkflowEventEmitter → interfaces.EventEmitter (engine field + bridge ctor) +// - module.TriggerRegistry → interfaces.TriggerRegistrar (engine field + bridge ctor) +// - recordWorkflowMetrics → uses interfaces.MetricsRecorder (no concrete *MetricsCollector) +// - Plugin step/trigger wiring → bridge helpers (registerPluginSteps, registerPluginTrigger) + +import ( + "fmt" + "time" + + "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" +) + +// newTriggerRegistrar creates the default concrete trigger registry. +// Called from NewStdEngine so that engine.go need not import module. +func newTriggerRegistrar() interfaces.TriggerRegistrar { + return module.NewTriggerRegistry() +} + +// newStepRegistry creates the default concrete step registry. +// *module.StepRegistry satisfies interfaces.StepRegistryProvider and is used +// as a concrete type in engine.go until StepFactory is fully abstracted. +func newStepRegistry() *module.StepRegistry { + return module.NewStepRegistry() +} + +// newEventEmitter creates a WorkflowEventEmitter from the application service +// registry. Called from BuildFromConfig after app.Init(). +func newEventEmitter(app modular.Application) interfaces.EventEmitter { + return module.NewWorkflowEventEmitter(app) +} + +// recordWorkflowMetrics records workflow execution metrics if the metrics +// collector service is available. Uses interfaces.MetricsRecorder so that +// engine.go need not hold a concrete *module.MetricsCollector pointer. +func (e *StdEngine) recordWorkflowMetrics(workflowType, action, status string, duration time.Duration) { + var mr interfaces.MetricsRecorder + if err := e.app.GetService("metrics.collector", &mr); err == nil && mr != nil { + mr.RecordWorkflowExecution(workflowType, action, status) + mr.RecordWorkflowDuration(workflowType, action, duration) + } +} + +// registerPluginSteps wires step factories from a plugin into the engine's +// step registry. Lives here (instead of LoadPlugin in engine.go) because it +// type-asserts the factory result to module.PipelineStep. +func (e *StdEngine) registerPluginSteps(typeName string, stepFactory func(name string, cfg map[string]any, app modular.Application) (any, error)) { + capturedType := typeName + e.stepRegistry.Register(typeName, func(name string, cfg map[string]any, app modular.Application) (module.PipelineStep, error) { + result, err := stepFactory(name, cfg, app) + if err != nil { + return nil, err + } + if step, ok := result.(module.PipelineStep); ok { + return step, nil + } + return nil, fmt.Errorf("step factory for %q returned non-PipelineStep type", capturedType) + }) +} + +// registerPluginTrigger wires a trigger from a plugin into the engine. +// Lives here to avoid a direct module.Trigger type assertion in engine.go. +// Since module.Trigger is now an alias for interfaces.Trigger, the assertion +// uses the canonical interface type. +func (e *StdEngine) registerPluginTrigger(triggerType string, factory func() any) { + result := factory() + if trigger, ok := result.(interfaces.Trigger); ok { + e.triggerTypeMap[triggerType] = trigger.Name() + e.RegisterTrigger(trigger) + } +} diff --git a/engine_test.go b/engine_test.go index 96740c64..2faecbc3 100644 --- a/engine_test.go +++ b/engine_test.go @@ -17,6 +17,7 @@ import ( "github.com/GoCodeAlone/workflow/handlers" "github.com/GoCodeAlone/workflow/mock" "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" ) // setupEngineTest creates an isolated test environment for engine tests @@ -269,7 +270,9 @@ func (a *mockApplication) Init() error { return nil } -// GetService retrieves a service by name and populates the out parameter if provided +// GetService retrieves a service by name and populates the out parameter if provided. +// Supports both direct type assignment and interface satisfaction checks (matching +// the behaviour of the real modular.StdApplication.GetService). func (a *mockApplication) GetService(name string, out any) error { svc, ok := a.services[name] if !ok { @@ -290,14 +293,24 @@ func (a *mockApplication) GetService(name string, out any) error { return fmt.Errorf("out parameter cannot be set") } - // Set the value if compatible svcVal := reflect.ValueOf(svc) - if !svcVal.Type().AssignableTo(outVal.Type()) { - return fmt.Errorf("service type %s not assignable to out parameter type %s", - svcVal.Type(), outVal.Type()) + svcType := svcVal.Type() + targetType := outVal.Type() + + // Case 1: target is an interface that the service implements + if targetType.Kind() == reflect.Interface && svcType.Implements(targetType) { + outVal.Set(svcVal) + return nil + } + + // Case 2: direct type assignment + if svcType.AssignableTo(targetType) { + outVal.Set(svcVal) + return nil } - outVal.Set(svcVal) + return fmt.Errorf("service type %s not assignable to out parameter type %s", + svcType, targetType) } return nil @@ -1249,3 +1262,338 @@ func TestCanHandleTrigger_EventBus(t *testing.T) { t.Errorf("canHandleTrigger(%q, %q) = false, want true", module.EventBusTriggerName, "eventbus") } } + +// ============================================================================ +// Tests for requires.plugins validation (Phase 4 - Engine Decomposition) +// ============================================================================ + +// minimalPlugin is a test-only EnginePlugin with a controllable name/version. +type minimalPlugin struct { + plugin.BaseEnginePlugin +} + +func newMinimalPlugin(name, version string) *minimalPlugin { + return &minimalPlugin{ + BaseEnginePlugin: plugin.BaseEnginePlugin{ + BaseNativePlugin: plugin.BaseNativePlugin{ + PluginName: name, + PluginVersion: version, + }, + Manifest: plugin.PluginManifest{ + Name: name, + Version: version, + Author: "test", + Description: "test plugin for requires validation", + }, + }, + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_NilRequires verifies that a +// config with no requires section is accepted without error. +func TestEngine_BuildFromConfig_RequiresPlugins_NilRequires(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: nil, + } + + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error for nil Requires, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_PluginLoaded verifies that a +// required plugin that is loaded passes validation. +func TestEngine_BuildFromConfig_RequiresPlugins_PluginLoaded(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + // Load a minimal plugin with a known name. + p := newMinimalPlugin("my-plugin", "1.2.3") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "my-plugin"}, + }, + }, + } + + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error when required plugin is loaded, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_PluginNotLoaded verifies that a +// required plugin that is NOT loaded produces a clear error. +func TestEngine_BuildFromConfig_RequiresPlugins_PluginNotLoaded(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + // Do NOT load "missing-plugin" — only load an unrelated one. + p := newMinimalPlugin("other-plugin", "1.0.0") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "missing-plugin"}, + }, + }, + } + + err := engine.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error when required plugin is not loaded") + } + if !strings.Contains(err.Error(), "missing-plugin") { + t.Errorf("expected error to mention missing plugin name, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_VersionSatisfied verifies that +// a semver constraint that IS satisfied passes validation. +func TestEngine_BuildFromConfig_RequiresPlugins_VersionSatisfied(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + p := newMinimalPlugin("versioned-plugin", "2.5.1") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "versioned-plugin", Version: ">=2.0.0"}, + }, + }, + } + + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error for satisfied version constraint, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_VersionNotSatisfied verifies that +// a semver constraint that is NOT satisfied returns a meaningful error. +func TestEngine_BuildFromConfig_RequiresPlugins_VersionNotSatisfied(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + p := newMinimalPlugin("versioned-plugin", "1.0.0") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "versioned-plugin", Version: ">=2.0.0"}, + }, + }, + } + + err := engine.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error for unsatisfied version constraint") + } + if !strings.Contains(err.Error(), "versioned-plugin") { + t.Errorf("expected error to mention plugin name, got: %v", err) + } + if !strings.Contains(err.Error(), ">=2.0.0") { + t.Errorf("expected error to mention version constraint, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_NoVersionConstraint verifies that +// a plugin requirement with no version constraint matches any loaded version. +func TestEngine_BuildFromConfig_RequiresPlugins_NoVersionConstraint(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + p := newMinimalPlugin("any-version-plugin", "99.0.0") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "any-version-plugin"}, // no Version constraint + }, + }, + } + + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error when no version constraint is set, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_MultiplePlugins verifies that all +// plugins in the requires list must be loaded. +func TestEngine_BuildFromConfig_RequiresPlugins_MultiplePlugins(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + // Load two of three required plugins. + for _, name := range []string{"plugin-alpha", "plugin-beta"} { + if err := engine.LoadPlugin(newMinimalPlugin(name, "1.0.0")); err != nil { + t.Fatalf("LoadPlugin(%s) failed: %v", name, err) + } + } + // "plugin-gamma" is intentionally NOT loaded. + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "plugin-alpha"}, + {Name: "plugin-beta"}, + {Name: "plugin-gamma"}, + }, + }, + } + + err := engine.BuildFromConfig(cfg) + if err == nil { + t.Fatal("expected error when one of multiple required plugins is missing") + } + if !strings.Contains(err.Error(), "plugin-gamma") { + t.Errorf("expected error to mention the missing plugin 'plugin-gamma', got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_NoPluginLoaderFails verifies that +// if the plugin loader is nil but plugins are required, the validation is skipped +// (not a hard failure — engine must be usable without a plugin loader). +func TestEngine_BuildFromConfig_RequiresPlugins_NoPluginLoader(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + // Explicitly do not set a plugin loader (pluginLoader stays nil). + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "some-plugin"}, + }, + }, + } + + // When no plugin loader is configured, requires.plugins validation is + // skipped (pluginLoader == nil guard in validateRequirements). + // This is intentional: simple engines that don't use LoadPlugin() should + // not be broken by a requires section. + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error when plugin loader is nil, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_EmptyPluginsList verifies that +// an empty plugins list in requires is handled gracefully. +func TestEngine_BuildFromConfig_RequiresPlugins_EmptyPluginsList(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + if err := engine.LoadPlugin(newMinimalPlugin("some-plugin", "1.0.0")); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{}, // empty list + }, + } + + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatalf("expected no error for empty plugins list, got: %v", err) + } +} + +// TestEngine_BuildFromConfig_RequiresPlugins_ExactVersionMatch verifies +// exact version matching (=1.2.3 constraint). +func TestEngine_BuildFromConfig_RequiresPlugins_ExactVersionMatch(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + + p := newMinimalPlugin("exact-plugin", "1.2.3") + if err := engine.LoadPlugin(p); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + tests := []struct { + constraint string + wantOK bool + }{ + {"=1.2.3", true}, + {"=1.2.4", false}, + {">=1.0.0", true}, + {">=2.0.0", false}, + {"<2.0.0", true}, + {"<1.0.0", false}, + } + + for _, tt := range tests { + t.Run(tt.constraint, func(t *testing.T) { + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{}, + Workflows: map[string]any{}, + Triggers: map[string]any{}, + Requires: &config.RequiresConfig{ + Plugins: []config.PluginRequirement{ + {Name: "exact-plugin", Version: tt.constraint}, + }, + }, + } + + // Use a fresh engine for each sub-test to avoid state contamination. + // We re-use the same app since plugins are already registered in the + // PluginLoader (which we recreate by calling LoadPlugin again on a + // fresh engine but referencing the same plugin instance). + subApp := newMockApplication() + subEngine := NewStdEngine(subApp, subApp.Logger()) + subP := newMinimalPlugin("exact-plugin", "1.2.3") + if err := subEngine.LoadPlugin(subP); err != nil { + t.Fatalf("LoadPlugin failed: %v", err) + } + + err := subEngine.BuildFromConfig(cfg) + if tt.wantOK && err != nil { + t.Errorf("expected no error for constraint %q, got: %v", tt.constraint, err) + } else if !tt.wantOK && err == nil { + t.Errorf("expected error for constraint %q but got none", tt.constraint) + } + }) + } +} diff --git a/interfaces/events.go b/interfaces/events.go new file mode 100644 index 00000000..75db49b7 --- /dev/null +++ b/interfaces/events.go @@ -0,0 +1,23 @@ +package interfaces + +import ( + "context" + "time" +) + +// EventEmitter publishes workflow and step lifecycle events. +// *module.WorkflowEventEmitter satisfies this interface. +// All methods must be safe to call when no event bus is configured (no-ops). +type EventEmitter interface { + EmitWorkflowStarted(ctx context.Context, workflowType, action string, data map[string]any) + EmitWorkflowCompleted(ctx context.Context, workflowType, action string, duration time.Duration, results map[string]any) + EmitWorkflowFailed(ctx context.Context, workflowType, action string, duration time.Duration, err error) +} + +// MetricsRecorder records workflow execution metrics. +// *module.MetricsCollector satisfies this interface. +// All methods must be safe to call when no metrics backend is configured (no-ops). +type MetricsRecorder interface { + RecordWorkflowExecution(workflowType, action, status string) + RecordWorkflowDuration(workflowType, action string, duration time.Duration) +} diff --git a/interfaces/trigger.go b/interfaces/trigger.go new file mode 100644 index 00000000..997878df --- /dev/null +++ b/interfaces/trigger.go @@ -0,0 +1,25 @@ +package interfaces + +import "github.com/CrisisTextLine/modular" + +// Trigger defines what can start a workflow execution. +// Moving this interface here breaks the engine→module import dependency while +// preserving backward compatibility via the type alias in the module package. +// +// *module.HTTPTrigger, *module.ScheduleTrigger, and other concrete trigger +// implementations all satisfy this interface. +type Trigger interface { + modular.Module + modular.Startable + modular.Stoppable + + // Configure sets up the trigger from configuration. + Configure(app modular.Application, triggerConfig any) error +} + +// TriggerRegistrar manages registered triggers. +// *module.TriggerRegistry satisfies this interface. +type TriggerRegistrar interface { + // RegisterTrigger adds a trigger to the registry. + RegisterTrigger(trigger Trigger) +} diff --git a/module/command_handler_test.go b/module/command_handler_test.go index 7819aad4..14605036 100644 --- a/module/command_handler_test.go +++ b/module/command_handler_test.go @@ -294,4 +294,3 @@ func TestCommandHandler_RoutePipeline_TypedNil(t *testing.T) { t.Errorf("expected 404 for typed-nil pipeline, got %d", rr.Code) } } - diff --git a/module/query_handler_test.go b/module/query_handler_test.go index a8243538..132ebab5 100644 --- a/module/query_handler_test.go +++ b/module/query_handler_test.go @@ -299,4 +299,3 @@ func TestQueryHandler_RoutePipeline_TypedNil(t *testing.T) { t.Errorf("expected 404 for typed-nil pipeline, got %d", rr.Code) } } - diff --git a/module/trigger.go b/module/trigger.go index 663a8789..8262f091 100644 --- a/module/trigger.go +++ b/module/trigger.go @@ -1,20 +1,17 @@ package module import ( - "github.com/CrisisTextLine/modular" + "github.com/GoCodeAlone/workflow/interfaces" ) -// Trigger defines what can start a workflow execution -type Trigger interface { - modular.Module - modular.Startable - modular.Stoppable +// Trigger is a type alias for interfaces.Trigger. +// The canonical definition lives in the interfaces package so that the engine +// and other packages can reference it without importing this module package. +// All existing code using module.Trigger is unaffected by this alias. +type Trigger = interfaces.Trigger - // Configure sets up the trigger from configuration - Configure(app modular.Application, triggerConfig any) error -} - -// TriggerRegistry manages registered triggers and allows finding them by name +// TriggerRegistry manages registered triggers and allows finding them by name. +// It satisfies interfaces.TriggerRegistrar. type TriggerRegistry struct { triggers map[string]Trigger } diff --git a/plugins/api/plugin.go b/plugins/api/plugin.go index c3e1fa49..9ef86b44 100644 --- a/plugins/api/plugin.go +++ b/plugins/api/plugin.go @@ -100,12 +100,14 @@ func New() *Plugin { return &Plugin{ // Default constructors wrap the concrete module constructors, adapting // their return types to modular.Module via implicit interface satisfaction. - newQueryHandler: func(name string) modular.Module { return module.NewQueryHandler(name) }, - newCommandHandler: func(name string) modular.Module { return module.NewCommandHandler(name) }, - newRESTAPIHandler: func(name, resourceName string) modular.Module { return module.NewRESTAPIHandler(name, resourceName) }, - newAPIGateway: func(name string) modular.Module { return module.NewAPIGateway(name) }, - newWorkflowRegistry: func(name, storageBackend string) modular.Module { return module.NewWorkflowRegistry(name, storageBackend) }, - newDataTransformer: func(name string) modular.Module { return module.NewDataTransformer(name) }, + newQueryHandler: func(name string) modular.Module { return module.NewQueryHandler(name) }, + newCommandHandler: func(name string) modular.Module { return module.NewCommandHandler(name) }, + newRESTAPIHandler: func(name, resourceName string) modular.Module { return module.NewRESTAPIHandler(name, resourceName) }, + newAPIGateway: func(name string) modular.Module { return module.NewAPIGateway(name) }, + newWorkflowRegistry: func(name, storageBackend string) modular.Module { + return module.NewWorkflowRegistry(name, storageBackend) + }, + newDataTransformer: func(name string) modular.Module { return module.NewDataTransformer(name) }, newProcessingStep: func(name string, cfg module.ProcessingStepConfig) modular.Module { return module.NewProcessingStep(name, cfg) }, From e8552b1aa6c3fca816890f0d6d25d162ad34db91 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:36:08 -0500 Subject: [PATCH 2/7] fix: tighten engine_module_bridge and mockApplication based on review feedback (#148) * Initial plan * refactor: address code review feedback on engine_module_bridge and engine_test Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- engine_module_bridge.go | 11 ++++++++--- engine_test.go | 23 +++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/engine_module_bridge.go b/engine_module_bridge.go index 947eca65..7bf2cab1 100644 --- a/engine_module_bridge.go +++ b/engine_module_bridge.go @@ -86,8 +86,13 @@ func (e *StdEngine) registerPluginSteps(typeName string, stepFactory func(name s // uses the canonical interface type. func (e *StdEngine) registerPluginTrigger(triggerType string, factory func() any) { result := factory() - if trigger, ok := result.(interfaces.Trigger); ok { - e.triggerTypeMap[triggerType] = trigger.Name() - e.RegisterTrigger(trigger) + trigger, ok := result.(interfaces.Trigger) + if !ok { + // Fail fast with a clear warning when a plugin misconfigures its trigger factory. + // This avoids silent failures that later surface as "no handler found" errors. + e.logger.Error(fmt.Sprintf("workflow: plugin trigger factory for %q returned non-Trigger type %T; trigger not registered", triggerType, result)) + return } + e.triggerTypeMap[triggerType] = trigger.Name() + e.RegisterTrigger(trigger) } diff --git a/engine_test.go b/engine_test.go index 2faecbc3..c9d499ae 100644 --- a/engine_test.go +++ b/engine_test.go @@ -281,10 +281,15 @@ func (a *mockApplication) GetService(name string, out any) error { // If out is provided, try to assign the service to it using reflection if out != nil { + // Guard: nil service value would make reflect.ValueOf(svc).Type() panic. + if svc == nil { + return fmt.Errorf("service %s has a nil value", name) + } + // Get reflect values outVal := reflect.ValueOf(out) - if outVal.Kind() != reflect.Pointer { - return fmt.Errorf("out parameter must be a pointer") + if outVal.Kind() != reflect.Pointer || outVal.IsNil() { + return fmt.Errorf("out parameter must be a non-nil pointer") } // Dereference the pointer @@ -1544,14 +1549,6 @@ func TestEngine_BuildFromConfig_RequiresPlugins_EmptyPluginsList(t *testing.T) { // TestEngine_BuildFromConfig_RequiresPlugins_ExactVersionMatch verifies // exact version matching (=1.2.3 constraint). func TestEngine_BuildFromConfig_RequiresPlugins_ExactVersionMatch(t *testing.T) { - app := newMockApplication() - engine := NewStdEngine(app, app.Logger()) - - p := newMinimalPlugin("exact-plugin", "1.2.3") - if err := engine.LoadPlugin(p); err != nil { - t.Fatalf("LoadPlugin failed: %v", err) - } - tests := []struct { constraint string wantOK bool @@ -1577,10 +1574,8 @@ func TestEngine_BuildFromConfig_RequiresPlugins_ExactVersionMatch(t *testing.T) }, } - // Use a fresh engine for each sub-test to avoid state contamination. - // We re-use the same app since plugins are already registered in the - // PluginLoader (which we recreate by calling LoadPlugin again on a - // fresh engine but referencing the same plugin instance). + // Use a fresh app and engine (with a freshly loaded plugin) for each + // sub-test to avoid state contamination. subApp := newMockApplication() subEngine := NewStdEngine(subApp, subApp.Logger()) subP := newMinimalPlugin("exact-plugin", "1.2.3") From dc0f99ee460a255b93b1e7e8d5544901be3ce3b6 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 23 Feb 2026 15:26:50 -0500 Subject: [PATCH 3/7] fix: correct syntax error in TestMergeInto_WithRealAdminConfig The function was closed with a mismatched `)` instead of `}` and had 2-space indented closing brace for the if block instead of tab-indented. This caused `expected statement, found ')'` errors, breaking all build/vet/test CI checks on any branch touching this file. Co-Authored-By: Claude Opus 4.6 --- admin/admin_test.go | 241 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 admin/admin_test.go diff --git a/admin/admin_test.go b/admin/admin_test.go new file mode 100644 index 00000000..ad324864 --- /dev/null +++ b/admin/admin_test.go @@ -0,0 +1,241 @@ +package admin + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/config" +) + +func TestLoadConfigRaw(t *testing.T) { + t.Parallel() + + data, err := LoadConfigRaw() + if err != nil { + t.Fatalf("LoadConfigRaw() error: %v", err) + } + if len(data) == 0 { + t.Fatal("LoadConfigRaw() returned empty data") + } +} + +func TestLoadConfig(t *testing.T) { + t.Parallel() + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg == nil { + t.Fatal("LoadConfig() returned nil config") + } + if len(cfg.Modules) == 0 { + t.Error("expected at least one admin module") + } +} + +func TestMergeInto(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + primary *config.WorkflowConfig + admin *config.WorkflowConfig + check func(t *testing.T, result *config.WorkflowConfig) + }{ + { + name: "merge modules appended", + primary: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "primary-mod", Type: "http.server"}, + }, + Workflows: map[string]any{"http": "primary-wf"}, + Triggers: map[string]any{"http": "primary-trigger"}, + }, + admin: &config.WorkflowConfig{ + Modules: []config.ModuleConfig{ + {Name: "admin-mod", Type: "http.server"}, + }, + Workflows: map[string]any{"http-admin": "admin-wf"}, + Triggers: map[string]any{"admin-trigger": "admin-cfg"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if len(result.Modules) != 2 { + t.Errorf("expected 2 modules, got %d", len(result.Modules)) + } + if result.Workflows["http-admin"] == nil { + t.Error("admin workflow not merged") + } + if result.Triggers["admin-trigger"] == nil { + t.Error("admin trigger not merged") + } + }, + }, + { + name: "workflows not overwritten", + primary: &config.WorkflowConfig{ + Modules: nil, + Workflows: map[string]any{"http": "primary"}, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Modules: nil, + Workflows: map[string]any{"http": "admin-should-not-replace"}, + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Workflows["http"] != "primary" { + t.Errorf("primary workflow was overwritten: got %v", result.Workflows["http"]) + } + }, + }, + { + name: "nil primary workflows map initialized", + primary: &config.WorkflowConfig{ + Workflows: nil, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Workflows: map[string]any{"admin-wf": "cfg"}, + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Workflows == nil { + t.Fatal("workflows map should have been initialized") + } + if result.Workflows["admin-wf"] == nil { + t.Error("admin workflow not merged into nil map") + } + }, + }, + { + name: "nil primary triggers map initialized", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: nil, + }, + admin: &config.WorkflowConfig{ + Workflows: nil, + Triggers: map[string]any{"admin-trig": "cfg"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Triggers == nil { + t.Fatal("triggers map should have been initialized") + } + if result.Triggers["admin-trig"] == nil { + t.Error("admin trigger not merged into nil map") + } + }, + }, + { + name: "triggers not overwritten", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: map[string]any{"http": "primary"}, + }, + admin: &config.WorkflowConfig{ + Triggers: map[string]any{"http": "admin-should-not-replace"}, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if result.Triggers["http"] != "primary" { + t.Errorf("primary trigger was overwritten: got %v", result.Triggers["http"]) + } + }, + }, + { + name: "empty admin triggers no-op", + primary: &config.WorkflowConfig{ + Workflows: map[string]any{}, + Triggers: map[string]any{"existing": "val"}, + }, + admin: &config.WorkflowConfig{ + Triggers: nil, + }, + check: func(t *testing.T, result *config.WorkflowConfig) { + if len(result.Triggers) != 1 { + t.Errorf("expected 1 trigger, got %d", len(result.Triggers)) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + MergeInto(tt.primary, tt.admin) + tt.check(t, tt.primary) + }) + } +} + +func TestMergeInto_WithRealAdminConfig(t *testing.T) { + t.Parallel() + + adminCfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + primary := config.NewEmptyWorkflowConfig() + primary.Modules = []config.ModuleConfig{ + {Name: "user-server", Type: "http.server"}, + } + + initialModuleCount := len(primary.Modules) + MergeInto(primary, adminCfg) + + if len(primary.Modules) <= initialModuleCount { + t.Error("expected admin modules to be appended") + } +} + +func TestLoadConfig_Parses(t *testing.T) { + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if len(cfg.Modules) == 0 { + t.Error("expected at least one module in admin config") + } +} + +func TestLoadConfigRaw_NonEmpty(t *testing.T) { + raw, err := LoadConfigRaw() + if err != nil { + t.Fatalf("LoadConfigRaw: %v", err) + } + if len(raw) == 0 { + t.Error("expected non-empty raw config data") + } +} + +func TestLoadConfig_HasExpectedModules(t *testing.T) { + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + moduleNames := make(map[string]bool) + for _, m := range cfg.Modules { + moduleNames[m.Name] = true + } + + required := []string{"admin-server", "admin-router", "admin-db", "admin-auth"} + for _, name := range required { + if !moduleNames[name] { + t.Errorf("expected module %q in admin config", name) + } + } +} + +func TestLoadConfig_HasWorkflows(t *testing.T) { + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if len(cfg.Workflows) == 0 { + t.Error("expected at least one workflow in admin config") + } +} From 8d570d48e664e4cebaed2d1963569c2cddb635c9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:18:24 -0500 Subject: [PATCH 4/7] fix: EventEmitter doc accuracy, registerPluginTrigger deterministic failure, remove duplicate admin tests (#151) * Initial plan * fix: address review comments - EventEmitter doc, registerPluginTrigger error handling, remove duplicate admin tests Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- admin/admin_test.go | 23 ----------------------- engine.go | 4 +++- engine_module_bridge.go | 12 +++++++----- interfaces/events.go | 2 +- 4 files changed, 11 insertions(+), 30 deletions(-) diff --git a/admin/admin_test.go b/admin/admin_test.go index ad324864..4cd350d2 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -188,29 +188,6 @@ func TestMergeInto_WithRealAdminConfig(t *testing.T) { } } -func TestLoadConfig_Parses(t *testing.T) { - cfg, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - if cfg == nil { - t.Fatal("expected non-nil config") - } - if len(cfg.Modules) == 0 { - t.Error("expected at least one module in admin config") - } -} - -func TestLoadConfigRaw_NonEmpty(t *testing.T) { - raw, err := LoadConfigRaw() - if err != nil { - t.Fatalf("LoadConfigRaw: %v", err) - } - if len(raw) == 0 { - t.Error("expected non-empty raw config data") - } -} - func TestLoadConfig_HasExpectedModules(t *testing.T) { cfg, err := LoadConfig() if err != nil { diff --git a/engine.go b/engine.go index 3fc9d9ed..7182d970 100644 --- a/engine.go +++ b/engine.go @@ -218,7 +218,9 @@ func (e *StdEngine) LoadPlugin(p plugin.EnginePlugin) error { for triggerType, factory := range p.TriggerFactories() { // Delegate to the bridge helper; triggers are interfaces.Trigger values // (module.Trigger is a type alias for interfaces.Trigger). - e.registerPluginTrigger(triggerType, factory) + if err := e.registerPluginTrigger(triggerType, factory); err != nil { + return fmt.Errorf("load plugin: %w", err) + } } // Register pipeline trigger config wrappers from plugin (optional interface). diff --git a/engine_module_bridge.go b/engine_module_bridge.go index 7bf2cab1..c1b2ca7e 100644 --- a/engine_module_bridge.go +++ b/engine_module_bridge.go @@ -84,15 +84,17 @@ func (e *StdEngine) registerPluginSteps(typeName string, stepFactory func(name s // Lives here to avoid a direct module.Trigger type assertion in engine.go. // Since module.Trigger is now an alias for interfaces.Trigger, the assertion // uses the canonical interface type. -func (e *StdEngine) registerPluginTrigger(triggerType string, factory func() any) { +// Returns an error when the factory returns a value that does not satisfy +// interfaces.Trigger, so LoadPlugin can fail deterministically instead of +// silently skipping the trigger and surfacing a confusing "no handler found" +// error later at runtime. +func (e *StdEngine) registerPluginTrigger(triggerType string, factory func() any) error { result := factory() trigger, ok := result.(interfaces.Trigger) if !ok { - // Fail fast with a clear warning when a plugin misconfigures its trigger factory. - // This avoids silent failures that later surface as "no handler found" errors. - e.logger.Error(fmt.Sprintf("workflow: plugin trigger factory for %q returned non-Trigger type %T; trigger not registered", triggerType, result)) - return + return fmt.Errorf("workflow: plugin trigger factory for %q returned non-Trigger type %T", triggerType, result) } e.triggerTypeMap[triggerType] = trigger.Name() e.RegisterTrigger(trigger) + return nil } diff --git a/interfaces/events.go b/interfaces/events.go index 75db49b7..74f52057 100644 --- a/interfaces/events.go +++ b/interfaces/events.go @@ -5,7 +5,7 @@ import ( "time" ) -// EventEmitter publishes workflow and step lifecycle events. +// EventEmitter publishes workflow lifecycle events. // *module.WorkflowEventEmitter satisfies this interface. // All methods must be safe to call when no event bus is configured (no-ops). type EventEmitter interface { From 3b422815ce6e3e0688e3de5a58ee30d06bda647b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 23 Feb 2026 19:38:56 -0500 Subject: [PATCH 5/7] fix: require master_password for RDS instance creation instead of defaulting Remove hardcoded default password fallback in the RDS driver. The Create method now returns an error if master_password is not provided, preventing insecure defaults from being used in production. Co-Authored-By: Claude Opus 4.6 --- platform/providers/aws/drivers/rds.go | 2 +- platform/providers/aws/drivers/rds_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/platform/providers/aws/drivers/rds.go b/platform/providers/aws/drivers/rds.go index 52546027..4abfb9aa 100644 --- a/platform/providers/aws/drivers/rds.go +++ b/platform/providers/aws/drivers/rds.go @@ -56,7 +56,7 @@ func (d *RDSDriver) Create(ctx context.Context, name string, properties map[stri } masterPass, _ := properties["master_password"].(string) if masterPass == "" { - masterPass = "changeme123!" // Would be from secrets in production + return nil, fmt.Errorf("rds: create %q: master_password is required", name) } input := &rds.CreateDBInstanceInput{ diff --git a/platform/providers/aws/drivers/rds_test.go b/platform/providers/aws/drivers/rds_test.go index 1532939c..640ee5ed 100644 --- a/platform/providers/aws/drivers/rds_test.go +++ b/platform/providers/aws/drivers/rds_test.go @@ -96,6 +96,7 @@ func TestRDSDriver_Create(t *testing.T) { "instance_class": "db.r5.large", "allocated_storage": 50, "multi_az": true, + "master_password": "s3cureP@ss!", }) if err != nil { t.Fatalf("Create() error: %v", err) @@ -111,6 +112,18 @@ func TestRDSDriver_Create(t *testing.T) { } } +func TestRDSDriver_CreateMissingPassword(t *testing.T) { + d := NewRDSDriverWithClient(&mockRDSClient{}) + ctx := context.Background() + + _, err := d.Create(ctx, "test-db", map[string]any{ + "engine": "postgres", + }) + if err == nil { + t.Fatal("expected error for missing master_password") + } +} + func TestRDSDriver_Read(t *testing.T) { d := NewRDSDriverWithClient(&mockRDSClient{}) ctx := context.Background() From 00f03aa817b911eb07d44eeb30b7eb03ce508f69 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Mon, 23 Feb 2026 19:39:46 -0500 Subject: [PATCH 6/7] fix: restore missing multiWorkflowAddr flag declaration in cmd/server The flag was referenced but not declared, causing build failures. Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index 04858923..cc8062f2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -83,6 +83,7 @@ var ( anthropicModel = flag.String("anthropic-model", "", "Anthropic model name") // Multi-workflow mode flags + multiWorkflowAddr = flag.String("multi-workflow-addr", ":8080", "Listen address for the multi-workflow API server") databaseDSN = flag.String("database-dsn", "", "PostgreSQL connection string for multi-workflow mode") jwtSecret = flag.String("jwt-secret", "", "JWT signing secret for API authentication") adminEmail = flag.String("admin-email", "", "Initial admin user email (first-run bootstrap)") From 9c282cdb5b436aa7193eac1ed903434fd14e0de3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:46:42 -0500 Subject: [PATCH 7/7] fix: restore missing `multiWorkflowAddr` flag in cmd/server/main.go (#153) * Initial plan * fix: restore missing multiWorkflowAddr flag definition in cmd/server/main.go Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> Co-authored-by: Jonathan Langevin