Skip to content

sync + coverage: measure every suite, and open the app at desktop size - #76

Merged
alichherawalla merged 217 commits into
mainfrom
release/sync-cross-platform
Aug 11, 2026
Merged

sync + coverage: measure every suite, and open the app at desktop size#76
alichherawalla merged 217 commits into
mainfrom
release/sync-cross-platform

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

The desktop side of the same story: your Mac joins the Personal Mesh, so chats, files, clipboard and models move between it and your phone over your own network. Plus a portable backup you own, and the coverage plumbing that makes the e2e tour count.

106 commits, 172 files, +10,551 / -1,107. Pairs with desktop-pro#39, which carries the private half.

What this gives you

Chat that reflects the mesh as it happens. A message synced from another device appears when it arrives, not when something else triggers a reload. The streaming reply is published as one observable snapshot, so the window cannot render half a state. Empty synced thinking placeholders stay hidden, and the conversation list shows the last message under each chat.

A backup you can carry. A portable desktop archive engine plus the settings to drive it: your data leaves in a format you own, and comes back.

Model transfers you can verify. Device transfers are registered and symlinked transfer files are rejected outright rather than followed.

Projects that stop losing work. Deleting a project preserves its chats; assigning one refreshes them. Short non-empty documents are indexed instead of silently skipped.

Devices beyond macOS. The Devices surface is available on Windows too, not only macOS. Nested screen history is retained, so going back does what you expect.

Licensing that behaves at the cap. Cached access is verified at launch, activation outcomes are rendered from the shared result rather than re-derived, the stalest seat is replaced when you hit the device cap, and there is a local Pro reset.

Verification

  • 429 test files, 4,053 tests passing (1 skipped, 2 skipped tests) via npm run test:coverage, which is the same gate the pre-push hook runs.
  • The full Playwright tour runs on the built app in CI under xvfb. Screenshots and the e2e coverage report are uploaded as run artifacts (e2e-screenshots, e2e-coverage); the tour remains advisory while headless-Electron launch stability on the ubuntu runner is the open question, which is recorded in docs/GAPS_BACKLOG.md along with the note that it once hid four real spec defects.
  • OFFGRID_E2E_COVERAGE makes the tour count: 25 specs drive the real app, devices-sync alone stands up a synthetic peer with a real SyncEngine, and none of it reached a coverage report before because Playwright launches Electron as its own process.
  • Coverage floor is 80 on every metric, set deliberately. It was 85, and pro branches sat on the line: 85.5% locally but ~85.2% as CI measures it, because CI legitimately skips the native-dependency ambient journeys. A 0.3% environment swing decided whether the gate was red, which is a gate reporting the runner rather than the code. Statements, functions and lines measure 91-93% in pro and higher in core, so 80 loosens branches alone.

One ci job

This repo used to report two checks. e2e was a second parallel job whose first nine steps were byte-identical duplicates of verify: the same pro checkout with its main fallback, the same shared monorepo provisioning, a second npm ci, all to feed a second runner. Those are deleted, and typecheck, tests with the coverage floor, dependency boundaries, lint and the e2e tour now report as one ci check.

