fix: everything found running two sync nodes against each other - #1286
Merged
Conversation
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',…]`).
There was a problem hiding this comment.
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.
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.
**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.
Closed
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
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.
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.
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>
2 tasks
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>
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.


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.
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=atomsrepaired it in ~40s. How it got short is still unknown — the rebuild destroyed the evidence.Fixes
fix(db)—query_complexrebuilt 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)—setDriverepointed the store forhttps://drives and did nothing fordid: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)— thedist→assets_tmpcopy was gated onneeds_build, which answers "shouldpnpm buildrun", 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, andrefresh()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. AlsopreventDefaulton 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
is_a_encodings_all_match_the_class_constraintis#[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_lib328 pass, 6 ignored;@tomic/lib284 pass; clippy and fmt cleandistA note on method
planning/silent-failures.mdis 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
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.setDriveno longer persists foreign origins on desktop, and switching back to adid: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
runrejections are caught and toasted (with a resolved action label). Delete errors no longer become empty toasts. Table cell mousedownpreventDefaultstops native text selection over the grid.Sync UI / build. Sync page cards share a
SyncCardlayout (controls vs footer, node-id copy with error handling).build.rscopiesdistinto the embed when the bundle is newer thanassets_tmp, even ifpnpm buildwas skipped. Addsplanning/silent-failures.md.Reviewed by Cursor Bugbot for commit f8b9c5f. Bugbot is set up for automated code reviews on this repo. Configure here.