fix(connections): make the group system report its refusals and survive a broken graph - #2619
Merged
Conversation
…ve a broken graph Claude-Session: https://claude.ai/code/session_01L81TaoPWxkLd15CGw2riPq
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This was referenced Sep 3, 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.
Found while investigating #1311, which asks for the connection group hierarchy inside the main
window. Before a second surface can render groups, the group system has to be able to report a
change and report a refusal. It can do neither today, and four more defects fall out of that.
This is the prerequisite. The #1311 feature builds on this branch.
What was wrong
A parent cycle crashed the connection list.
computeGroupTreeIndicesfalls back to theunguarded
maxDescendantDepthfor any group its root walk cannot reach, which is exactly what acycle produces, and the two recurse into each other until the stack runs out. The welcome list
recomputes those indices on every rebuild. A cycle got there because
applyRemoteGroupwrote aremote record straight through
saveGroups, bypassingwouldCreateCircleandvalidateDepth,the only callers of either. Two Macs that reparent P under C and C under P inside one sync window
each apply the other's record and end up with no root for either group.
Nothing announced a group change.
GroupStoragenever touchedAppEvents, so a group createdin the connection form did not appear in an open welcome window until relaunch.
connectionUpdatedalready documents itself as covering group changes, and
SyncCoordinatoralready sends it for aremote group, so only the local path skipped the contract.
addGroupswallowed three refusals. A duplicate sibling name, a cycle and a nesting past thecap were all a bare early return on a
Voidfunction.ConnectionGroupPickerthen setselectedGroupIdto the id of a group that was never saved, so the connection was stored with agroupIdno group answers to and rendered as ungrouped, with nothing said.Drag reorder moved the wrong row.
.onMovereports positions in the rendered node list;moveUngroupedConnectionsandmoveGroupedConnectionsmapped those offsets into the unfilteredconnectionsarray.rebuildTreehides every top-level favorite and a tag filter hides whatever itdoes not match, so with one favorite present, dragging the last row moved the one before it.
Two Macs traded the whole group list forever.
saveGroupsmarks every group dirty andcollectDirtyGroupsuploads every dirty group, andapplyRemoteGroupwrote a remote recordwithout asking whether it differed from the one already stored. One changed group therefore
re-uploaded all of them, the other Mac wrote them and re-uploaded them back, and neither side
settled.
IOSSyncCoordinator.mergeGroupshas always had the equality guard that stops this.One unreadable entry lost every group.
loadGroupscaught the decode error, returned[]andcached it. Every mutation rewrites the whole array, so the next group operation, including a single
incoming sync record, persisted that emptiness over the user's groups.
What changed
groupsWithReachableParents: a group whose parent chain neverreaches the root is presented at the top level. Both tree builders,
depthOf,maxDescendantDepthandconnectionCountgo through it, so no stored graph can hide a group orsend a walk round a cycle.
maxDescendantDepthalso carries a visited set of its own.applyRemoteGrouponGroupStoragewrites the record exactly as it arrived, and the pull callsrepairHierarchyonce the whole batch has landed. A record cannot be judged on its own: a pullcarries no dependency order, so a hierarchy the other Mac reversed legally, rooting B and then
moving A under B, arrives as two records and whichever lands first reads as a cycle against the
half that has not arrived. Judging per record rooted A for good and pushed that back as a revert
of the user's own move. Neither raises a notification, because the pull already raises one
coalesced notification for the batch.
deleteGroupcomputes its delete set from the graph the list draws. A group left on a cycle isdrawn as a top-level row while each cycle member is still the other's descendant in the raw
stored graph, so deleting one displayed root used to delete the other and tombstone both.
addGroupandupdateGroupthrowGroupStorageError. The create sheet keeps itself open andsays which rule refused it; rename, recolour and move report through a new alert on the welcome
window.
saveGroupsreturnsBool, matchingConnectionStorage's documented convention.connectionUpdatedafter a successful write.saveGroupsstays silent sothe sync pull keeps its single coalesced event.
canPlaceGroup, replacing the copy inWelcomeViewModel.moveGroupand thecopy in the move menu. It is subtree-aware, which the storage's own rule was not: moving a
two-level subtree one level down used to be allowed and pushed its children past the cap, where
the tree stops drawing them.
ConnectionGroup.maxNestingDepthreplaces the three literal3s.draw keep the slots they held.
loadGroupsdecodes element by element, so one unreadable entry costs that entry. A payload itcannot read at all leaves the store untouched and refuses the write instead.
applyRemoteGroupskips a record identical to the stored one and returns whether it changedanything, so the pull's coalesced notification is not raised for a no-op either.
snapshot taken before either was added.
Localizable.xcstringswith all five translations.xcodebuildextracts them into.stringsdatabut never writes them back to the catalog, whichonly Xcode does, so a CLI-verified branch ships them in English. Added through
scripts/localization.py's own serializer: 204 insertions, no deletions, andscripts/localization.py verifystill round-trips both catalogs byte for byte.Verification
verify.sh build: PASSverify.sh test GroupStorageTests ConnectionGroupTreeTests WelcomeViewModelTests StringCatalogIntegrityTests GroupMenuEntriesTests: PASS, 95 casesverify.sh linton every changed app file: clean. The three changed test files carry the sameseven pre-existing violations as at HEAD and no new ones;
.swiftlint.ymlscopesincludedtoTablePro, so they have never been linted.maxDescendantDepthterminating on a cycle, each
GroupStorageError, the subtree-aware move rule, an event permutation and none from a remote apply, an unreadable store left untouched, an unreadable entry
not taking the list down, an unchanged remote record leaving nothing dirty, and reordering under
both a hidden favorite and an active tag filter.
No UI automation: every one of these is a storage or model rule, and the two UI surfaces are an
alert and a sheet field whose content the unit tests already pin.
Known residual
A remote group record that cannot be persisted is still acknowledged.
applyPullResultsaves thenew server token before applying anything (
SyncCoordinator.swift:464-467), so any apply thatfails is not re-fetched until a full resync, for every record type and not only groups. This PR
adds one more way a group apply can fail, the unreadable-store refusal, but not the cause. Fixing
it means moving the token commit behind a successful apply across every record type, which is a
sync change well past this one.
Not in this PR
TableProMobilehas its own group storage with no validation at all, andIOSSyncCoordinator .mergeGroupswrites remote records unvalidated. Porting the rule was planned and then dropped:parentIdappears nowhere in the iOS target, so every group it creates is top-level and no iOSpath can author a cycle or exceed the cap. Validation there would have no reachable input today.
https://claude.ai/code/session_01L81TaoPWxkLd15CGw2riPq