Skip to content

fix(graphical): never report a POU as saved when its flow write-back fails (DOPE-495) - #973

Merged
JoaoGSP merged 5 commits into
developmentfrom
bugfix/DOPE-495-flow-writeback-silent-skip
Aug 5, 2026
Merged

fix(graphical): never report a POU as saved when its flow write-back fails (DOPE-495)#973
JoaoGSP merged 5 commits into
developmentfrom
bugfix/DOPE-495-flow-writeback-silent-skip

Conversation

@JoaoGSP

@JoaoGSP JoaoGSP commented Aug 1, 2026

Copy link
Copy Markdown
Member

Fixes DOPE-495.

Problem

The graphical flow write-back validates the flow with zod before persisting it into pou.body.value. On failure it returned silently. The save flow then went on to serialize the stale pre-edit body, upload it, mark every file saved, clear every updated flag, and show "Changes saved!".

The user's graphical edit survived only in memory, and died with the app. Worse, every signal the user could check agreed with the lie: the tab went clean, the undo baseline reset, and Source Control adopted the stale content as the new baseline.

Fix

Per-POU, never global — one flow failing a defensive schema check must not hold every other POU's valid work hostage.

  • runWriteBack returns a failure result and logs the zod issues (console.warn).
  • flushFlowWriteBacks returns the names of POUs whose body is still stale.
  • executeSaveProject: stale POUs keep updated, stay dirty, are excluded from the undo savedAtDepth reset, and get a failure toast naming them. Every other POU saves normally. Returns success: false so callers that gate on the save (build, close-project) don't proceed on a body that never reached disk.
  • executeSaveFile: aborts before writing rather than overwriting disk with the stale body.
  • markAllSaved takes an optional except list.

Bonus hole closed

flushFlowWriteBacks swept only POUs with a live debounce timer. A timer that had already fired and failed leaves no pending entry behind, so the save-time sweep came back empty and reported success anyway. It now writes back every updated flow, which is also a stronger coherence guarantee for the undo/redo and snapshot callers.

Review response

Landed on top of the original fix, all mirrored byte-for-byte.

An invalid orphan flow no longer blocks saves and builds for good. Deleting a POU leaves its flow behind in ladderFlows / fbdFlowsdeleteElement clears the project entry, model, file and tab, but not the flow, and removeLadderFlow has exactly one caller (the AI tool executor). Because the fix widened the sweep to every updated flow, an invalid orphan was reported stale on every subsequent save, so success stayed false forever: builds blocked at workspace-activity-bar/default.tsx:186-188, "save and close" never completing at save-changes-modal.tsx:58-59, and a toast naming a POU that no longer exists.

That mattered more than its reachability suggests: deleting the POU is the user's only escape hatch from a corrupted flow, and it was the one path that stayed broken. A valid orphan already self-healed, so only the invalid case stuck. flushFlowWriteBacks now skips flows whose POU is gone — they have no body left to write back, so they were never write-back failures.

executeSaveFile scopes its flush to the target. It was flushing every dirty graphical POU on a single-file save. Nothing was lost or misreported — an unrelated failing POU keeps saved: false / updated: true, and executeSaveFile never marks other files saved — but it validated and warned about POUs the user wasn't saving.

undo / redo / snapshot capture now respect the flush result. snapshotActions.undo, redo and usePouSnapshot.captureAndPush all discarded it, which reproduced the original DOPE-495 lie by another route:

liveFlowIsTheCorruptedOne: true
flowUpdatedFlag:           false   ← nothing will ever retry
fileSaved:                 true    ← reports itself saved

Worse than the orphan case, in fact: it needs no POU deletion, and it clears updated, so nothing retries. The module docblock already promised this invariant ("undo/redo and snapshot capture flush the affected POU so a history entry never pairs a stale body with a fresh flow") — it just wasn't enforced. All three now bail when the flush fails.

undo / redo return boolean (false only for the stale-body case) so the UI can react, matching runWriteBack's shape. accelerator-handler.tsx — the only live entry point, since the Edit menu's items are disabled — raises a "History unavailable" toast naming the POU, so the shortcut doesn't just look broken. Deliberately no toast on captureAndPush: it fires on every graphical edit and would spam one per keystroke.

The toast lives in the component rather than the slice because no store slice imports toast today, and keeping UI concerns out of the store preserves the layer split.

