-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add multi-workflow application config and cross-workflow invocation #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,67 @@ import ( | |
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| // WorkflowRef is a reference to a workflow config file within an application config. | ||
| type WorkflowRef struct { | ||
| // File is the path to the workflow YAML config file (relative to the application config). | ||
| File string `json:"file" yaml:"file"` | ||
| // Name is an optional override for the workflow's name within the application namespace. | ||
| // If empty, the filename stem (without extension) is used. | ||
| Name string `json:"name,omitempty" yaml:"name,omitempty"` | ||
| } | ||
|
|
||
| // ApplicationInfo holds top-level metadata about a multi-workflow application. | ||
| type ApplicationInfo struct { | ||
| // Name is the application name. | ||
| Name string `json:"name" yaml:"name"` | ||
| // Workflows lists the workflow config files that make up this application. | ||
| Workflows []WorkflowRef `json:"workflows" yaml:"workflows"` | ||
| } | ||
|
|
||
| // ApplicationConfig is the top-level config for a multi-workflow application. | ||
| // It references multiple workflow config files that share a module registry. | ||
| type ApplicationConfig struct { | ||
| // Application holds the application-level metadata and workflow references. | ||
| Application ApplicationInfo `json:"application" yaml:"application"` | ||
| // ConfigDir is the directory of the application config file, used for resolving relative paths. | ||
| ConfigDir string `json:"-" yaml:"-"` | ||
| } | ||
|
|
||
| // LoadApplicationConfig loads an application config from a YAML file. | ||
| func LoadApplicationConfig(filepath string) (*ApplicationConfig, error) { | ||
| data, err := os.ReadFile(filepath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to read application config file: %w", err) | ||
| } | ||
|
|
||
| var cfg ApplicationConfig | ||
| if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| return nil, fmt.Errorf("failed to parse application config file: %w", err) | ||
| } | ||
|
|
||
| // Store the config file's directory for relative path resolution | ||
| absPath, err := pathpkg.Abs(filepath) | ||
| if err == nil { | ||
| cfg.ConfigDir = pathpkg.Dir(absPath) | ||
| } | ||
|
|
||
| return &cfg, nil | ||
| } | ||
|
|
||
| // IsApplicationConfig returns true if the YAML data contains an application-level config | ||
| // (i.e., has an "application" key with a "workflows" section). | ||
| func IsApplicationConfig(data []byte) bool { | ||
| var probe struct { | ||
| Application *struct { | ||
| Workflows []any `yaml:"workflows"` | ||
| } `yaml:"application"` | ||
| } | ||
| if err := yaml.Unmarshal(data, &probe); err != nil { | ||
| return false | ||
| } | ||
| return probe.Application != nil && len(probe.Application.Workflows) > 0 | ||
| } | ||
|
|
||
| // ModuleConfig represents a single module configuration | ||
| type ModuleConfig struct { | ||
| Name string `json:"name" yaml:"name"` | ||
|
|
@@ -98,6 +159,88 @@ func ResolvePathInConfig(cfg map[string]any, path string) string { | |
| return path | ||
| } | ||
|
|
||
| // MergeApplicationConfig loads all workflow config files referenced by an | ||
| // ApplicationConfig and merges them into a single WorkflowConfig. This is | ||
| // useful for callers that need a single combined config (e.g., the server's | ||
| // admin merge step) before passing it to the engine. | ||
| // | ||
| // Module name conflicts across files are reported as errors. | ||
| func MergeApplicationConfig(appCfg *ApplicationConfig) (*WorkflowConfig, error) { | ||
| if appCfg == nil { | ||
| return nil, fmt.Errorf("application config is nil") | ||
| } | ||
|
|
||
| combined := NewEmptyWorkflowConfig() | ||
| combined.ConfigDir = appCfg.ConfigDir | ||
| seenModules := make(map[string]string) | ||
| seenTriggers := make(map[string]string) | ||
| seenPipelines := make(map[string]string) | ||
|
|
||
| for _, ref := range appCfg.Application.Workflows { | ||
| if ref.File == "" { | ||
| return nil, fmt.Errorf("application %q: workflow reference has no 'file' field", appCfg.Application.Name) | ||
| } | ||
|
|
||
| filePath := ref.File | ||
| if !pathpkg.IsAbs(filePath) && appCfg.ConfigDir != "" { | ||
| filePath = pathpkg.Join(appCfg.ConfigDir, filePath) | ||
| } | ||
|
|
||
| wfCfg, err := LoadFromFile(filePath) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("application %q: failed to load workflow file %q: %w", appCfg.Application.Name, ref.File, err) | ||
| } | ||
|
|
||
| // Derive a name for error messages | ||
| wfName := ref.Name | ||
| if wfName == "" { | ||
| base := pathpkg.Base(filePath) | ||
| wfName = base[:len(base)-len(pathpkg.Ext(base))] | ||
| } | ||
|
|
||
| for _, modCfg := range wfCfg.Modules { | ||
| if existing, conflict := seenModules[modCfg.Name]; conflict { | ||
| return nil, fmt.Errorf("application %q: module name conflict: module %q is defined in both %q and %q", | ||
| appCfg.Application.Name, modCfg.Name, existing, wfName) | ||
| } | ||
| seenModules[modCfg.Name] = wfName | ||
| } | ||
|
|
||
| for k := range wfCfg.Triggers { | ||
| if existing, conflict := seenTriggers[k]; conflict { | ||
| return nil, fmt.Errorf("application %q: trigger name conflict: trigger %q is defined in both %q and %q", | ||
| appCfg.Application.Name, k, existing, wfName) | ||
| } | ||
| seenTriggers[k] = wfName | ||
| } | ||
| for k := range wfCfg.Pipelines { | ||
| if existing, conflict := seenPipelines[k]; conflict { | ||
| return nil, fmt.Errorf("application %q: pipeline name conflict: pipeline %q is defined in both %q and %q", | ||
| appCfg.Application.Name, k, existing, wfName) | ||
| } | ||
| seenPipelines[k] = wfName | ||
| } | ||
|
|
||
| combined.Modules = append(combined.Modules, wfCfg.Modules...) | ||
| for k, v := range wfCfg.Workflows { | ||
| combined.Workflows[k] = v | ||
| } | ||
| for k, v := range wfCfg.Triggers { | ||
| combined.Triggers[k] = v | ||
|
Comment on lines
+225
to
+229
|
||
| } | ||
| for k, v := range wfCfg.Pipelines { | ||
| combined.Pipelines[k] = v | ||
|
Comment on lines
+231
to
+232
|
||
| } | ||
| // Fall back to first workflow file's directory if application config | ||
| // directory was not set. | ||
| if combined.ConfigDir == "" { | ||
| combined.ConfigDir = wfCfg.ConfigDir | ||
| } | ||
|
Comment on lines
+236
to
+238
|
||
| } | ||
|
|
||
| return combined, nil | ||
| } | ||
|
|
||
| // NewEmptyWorkflowConfig creates a new empty workflow configuration | ||
| func NewEmptyWorkflowConfig() *WorkflowConfig { | ||
| return &WorkflowConfig{ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says the engine is built using
BuildFromApplicationConfig, but the implementation merges configs and then callsbuildEngine(combined, ...)(which usesBuildFromConfig). Either update the comment to match the actual flow, or switch to callingengine.BuildFromApplicationConfig(appCfg)if that’s the intended entry point so the code and documentation stay aligned.