sync + coverage: measure every suite, and open the app at desktop size - #76
Merged
Conversation
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.
|
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.



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
npm run test:coverage, which is the same gate the pre-push hook runs.e2e-screenshots,e2e-coverage); the tour remains advisory while headless-Electron launch stability on the ubuntu runner is the open question, which is recorded indocs/GAPS_BACKLOG.mdalong with the note that it once hid four real spec defects.OFFGRID_E2E_COVERAGEmakes the tour count: 25 specs drive the real app,devices-syncalone 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.One
cijobThis repo used to report two checks.
e2ewas a second parallel job whose first nine steps were byte-identical duplicates ofverify: the same pro checkout with its main fallback, the same shared monorepo provisioning, a secondnpm 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 onecicheck.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
verifytoci.Tests worth calling out
approval-lifecycle.tswent from 0% coverage to covered. Its only tests were.dbtest.tsfiles, 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:The desktop
macos-proximitysuite 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, notest:dbstep), 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.
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
Sequence Diagram
Reviews (3): Last reviewed commit: "test(e2e): stop the Devices specs failin..." | Re-trigger Greptile