Smaller changes in the same lines: flushFlowWriteBacks returns readonly string[], symmetric with markAllSaved(except?: readonly string[]); the two staleFlows.includes() calls inside the flow loops became a Set; and a comment records that the updateFile({ saved: false }) loop must stay after setAllToSaved(). That ordering is already pinned by a test — reversing it fails does not report a POU as saved when its flow fails validation — so it stayed a comment rather than a new setAllToSaved(except) parameter on a mirrored surface.

Follow-ups

Tracked in DOPE-524, led by the deleteElement flow leak — the root cause the guard above only treats. renameElement already renames both flows, so delete is simply missing the equivalent. Also covers the missing recovery path for an unrepairable flow, the inaccurate "could not be written to disk" toast copy (the stale body was written by then), the unbounded name list, repeated zod dumps on every autosave, test isolation in save-actions.test.ts, two uncovered branches, and the flushFlowWriteBacks naming. Every item needs a mirror PR.

Reachability

Investigated before writing the fix; the card's "Low" holds for everything probed:

  • 3 real .ld projects on disk (21/21/2 rungs) — all schema-valid
  • startLadderRung, addNewElement for contact/coil/block/variable — all valid
  • 2 contacts → parallel branch → block → coil (7 nodes / 7 edges) — valid
  • every node builder in buildNodes.tsx sets draggable/selectable

No live-UI repro found. Deeper paths (nested parallel, handle-branch, drag-n-drop, copy-paste, FBD) were not exhaustively probed — the new console.warn plus the failure toast are precisely the instrument that will name the offending fields if one exists in the wild.

Noted for later, deliberately not fixed here

parseGraphicalPouFromString runs JSON.parse straight into body.value with no schema validation — nothing between disk and the store checks the flow. That is what lets a corrupted or hand-edited .ld/.fbd reach the store schema-invalid in the first place, and it sits against this repo's own "validate external data at the boundary" rule.

Left alone on purpose: repairing at load would byte-drift the serialized POU vs. the disk copy and reintroduce the phantom "Modified" entries DOPE-477 removed. Recording it here so we can revisit if this ever turns out to bite in practice.

Testing

  • 11 new tests, 181 passing across the three affected suites under both runners (vitest on web, jest on the editor). The old flushFlowWriteBacks case 'is a no-op when nothing is pending' encoded the exact hole described above and was rewritten to the new contract.
  • Every fix verified load-bearing by reverting it alone and confirming only its own test fails — the orphan guard, the scoped flush, and the undo guard each have one.
  • save-actions.test.ts was a stub; it now drives the real store singleton through both save paths. It avoids vi.mock/vi.hoisted on purpose — the editor aliases vi = jest, and jest's hoisting plugin does not recognise vi.mock. Asserts against the exported getMemoryState() instead, so the file works byte-identically under both runners.
  • Manually validated against a hand-corrupted .ld (a node missing its required selectable): console warning, failure toast, tab stays dirty, other POUs save, single-file save refuses. Regression pass over normal save / undo-redo / debounce-window save / Source Control / build showed no change.
  • flow-writeback.ts at 100% statements/lines/functions. tsc, prettier, eslint and validate:arch clean in both repos — the editor's tsc was run explicitly, since the web project's config misses callback-variance errors that the readonly string[] change could have introduced.
  • The "History unavailable" toast wiring itself has no unit test: _templates/ has no test directory and is not under a coverage threshold. Verified manually instead.
  • Manually re-validated after the review changes: corrupted flow raises the toast on Ctrl+Z/Ctrl+Y, healthy LD/FBD/ST undo-redo unaffected, deleting a corrupted POU restores saving, and a single-file save leaves a second dirty POU's edit intact.
  • Pre-existing failures in src/frontend/services/st-lsp/__tests__/boot.test.ts (jest-only APIs) are unrelated — verified identical on a clean tree.

Mirror

Mirror of https://github.com/Autonomy-Logic/openplc-web/pull/639 — diffs are byte-identical across all 9 files.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Btq4UkctubeNQvh2cYAUv

Review follow-up: https://claude.ai/code/session_01VKiQpeqRqne8nLj2fUGH21

Summary by CodeRabbit

  • Bug Fixes
    • Improved project and file saving when graphical flows contain validation errors.
    • Prevented invalid flow data from being written or incorrectly marked as saved.
    • Partial project saves now clearly report failures while preserving unsaved changes.
    • Added clearer failure notifications during save, undo, and redo operations.
    • Improved synchronization of saved changes across ladder and FBD flows, including repeated or scoped save operations.
    • Prevented snapshots and history changes when pending graphical updates fail validation.

