fix: resolve ternary CEL expressions with partial inputs (#814)#815
fix: resolve ternary CEL expressions with partial inputs (#814)#815
Conversation
PR #655 introduced a skip-guard in evaluateDefinitionExpressions() and evaluateDefinition() that used extractInputReferencesFromCel() — a regex scan — to detect all inputs.* references and skip evaluation if any were absent. This was intended to fix issue #653 (factory model delete steps failing with 'No such key: cidrBlock' when create-time inputs are omitted). The guard is too aggressive: for a ternary such as inputs.transport == "lan" ? inputs.lan_host : inputs.tailnet_host the regex reports all three inputs as required. When lan_host is absent (because transport is "wan"), the entire expression is skipped — leaving globalArguments.host as a raw ${{ }} string. The shell model then tries to execute that literal string, producing exit code 1 and a failed workflow step. CEL natively short-circuits ternaries, so missing branch inputs are never accessed at runtime. The correct fix is to attempt evaluation and catch any CEL error (which only fires for inputs referenced directly, not inside a non-taken branch). This preserves the #653 behaviour — a directly- missing input still throws at eval time and is caught, leaving the expression unresolved — while allowing ternary and other conditional expressions to evaluate correctly. Changes: - execution_service.ts: remove providedInputKeys / extractInputReferencesFromCel skip block; wrap celEvaluator.evaluate() in try-catch; rename hasMissing → hasMissingModelDep for the retained model-resource dependency check - expression_evaluation_service.ts: identical change for the CLI path - cel_evaluator_test.ts: two new tests proving CEL ternary short-circuit and direct-missing-input throw behaviour - expression_parser_test.ts: documents why extractInputReferencesFromCel must not be used as a skip gate (reports all ternary branch inputs) - expression_evaluation_service_test.ts: two model-agnostic unit tests for evaluateDefinition() with globalArguments ternary (primary regression proof) - workflow_partial_inputs_test.ts: end-to-end ternary test through the shell model execution path Fixes #814. Preserves #653 fix. Co-authored-by: Adam Stacoviak <[email protected]> Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
There was a problem hiding this comment.
Review: fix: resolve ternary CEL expressions with partial inputs (#814)
No blocking issues found. The fix is clean, well-tested, and correctly addresses the regression.
What I verified
- Root cause & fix: The regex-based
extractInputReferencesFromCel()pre-check was semantically incorrect for conditional expressions — it treated allinputs.*references as required regardless of branch. Replacing it with try-catch aroundcelEvaluator.evaluate()is the right approach: CEL natively short-circuits ternaries, so only the taken branch needs its inputs. - Both code paths fixed: The identical fix is applied in both
ExpressionEvaluationService.evaluateDefinition()andDefaultStepExecutor.evaluateDefinitionExpressions(), keeping them in sync. - Model dependency pre-check retained: The structural
extractDependencies()check for model resource/file availability is correctly preserved — model data is never conditionally accessed, so the pre-check remains valid. The rename fromhasMissingtohasMissingModelDepmakes the intent clearer. - #653 regression protection: The "directly-missing input stays unresolved" test explicitly verifies the original fix is preserved.
- Import boundary: No libswamp internal imports from CLI/presentation layers. Integration tests import from domain/infrastructure directly, which is correct for test code.
- License headers: All new/modified files have the AGPLv3 header.
- DDD: Changes are scoped to domain services (
ExpressionEvaluationService,DefaultStepExecutor) — the right layer for expression evaluation logic. No domain model changes needed. - Test coverage: Excellent — 6 files changed with tests at every layer: CEL evaluator unit tests, expression parser documentation test, expression evaluation service unit tests, and an end-to-end integration test.
Suggestions (non-blocking)
-
Bare
catchswallows all CEL errors (expression_evaluation_service.ts:251,execution_service.ts:779): The catch blocks silently discard anyInvalidExpressionError, not just missing-input errors. A CEL syntax error in a${{ }}expression would be silently ignored and left as a raw string, only surfacing later when the Proxy throws. Consider catchingInvalidExpressionErrorspecifically and logging at debug level so expression authoring mistakes are easier to diagnose. This is a minor observability improvement, not a correctness issue — the Proxy still guards against using unresolved values. -
extractInputReferencesFromCelis now unused outside tests andextractInputReferences: It's still exported and called internally byextractInputReferences()inexpression_parser.ts:272, so it's not dead code, but it might be worth adding a doc comment noting it should not be used as a skip gate (the test documents this, but a comment on the function itself would help future contributors).
LGTM — approve.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Overly broad catch swallows all CEL errors, not just missing-input errors —
src/domain/expressions/expression_evaluation_service.ts:251andsrc/domain/workflows/execution_service.ts:779The empty
catch {}catches every error fromcelEvaluator.evaluate(), including syntax errors, type mismatches, and other genuine bugs in CEL expressions — not just the missing-input case the comment describes.Breaking example: A user writes a typo in a CEL expression in
methods.execute.arguments.run:run: '${{ "echo " + inputs.transprot }}' # typo: transprot instead of transport
Previously (before #655), this would throw
InvalidExpressionErrorwith a clear message like"No such key: transprot". Now it's silently swallowed, the raw${{ ... }}string is passed to the shell, and the user seesbash: ${{: command not foundor similar — a far less helpful error.For
globalArguments, the Proxy safety net catches this with a reasonable message. But formethods.<name>.argumentsvalues (theexecution_service.tspath), there is no Proxy — the raw${{ }}string flows directly to the execution driver. The comment on line 782 says "The Proxy on globalArgs will surface a clear error" but this is only true for globalArguments, not method arguments resolved in this same code path.Suggested fix: Catch only
InvalidExpressionErrorwhere the cause message indicates a missing variable/key, and re-throw everything else. Alternatively, at minimum log a warning in the catch block so users have a breadcrumb when debugging expression issues. Something like:} catch (error) { if (error instanceof InvalidExpressionError && isMissingInputError(error)) { // Leave unresolved — CEL short-circuited past this input continue; } throw error; }
-
Model dependency pre-check has the same ternary problem this PR fixes for inputs —
src/domain/expressions/expression_evaluation_service.ts:228-243andsrc/domain/workflows/execution_service.ts:756-770The comment says "model data is never conditionally accessed in CEL — member access on a missing model ref is always an error." This is an assumption about user behavior, not enforced by the system. A user could write:
${{ inputs.use_cache ? model.cache-model.resource.value : "default" }}The
extractDependenciesregex would findmodel.cache-model.resource, see it missing, and skip the entire expression — the exact same class of bug this PR fixes for inputs.This is pre-existing behavior and not introduced by this PR, so it doesn't block merge. But worth noting since the PR specifically touches this code and adds a comment claiming it's always correct.
Low
-
Duplicated evaluation logic between two files — The expression evaluation loop in
execution_service.ts:749-785andexpression_evaluation_service.ts:221-256are near-identical. This was pre-existing duplication, but the PR required applying the same fix in both places. A future refactor to share this logic would prevent the two paths from diverging. -
extractInputReferencesFromCelis now unused in production code but still exported — The import was removed from bothexpression_evaluation_service.tsandexecution_service.ts. It's still used in tests (to document why it shouldn't be used as a skip gate), but if no production code calls it, it's dead code.
Verdict
PASS — The fix is logically sound and well-tested. CEL's native short-circuit evaluation is the correct approach to handle conditional expressions with partial inputs, and the regression tests are thorough. The broad catch (Medium #1) is a real concern for debuggability but not a correctness or safety issue — the Proxy provides a safety net for globalArguments, and for method arguments the expression would have been skipped by the old regex check in many of the same scenarios. The test coverage (unit + integration) gives confidence the fix works for both the new ternary case and the original #653 partial-inputs case.
Summary
Fixes a regression introduced in PR #655 where CEL expressions containing ternary operators would be incorrectly skipped during workflow execution when any input referenced in any branch of the ternary was absent — even if that branch would never be evaluated at runtime.
Root Cause
PR #655 added a skip-guard to
evaluateDefinitionExpressions()andevaluateDefinition()to fix issue #653 (factory model delete steps failing withNo such key: cidrBlock). The guard usedextractInputReferencesFromCel()— a regex scan — to detect allinputs.*references and skip evaluation if any were absent.This is semantically incorrect for conditional expressions. Given:
The regex reports
transport,lan_host, andtailnet_hostas all required. Iflan_hostis absent (becausetransportis"wan"), the entire expression is skipped — leavingglobalArguments.hostas a raw${{ }}string. The shell model then tries to execute that literal string and fails with exit code 1.Fix
Replace the inputs regex pre-check with try-catch around the CEL evaluation call.
CEL natively short-circuits ternaries — it only evaluates the taken branch. So:
The model resource/file dependency pre-check (checking whether a prior step's output is available in context) is retained unchanged — that is a structural availability check, not an input presence check, and is always correct to guard.
User Impact
Who is affected: Any workflow step whose model definition uses a ternary (or other conditional CEL expression) in
globalArgumentsormethods.<name>.arguments, where only the inputs for the taken branch are provided.Symptom: The workflow step fails with a non-zero exit code because the shell executes a literal
${{ ... }}string rather than the resolved command.Breaking change: No. This is a bug fix. Existing workflows that worked before #655 will now work correctly again. The factory model delete pattern fixed by #653 continues to work — directly-referenced missing inputs are still left unresolved via try-catch.
Changes
src/domain/workflows/execution_service.tsprovidedInputKeys/ regex skip block; wrapcelEvaluator.evaluate()in try-catch; renamehasMissing→hasMissingModelDepfor claritysrc/domain/expressions/expression_evaluation_service.tssrc/infrastructure/cel/cel_evaluator_test.tssrc/domain/expressions/expression_parser_test.tsextractInputReferencesFromCelmust not be used as a skip gatesrc/domain/expressions/expression_evaluation_service_test.tsevaluateDefinition()with ternary inglobalArgumentsintegration/workflow_partial_inputs_test.tsTest Plan
deno lint— cleandeno fmt --check— cleandeno run test— 3484 passed, 0 faileddeno run compile— binary builds successfullyworkflow run issue-814-ternary→succeeded(regression fixed)workflow run issue-653-partial-inputs→succeeded(GlobalArguments CEL expressions fail when factory inputs are omitted — selective evaluation not working #653 fix preserved)globalArgumentsternary resolves correctlyCloses #814
🤖 Generated with Claude Code