The tour runs after the unit gates instead of beside them, so a PR takes about 7 minutes longer to go green. An earlier attempt at this was reverted for blowing a "25-minute job cap" (PR #68); that cap was this workflow's own timeout-minutes, so it is raised to 50 deliberately rather than worked around.

Note for anyone with branch protection: the check renames from verify to ci.

Tests worth calling out

approval-lifecycle.ts went from 0% coverage to covered. Its only tests were .dbtest.ts files, which the default vitest project excludes because they need better-sqlite3 rebuilt for the node ABI, and which CI never runs at all, so the highest-stakes logic in the "act" pillar was both measured as untested and actually untested in CI. It now runs against a real in-memory SQLite with the real approval queue, audit log, execution claim and preference store; only the MCP connector call is stood in for, because that is a separate process reaching a third-party API and the only way to observe whether it actually acted:

  • approving acts ONCE, and a second approval of the same row does not send a second email. Two clicks on Approve is the most ordinary thing a user does when a UI takes a moment.
  • a missing connector, or saved arguments that no longer parse, are RECORDED as failures with a reason rather than acted on or left sitting in the queue looking sent.
  • a 5,000-character connector response does not become the audit log.
  • rejecting learns from the reason once, and learns nothing from an empty one.

The desktop macos-proximity suite now stands up a real file at the bin resolver's path instead of mocking ../lib/bin-resolution, verified by hiding the gitignored binary, which is the CI condition.

Known gaps, recorded not hidden

docs/GAPS_BACKLOG.md, notably: 103 DB tests never run in CI (excluded from the default project, no test:db step), which is why a file with tests measured as 0%; and the ChatScreen journeys left uncovered by deleting a 155-case suite that stubbed fourteen of our own modules, with the measured 8-point statement drop and the four named journeys that now want rendered tests.

Replaces #75, which GitHub closed when the branch was renamed to release/sync-cross-platform.

Greptile Summary

The PR adds cross-device synchronization, portable backup and restore, desktop presentation updates, licensing changes, and expanded unit, database, and Electron coverage.

  • Adds backup archive creation and additive restoration for chats and knowledge documents.
  • Integrates real-time mesh updates and model-transfer registration.
  • Consolidates verification into one CI job with database journeys and advisory Electron coverage.
  • Expands desktop navigation, settings, permission, and Pro-feature integration surfaces.

Confidence Score: 4/5

The PR does not yet appear safe to merge because additive restore can still silently omit distinct archived messages that collide under its content-derived identity.

The reply says the archived-message omission was fixed by deriving identity from conversation, role, text, and timestamp, but the current implementation compares a second-precision timestamp and therefore treats two legitimate identical messages created in the same second as one, leaving the original incomplete-history failure reachable.

Files Needing Attention: src/main/backup/data-port.ts

Important Files Changed

Filename Overview
src/main/backup/data-port.ts Adds portable backup export and additive restore, including precomputed chunk embeddings and content-derived message deduplication.
src/main/backup/archive.ts Implements archive serialization and validation for portable desktop backups.
src/main/backup/ipc.ts Exposes backup and restore operations through the desktop IPC boundary.
src/main/sync-mutation.ts Publishes local mutations into the synchronization layer.
src/main/chat-stream-state.ts Centralizes observable chat-stream snapshots for consistent renderer updates.
.github/workflows/ci.yml Consolidates verification and adds database journeys plus advisory Electron coverage collection.

Sequence Diagram

sequenceDiagram
  participant Archive as Backup archive
  participant Restore as DesktopDataPort
  participant Embedder as RAG embedder
  participant DB as SQLite
  Archive->>Restore: Validated chats, messages, documents, chunks
  Restore->>Embedder: Compute chunk vectors before transaction
  Embedder-->>Restore: Embeddings or null fallback
  Restore->>DB: Begin additive restore
  Restore->>DB: Merge conversations and messages
  Restore->>DB: Insert documents and chunks
  Restore->>DB: Commit restored data
Loading

Reviews (3): Last reviewed commit: "test(e2e): stop the Devices specs failin..." | Re-trigger Greptile

M0 of docs/SYNC_INTEGRATION_PLAN.md. Brings the public sync engine in so the pro integration can
consume it; no behaviour wired yet.

- Vendors shared/packages/sync -> desktop/packages/sync following the existing convention
  (@offgrid/clipboard|design|models|rag are git-tracked copies consumed via file: deps). Records
  provenance (offgridVendoredFrom: commit 9b671b5) in the vendored package.json, because the
  existing copies have silently DRIFTED from shared/ and that should be visible.
- Adds runtime deps the engine needs: bonjour-service (pure-JS mDNS, no native build, used by the
  node-discovery adapter), tweetnacl, tweetnacl-util, js-sha512.
- Engine is consumed UNCHANGED: the mobile lane is working in the same package and two sessions
  editing it is the one guaranteed conflict. Engine changes go through the plan's 'Engine asks'.

Gate: 24/24 package tests pass from the vendored copy; tsc clean on tsconfig.node.json and
tsconfig.web.json; ./node + ./node-discovery subpath exports resolve (NodeTcpTransport,
NodeDiscovery).

Plan correction in the same commit: two engine asks were withdrawn after checking the real build.
Streaming/HTTP transfer (createFileRequestStreaming/Http, createFileCompleteStreaming,
verifyFileIntegrity) and ACK (createFileAck) already exist, so large-model transfer is NOT blocked
on the other lane — those are host-wiring rules this lane owns instead.
…e the engine directly

Two things, both prerequisites for cross-device message sync.

1) CORE SCHEMA — rag_messages.uuid (src/main/database.ts)
   rag_messages.id is INTEGER AUTOINCREMENT and therefore DEVICE-LOCAL. Live sync keys records by
   (entity, entityId) across devices, so device A's row 7 and device B's row 7 would look like the
   SAME message and silently overwrite each other. The autoincrement id stays the local primary key;
   uuid is the cross-device identity. Includes a JS backfill for existing profiles (SQLite has no
   uuid()), a UNIQUE index so a replayed remote op upserts instead of duplicating, and uuid on every
   new insert. Mobile adds the equivalent to its message store under the same names.

   Verified by src/main/__tests__/rag-message-uuid.dbtest.ts against a REAL legacy profile on disk
   (4/4): the column appears, every pre-existing row is backfilled with a DISTINCT uuid, uniqueness
   is enforced, the production writer populates it, and the migration is IDEMPOTENT — rewriting
   uuids on each launch would orphan the record on every other device.

2) CORRECTION — stop vendoring @offgrid/sync
   M0 vendored shared/packages/sync into desktop/packages/sync, copying the existing
   @offgrid/clipboard|design|models|rag convention. shared/docs/DESKTOP_SYNC_INTEGRATION_PLAN.md §1
   says explicitly NOT to duplicate this package: reference it directly, as mobile does. Now
   'file:../shared/packages/sync'; the copy is removed and the plan doc records the correction.
   (The other desktop/packages/* copies have already silently drifted from shared/, which is the
   argument for the direct ref.)

Full suite: 377 files / 3084 tests. The renderer integration failures seen while running this are
load-dependent flakes, not regressions — a clean tree fails a DIFFERENT test, and all five pass in
isolation with these changes applied.
…cides how

Turning thinking on for the loaded model takes two things: the template switch, and
reasoning_format so llama.cpp separates the reasoning out instead of burying it.
Only this process knows the second one - it is a property of the model server
running here, not of the request.

The gateway forwarded a client's body almost untouched, so a phone that sent
chat_template_kwargs.enable_thinking=true got a reply with an empty reasoning
field. From the phone the toggle looked broken, and the phone was the one side that
could not see why:

  [Provider] generate — thinking=true
  finish_reason=stop, fullContent=34, reasoning=0

The gateway now answers a thinking request the way it answers its own, through the
same thinkingPayload its own chat path uses. A request that says nothing about
thinking is left alone and keeps the model's default.
… model

Windows told every user their layers went to a Metal GPU. The string was
hardcoded in the panel, while llm.ts had already chosen between the Vulkan
build in bin/llama and the CPU-only fallback in bin/llama-cpu at spawn.

The spawn decision is now the only thing entitled to name the accelerator:
LLMService records which binary took the model and reports it with the
settings it already reports the clamped context window through. The panel
renders that. A box with no Vulkan driver reads that it fell back to the
CPU and that the slider changes nothing, instead of being told about a GPU
it does not have.

Linux ships no engine of ours, so the accelerator resolves to null there
and the hint drops the API name rather than guess one.
"5h ago" sat above "just now" under Today. The query sorts by updated_at and
the row prints updated_at, so they cannot disagree when the list is fetched -
but a conversation whose timestamp is bumped in place after a new message
keeps its old position in the array, and nothing re-orders it.

Sorted where the value is rendered. The position and the words then come from
one read of one field, so no later mutation can put them out of step.
Two parsers for one field. The row's label goes through parseSqliteUtc, which
knows these timestamps are UTC with no zone marker. The grouping read the same
string with a bare new Date(), and a space-separated string with no zone parses
as LOCAL - so position and words disagreed by the whole timezone offset.

At UTC+5:30 that put "just now" underneath "5h ago" and dropped this morning's
conversations into Yesterday. Reported from the machine, where every chat was
minutes old and the list claimed hours.

One parser now decides both.
A model that wrote its tool call inside the thinking block printed the whole
thing at the user:

  <tool_call><function=search_memory><parameter=query>…

The call itself was recovered and executed correctly - the answer said
"Searched your memory, 22 results" - so this was purely a display leak. The
filter that exists for exactly this was applied to content and not to
reasoning, and the comment beside it said so.

Two leaks, one cause. Reasoning from think tags went round the filter, and a
server reporting thinking in its own reasoning_content field skipped the
splitter as well and went straight to the screen.

Each channel gets its own filter instance, because the filter holds
partial-opener state across chunk boundaries and one shared instance would let
a fragment on either channel suppress the other.
The generator wrote {conversationId, projectId} beside an image and the sync receiver wrote
{syncId, conversationId, metadataJson}, so what an image was called on the mesh depended on which
side of the wire made it. An image generated here had no mesh identity at all.

The sidecar module now owns the shape, the read, and the write. The write merges, because the scope
is saved after an image has been given its identity and the old write replaced the file every time.
…ays named

The descriptor was a hand-written literal, so nothing checked it and a peer's refusal arrived as
silence: the Mac went on believing it had sent the picture. It is now built by the same builder the
phone uses, and a refusal is logged.

The identity is also recorded for every image, not only for one made inside a chat. An image from
the tool loop or the gateway needs a name on the mesh exactly as much, and without one the gallery
and the file record called the same picture different things.
… the facts beside it

An image is described twice: when it is made, and again once the chat has persisted the message it
hangs under. Two literals would drift, and the second one would drop whatever the first carried, so
both now call one function that rebuilds the description from the sidecar.

The sidecar therefore holds every fact except the bytes - the time, the size, the generation
parameters - and GeneratedImageScope goes back to meaning only what it says: which chat or project
to narrow the gallery to.
The phone puts a generated image under its chat message only when the file record names the message,
and this Mac never named it, so every peer filed the picture in its gallery and drew a hole in the
chat. The mobile side has been right all along and needed no change.

The message did not exist when the image was described - the chat persists it afterwards - so the
link is made at the one moment it becomes known, on the announcement the chat already sends. The
same syncId goes through the same door again, which updates the record every device holds.

addRagMessage now returns the uuid it mints instead of discarding it. Nothing could name the message
it had just created, which is the reason a generated image came to be named by an absolute path on
one machine.
…e machine's path

context.image held an absolute path on the Mac that made the picture, which is a third answer to a
question the file record already owned and is meaningless on every other device. The reference now
carries both: the id the mesh knows the image by, and where the bytes are on THIS device.

Rows written before it still render - the reader takes either form and reports honestly that an old
row has no id, rather than inventing one.
The images already on disk predate the sidecar carrying an identity. Minting one on the spot without
writing it down renames the same picture on every scan, and a peer cannot tell it from an image it
has never seen. Assigned once and recorded, so the name survives.
… the description

The describe rule and ChatHome both lived here as a second copy of what the phone already had, and
the copies disagreed about the name of the model field. Reading a sidecar beside a PNG is this
platform's business; what a generated image looks like on the wire is not.

So the metadata now goes out under the shared names. It went out as `model` while the phone only
ever read `modelId`, which is why an image made here arrived there reading "synced" with 0 steps.
The negative prompt and the step count travel now as well, because they were simply left out.
The init image was picked from the user's own disk, handed to the generator, and forgotten. Nothing
recorded it, so the moment that file moved or was deleted there was no record of what an img2img turn
was made from - "convert this into light mode" with nothing to show for the thing being converted.

The app now keeps its own copy and records it beside the generated image. Copies live in a `sources`
subdirectory so the gallery, which reads PNGs in the directory itself, does not list an input as
though the user had generated it.
…that used it

"Change this into light mode" arrived on the phone with nothing to show for the thing being converted.
The init image was kept on this Mac and recorded beside the generated image, but it never entered the
mesh: no record, no bytes, and nothing in the wire metadata naming it.

It is an attachment on the user's own turn, so it is now stored as one. That needs no new file kind, no
new metadata field and no new phone UI: handleMessageMutation already shares context.attachments as
message_attachment with the message id, and every device already hangs those under their message.

Kept in app storage before the turn is persisted, because the user's own copy can move or be deleted the
moment the turn ends and its path means nothing on another device. The generation is pointed at the kept
copy too, so there is one file rather than two, and the record of what an image was made from cannot
outlive the file it names.

It is appended to what is PERSISTED, never folded into the attachments the model is given: the init
image is an input to the image runtime, not text for the language model to read.
… stored on it

It was persisted but the in-session message was built first, so the turn looked as though nothing had
been attached until the conversation was loaded again. The phone showed the Mac's own init image while
the Mac did not.

Kept before the turn is drawn now, so one value feeds both what is rendered and what is stored.
addRagMessage returns { id, uuid } now, so the row is looked up by the id it gives back and the uuid it
minted is asserted against the stored one. The point of the change was that a caller can name the message
it just made; this is where that is checked.
… belongs

Appended to the end of the file, they landed below RESOLVED and read as though they were done, which is
the one thing a gaps document must never do. Given ids so they can be referred to and closed.
macOS Accessibility hands us UTF-16. A read that lands mid surrogate pair — an
emoji, a CJK extension character — yields a LONE surrogate. JSON.stringify
emits it as \ud83d, which is valid ECMAScript and not valid JSON text, so
nlohmann inside llama-server rejects the entire body:

  parse error at line 1, column 14297: invalid string: surrogate
  U+D800..U+DBFF must be followed by U+DC00..U+DF

The server then 500s on every retry, forever, because the input never changes.

Repairing at each producing field would be a losing game: any string reaching
the payload can carry one, and on the frame that proved this, the frame's own
text, app, window title, URL and the recent summaries were all clean — the bad
character was somewhere else in the assembled prompt. Only the layer that
builds the request can guarantee the request is well formed, so the repair runs
once, there.
`describeServerError` already knew the difference between a context overflow
and an uncompilable grammar. It flattened both into a string, so every consumer
had to read message text to decide what to do, and capture did exactly that:
it called every refusal `capture-model-unavailable` and retried a permanently
broken request 34 times.

A non-200 now rejects with a typed error carrying the reason the boundary
actually observed:

  unavailable  transport error, timeout, 503 while a model loads
  overflow     does not fit the loaded model's context window
  rejected     the server read this request and refused it
  aborted      the caller cancelled

`unavailable` is the only one that clears on its own. A transport error is
always retryable, because the server never answered — that is never a property
of the request. The message is unchanged, so existing callers reading .message
behave exactly as before.
Every defect found today was a consumer deciding a fact only the owner could
decide. Recorded with the evidence, the decisions Mac made, and the five items
still open — including one I may have caused and have not yet checked.
The first cut of the classifier treated every status except 503 as a permanent
refusal. A 502 is the server failing to service the request, not refusing it,
and an existing capture test — three simulated outages, 503, 503, 502 — caught
it immediately: the frame died on the third instead of recovering.

A 4xx means the server understood the request and refused it. Everything else
is an outage. Deliberately generous, because the two mistakes are not equal: an
outage misread as a refusal loses a frame permanently, while a refusal misread
as an outage costs at most the bounded retry budget. Before that budget existed
the generous reading was dangerous; now it is the safe one.

A body naming a JSON parse or tokenize failure stays a refusal whatever the
status. Those are the server saying it could not READ our request, which the
identical bytes will reproduce forever.
A stale reference makes a passing run look like a regression and a real regression look like noise.
…uite gets it

`@radix-ui/react-use-size` constructs a ResizeObserver in a layout effect, so a UI journey against the
real database threw during commit. React reports that as an UNCAUGHT exception, and the assertion that
followed then failed on text it never had a chance to render: "unable to find: The production bridge
persisted this answer" was a missing global, not a broken bridge.

The shim moves out of the renderer setup into one file both configs name. Guarded on `window`, so it is
inert in every node-environment file in the same suite.
…mething

It was advisory, so it gated nothing, and it spent up to 28 minutes of every run saying so. The specs
added on 2026-08-09 pass on a real macOS display and fail on the Linux runner, because a fixture that
needs Pro's macOS-only native layer cannot be satisfied there - so the red carried no product signal.

The suite now runs through scripts/e2e-on-box.sh. Set the repository variable OFFGRID_CI_E2E=1 to turn
the CI step back on; no code change needed.
@sonarqubecloud

Copy link
Copy Markdown

@alichherawalla
alichherawalla merged commit e7c1bf2 into main Aug 11, 2026
3 checks passed
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.

1 participant