JoaoGSP and others added 2 commits August 1, 2026 17:02
…fails (DOPE-495)

The graphical flow write-back validates with zod before persisting into
`pou.body.value`. On failure it returned silently, so the save flow went on
to serialize the stale pre-edit body, mark every file saved, clear every
`updated` flag and show "Changes saved!". The user's edit survived only in
memory and died with the app.

`runWriteBack` now reports failure and logs the zod issues; `flushFlowWriteBacks`
returns the POUs whose body is still stale. The save paths handle those per-POU:
the flow keeps `updated`, the file stays dirty, its undo baseline is not reset,
and a failure toast names it — while every other POU still saves. Single-file
save aborts before writing rather than overwriting disk with the stale body.

Flush also had a hole: it swept only POUs with a live debounce timer, so a timer
that had already fired and failed left nothing pending and the save reported
success anyway. It now writes back every `updated` flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Btq4UkctubeNQvh2cYAUv
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Graphical flow write-backs now return validation failures. Project saves preserve stale flows as unsaved, while file saves reject stale graphical bodies before writing. Undo, redo, and snapshot capture also stop when write-back validation fails.

Changes

Graphical flow save validation

Layer / File(s) Summary
Write-back validation and reporting
src/frontend/store/slices/shared/flow-writeback.ts, src/frontend/store/__tests__/flow-writeback.test.ts
Write-back functions validate updated ladder and FBD flows, preserve invalid bodies, report failed POUs, and cover scoped flushing and no-op behavior.
Save orchestration and dirty-state preservation
src/frontend/services/save-actions.ts, src/frontend/services/__tests__/save-actions.test.ts, src/frontend/store/slices/shared/slice.ts, src/frontend/store/__tests__/shared-slice.test.ts
Project saves retain stale files as unsaved. Single-file saves reject stale graphical bodies. markAllSaved supports exclusions. Tests cover successful, partial, and failed saves.
History and snapshot failure handling
src/frontend/store/slices/shared/types.ts, src/frontend/store/slices/shared/slice.ts, src/frontend/hooks/use-pou-snapshot.ts, src/frontend/store/__tests__/shared-slice.test.ts
undo and redo return failure status when write-back fails. Snapshot capture stops on failure. Tests verify history preservation and no-op results.
Accelerator failure notification
src/frontend/components/_templates/accelerator-handler.tsx
Undo and redo show a failure toast when an invalid graphical body blocks the operation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant executeSaveProject
  participant flushFlowWriteBacks
  participant ProjectPersistence
  participant ToastState
  User->>executeSaveProject: save project
  executeSaveProject->>flushFlowWriteBacks: flush updated graphical flows
  flushFlowWriteBacks-->>executeSaveProject: return stale POU names
  executeSaveProject->>ProjectPersistence: persist valid project files
  executeSaveProject->>ToastState: show partial-save failure
Loading

Possibly related PRs

Suggested reviewers: dcoutinho1328, thiagoralves

Poem

A rabbit checks each flow with care,
Invalid graphs remain right there.
Good files hop into the save,
Stale ones keep their flags in place.
Toasts report what made it through.
🐇 The store keeps state true.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: preventing graphical POUs from being reported as saved after write-back failure.
Description check ✅ Passed The description clearly explains the problem, implementation, testing, follow-ups, and scope, despite omitting the template's explicit DOD checklist.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/DOPE-495-flow-writeback-silent-skip

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/frontend/store/slices/shared/slice.ts (1)

993-996: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Check flushFlowWriteBacks before building undo/redo snapshots.

flushFlowWriteBacks returns the POUs whose write-back left pou.body.value stale. undo and redo discard this and still read that stale body while pairing it with the fresh flow snapshot. If pouName is in the returned array, skip the history capture/restore for this operation or otherwise defer it until the pending edit is fixed; mirror the same check in redo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/store/slices/shared/slice.ts` around lines 993 - 996, Update the
undo and redo handlers around flushFlowWriteBacks to inspect its returned
stale-POU list before capturing or restoring history snapshots. If pouName is
included, skip or defer that operation until the pending body edit is
synchronized; apply the same guard to both undo and redo so stale pou.body.value
is never paired with a fresh flow snapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/services/save-actions.ts`:
- Line 516: Update the save flow around staleFlows and the fileName handling to
iterate over every entry returned by
flushFlowWriteBacks(openPLCStoreBase.getState), mirroring executeSaveProject.
For each failed write-back, call updateFile with saved: false and show the
corresponding failure toast, while preserving the existing target-file behavior.

