fix(workflow): a save accepted workflows the engine cannot run - #70
Merged
agnt-gg merged 2 commits intoAug 23, 2026
Merged
Conversation
WorkflowEngine reads exactly one node/edge shape. The workflow designer
produces it; every other writer invents its own. Nothing checked.
saveWorkflow validated only that the body was an object, then stringified
whatever arrived into the row. The mismatch surfaced later, once, at
activation:
TypeError: Cannot read properties of undefined (reading 'toLowerCase')
at WorkflowEngine._initializeNodeNameMapping (WorkflowEngine.js:518)
and reached the caller as `{ status: 'error' }`, because
WorkflowProcessBridge.fetchWorkflowState returns that for any caught IPC
exception. So the save answered 201, the workflow sat in the list looking
fine, and activation failed with a string naming neither the node nor the
field. On one developer machine 22 of 22 stored workflows were in a shape
the engine could not execute, across five different variants.
Adds validateWorkflowShape: a pure check of only those properties the
engine dereferences — node.text (:518), node.id (:197), node.type (:114),
edge.start.id (:201) and edge.end.id (:405,:527) — plus unique node ids and
edge endpoints that resolve. Nothing here encodes taste; if the engine
stops reading a field the rule for it should go too.
Wired in twice, because save-time validation alone would only protect rows
written from now on:
- saveWorkflow answers 400 with a per-problem list naming the node and
the field, and stores nothing.
- The WorkflowEngine constructor asserts the same shape, so rows that
predate this — and those arriving via /workflows/import or a direct DB
write, neither of which passes through save — fail with a diagnosis
instead of a TypeError.
Absent nodes/edges are normalised to [] rather than rejected: a blank draft
must stay savable, and both collections are iterated unconditionally.
WorkflowImportService already coerces them the same way. The engine
normalises into a copy, leaving the caller's object alone.
50 tests. 11 fail without this change, including the original TypeError.
Fixtures under "shapes found in a real database" are the verbatim key sets
of the five malformed variants observed in the wild.
There was a problem hiding this comment.
🟡 Changes recommended
The new validator currently allows edges to “resolve” even when there are zero declared nodes, which can let invalid workflows pass validation despite being non-executable.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR prevents storing workflows that the backend WorkflowEngine cannot execute by introducing a shared, engine-derived shape validation and applying it both at the /workflows/save API boundary and inside the engine constructor to avoid late, unqualified TypeError failures at activation.
Changes:
- Added
validateWorkflowShape/assertWorkflowShapeto validate the exact node/edge fields the engine dereferences. - Wired shape validation into
WorkflowService.saveWorkflow(400 + per-problem details; stores nothing on failure) and intoWorkflowEngineconstruction (asserts + normalizes missingnodes/edgesto[]). - Added focused Vitest coverage for save-path behavior and engine-constructor backstop behavior.
File summaries
| File | Description |
|---|---|
| backend/src/workflow/validateWorkflowShape.js | New workflow shape validator + assertion used by both API and engine. |
| backend/src/workflow/validateWorkflowShape.test.js | Unit tests covering accepted designer shape + rejected real-world malformed shapes. |
| backend/src/services/WorkflowService.js | Adds save-time shape validation returning 400 with detailed diagnostics. |
| backend/src/services/WorkflowService.saveWorkflow.test.js | Tests that malformed workflows are rejected and never persisted/updated. |
| backend/src/workflow/WorkflowEngine.js | Adds constructor assertion and normalization for missing nodes/edges. |
| backend/src/workflow/WorkflowEngine.shapeGuard.test.js | Tests engine constructor fails with diagnosis (not TypeError) and preserves input. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ilot review) Three things Copilot was right about. An edge list with an empty node list skipped the unknown-node-id check entirely, because the guard was `nodeList.length && !declaredIds.has(...)`. That is not a blank draft — a blank draft has no edges either — and letting it through left _findStartNodes with no start node and a fallback that indexes nodes[0] on an empty array. Now checked unconditionally. describe() rendered an unexpected object as "a object", which reached the caller verbatim in the 400 `details`. The comments cited WorkflowEngine.js line numbers that this change itself shifted (:516 -> 530, :527 -> 541). Replaced with method names in all four files, since the numbers drift on the first edit above them. 53 tests, +3 for the endpoint rule and the article.
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
POST /workflows/saveaccepted workflows thatWorkflowEnginecannot execute, stored them, and answered201. The mismatch surfaced later, once, at activation — as aTypeErrorinside the workflow process that reached the caller as an unqualifiederror.This adds a shape check derived from what the engine actually dereferences, and wires it into both the save path and the engine constructor.
Why
The engine reads exactly one node/edge shape.
WorkflowDesigner.vueproduces it. Every other writer invents its own, and nothing checked:node.textwas undefined. Two things made this hard to see:saveWorkflowvalidated only that the body was an object, thenJSON.stringify'd it into the row. The workflow appeared in the list looking fine.WorkflowProcessBridge.fetchWorkflowStatereturns{ status: 'error' }for any caught IPC exception, so/workflows/:id/statusreportederrorwith no indication of which node or which field.On one developer machine 22 of 22 stored workflows were in a shape the engine could not execute, across five distinct variants — none of them authored in the UI:
fields, id, nodeType, position, typedata, id, position, typedata, id, typecode, id, name, typeconfig, id, name, typeEdges in those rows use
source/target; the engine readsedge.start.id/edge.end.id.How
backend/src/workflow/validateWorkflowShape.js— a pure, dependency-free check. Every rule corresponds to a property the engine dereferences, and the module comment cites the line:node.textnon-emptyWorkflowEngine.js:518—node.text.toLowerCase()node.idnon-empty, unique:197—new Map(nodes.map(n => [n.id, n]))node.typenon-empty:114—import(.../triggers/${node.type}.js)edge.start.idresolves:201,:204edge.end.idresolves:405,:408,:527Nothing here encodes taste. If the engine stops reading a field, the rule for it should go too.
Wired in twice, because save-time validation alone would only protect rows written from now on:
saveWorkflowanswers400with a per-problem list naming the node and the field, and stores nothing.WorkflowEngineconstructor asserts the same shape. Rows that predate this — plus anything arriving via/workflows/importor a direct DB write, neither of which passes through save — now fail with a diagnosis instead of aTypeError.Two deliberate choices:
nodes/edgesare normalised to[], not rejected. A blank draft must stay savable, and both collections are iterated unconditionally (:516,:527).WorkflowImportService.js:39-40already coerces them the same way.Testing
50 new tests across 3 files. 11 fail without this change, including a direct reproduction of the original failure (
expected TypeError: Cannot read properties of unde… to not be an instance of TypeError).The fixtures under "shapes found in a real database" are the verbatim key sets of the five malformed variants above, not invented examples.
Mutation tested — 8 mutants, 8 killed, 0 survived:
node.textnode.typeFull backend suite: 292 passed / 293 files. The one failure,
UpdateScheduler.status.test.js > replaces the previous pass rather than accumulating, is pre-existing and unrelated — I verified it fails identically on untouchedorigin/main(1 failed | 289 passed), it imports nothing this PR touches, and it passes in isolation. Looks order-dependent.Notes for the reviewer
201today and will get400after this. That is the point — those workflows could never run — but it is worth a deliberate nod, particularly for anything installing workflows from a remote marketplace (frontend/src/store/features/marketplace.js:442,532passesassetDatathrough untransformed).WorkflowProcessBridge.fetchWorkflowStatecollapsing every IPC exception to{ status: 'error' }is why this took so long to diagnose. Worth surfacing the underlying message, but it is a separate change./workflows/importstill does not validate. The engine-side assert catches it at activation, but a 400 at import time would be friendlier.