feat(expressions): expose workflow run context as run.* in CEL (swamp-club#331)#1370
Conversation
…essions (swamp-club#331) Workflow steps can now reference `run.id`, `run.workflowName`, `run.workflowId`, `run.startedAt`, and `run.tags` in CEL template expressions. This enables run-scoped resource keys that prevent collisions during concurrent workflow executions. The root cause of the missing variable was timing: WorkflowExpressionEvaluator eagerly evaluated all expressions before the WorkflowRun was created. The fix defers `run.*` and `workflowRunId` expressions to step execution time, matching the existing `self.*` deferral pattern. Co-authored-by: Blake Irvin <blakeirvin@me.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Well-structured feature that exposes workflow run metadata as run.* in CEL expressions. The implementation correctly defers run.* and workflowRunId expressions to step execution time (matching the existing self.* pattern), and the RunContext interface is a clean value object placed appropriately in the expression domain.
Blocking Issues
None.
Suggestions
-
Minor: regex could use
test()instead ofmatch()— Inexpression_evaluators.ts:100-101,expr.celExpression.match(/\brun\./)andexpr.celExpression.match(/\bworkflowRunId\b/)allocate match arrays that are immediately discarded. Using.test()(e.g.,/\brun\./.test(expr.celExpression)) is marginally more efficient and expresses intent more clearly. Same pattern exists for theself.*check on line 94. Non-blocking — consistent with existing code style. -
Design doc table formatting — The
design/expressions.mdtable usesRecord<string,string>forrun.tagstype. Consider using backticks around the generic to prevent any markdown rendering ambiguity with the angle brackets if this doc is ever rendered outside of GitHub. Very minor.
Overall: clean implementation, good test coverage across all three layers (expression evaluation service, workflow expression evaluator skip logic, and execution service integration), correct DDD placement of RunContext as a value object in the expressions domain, and the non-null assertion on run.startedAt! is safe since it immediately follows run.start() which sets the field.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
src/domain/workflows/expression_evaluators.ts:100— Regex/\brun\./could false-match inside CEL string literals, deferring unrelated expressions.
If a user writes a CEL expression containingrun.inside a string literal — e.g.${{ data.query('hasPrefix(name, "run.")') }}— the regex matches the inner string content and incorrectly skips the expression during workflow evaluation. The expression is still evaluated at step execution time (step inputs are always re-resolved), so there's no data loss — only deferred evaluation. This is the same limitation the existing\bself\.check has (line 94), so this PR is consistent with the established pattern. A CEL-aware check would be more robust, but the blast radius here is minimal.
Low
-
src/domain/workflows/execution_service.ts:1375— Non-null assertion onrun.startedAt!.
run.start()is called on line 1368 which sets_startedAt = new Date(), sostartedAtis guaranteed non-null. The assertion is safe. A defensive?? new Date().toISOString()fallback would remove the assertion, but this is style, not a bug. -
src/domain/workflows/execution_service.ts:1370-1378— Therunexpression context is a snapshot, not a live view.
expressionContext.runis set once afterrun.start()and capturestagsvia shallow copy ({ ...run.tags }). If tags were mutated later in the run (they aren't currently), the expression context would be stale. This is the correct design — snapshots avoid concurrency hazards — but worth noting if tags ever become mutable mid-run.
Verdict
PASS — Clean, well-structured change that follows established patterns. The run.* namespace is correctly deferred past workflow-level evaluation, the RunContext interface is appropriately typed, the startedAt timing (set after run.start()) is correct, and the spread-copy of tags prevents aliasing. Test coverage is thorough: unit tests for CEL resolution of all five fields, skip-behavior tests for both run.* and workflowRunId, and an integration test verifying end-to-end resolution against a real WorkflowExecutionService. No blocking issues.
Summary
runnamespace to the CEL expression context so workflow steps can referencerun.id,run.workflowName,run.workflowId,run.startedAt, andrun.tagsin${{ }}template expressionsworkflowRunIdwas set on the context after workflow expressions were eagerly evaluated —run.*andworkflowRunIdexpressions are now deferred to step execution time, matching the existingself.*patternfiltered-vms-${{ run.id }}) to prevent collisions during concurrent workflow executionsTest plan
run.id,run.workflowName,run.startedAt,run.tagsresolution viaresolveAllExpressionsInDatarun.*andworkflowRunIdexpressions skipped during workflow evaluation${{ run.id }}in step inputs resolves to actual run UUID${{ run.id }}and${{ run.workflowName }}— both resolve correctlydeno check,deno lint,deno fmtall passCloses swamp-club#331
🤖 Generated with Claude Code