Skip to content

fix: everything found running two sync nodes against each other - #1286

Merged
joepio merged 24 commits into
developfrom
fix/sync-nodes-index-and-ui
Aug 21, 2026
Merged

fix: everything found running two sync nodes against each other#1286
joepio merged 24 commits into
developfrom
fix/sync-nodes-index-and-ui

Conversation

@joepio

@joepio joepio commented Aug 19, 2026

Copy link
Copy Markdown
Member

Everything found while running a desktop node and a Home Assistant node against the same drive and watching where they disagreed. Seventeen commits; each one has its reasoning in its message.

Overlaps two open PRs. da58ff72 + 6c83924f are #1285, and the planning/silent-failures.md commits were cherry-picked onto #1283. If those merge first this branch dedupes against them; if it is easier, this supersedes #1285. Say which and I will close the other.

The bug that started it

A Houseplants table rendered 5 rows on one node and 22 on the other. Same resources, present in both stores, returned by every unfiltered query — absent only from the drive-scoped, class-filtered, sorted index the table actually reads.

Root cause was an index holding 5 of 22 members. ATOMIC_REBUILD_INDEX=atoms repaired it in ~40s. How it got short is still unknown — the rebuild destroyed the evidence.

Fixes

fix(db)query_complex rebuilt a filter's index only when it came back EMPTY and unwatched. "Not watched" means nothing has been maintaining it, so any count it holds proves nothing; but a partial index is non-zero, so the rebuild never fired and the query kept returning a wrong, plausible number. Now rebuilds whenever unwatched, clearing existing members first so the rebuild is authoritative.

fix(app)setDrive repointed the store for https:// drives and did nothing for did: ones, so opening a drive elsewhere moved the app to that server and coming back to a local drive left it there. Every collection was then asked of a server that does not hold the drive; when that server was also unreachable, the sidebar rendered empty with nothing saying why. Restarting fixed it, which is what made it look intermittent.

fix(build) — the distassets_tmp copy was gated on needs_build, which answers "should pnpm build run", not "is the embedded copy current". Those diverge exactly when you build the frontend by hand, which is what a deploy does. The more carefully you prepared the bundle, the more certainly you embedded a stale one.

fix(collection) — a partial local index passed as the whole set. The empty case was carefully guarded; any non-empty answer was taken as authoritative and never re-checked, and refresh() re-read the same stale index.

fix(table) — two nodes ordered the same collection differently. Servers agree exactly (same 22 subjects, same sequence); the client sort was the outlier: missing values went last where the server puts them first (TAG_NONE = 0x05), and ties returned 0 where the server breaks them by subject. Ties are the common case — an unfilled column is all ties. Also preventDefault on mousedown so drag-selecting cells stops painting a native text selection.

Plus the earlier desktop fixes: embedded-node bias, menu-action errors surfacing, and the SyncCard extraction.

Still open

  • How the index got to 5. Three passing tests rule out the local write path; rows arriving via sync are the untested suspicion.
  • is_a_encodings_all_match_the_class_constraint is #[ignore]d with its reason: a member that is written, parseable, local and matching is still dropped, with the read stopping after the second of three. Not proven to be the same bug as the 5-of-22.

Testing

  • atomic_lib 328 pass, 6 ignored; @tomic/lib 284 pass; clippy and fmt clean
  • Both nodes built from one worktree and verified by serving, not by inference: binary md5 matched across the deploy, and HA's served chunk hashes matched the local dist
  • After the index rebuild both nodes answer the table's exact query with 22

A note on method

planning/silent-failures.md is the running list this session produced. Most of the time went into failures that report success: a stale bundle whose binary md5 matched, an add-on "update" that re-pulled a two-month-old image, a tunnel pointing at a stopped container, and my own probes measuring a query the product does not issue. That last one cost the most — every reconstruction said 24/24 while the UI showed 5 and 22.

Checklist

  • Add or update tests if needed
  • Update docs if needed

Note

High Risk
Changes which server the desktop app talks to across boot and drive switches, plus client collection sort order. Wrong routing or sort can hide local data or reshuffle tables.

Overview
Fixes several ways two nodes (or a desktop app vs its own node) could disagree or look broken: the app talking to the wrong server, tables ordered differently than the server, and failures that never showed a toast.

Server vs embedded node. Stored server URLs now distinguish an explicit choice (?server=, connect dialog) from a drive-derived origin. On Tauri, only an explicit choice outranks the local node at boot. setDrive no longer persists foreign origins on desktop, and switching back to a did: drive restores the chosen or embedded server so collections are not asked of the last https host.

Collections. Client-side sort now matches the server: missing values first (TAG_NONE), ties broken by subject. Tests cover that and that a locally larger page is not treated as stale.

Surfacing failures. Context-menu run rejections are caught and toasted (with a resolved action label). Delete errors no longer become empty toasts. Table cell mousedown preventDefault stops native text selection over the grid.

Sync UI / build. Sync page cards share a SyncCard layout (controls vs footer, node-id copy with error handling). build.rs copies dist into the embed when the bundle is newer than assets_tmp, even if pnpm build was skipped. Adds planning/silent-failures.md.

Reviewed by Cursor Bugbot for commit f8b9c5f. Bugbot is set up for automated code reviews on this repo. Configure here.

joepio added 17 commits August 18, 2026 11:18
The Sync page built four device cards inline — the server in use, two kinds
of peer, and this device — out of shared styled pieces but no shared shape,
~1200 lines apart in a 2767-line file. They drifted, visibly: the same
"Disconnect" verb sat top-right on one card and bottom-left on another,
telemetry lived in the subtitle for peers and in a facts line for the server,
and the status pill carried an icon on exactly one of them.

`SyncCard` makes those differences data. Two action slots, split by what the
control does rather than by where it looked best on each card: `controls`
change the connection (Disconnect, Reconnect, Sync now, Switch) and sit beside
the status they act on; `footer` leaves it (Remove, Manage account).

Two defects fell out of the merge, which is the usual dividend:

- the node-id copy existed three times, and the "This device" copy had no
  try/catch — a denied clipboard was an unhandled rejection and a button that
  looked dead. One implementation now, with the error handling.
- converting the server card dropped its `StatusIcon`, which lint caught as an
  unused function. The pill now draws its own icon from the tone, so every
  card has one and no caller has to remember.

`DeviceCard` (the styled shell) retires in favour of `ConnCard $spacious`.
The catalog diff shows the dedup landing: "Node ID" loses a duplicate source
reference, "Paired with {0}" becomes shared, and the odd-one-out "Copy Node
ID" tooltip is obsolete.

Verified in the running desktop app: "This device" renders with no pill, the
paired peer with pill+icon, Sync now top-right, facts on their own line and
Remove in the footer.
Deleting a resource that the server refuses left no trace in the UI. The row
stayed in the sidebar and later rendered as "Resource with error", which reads
as a sync bug rather than as a delete that was declined.

Two swallows, one behind the other.

`ResourceContextMenu` invoked `action.run(ctx)` at both call sites without
awaiting it, so any rejection became an unhandled promise: the menu closed,
nothing happened, and the only trace was in a console nobody had open.
`runAction` now catches the rejection AND a synchronous throw, logs the whole
error, and toasts which action failed and why.

The delete action did catch, but reported `(error as Error).message` — which
is `undefined` for anything thrown that is not an Error, i.e. an empty toast.
It now falls back through Error/string/unknown, and logs the subject with the
raw error, because `destroy()` throws BEFORE removing the resource locally:
that message is the only thing separating "deleted" from "refused".

Found while chasing stuck rows in the sidebar. The delete failures behind them
were a separate matter — the app was pointed at a server that rejects the
commit — but they were invisible, which is what made them hard to place.
The desktop app booted against https://atomicdata.dev and stayed there: every
write went to a public server, its own DID drive did not exist there, and the
drive page read "Could not reach the server — resource not available locally"
about data sitting on disk beside it. Deletes failed too, since that server
rejects a destroy commit whose previousCommit is a DID.

Nothing switched servers mid-session. A stored `serverUrl` simply outranked the
embedded node at boot, and that value was written by two different things that
storage could not tell apart:

  setServer(url)  — the user picked a server.
  setDrive(drive) — the drive's subject happened to start with http(s).

The second is not a decision about where the app belongs. With a couple of
dozen `https://atomicdata.dev/drive/…` entries in the switcher, one visit to
any of them pinned the public server for every launch after.

So record which kind it was, and on Tauri require an explicit choice to
outrank the embedded node. A drive-derived repoint still moves the app for the
session — you have to read that drive from somewhere — it just no longer
decides where the app lives. Storage written before this distinction existed
counts as not-chosen, so an already-stranded install gets one reset back to
the node that was always the right answer.

Verified against the stranded app itself: it moved from atomicdata.dev
(disconnected, drive unreadable) to localhost:9883, connected, drive "Joeps
drijf" loading — without touching its stored settings.

Unit tests cover which write wins, including the pre-distinction state.
Brings in the change that matters for HA<->desktop sync testing:
hydration writes no longer enter the outbox as though they were user
edits, so opening a resource you can read but not write stops queueing a
commit the server rejects forever.

Keeps this branch's three desktop fixes — embedded-node bias,
menu-action errors, and the SyncCard extraction.

`SyncRoute.tsx` conflicted because both sides rewrote it. develop
inlined a "Manage account & plan" link and a Remove action inside the
shared card; this branch had already lifted both to the call site and
passes them as `footer`. Kept the generic slot — re-inlining would have
rendered each twice.

While resolving, adopted develop's `externalLinkProps` for that footer
link. The lifted version still used a raw target/rel, which opens
nothing in the desktop app — the very thing ff0adb9 fixed.

Locale catalogs unioned and regenerated with `clean-translations` (no
dev server running, so a single writer): 1574 msgids in all four.
The local WASM DB is the source of truth for `parent=` queries, and the
EMPTY case was already handled with care: an empty local result is only
trusted once the drive's sync has completed, because an empty index is
indistinguishable from an unpopulated one.

A PARTIAL index got no such treatment. Any non-empty answer returned
`'ok'` and `fetchPage` returned before it could reach the server, so an
index holding 15 of 24 rows rendered 15 rows. `refresh()` re-entered the
same local path and produced the same 15, and OPFS persists, so it
survived reloads. Nothing anywhere compared the two counts.

Found while debugging a Houseplants table that showed 22+ rows on one
device and 15 on another. Everything pointed at sync: differing counts,
rows carrying `lastCommit` the peer "lacked", and a peer log reporting
`SYNC_DIFF: server pushes 0` — "I have nothing to send you". All of it
was consistent, and none of it was the bug. Querying both servers
directly returned 24 members each: they had been converged the whole
time and the zero-push diff was correct. One client was showing a stale
local index, and no signal existed anywhere to say so — which is what
makes this expensive, because the symptom sends you into the sync code,
where everything is working.

So: after serving from the local index, check its count against the
server in the background, and repair loudly if they disagree. A count,
not a page — the fast local render stays, and this costs one small query
rather than the duplicate `/query` per mount that the OPFS-first
ordering was introduced to remove. Once per collection instance, so it
cannot loop with its own repair, and the repair sets `_bypassLocalDb`
because refreshing through the local path would just re-read the same
stale index.

Tests cover the repair, the agreeing case (no refetch behind the probe),
and that the check happens once rather than per page. All three fail
with the change reverted.
Both sync nodes are built from this worktree, so a fix has to land here
to be under test at all.
…uilt it

The copy from `browser/data-browser/dist` into the embedded `assets_tmp`
was gated on `needs_build`. But `should_build` answers "are the JS
SOURCES newer than dist" — i.e. "should I run pnpm build" — which is a
different question from "is the copy I embed current".

They diverge exactly when someone builds the frontend themselves. Then
dist is fresh, `needs_build` is false, the copy is skipped, and the
binary ships whatever `assets_tmp` held from an earlier build. So the
more carefully you prepared — build the bundle, check it is newer than
every source — the more certainly you embedded a stale one. Deploy does
this, and `browser/e2e/README.md` now tells people to do it too.

Cost a full round of wrong conclusions today: a Home Assistant node was
deployed from a verified-current bundle, the binary md5 matched what was
built, and it still served a two-builds-old frontend. It only surfaced
because the chunk hashes in the served index.html were compared against
the local dist — nothing in the build said a word.

Compare the two directories instead. `dircpy` preserves mtimes, so after
a copy the newest times are equal and a no-op build still skips; a
hand-rebuilt dist is strictly newer and gets re-embedded. Verified both
paths.
Separate from the bugs themselves. The pattern behind nearly every
expensive debugging session here is the same: a command reports success,
a health check passes, a tool "updates" nothing, and the damage surfaces
hours later disguised as an unrelated bug. The time goes into
discovering that something did nothing.

Fifteen entries from the last two days, each with what happened, what it
looked like, and what should have shouted. Several are already fixed and
noted as such; the open one that matters most is that two servers can
hold different data while both report healthy sync — a sync system has
nothing louder to say than "we disagree", and it says nothing at all.

The rule the list argues for: prefer failures that cannot be missed —
refuse to start, wipe the bad state, exit non-zero — over printing a
note. Notes scroll past, and most of these tools run with their output
redirected to a log nobody reads.
Both servers return 24 members for the same collection query. The
divergence was a client rendering a stale local index, not a
convergence failure, and `SYNC_DIFF: pushes 0` was correct.

Worth keeping the wrong turn in the file: three signals — differing row
counts, a zero-push diff, and rows carrying lastCommit the peer
'lacked' — were all consistent with a sync bug, and none of them were
it.
Second entry in the same family as the tsbuildinfo one: a staleness
check answering a neighbouring question to the one being asked, and
saying nothing when it guesses wrong.
`setDrive` repointed the store whenever the new drive was an
`https://…` subject, and did nothing at all when it was a `did:`. So
opening a drive that lives elsewhere moved the app to that server, and
returning to a drive that lives on THIS device left it there.

From then on every collection is asked of a server that does not hold
the drive. If that server is also unreachable — atomicdata.dev was, with
a WebSocket 1006 — the answers are empty and the sidebar renders
nothing. No error says the app is talking to the wrong machine; the
resources are simply gone. Restarting recovers it, because
`embeddedNodeWins` re-applies the device's own node at boot, which is
exactly what made it look intermittent rather than reproducible.

The earlier embedded-node change guarded only the PERSISTENCE of a
foreign origin, not the live switch, so a device with its own node could
still be stranded on another server for the rest of a session.

Come home when the drive names no server, under the same rule boot uses:
only a server someone actually chose outranks the node running beside
them.
The one that took all day. A table rendering 5 rows against a server
holding 22, where every probe reported agreement because every probe was
a reconstruction of the query rather than the query itself.

filters + drive + sort_by returns 5 on the desktop node and 22 on HA;
removing either the drive scope or the sort returns 22 on both. So the
compound drive-keyed index holds 5 of 22 rows while the resources and
every other query shape are complete.

Includes the diagnostic lesson, because it is the more transferable
half: capture what the application actually sends before comparing
anything.
A table showed 5 of 22 rows on one node and 22 on another. Same
resources, same query — the rows were present in the store and returned
by every unfiltered query, and absent only from the drive-scoped,
class-filtered, sorted index the table actually reads.

`is_a_encodings_all_match_the_class_constraint` reproduces it in
isolation: four rows whose `isA` names the same class, encoded four
ways. Two are found, two are not.

    ResourceArray([cls])      found
    AtomicUrl(cls)            found
    String(cls)               MISSING
    String("[\"cls\"]")       MISSING

That matters because rows created by a local commit carry `isA` as a
ResourceArray, while rows rebuilt from a Loro doc — which is every sync
import, via `apply_state_doc` — recover their `Value` variant by
inference. Two nodes holding identical data can therefore disagree about
which rows exist, and the one that is wrong reports a number that looks
entirely plausible.

`is_a_string_encoding_matcher_vs_candidates` narrows where it is NOT:
the matcher accepts a String-encoded isA (`contains_value` compares by
string), and the atom does yield an index entry keyed by the class
subject. Both pass. So the row is never offered as a candidate rather
than being considered and rejected — the fault is in candidate planning,
not in matching.

The three `sorted_query_index_keeps_up_*` tests are the negative result
that got here: the incremental path DOES keep up with rows added after a
filter is watched, for http subjects, DID subjects, and the real
two-constraint shape. Drift over time was the wrong theory.

No fix in this commit — the reproduction first, deliberately.
`query_complex` rebuilt a filter's member index only when it came back
EMPTY and unwatched. "Not watched" means nothing has been maintaining
that index — so however many entries it happens to hold, they say
nothing about whether it is complete. A partial one is non-zero, the
rebuild never fired, and the query went on returning a count that was
wrong and entirely plausible.

Empty was treated as suspicious and partial as authoritative, which is
backwards: partial is the state that looks fine. Same shape as two other
bugs found today — a client collection trusting a partial local index,
and `waitForSearchIndex` treating a fixed sleep as a readiness signal.

Rebuild whenever the filter is unwatched, and clear the existing members
for that filter first so the rebuild is authoritative rather than merged
into a set of unknown provenance — otherwise a stale member for a
resource that no longer matches survives the rebuild that was meant to
correct exactly that.

`partial_index_for_an_unwatched_filter_is_rebuilt` seeds two of six
members without watching, and asserts the query returns six. It reports
"Got 2 of 6" without this change.

Also marks `is_a_encodings_all_match_the_class_constraint` ignored with
its reason. It reproduces a SEPARATE open bug — a member that is
written, parseable, local and matching is still dropped, and the read
stops after the second of three — and a red suite helps nobody while
that is unfixed. Run it with `--ignored`.
Both sync nodes build from this worktree, so the fix has to land here to
be under test.
Two things noticed while testing the same table on two devices.

**Order.** The same collection can be answered by the local index or by
the server, and they disagreed, so the row list reshuffled depending on
which side answered — which reads as the data changing under you. The
servers are identical here: both return the same 22 subjects in the same
sequence. The client sort was the odd one out, in two ways.

Rows with no value for the sort column went LAST; the server encodes a
missing value as `TAG_NONE` (0x05), below every value tag, so they lead
an ascending sort. And ties returned 0, leaving them in whatever order
the local index yielded — stable, but at a different order than the
server's, whose member key is `id || sort_key || subject` and therefore
breaks ties by subject. Ties are the common case, not the rare one: a
column nobody has filled in is all ties, which is why the first and last
rows matched while everything between them moved.

**Selection.** Dragging across cells painted a native text selection over
the grid. The cell already carries `user-select: none`, but that governs
what can be selected, not whether a drag begins, so the selection still
started — and in the desktop webview it stayed behind after mouseup.
`preventDefault` on mousedown, except in Edit mode where selecting text
is the point.

The sort test asserts the exact expected order and fails with the old
comparator (`['rowD','rowA',…]` against `['rowB','rowC',…]`).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Action errors stringify label function
    • Added ctx parameter to reportActionError and called action.label(ctx) to get the actual label string instead of stringifying the function.
  • ✅ Fixed: Explicit choice blocks local drive restore
    • Removed the Tauri guard so serverURLStorage.set is called even on Tauri to clear the explicit flag when opening an https:// drive.
  • ✅ Fixed: Count repair treats surplus as stale
    • Added a check to only repair when serverTotal > localTotal, preventing local surplus (pending writes, optimistic adds) from being treated as incomplete data.

Create PR

Or push these changes by commenting:

@cursor push 10d79702b4
Preview (10d79702b4)
diff --git a/browser/data-browser/src/components/ResourceContextMenu/index.tsx b/browser/data-browser/src/components/ResourceContextMenu/index.tsx
--- a/browser/data-browser/src/components/ResourceContextMenu/index.tsx
+++ b/browser/data-browser/src/components/ResourceContextMenu/index.tsx
@@ -44,15 +44,15 @@
     const result = action.run(ctx) as unknown;
 
     if (result instanceof Promise) {
-      void result.catch((e: unknown) => reportActionError(action, e));
+      void result.catch((e: unknown) => reportActionError(action, ctx, e));
     }
   } catch (e) {
     // A synchronous throw, before the promise even exists.
-    reportActionError(action, e);
+    reportActionError(action, ctx, e);
   }
 }
 
-function reportActionError(action: ActionDefinition, e: unknown): void {
+function reportActionError(action: ActionDefinition, ctx: ActionContext, e: unknown): void {
   const detail =
     e instanceof Error && e.message
       ? e.message
@@ -60,10 +60,12 @@
         ? e
         : 'unknown error';
 
+  const label = action.label(ctx);
+
   // Logged as well as shown: the toast is necessarily short, and a server's
   // parse error is the kind of thing worth having in full.
-  console.error(`[action] "${action.label}" failed:`, e);
-  toast.error(`${action.label} failed: ${detail}`);
+  console.error(`[action] "${label}" failed:`, e);
+  toast.error(`${label} failed: ${detail}`);
 }
 
 /** Ids of the actions in the registry (`actions/resourceActions.tsx`). */

diff --git a/browser/data-browser/src/helpers/AppSettings.tsx b/browser/data-browser/src/helpers/AppSettings.tsx
--- a/browser/data-browser/src/helpers/AppSettings.tsx
+++ b/browser/data-browser/src/helpers/AppSettings.tsx
@@ -141,9 +141,9 @@
         // `https://…/drive/…` entry in the switcher left the app booting
         // against that server forever, ignoring the node running beside it.
         // Session-only here; `setServer` is the deliberate route.
-        if (!isRunningInTauri()) {
-          serverURLStorage.set(url.origin);
-        }
+        // Even on Tauri we must call this to clear the explicit flag, so
+        // switching back to a local drive can restore the embedded node.
+        serverURLStorage.set(url.origin);
 
         return;
       }

diff --git a/browser/lib/src/collection.ts b/browser/lib/src/collection.ts
--- a/browser/lib/src/collection.ts
+++ b/browser/lib/src/collection.ts
@@ -1171,6 +1171,11 @@
 
       if (!isNumber(serverTotal) || serverTotal === localTotal) return;
 
+      // Only repair when the server has MORE members than we do locally.
+      // A local surplus (pending writes, optimistic adds already in
+      // `_totalMembers`) is not a sign of an incomplete index.
+      if (serverTotal < localTotal) return;
+
       // Loud on purpose. This is the state that reads as a sync bug, so say
       // plainly that it is not one and that the client is repairing itself.
       console.warn(

You can send follow-ups to the cloud agent here.

Comment thread browser/data-browser/src/components/ResourceContextMenu/index.tsx
Comment thread browser/data-browser/src/helpers/AppSettings.tsx Outdated
Comment thread browser/lib/src/collection.ts Outdated
joepio added 2 commits August 19, 2026 14:36
**CI, and why my check missed it.** `tsup` runs `tsc --emitDeclarationOnly`
and its failure does not stop the build printing "Build Finished!", so
`pnpm build` looked fine locally while CI failed on the same command.
The diagnostic — `error TS2345` in the new sort test, `getMemberWithIndex`
returns `string | undefined` — only appeared by running `tsc` directly.
`pnpm typecheck` passes because its tsconfig excludes tests. Added to
`planning/silent-failures.md`; it is the same shape as the rest.

**Action labels.** `label` is `(ctx) => string`, not a string. Interpolating
it put the function's source in the toast, so the net added to surface a
failed action named it `(ctx) => ...` rather than "Delete". Resolve it
against the context, and guard the resolution — it reads the resource and
can itself throw, and a label that fails must not swallow the error it
exists to report.

**Coming home to the local node.** Gating the whole restore on
`wasExplicitlyChosen` left anyone who had ever picked a server stranded on
the origin of the https drive they just came from — the bug this branch is
meant to fix, merely harder to reach. Come home either way; only the
destination differs, the chosen server if there is one, else this device's
node.

**Count repair.** It refetched whenever the totals differed, in either
direction. A local surplus is the normal local-first case — an optimistic
add already folded into `_totalMembers`, or an unacknowledged write — and
replacing the page with the server's smaller one deletes rows that
genuinely exist. Only a shortfall is repairable, with a test that fails if
the surplus case starts refetching again.
…s not worth

`verifyCountAgainstServer` fired a count query per collection after the
local index answered. `query-drive-filter.spec.ts:100` exists to assert
"refresh on a populated drive does not refetch known resources from
server", and the probe produced seven such requests — so it violated a
deliberate, tested invariant. The OPFS-first ordering was introduced
precisely to remove those requests; adding one back per mount undoes it.
It very likely also explains the two offline specs that failed alongside.

The probe was compensating for a client rendering a partial local index.
The actual cause turned out to be server-side — a drive-scoped filtered
index holding 5 of 22 members, fixed in `6c83924f` and repaired in place
with `ATOMIC_REBUILD_INDEX=atoms`. With the cause addressed, the
compensation costs a guarantee and buys nothing.

Joep called it a monkey patch pointing at a deeper problem before CI
did. Both were right; the deeper problem was the index.

The repair suites went with it — they tested a mechanism that no longer
exists. The file keeps its ordering suite and is renamed to match what
it now covers.
@joepio joepio mentioned this pull request Aug 19, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Client sort ties mismatch server
    • Replaced localeCompare with lexicographic comparison (< and > operators) to match the server's raw byte order for subject tie-breaking.

Create PR

Or push these changes by commenting:

@cursor push 017a207013
Preview (017a207013)
diff --git a/browser/lib/src/collection.ts b/browser/lib/src/collection.ts
--- a/browser/lib/src/collection.ts
+++ b/browser/lib/src/collection.ts
@@ -1063,7 +1063,7 @@
         }
 
         if (cmp === 0) {
-          cmp = a.localeCompare(b);
+          cmp = a < b ? -1 : a > b ? 1 : 0;
         }
 
         return sortDesc ? -cmp : cmp;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 7321041. Configure here.

Comment thread browser/lib/src/collection.ts
joepio and others added 4 commits August 19, 2026 18:55
Merging develop brought in `a387e36f`, which narrowed the gate on the
server-switch branch of `setDrive` from `startsWith('http')` to
`Client.isBareHttpOrigin`: a bare origin is a server switch, an HTTP
subject with a path is a workspace fetched cross-origin, and opening one
must leave the home server alone.

Git merged that against this branch's `did:` branch without reporting a
conflict, and the result was wrong. The early `return` used to catch
every `http(s)://` subject; under the narrower gate only a bare origin
leaves through it, so `https://host/drive/abc` fell past it into the
come-home branch and — on Tauri — repointed the base URL to the chosen
server or the local node. That is precisely the session move `a387e36f`
was written to prevent, reintroduced by a clean auto-merge.

The two fixes are orthogonal and compose in three cases, which the code
now states explicitly: a bare origin switches the server, an HTTP drive
returns without touching it, and a `did:` drive comes home.

Worth noting for the next merge: this file has now auto-merged into a
wrong result twice on this stack, both times silently. It is not covered
by a unit test because `setDrive` is a callback inside the provider;
only e2e exercises it.
develop put missing sort keys last; this branch matches the server
TAG_NONE order (missing first, ties by subject). Keep that, and point
the page-assemble missing-key test at a name that would beat the
string "undefined" so it still catches the stringify bug.

Co-authored-by: joepmeindertsma <joepmeindertsma@gmail.com>
@joepio
joepio merged commit bf34c0a into develop Aug 21, 2026
7 checks passed
@joepio
joepio deleted the fix/sync-nodes-index-and-ui branch August 21, 2026 17:54
joepio added a commit that referenced this pull request Aug 21, 2026
JSON.stringify('(full)') inside a double-quoted bash echo closed the
quote, so `(full)` became a subshell and every shard died before tests
started. Light CI hid it because `@smoke` is not a metacharacter. This
is why develop has been red since the light/heavy split, including the
#1286 merge.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
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