---

Outside diff comments:
In `@src/frontend/store/slices/shared/slice.ts`:
- Around line 993-996: Update the undo and redo handlers around
flushFlowWriteBacks to inspect its returned stale-POU list before capturing or
restoring history snapshots. If pouName is included, skip or defer that
operation until the pending body edit is synchronized; apply the same guard to
both undo and redo so stale pou.body.value is never paired with a fresh flow
snapshot.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0529904d-4668-4cf7-a9ea-c9edb6d8d3b3

📥 Commits

Reviewing files that changed from the base of the PR and between eb79bbf and ebf167c.

📒 Files selected for processing (7)
  • src/frontend/services/__tests__/save-actions.test.ts
  • src/frontend/services/save-actions.ts
  • src/frontend/store/__tests__/flow-writeback.test.ts
  • src/frontend/store/__tests__/shared-slice.test.ts
  • src/frontend/store/slices/shared/flow-writeback.ts
  • src/frontend/store/slices/shared/slice.ts
  • src/frontend/store/slices/shared/types.ts

Comment thread src/frontend/services/save-actions.ts Outdated
@JoaoGSP

JoaoGSP commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Thanks — the unscoped flush is accurate, but I don't think the missing handling is a defect here. Not adopting, reasoning below.

X's file is already marked unsaved. scheduleFlowWriteBack calls handleFileAndWorkspaceSavedState(pouName) at edit time (flow-writeback.ts), so the moment the user edits X, its tab goes dirty. executeSaveFile only touches fileName's flags — updateFile({ name: fileName, ... }) and markSaved(fileName) — and nothing in that path clears X's. So updateFile({ name: X, saved: false }) would be a no-op, and the user does have feedback that X didn't persist: its tab still reads unsaved.

That's why the loop is load-bearing in executeSaveProject and not here. There, setAllToSaved() had just wiped every dirty flag one line earlier, so re-marking the stale POUs is the entire point. executeSaveFile never wipes anything beyond its target, so there's no flag to restore.

The toast would be a UX regression. The user pressed Ctrl+S on Y; a failure toast naming X is about a file they weren't saving. Worse, it would re-fire on every single-file save for as long as X stays invalid — that's the toast-spam pattern this PR deliberately avoids by surfacing failures at save time for the thing being saved, and leaving the per-edit debounce path to console.warn only.

One real gap, narrow: when the debugger is visible, scheduleFlowWriteBack skips the dirty-marking gate, so X could be updated without its file being dirty. That's the debugger's deliberate "driving node values must not make the file unsaved" behavior, and a stale-flow toast during a debug session would be more noise than signal. Leaving it.

The unscoped flush itself is pre-existing (unchanged by this PR) and harmless — a single-file save has no reason to write back other POUs, but they flush on their own 200ms timer regardless, so scoping it would be a no-op refactor outside this ticket's scope.

@Gustavohsdp

Copy link
Copy Markdown
Contributor

Review — one blocker, then good to go

Scoped deliberately to the changed lines. Where a finding depends on code this PR didn't touch, I say so.

Mirror check: reviewed together with the web counterpart Autonomy-Logic/openplc-web#639 — patches match except for git's abbreviated blob hash length, and all 7 files are byte-identical (shasum per blob). Shared Surface Sync is green on both. So any fix has to land identically in both repos or the sync gate breaks.

How I verified: ran the three affected jest suites with the fix (177 pass), then reverted the sources and kept the new tests — 8 of 21 fail, so the tests genuinely pin the new behaviour. I also drove the real store singleton through executeSaveProject / executeSaveFile / undo / redo in throwaway probes; the numbers below come from those runs, not from reading.

The core fix is right, and the subtle part is the part that matters: flushFlowWriteBacks only swept POUs with a live timer, so a timer that had already fired and failed left no pending entry behind and the save reported success over a stale body. Sweeping every updated flow instead is the structural fix, and the per-POU granularity is threaded through consistently. One consequence of that wider sweep needs closing first.


🔴 An invalid orphan flow blocks every save and every build, permanently

flow-writeback.ts:104-111 + save-actions.ts:487

