Traps, dead-ends, and things that wasted real time. Read this before touching the relevant subsystem; add to it whenever you lose time to something non-obvious (symptom → root cause → fix/lesson). Ordered by blast radius.
The .flow.json format is shared by three independently-versioned apps: FlowRunner UI (JS), flowrunner-cli (/Users/taly/Development/flowrunner-cli, Python — runs flows 24/7 in containers, stricter parser), and the ShowRunner Demo-Management-Portal (dump/Demo-Management-Portal, Python + React). A schema change here can silently break the production CLI. See architecture.md §4 and masterplan.md.
- Frozen fields — never rename or repurpose in place (to change, add a new field alongside and keep reading the old one):
staticVars(camelCase; the CLI reads only this — astatic_varsrename emptied all seeded vars in a live incident, portal D-025); the condition wire keysthen/elseand loop wire keysteps(the UI's internalthenSteps/elseSteps/loopStepsmust never leak to disk — the CLI reads only the aliased wire keys and silently sees empty branches otherwise);onFailure(Pydantic-required on every request step in the CLI — omitting it hard-fails the whole flow);type(CLI discriminated union rejects unknown types — stricter than the UI); the##VAR:type:name##body markers; theconditionDataoperator vocabulary; the extract namespaces (body./headers./.status); the method allow-list. - Additive is safe; renames/removals are silently destructive. The CLI ignores unknown fields (
extra='ignore'), so new optional fields are safe — but no app is a pass-through (unknown fields are dropped on any round-trip through the UI or CLI). New step types / operators must land in the CLI before any flow uses them.
- Flow files carry no machine-readable version. Every consumer is a tolerant reader that "does something plausible" with a too-new file instead of failing loudly, so a breaking change lands silently in the 24/7 runner. Planned fix (see masterplan.md): add an additive top-level
schemaVersionnow, give the CLI a version-gate (unknown MAJOR ⇒ reject), and make readers skip-unknown-with-warning.
- ShowRunner portal drops camelCase
visualLayouton flow create/import (needs avisualLayout/visual_layoutfallback like it already has forstaticVars). - flowrunner-cli silently downgrades an unknown transform
optobase64_decode(should skip/fail loudly).
- Symptom: after a multi-agent
Workflowwithisolation: 'worktree',npm testreports a huge count (e.g. 2039 tests / 100 suites) or fails on a path like.claude/worktrees/wf_.../__tests__/.... - Cause: each worktree is a full repo checkout under
.claude/worktrees/; jest'stestMatch**/__tests__/**/*.test.jsscans them as extra (often stale) suites;testPathIgnorePatternsonly excluded/node_modules/. - Fix:
jest.config.mjsnow also ignores/\.claude/and/visualizer-island/. Also remove worktrees after integrating a wave —git worktree remove --force <path>for each under.claude/worktrees/, thengit worktree prune.
- A conformance test that reads a gitignored flow (e.g.
jwt-manipulation-attacks.flow.json) passes on your working tree but fails in worktrees/CI (the file isn't checked out). Use tracked fixtures (httpbin-flow.flow.json,random-ip-example.flow.json,__tests__/fixtures/**).
- Symptom: the React Flow island (
visualizer-island/) builds, packages, and passes unit tests, but at runtime the facade logsReact Flow island loaded but did not expose createReactFlowVisualizerand falls back to Drawflow — the island never mounts. - Cause: Vite library builds (unlike app builds) do NOT auto-define
process.env.NODE_ENV. React's bundle then ships bareprocess.env.NODE_ENVreads; in the browser (noprocess) the first read throwsprocess is not defined, the IIFE aborts before assigning theFlowRunnerReactIslandglobal, and the facade rejects. - Fix:
visualizer-island/vite.config.js→define: { 'process.env.NODE_ENV': JSON.stringify('production') }. Side benefit: React tree-shakes dev code, halving the bundle (778 kB → 380 kB / 122 kB gzip). - Lesson: jsdom unit tests never load the real bundle and the packaged build only checks it compiles — this class of bug is only caught by running the app (browser preview or packaged). Dogfound flag-gated features before flipping the default. (Note: a browser preview served by
http.servermay cacheisland.js, so a rebuild needs a cache-bust to be observed.)
- Symptom: during browser validation the tab became permanently "still loading"; screenshots/JS injection failed until the tab was killed. Root cause: a native dialog (
confirm()on dirty-step select) blocks the JS event loop; the page never reaches idle, and on a projector it freezes the live demo. - Fix/lesson: never call native dialogs in the renderer. Use
showConfirmDialog()(dialogs.js, promise-based) orshowMessagetoasts; low-level modules dispatch theflowrunner:toastCustomEvent instead of importing uiUtils.
- Symptom: a spec seeds
localStorage.flowrunnerRecentFiles, reloads, and the item is gone → click times out.hydrateRecentFiles()treats the durable electron-store list as the source of truth on startup and overwrites localStorage-only seeds. Passes/failures depended on what earlier runs left in the shared store profile (also whyworkers: 1). - Fix/lesson: seed BOTH (
localStorage+await window.electronAPI.setRecentFiles(arr)) — helper ine2e/testUtils.js. Watch for spec-local copies ofpushRecentshadowing the shared helper (flow-execution had one).
- Symptom: first-run onboarding overlay intercepts pointer events; Playwright waits for the target to be unobstructed (it will NOT click the scrim to dismiss), so every spec times out on a fresh profile.
- Fix/lesson:
maybeShowOnboardingskips undernavigator.webdriver. Generally: overlays in this app must be dismissible AND absent under automation.
- Symptom: minimap glared near-white in dark mode. Canvas pixels are painted once; they don't re-resolve
var(--…)on theme change, and the node fill was hardcoded#ffffff. - Fix/lesson: resolve theme tokens at paint time (
getComputedStyle) and repaint on theme toggle (visualizer.refreshMinimap()fromtoggleTheme()in app.js).
- Symptom: Tidy Up threw "Could not compute an auto-layout" ONLY in the installed app.
autoLayout.jsdynamic-importedelkjs/@dagrejs/dagreby bare specifier — Node resolution makes that work in dev and Jest, but the packaged renderer (and a served browser) cannot resolve bare specifiers. - Fix/lesson: vendor renderer-facing deps under
assets/vendor/(the fuse/immer/drawflow pattern) and import them by relative path first, falling back to the bare specifier for Node/Jest. Cousin of gotcha #1: anything that resolves via node_modules at runtime must be treated as suspect in the renderer.
- Symptom: stopping a continuous run between iterations (with short flows, ~95% of wall-time) cancelled the next iteration but never fired
onFlowStopped— any UI tracking the run showed "running" forever. - Fix/lesson: stop() now completes the stop itself when it clears a pending iteration while nothing is executing. If you add engine callers (like backgroundRuns.js), remember: continuous iterations are delimited by
onIterationStart;onFlowCompleteonly fires for single runs / the very end.
- Symptom: tests OUTSIDE a describe hang on plain
setTimeoutsleeps (5s timeout) yet pass with-tisolation. - Root cause:
beforeEachcapturedoriginalGlobalSetTimeout = global.setTimeoutAFTERjest.useFakeTimers()had already swapped it, soafterEachranuseRealTimers()then re-installed the captured FAKE function on the global. - Fix/lesson: capture originals BEFORE
useFakeTimers(). If a test only fails in the full run, suspect leaked globals from an earlier describe, not the test itself.
fileOperations.js<->launchFlows.js<->flowIndex.jsimport each other. This is fine ONLY because every cross-module reference is used inside a FUNCTION body, never at module-evaluation time (all top-level code is imports + literals + hoisted function declarations). If you add top-level code that calls across the cycle, it will read an undefined binding. Keep launcher cross-calls inside functions.
- Symptom: The packaged app launches but appears frozen — no buttons work (Open/Save/Run all dead). Never reproduces in
npm start. - Root cause: A JS module is imported by the app but not listed in
build.files(package.json). The renderer can't load the module graph and dies silently — no event handlers register. - History: Hit at v1.2.1 (
transformOps.js+harExporter.jsmissing) and repeatedly during the v1.1.1 electron-forge→electron-builder migration (logger.js,config.js,app.js…). - Lesson: Every JS module the app imports MUST be in
build.files. Verify in a packaged build (npm run dist), not just dev mode.
- Keep the dist build separate from the publish step (
--publish never). GitHub Actions occasionally needs action-version bumps. The dist build should never auto-release.
- Symptom: release job fails at "Prepare release assets" —
zip I/O error: No such file or directory/Could not create output file (../release/…zip), exit 15. - Cause:
mkdir -p releasemakesrelease/at the repo root; the Windows stepcds two levels intoartifacts/windows-latest-buildbut wrote to../release(one level up =artifacts/release, which doesn't exist). Must be../../release. (The old portable stepcd'd three levels and correctly used../../../release.) - Lesson: when editing the release-packaging step, the relative path to
release/must track thecddepth. The step runs underbash -e, so the first failing command aborts it — a good place for als -lhbefore the zip to make failures diagnosable.
build.ymltriggers on: push tomain/master(builds and publishes the release), pull_request tomain/master(builds only), and workflow_dispatch (manual, any branch — builds only). All three honorpaths-ignore(**.md,docs/**,.vscode/**,schemas/**), so docs/schema-only changes don't build.- The
releasejob is gatedif: github.event_name == 'push' && ref is main/master, so only a real push/merge to main publishes — PR and manual runs never touch the live release (verified: dispatch run →releaseskipped). Build a branch without publishing:gh workflow run "FlowRunner Build" --ref <branch>, thengh run download <run-id>. - Trap:
workflow_dispatch/pull_requestonly work once the workflow file defining them is on the default branch — you can't dispatch a trigger that exists only on a feature branch.
- Symptom: every Jest suite fails to load with
Must use import to load ES Module: .../drawflow.min.js(0 tests run). Went unnoticed because CI never runsnpm test(build.ymlruns onlynpm run dist). - Cause:
__tests__/setup.jsrequire()s the vendored UMDassets/vendor/drawflow/drawflow.min.js; the rootpackage.json"type": "module"makes Node treat that.jsas ESM, sorequire()throws. - Fix:
assets/vendor/drawflow/package.json={"type": "commonjs"}scopes only that vendored dir to CJS (the browser loads Drawflow via a<script>tag, so runtime is unaffected; the file isn't imported by the app). - Note: fixing this unmasked 4 pre-existing failures in
flowVisualizer.test.js(Drawflow drag/double-click/add-step under jsdom). They pre-date the fix — treat as a known follow-up, not a regression.
- Symptom (would-be): a renderer module
import Fuse from 'fuse.js'(orimmer) works innpm start/Jest but the packaged app can't resolve the bare specifier underscript-src 'self'(no Node integration in the renderer) — silent module-graph death, dead UI (same failure mode as gotcha #1). - Fix (Wave 2 file-features): vendor the library's single-file
.mjsbuild underassets/vendor/<lib>/and import by relative path (import Fuse from './assets/vendor/fuse/fuse.min.mjs'). Unlike Drawflow (UMD → needs a{"type":"commonjs"}scopingpackage.json), these are already ESM, so no scoping file is needed. Keep the npm package independenciesonly so Jest's node_modules resolver runs the same source; the packaged app imports the vendored copy, not node_modules. Covered by the existingassets/**/*inbuild.files— but the JS modules that import them still need their ownbuild.filesentries.
- Trap: wiring
resetFlowHistory()into every load/create/clone/save-as handler is high-blast-radius across lanes. InsteadflowHistory.jswatchesappState.currentFlowModelobject identity inrenderCurrentFlow: loads reassign the reference (⇒ reset the stack), in-place edits keep it (⇒ snapshot). Gotcha: undo/redo also reassigns the reference (it writes a fresh clone), so asuppressAutoResetlatch tells the next render "this is time-travel, not a new flow." That latch must be cleared on every hardresetFlowHistoryor a stale suppress from an interrupted undo makes a subsequent real load fail to reset (surfaced as cross-test history leakage under the sharedappStatesingleton in jsdom).
- Trap: It reads
this.encodeUrlVars/this.stateviathis instanceof FlowRunner. Works only because the runner callsthis.substituteVariablesFn(...)andapp.jspasses a bare reference (no.bind). If anyone wraps or binds it, URL-encoding and special-var caching for URLs silently fall back to off/null.
- Symptom: Requests carried two Content-Type headers when a global header and a step header both set it (esp. different casing).
- Cause:
{...globalHeaders, ...headers}treatsContent-Typevscontent-typeas distinct keys. - Fix/now:
mergeHeaderscanonicalizes onlycontent-type→Content-Type; step headers override globals. Other headers keep original casing, so differently-cased duplicates of other headers can still both survive.
- Cause: Body substitution only ran when
rawBodyWithMarkerswas a non-null object; a null marker field skipped the textualstep.bodyentirely. - Fix/now: Explicit
hasRawMarkers/hasBodyValuechecks + a string-body fallback. Body is only sent for non-GET/HEAD when non-null/undefined;flowModelToJsonstores a body only ifstep.bodyis a non-empty trimmed string.
- After unquoted-placeholder replacement, if Content-Type includes
application/jsonand the result failsJSON.parse, the step throws and returnserror. Anunquotedvariable that resolves to a string (or undefined→null) can produce malformed JSON and abort the request.
- For an unprefixed path,
evaluatePathlooks insidedata.bodyif abodykey exists, else indataitself. The same extract path can resolve differently depending on whether the object has abodyproperty — subtle in nested/loop contexts vs raw responses.
- Extracting
bodyproduced a huge value containing every response repeated. Fixed by simplifying to a singleevaluatePath(responseOutput, path)against the full response.
evaluateCondition(runtime) supports operators (not_contains,is_null,is_empty,is_object,*_or_equalaliases) thatgenerateConditionString/parseConditionString(the structured editor) can't produce or round-trip. Hand-edited flows using these execute but won't display/preview correctly. Also:existsdiffers — flowCore emits!= null(null≈undefined) but runtime definesexistsas!== undefinedonly, so explicitnullis treated differently by the two layers.
null/undefinedsource → empty array + warning. A non-array value (object/number) throws and stops the flow.
- Request timeout is hardcoded 30s (not configurable; only inter-step
delayis).RANDOM_*values are cached per exact reference string per run, so the same{{RANDOM_INT(1,10)}}yields the same number every appearance in one run.
appState.isDirty(flow structure) andappState.stepEditorIsDirty(open editor panel).setDirty()ORs them for the Save/Close buttons. Consequence: an unsaved open editor blocks Close even when the flow itself is saved. Deleting a step used to leave a phantomstepEditorIsDirty(fixed v1.1.2) — clear it on deletion.
- The OS-quit guard (
onCheckDirtyState) reportsisDirty || stepEditorIsDirty || isContinuousRunActive, but in-appconfirmDiscardChanges()/handleCancelFlow()check only the first two (continuous-run routes through a separateconfirmStopContinuousRun). So quitting during a continuous run prompts, but switching flows in-app does not.
- Every mutator (add/move/delete/clone) explicitly comments "do NOT call setDirty here." The caller must mark the flow dirty — an easy omission when adding a new code path.
saveCurrentFlowreaches into the DOM (querySelector('.btn-save-step')), synthesizes a.click(), then re-checksstepEditorIsDirtyto infer success. Couples file-save to exact CSS class names and to the editor's click handler running synchronously. A markup change or an async editor save silently breaks committing.
checkUnsavedChanges()round-trips to the renderer and resolvesfalse(assume clean) after a 1.5s timeout or ifwebContents.sendthrows. If the renderer is busy/hung, the app discards unsaved work without prompting.
window.on('close'),app.on('window-all-closed'),app.on('before-quit')all read/mutate a module-globalforceQuitplus an ad-hocapp.isQuitting. The close path setsforceQuit = truethen re-callsmainWindow.close()to re-enter and pass through — a re-entrancy pattern easy to break if any branch forgets the flag.
- IPC handlers are registered inside
app.whenReady()beforecreateWindow(). Several handlers early-return{success:false}ifmainWindowis falsy.
- preload can't import the ESM
logger.js, so it duplicates the logic. Its level comes fromprocess.env.LOG_LEVEL, notconfig.js'sLOG_LEVEL— the two can diverge.
- Each enforces a single listener by wiping the channel first. Any other code registering on those channels would be silently removed. Latent now, but a footgun for future listeners.
- Symptom: "Cannot insert variable: Target input is null" in the loop source field and other rebuilt editors. Cause: a stale reference to a target input that had been re-rendered away. Fix: re-find the target at insertion time + de-duplicate dropdown listeners. The captured
targetInputcan still go stale if the editor re-renders between open and insert (dialogs.jsguards null, not staleness).
getRecentFiles()silently rewrites localStorage when it finds corruption (non-array / invalid entries). Drag-reorder and remove paths write order directly toRECENT_FILES_KEYbypassingaddRecentFile, so theMAX_RECENT_FILEScap isn't enforced on those paths.
fileOperationsimportsinitializeAppComponentsfromapp.js;app.jsimports many handlers back. Works under ESM live bindings but is fragile to refactors / import-order changes. (uiUtils.jsalso importsadjustCollapsibleHeightfromapp.js.)
- A prominent code comment warns: calling
renderCurrentFlow()here causes node "snap-back" during drag. Easy correctness trap for anyone "tidying" the handler to re-render like the others.
- Both
flowBuilderComponent.jsanduiUtils.jsdefine_addGlobalHeaderRow/_addFlowVarRowthat populate the same Info-Overlay elements. The builder version attaches local listeners; the uiUtils version deliberately does not (handled globally inapp.js). Which runs depends on the render path — header/var rows can end up with or without local listeners. Keep the two in sync.
flowBuilderComponentdoesinnerHTML = ''and rebuilds the whole step tree;flowVisualizer.rendercallseditor.clear()and rebuilds all nodes/connections (even collapsing one node triggers a full re-render). Re-binds all listeners each time — slow for large flows, and a place where freshly captured DOM refs go stale.
- The visualizer suppresses Drawflow's native Delete/Backspace + context-menu delete, implements its own canvas panning (Drawflow's drag conflicts with node interaction), and sets
zoom_value = 0.1beforestart(). These are load-bearing hacks against the bundled library; minimap/connector/zoom interaction is historically the most fragile area (~20 stabilization commits in v1.1.1).
- It's appended to
document.body, outside#flow-visualizer-mount, and only removed indestroy(). An error duringclearWorkspacewould leave it orphaned.
- Condition/loop save-validation and cancel/close discard prompts use blocking
alert()/confirm()instead of the app's toast/dialog system — inconsistent UX and harder to test.
FlowVisualizer.clearHighlights()takes no parameter and always clears all highlight classes; callers passing'active-step'wipe every status highlight, not just the active one.
31. ⚠️ One missing } in styles.css silently kills the whole tail via CSS nesting (cost hours of "why does it still look old")
- Symptom: large, recently-added CSS sections (command palette, Demo Mode, guided onboarding, teaching empty-states, Basic/Power inspector, test-summary, tidy-relayout, on-node error badges) render completely unstyled — the app "looks like the old UI" even though the rules exist in the file and the file loads without error.
- Root cause: a rule with no closing brace (found:
.inspector-disclosure-btn:focus-visible {and.palette-search:focus {, each with a comment where the body should be and no}). Modern browsers parse native CSS nesting, so every subsequent top-level rule becomes a nested rule inside the unclosed one (you'll see& .foo {…}in the CSSOM). Those nested selectors only match as descendants of a non-matching parent, so they never apply. One missing brace can bury 100+ rules. No parse error is thrown. - Diagnose fast: in the page, walk
document.styleSheets[…].cssRulesand find the top-level rule whose.cssRulescontains your "dead" selector — that rule is the one missing its}. Or strip comments and count braces:node -e "const c=require('fs').readFileSync('styles.css','utf8').replace(/\/\*[\s\S]*?\*\//g,''); let d=0; for(const ch of c){if(ch==='{')d++;else if(ch==='}')d--;} console.log(d)"— must be0. (A naive count that includes comments gives false positives.) - Lesson: run the comment-stripped brace check after any hand-edit to
styles.css; treat "a block of new styles isn't applying at all" as a missing-brace suspicion, not a specificity problem.
.result-status.successetc. never matched:runnerInterface.jsputs the status class on the parent<li class="result-item success">, while the inner<span class="result-status">carries no modifier. Style status badges as.result-item.<status> .result-status(and::beforepip). Same shape applies to node status (.result-item-style parents) — check the parent, not the span, before writing status selectors.
- Symptom: you edit a renderer module (
app.js,eventHandlers.js,firstRun.js, …), reload the localhost preview, and your change isn't running (old behavior persists), even thoughcurlshows the served file is updated. - Cause:
python -m http.serversends no cache headers, so Chrome heuristically caches the ES modules and, on a normal reload, serves them from disk without revalidating.styles.css(a<link>) refetches fine, which is why CSS edits look live but JS edits don't. - Fix: hard-reload (Cmd/Ctrl+Shift+R) after JS edits, or serve with
Cache-Control: no-store. Tell whether the new JS loaded by probing for a change you made in the DOM (e.g. a new attribute) before assuming your logic is wrong.
- macOS unsigned build: "damaged" error →
xattr -c /Applications/FlowRunner.app. - Windows SmartScreen: "More info → Run anyway".
- Linux AppImage:
chmod +xit; needs--no-sandboxwhen run as root.