Skip to main content
← Back to list
01Issue
FeatureShippedSwamp CLI
Assigneesstack72

#279 Resolve self.* expressions in modelIdOrName during forEach expansion

Opened by webframp · 5/7/2026· Shipped 5/7/2026

Problem Statement

When using forEach to iterate over a list and target different model instances per iteration, self.* expressions in modelIdOrName are never resolved. This prevents a common pattern where a workflow needs to fan out across multiple model instances named by convention.

Example that doesn't work today:

steps:
  - name: summary-${{ self.region }}
    forEach:
      item: region
      in: ${{ inputs.regions }}
    task:
      type: model_method
      modelIdOrName: aws-alarms-${{ self.region }}
      methodName: get_summary
      inputs:
        historyHours: 24

The step name resolves correctly, task.inputs resolves correctly, but modelIdOrName arrives at findDefinitionByIdOrName() as the literal string aws-alarms-${{ self.region }} and fails with "Model not found".

Root Cause

There are two expansion paths, and neither resolves self.* in modelIdOrName:

  1. Pre-evaluation (src/libswamp/workflows/evaluate.ts): resolveForEachTaskExpressions() only resolves expressions in task.inputs and task.args. Its docstring explicitly says: "Resolves forEach self.* expressions in a task's inputs and args."

  2. Runtime (src/domain/workflows/execution_service.ts): evaluateWorkflow() skips self.* expressions (correctly — self doesn't exist yet). expandForEachSteps() creates the forEachVar context but passes the original step with unresolved modelIdOrName to execution. The stepExprContext with self populated is available in StepExecutionContext, but executeModelMethod() uses task.modelIdOrName directly at line 224 without resolving it first.

Note: ${{ inputs.* }} in modelIdOrName works fine (confirmed by PR #543 fix) because those are resolved during evaluateWorkflow() before forEach expansion.

Proposed Solution

Resolve self.* expressions in modelIdOrName (and methodName) during forEach expansion. Two possible approaches:

Option A — Evaluate-time (in resolveForEachTaskExpressions):

// Also resolve modelIdOrName and methodName
if (expandedTask.modelIdOrName && typeof expandedTask.modelIdOrName === 'string') {
  const match = expandedTask.modelIdOrName.match(/\$\{\{\s*(.+?)\s*\}\}/);
  if (match && !containsRuntimeExpression(match[1])) {
    try {
      expandedTask.modelIdOrName = String(deps.evaluateCel(match[1], stepContext));
    } catch { /* leave as-is */ }
  }
}

Option B — Runtime (in DefaultStepExecutor.execute): Resolve any remaining ${{ self.* }} in task.modelIdOrName using ctx.expressionContext before calling executeModelMethod().

Option A is preferred since it also makes --last-evaluated output show the resolved model names.

Alternatives Considered

  • Nested workflow workaround: Pass region as input to a child workflow where ${{ inputs.region }} resolves in modelIdOrName. Works but adds unnecessary complexity.
  • Fan-out model method: A single model that accepts a list of targets internally. Loses the parallelism and composability of forEach.

Use Case

Multi-region/multi-account AWS monitoring workflows where model instances follow a naming convention (e.g., aws-alarms-us-east-1, aws-alarms-eu-west-1) and the workflow should dynamically target them based on user-provided region list.

Discovery context: https://github.com/webframp/swamp-extensions/issues/58

02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 3 MOREREVIEW+ 3 MOREPR_MERGEDSHIPPED

Shipped

5/7/2026, 6:48:12 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack725/7/2026, 5:41:47 PM

Sign in to post a ripple.