// flow-writeback.ts:104-111 — sweeps the store now, not the pending timers
for (const flow of state.ladderFlows) {
  if (pouName !== undefined && flow.name !== pouName) continue
  if (flow.updated && !runWriteBack(getState, flow.name, 'ld')) failed.push(flow.name)
}
// save-actions.ts:487
return { success: res.success && staleFlows.length === 0 }

The sweep never checks that a POU still exists for the flow. deleteElement (slice.ts:27-35) removes the editor model, the file entry and the POU, but not the flow from ladderFlows / fbdFlows — only the AI tool executor calls removeLadderFlow. So a corrupted flow survives deleting its POU and gets reported as stale on every subsequent call.

Probe: corrupt a flow, save, then delete the POU (the user's only escape hatch through the UI), then save twice more:

firstSave:          false   ← correct, the fix doing its job
pouStillInProject:  false   ← POU deleted
fileStillInStore:   false   ← file entry gone
flowStillInStore:   true    ← the flow stays behind
saveAfterDelete:    false
saveAgain:          false   ← every future save fails
healthySaved:       true
lastToast: "The graphical body of Doomed is invalid and could not be written to disk. Every other file was saved."

What that costs the user, all traceable in code:

  • Builds are blocked for goodworkspace-activity-bar/default.tsx:186-188 does const saved = await executeSave(); if (!saved) return.
  • "Save and close" never completessave-changes-modal.tsx:58-59 does if (!result.success) return.
  • The toast names a POU that no longer exists in the project, so it isn't actionable.
  • updateFile({ name, saved: false }) (:461) now runs against a file entry that's gone, on every save.

Before this diff an invalid orphan flow was silently skipped — wrong, but it blocked nothing. The blocking behaviour comes from these lines, so I'd fix it here. Smallest change, in the file you're already rewriting — a flow with no POU has no body to write back, so it isn't a write-back failure:

export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): readonly string[] {
  cancelFlowWriteBacks(pouName)

  const state = getState()
  const hasPou = (name: string) => state.project.data.pous.some((p) => p.name === name)
  const failed: string[] = []
  for (const flow of state.ladderFlows) {
    if (pouName !== undefined && flow.name !== pouName) continue
    if (!hasPou(flow.name)) continue // orphan flow (POU deleted) — nothing to write back
    if (flow.updated && !runWriteBack(getState, flow.name, 'ld')) failed.push(flow.name)
  }
  // same for fbdFlows

The leak itself (deleteElement not clearing the flow) is pre-existing and outside this diff — worth its own ticket, but the guard above is what keeps this PR from introducing the lock-up.


🟡 Worth changing

1. executeSaveFile flushes unscopedsave-actions.ts:516

const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState)

On a single-file save this now writes back every dirty graphical POU in the project (previously: only those with a live timer). The resulting staleFlows mixes the target with unrelated POUs, and only the target is handled (:545).

I checked what that actually causes: nothing is lost or misreported — an unrelated failing POU keeps saved: false and updated: true, because executeSaveFile never marks other files saved. So it isn't an integrity bug, just unnecessary blast radius. Scoping it fixes that and removes the ambiguity behind CodeRabbit's comment (see below):

const staleFlows = flushFlowWriteBacks(openPLCStoreBase.getState, fileName)

2. No recovery path when a flow can't be repairedsave-actions.ts:487

Even without the orphan case: a flow that never validates again means no successful project save and no build, indefinitely. The toast names the POU, but the UI offers nothing to repair a corrupted flow (reload from disk, discard the graphical edit). Failing loudly is the right call; failing loudly with no way out turns a data bug into blocked work. Worth confirming as a product decision and, at minimum, pointing at the remedy in the toast text.

3. setAllToSaved() gets no exception list; a second loop undoes itsave-actions.ts:443-462

setAllToSaved()          // :444  marks everything saved
markAllSaved(staleFlows) // :445  already takes the exceptions
...
for (const name of staleFlows) {
  updateFile({ name, saved: false })  // :461  walks back :444 for the stale ones
}

Two shapes for the same concern — markAllSaved grew an except, setAllToSaved didn't — and correctness depends on :459-462 running after :444. An innocent reorder reintroduces exactly the bug this ticket is about, with no test to catch it. Either make it symmetric (setAllToSaved(staleFlows)) or add a comment stating the ordering dependency.

4. Test isolationsave-actions.test.ts:77-80

