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
7 changes: 4 additions & 3 deletions .claude/skills/swamp-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,10 @@ End-to-end workflow creation:
[references/ci-integration.md](../swamp-repo/references/ci-integration.md) for
installing swamp in CI and GitHub Actions examples
- **Nested workflows**: See
[references/nested-workflows.md](references/nested-workflows.md) for full
examples of workflows calling other workflows, forEach with workflows, and
nesting limitations
[references/nested-workflows.md](references/nested-workflows.md) for when to
split a workflow into parent + child (including the async-list-into-forEach
pattern), full examples of workflows calling other workflows, forEach with
workflows, and nesting limitations
- **Expressions, forEach, and data tracking**: See
[references/expressions-and-foreach.md](references/expressions-and-foreach.md)
for forEach iteration patterns, CEL expressions, environment variables, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ With `--input '{"tags": {"env": "prod", "team": "platform"}}'`, creates steps:
| `self.{item}.key` | Key name (object iteration) |
| `self.{item}.value` | Value (object iteration) |

### forEach.in Cannot Await Promises

`forEach.in` is evaluated **synchronously**. Async CEL functions that return a
Promise — `data.latest()`, `data.findByTag()`, `data.findBySpec()` — do not
resolve in this position and the step fails with
`forEach.in must evaluate to an array or object, got: object`.

Only these shapes work directly in `forEach.in`:

- `${{ inputs.<name> }}` — workflow inputs (pre-resolved before expansion)
- Static literals

To iterate over a list produced by `data.latest()` (or any async source), split
into a parent + child workflow and let the parent's `task.inputs` resolve the
list — task inputs ARE awaited. See
[nested-workflows.md § When to Use Nested Workflows](nested-workflows.md#when-to-use-nested-workflows)
for the canonical pattern.

### forEach with Vary Dimensions

Use `vary` on `dataOutputOverrides` to isolate data per forEach iteration:
Expand Down
77 changes: 77 additions & 0 deletions .claude/skills/swamp-workflow/references/nested-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Table of Contents

- [When to Use Nested Workflows](#when-to-use-nested-workflows)
- [Basic Nested Workflow](#basic-nested-workflow)
- [Workflow Task Fields](#workflow-task-fields)
- [Nested Workflow with forEach](#nested-workflow-with-foreach)
Expand All @@ -11,6 +12,82 @@
Steps can invoke another workflow using `type: workflow`. The parent step waits
for the child workflow to complete before continuing.

## When to Use Nested Workflows

Reach for a child workflow when a flat workflow can't express the shape you
need. The cases that come up in practice:

### 1. forEach over an async list (`data.latest()`, `data.findByTag()`, etc.)

`forEach.in` is evaluated **synchronously** at expansion time. CEL expressions
that return a `Promise` — `data.latest()`, `data.findByTag()`,
`data.findBySpec()` — never resolve in this position, and forEach fails with
`forEach.in must evaluate to an array or object, got: object` (the "object" is
the unresolved Promise).

Task `inputs:` ARE awaited for both `model_method` and `workflow` tasks. Move
the async call into the parent's `task.inputs` and let the child iterate over a
plain `inputs.<name>`:

```yaml
# parent — task.inputs awaits data.latest() before invoking child
- name: download
task:
type: workflow
workflowIdOrName: download-episodes
inputs:
episodes: ${{ data.latest("dedup", "current").attributes.episodes }}
```

```yaml
# child — declares episodes as an array input
inputs:
properties:
episodes:
type: array
items: { type: object }
required: ["episodes"]

jobs:
- name: download
steps:
- name: download-${{ self.ep.show }}
forEach:
item: ep
in: ${{ inputs.episodes }} # already resolved — sync eval is fine
task:
type: model_method
modelIdOrName: transmission
methodName: add
inputs:
uri: ${{ self.ep.magnet }}
protocol: torrent
```

The child's input schema validates the boundary, so shape drift between producer
and consumer is caught at invoke time.

### 2. Reusable sub-process invoked from multiple parents

When the same sequence of steps runs from a cron parent, a manual run, and
another workflow, extract it into a child workflow with a typed input schema.
Duplicating steps across workflows is the wrong trade — the child gives you one
validated entry point.

### 3. Independent cadence or isolation

A child workflow can carry its own `trigger.schedule` and still be invoked by a
parent. Splitting lets the child run independently — useful for backfill, manual
replays, and tests — without dragging the parent's prelude along.

### When NOT to nest

- **Pure ordering within a single run** → use `dependsOn` between jobs or steps.
A workflow boundary is not an ordering primitive.
- **Sharing a single resolved value across steps** → reference it directly via
CEL in each step's inputs; don't pay the boundary cost.
- **Nesting depth pressure** — the cap is 10. Each level should earn its keep.

## Basic Nested Workflow

**Child workflow** (`notify-team`):
Expand Down
Loading