Skip to content

[pull] main from czlonkowski:main - #100

Merged
pull[bot] merged 1 commit into
5474312:mainfrom
czlonkowski:main
Jul 26, 2026
Merged

[pull] main from czlonkowski:main#100
pull[bot] merged 1 commit into
5474312:mainfrom
czlonkowski:main

Conversation

@pull

@pull pull Bot commented Jul 26, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

* fix: preserve and author n8n canvas groups (nodeGroups)

n8n 2.28 added canvas groups, stored on the workflow as `nodeGroups` and
validated by n8n on every write — including writes unrelated to grouping.
Our update payload dropped the field, so n8n backfilled the stored groups
and validated them against the graph we submitted. Any grouped workflow
then failed with HTTP 400 as soon as a diff removed a grouped node or
changed a group's connectivity, taking partial updates, full updates,
autofix, rollback and version restore with it.

Groups are now carried through writes and reconciled with the finished
graph once, at the end of a diff: members a diff deleted are pruned, a
group left empty is removed, and a group n8n refuses is ungrouped so the
edit still lands. Nodes and connections are never altered to save a frame.

Topology stays n8n's call. Its rejection names the offending group, so the
server adjudicates and we drop precisely that group — no local copy of its
grouping rules to drift from the running instance. Groups the caller
authored in the same request are never silently discarded; n8n's message
is surfaced instead. Support is discovered from the write itself rather
than a version probe, since /rest/settings does not expose a version on
every instance: an instance that rejects the field gets the workflow
without it, one that rejects group descriptions (n8n 2.32+) gets the
groups without them, and both warn.

Also adds the setNodeGroups diff operation, `nodeGroups` on create and
full update, groups in structure/filtered/active reads and template
deploys, non-blocking group checks in validate_workflow, and two fixes
found along the way: `updateNode` no longer accepts an `id` rewrite (it
orphaned group membership and pinned data), and compareVersions now
notices a group-only change.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review findings on canvas-group support

Copilot:
- classifyGroupError bailed out when the sent nodeGroups array was empty, so
  an explicit `nodeGroups: []` (ungroup everything) against a pre-2.28
  instance threw instead of degrading to omitting the field. The classifier is
  only consulted once the field was sent, and [] is a sent field.
- setNodeGroups reported "node is in two groups" when the same node appeared
  twice in ONE group's member list. A duplicate within a group describes the
  same member set, so it is deduped; only a claim by a different group is a
  conflict.

Codex:
- sendWorkflowWrite warned about an unsupported nodeGroups field only on the
  write that discovered it. The client outlives a request, so every later
  write silently dropped groups — including ones the caller had just authored,
  reported as plain success. Both capability limits now warn on every affected
  write, from shared constants so the paths cannot drift.
- patchNodeField could rewrite node.id by dot path, bypassing the immutability
  guard added to updateNode and orphaning canvas groups and pinned data. Only
  the node's own id is refused; a nested id such as
  parameters.assignments.assignments[0].id is ordinary data.
- Group warnings raised while rolling back never reached the caller, because
  that path throws before the success response is built. They now ride the
  augmented error details, so "restored to prior state" no longer hides that
  a group was dropped to get there.

Also folds in a simplification pass: toWorkflowNodeGroup() is shared by the
create/update path and the diff operation so they cannot drift on trimming or
id generation, nodeGroupsField() replaces the repeated spread-conditionals in
the read handlers, and a docblock that had drifted onto the wrong function is
back where it belongs.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close canvas-group defects found in review

Critical: a payload with `nodeIds: []` alongside a populated `nodeNames`
ungrouped the entire workflow while reporting success. validateSetNodeGroups
picked the member list by non-emptiness, resolveGroupMembers by mere presence,
so the group resolved to zero members, finalization dropped it as empty, and —
setNodeGroups being a full replacement — every other group went with it. Both
keys with one empty is a common LLM shape. The resolver now uses the validator's
predicate, and applySetNodeGroups throws instead of skipping when the two
disagree, so an unvalidated group can never reach the workflow.

The additionalProperties branch of classifyGroupError was not scoped to
nodeGroups: any unknown-property 400 (a stray settings key, a field a future
n8n stops accepting) latched `groupSupport.groups = false` on a client that
lives for the process. Every later write then omitted the field, n8n backfilled
the stored groups and revalidated them, and the exact 400 this feature exists
to prevent came back — on an instance that supports groups fine. The complaint
must now implicate nodeGroups. On a genuinely pre-2.28 instance the only way to
reach that branch is an explicitly authored grouping, and failing that loudly is
the documented contract.

A group name containing a quote (`Say "hi"`) truncated the name capture, so the
offending group matched nothing and control fell through to ungroup-everything —
destroying every other group to recover from one. n8n's connectivity message
carries the group id, which is now preferred for matching; an identified group
we cannot match surfaces the error instead of guessing.

The authored-group contract held in the write ladder but not in repair, so a
mistyped nodeId in a group the caller had just authored was silently pruned (or
the group dropped) and reported as success. Repair now takes the authored set
and errors for those, while inherited groups keep degrading as before.

Also: the last resort drops only inherited groups rather than all of them;
malformed group entries are reported instead of vanishing; a payload with no
`nodes` is left alone rather than treated as "no nodes exist"; a null
`nodeGroups` from a GET is not forwarded; one malformed row no longer fails a
filtered read; and version restore and template deploy carry the warning channel
so group adjustments made during those writes reach the caller.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: only forward a usable string as a canvas-group description

