5.8 KiB
Workflow Template Resolution Implementation
Date: 2026-02-27
Problem
Workflow task parameters containing {{ }} template expressions were being passed to workers verbatim without resolution. For example, a workflow task with seconds: "{{item}}" would send the literal string "{{item}}" to core.sleep, which rejected it with "ERROR: seconds must be a positive integer".
Three interconnected features were missing from the executor's workflow orchestration:
- Template resolution —
{{ item }},{{ parameters.x }},{{ result().data.items }}, etc. in task inputs were never rendered through theWorkflowContextbefore dispatching child executions. with_itemsexpansion — Tasks declaringwith_items: "{{ number_list }}"were not expanded into multiple parallel child executions (one per item).publishvariable processing — Transitionpublishdirectives likenumber_list: "{{ result().data.items }}"were ignored, so variables never propagated between tasks.
A secondary issue was type coercion: render_json stringified all template results, so "{{ item }}" resolving to integer 5 became the string "5", causing type validation failures in downstream actions.
Root Cause
The ExecutionScheduler::dispatch_workflow_task() method passed task_node.input directly into the child execution's config without any template rendering. Neither process_workflow_execution (entry-point dispatch) nor advance_workflow (successor dispatch) constructed or used a WorkflowContext. The publish directives on transitions were completely ignored in advance_workflow.
Changes
crates/executor/src/workflow/context.rs
- Function-call expressions: Added support for
result(),result().path.to.field,succeeded(),failed(), andtimed_out()in the expression evaluator viatry_evaluate_function_call(). TaskOutcomeenum: New enum (Succeeded,Failed,TimedOut) to track the last completed task's status for function expressions.set_last_task_outcome(): Records the result and outcome of the most recently completed task.- Type-preserving
render_json: When a JSON string value is a pure template expression (the entire string is{{ expr }}),render_jsonnow returns the rawJsonValuefrom the expression instead of stringifying it. Addedtry_evaluate_pure_expression()helper. This means"{{ item }}"resolving to5stays as integer5, not string"5". rebuild()constructor: Reconstructs aWorkflowContextfrom persisted workflow state (stored variables, parameters, and completed task results). Used by the scheduler when advancing a workflow.export_variables(): Exports workflow variables as a JSON object for persisting back to theworkflow_execution.variablescolumn.- Updated
publish_from_result(): Uses type-preservingrender_jsonfor publish expressions so arrays/numbers/booleans retain their types. - 18 unit tests: All passing, including new tests for type preservation,
result()function,succeeded()/failed(), publish with result function, rebuild, and the exactwith_itemsinteger scenario from the failing workflow.
crates/executor/src/scheduler.rs
- Template resolution in
dispatch_workflow_task(): Now accepts aWorkflowContextparameter and renderstask_node.inputthroughwf_ctx.render_json()before wrapping in the execution config. - Initial context in
process_workflow_execution(): Builds aWorkflowContextfrom the parent execution's parameters and workflow-level vars, passes it to entry-point task dispatch. - Context reconstruction in
advance_workflow(): Rebuilds theWorkflowContextfrom theworkflow_execution.variablescolumn plus results of all completed child executions. Setslast_task_outcomefrom the just-completed execution. publishprocessing: Iterates transitionpublishdirectives when a transition fires, evaluates expressions through the context, and persists updated variables back to theworkflow_executionrecord.with_itemsexpansion: Newdispatch_with_items_task()method resolves thewith_itemsexpression to a JSON array, then creates one child execution per item withitem/indexset on the context. Each child getstask_indexset in itsWorkflowTaskMetadata.with_itemscompletion tracking: Inadvance_workflow(), tasks withtask_index(indicatingwith_items) are only marked completed/failed when ALL sibling items for that task name are done.
packs/examples/actions/list_example.sh & list_example.yaml
- Rewrote shell script from
bash+jq(unavailable in worker containers) to pure POSIX shell with DOTENV parameter parsing, matching the core pack pattern. - Changed
parameter_formatfromjsontodotenv.
packs.external/python_example/actions/list_numbers.py & list_numbers.yaml
- New action
python_example.list_numbersthat returns{"items": list(range(start, n+start))}. - Parameters:
n(default 10),start(default 0). JSON output format, Python ≥3.9.
Workflow Flow (After Fix)
For the examples.hello_workflow:
1. generate_numbers task dispatched with rendered input {count: 5, n: 5}
2. python_example.list_numbers returns {items: [0, 1, 2, 3, 4]}
3. Transition publish: number_list = result().data.items → [0,1,2,3,4]
Variables persisted to workflow_execution record
4. sleep_2 dispatched with with_items: "{{ number_list }}"
→ 5 child executions created, each with item/index context
→ seconds: "{{item}}" renders to 0, 1, 2, 3, 4 (integers, not strings)
5. All sleep items complete → task marked done → echo_3 dispatched
6. Workflow completes
Testing
- All 96 executor unit tests pass (0 failures)
- All 18 workflow context tests pass (including 8 new tests)
- Full workspace compiles with no new warnings (30 pre-existing)