Skip to content

Commit b532b20

Browse files
fix: #2305 emit Condition rule groups under conditionConfig
buildConditionNode emitted the rule group as a top-level `config.group`. processActionConfig lifts only `condition` and `conditionConfig` out of the config before rendering templates, and processTemplates does not recurse into arrays, so `group.rules[0].leftOperand` kept its token. The leftover-literal scan then found the survivor and failed closed, aborting the run before the Condition node ever executed. Three parts, which cannot ship apart: 1. The builder emits `conditionConfig: { group }`, matching the shape the scan factory builder has always produced. 2. A migration moves the key on rows already written. Seeded starters cannot be repaired from the editor, which persists conditionConfig but leaves the stale group in place, and the public hub rows insert with a fixed id and onConflictDoNothing() so they never pick up a fixture change. 3. The leftover-literal error names the field that carried the token. When an unread key holds a correctly spelled reference, the old message sent the reader to rewrite something that was never wrong. Part 3 is not Condition-specific by design. data.config is deliberately an open record, so there is no allowlist to validate against, but the path is known at the point the token is found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e089f84 commit b532b20

5 files changed

Lines changed: 172 additions & 8 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
-- Issue #2305: move a Condition node's rule group from the unread top-level `group`
2+
-- key to `conditionConfig`, which is the shape the runtime reads.
3+
--
4+
-- lib/workflow/node-builders.ts emitted `data.config.group`. processActionConfig lifts
5+
-- only `condition` and `conditionConfig` out of the config before rendering templates,
6+
-- so `group.rules` kept its unrendered `{{...}}` tokens, and the leftover-literal scan
7+
-- that runs next found them and aborted the run before the Condition node executed.
8+
--
9+
-- The rows cannot be repaired from the editor: opening a seeded Condition node parses
10+
-- the `condition` string into a group and persists it as `conditionConfig`, but never
11+
-- deletes the stale top-level `group`, so the workflow still aborts. That is why the
12+
-- builder fix alone does not reach organizations provisioned so far, including the
13+
-- public hub rows, which insert with a fixed id and onConflictDoNothing() and are
14+
-- therefore never refreshed from the fixture.
15+
--
16+
-- Idempotent. A row whose Condition nodes already carry only `conditionConfig` is not
17+
-- matched. Where both keys exist the existing `conditionConfig` wins and only the stale
18+
-- `group` is dropped, so re-running changes nothing.
19+
--
20+
-- `updated_at` is deliberately left alone: this is a repair, not a user edit, and
21+
-- moving it would reorder every affected workflow in the user's list.
22+
23+
UPDATE workflows AS w
24+
SET nodes = fixed.nodes
25+
FROM (
26+
SELECT
27+
src.id AS id,
28+
jsonb_agg(
29+
CASE
30+
WHEN node #>> '{data,config,actionType}' = 'Condition'
31+
AND jsonb_exists(node #> '{data,config}', 'group')
32+
THEN jsonb_set(
33+
node #- '{data,config,group}',
34+
'{data,config,conditionConfig}',
35+
CASE
36+
WHEN jsonb_exists(
37+
COALESCE(node #> '{data,config,conditionConfig}', '{}'::jsonb),
38+
'group'
39+
)
40+
THEN node #> '{data,config,conditionConfig}'
41+
ELSE COALESCE(node #> '{data,config,conditionConfig}', '{}'::jsonb)
42+
|| jsonb_build_object('group', node #> '{data,config,group}')
43+
END,
44+
true
45+
)
46+
ELSE node
47+
END
48+
ORDER BY ord
49+
) AS nodes
50+
FROM workflows AS src,
51+
LATERAL jsonb_array_elements(src.nodes) WITH ORDINALITY AS elem(node, ord)
52+
WHERE jsonb_typeof(src.nodes) = 'array'
53+
AND EXISTS (
54+
SELECT 1
55+
FROM jsonb_array_elements(src.nodes) AS probe(node)
56+
WHERE probe.node #>> '{data,config,actionType}' = 'Condition'
57+
AND jsonb_exists(probe.node #> '{data,config}', 'group')
58+
)
59+
GROUP BY src.id
60+
) AS fixed
61+
WHERE w.id = fixed.id;

drizzle/meta/_journal.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,13 @@
10581058
"when": 1788517619226,
10591059
"tag": "0150_keep_1333_summary_covering_index",
10601060
"breakpoints": true
1061+
},
1062+
{
1063+
"idx": 151,
1064+
"version": "7",
1065+
"when": 1788517620226,
1066+
"tag": "0151_keep_2305_condition_group_to_condition_config",
1067+
"breakpoints": true
10611068
}
10621069
]
1063-
}
1070+
}

lib/workflow/executor/template-resolution.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export type UnresolvedRef = {
2424
token: string;
2525
reason: UnresolvedReason;
2626
detail?: string;
27+
/**
28+
* Where in the rendered config the token was found, as a dotted path with
29+
* bracket indices, for example `group.rules[0].leftOperand`. Present for
30+
* leftover literals, which are the case where the token's own spelling is
31+
* often correct and the field holding it is the actual fault.
32+
*/
33+
path?: string;
2734
};
2835

2936
export type TemplateResolutionTracker = {
@@ -63,7 +70,8 @@ export function recordUnresolved(
6370
export function scanForLeftoverLiterals(
6471
value: unknown,
6572
out: UnresolvedRef[],
66-
depth = 0
73+
depth = 0,
74+
path = ""
6775
): void {
6876
if (depth > 10 || out.length > 50) {
6977
return;
@@ -74,6 +82,7 @@ export function scanForLeftoverLiterals(
7482
token: match[0],
7583
reason: "literal-leftover",
7684
detail: "Reference left in rendered config; resolver did not match.",
85+
path: path || undefined,
7786
});
7887
if (out.length > 50) {
7988
return;
@@ -82,14 +91,19 @@ export function scanForLeftoverLiterals(
8291
return;
8392
}
8493
if (Array.isArray(value)) {
85-
for (const item of value) {
86-
scanForLeftoverLiterals(item, out, depth + 1);
94+
for (const [index, item] of value.entries()) {
95+
scanForLeftoverLiterals(item, out, depth + 1, `${path}[${index}]`);
8796
}
8897
return;
8998
}
9099
if (value && typeof value === "object") {
91-
for (const item of Object.values(value as Record<string, unknown>)) {
92-
scanForLeftoverLiterals(item, out, depth + 1);
100+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
101+
scanForLeftoverLiterals(
102+
item,
103+
out,
104+
depth + 1,
105+
path ? `${path}.${key}` : key
106+
);
93107
}
94108
}
95109
}
@@ -129,7 +143,13 @@ function dedupeByToken(refs: UnresolvedRef[]): UnresolvedRef[] {
129143

130144
function formatErrorMessage(unresolved: UnresolvedRef[]): string {
131145
const summaries = unresolved.slice(0, 5).map((ref) => {
132-
return ref.detail ? `${ref.token} (${ref.detail})` : ref.token;
146+
// Naming the field matters when the token itself is spelled correctly and the
147+
// key holding it is the one the renderer never reached. Without it the message
148+
// sends the reader to rewrite a reference that was never wrong.
149+
const where = ref.path ? ` at ${ref.path}` : "";
150+
return ref.detail
151+
? `${ref.token}${where} (${ref.detail})`
152+
: `${ref.token}${where}`;
133153
});
134154
const more =
135155
unresolved.length > summaries.length

lib/workflow/node-builders.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,13 @@ export function buildConditionNode(
9292
config: {
9393
actionType: "Condition",
9494
condition: conditionConfig.condition,
95-
group: conditionConfig.group,
95+
// The runtime reads the rule group from `conditionConfig`, and
96+
// `processActionConfig` lifts only `condition` and `conditionConfig` out
97+
// before rendering templates. A top-level `group` is therefore never read,
98+
// and its rules array carries unrendered tokens into the leftover-literal
99+
// scan, which aborts the run. `lib/scan/factory/node-builders.ts` has always
100+
// emitted the nested shape; this builder is the one that drifted.
101+
conditionConfig: { group: conditionConfig.group },
96102
},
97103
status: "idle",
98104
},

tests/unit/template-fail-closed.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,3 +698,73 @@ describe("extractTemplateParameters strict integration", () => {
698698
expect(tracker.unresolved[0]?.reason).toBe("no-path");
699699
});
700700
});
701+
702+
describe("leftover literals name the field that carried them", () => {
703+
// Issue #2305: a config key the renderer never reaches keeps its tokens, and the
704+
// scan then reports the reference as unresolved. The reference is usually spelled
705+
// correctly and the key above it is the fault, so the message has to say where.
706+
const conditionConfigWithStaleGroup = {
707+
actionType: "Condition",
708+
condition: "resolved by its own path",
709+
group: {
710+
id: "group-1",
711+
logic: "AND",
712+
rules: [
713+
{
714+
id: "rule-1",
715+
leftOperand: "{{@step-1:Get Aave Health Factor.healthFactor}}",
716+
operator: "<",
717+
rightOperand: "1500000000000000000",
718+
},
719+
],
720+
},
721+
};
722+
723+
it("names the path through an array-valued key", () => {
724+
const tracker = createTracker();
725+
let message = "";
726+
try {
727+
assertResolved(tracker, conditionConfigWithStaleGroup, {
728+
nodeId: "step-2",
729+
nodeLabel: "Condition",
730+
actionType: "Condition",
731+
});
732+
} catch (error) {
733+
message = (error as Error).message;
734+
}
735+
expect(message).toMatch(UNRESOLVED_REF_MESSAGE);
736+
expect(message).toContain("group.rules[0].leftOperand");
737+
expect(message).toContain("{{@step-1:Get Aave Health Factor.healthFactor}}");
738+
});
739+
740+
it("records the path on the ref itself", () => {
741+
const tracker = createTracker();
742+
try {
743+
assertResolved(tracker, conditionConfigWithStaleGroup, {});
744+
} catch (error) {
745+
const { unresolved } = error as TemplateResolutionError;
746+
expect(unresolved[0]?.path).toBe("group.rules[0].leftOperand");
747+
expect(unresolved[0]?.reason).toBe("literal-leftover");
748+
}
749+
});
750+
751+
it("omits the path clause when the token sits at the root", () => {
752+
const tracker = createTracker();
753+
let message = "";
754+
try {
755+
assertResolved(tracker, "{{@step-1:Node.field}}", {});
756+
} catch (error) {
757+
message = (error as Error).message;
758+
}
759+
expect(message).toMatch(UNRESOLVED_REF_MESSAGE);
760+
expect(message).not.toContain(" at ");
761+
});
762+
763+
it("does not throw once the group is nested under conditionConfig", () => {
764+
// processActionConfig lifts `conditionConfig` out before rendering, so its
765+
// tokens never reach this scan. Simulate that by passing the config without it.
766+
const { group, ...rest } = conditionConfigWithStaleGroup;
767+
const repaired = { ...rest };
768+
expect(() => assertResolved(createTracker(), repaired, {})).not.toThrow();
769+
});
770+
});

0 commit comments

Comments
 (0)