Groups read back from n8n are untyped at runtime, so sanitizeGroupsForApi could
forward a number or object as `description` and earn a 400 the degradation
ladder cannot attribute to descriptions. Non-strings and blanks are now dropped.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: validate the setNodeGroups payload instead of trusting it

This operation's payload is unvalidated at the tool boundary (z.any() in the
operations schema), so malformed input reached code that assumed strings: a
non-string member threw a bare "trim is not a function" from normalizeNodeName,
and a non-string id did the same inside toWorkflowNodeGroup. Both now return an
actionable message.

Description is validated here too — type and n8n's 155-character cap — so the
diff operation agrees with the create/update tools rather than letting n8n
answer with a 400 that the degradation ladder then has to interpret.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore n8n_workflow_versions rollback without an instance context

The handler resolved its API client only when an InstanceContext was supplied:

    const client = context ? getN8nApiClient(context) : null;

That skipped the environment-variable fallback inside getN8nApiClient which
every other tool reaches through ensureApiConfigured. On a plain N8N_API_URL
setup — the common single-instance case — `client` was therefore always null,
and since `rollback` is the only mode that needs the API, it answered
"n8n API not configured. Cannot perform rollback without API access." for every
workflow while `list` and `get` kept working off the local version store.

Multi-tenant isolation is unaffected: it is enforced inside getN8nApiClient
(which refuses process credentials when ENABLE_MULTI_TENANT is set) and by the
tenant-scope check at the top of this handler, not by the ternary.

Found by the canvas-group live test, which could not exercise rollback across a
node deletion. Unrelated to canvas groups, folded into this PR at the author's
request.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close four defects from the second review pass

Template deploy collected canvas-group warnings into a variable nothing read,
so a template whose frames n8n could not store reported an unqualified
"deployed successfully". It was the only one of the five write entry points
breaking the warning contract; tsc cannot flag a write-only local.

dropRejectedGroup matched "id only, else name only" rather than id first and
name second. The id is scraped from a parenthetical in n8n's message, so any
message shaped `Node group "X" (2 nodes) ...` produced an id that matched
nothing and suppressed a perfectly good name match — failing the whole write
for an inherited group, which is the class of failure this feature removes.
Group names are unique, so the fallback cannot select the wrong group.

The setNodeGroups member guard used find(), which returns the element: a member
that IS undefined satisfied `badMember === undefined` and skipped the very guard
written to catch it, leaving `undefined.trim()` to throw. Unreachable over JSON
but reachable in-process. findIndex fixes it.

The filtered read lost its element-shape guard in an earlier simplification
pass, so one malformed group row would fail the whole read.

Also softens the changelog's degradation claim: it holds when n8n identifies the
offending property, which its request validator reports, and a rejection naming
nothing is surfaced rather than guessed at.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make the unsupported-field rung work against real n8n responses

The scoping added earlier required the string "nodeGroups" to appear in n8n's
400 before treating it as a schema rejection. Captured from a live instance,
n8n's actual bodies are:

  unknown top-level property  {"message":"request/body must NOT have additional properties"}
  unknown key inside a group  {"message":"request/body/nodeGroups/0 must NOT have additional properties"}

Its public-API serializer keeps only `message`, and AJV's text names the
offending property only for a nested path. So the description rung worked — a
group-level rejection carries `nodeGroups/0`, confirmed live — but the
field rung could never fire: a pre-2.28 instance would hard-fail every write
carrying stored groups, which is the failure this feature exists to remove.

Blaming the field on a pathless message would reintroduce the poisoning risk
that scoping was added to prevent, so neither guess is used. A pathless
rejection is now a hypothesis: retry without nodeGroups, and record the
instance as lacking the field only if that retry succeeds. If it fails too,
something else in the body was wrong, n8n's original error is surfaced and the
capability memo is left alone. A nested path still identifies the group schema
directly, and a path naming another field is ignored outright.

The classifier fixtures asserted shapes n8n never sends — a property name baked
into the message text, or a fabricated details.errors[].path — so they passed
while encoding the same wrong model as the code. They are now the bodies
captured from the live instance.

Also documents why the diff engine's finalization deliberately does not apply
the authored-group contract: inside one batch the only way an authored group
loses a member is that the batch removed it, which is an instruction rather
than a mistake, and a group naming a node that never existed is already
rejected by validateSetNodeGroups. Pinned with a test.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: drop the now-unused sentGroups parameter from classifyGroupError

The branch that consumed it — preferring a description strip when a pathless
rejection could plausibly have been caused by one — was removed once the real
n8n error shapes were captured. Classification is decided entirely by the
message now.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: make rollback warnings the same shape as success-path warnings

The rollback-on-error path put a string[] into details.warnings, while the
success path returns WorkflowDiffValidationError objects via mergeWarnings.
A client reading details.warnings had to branch on the outcome to know which
it was getting. Rollback warnings are now objects with operation: -1, matching
the batch-level convention used everywhere else.

Also corrects the setNodeGroups docblock: it claimed exactly one of
nodeNames/nodeIds may be present, but the engine deliberately accepts both when
one is empty — that shape is common in generated payloads, and rejecting it was
the critical bug fixed in c440093. Both populated is still ambiguous and
rejected.

Conceived by Romuald Członkowski - www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Jul 26, 2026
@pull pull Bot added the ⤵️ pull label Jul 26, 2026
@pull
pull Bot merged commit 84f1145 into 5474312:main Jul 26, 2026
4 of 6 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant