sync: ambient directories, receiving rules, and the gates that were red - #625
Open
alichherawalla wants to merge 339 commits into
Open
sync: ambient directories, receiving rules, and the gates that were red#625alichherawalla wants to merge 339 commits into
alichherawalla wants to merge 339 commits into
Conversation
Keep public Core limited to provider-safe device and fingerprint boundaries while private Pro owns raw entitlement credential storage and orchestration.
…ilable placement Sync is a Pro feature, so a journey that exercises it runs on a licensed install. The persistence journey installed a licensed phone in the registry but never set the app's Pro flag, so the phone correctly resolved as unlicensed and never advertised - every assertion after that failed for the wrong reason. The state-sync journey asserted that pairing moves a device OUT of Available, which is the rule that changed: a reachable device stays there with its saved row, and the Saved section disappears when it has nothing left rather than captioning blank space.
…d the pro pointer desktop-cdp.mjs makes the packaged Mac app DRIVABLE. It could not be driven the two obvious ways: macOS refuses synthetic clicks to an ssh session (-25211), and the app publishes no accessibility tree at all - every query returns -1700, so desktop-ssh's labels() cannot read it either. Relaunching with --remote-debugging-port makes the renderer scriptable, and a DOM click is not a synthetic OS event so nothing refuses it. That keeps the REAL profile, licence and device identity, which a Playwright run cannot: it launches its own instance on a throwaway profile and would join the mesh as a different device. connect-android-to-mac.mjs targets the Mac by FINGERPRINT, not name or row order, because the Windows guest advertises itself as macos too - so "the macOS row" is ambiguous on this LAN and a name match can pair the wrong machine. Also bumps the pro submodule pointer, which was left behind: mobile-pro was pushed but the parent still referenced the previous commit, so a fresh clone would have got the app without the pro changes it now depends on.
The journey asserted that a forget left behind a row carrying retry/dismiss. That row is gone by design: a device you have just removed reappearing beside ones you can still connect to read as though the removal had failed. The revocation is still tracked and retried in the background; the assertion now checks the device is absent. Bumps the pro submodule.
The Pro screen already rendered "Device Not Active" while Sync, the upsell suppression and the aha prompts all read the credential alone - so a deactivated device kept working. They now share one selector, which delegates the rule to hasProAccess in @offgrid/sync so mobile and desktop cannot drift on what "still paid for" means.
A device removed from the licence was showing "Device Not Active" above the full "INCLUDED WITH PRO" list with every paid feature working. There is no in-between: you have Pro or you do not, and a device the roster dropped sees exactly what someone who never bought it sees. - ProDetailScreen branches on access, so eviction reverts it to the buy screen - useIsProActive requires access as well as a registered screen, because registries cannot be unregistered and a device deactivated mid-session would otherwise keep every Pro entry point for the rest of the session - loadProFeatures refuses to activate the bundle without access - admission is persisted, so an eviction survives a relaunch instead of being granted again at every cold start - one admission field instead of a boolean shadowing it, which after persistence would have contradicted itself outright
Two driver families, not four: iOS and Android are React Native (WDA, adb) and
macOS and Windows are Electron (CDP). connectSurface({kind}) hands back the same
verbs - openDevices, text, pairingCode, sees, rescan, startPairing,
enterPairingCode, isConnectedTo - so a flow is written once and runs anywhere.
Written because every new bit of sync behaviour was costing a fresh throwaway
script, and each one re-learned the same lessons the hard way.
pair({host, joiner}) is the foundational block: it reads each device's name off
its own screen, skips a link that is already up, shows the code on the host and
types it on the joiner. pair-devices runs one route, mesh-routes runs them all
and reports each independently, because a sweep that stops at the first failure
hides the other answers.
Verified live today: ios -> windows, android -> macos, macos -> windows.
… name controlFor scanned labels forward from the device name. Android renders name-then-actions but iOS renders the actions first, so a saved Mac on the iPhone reported 'lists no pair/repair control' when it was one sync-reconnect tap away. Take the nearest match in both directions, as nameOf already did. mesh-routes carried its own endpoints and defaulted Windows to :9223 while the tunnel had moved to :9224, so a healthy app read as a dead one. Endpoints now live in mesh-config, overridable by env or flag. nameOf moves into the surface layer as deviceName, one implementation per driver family, so a flow never has to know how a platform spells its own name.
Same gap as the desktop: the engine's disconnect notification never reached the orchestrator, so a saved peer that lost its session was never retried.
… the list run-flows.mjs owns ordering and reporting, flows/ own the journey, sync-surface owns the platform. A flow says "forget, then pair, then assert" and cannot tell whether it is driving WDA, adb or CDP; adding a platform touches only the surface, adding a flow only flows/. Selectors move to selectors.mjs so no flow contains a string that has to match the product. The surface now knows about confirmation sheets, which it has to. Every RN read works off the flat accessibility label list, and an open sheet REPLACES that list - so "is this device still connected?" answered false while the device was perfectly connected. Forget tapped a guessed testID and swallowed the failure, leaving the real "Evict device" sheet untouched; the check that followed read the covering sheet, concluded the credential was gone, and the route would have passed without one being dropped. openSheet() makes the sheet visible to the surface, confirmDestructive() requires the confirmation rather than hoping for it, and the runner dismisses any sheet a failed route leaves open so the next flow does not start by reading the wrong screen.
…f the network The promise this covers is the one a person feels rather than drives: you walk out of range, you come back, and the devices are talking again without you opening anything. So the assertion window contains no taps at all - a flow that presses Reconnect passes on a build where nothing heals itself, which is exactly the state this codebase was in. goOffline(ms) is one self-restoring verb rather than an off switch and an on switch, because the desktops are driven THROUGH the network they are being asked to leave: turning it off over ssh would cut the channel that would later turn it back on, and strand the box until somebody walked to it. The machine schedules its own return before it goes. Android is the same shape for consistency, iOS throws with the reason rather than skipping quietly. Verified live: the Mac saw the Android drop, and with nothing touched, reconnected in 5s.
isConnectedTo sliced labels forward from the device name, so on an iPhone - which renders the status line before the name on some rows and nine labels after it on others - a connected Mac reported as not connected. The same directional bug controlFor had. Take the NEAREST status line in either direction and read what it says, rather than asking whether a Connected line exists nearby: rows sit next to each other, so a window wide enough to find this row's status can also find the next row's. The window is 14 because a connected row carries three more controls than a disconnected one, which pushes its status past a window sized on the disconnected case - the exact reading that made a live mesh look half down.
Two journeys waited for the word "Discoverable" to know the Sync screen had arrived. Neither is about discoverability, and the card no longer prints that word when the device is simply discoverable - the switch beneath it says so. Wait for the device card and the pairing code instead, which are on the screen whatever the mesh is doing.
"Peers stop seeing it" is only observable against a device that is NOT already paired - a paired peer keeps its link whether or not the advertisement is running, because hiding is about being found, not about being reachable. The card says so itself: "Discoverable to new devices". With every pair in the mesh already paired, the testable half is the guarantee underneath, and it is the one worth guarding: a reader could plausibly implement hiding by tearing down the transport, and every existing link would go with it. isDiscoverable reads the card's STATUS LINE rather than the switch. The switch's value is not in the label tree on both phones - iOS emits a "1"/"0" beside the testID and Android emits nothing - so a reader built on it works on one phone and throws on the other. The status line is a contract: the card prints it only when it says something the switch does not, so a discoverable device shows none and a hidden one names itself. The flow restores the setting it found, in a finally. Discoverability persists across restarts, so a flow that fails half way through and leaves a device hidden has changed a user's setting - and the next flow would fail to discover anything for a reason nowhere in its own output. Verified live: Android hidden 45s, the Mac kept the link throughout, setting restored.
The gentle counterpart to forget, and the one flows should reach for. Forget frees a licence seat and ends trust everywhere - it cost a real pairing and a seat when a flow used it as a routine teardown. Disconnect only closes the link: the pairing survives, the device comes back with a tap rather than a code, and nothing about the licence moves.
… its projector One 508 MB download had 35 rows in the picker. The store identified a model by its ABSOLUTE path, and on iOS that path lives under a container UUID that changes on every reinstall - so after each install the scan found no row matching the file's new path, called it untracked, adopted it again under a fresh `recovered_<name>_<Date.now()>` id, and left the old row behind. 35 rows, 29 app starts, four days. Timestamps in the id made it worse: the same file adopted twice produced two ids nothing could reconcile. A file name is unique within the models dir, which is why it is the stable key - the conclusion useTextModels.ts had already reached for its own display matching, applied here where rows are created. The scan now REPAIRS a known row's path instead of adopting a second one, ids are derived from the file name alone, and load collapses existing duplicates to one row per file, preferring a real catalog id over a recovered one and then the earliest download. Rows are dropped, never files. Adoption also links the projector now, via the strict name+variant rule the loader already uses. It was skipping mmproj files entirely, so a recovered vision model came back as text - the primary alone, 508 MB where the package is 706 MB. Note for whoever gets there next: scanForUntrackedImageModels has the same path-identity flaw and the same `recovered_<name>_<Date.now()>` id, untouched here.
`passive` on the phone surfaces: attach to whatever is on screen, launch nothing. A WDA session created WITH a bundle id ACTIVATES that app, which terminates what it was doing - and the snapshot tool, which described itself as "read-only by design", called exactly that on every step. It killed a 706 MB model transfer mid-receive. Android's session() relaunches to the foreground for the same reason and is now skipped too. The honest limit is recorded in GAPS_BACKLOG: even a bundle-less WDA session appears to deactivate the foreground app, so `passive` will not relaunch but cannot be trusted not to disturb. While a person is driving a device, ask them for a screenshot instead - it is the only genuinely free observation on iOS. rules.md points at the canonical SSOT debugging rule in shared/.
scan.ts crossed the 500-line cap adding the projector link, and the cap was right: one file was doing crash recovery, projector linking, image-zip reconciliation AND importing a file the user picked. Local import is the odd one out - it brings in a file from OUTSIDE the models dir, where everything else adopts what is already inside - so it moves to importLocalModel.ts with its own options type. parseSizeInt is exported rather than copied, because both files need it and a second copy of a parser is a second source of truth for how a size is read.
The image registry answered "is this model still on disk" with its own copy of the text registry's logic, and the copy had drifted: it used a raw RNFS.exists where the text loader uses the verifiable probe. That is the G1 data-loss guard protecting exactly half the registry. Consequences on the image path: - a transient FS reject rejected the whole Promise.all, threw the load, and handed the caller an EMPTY registry - the scan then re-adopted every directory, so catalog rows were replaced by `recovered_` ones and anything holding the previous id dangled - an unverifiable probe pruned the row and PERSISTED the prune, silently unregistering a valid multi-GB model Extracted to reconcileStoredPaths.ts, now the single home of basenameOf, resolveStoredPath, probeExists and reconcilePrimaryPaths. Both loaders call it; neither owns a copy. collapseDuplicateFileRows generalised to collapseRows with file- and directory-keyed wrappers, so the image registry gets the same repair for rows already grown under the old ids. Image scan identity is now the directory NAME, not the absolute path, and the adopted id is `recovered_<name>` with no Date.now() — the same conclusion the text scan reached, applied where image rows are created. Typecheck clean, lint clean, 350 modelManager tests green. NOT device-verified.
nativeSync takes one resolver and feeds BOTH halves from it - the engine answering inbound requests and the orchestrator initiating outbound ones - so this device cannot offer a codeless join it would itself refuse. Bumps pro for the host adapter.
|
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.