Driving the real store singleton is the right choice for pinning this contract, but beforeEach only calls clearLadderFlows(). POUs and files created by each test stay in project.data.pous and get serialized by the next test's executeSaveProject. It passes today and order held across my runs, but that's luck rather than design — a store reset in afterEach would fix it.

5. Changed branches with no coverage

  • save-actions.ts:487 when res.success === false and stale flows exist — note updateFile(saved:false) at :461 doesn't even run on that path, since it lives in the success branch.
  • The catch in executeSaveProject, which ignores staleFlows.
  • The unscoped flush in executeSaveFile — the exact behaviour CodeRabbit questioned.

🟢 Nits

  • flow-writeback.ts:99 — return readonly string[], symmetric with the markAllSaved(except?: readonly string[]) you introduced in types.ts:120.
  • save-actions.ts:460 / slice.ts:986includes() inside a loop is O(n·m). Given the ticket's premise (LD project with many rungs, many POUs), a Set is the idiomatic form.
  • flow-writeback.ts:54-56 — the warn dumps every zod issue, repeated on each autosave. issues.slice(0, 5) + a count, ideally deduped by signature.
  • save-actions.ts:467staleFlows.join(', ') is unbounded; truncate at ~3 names + "and N others".
  • flow-writeback.ts:99 — the name now means "write back everything dirty" rather than "flush the pending ones". The docblock covers it; flushDirtyFlows wouldn't need one.
  • save-actions.ts:466-469 — the copy says "could not be written to disk", but saveProject already wrote the stale body. "The previous version was kept on disk" describes it better. (The asymmetry with executeSaveFile, which aborts before writing, is defensible — aborting the whole project save would hold the valid POUs hostage.)

