fix: detect async CEL functions in forEach.in and throw actionable error#1170
fix: detect async CEL functions in forEach.in and throw actionable error#1170
Conversation
forEach.in is evaluated synchronously, but async CEL functions
(data.latest, data.findByTag, data.findBySpec, data.query) return
Promises that coerceBigInts silently converts to {}, causing forEach
to expand zero steps with no error. Add Promise detection in
CelEvaluator.evaluate before coerceBigInts runs, and wrap the error
in expandForEachSteps and libswamp evaluate.ts with a UserError that
names the expression and points to the nested workflow workaround.
Closes: swamp-club #88
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
-
src/libswamp/workflows/evaluate.ts:248— plainErrorinstead ofUserErrorcauses stack trace onswamp workflow evaluateThe forEach async-detection error in
evaluate.tsthrowsnew Error(...)while the identical error inexecution_service.tscorrectly throwsnew UserError(...). This matters because the top-levelrenderErrorinmain.tsdistinguishes between the two:UserError→logger.fatal("Error: {message}", ...)— clean, message only- plain
Error→logger.fatal("{error}", { error: err })— includes full stack trace
When a user runs
swamp workflow evaluatewith an async CEL function inforEach.in, they'll receive the actionable message plus a noisy stack trace. Theworkflow runpath avoids this because its catch block atworkflow_run.ts:290-294re-wraps the error inUserError, butworkflow_evaluate.tshas no such re-wrap.Fix: Change
throw new Error(at line 248 ofsrc/libswamp/workflows/evaluate.tstothrow new UserError(, and add the missing import:import { UserError } from "../../domain/errors.ts". Theevaluate.tserror message is clear and actionable — it just needs to be surfaced cleanly without a stack trace.
Suggestions
workflow_run.ts:294— "Workflow execution failed: " prefix is redundant with the new error message. The error message already saysforEach.in expression '...' returned an unresolved Promise— prefixing it with "Workflow execution failed:" doesn't add context. No change needed now (it's pre-existing catch-all behavior), but worth noting for future cleanup.
Verdict
NEEDS CHANGES — evaluate.ts throws new Error instead of new UserError, causing a stack trace to appear on swamp workflow evaluate for what is a clean, user-actionable error condition.
There was a problem hiding this comment.
Code Review
Blocking Issues
-
Overbroad
InvalidExpressionErrorcatch swallows non-Promise errors (src/domain/workflows/execution_service.ts:1557-1572,src/libswamp/workflows/evaluate.ts:246-261)Both catch blocks catch all
InvalidExpressionErrorinstances and unconditionally replace the message with "returned an unresolved Promise". However,CelEvaluator.evaluate()wraps every error (syntax errors, undefined variables, type mismatches, etc.) asInvalidExpressionErrorin its generic catch block (cel_evaluator.ts:378-384). This means any CEL evaluation error in aforEach.inexpression would be misleadingly reported as an "unresolved Promise" error.For example,
${{ data.findBySpce("model", "spec") }}(a typo) would produce:forEach.in expression '...' returned an unresolved Promise.instead of the actual "function not found" / evaluation error.
Fix: Discriminate based on the error message content:
if (error instanceof InvalidExpressionError && error.message.includes("unresolved Promise")) {
This works because
CelEvaluator.evaluate()re-wraps theInvalidExpressionErrorfrom the Promise check, preserving the "unresolved Promise" substring in the message.
Suggestions
-
Double-wrapping of
InvalidExpressionErrorincel_evaluator.ts: The Promise detection throwsInvalidExpressionErrorat line 364 inside the try block, which is then caught by the generic catch at line 378 and re-wrapped as anotherInvalidExpressionError. The resulting message is"Invalid expression: Invalid expression: Expression returned...". While the callers replace the message entirely, thecel_evaluator_test.tstests assert on this double-prefixed message. Consider either: (a) checking forInvalidExpressionErrorin the catch and re-throwing it directly, or (b) moving the Promise check after the try/catch. -
Inconsistent error type in
evaluate.ts: The libswamp catch throwsnew Error(...)whileexecution_service.tsthrowsnew UserError(...). Sinceevaluate.tsalready imports from the domain layer (InvalidExpressionError), consider usingUserErrorfor consistency — it suppresses the stack trace for expected user-facing errors. -
No unit test for the forEach Promise path in
evaluate_test.ts: The convention is "unit tests live next to source files". Theevaluate.tsmodule has its own parallel implementation of the Promise catch-and-wrap logic, but the test for this path only exists inexecution_service_test.ts. A unit test inevaluate_test.tswould catch regressions if these two implementations drift apart.
There was a problem hiding this comment.
Adversarial Review
Critical / High
-
Over-broad catch replaces ALL forEach.in errors with misleading "unresolved Promise" message —
execution_service.ts:1557-1572andevaluate.ts:246-262Both catch blocks catch
InvalidExpressionErrorand unconditionally throw a "returned an unresolved Promise" error. However,CelEvaluator.evaluate()wraps all errors asInvalidExpressionErrorvia the catch-all atcel_evaluator.ts:378-385. This means any CEL evaluation failure in aforEach.inexpression — syntax errors, missing variables, wrong argument counts — will produce the misleading Promise error instead of the real one.Breaking example: A user writes
forEach.in: '${{ undefinedVar.field }}'. The CEL evaluator throws becauseundefinedVaris not in context → wrapped asInvalidExpressionError→ caught by the new catch block → user sees "forEach.in expression returned an unresolved Promise" and guidance about async functions, when the actual problem is a missing variable.Fix: Check whether the caught error is specifically about Promises before replacing the message. For example:
} catch (error) { if ( error instanceof InvalidExpressionError && error.message.includes("unresolved Promise") ) { throw new UserError(/* async-specific message */); } throw error; }
Alternatively, throw the Promise-specific
InvalidExpressionErroroutside the try/catch incel_evaluator.ts(see finding #2) so it doesn't get double-wrapped, then use a distinct subclass or a flag to discriminate it.
Medium
-
Double-wrapping of InvalidExpressionError in
cel_evaluator.ts:364→378— The Promise detection at line 364 throwsInvalidExpressionErrorinside thetryblock. The catch at line 378 catches it and wraps it in anotherInvalidExpressionError. SinceInvalidExpressionError's constructor prepends"Invalid expression: ", the resulting message is"Invalid expression: Invalid expression: Expression returned an unresolved Promise...". The callers construct their own message so this doesn't directly hit users, but it would confuse anyone debugging.Fix: Move the Promise check and throw after the try/catch, or add
if (error instanceof InvalidExpressionError) throw error;at the top of the catch block. -
evaluate.ts:248throws plainErrorinstead ofUserError— The PR description says both sites throwUserError, butevaluate.tsthrowsnew Error(...)whileexecution_service.tscorrectly throwsnew UserError(...). Perdomain/errors.ts,UserErrorsuppresses stack traces for expected user-facing errors. Users hitting this code path throughswamp workflow evaluatewill see an unnecessary stack trace.
Low
- No test for
data.queryPromise detection — The unit tests incel_evaluator_test.tscoverdata.latest,data.findBySpec, anddata.findByTag, but notdata.query. The detection logic atcel_evaluator.ts:357handles all four correctly viainstanceof Promise, so this is just a coverage gap, not a code bug.
Verdict
FAIL — Finding #1 is a logic error that turns all forEach.in CEL failures into misleading "unresolved Promise" messages, which is worse than the pre-PR behavior (where the real error propagated). The fix is straightforward: narrow the catch to only match Promise-specific errors.
…rError
- Narrow catch blocks to only match Promise-specific errors by checking
error.message.includes("unresolved Promise"). Other CEL errors (syntax,
missing vars) now propagate with their original message.
- Add `if (error instanceof InvalidExpressionError) throw error` in
CelEvaluator.evaluate catch to prevent double-wrapping.
- Change evaluate.ts from plain Error to UserError for clean output
without stack traces.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Swallowed async errors in forEach task input resolution (
src/libswamp/workflows/evaluate.ts:386-393): InsideresolveForEachTaskExpressions, the syncdeps.evaluateCel()call is wrapped in a barecatch {}that silently discards all errors. If a user placesdata.findBySpec()in atask.inputsexpression within a forEach step (rather than inforEach.in), the new Promise detection will throwInvalidExpressionError, which gets silently caught. The input value is left as the raw${{ ... }}string, which will then fail later with a confusing error (or worse, be passed through as a literal string to the model method). Same pattern at line 408 for shell args. This isn't introduced by this PR — it's pre-existing — but the PR makes it more relevant since users hitting this error will be guided to move async calls intotask.inputs, yet the evaluate (dry-run) path would silently eat the same function there. The execution path handles it correctly since it usesevaluateAsync. -
String-based error dispatch (
src/domain/workflows/execution_service.ts:1558-1560,src/libswamp/workflows/evaluate.ts:248-250): The catch blocks identify the Promise case viaerror.message.includes("unresolved Promise"). This is fragile — if theInvalidExpressionErrorconstructor or the message text incel_evaluator.tschanges, these catches silently stop matching and the error falls through to the genericthrow errorpath, surfacing as an unhelpfulInvalidExpressionErrorinstead of aUserError. A dedicated error subclass (e.g.,AsyncInSyncContextError) or a discriminant property would be more robust. Not a correctness issue today, but a maintenance hazard. -
Exact duplication of the UserError message across
execution_service.ts:1562-1573andevaluate.ts:252-263: These are identical 12-line blocks. If the guidance or the workaround path changes, one will be updated and the other forgotten. Consider extracting to a shared helper.
Low
-
result.catch(() => {})swallows all rejections (src/infrastructure/cel/cel_evaluator.ts:362): The empty catch handler is intentional — the comment explains it. However, if the underlying async operation fails with a meaningful error (e.g., database connection failure), that error is silently discarded. In the current context this is fine (we're about to throw anyway and the caller doesn't care about the async result), but it means diagnostic information is lost. Aresult.catch((e) => { getLogger(["swamp", "cel"]).debug("suppressed dangling promise", { error: e }); })would preserve observability without changing behavior. -
Test sanitizers disabled (
src/domain/workflows/execution_service_test.ts:2699-2700):sanitizeResources: falseandsanitizeOps: falsedisable Deno's leak detection for this test. The comment explains why (CatalogStore WAL cleanup is interrupted by the error path), but this could mask real resource leaks introduced by future changes to this code path. Not blocking, but worth noting.
Verdict
PASS — The core fix is sound: detecting Promises before coerceBigInts destroys them and throwing a clear, actionable error is the right approach. The InvalidExpressionError re-throw guard in the catch block correctly prevents double-wrapping. Tests cover the key scenarios. The medium findings are maintenance concerns, not correctness bugs.
There was a problem hiding this comment.
Code Review
Well-structured fix for a subtle and nasty silent-failure bug. The three-layer approach (detect in infrastructure, translate in domain/libswamp services, document in design) is clean and follows DDD principles correctly.
Blocking Issues
None.
Suggestions
-
String-matching for error dispatch is fragile: Both
execution_service.tsandevaluate.tscheckerror.message.includes("unresolved Promise")to identify the specificInvalidExpressionError. If the message text incel_evaluator.tsis ever reworded, these catch blocks silently stop matching. Consider adding a discriminant property (e.g.,errorCode: "ASYNC_IN_SYNC_CONTEXT") toInvalidExpressionErrorfor a more robust check. Not urgent since both throw and catch sites are co-located in this codebase. -
Missing
data.queryPromise test: The CEL evaluator tests coverdata.latest,data.findBySpec, anddata.findByTagreturning Promises, butdata.query(also listed in the error message as an async function) has no corresponding test. Adding one would complete the coverage. -
No libswamp evaluate.ts forEach Promise test: The new catch-and-wrap in
src/libswamp/workflows/evaluate.ts(theworkflowEvaluatecode path) isn't directly tested — only theexecution_service.tspath has an integration test. The core detection inCelEvaluatoris well-tested, so this is low risk, but a parallel test inevaluate_test.tswould fully close the gap.
DDD Assessment
- Error placement is correct:
InvalidExpressionErrorlives in the domain expressions layer,UserErrorin the domain errors module. - Promise detection at the infrastructure layer (CEL evaluator) with translation to user-facing errors at the service layer follows the right responsibility boundaries.
- The libswamp
evaluate.tsimports from internal domain paths, which is permitted for libswamp-internal code per CLAUDE.md.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Error message reference path — Both
execution_service.tsandlibswamp/workflows/evaluate.tsend the error with:See: .claude/skills/swamp-workflow/references/nested-workflows.md#when-to-use-nested-workflowsThis path points to an internal Claude Code skill file. Users who installed swamp as a compiled binary won't have this directory at all, and even source-checkout users would find it unusual to navigate into
.claude/skills/. The actionable fix is already in the error body itself (move the async call into a parent workflow's task.inputs), so this is supplementary — but a GitHub URL to the relevant docs or a swamp docs link would serve binary users better. -
Duplicated error string — The
UserErrormessage is written verbatim in two places (execution_service.ts:1560andlibswamp/workflows/evaluate.ts:250). If the wording or the reference path ever changes, both must be updated in sync. Not a UX issue today, but worth noting.
Verdict
PASS — This is a clear UX improvement. The previous behavior silently produced 0 steps with no error; now users get an explicit, actionable message that names the bad expression, explains why it fails, and describes the fix. The UserError pattern propagates correctly through the workflow_run handler.
Summary
forEach.inuses syncCelEvaluator.evaluate, but async CEL functions (data.latest,data.findByTag,data.findBySpec,data.query) return Promises thatcoerceBigIntssilently converts to{}, causing forEach to expand zero steps with no error — a silent no-opCelEvaluator.evaluate(beforecoerceBigIntsdestroys the Promise) that throwsInvalidExpressionErrorwith a clear messageexpandForEachStepsandlibswamp/evaluate.tswith aUserErrorpointing to the nested workflow workaround documented in docs(skills/swamp-workflow): document when to use nested workflows #1165design/expressions.mdto reflect the async limitation ofdata.findBySpec()inforEach.inBefore: workflow silently "succeeds" with 0 steps executed
After:
Closes swamp-club #88
Test Plan
cel_evaluator_test.ts: Promise detection fordata.latest,data.findBySpec,data.findByTag; sync data functions still workexecution_service_test.ts:expandForEachStepsthrowsUserErrorwith actionable message whenforEach.inuses async data functiondata.findBySpecinforEach.in— confirmed clear error outputextension_push_test.tsunrelated)🤖 Generated with Claude Code