Your phone and your Mac become one device you can trust: files, chats, clipboard and models move between them by themselves, over your own network, and nothing leaves either device that you did not agree to.
293 commits, 335 files, +39,567 / -10,225.
What this gives you
Your devices find each other and stay paired. Discovery over the LAN with a persistent device name, a code you confirm on the other screen, and pairings that survive an app restart, a reinstall and an OS upgrade. Android stops advertising a LAN route it cannot actually dial, so a row never says reachable when it is not.
Files arrive on their own, but only the ones you chose. Screenshots and downloads share ambiently per source and per destination, with "auto", "ask me" and "off" obeyed exactly. Media access is requested at the moment you turn screenshot sharing on, not at launch. A synced files library holds what arrived, attributed to the device that sent it, and tells "we have this" apart from "we know about this" so Open and Share are never offered on a file that is gone.
The clipboard follows you, opt-in. Copy on one device, paste on the other, with the origin device preserved so you can see where a snippet came from. Bridged natively on Android, with guided access on iOS.
Chats and projects converge. A message that arrives from another device shows up when it arrives, not when something else happens to reload. Received messages keep the tools they were offered. Project knowledge bases accept pasted text directly.
Models transfer between devices. A model you already downloaded on one device can be sent to the other and is admitted as a real installed model, checksum-verified, rather than re-downloaded over cellular.
You decide what lands. Per-device receiving rules, a clipboard gate, and rules that are cleared on unpair so an id reused by a future device never inherits a decision you made about a different one.
Licensing and the device cap. Entitlement bootstraps during pairing, revalidates on launch, normalises a pasted key, and replaces the least-recently-used seat when you hit the cap instead of refusing.
Verification
jest --coverage --forceExit --runInBand.:app:testDebugUnitTest) and iOS tests run in CI.__tests__/device/meshPairing.e2e.mjspairs an iPhone and an Android device on the real network and asserts each one shows the other, and that neither claims a relationship the other denies.srcat 80 on every metric,./proat 80 on statements/functions/lines and 79 on branches, which is where pro genuinely measures (79.44% of ~4,700 branches). Reaching 80 on branches needs about 78 more covered branches inttsService,mcp/oauth metadataandknowledgeDocumentSyncService; that is real work, not a rounding nudge, so the floor is pinned just under the measured value rather than at a number nothing satisfies.CI, and why it was red
Four separate causes, none of them a failing test:
npm run lintchained./gradlew :app:lintDebug, which cold-configures every React Native native module on a macOS runner. CI now runsnpx eslint .; Android Lint is a local pre-merge gate, the same call this workflow already documents for the Android build. Android unit tests still run here. Expect roughly 22 minutes instead of 90.jest.resetModules(). The harness now stops in-flight generation on teardown; the whole integration and rntl set (2,236 tests) then passes repeatedly with zero failures.One
cijob reports for this repo, matching the other three.Tests worth calling out
The doctrine here is integration over mocks, with fakes only at genuine device boundaries. Every mock in the sync test surface of this release is a real boundary: native TCP, native mDNS, the filesystem, the keychain, the document picker. There are no mocks of our own code in the new sync tests.
Where older suites did mock our own code, they were deleted rather than repaired, and the journeys they claimed were rewritten against the real thing:
generationFlow.test.tsfedonStreamitself, so the test was the model. 12 of its 15 cases were already covered by rendered suites; the two that were not are now real, asserted at the native engine.imageGenerationFlow.test.tswas 60 tests over a stubbed image generator, six of them named after line numbers. What it never covered is the window a user actually sits in: STOP reaching the native generator, progress moving on the card, and a second send not starting a second diffusion.ragFlow.test.tsmocked the DATABASE by matching SQL strings. Retrieval "found" whatever the matcher returned. Prompt-budget truncation and project scoping are now asserted over a real in-memory SQLite, including that a search never returns another project's documents.Three sync modules that had no test at all are now covered: mesh residency policy (a refused foreground service must not fail sync start),
availableSyncIds, andforgetDeviceRules.Known gaps, recorded not hidden
docs/GAPS_BACKLOG.mdcarries the open items, including: ejecting a model mid-reply unloads the engine without stopping the generation (measured: nativeunloadModel1, nativestopGeneration0); the ChatScreen journeys left uncovered by deleting a 155-case mockist suite, with the measured 8-point drop and the four named journeys; and the image-generation journeys not yet rewritten.Greptile Summary
This release substantially expands cross-device synchronization, pairing, receiving controls, licensing, model transfer, clipboard sharing, and chat convergence while consolidating CI verification.
Confidence Score: 5/5
The PR appears safe to merge because no eligible blocking failure or outstanding prior finding is established.
No blocking failure remains.
Important Files Changed
Sequence Diagram
sequenceDiagram participant A as Sending device participant D as Discovery and pairing participant R as Receiving rules participant T as Encrypted transfer participant B as Receiving device A->>D: Advertise stable identity B->>D: Discover and confirm pairing code D-->>A: Persist trusted peer D-->>B: Persist trusted peer A->>R: Announce clipboard, file, chat, or model R->>R: Apply peer and content-specific policy alt Receiving allowed R->>T: Authorize transfer T->>B: Send encrypted payload B->>B: Verify checksum and materialize B-->>A: Record completion else Ask or off R-->>B: Prompt or suppress transfer endReviews (3): Last reviewed commit: "fix(sync): make a failed receive discard..." | Re-trigger Greptile