fix: #2305 emit Condition rule groups under conditionConfig - #2320
fix: #2305 emit Condition rule groups under conditionConfig#2320bongbongcrypto wants to merge 1 commit into
Conversation
About the
|
suisuss
left a comment
There was a problem hiding this comment.
What this changes
lib/workflow/node-builders.ts:101 stops emitting data.config.group and emits data.config.conditionConfig = { group }; condition is unchanged. drizzle/0151_keep_2305_condition_group_to_condition_config.sql repairs existing rows with one UPDATE workflows, and _journal.json gains the matching entry. lib/workflow/executor/template-resolution.ts threads a path accumulator through scanForLeftoverLiterals, adds it to UnresolvedRef, and interpolates it into the message. Four tests.
I confirmed the diagnosis independently rather than taking it: processTemplates (executor.workflow.ts:971-983) recurses into plain objects but excludes arrays, so config.group.rules is copied verbatim with its tokens intact, while scanForLeftoverLiterals does walk arrays and therefore finds them. processActionConfig (:2341-2342) blanks only condition and conditionConfig before rendering, which is why moving the group under conditionConfig fixes it. node-builders.ts really was the only producer of the top-level key.
Does it match the description
Matches, and the three parts are as described.
Blocking
-
drizzle/0151_keep_2305_condition_group_to_condition_config.sql:41-42-||on a non-objectconditionConfigconcatenates rather than merges. I ran the migration file against Postgres 16.14: with"conditionConfig": nullalongside a stalegroup, the result is"conditionConfig": [null, {"group": {...}}], and an existing array concatenates. ->resolveConditionExpression(lib/workflow/nodes/condition/resolver.ts:20-26) then reads.groupoff an array, getsundefined, and silently falls back to theconditionstring, whilesanitize-nodes.ts:148-165declines to repair it becausetypeof group !== "object". -> Guard the ELSE branch onjsonb_typeof(... conditionConfig) = 'object'and replace outright otherwise. -
drizzle/0151...sql:31,57- both guards test key presence, not type. With"group": nullthe migration writes"conditionConfig": {"group": null}, which I reproduced. -> That is a new editor crash rather than a repair:components/workflow/config/action-config.tsx:299-303callsvisualConditionToExpressionwheneverexistingConditionConfigis truthy, andgroupToExpression(lib/workflow/nodes/condition/expression.ts:167-168) dereferencesgroup.rules.length, so opening the node throws. -> Usejsonb_typeof(node #> '{data,config,group}') = 'object'in both guards. I found no path on staging that writes JSONnullthere, so this is latent rather than demonstrated - but the migration is the one place that should not create the shape it exists to remove.
Mechanical - actionable as-is
-
drizzle/0151...sql:5-11- the justification in the comment is wrong. It says the public hub rows "insert with a fixed id andonConflictDoNothing()and are therefore never refreshed from the fixture". Neither seeder does that:scripts/seed/seed-onboarding-workflows.ts:56-119andseed-tempo-templates.ts:68-96both select-then-insert-or-update, and they do refresh a fixed-id row whenseededAtis set and the row is withinUSER_EDIT_EPSILON_MS.grep onConflictDoNothingover both returns nothing. The migration is still necessary, for a different reason worth recording instead: the per-org rows inserted at signup (lib/auth.ts:871-886) get generated ids and noseededAt, souserEditedis true and no seeder can ever match them. -
No test covers either of the two seams that matter. Nothing asserts
buildConditionNodeemitsconditionConfig.groupand no top-levelgroup- that is the one line stopping the bug recurring, and a three-line assertion intests/unit/onboarding-workflows.test.tscloses it. And the fourth new test, "does not throw once the group is nested underconditionConfig", destructuresgroupaway and asserts that a config with no tokens does not throw; it exercises neitherprocessActionConfignor the lifting. -
dedupeByToken(template-resolution.ts:133-141) keys ontoken::reasonand keeps the first, so a token appearing at two paths reports only one location. Worth a sentence in the doc comment now that the path is user-visible. -
Check 0151 is still free before merge - it is unused on staging today, but another migration in flight would collide.
Verdict
Changes requested - the migration can write two shapes it should not, one of which crashes the editor it is meant to unbreak.
The rest of it holds up well and I want to be specific about what I checked, because a data migration deserves it. The node order is preserved by the ORDER BY ord in jsonb_agg. It is idempotent - a row carrying only conditionConfig is not matched on a re-run. There is no DDL, so it takes ROW EXCLUSIVE and row locks for the statement only, with no read blocking; measured on Postgres 16.14 over 200k rows at 88 MB with one in three matching, it ran in 2.65s. Give me the real prod row count and I will sanity-check that before it goes out. No down migration, consistent with the ten prior data-only migrations, and safe to roll the app back against because nothing has ever read the top-level key. _journal.json is contiguous with a monotonic when and no snapshot, which matches repo practice.
Not splitting this. The three seams are independent in the correctness sense, but the migration alone leaves every new signup recreating broken rows and the builder change alone leaves every existing org broken - that is a sequencing coupling, and they should land together.
One thing worth its own issue rather than this PR: the fix addresses the array reaching the scan, not the asymmetry that processTemplates skips arrays while scanForLeftoverLiterals walks them. No action schema declares an array field today, so nothing else is exposed, but web3 args and functionArgs are free-form JSON and an MCP-authored array containing a token would land in the same place.
lib/workflow/node-builders.ts emitted the rule group at data.config.group.
processActionConfig lifts only condition and conditionConfig out of the config
before rendering templates, so group.rules kept its unrendered {{...}} tokens,
the leftover-literal scan found them, and the run aborted before the Condition
node executed. The rows cannot be repaired from the editor, which persists
conditionConfig without deleting the stale key, so a data migration goes with
the builder change.
Rebased onto staging: 0151 was taken by the org circuit breaker, so the
migration is 0152 and the journal entry follows staging's.
Review fixes:
- The migration merged into conditionConfig with `||`, which concatenates
rather than merges when either side is not an object. A JSON null produced
[null, {"group": ...}], which resolveConditionExpression reads .group off as
undefined and sanitize-nodes.ts declines to repair. Anything that is not an
object is now replaced outright.
- Both guards tested key presence, so a "group": null wrote {"group": null}
into conditionConfig, and action-config.tsx would then dereference
group.rules and throw on open. Both now test jsonb_typeof(...) = 'object',
and a non-object group is left exactly as it is.
- The comment justified the migration with onConflictDoNothing(), which
neither seeder uses. The real reason is that lib/auth.ts:871-886 inserts the
fixtures for a new organization with no id and no seededAt, so
seed-onboarding-workflows.ts matches them neither by fixed id nor by the
updatedAt-to-seededAt window.
- Added the assertion that stops this recurring: every Condition node in
ONBOARDING_WORKFLOW_FIXTURES carries conditionConfig.group and no top-level
group. It fails on all four Condition nodes with the previous builder.
- Dropped the fourth test. It destructured the group away and asserted a
config with no tokens does not throw, which exercised neither
processActionConfig nor the lifting, and processActionConfig is nested
inside the executor closure so a unit test cannot reach it.
- Noted on UnresolvedRef.path that dedupeByToken keys on token and reason and
keeps the first, so a token at several paths is reported at one of them.
Verified on Postgres 18.3 against fixtures covering an absent, JSON null,
array, string and object conditionConfig, a null and a non-object group, a
non-Condition node, an already-migrated row and a three-node workflow: the
previous revision fails five of those, this one passes, twice in a row, with
node order and updated_at unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b532b20 to
34421e2
Compare
|
Thank you for running the migration rather than reading it. Both blockers reproduce exactly as described, and pushing back on a data migration this way caught a third one that was mine. Pushed 34421e2. The branch is rebased onto The two blockers
A third one, found while testing the fix My first pass used Verification Postgres 18.3, eleven fixtures: an absent, JSON The mechanical items
On the array asymmetry: agreed it is its own issue, and I will open one for it rather than widen this. |
|
Opened the array asymmetry as #2359, with a reproduction: the same token in the same node resolves when |
Issue
Closes #2305
What this changes
Following the shape you accepted, in three parts.
1.
lib/workflow/node-builders.tsemitsconditionConfig: { group }. It was the onlyproducer of the top-level
group, and it has emitted it since the file was introduced.lib/scan/factory/node-builders.tsalready produces the nested shape, so this removes thedrift rather than adding a second supported shape. I did not teach the resolver to read
group, for the reason you gave: the run never reaches the resolver, and an alias wouldmake the second shape permanent.
2.
drizzle/0151_keep_2305_condition_group_to_condition_config.sqlrepairs rows alreadywritten. For each Condition node carrying a top-level
group, the key moves underconditionConfigand the stale one is dropped. Where both keys exist the existingconditionConfigwins and only the stale key is removed, which is the state a user reachesby opening the node in the editor. Re-running changes nothing.
updated_atis deliberately not touched. This is a repair, not a user edit, and moving itwould reorder every affected workflow in the user's list.
3. The leftover-literal error names the field that carried the token.
scanForLeftoverLiteralsnow threads a path,UnresolvedRefcarries it, and the messagereads
{{@step-1:Node.field}} at group.rules[0].leftOperand (Reference left in rendered config...).On your question about how far to take that third part: I kept it to naming the path and
did not attempt to validate keys.
data.configis an open record by design, so anyallowlist would either be wrong for plugins or need a registry that does not exist. The
path is already known at the point the token is found, costs one parameter to carry, and
generalises to any array-valued or unlifted config field rather than to Condition alone.
Root-level tokens omit the clause, so single-field configs read as they do today.
Scope
One change. Part 1 without part 2 leaves every organization provisioned so far broken,
including the public hub rows that other users clone. Part 2 without part 1 repairs the
data and then the next signup writes it wrong again. Part 3 is the diagnostic that made the
first two take four runs to find, and it touches the same failure path.
Touched outside the obvious:
drizzle/meta/_journal.jsongains the entry for the newmigration.
How it was verified
Added to
tests/unit/template-fail-closed.test.ts, over a config with the exact shape fromthe issue:
group.rules[0].leftOperandand still contains the tokenUnresolvedRef.pathcarries the same path, with reasonliteral-leftoverFor the migration I could not run it against a database, so I checked the transformation
rather than the SQL: the same rules applied in JavaScript to the node shapes this touches.
groupremoved, promoted underconditionConfigcondition, id, label, status, position unchangedconditionConfigkept, stale key droppedgroupkeyTwo things I would like checked on your side, since I cannot:
jsonb_agg ... ORDER BY ordregrouping, which is where an error would silently reorder a workflow's nodes
0151is the right way to ship a data fix here, or whether these run outside thenumbered sequence. I added the journal entry on the assumption that they do not.
Same honest note as on #2319: I have not run
pnpm checkorpnpm type-checklocally,because I do not install and run an unfamiliar repository on the machine I work from. The
changed files were type-checked in isolation for syntax. CI on this PR is the real check
and I will fix what it reports.
Screenshots
Nothing renders.
stagingpnpm checkandpnpm type-checkpass (not run locally, see the note above).envfiles, or credentials committed🤖 Generated with Claude Code