What's good

  • The hard part is right. The "timer already fired and failed, so no pending entry remains" hole (flow-writeback.ts:92-97) was the actual cause of the false success, and the docblock explains it.
  • Per-POU granularity all the way through, no halfway state: markAllSaved(staleFlows), the continue in both setFlowUpdated loops, per-name updateFile, a toast that names the culprits, a composed success.
  • The new success contract is consumed correctly — I checked the two gates that matter (activity-bar/default.tsx:186-188, save-changes-modal.tsx:58-59); nobody treats false as "retry in a loop".
  • runWriteBack returning boolean with a @returns (:37) plus the zod issues in the warn — without it the next occurrence would be undebuggable. POU / language / issues only; zod issues carry paths, codes and type names, not variable values.
  • Tests that fail without the fix — 8 of 21, verified. LD and FBD, the no-timer path, markAllSaved's except, and "saves the valid POUs alongside a failing one".
  • The casts in the tests come with a justification comment (corruptFlow, // defaultBounds is required by the schema…) — the only honest way to fabricate corruption, and it satisfies the repo's rule on as.
  • except?: readonly string[] is optional, so every existing markAllSaved() caller is untouched: a backwards-compatible, well-typed API change. No any, no !.
  • Layers respectedflow-writeback.ts stays in the store, save-actions.ts in services; the direct console.warn matches existing practice in the slice (slice.ts:488), so it isn't a port bypass.

Previous review — status

CodeRabbit reviewed HEAD (20:18Z); your last commit is 20:02Z, so neither point has been addressed yet.

1. save-actions.ts:516 — "handle every entry in staleFlows" (Major). The diagnosis doesn't hold: the unrelated POU keeps saved: false and updated: true, and executeSaveFile never marks other files saved, so nothing is lost or misreported. The proposed fix (toasts naming unrelated files during a single-file save) is noise. But it is pointing at real code — the fix is scoping the flush, 🟡 1 above.

2. slice.ts:993-996 — check the flush result before undo/redo snapshots (Major). Valid, though outside this diff. undo / redo — and use-pou-snapshot.ts:28, which the bot didn't flag — discard the new return value. Probe, after undo then redo on a POU whose write-back fails:

liveFlowIsTheCorruptedOne: true   ← the editor shows the corrupted flow
bodyStillTheStaleOne:      true   ← pou.body.value is the pre-edit body
flowUpdatedFlag:           false  ← nothing will retry
fileSaved:                 true   ← THE FILE REPORTS ITSELF SAVED

That's the DOPE-495 lie back again, and it erases the protections this PR just added. The mechanism is pre-existing (the flush used to return void), but the module's own docblock promises the invariant — "undo/redo and snapshot capture flush the affected POU so a history entry never pairs a stale body with a fresh flow" — and it's now falsifiable. Since the PR title covers exactly this failure mode, your call whether it lands here or in a follow-up:

undo: (pouName) => {
  // A failed write-back leaves pou.body.value stale — capturing now would store
  // an old body next to a fresh flow in the history (DOPE-495).
  if (flushFlowWriteBacks(getState, pouName).length > 0) return

Solid work — the dead-timer hole is the kind of thing that's easy to miss and it's the actual root cause, the per-POU choice is right (one bad flow shouldn't hold every other POU's work hostage), and the reachability section in the description is honest about scope. The 🔴 is what I'd want fixed before merge, plus 🟡 1 since it's a one-liner that settles the open review thread. And whatever you change, mirror it byte-for-byte in the other repo.

Review assisted by Claude Code.

…(DOPE-495)

Review follow-up on the flow write-back fix. Three consequences of sweeping
every `updated` flow rather than only the ones with a live timer:

Deleting a POU leaves its flow behind — `deleteElement` clears the project
entry, model, file and tab, but not the flow, and `removeLadderFlow` has one
caller (the AI tool executor). So an invalid orphan was reported stale on every
later save and `success` stayed false for good: builds blocked, "save and close"
never completing, and a toast naming a POU that no longer exists. Deleting the
POU is the user's only escape hatch from a corrupted flow, and it was the one
path still broken. A flow with no POU has no body to write back, so skip it.

`executeSaveFile` flushed every dirty graphical POU on a single-file save.
Nothing was misreported, but it validated and warned about POUs the user was
not saving. Scope it to the target.

undo, redo and snapshot capture discarded the flush result, which restored a
file to "saved" over a body that never reached disk — the original bug by
another route, and worse: it clears `updated`, so nothing retries. All three
now bail. `undo`/`redo` return a boolean so the accelerator handler can raise
a "History unavailable" toast instead of letting the shortcut look broken;
`captureAndPush` stays silent because it fires on every edit.

Follow-ups, including the `deleteElement` leak this only treats: DOPE-524.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKiQpeqRqne8nLj2fUGH21
@JoaoGSP

JoaoGSP commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Review response is on the web-side mirror, to keep it in one place: https://github.com/Autonomy-Logic/openplc-web/pull/639#issuecomment-5182496393

Short version: the 🔴 orphan-flow guard and 🟡 1 (scoped flush) are in, plus the undo/redo/snapshot flush-result checks that CodeRabbit raised — landed here rather than deferred, since that path needs no POU deletion and clears updated so nothing retries. undo/redo now return a boolean and the accelerator handler raises a "History unavailable" toast.

All 9 files byte-identical with #639 (shasum per file). 181/181 under jest here, same under vitest on web; tsc --noEmit clean. Follow-ups on DOPE-524.

Reply assisted by Claude Code.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/frontend/store/__tests__/shared-slice.test.ts`:
- Around line 1114-1118: Replace the `as unknown as LadderFlowType` assertion in
the `addLadderFlow` test setup with a properly typed fixture or dedicated test
seam that intentionally triggers the Zod validation failure. Preserve the
malformed-data scenario without bypassing the TypeScript model, and avoid all
type assertions except `as const`.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e09b9e85-1cd0-4f84-b313-4d6a8e2124c3

📥 Commits

Reviewing files that changed from the base of the PR and between ebf167c and 4e11afa.

📒 Files selected for processing (8)
  • src/frontend/components/_templates/accelerator-handler.tsx
  • src/frontend/hooks/use-pou-snapshot.ts
  • src/frontend/services/__tests__/save-actions.test.ts
  • src/frontend/services/save-actions.ts
  • src/frontend/store/__tests__/shared-slice.test.ts
  • src/frontend/store/slices/shared/flow-writeback.ts
  • src/frontend/store/slices/shared/slice.ts
  • src/frontend/store/slices/shared/types.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/frontend/services/save-actions.ts
  • src/frontend/store/slices/shared/flow-writeback.ts

Comment thread src/frontend/store/__tests__/shared-slice.test.ts
@JoaoGSP
JoaoGSP requested a review from Gustavohsdp August 4, 2026 20:50
@JoaoGSP
JoaoGSP merged commit d282c2d into development Aug 5, 2026
13 checks passed
@JoaoGSP
JoaoGSP deleted the bugfix/DOPE-495-flow-writeback-silent-skip branch August 5, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants