Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 113 additions & 2 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,46 @@ Pipeline steps support Go template syntax with these built-in functions:
| Function | Description | Example |
|----------|-------------|---------|
| `uuidv4` | Generates a UUID v4 | `{{ uuidv4 }}` |
| `now` | Current time in RFC3339 format | `{{ now }}` |
| `now` | Current time (RFC3339 default, or named/custom layout) | `{{ now }}`, `{{ now "DateOnly" }}`, `{{ now "2006-01-02" }}` |
| `lower` | Lowercase string | `{{ lower .name }}` |
| `default` | Default value when empty | `{{ default "pending" .status }}` |
| `json` | Marshal value to JSON string | `{{ json .data }}` |
| `trimPrefix` | Remove prefix from string | `{{ .phone | trimPrefix "+" }}` |
| `trimSuffix` | Remove suffix from string | `{{ .file | trimSuffix ".txt" }}` |
| `step` | Access step outputs by name and keys | `{{ step "parse-request" "path_params" "id" }}` |
| `trigger` | Access trigger data by keys | `{{ trigger "path_params" "id" }}` |

Template expressions can reference previous step outputs via `{{ .steps.step-name.field }}` or for hyphenated names `{{index .steps "step-name" "field"}}`.
#### Template Data Context

Templates have access to four top-level namespaces:

| Variable | Source | Description |
|----------|--------|-------------|
| `{{ .field }}` | `pc.Current` | Merged trigger data + all prior step outputs (flat) |
| `{{ .steps.NAME.field }}` | `pc.StepOutputs` | Namespaced access to a specific step's output |
| `{{ .trigger.field }}` | `pc.TriggerData` | Original trigger data (immutable) |
| `{{ .meta.field }}` | `pc.Metadata` | Execution metadata (pipeline name, etc.) |

#### Hyphenated Step Names

Step names commonly contain hyphens (e.g., `parse-request`, `fetch-orders`). Go's template parser treats `-` as subtraction, so `{{ .steps.my-step.field }}` would normally fail. The engine handles this automatically:

**Auto-fix (just works):** Write natural dot notation — the engine rewrites it before parsing:
```yaml
value: "{{ .steps.parse-request.path_params.id }}"
```

**Preferred syntax:** The `step` function avoids quoting issues entirely:
```yaml
value: '{{ step "parse-request" "path_params" "id" }}'
```

**Manual alternative:** The `index` function also works:
```yaml
value: '{{ index .steps "parse-request" "path_params" "id" }}'
```

`wfctl template validate --config workflow.yaml` lints template expressions and warns on undefined step references, forward references, and suggests the `step` function for hyphenated names.

### Infrastructure
| Type | Description |
Expand Down Expand Up @@ -816,6 +850,83 @@ triggers:
action: "create-order"
```

### Config Imports

Large configs can be split into domain-specific files using `imports`. Each imported file is a standard workflow config — no special format required. Imports are recursive (imported files can import other files) with circular import detection.

```yaml
# main.yaml
imports:
- config/modules.yaml
- config/routes/agents.yaml
- config/routes/tasks.yaml
- config/routes/requests.yaml

modules:
- name: extra-module
type: http.server
config:
address: ":9090"

pipelines:
health-check:
steps:
- name: respond
type: step.json_response
config:
body: '{"status": "ok"}'
```

```yaml
# config/modules.yaml
modules:
- name: my-db
type: storage.sqlite
config:
dbPath: ./data/app.db

- name: my-router
type: http.router
```

```yaml
# config/routes/agents.yaml
pipelines:
list-agents:
steps:
- name: query
type: step.db_query
config:
query: "SELECT * FROM agents"
- name: respond
type: step.json_response

triggers:
list-agents:
type: http
config:
path: /api/agents
method: GET
```

**Merge rules:**
- **Modules:** All modules from all files are included. Main file's modules appear first.
- **Pipelines, triggers, workflows, platform:** Main file's definitions take precedence. Imported values only fill in keys not already defined.
- **Recursive imports:** Imported files can themselves use `imports`. Circular imports are detected and produce an error. Diamond imports (multiple files importing a shared dependency) are allowed.
- **Import order:** Depth-first traversal — if main imports [A, B] and A imports C, module order is: main, A, C, B.
- **Relative paths:** Import paths are resolved relative to the importing file's directory.

**Comparison with ApplicationConfig:**

| Feature | `imports` | `ApplicationConfig` |
|---------|-----------|---------------------|
| Format | Standard `WorkflowConfig` with `imports:` field | Separate `application:` top-level format |
| Conflict handling | Main file wins silently | Errors on name conflicts |
| Use case | Splitting a monolith incrementally | Composing independent workflow services |
| Nesting | Files can import other files recursively | Flat list of workflow references |

Both approaches work with `wfctl template validate --config` for validation.

## Visual Workflow Builder (UI)

A React-based visual editor for composing workflow configurations (`ui/` directory).
Expand Down
169 changes: 153 additions & 16 deletions cmd/wfctl/template_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,17 @@ import (

// templateValidationResult holds the outcome of validating a single template.
type templateValidationResult struct {
Name string
ModuleCount int
ModuleValid int
StepCount int
StepValid int
DepCount int
DepValid int
TriggerCount int
TriggerValid int
Warnings []string
Errors []string
Name string
ModuleCount int
ModuleValid int
StepCount int
StepValid int
DepCount int
DepValid int
TriggerCount int
TriggerValid int
Warnings []string
Errors []string
}

// pass returns true if there are no errors.
Expand Down Expand Up @@ -341,7 +341,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
moduleNames[mod.Name] = true
}

// 1. Validate module types
// 1. Validate module types and config fields
result.ModuleCount = len(cfg.Modules)
for _, mod := range cfg.Modules {
if mod.Type == "" {
Expand All @@ -353,7 +353,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
result.Errors = append(result.Errors, fmt.Sprintf("module %q uses unknown type %q", mod.Name, mod.Type))
} else {
result.ModuleValid++
// 5. Warn on unknown config fields
// Warn on unknown config fields
if mod.Config != nil && len(info.ConfigKeys) > 0 {
knownKeys := make(map[string]bool)
for _, k := range info.ConfigKeys {
Expand All @@ -367,7 +367,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
}
}

// 3. Validate dependencies
// 2. Validate dependencies
for _, dep := range mod.DependsOn {
result.DepCount++
if !moduleNames[dep] {
Expand All @@ -378,7 +378,7 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
}
}

// 2. Validate step types in pipelines
// 3. Validate step types in pipelines
for pipelineName, pipelineRaw := range cfg.Pipelines {
pipelineMap, ok := pipelineRaw.(map[string]any)
if !ok {
Expand Down Expand Up @@ -416,7 +416,10 @@ func validateWorkflowConfig(name string, cfg *config.WorkflowConfig, knownModule
}
}

// 4. Validate trigger types
// 4. Validate template expressions in pipeline steps
validatePipelineTemplates(pipelineName, stepsRaw, &result)

// 5. Validate trigger types
if triggerRaw, ok := pipelineMap["trigger"]; ok {
if triggerMap, ok := triggerRaw.(map[string]any); ok {
triggerType, _ := triggerMap["type"].(string)
Expand Down Expand Up @@ -530,3 +533,137 @@ func printTemplateValidationResults(results []templateValidationResult, summary
// templateFSReader allows reading from the embedded templateFS for validation.
// It wraps around the existing templateFS embed.FS.
var _ fs.FS = templateFS

// --- Pipeline template expression linting ---

// templateExprRe matches template actions {{ ... }}.
var templateExprRe = regexp.MustCompile(`\{\{(.*?)\}\}`)

// stepRefDotRe matches .steps.STEP_NAME patterns (dot access).
var stepRefDotRe = regexp.MustCompile(`\.steps\.([a-zA-Z_][a-zA-Z0-9_-]*)`)

// stepRefIndexRe matches index .steps "STEP_NAME" patterns.
var stepRefIndexRe = regexp.MustCompile(`index\s+\.steps\s+"([^"]+)"`)

// stepRefFuncRe matches step "STEP_NAME" function calls at the start of an
// action, after a pipe, or after an opening parenthesis.
var stepRefFuncRe = regexp.MustCompile(`(?:^|\||\()\s*step\s+"([^"]+)"`)

// hyphenDotRe matches dot-access chains with hyphens (e.g., .steps.my-step.field),
// including continuation segments after the hyphenated part.
var hyphenDotRe = regexp.MustCompile(`\.[a-zA-Z_][a-zA-Z0-9_]*-[a-zA-Z0-9_-]*(?:\.[a-zA-Z_][a-zA-Z0-9_-]*)*`)

// validatePipelineTemplates checks template expressions in pipeline step configs for
// references to nonexistent or forward-declared steps and common template pitfalls.
func validatePipelineTemplates(pipelineName string, stepsRaw []any, result *templateValidationResult) {
// Build ordered step name list
stepNames := make(map[string]int) // step name -> index in pipeline
for i, stepRaw := range stepsRaw {
stepMap, ok := stepRaw.(map[string]any)
if !ok {
continue
}
name, _ := stepMap["name"].(string)
if name != "" {
stepNames[name] = i
}
}

// Check each step's config for template expressions
for i, stepRaw := range stepsRaw {
stepMap, ok := stepRaw.(map[string]any)
if !ok {
continue
}
stepName, _ := stepMap["name"].(string)
if stepName == "" {
stepName = fmt.Sprintf("step[%d]", i)
}

// Collect all string values from the step config recursively
templates := collectTemplateStrings(stepMap)

for _, tmpl := range templates {
// Find all template actions
actions := templateExprRe.FindAllStringSubmatch(tmpl, -1)
for _, action := range actions {
if len(action) < 2 {
continue
}
actionContent := action[1]

// Skip comments
trimmed := strings.TrimSpace(actionContent)
if strings.HasPrefix(trimmed, "/*") {
continue
}

// Check for step name references via dot-access
dotMatches := stepRefDotRe.FindAllStringSubmatch(actionContent, -1)
for _, m := range dotMatches {
refName := m[1]
validateStepRef(pipelineName, stepName, refName, i, stepNames, result)
}

// Check for step name references via index
indexMatches := stepRefIndexRe.FindAllStringSubmatch(actionContent, -1)
for _, m := range indexMatches {
refName := m[1]
validateStepRef(pipelineName, stepName, refName, i, stepNames, result)
}

// Check for step name references via step function
funcMatches := stepRefFuncRe.FindAllStringSubmatch(actionContent, -1)
for _, m := range funcMatches {
refName := m[1]
validateStepRef(pipelineName, stepName, refName, i, stepNames, result)
}

// Warn on hyphenated dot-access (auto-fixed but suggest preferred syntax)
if hyphenDotRe.MatchString(actionContent) {
result.Warnings = append(result.Warnings,
fmt.Sprintf("pipeline %q step %q: template uses hyphenated dot-access which is auto-fixed; prefer step \"name\" \"field\" syntax", pipelineName, stepName))
}
}
}
}
}

// validateStepRef checks that a referenced step name exists and appears before the
// current step in the pipeline execution order.
func validateStepRef(pipelineName, currentStep, refName string, currentIdx int, stepNames map[string]int, result *templateValidationResult) {
refIdx, exists := stepNames[refName]
switch {
case !exists:
result.Warnings = append(result.Warnings,
fmt.Sprintf("pipeline %q step %q: references step %q which does not exist in this pipeline", pipelineName, currentStep, refName))
case refIdx == currentIdx:
result.Warnings = append(result.Warnings,
fmt.Sprintf("pipeline %q step %q: references itself; a step cannot use its own outputs because they are not available until after execution", pipelineName, currentStep))
case refIdx > currentIdx:
result.Warnings = append(result.Warnings,
fmt.Sprintf("pipeline %q step %q: references step %q which has not executed yet (appears later in pipeline)", pipelineName, currentStep, refName))
}
}

// collectTemplateStrings recursively finds all strings containing {{ in a value tree.
// This intentionally scans all fields (not just "config") because template expressions
// can appear in conditions, names, and other step fields.
func collectTemplateStrings(data any) []string {
var results []string
switch v := data.(type) {
case string:
if strings.Contains(v, "{{") {
results = append(results, v)
}
case map[string]any:
for _, val := range v {
results = append(results, collectTemplateStrings(val)...)
}
case []any:
for _, item := range v {
results = append(results, collectTemplateStrings(item)...)
}
}
return results
}
Loading
Loading