| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
iOS: don't lose saved hosts/IPs on upgrade (paired-Mac backup + restore) (#6405) * ios: failing test — paired-Mac store strands data on future schema version Adds the paired-Mac backup/restore design doc and a red regression test: when an older build opens a paired-macs.sqlite3 whose user_version was bumped by a newer build, the store currently throws unknownSchemaVersion and every read fails, surfacing as total loss of the user's saved hosts even though the rows are still on disk. The fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: don't strand the paired-Mac store on a newer on-disk schema version runMigrations threw unknownSchemaVersion when user_version exceeded this build's, failing ensureReady and every read — so a user who upgraded (future schema vN) and then ran an older build saw all saved hosts as gone, though the rows were intact. Schema migrations are additive by contract, so older builds can still read the columns/tables they know. Degrade gracefully: log and read existing rows, never reset user_version (no destructive downgrade marker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: per-user pairedMacs backup collection (server) Adds the first client-owned sync collection. The phone backs up its local saved-host list (including manually typed host/IPs, which live only on-device today) so it survives an app upgrade, bundle-id change, or reinstall. - New POST /v1/sync/paired-macs route → DO RPC backupPairedMacs(teamId, userId, ops), mirroring the trusted heartbeat RPC rather than expanding the live WS inbound surface. - Per-user privacy scoping by physical collection name pairedMacs:<userId> (userId is verified, never client input); outgoing frames are relabeled to the logical `pairedMacs` so the client never sees the suffix. Reuses the whole generic snapshot/delta/tombstone/GC machinery unchanged. - Subscribe forwards the verified x-presence-user-id; the DO pins it on the WS attachment and serves/broadcasts pairedMacs scoped to that user. - Per-user record cap, op bounds, route byte budget (mirrors heartbeat). - bun tests: parse bounds, per-user isolation, cap, relabel, tombstone, no-op idempotency. Full suite 144 pass; typecheck + wrangler dry-run clean. Additive and live-safe: new collection keys only, no class migration, old DO instances ignore the new RPC/collection during rollout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: GET /v1/sync/paired-macs restore path Adds the read side of the per-user backup: DO RPC listPairedMacs returns the live (non-tombstone) saved-host records newest-first, served by GET on the same authenticated, user-scoped route. The phone fetches this on sign-in to restore saved hosts after a reinstall or bundle-id change. Decouples restore from the WS sync client (which is built but not yet wired into the live app). bun test for list ordering + per-user isolation; strict test typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: shape-aware equality for pairedMacs (no rev churn on timestamp drift) A backup upsert whose routes/name/active are unchanged but whose lastSeenAt advanced (every route refresh, and every full reconcile push on sign-in) must not re-mint a rev or broadcast a delta. Compare list-shape only, ignoring timestamps, mirroring the device-list collection. Stored lastSeenAt then tracks the last shape change (correct as-of-rev semantics for restore ordering). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: paired-Mac backup uploader + restore-on-sign-in Wires the iOS side of saved-host durability behind the mobilePairedMacBackup flag (DEBUG-on/Release-off, env/UserDefaults overridable): - PairedMacBackupClient: HTTP client for /v1/sync/paired-macs (POST ops, GET restore), auth mirrors PresenceClient/DeviceRegistryService. - BackingUpPairedMacStore: a MobilePairedMacStoring decorator so EVERY paired-Mac mutation flows through one seam — upsert/remove mirror to the DO best-effort (local stays authoritative); the sign-out wipe (removeAll) is NOT mirrored so the server backup survives for the next sign-in. - PairedMacRestore: on the first signed-in read, merge the backup into the local store — LWW by lastSeenAt (never clobber a newer local edit), insert missing hosts, and honor the backup's active host only when local has none (fresh install), so restore never hijacks the device's current active selection. - Composition root wraps the local store with the decorator when the flag is on and a presence URL resolves. Restore goes over HTTP (GET) rather than the WS sync client, which is built but not yet wired into the live app, so this feature is self-contained. No new user-facing strings (silent background backup/restore). swift test: 7 new tests pass (decorator mirroring, restore LWW/active rules, flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make PairedMacRestore an injectable struct (package conventions) The iOS package-conventions lint forbids caseless enums with only static members (namespace-enum/namespace-type). Convert PairedMacRestore to a struct that takes the store + backup as injected dependencies with an instance run(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: address review — restore memoization, retry, team scope, setActive mirror Fixes from Cursor Bugbot / CodeRabbit / Greptile on the backup decorator: - removeAll (sign-out wipe) now resets the restore memo, so a same-launch re-sign-in restores again instead of returning an empty list (this was the exact sign-out→sign-in path; it was silently broken). - fetchAll returns nil on transport/auth failure (vs [] for genuinely empty), and restore is memoized only on a successful fetch — a transient first-launch failure now retries on the next read instead of stranding restore until restart. - Restore is scoped per (account, team), not per account: the backup DO is per-team, so switching teams re-restores (teamIDProvider injected). - Concurrent first reads share one in-flight restore Task, so a second read can't slip past the memo and observe a half-merged store. - setActive now mirrors the affected account scope to the DO (accurate records read back from the local store), so "select a host without connecting, then reinstall" no longer restores a stale active host. markActive upserts mirror the scope too, preserving the single-active invariant in the backup. - remove only mirrors a delete while signed in (no auth-failing noise for anonymous removals). - Migration test asserts user_version is left untouched (no downgrade marker). swift test: 11 backup + 5 migration tests pass; package-conventions lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): auto-publish this Mac's route to the user's pairedMacs backup DEV-only convenience so a fresh dev iOS build never needs a manual host entry. MacPairedMacBackupPublisher (DEBUG-on, env/UserDefaults overridable) registers the iOS-pairing-listener default on (so an attach route exists without toggling a setting), observes MobileHostService.statusUpdates(), and POSTs this Mac's deviceId+displayName+routes (active) to /v1/sync/paired-macs whenever routes change and the user is signed in. Routes are encoded via CmxAttachRoute so the iOS restore decodes them identically. Best-effort and Release-noop, mirroring PresenceHeartbeatClient. Bridges the dev gap where the registry (localhost) and presence devices projection don't deliver the Mac's route to the dev iOS build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): default iOS-pairing listener ON in DEBUG; drop runtime register The dev self-publisher needs the pairing listener bound so an attach route exists. Registering a UserDefaults fallback at runtime was clobbered by the settings runtime registering the catalog default, so move the default to the source: MobileCatalogSection.iOSPairingHost defaults true in DEBUG, false in Release (an explicit user toggle still wins). Release behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac: wire MacPairedMacBackupPublisher.swift into cmux.xcodeproj The new file was never added to the Xcode project, so it didn't compile, the AppDelegate reference was an undefined symbol, and every macOS build failed (reload-cloud kept the stale binary; CI would fail too). Add the four pbxproj entries (PBXBuildFile + PBXFileReference + Cloud group + app-target Sources phase), mirroring PresenceHeartbeatClient.swift. Verified: the dev Mac now auto-publishes its route to the user's pairedMacs backup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh AppDelegate Swift file-length budget for the publisher wiring The one-line MacPairedMacBackupPublisher.shared.configure(auth:) call (+ its comment) at the composition root grew AppDelegate.swift by 4 lines, tripping the file-length budget guard. Accept the minor known debt: the wiring belongs next to the other client configures. 17593 -> 17597. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: surface restored saved Macs on the disconnected screen Restoring saved Macs into the local store wasn't visible: the disconnected screen only auto-reconnected and otherwise jumped straight to "add device", so a restored Mac (e.g. on a fresh dev build, or when auto-reconnect can't reach it) never showed. Now the disconnected screen loads saved Macs (which also triggers the backup restore) and lists them for one-tap reconnect, only auto-presenting the pairing sheet when there are none to pick. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tapping a saved Mac dialed the phone's own loopback instead of Tailscale A restored/published Mac advertises both a debug_loopback route (127.0.0.1, priority 0) and a tailscale route. DEBUG builds keep .debugLoopback in supportedRouteKinds even on a physical device (for the on-device XCUITest mock host), so firstReconnectHostPortRoute, which picks the lowest-priority supported route, chose 127.0.0.1 — the phone's own loopback — and the connect silently failed without ever trying Tailscale. That made tapping a saved/restored Mac (switchToMac) and stored-Mac reconnect not connect on a device. Fix in route selection, not supportedKinds (XCUITests still need loopback): add preferNonLoopback (true on physical devices, false on the simulator where 127.0.0.1 IS the Mac). When set, a real route always wins over a .debugLoopback route regardless of priority; loopback is used only when it's the sole supported route. Tests cover device-prefers-tailscale, device-loopback-only fallback, and simulator-keeps-loopback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P1): tag workspaces with their Mac (macDeviceID) Foundation for the aggregated multi-Mac workspace list + machine filtering. Adds macDeviceID to MobileWorkspacePreview (additive, defaulted) and stamps it from the connected Mac's ticket where the workspace list is built. Invisible today (single Mac), but every workspace now records which Mac it's from, which P3 (aggregation) and P4 (group/filter by machine) build on. Design in plans/feat-ios-multi-mac-workspaces/DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4a): compound workspace filter (read-state × machine) Replaces the single-dimension All/Unread filter enum with a composable struct: readState (all/unread) × machines (Set<macDeviceID>, empty = all), passing both only when a row satisfies both. Expresses "unread on Mac X and Mac Y" directly. The filter menu gains a machine multi-select section that appears once more than one machine is present (single-Mac users see the unchanged All/Unread control); the list views compile unchanged since .all/.matches/.isActive/.emptyStateText are preserved on the struct. en+ja localized. 6 model tests incl. the compound case. Machine names are wired in once aggregation (P3) provides multiple Macs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4b): machine-list derivation + prune for the filter Pure, tested helpers the filter UI and aggregation need: machineIDs(in:) gives the distinct machines present in a workspace list (first-appearance order, skips unknown-machine rows) to populate the filter's machine multi-select, and pruneMachines(notIn:) drops selections for machines that vanished so a stale machine filter never silently hides everything. Full model suite 52 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P2): per-Mac connection pool foundation Introduces MacConnection {macDeviceID, ticket, route, client, generation} and a connections:[macDeviceID:MacConnection] pool + foregroundMacDeviceID on the composite. The foreground attach now records its entry in the pool and teardown clears it. Additive and behavior-preserving (single-Mac == a pool of one); anonymous (empty-id) tickets are not pooled. This is the structure P3 builds on to open read-only connections to the user's other Macs and aggregate their workspaces. Compiles; route + backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3a): read-only secondary-Mac workspace fetch fetchSecondaryWorkspaceList(for:) opens a short-lived client to another paired Mac (reusing the manualHostTicket + workspace.list path, loopback-deprioritized on device) and returns its workspaces tagged with that Mac's macDeviceID, never touching the foreground connection. refreshSecondaryMacWorkspaces() populates secondaryWorkspacesByMac for every signed-in non-foreground Mac. Additive: not yet merged into the published list, so the single-Mac flow is untouched. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3b): merge other Macs' workspaces into the list (flag-gated) Foreground connect now kicks a background refreshSecondaryMacWorkspaces(), and publishAggregatedWorkspaces() merges the other Macs' rows after the foreground Mac's (de-duped by id, per-Mac order preserved). Gated by multiMacAggregation (env/UserDefaults, DEBUG on / Release off) and a no-op when there are no secondaries, so the single-Mac list is byte-for-byte unchanged. Cleared on teardown. Compiles; route/backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4): surface the machine multi-select in the filter WorkspaceListView derives the machines present in the (aggregated) workspace list and passes them to the filter menu, so the read-state × machine compound filter's machine section appears once more than one Mac has workspaces. Names come from the device tree; single-Mac shows the unchanged All/Unread control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P5): cross-Mac open switches the foreground connection openWorkspace now detects when the tapped workspace belongs to a Mac other than the current foreground connection (aggregated list) and switches the foreground to that Mac before selecting, so the terminal attaches to the right Mac. Gated by multiMacAggregation; no-op for single-Mac. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh MobileShellComposite file-length budget for multi-Mac code The P2-P5 multi-Mac connection pool + aggregation + cross-Mac open added ~196 lines to MobileShellComposite.swift. The methods call private connect/ticket helpers so they can't move to a separate-file extension; accept the known debt. 5566 -> 5762. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh paired-Mac routes from backup before multi-Mac aggregation The aggregated multi-Mac workspace list only showed the foreground Mac's workspaces because secondary Macs' stored routes went stale: refreshSecondary- MacWorkspaces read the local paired-Mac store, but that store is only restored from the backup once per launch (memoized scope). When a secondary Mac relaunched on a new port and republished its route to the per-user backup, the iPhone never re-read it, so the read-only workspace fetch dialed a dead port and that Mac silently dropped out of the list. Fix: add PairedMacBackupRefreshing.refreshFromBackup(stackUserID:) on BackingUpPairedMacStore, which forces a backup re-fetch + LWW merge (bypassing the once-per-launch memo, coalescing with any in-flight restore). refreshSecondary- MacWorkspaces calls it before loadAll, so secondary routes are current before the fetch. LWW by lastSeenAt means the live foreground route is never clobbered. Principled: routes are kept fresh from the authoritative per-user backup at aggregation time, instead of relying on a single sign-in-time restore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect first reachable Mac so home opens on the integrated list The home fell back to the "Your Macs" picker whenever there was no Mac marked active (or the active Mac's stored route was stale), forcing a manual tap before any workspaces showed. Rework the launch auto-connect so the home comes up connected to all Macs and shows one integrated list, without the picker: - Refresh saved-Mac routes from the per-user backup before dialing (LWW), so a Mac that relaunched on a new port is still reachable instead of failing to the picker. - Connect the explicitly-active Mac when reachable, otherwise the FIRST saved Mac with a usable route, instead of bailing when nothing is marked active. The other Macs are aggregated read-only (refreshSecondaryMacWorkspaces) into the same list, so the home is one integrated cross-Mac workspace list. The picker now only appears as the genuinely-offline fallback (no saved Mac has a usable route). Principled: auto-connect targets any reachable saved Mac with fresh routes, rather than depending on a single persisted "active" selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh routes from backup before manual Mac switch too switchToMac dialed the in-memory snapshot's routes, so manually switching to a Mac that had relaunched on a new port could fail on a stale route. Apply the same backup-refresh used by auto-connect and aggregation: refresh the per-user backup, re-read the target from the store, then dial its fresh route (falling back to the snapshot if the re-read yields nothing). Completes route-freshness across every saved-Mac connect path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: prefer IP-literal routes over MagicDNS hostnames for Mac connect/aggregation The multi-Mac aggregated list showed only the foreground Mac because the read-only secondary fetch to another Mac timed out. Root cause (confirmed on device via console diagnostics): a Mac can advertise three attach routes — debug_loopback, a MagicDNS hostname (e.g. <node>.<tailnet>.ts.net), and the raw tailscale IP. firstReconnectHostPortRoute picked the first non-loopback route, which was the MagicDNS hostname. MagicDNS doesn't resolve on every client (the phone here), so the attach-ticket request to the hostname timed out and that Mac was silently dropped from the aggregated list. A Mac that only advertises an IP route (no hostname) connected fine, which is why one Mac showed and the other didn't. Fix: among non-loopback routes, prefer one whose host is a numeric IP literal (IPv4/IPv6) over a hostname, since an IP is dialable without DNS. Falls back to a hostname route when no IP route exists, and loopback only as last resort. firstReconnectHostPortRoute is the shared selector for reconnect, manual switch, and secondary aggregation, so this fixes tap-to-connect to hostname-route Macs too. Added isIPLiteralHost + 3 route-selection tests incl. the exact repro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: re-aggregate other Macs on pull-to-refresh and app foreground The aggregated multi-Mac list fetched each other Mac's workspaces once, on foreground attach. Workspaces created on a secondary Mac afterwards never appeared, because the read-only secondary list is a snapshot, not a live subscription (only the foreground Mac streams workspace.updated). Re-run refreshSecondaryMacWorkspaces from the two natural refresh points: - refreshWorkspaces() (pull-to-refresh) now re-aggregates after reloading the foreground list. - resumeForegroundRefresh() (app returns to foreground) re-aggregates when connected, so switching back to the app surfaces newly-created remote workspaces without a manual pull. Both gated on multiMacAggregationEnabled + an active foreground connection, so single-Mac behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: add transport-agnostic per-Mac workspace state + pure derivation Foundation for deriving the aggregated multi-Mac workspace list from a single source of truth instead of imperatively merging a live foreground list with stale secondary snapshots. MacWorkspaceState is the phone's view of ONE Mac's workspaces (workspaces + groups + liveness), keyed by macDeviceID, carrying NO transport/connection detail. MobileWorkspaceAggregation derives the flat ordered de-duplicated list (foreground first, then by display name) and the group sections as pure functions of [macID: MacWorkspaceState]. Same model + derivation whether each entry is fed by N direct phone->Mac connections (now) or one phone->Durable Object stream delivering per-Mac deltas (planned), so that migration is a transport swap, not a data-model change. 6 derivation tests. Not yet wired into the composite (next commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: derive the workspace list from per-Mac state (slice 2) Wire the transport-agnostic data structure in: workspacesByMac is now the only stored workspace state, and `workspaces`/`workspaceGroups` are materialized derivations (private(set), assigned only by recomputeDerivedWorkspaceState). The foreground sync stream, secondary fetch, optimistic create-workspace/ terminal, preview, and reset all write per-Mac entries; the derived list recomputes via didSet. Anonymous/manual-ticket foreground uses a sentinel key. Deletes the two-sources-of-truth machinery: publishAggregatedWorkspaces (the re-merge band-aid) and secondaryWorkspacesByMac (the snapshot store). The foreground-update-overwrites-then-re-merges race is gone by construction: each Mac owns its entry, the aggregate is a pure function of them. clearRemoteConnection- Context keeps the offline foreground entry and drops only secondaries. Tests: 56 model+composite tests green (incl. new derivation + create/terminal/ preview paths). Test seam setWorkspacesForTesting replaces direct workspaces assignment. The 6 remaining failures are the pre-existing flaky render-grid timing tests, unchanged by this commit. Next (slice 3): per-Mac live workspace subscriptions feed workspacesByMac so remote-created workspaces appear with no refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: live per-Mac workspace subscriptions (slice 3) Each non-foreground Mac now holds a persistent read-only connection with its own live workspace.updated subscription that re-fetches its list on each change and writes its workspacesByMac entry, so a workspace created on another Mac appears with no pull-to-refresh. The derived list recomputes automatically. SecondaryMacSubscription holds the client + a fresh per-connection stream id + the consumer Task. refreshSecondaryMacWorkspaces is now an idempotent reconciler: establish a subscription for each newly-present secondary Mac, drop ones that disappeared or became the foreground. Fully best-effort and additive: any failure (no route, ticket/connect error, stream end) tears that entry down and the pull-to-refresh / foreground re-aggregate path remains the fallback, so a secondary subscription can never crash or block the foreground. Subscriptions are torn down on disconnect/sign-out (teardownSecondaryMacSubscriptions in clearRemoteConnectionContext). This is the N-persistent-connections model approved for now; the same per-Mac entries would later be fed by one phone->Durable Object stream (transport swap, no data-model change). 62 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: never show the Your Macs picker when Macs are saved The auto-connect made the home connect, but the root still fell back to the DisconnectedWorkspaceShellView picker whenever the foreground was not yet connected (initial connect window, or a failed/slow reconnect). Eliminate that: show the integrated workspace list whenever there are saved Macs, auto-connecting in the background, and only show the add-device flow when there are NO saved Macs at all. The list renders whatever has aggregated (foreground + live secondary subscriptions) and its toolbar carries settings/devices/sign-out, so nothing is lost by dropping the picker. Opening a workspace attaches its Mac on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect falls through to the next Mac when one is offline reconnectActiveMacIfAvailable picked a single target (active Mac, else first with a route) and connected once; if that Mac had a stored route but was actually down, the connect failed and the home showed "Mac offline" without trying any other reachable Mac. Build an ordered candidate list (active first, then every other Mac with a usable route) and try each via connectManualHost until one connects, so a single offline Mac never blocks the others. The restoring-gate deadline still caps the UI; the loop keeps trying in the background. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview findings (active-mac deactivation, stale secondary refresh, backup body cap) P1: PairedMacRestore deactivated the currently-active Mac when refreshFromBackup brought a fresher record for it (route refresh before reconnect/aggregation), losing the user's selection. Preserve the existing local active flag when updating an existing record; only honor the backup's active for records missing locally on a fresh install. Regression test added. P2: refreshSecondaryMacWorkspaces (foreground/pull) skipped Macs that already had a subscription, so a suspended/never-pushing secondary stream left a stale snapshot forever. Explicit refresh now reseeds existing secondary clients (and recreates dead ones), so a pull/foreground always updates the aggregate. P2: the paired-Mac backup POST reused the 16 KiB heartbeat cap while accepting up to 200 ops x 2 KiB routes, so legitimate backups 413'd and the best-effort client silently dropped them, staleing the server backup. readBoundedJson now takes a maxBytes; the backup route uses MAX_PAIRED_MAC_BACKUP_BYTES sized to the declared limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 2 (sign-out restore race, stale machine filter blanks list) P1: BackingUpPairedMacStore.removeAll (sign-out wipe) cleared the inFlight map but did not cancel the restore tasks, so a backup fetch suspended across the wipe could resume and re-upsert the previous account's Macs into the emptied local store (privacy boundary). removeAll now cancels in-flight restores, and PairedMacRestore.run checks Task.isCancelled after its fetch and skips all writes. Regression test added. P2: the machine filter was never pruned, so when a filtered Mac left the aggregated list (a secondary disconnected, or fewer than two machines so the filter menu's machine section hid) the stale machine id rejected every row and stranded the user on a blank list with no visible control to clear it. This is the likely "blank black screen" after reconnect churn. WorkspaceListView now prunes filter.machines whenever the present machine set changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-machine avatar color + fix autoreview round 3 (wrong foreground key, secondary refetch storm, offline dead-end) Feature: workspaces from the same Mac now share one avatar color in the aggregated list (MachineAvatarPalette, keyed to macDeviceID with a workspace-id fallback and djb2 spread); the symbol still encodes terminal count. Unit-tested. P1: applyRemoteWorkspaceList wrote the foreground Mac's workspaces under the PREVIOUS foreground key because foregroundMacDeviceID was assigned after the apply. On a Mac A->B switch this stored B's list under A's key and the derived list went stale/empty once the id flipped. Set foregroundMacDeviceID before applying. P1: every secondary workspace.updated push awaited a full workspace.list with no coalescing, so a title/progress churn stream queued repeated full scans and MainActor aggregate updates. Added a per-Mac leading+trailing coalesced refresh (SecondaryMacSubscription.refreshTask/refreshPending) — bounded, no cancel/restart starvation. P2: an offline returning user whose auto-reconnect failed fell through to a workspace list whose only affordance (pull-to-refresh) no-ops while disconnected, with no reconnect control — a dead end. Added store.reconnectOrRefresh (reconnect when offline, refresh when connected), wired pull-to-refresh to it, and added a Reconnect button to the offline status row (localized en/ja). Keeps the integrated list as the only surface — no picker screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 4 (sign-out aggregation race, backup freshness on republish) P1: refreshSecondaryMacWorkspaces captured the account then awaited backup refresh, store load, client creation, and per-Mac fetches before mutating secondaryMacSubscriptions/workspacesByMac, while callers launched it in untracked Tasks. An in-flight pass could resume after sign-out/account switch and write the previous user's Macs/workspaces into the new UI. Added an isAggregationScopeValid guard (signed-in + same account + not cancelled) re-checked after every await before any mutation/connection, routed the pass through a tracked secondaryAggregationTask, and cancel it (plus tear down live secondary subscriptions) on sign-out and full reset. P1: a same-shape backup republish (Mac re-confirming its current live route) no-op'd without advancing the stored lastSeenAt, so the iOS LWW restore skipped the backup and kept dialing a stale local route. upsertRecord gained an opt-in freshnessOf; the paired-Mac path now refreshes lastSeenAt in place (same rev, no delta/broadcast) so restore sees the republish as fresh. Test extended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 5 (unscoped aggregation read, restore-memo race, unbounded paired-Mac tombstones) P1: refreshSecondaryMacWorkspaces allowed a nil/empty account, so loadAll(stackUserID: nil) would read EVERY locally stored Mac across Stack accounts and could publish another account's workspaces into the UI. Now requires a concrete signed-in user before any load/connection (mirrors loadPairedMacs), keeping the post-await scope checks. P1: per-user paired-Mac delete tombstones were never garbage-collected — the alarm only GC'd the devices collection — so an authenticated client churning create/delete grew synced:/synctomb: storage without bound (the live-record cap resets on delete). Added listTombstonedCollections; the alarm now GCs every per-user pairedMacs:<userId> collection that holds tombstones and folds each next-GC deadline into its schedule. P2: a restore suspended at `await task.value` across a sign-out wipe could resume and re-insert restoredScopes (or clobber a post-wipe inFlight entry), making a same-launch re-sign-in skip the backup restore and show an empty list. Added a resetGeneration bumped by removeAll; both restore paths bail if it changed across the await. Tests: paired-Mac tombstone discovery+GC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 6 (restore cancellation per-await, switchToMac stale cache) P1: PairedMacRestore checked Task.isCancelled only once after fetchAll, so a sign-out wipe landing during loadAll or any later upsert let the loop reinsert the previous account's Macs into the wiped store. Now re-checks after the load and before every write, bailing with completed: false. P1: switchToMac hard-failed unless the target was in the in-memory pairedMacs cache, but the multi-Mac aggregation reads Macs straight from the store, so tapping a freshly-restored secondary Mac's workspace no-op'd and stranded the user on a workspace whose Mac never connected. switchToMac now resolves the target from the store (after the backup refresh), falling back to the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: redesign devices screen as Computers management view (no connect step) Since workspaces from every Mac now appear together automatically, the device tree's "connect to a device" step is obsolete. Replace it with a Computers screen that manages the Macs signed in to the account: - One row per computer: machine-colored avatar (same color its workspaces use in the list, via the new shared MachineAvatarColors), name, online/last-seen status from durable-object presence, and workspace count. - Remove a computer via swipe or context menu (confirmed) -> forgetMac. - Add a computer via a toolbar + that opens the existing pairing flow (showAddDevice plumbed root -> shell -> list -> screen). - Drop the instance/tag/workspace expansion tree and Connect affordances; delete the now-dead DeviceTreeExpansionStore (+ tests) and the unused tree row snapshots, keeping only DeviceTreePresence. - New mobile.computers.* strings localized en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 7 (Release preview compile break, ineffective computer remove) P1: rootContent referenced WorkspaceListLayoutPreviewView directly, but that type is compiled only under `os(iOS) && DEBUG`, so a Release/iOS archive failed to type-check the branch ("cannot find ... in scope"). Added a DEBUG-wrapped workspaceListLayoutPreview helper (mirroring terminalLayoutPreview) so Release never names the gated type. P2: the Computers list was built from deviceTreeDevices (prefers the team registry), but Remove calls forgetMac, which only deletes the local paired-Mac backup row — so a registry-backed computer reappeared on the next registry load and Remove looked broken. Build the list from pairedMacs instead: this feature's source of truth, the same set that feeds the workspace aggregation and the exact rows forgetMac removes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: track CMUXMobileRootView in swift file-length budget (preview helper) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 8 (restore not cancelled on sign-out, idle-team tombstone GC, stale secondary rows) P1: signOut() never cancelled in-flight paired-Mac restores (it does not call removeAll), so the cancellation guards could not fire on the normal sign-out path; a restore suspended at its backup fetch could resume — possibly authorized with the next account's live token — and write rows for the previous account. Added PairedMacBackupRefreshing.cancelInFlightRestores (cancel tasks + bump reset generation, without wiping the per-user rows) and call it from signOut. P1: backupPairedMacs created delete tombstones but never scheduled an alarm, so an idle team (no presence instances/subscribers) would never wake to GC them and a create/delete churn grew DO storage unbounded. It now schedules the next tombstone-GC deadline for the user's collection after applying ops. P2: when a secondary Mac's event stream ended, the subscription was removed but its workspacesByMac entry stayed marked connected, leaving dead rows in the aggregate that taps routed into. The stream-end teardown now downgrades that Mac's state to unavailable so the rows show offline until a refresh re-establishes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 9 (forget leaves secondary subscription, failed switch still opens workspace) P1: forgetMac removed the store row but, for a SECONDARY Mac, left its live read-only subscription and workspacesByMac entry intact, so the Computers screen's Remove left the forgotten Mac's workspaces in the list (still updating, tappable) until a later aggregation pass. forgetMac now cancels secondaryMacSubscriptions and clears workspacesByMac for that Mac. P2: openWorkspace awaited switchToMac for a cross-Mac workspace but selected the workspace even when the switch failed (no route / failed connect / fell back to the previous Mac), focusing a workspace whose Mac is not the live connection so terminal input targeted the wrong client. switchToMac now returns whether the foreground connection targets that Mac; openWorkspace bails (leaving the user on the list, with the Reconnect affordance) when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: pop the compact stack when a cross-Mac workspace open fails (autoreview round 10 P1) The tap selects a workspace and pushes its detail synchronously, and openWorkspace runs from that detail's task — so an early return on a failed switchToMac left the user inside a workspace whose Mac never became the foreground connection (terminal input would route to the wrong live client). On switch failure, roll the selection back (selectedWorkspaceID = nil) so the compact stack pops to the list, where the offline row's Reconnect / next aggregation pass recovers the Mac. Known follow-ups (autoreview round 10, narrow edges not on the dogfood path): - sign-out-during-restore cancellation is fire-and-forget; the residual race needs the restore fetch bound to the captured account/team in the backup client. - an empty-macDeviceID QR connect keeps the foreground under the anonymous key and does not migrate workspacesByMac/foregroundMacDeviceID when the real id is adopted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: color computers by distinct position, not a colliding hash (fix two Macs both yellow) The avatar color hashed macDeviceID into 8 slots, so two Macs collided on one color ~1/8 of the time — Lawrence's two real device ids both hashed to slot 2 (yellow). Assign a DISTINCT color index per Mac by sorted device id in the aggregation (MobileWorkspaceAggregation.machineColorIndex), stamp it onto each derived workspace (machineColorIndex), and color the Computers rows from the same store map. Different Macs are now guaranteed distinct up to the palette size; the id hash remains only as a fallback outside the aggregated list. Tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: promote the live secondary connection on cross-Mac open instead of re-dialing (root-cause fix for "Mac offline") Architectural fix. Tapping a secondary Mac's workspace ran openWorkspace -> switchToMac -> connectManualHost -> connect(), which threw away the already-live, authorized read-only client in secondaryMacSubscriptions and re-dialed the foreground from scratch. That re-dial pipeline has several independent failure points — route re-derivation via refreshFromBackup LWW, the offline preflight, and connect()'s connectionGeneration supersession race — any of which strands the user as "Mac offline" even though a working client to that exact Mac exists. switchToMac now first tries promoteSecondaryToForeground: probe the live secondary client, and on success take ownership of it as the foreground connection (reuse the client/route/ticket, start terminal polling, re-aggregate the demoted Mac) with no re-dial. Falls back to the existing re-dial only when no live connection exists. This makes "offline on a reachable, already-aggregated Mac" unrepresentable. First cut of the larger unification (one MacConnection per Mac, foreground as a selector); the write-only `connections` pool and the duplicate connect path collapse in the follow-up. Orthogonal dev-only gap remains: a secondary on an ephemeral port the phone can't refresh (no dev registry) has no live connection to promote. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: Computers screen — drive the dot from the phone's real connection, show presence + route as diagnostics, refresh live while open The connection dot mixed two sources: the phone's live RPC status for the foreground Mac, but the Durable Object presence worker (the Mac's own heartbeat, not the phone's connection) for every other Mac. So a Mac the phone is actively connected to as a SECONDARY showed not-green because presence (unreliable on dev) didn't report it — exactly the "MacBook Pro not green" case. Now the dot is driven by the phone's own per-Mac connection (store.macConnectionStatuses, derived from each MacWorkspaceState.status: green=connected foreground/secondary, orange=reconnecting, grey=not connected), which updates reactively as subscriptions connect/drop. Presence and the dialable route (host:port) move to a separate diagnostic line, so a mismatch — "online via presence but the phone can't connect" — is a visible tailscale/route signal, and the user can see the exact endpoint. While the sheet is open it re-aggregates every 4s so a dropped Mac reconnects quickly. New strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tap a computer for a comprehensive detail/debug sheet Tapping a row on the Computers screen now pushes MacComputerDetailView with the full per-Mac picture, separated so a connection problem is diagnosable: - Connection: the PHONE's live status to this Mac + workspace count + whether it is the active foreground. - Presence (from the Durable Object presence worker): online/offline + last seen, or "unknown", with a footer explaining that presence is the Mac's heartbeat, not the phone's connection, and that online-but-not-connected = a Tailscale/route problem. - Routes the phone can dial: every saved route (kind + host:port), selectable. - Identity: device id, paired-since, route-updated. - Actions: Reconnect, Remove. Rows are NavigationLinks into the sheet; strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-Mac custom name, color, and icon — synced across the user's devices Users can rename a computer and give it a custom color (8 swatches or any color) and icon (curated SF Symbols or any emoji) from the computer's detail sheet. The override wins over the Mac-reported name and the automatic color/icon everywhere: the workspace list avatars, the Computers screen, and the detail sheet. Persistence + sync reuse the existing per-user Durable Object paired-Mac backup: - MobilePairedMac + customName/customColor/customIcon; SQLite store v2 migration (additive nullable columns) + setCustomization (preserves the Mac's reported name/routes/active, bumps lastSeenAt for LWW). - PairedMacBackupRecord (Swift + worker) carries the fields; parse + bounds + pairedMacShapeEqual treat them as shape so a change mints a rev and broadcasts. - BackingUpPairedMacStore uploads the COMPLETE current record on every write (so a route refresh never clobbers a customization) and mirrors setCustomization. - PairedMacRestore applies the fields (LWW) so an edit on device A appears on B. - store.updateMacCustomization persists + uploads + re-derives; the aggregation stamps custom color/icon onto each workspace preview. Color is "palette:<n>" or "#RRGGBB"; icon is an SF Symbol name or an emoji (classified by non-ASCII). Strings localized en/ja. Tests: worker customization sync + shape; restore-applies + setCustomization-preserves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: show a Reconnecting/Reconnect overlay on the terminal when disconnected (fix recurring "black screen") Recurring report: the phone drops its connection (dev route staleness with no registry to refresh) and the workspace detail keeps showing the now-dead terminal surface — an unrendered black screen with only a tiny status pill. The connection is fine to re-establish, but nothing tells the user that or offers an action. WorkspaceDetailView now overlays the terminal with TerminalDisconnectedOverlay whenever macConnectionStatus != .connected: a spinner for .reconnecting, and an offline icon + host + a Reconnect button (-> store.reconnectOrRefresh) for .unavailable. So a dropped connection reads as "Reconnecting…" with a clear action instead of a black void. Localized (reuses mobile.workspace.reconnect). Note: the underlying dev route-refresh gap (a secondary Mac on an ephemeral port the phone can't relearn without the registry) still requires a re-pair on dev; this makes that state visible + recoverable instead of silent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: add wrangler.dev.toml for safe cmux-presence-dev deploys Deploying the dev instance with `wrangler deploy --name cmux-presence-dev` inherits the production presence.cmux.dev custom domain from wrangler.toml (--name only overrides the worker name), STEALING the prod domain from cmux-presence and breaking prod auth (the dev worker uses the dev Stack project). Add a dedicated wrangler.dev.toml (workers_dev = true, no custom domain) so the dev instance stays on its *.workers.dev URL, and point the README at it with a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: encode a per-developer isolated-worker pattern for concurrent dev Problem: cmux-presence-dev is a SINGLE shared worker — last deploy wins, and an unmerged feature (the paired-Mac backup lives only on its branch) exists only on whoever deployed last, so two people working on the worker clobber each other. Pattern: each developer deploys their own cmux-presence-dev-<slug> via scripts/deploy-dev.sh. Each named worker has its OWN Durable Object namespace, so presence + paired-Mac-backup state is fully isolated per dev — any number of people dogfood worker changes at once without collision. Builds point at it via CMUX_PRESENCE_BASE_URL; the shared cmux-presence-dev stays the integration baseline (the script refuses reserved/prod names). To make a tapped iOS DEVICE build honor the override (it sees no shell env), the resolver now also reads an Info.plist key CMUXPresenceBaseURL — precedence env → UserDefaults → Info.plist → Debug default (tested). README documents the full pattern + guardrails; the remaining wiring (reload baking CMUXPresenceBaseURL into the tagged Info.plist next to CMUXDevTag) is flagged as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: track PresenceServiceConfiguration in swift file-length budget Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden paired-Mac v2 migration + bound Computers-screen polling Autoreview findings on the multi-Mac PR: [P1] v2 SQLite migration was neither idempotent nor atomic: it ran the three ADD COLUMN statements then bumped user_version separately, so a kill / disk-full / SQLite error after a partial apply stranded the DB at v1 with some v2 columns present. The next launch re-ran ADD COLUMN custom_name and failed with a duplicate-column error, bricking the paired-Mac store. Now each migration step runs inside one transaction (SQLite DDL + PRAGMA user_version are both transactional, so a partial apply rolls back and retries cleanly), and migrateToV2 only adds columns missing from PRAGMA table_info, which also recovers any dogfood device already left half-migrated by the earlier build. Adds a regression test that seeds a partially-applied v2 schema and asserts recovery. [P2] The Computers sheet polled store.reconnectOrRefresh() every 4s while open, which pulled the DO backup over the network and, when disconnected, re-dialed offline Macs on a fixed timer (battery/network fan-out). The online dots (presence) and secondary workspace lists are already push-driven, so the timer now calls a bounded refreshComputersScreen() (local row reload + coalesced foreground refresh only) on a gentler 10s cadence and leaves offline-Mac dialing to presence-push recovery and the explicit pull-to-refresh / per-Mac Reconnect button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Route multi-Mac workspace mutations + reconnect to the owning Mac Round 2 autoreview findings on the multi-Mac aggregation: [P1] promoteSecondaryToForeground reused a live secondary connection as the new foreground but never started its terminal event stream: it called cancelRemoteOperationTasks() (which does NOT clear terminalEventListenerTask/ ID) and then startTerminalRefreshPolling(), which no-ops while a listener task is still installed. The promoted client got no terminal/workspace/ notification push events, so output stalled until another path restarted the stream. Now stop+start the listener (the existing == listenerID defer guard makes the old listener's async teardown safe). [P2] Aggregated workspace rows can belong to a secondary Mac, but rename/pin/ unread/close all sent to the single foreground remoteClient — wrong Mac, and with a colliding id could mutate a foreground workspace. sendWorkspaceMutation now resolves the workspace's owning Mac (workspaceMutationTarget) and routes to that Mac's client: foreground -> remoteClient + refreshWorkspaces(); a live secondary -> its client + scheduleSecondaryRefresh(); a known offline owner -> no send + snap back (never misroute to foreground). A failed secondary write no longer marks the foreground connection unavailable. [P2] The per-computer detail Reconnect button called reconnectOrRefresh() (foreground/active Mac) and ignored the computer being viewed. It now calls switchToMac(macDeviceID:), which promotes a live secondary to this Mac or re-dials it specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix foreground-Mac ownership: stale rows, switch fast path, test double Round 3 autoreview findings: [P1] Adding setCustomization to MobilePairedMacStoring broke the CmuxSyncStore test target: FakePairedStore conformed with only the old methods. Added a no-op setCustomization to the fake. [P1] On a foreground Mac change (connect A->B, promotion, or a real connect after an anonymous/sign-out session) the previous foreground/anonymous entry was left in workspacesByMac. recomputeDerivedWorkspaceState derives over every entry, so stale rows kept showing and could route actions/opens through stale ownership (regressing the old workspaces = remoteWorkspaces full replacement). Added dropStalePreviousForeground(): on the foreground flip it removes only the old foreground key (never a live/offline secondary, which aggregation re-adds), wired into both the connect path and promoteSecondaryToForeground. [P1] switchToMac's already-foreground fast path trusted the persisted isActive flag, which lags the live connection (promoteSecondaryToForeground writes it via an unawaited Task; stale during reconnect/switch races). It could return success without switching and leave input/mutations on the wrong Mac. Now gates on the live foregroundMacDeviceID == macDeviceID identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix offline-Mac dot + bake iOS presence override; doc team-scope limit Round 4 autoreview findings: [P2] clearRemoteConnectionContext set the global status unavailable but left the retained offline foreground entry in workspacesByMac with status .connected. macConnectionStatuses (the Computers screen's per-Mac dots) derives from those per-Mac states, so a just-disconnected Mac kept showing a green connected dot. Now downgrade the retained entry to .unavailable. [P2] The CMUXPresenceBaseURL Info.plist override was read by PresenceServiceConfiguration but never baked, so a tapped dev device build ignored a per-developer isolated worker. Wired the bake end to end: added the CMUX_PRESENCE_BASE_URL build setting to ios/Config/Shared.xcconfig (empty default) + the CMUXPresenceBaseURL key in ios/Config/Info.plist, and ios/scripts/reload.sh now passes $CMUX_PRESENCE_BASE_URL at both xcodebuild sites (next to CMUX_DEV_TAG). Release/TestFlight stay empty -> unaffected. Updated the worker README (no longer a TODO). [P2] Documented the per-(account, team) backup vs account-scoped local rows scope gap inline at mirrorAccountScope. Solo/single-team users are unaffected; proper multi-team isolation needs a team_id store column (v3 migration), tracked as a follow-up rather than expanding this upgrade-safety PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound Computers timer, key manual reconnects by real id, stop backup clobber Round 5 autoreview findings: [P1] refreshComputersScreen() (the open-sheet 10s timer) delegated to refreshWorkspaces(), which fans out refreshSecondaryMacWorkspaces() to every saved Mac and re-establishes/re-dials missing (offline) subscriptions — the reconnect storm the screen is meant to avoid (my earlier bounding fix was incomplete). It now does a foreground-only reload (riding any in-flight pull-to-refresh) and never initiates the secondary fan-out; recovery stays on presence-push + explicit pull/Reconnect. [P1] A Mac without mobile.attach_ticket.create connects via a synthetic manual-<host>:<port> ticket, and connect() keyed foreground state by ticket.macDeviceID. So a switch/reconnect to such a Mac stamped foreground workspaces with the synthetic id; filters, Computers rows, mutation routing, and aggregation no longer recognized the real Mac as foreground (and could open a duplicate secondary). connect()/connectManualHost now take the real pairedMacDeviceID hint (threaded from switchToMac, reconnect, device-row paths) and key foreground state + the connection pool under it. [P2] The Mac route-publisher omits customName/color/icon, but the worker treated absent fields as part of the record shape, so every Mac heartbeat minted a rev that wiped the user's iOS-set customizations and the next restore cleared them. Fix: iOS uploads now ALWAYS emit the three custom keys (null = reset-to-Auto, authoritative) via a custom encoder; the worker preserves stored customizations for any key an upload OMITS (the Mac), while a present key (iOS) still sets/clears it. Tests on both sides. (Dev worker needs redeploy for dogfood.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stamp foreground rows with real Mac id; migrate test off private workspaces Round 6 autoreview findings: [P1] remoteWorkspacesPreservingSnapshots stamps each foreground workspace with activeTicket?.macDeviceID (the synthetic manual-<host>:<port> id for an attach-ticket-less Mac), and setForegroundWorkspaceState only restamped nil ids — so round 5's real-id foreground KEY did not reach the rows. The same machine then looked like a different Mac (wrong counts/customizations; openWorkspace tried to switch to a nonexistent Mac). setForegroundWorkspaceState now stamps ALL foreground rows with the resolved foregroundMacDeviceID. [P1] The aggregation refactor made public private(set), but the iOS cmuxFeatureTests still assigned store.workspaces directly (7 sites), breaking the feature test target compile. Migrated them to the existing setWorkspacesForTesting DEBUG seam (reachable via @testable import), which writes the foreground per-Mac state so the derived list recomputes identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drain in-flight restores before sign-out wipe (privacy race) Round 7 autoreview finding: [P1] removeAll() (sign-out wipe) cleared the local store BEFORE cancelling in-flight restores. A restore can pass its Task.isCancelled check, suspend inside inner.upsert, then the wipe runs and only afterwards cancels — but cancellation does not withdraw the already-queued upsert, so the previous account's Mac could be written back into the just-emptied store after sign-out (privacy boundary). removeAll now cancels AND DRAINS (awaits) the in-flight restores before wiping, so every pending write completes first and the wipe is final. Adds a deterministic regression test (GatedUpsertStore) that suspends a restore inside upsert across the wipe and asserts the store ends empty; it fails under the old wipe-then-cancel ordering. (The QuickLook finding the reviewer raised is out-of-scope: it comes from the origin/main merge, not this PR's diff, and the helper flagged it as ignored.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Team-safe backup mirror, non-loopback secondary dial, QR identity re-key Round 8 autoreview findings: [P1] mirrorAccountScope uploaded the WHOLE account's local rows into whichever team the backup client targets, so a multi-team user activating a host could copy other-team hosts into the selected team's per-team DO. Removed the whole-account mirror: upsert(markActive)/setActive now upload only the two records whose active flag actually changes (the newly-active host + the previously-active one, now cleared), preserving the backup's single-active invariant without dumping the account. (Local rows still carry no team id; a full team-scoped store is a separate v3-migration follow-up, but the leak vector is gone.) [P1] makeSecondaryClient proved a non-loopback route to fetch the attach ticket but then dialed supportedRoutes.first, which on a physical phone can be a higher-priority debugLoopback (127.0.0.1) — every secondary subscription dialed the phone itself, so the Mac was unreachable and dropped from aggregation. Now dials the proven route (exact host/port match, else any non-loopback, else first). [P2] A compact/anonymous QR pairing connects with an empty macDeviceID, so foreground state lands under the anonymous key with foregroundMacDeviceID nil. applyHostReportedIdentity adopted the real id into activeTicket but never updated the aggregate key, so the Computers screen showed the Mac as not-connected and aggregation (which excludes foregroundMacDeviceID) could open a DUPLICATE secondary to the same Mac. Added adoptForegroundMacIdentity to move/restamp the foreground per-Mac state and connection-pool entry to the reported id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stack teams: team-scoped paired-Mac data + lazy re-scope + nav drawer Implements full Stack-team support on iOS (the team-scope gap autoreview kept flagging is now closed by real per-team scoping rather than a documented caveat). A) Team-scoped local paired-Mac data - v3 SQLite migration adds a nullable team_id column (idempotent, mirrors v2); legacy/pre-v3 rows have NULL team and stay visible under EVERY team (loadAll filter is ) so an upgrade never hides existing hosts. - MobilePairedMac gains teamID; protocol upsert/loadAll/activeMac gain a teamID param with convenience overloads (teamID:nil) so existing call sites compile unchanged. markActive/setActive clear the active flag per (user, team) so activating in team A never deactivates team B. - BackingUpPairedMacStore injects the current team (teamIDProvider) into inner upsert/loadAll/activeMac; PairedMacRestore stamps restored rows with the team whose DO they came from. Multi-team users now only see/dial the active team's Macs. Tests: v2→v3 migration legacy visibility, per-team isolation, decorator injection. B) Lazy re-scope on team switch (keep the live terminal) - MobileShellComposite.currentTeamDidChange() re-subscribes presence, tears down secondary aggregation, invalidates the restore memo, and clears the pairedMacs/registryDevices caches — but never touches the foreground connection, so switching teams does NOT drop the live terminal. Rebuild is lazy (next foreground / Computers .task / pull). CMUXMobileRootView observes selectedTeamID (single mutation path). Test: foreground workspaces survive. C) Left-edge-swipe nav drawer - New MobileNavDrawerView (account header, Stack team list with current checked, Settings, Sign out) + EdgeSwipeDrawerContainer (leading-edge drag + scrim; toolbar button is the primary/accessible entry). Mounted in WorkspaceShellView over both layouts; WorkspaceListView gains a leading drawer button. Tapping a team only writes AuthCoordinator.selectedTeamID (the root re-scopes). en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix sign-out foreground reset, cumulative backup cap, iOS drawer type ref Autoreview round on the teams feature: [P1] signOut seeded the anonymous preview workspacesByMac entry but left foregroundMacDeviceID at the old real Mac id. The next connect() then captured that stale id as previousForegroundKey, so dropStalePreviousForeground (the round-6 stale-row fix) dropped the WRONG key and the preview rows survived alongside the newly-connected Mac. signOut now clears foregroundMacDeviceID and the foreground connection pool before seeding the anonymous entry, so foregroundMacKey matches the seeded key and the next connect drops the anonymous preview correctly. [P1] The new /v1/sync/paired-macs write path capped only LIVE records, so create→delete→repeat churn with fresh ids grew the DO unbounded across the tombstone GC window. Added MAX_PAIRED_MAC_RECORDS_PER_USER (5× live): a brand-new id is refused at the cumulative (live + retained-tombstone) cap; reviving a tombstoned id reuses its slot. Test churns to the cap and asserts new ids are refused while a revive is allowed. Also fixed the iOS archive compile error: MobileNavDrawerView named CMUXAuthTeam (from CMUXAuthCore, not a direct dep of CmuxMobileShellUI). Pass the team's id/displayName fields instead of the type, which also keeps the @Observable off the drawer's row closures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix iOS drawer compile: .rect arg order + gate drawer to iOS - EdgeSwipeDrawerContainer: UnevenRoundedRectangle .rect() wants bottomTrailingRadius before topTrailingRadius. - MobileNavDrawerView uses .listStyle(.insetGrouped) (iOS-only) and is only used on iOS, so gate the whole file behind #if os(iOS) (the package also compiles for macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix WorkspaceListView call: openDrawer must match declaration order openDrawer is declared right after store, so pass it there in both call sites (Swift requires call arguments in declaration order). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drawer edge swipe: use native UIScreenEdgePanGestureRecognizer The SwiftUI DragGesture edge-strip fought the workspace list's scroll + row swipe actions (SwiftUI gestures don't coordinate with UIScrollView) and felt broken. Replace it with UIKit's UIScreenEdgePanGestureRecognizer — the same system recognizer behind the interactive back gesture — installed on the hosting view via a representable. It has screen-edge priority and coordinates with the scroll view automatically, and now drives the drawer INTERACTIVELY (the panel tracks the finger; commit on release by threshold/velocity). Gated to the compact root list only (isEdgeSwipeEnabled): a pushed detail uses the left edge for the system back swipe and the split layout has its own sidebar gesture, so the edge swipe would conflict there. The ☰ toolbar button opens the drawer in every state regardless (primary, accessible entry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace team drawer with a native inline team picker in Settings Per dogfood feedback, the left-edge swipe drawer felt wrong (Apple also discourages hamburger drawers). Removed it entirely (EdgeSwipeDrawerContainer + MobileNavDrawerView deleted; WorkspaceShellView/WorkspaceListView reverted to the plain layout + the existing top-left Settings button) and put the team picker where it belongs: an INLINE Picker in the Settings sheet's account area — each Stack team is a row with a checkmark on the current one, one tap to switch. The team-scoped data + lazy re-scope (selectedTeamID observed by the root) are unchanged; only the entry point moved from a custom drawer to native Settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers screen: show each Mac's build channel (DEV+tag / Nightly / Stable) The Computers screen now labels which build each Mac runs, to debug 'which build is this host'. Full vertical: - Mac heartbeat (PresenceHeartbeatClient) now sends the app's bundleId alongside the existing CMUX_TAG. - Presence worker (validate/core/do.ts) parses, stores, and echoes bundleId on the instance (optional, bounded; a change re-syncs the device row). - iOS PresenceInstance decodes bundleId; PresenceMap.deviceSummary derives a build label via the new MacBuildChannel helper (a non-default tag => 'DEV · <tag>'; else the bundle-id suffix => Nightly/RC/Staging/Stable). - Computers UI: a small tinted pill next to each Mac's name (MacComputerRow) and a 'Build' row in the detail's Presence section (MacComputerDetailView). Tests: MacBuildChannel label derivation, worker bundleId carry, the Mac heartbeat body emits bundleId. en+ja localization for the new strings. Needs a dev-worker redeploy + mac & iOS rebuild for the value to flow on dogfood. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Build-channel label: component-based parse + handle future RC Align MacBuildChannel with the canonical SocketPathMarkerFiles.variant mapping: the channel is the component right AFTER com.cmuxterm.app (a tagged channel build appends a further .slug, e.g. com.cmuxterm.app.nightly.my-feature), so match the component, not a naive suffix. Adds 'rc' -> 'RC' so a future release-candidate desktop build (com.cmuxterm.app.rc) is labeled correctly the moment it ships, plus debug/dev -> DEV and an unknown future component -> no guess. Tests cover RC, slugged channel bundles, and the dev-tag-wins case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers: don't show contradictory 'presence unknown' when connected A Mac the phone is actively connected to (green, with workspaces) was still showing 'Presence: unknown' on its row — contradictory and confusing, since the live connection already proves the Mac is up. Presence is a SEPARATE signal (the Mac's heartbeat to the presence worker), and a dev phone watching the dev worker won't see a Mac that heartbeats to prod — so 'unknown' is common and meaningless next to 'Connected'. Row: when connected and the presence worker has no record, drop the 'Presence: unknown' and show just the route (real presence data still shows). Detail's 'Presence (from server)' section: when connected, say 'no heartbeat (connected directly)' instead of a bare 'unknown'. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers detail: a connected Mac reads 'Online' (connection is the truth) Follow-up to the presence-unknown fix: the root cause is that presence heartbeat is currently a DEV-only feature — stable cmux Macs don't announce presence (Release default OFF, no prod presence URL shipped), so a Mac you're connected to genuinely has no server heartbeat. Showing 'no heartbeat' for a Mac you're actively using reads as broken. Now, when the phone is connected, the detail's Presence section leads with 'Reports: Online' (the live connection proves it) plus a 'Source: this phone's connection (no server heartbeat)' clarifier, and the footer explains presence is a dev-only signal today. The row already shows just the route when connected. en+ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make presence production-ready: prod URL + follow the mobile toggle Presence was dev-only (Release default OFF, no prod URL). Make it ship on stable, gated on the mobile feature per the desired model: default OFF, ON when the user enables mobile. - PresenceSettings.isEnabled: an explicit override still wins, but with no stored value presence now FOLLOWS MobileHostService.isListeningEnabled (the iOS-pairing master switch). Default (mobile off) => off for privacy; turning on mobile pairing turns on presence automatically. Replaces the old DEBUG-on/Release-off. - Mac resolvedServiceURL: Release now defaults to the production worker (presence.cmux.dev) instead of nil, so a stable Mac with mobile on heartbeats to prod. Debug still uses the dev worker. - iOS PresenceServiceConfiguration: Release now defaults to the production worker too, so a stable iOS app subscribes to the same service stable Macs report to (env/UserDefaults/Info.plist overrides unchanged). On merge, CI (presence.yml) deploys the updated worker (bundleId + customization merge + tombstone cap) to prod, completing the production path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address iOS policy review cleanup * Fix paired Mac team scoping and aggregation guards * Satisfy paired Mac autoreview policy gate * Fix paired Mac team scope ownership * Fix quit confirmation reentrancy * Fix scoped backup and workspace action gates * Fix paired Mac legacy claim and selection remap * Fix team active legacy scope * Fix anonymous aggregation and backup actives * Fix visible legacy Mac customization scope * Fix legacy Mac active clearing scope * Make paired Mac backup decode tolerant * Fix stale route writes across team switches * Fix notification deeplink scope and backup URL joining * Provision secrets for isolated presence workers * Propagate paired Mac backup tombstones * Keep stale team loads from clearing current lists * Fix foreground suppression and secondary downgrades * Fix paired Mac backup review findings * Fix paired Mac scope and dismiss flush races * Satisfy iOS package convention lint * Fix visual line copy mode Ghostty API usage --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
iOS: stable Keychain device id + Forget computer (iroh re-key client) (#8888) * iOS: stable Keychain device id + Forget computer (iroh re-key client) Client complement to the broker binding re-key (manaflow-ai/cmux#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy. * iOS: fail closed on unreadable device id, alert on Forget failure, pin account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: harden iroh re-key client per review (device-id, session snapshot) Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @MainActor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured scope, not the live team Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: converge device identity under races, gate snapshot during token transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured team scope, no visibleScope re-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: hidden-computer unhide spinner tracks its own task, not forget's The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget deleting wrong paired-Mac scope Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes each pairing's own captured scope, not the live display scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — session snapshot pairs stale access with rotated refresh authenticatedSessionSnapshot() reads the access and refresh tokens through two separate awaits (currentTokens()), so a concurrent force refresh can rotate the pair between them and hand the broker an old access token with a new refresh token. Neither snapshot guard trips on a plain token rotation. The test scripts that torn store state and asserts the snapshot returns the access minted for the captured refresh, not the stale stored access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: session snapshot derives access from the captured refresh token authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(), which captures the refresh token once and mints the access token FOR that exact refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access always belongs to the returned refresh, so a concurrent forceRefreshAccessToken() can no longer hand the iroh broker an old access token paired with a rotated refresh token. currentTokens() is unchanged for its broader callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget routes backup tombstone to display team A team-less row's backup was uploaded under the row's own (nil) team scope, but forgetting it routes the tombstone to whatever team it happened to be displayed under. The tombstone lands in the wrong per-team backup scope: the row's real backup survives (and a restore under the row's own scope can resurrect the forgotten row), while a same-device record in the displayed team's backup can be wrongly deleted. Replaces the previous test, which asserted the display-team routing as the desired behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route forget backup tombstone to the row's own team scope The forget path routed the backup delete to the team the row was displayed under. For a team-less row that team is arbitrary (legacy visibility shows it under every selected team), while upsert stamps the row and uploads its backup under one resolved team, so the row's own team_id is the only client-side value tied to where the backup lives. Display-team routing also split the pending- delete lifecycle across two scopes: the tombstone was written and flushed under the display team's outbox scope, but a restore under the row's own (team-less) scope never saw it and could resurrect the forgotten row locally. Route the tombstone to the row's own captured team, the same scope the backup was uploaded under, keeping outbox key, local apply, flush, and restore- suppression on one scope. This removes the removeExactScope(backupTeamID:) variant entirely; the 4-arg exact-scope delete already carries the row's own team. Residual: a row uploaded while no team was selected client-side had its backup scope resolved server-side, and that resolution is not echoed back or persisted, so no client-only routing can name that scope with certainty. The symmetric nil route re-resolves through the same server path as the upload. Persisting a server-echoed backup team is a cross-stack follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — pending-delete replay deletes a surviving sibling row A forget whose backup upload fails leaves its tombstone in the outbox; the next read replays it through the broad remove path. TeamScopedPairedMacStore's remove re-resolves the device under the scope's team, which also returns team-less legacy rows, so with the exact row already deleted locally the replay resolves a SURVIVING unrelated alias of the same device and deletes it — the exact over-deletion the exact-scope forget path exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: replay pending backup tombstones through the exact-scope delete A pending tombstone names one exact pairing and its outbox scope key pins the exact (account, team) it was deleted under, so the replay's only job is to finish or confirm that one deletion. Replaying through the broad remove re-resolved visibility on the way down: TeamScopedPairedMacStore looks the device up under the scope's team (which also returns team-less legacy rows) and the build-scope decorator's broad remove drops its team-less fallback alias. In the common failed-upload case the exact row is already deleted, so the broad replay resolved a surviving unrelated alias of the same device and deleted it. Replaying via removeExactScope is a no-op there and, after a crash between the tombstone write and the local delete, removes exactly the named row. Residual: a crash-interrupted BROAD remove now replays exact too, so a team-less build-fallback alias can outlive that narrow window in dev builds; it resurfaces visibly and the next hide drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — wildcard forget leaves the device's sibling rows saved A row with no instance tag cannot name its broker binding, so forgetting it revokes EVERY binding for the device. The local cleanup deleted only the exact nil-tag row, leaving the device's coexisting tagged rows saved locally while their bindings were just revoked: dead entries that resurface in the computer list until the Mac happens to re-register. A tag-known forget stays narrow on both sides (second test, passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: match wildcard forget's local cleanup to its revoke breadth A tag-less row cannot name its own broker binding, so forgetting it revokes every binding of the device for the pinned account. Local cleanup deleted only the exact nil-tag row, stranding the device's coexisting tagged rows as dead entries whose bindings were just revoked. After the wildcard revoke the forget now also deletes the device's tagged sibling rows visible in the captured display scope and owned by the pinned account, each through the same exact-scope removal as the primary row. Tag-known forgets stay narrow on both sides. Rows in other teams' scopes are not enumerable through the scoped store rail and self-heal when the Mac re-registers; rows owned by other accounts keep their live bindings and survive. Closes https://github.com/manaflow-ai/cmux/issues/9078. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — forget mints a Stack token for every broker leg The forget flow captures one coherent session snapshot up front, but the broker token source re-snapshots on every request, and each snapshot now mints a fresh access token over the network. Discovery plus every sequential revoke each add a Stack round-trip, so forgetting a computer with many bindings can stall for minutes and fail during a Stack outage even though the pinned credentials in hand are valid. The test drives a forget across four broker legs through a broker fake that fetches one credential pair per request, exactly like the real client, and expects a single mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: reuse the forget's pinned credential pair for every broker leg The forget captures one coherent session snapshot up front; the broker token source now returns that pinned pair after only the cheap local session check (generation + account), instead of re-capturing a snapshot per request. Each snapshot performs a network token mint, so the old path added a Stack round-trip for the discovery and for every sequential revoke: forgetting a computer with many bindings could stall for minutes and fail during a Stack outage despite holding valid credentials. The pinned pair is coherent by construction, and the access token always travels with its refresh token, so the server can re-mint server-side if it expires mid-operation. A mid-forget sign-out or account switch still fails the check and yields nil, so the revoke fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — tombstone ignores the server-reported backup team A team-less row uploads with a nil team and the SERVER resolves which per-team Durable Object stores it; that resolution is not derivable client-side and can drift by the time the row is forgotten. The new uploadReportingResolvedTeam seam (default: echo unknown) lets a transport report the verified team an upload was stored under; the failing test shows the backing-up store discards the echo and re-resolves nil at delete time, so the tombstone can land in a different team's backup than the record it is meant to delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route delete tombstones to the server-reported backup team A team-less row uploads with a nil team and the presence worker resolves which per-team Durable Object stores it. That resolution is not derivable client-side and can drift by the time the row is forgotten, so re-resolving nil at delete time could send the tombstone to a different team's backup: the forgotten Mac's record survived and restored later, and a same-device record in the wrong team could be deleted. The worker now echoes its verified resolved team in the backup POST and GET responses (from the DO, which receives the verified value). The client persists the echo per pairing in a UserDefaults-backed map owned by the backing-up store, and the tombstone flush groups pending deletes by each pairing's persisted backup team (falling back to the scope's own team when no echo was ever seen), uploading each group to the backup its records actually live in. A flushed pairing's mapping is dropped with its backup record. Legacy rows converge on their next successful upload; restores still fetch the live scope (read-path residual, benign). Closes https://github.com/manaflow-ai/cmux/issues/9076. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling Two gaps in the round-4 fixes. Restored rows never pass through the upload path, so the reinstall case (empty mapping store, rows arriving via restore) loses the server's statement of where their backups live: a later forget re-resolves nil and the wrong-backup deletion returns for exactly the restored rows. The snapshot now carries the worker's echoed resolved team so the restore can persist it. And the wildcard forget's cleanup refreshes the paired list per deleted sibling, re-running the backup restore fetch each time — up to the 256-binding snapshot limit of sequential round-trips for one tap; the new test pins the whole cleanup to at most one refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: persist the restore snapshot's backup team; batch wildcard cleanup The restore path now records the worker's echoed resolved team for EVERY live record in the snapshot (not just locally-written ones — each record lives in that team's backup regardless of the local merge outcome), so a row restored after a reinstall and forgotten later routes its delete tombstone to the backup it actually lives in instead of re-resolving nil at delete time. The wildcard forget now deletes all of the device's rows first and runs ONE refresh (paired list + registry + reconnect hint) after the batch, instead of reloading per deleted sibling — each per-row reload also re-ran the backup restore fetch because the removal clears the restore memo, so a forget covering many bindings issued that many sequential network round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: make the coherent credential pair the broker token source's only input CmxIrohBrokerTokenSource previously accepted independent access and refresh closures with the coherent pair optional. Several production constructions (iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and their two closures each called auth.currentTokens() separately, so a session transition between the two reads could assemble one session's access token with another's refresh token and fail registration, discovery, or revocation. The pair closure is now the ONLY construction input, so a two-source token assembly is no longer expressible; the single-token accessors are derived from the pair. Every construction site provides a coherent capture: pinned-session pairs for the forget flow, pairs captured together up front for sign-out revokes, and a single currentTokens() call per fetch for the runtime paths. The performRequest legacy two-closure branch is gone. No new regression test: the removed hazard is inexpressible at compile time, and CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly one atomic capture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-5 review findings A wildcard forget must delete the device's same-account rows in OTHER teams (their bindings were revoked account-wide and an offline Mac cannot re-register to self-heal); the activation broker's credentials must fail closed after an account switch instead of vending the new session's tokens against the old activation; and a legacy device-id whose Keychain migration cannot persist is NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes the adopt-legacy-despite-failed-persist test and the scope-flip test's sibling-survives assertion, both of which pinned the rejected contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id Round-5 review fixes. The activation path now captures one coherent session snapshot, verifies it belongs to the activating account, and pins the broker token source to it (same helper as the forget path): a mid-activation account switch makes every later leg fail closed instead of mutating the new account's broker state against the old activation's endpoint identity. Wildcard forget cleanup now enumerates the device through a new cross-team loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator forwards it verbatim (its live-team substitution is exactly what the cleanup must see past), the build-scope decorator bounds it to its own build scope, and the backup decorator forwards without triggering a restore. Every same-account row of the device is deleted by its own exact scope, matching the account-wide revoke. DeviceRegistryService no longer reports a legacy UserDefaults id as durable when the Keychain migration write fails: the store was readable (id absent) but nothing durable holds the id, so binding activation defers and retries instead of registering a slot a reinstall would strand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-6 review findings A valid stored access token must be reusable without a network mint (forcing a mint made the session snapshot, and with it broker activation, fail offline despite a usable stored pair); and the persisted backup-team echo must be keyed by the row's own team — the local store deliberately allows the same (account, device, tag) pairing under several teams, so a team-agnostic key let team B's upload overwrite team A's destination and route A's tombstone into B's backup. Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's mint expectation drops to zero accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline Round-6 review fixes, one architectural piece plus three scoped ones. coherentTokenPair() replaces the always-minting snapshot read: capture the refresh token, resolve a usable access token FOR it (the SDK reuses a valid stored access without the network and mints only otherwise), then re-read the refresh — an unchanged refresh proves no rotation crossed the window, a changed one retries. It runs inside the coordinator's bounded token-touching phase. The session snapshot, the iOS quarantine-recovery source, and the macOS host activation source all read through it, so no torn two-await assembly remains and an offline launch with a valid stored pair succeeds. Activation no longer freezes an activation-time pair for the runtime's lifetime (ordinary force-refresh rotation does not bump the session generation, so a frozen pair went stale and stranded relay refresh and discovery until an unrelated reconcile). The activation gate is now a cheap local identity check — no token read, so offline activation still reaches the cached relay/offline-policy recovery — and every broker request re-checks the account/generation pin and re-reads a coherent pair from the store. The backup-team echo mapping key now includes the row's own team, and the forget revoke loop gets a 60-second operation deadline (deadlineExceeded surfaces the failure; applied revokes stand and a retry re-discovers what remains) instead of up to 256 sequential broker timeouts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-7 review findings An ordinary same-account foreground revalidation must not advance the session generation (every generation-pinned broker source would starve after the first foreground), and a UserDefaults device-id mirror must never be adopted when the Keychain authoritatively reports the id absent — the mirror travels in device backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so adoption would make two physical devices fight over one (user, device, tag) slot on every phone upgrade. Also pins persist-and-reuse of refreshed access tokens across repeated coherent captures (contract coverage: the ephemeral side-store defect is not expressible through the fake), and reworks the fakes to model the live store's stale-refresh-persist semantics. Supersedes the legacy-mirror-adoption migration test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: round-7 identity and credential lifecycle fixes Same-account revalidation no longer bumps the session generation: the bump now happens only on a genuine transition (signed-out -> signed-in, or a different account), so generation-pinned broker sources survive ordinary foreground returns while sign-out/sign-in still fences stale flows. The device id is minted fresh when the Keychain authoritatively reports it absent, never adopted from the UserDefaults mirror (which migrates in phone backups and would collide two physical devices onto one binding slot); the mirror remains trusted only while the Keychain is temporarily unreadable. This deliberately drops the seamless pre-Keychain upgrade migration — a one-time re-pair for existing installs — to prevent a permanent cross-device identity collision on every phone upgrade. The coherent pair now resolves the access token through the LIVE store inside the refresh bracket, so a stale token is refreshed once, persisted, and deduplicated by the SDK instead of re-minted per capture through an ephemeral side store. The long-lived activation source reads a full authenticated snapshot per request (atomic identity+credential capture, transition-checked) validated against the activation pin, closing the check-then-read race. Both credential containers get redacted descriptions so reflection cannot copy live tokens into logs or crash reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-8 review findings An in-place upgrade (Keychain absent, mirror holding the id the live binding already uses, no witness recorded) must ADOPT the mirror — minting there changes every existing installation's identity once and strands all of their bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a restored backup) must still mint fresh, and a witness matching this phone adopts. These pin the provenance mechanism that separates the two cases the last two rounds traded against each other. (The tests reference the new witness parameter, so this commit is red at compile time without the fix.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: device-witness provenance for the id mirror; pin the macOS broker source The UserDefaults device-id mirror now carries a per-device witness (identifierForVendor — a value a restored phone does not inherit), written on every mirror update. On authoritative Keychain absence the mirror is adopted only when the witness proves it was recorded on THIS device or predates the mechanism (the in-place upgrade population, whose mirror holds the id their live binding already uses); a mismatched witness means a backup restored onto another phone, which mints fresh so two physical devices never share one (user, device, tag) slot. The locked-Keychain fallback applies the same test. Residual: restoring a PRE-witness backup onto a new phone is indistinguishable from an upgrade and adopts — bounded to backups taken before this ships. The macOS host runtime's broker source now mirrors the iOS one: activation verifies the live account, captures the generation, and every request reads an atomic authenticated snapshot validated against that pin, so an A-to-B account switch fails the old runtime's requests closed instead of registering B's credentials against A's endpoint state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-9 review findings A wildcard forget's tombstones must travel in ONE request per destination (a device can carry 256 bindings, and per-row flushes each burn a request timeout); a pending tombstone must be visible to restores of its DESTINATION scope, which must both suppress the deleted record and retry the flush; an unmapped team-less tombstone must PARK instead of shipping with a guessed nil team the server would re-resolve from current account state; and a failed cross-team sibling enumeration is a cleanup failure, not silent success. Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team routing test is superseded by the parked contract, and the crash-intent test becomes the mapping-recovery test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure Round-9 review fixes. Pending backup tombstones are now keyed by their DESTINATION scope — the team whose Durable Object actually holds the record (the persisted echo, else the row's own concrete team) — with the row's LOCAL team encoded in each record for exact local replay. A restore of the destination therefore both suppresses the deleted record while its upload is pending and retries the flush, closing the resurrect-and-never-retry gap of local-scope keying. A team-less row with NO verified destination is parked under the nil-team scope and never uploaded with a guessed nil team; parked intents migrate to their destination and flush once a restore's echo recovers the verified mapping. Legacy single-field records decode as local==scope, preserving old outboxes. Residual, documented in code: while parked, a restore of a different team's scope cannot see the intent and may resurrect the record there; re-forgetting that row routes exactly, which is recoverable — unlike a misrouted destructive delete. removeExactScopes batches several rows: local deletes and outbox writes first, then ONE tombstone flush per destination, replacing the per-row flush that gave a wildcard forget up to one network round-trip per row. The composite deletes the primary and all wildcard siblings through one batch and clears markers only after it succeeds, and a failed sibling enumeration now fails the forget instead of silently claiming success after an account-wide revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-10 review findings A TAGGED forget's revoke is also account-wide for that (device, tag) binding, so same-tag rows in other teams must be cleaned too while different-tag rows survive; and reviving one team's row must clear only THAT row's pending tombstone — the destination-keyed outbox can hold same-pairing records for different local teams, and cancelling them all lets another team's forgotten record survive in the backup and restore later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget: a tagged revoke kills the (device, tag) binding account-wide, so other teams' same-tag rows are dead and get cleaned, while different-tag rows keep their own live bindings and survive; the tag-less wildcard keeps its every-tag breadth. And a revive clears only the pending tombstone whose LOCAL team matches the re-added row — same-pairing records for other local teams in the same destination stay pending, so their forgotten backup records still get deleted instead of surviving to restore later. Legacy unscoped records decode their local team from the scope they sit in and so match only in the re-added row's own scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-11 review findings Three confirmed defects, each with a failing test: - A wildcard forget's exact-scope cleanup silently skips rows whose instance tag is incompatible with this build, while the tombstone still flushes and the forget reports success; the revoked-binding row survives to resurface as a dead entry. - Forget clears hidden markers only in the display scope; markers are stored per (user, team), so another team's marker survives its row's deletion and keeps a re-registering Mac unexpectedly hidden there. - A whitespace-only persisted device identity classifies as .found, so the corrupt-item repair deadlocks: the mint path re-reads and adopts the same whitespace value and every launch advertises an invalid opaque device id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact-scope deletes match wildcard breadth; markers and identity repair Round-11 review fixes: - The build-compatibility store no longer guards exact-scope deletes. An exact-scope delete targets a row the cleanup explicitly captured from loadAllInstances, and the broker's wildcard revoke is tag-blind, so the local cleanup must cover incompatible tags too; the guard let the tombstone flush and the forget report success while the revoked-binding row survived. Ambient verbs keep the guard. - Forget clears each deleted row's hidden marker in that row's OWN team scope in addition to the display scope. Markers are stored per (user, team); clearing only the display scope left another team's marker to keep a re-registering Mac unexpectedly hidden there. - KeychainDeviceIdentityStore classifies a whitespace-only item as corrupt (.absent), so the duplicate-item repair path overwrites it instead of endlessly re-adopting it as .found; the in-memory test double mirrors the contract, now documented on DeviceIdentityStoring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-12 review findings - A pre-witness UserDefaults mirror is adopted on authoritative Keychain absence with no proof this is the same physical device; a backup taken before the witness shipped restores onto a new phone and clones the old phone's (user, device, tag) binding slot. - A concrete-team restore neither suppresses nor resolves a PARKED unknown-destination tombstone, so the supposedly forgotten computer is resurrected locally and its backup survives every future restore. - A partially failed batched cleanup still runs the post-forget refresh, whose rowless-marker migration clears the deleted primary's hidden marker — the retry entry disappears while the failed sibling row keeps its already-revoked binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve Round-12 review fixes: - Pre-witness mirror adoption now requires device-continuity evidence: a non-migrating artifact proving the install continues on this hardware. The probe is the iroh endpoint identity — in Release an AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a backup, and one every install with a live binding necessarily has. A restored pre-witness backup on a new phone lacks it and mints fresh (no more cloned (user, device, tag) slots); an in-place upgrade with a binding has it and keeps its id; an install that never activated iroh mints harmlessly. Both production device-id callers pass the same probe so concurrent resolutions agree, and the locked-Keychain mirror branch defers instead of trusting a possibly-restored mirror. - Every restore's suppression list now includes the account's PARKED (unknown-destination) tombstones, and a verified team's snapshot echo resolves any parked intent whose pairing it contains: the mapping is recorded under the parked record's own key and the parked scope flushes, migrating the intent to its destination and deleting the backup. A forget the user was told succeeded can no longer be resurrected by the next restore. FakeBackup now honors successful delete uploads in its snapshot, mirroring the server. - The post-forget refresh runs only after COMPLETE cleanup, so a partial batch failure keeps the hidden entry as the retry owner instead of letting the rowless-marker migration clear it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-13 review finding A forget's cleanup enumerates only the LOCAL store, but backups live in per-team Durable Objects and only the selected team's backup has been restored on this phone. The same device's records in another team's backup get no tombstone even though the wildcard revoke killed their bindings account-wide; switching to that team later restores the supposedly forgotten computer as a dead entry. FakeBackup gains a per-team-bucket mode to model the server's per-team storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-wide forget tombstones; device-id resolution off the UI actor Round-13 review fixes: - A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in addition to the routed per-row intents. Backups are per-team Durable Objects and only restored teams have local rows, so the local enumeration cannot match the broker revoke's account-wide breadth; the parked intent suppresses the pairing in EVERY team's restore, each verified snapshot that proves its team holds the pairing gets a direct delete (a tag-less intent is the device-wide wildcard and matches every tag, with the snapshot supplying the concrete tags), and the intent persists until a re-pair revives the pairing. Parked intents no longer migrate to a single destination — no single team could retire an account-wide tombstone. - Durable device-id resolution moved off the MainActor for activation: a private actor captures the identifierForVendor witness with one MainActor hop and runs the Keychain reads/writes, defaults mirror, and continuity probe on its own executor, restoring the off-UI-actor guarantee the merge reconciliation had dropped. DeviceRegistryService gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for such callers, and currentDeviceWitness() is public. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-14 review findings - Parked (account-wide) tombstones replay their local delete only when the nil-team scope itself is requested, so an offline launch after a crash keeps showing the supposedly forgotten computer: crash recovery must be network-independent. - The parked tombstone set retires only on revive and grows by every forget forever — unbounded persisted size and per-restore scan work; retention must be bounded. The forget-deadline scope finding (discovery and in-flight broker calls can suspend past the deadline) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: network-independent parked replay, bounded retention, full forget deadline Round-14 review fixes: - Both restore entry points now replay the account's PARKED tombstones locally before any backup fetch, so crash recovery (outbox written, local delete never landed) works offline instead of depending on the restore's suppression list reaching the network. - The parked account-wide tombstone set is bounded at 256 entries (matching the discovery wire cap): intents are deduped by identity, stamped with a coarse insertion time via an injected clock, and evicted oldest-first when over the cap — an evicted intent's forget has had the longest time to propagate, and losing one degrades to the pre-account-wide behavior for that single pairing. Routed records' encodings are unchanged, so exact-string outbox clearing still works. - The forget deadline now bounds the WHOLE operation: forgetComputer races credential capture, discovery, backpressure waits, and every revoke against a cancellable sleeper, cancelling in-flight broker work at the deadline instead of only checking between revokes; the per-revoke clock checks remain as a cheap early exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature Round-15 review findings — both compile errors in the iOS-only targets (no host-runnable or CI compile covers them, so no regression test is practical): - deviceLocalIrohIdentityExists (and its directory helper) are nonisolated so the off-main resolver actor's synchronous continuity probe closure can call them without a MainActor hop. - The sign-out test fake still optional-bound credentialPair from before it became the token source's only, non-optional input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: forget deadline sleeper becomes static — extensions cannot hold storage Round-16 review finding: the cancellable sleeper was declared as an instance stored property inside the extension that hosts the forget flow, which does not compile. Static storage keeps the bounded-timeout shape unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-17 review finding A completed same-account sign-in (fresh credential exchange while already authenticated) preserves the session generation, so operations pinned to the prior session — the forget flow's frozen credential pair, the activation runtime's pinned source — keep passing the session fence with the replaced session's authority. The sibling round-17 finding (the activation path creates the iroh endpoint identity before the device-id continuity probe checks for it, so a restored pre-witness backup sees its own moments-old identity as continuity evidence) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: sign-in always advances the session generation; probe before identity Round-17 review fixes: - applySignedInUser now takes an explicit SessionPublication reason: a completed credential exchange (.signIn) always advances the session generation, even for the same account, because the token session was replaced and prior-session pins must fail closed; only .revalidation (foreground/startup re-checks of the already-published session) preserves the generation for the same account. - The activation path resolves the durable device id BEFORE creating the iroh endpoint identity. The continuity probe treats a device-local identity as proof the install continues on this hardware; creating the identity first handed a phone restored from a pre-witness backup its own moments-old identity as evidence and adopted the migrated mirror id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: drop @MainActor child annotation the isolation checker cannot verify The hosted iOS build fails on the forget-deadline task group: "pattern that the region-based isolation checker does not understand how to check" at the @MainActor-annotated child. The plain child hops to the MainActor implicitly at the revokeMatchingBindings call, which is exactly what the annotation expressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-19 review findings - The upload echo is keyed by the live display team, but loadAll's legacy visibility can match a TEAM-LESS row: the forget then looks the mapping up under the row's own nil team, misses it, and parks the tombstone — undeliverable when the network is down at echo time. - A parked delete suspended in its upload can race a concurrent re-pair on the reentrant actor: the revive clears the intent and uploads the record, the older delete lands after it, and nothing repairs the wiped backup. - A partially failed batch cleanup returns before clearing ANY markers; rows deleted before the failure can never be re-enumerated on retry, so their per-team hidden markers keep a re-registering Mac hidden. FakeBackup gains an on-delete-upload hook (to interleave a mutation inside the uploader's suspension window), record-op application to its buckets, and a post-construction fetch-failure switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup Round-19 review fixes: - The upload echo's mapping is keyed by the ROW's stored team (mac.teamID), not the live display scope: loadAll's legacy visibility matches team-less rows under a selected team, and the forget looks the mapping up under the row's own team — a display-keyed echo was never found, leaving the tombstone parked and undeliverable offline. - Both delete uploaders (the concrete-scope flush and the parked echo resolver) now fence against the actor's reentrancy: any sent tombstone whose outbox record vanished during the upload suspension was revived by a concurrent re-pair, so its current local row is re-uploaded — the stale delete can no longer silently wipe the just-revived backup. The concrete flush also retires only the records it SENT, so intents added during the suspension survive to their own flush, and revived records keep their freshly re-saved mapping. - A partially failed batch cleanup clears the markers of rows it DID delete — narrowly: only the deleted row's own team key and the user-wide key, never the display scope, which the failed scope (the retry owner) shares. Rows deleted before the failure can never be re-enumerated on retry, so this is the only moment their markers can be cleared. FakeBackup applies record uploads to its per-team buckets only; the legacy single-bucket mode serves its seeded list to every team, so applying uploads there would leak one team's mirror into every other team's restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-20 review findings - The account-wide parked intent is inserted only AFTER the batch's local deletes have awaited; a Mac re-registering during that window clears the routed tombstone but cannot clear the not-yet-created parked intent, which then suppresses the revived pairing forever. - The flush retires sent tombstones by set subtraction computed AFTER its post-upload awaits; a re-pair plus second forget during those awaits re-adds the identical encoded record, which the subtraction silently consumes — an undelivered second tombstone loses its retry. - The persisted backup-team mapping grows without bound: entries retire only when THIS device delivers the pairing's tombstone. Test doubles: a paired-Mac store and a team-mapping store that fire a one-shot hook inside their suspension windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: park before deletes, atomic flush retirement, bounded team mapping Round-20 review fixes: - removeExactScopes resolves accounts and persists the account-wide parked intents BEFORE the first local-delete suspension, so a Mac re-registering during a delete clears every tombstone covering its pairing — routed and parked alike — instead of leaving a stale account-wide intent that would suppress the revived pairing forever. The parked scope now also dedupes by identity in addPendingDelete and applies the same oldest-first cap there, so a row intent never stacks a second encoding beside its account-wide twin and single exact-scope removes cannot grow the scope unbounded. - The concrete flush retires its sent tombstones atomically in one actor turn right after the upload (synchronous cache read + write), before the mapping-cleanup and repair awaits: a re-pair plus second forget interleaving those awaits re-adds its identical record AFTER retirement and keeps its own retry. - The persisted backup-team mapping is bounded at 512 entries with move-to-newest insertion order and oldest-first eviction; losing an evicted mapping degrades that pairing's next forget to the parked, echo-recovered path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-21 review findings - A parked intent matches later snapshots solely by pairing id and is cleared only by a LOCAL re-pair: when another device re-creates the record, this phone deletes the revival on every restore and keeps the intent forever, making cross-device re-pairing impossible to persist. - The restore echo records every snapshot mapping under the restore team, but LWW can retain a NEWER team-less local row un-stamped; the later forget looks the mapping up under the row's actual nil team, misses, and parks — undeliverable when the network drops. The third round-21 finding (a same-account sign-in advances the session generation but the long-lived activation runtimes stay pinned to the old generation and return nil credentials until restart) is fixed in the same round; it lives in the iOS-only and macOS app targets, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes Round-21 review fixes: - The LONG-LIVED activation runtimes (iOS composition and the macOS host) pin their broker token sources to the ACCOUNT only, not the session generation: every completed sign-in now advances the generation, and a same-account re-sign-in must keep the runtime serviceable — it is the same user, so serving the new session's credentials via the atomic snapshot is correct, where the generation pin stranded the runtime on nil credentials until relaunch. The forget's short-lived frozen pair stays strictly generation-pinned. - The restore echo now fires AFTER the merge and carries, per snapshot record, the RETAINED local row's actual team and the record's creation time. Mappings are keyed by the retained row's own scope (LWW can keep a newer team-less row un-stamped, and the forget looks the mapping up under the row's real team), falling back to the restore scope for records with no local row (the reinstall case). - A snapshot record CREATED after a parked intent's stamp is a REVIVAL — another device re-paired the Mac — and retires the intent instead of feeding it a delete; without this the forgetting phone deleted the revival on every restore forever. Unstamped legacy intents keep the old delete behavior (no boundary is known for them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-22 review findings - A revived record is recognized only AFTER suppression already filtered it out of the merge; with the completed restore memoized, the re-paired Mac stays missing locally until relaunch. - The revival signal compared client-authored createdAt, which another phone preserves across a re-pair; the genuine revival misclassifies as stale and is deleted on every restore. The record model gains the SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never uploaded). - Restore echoes persist mappings one save per record; the production store rewrites its whole state per save, so a large restore does quadratic UserDefaults work. The mapping protocol gains a batched saveAll (default forwards per entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: server-authored revival signal, in-merge revivals, batched mappings Round-22 review fixes: - The worker now surfaces the sync machinery's server-authored per-record write time as serverUpdatedAtMs on the restore read (never accepted from clients — sanitize strips it). Revival classification compares THAT against the tombstone's stamp through a shared skew-margined rule biased toward revival: client-authored createdAt is preserved across re-pairs on other phones and proves nothing. - Restore suppression is now stamp-aware: run() takes suppression entries (pairing + tombstone stamp), and a record every covering tombstone sees as revived MERGES in the same restore instead of being filtered out and stranded behind the completed-restore memo until relaunch. The post-merge echo then retires the covering intents. - Restore echoes persist their mappings through one batched saveAll — the UserDefaults store performs a single read-modify-write of its dictionary and ordering for the whole snapshot instead of a full-state rewrite per record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-23 review findings - The revival skew allowance accepts server writes up to a minute BEFORE the forget as revivals. Forgetting a currently-online Mac whose backup was route-mirrored seconds earlier is the COMMON case; the allowance bypasses suppression, retires the intent, and the supposedly forgotten Mac restores instead of receiving its delete. - A partial batch failure never records a hidden marker for a FAILED undisplayed sibling: the deleted primary's marker turns rowless and is migrated away, so the sibling — with its already-revoked binding — resurfaces as a normal computer with no Hidden Computers entry left to retry from. The third round-23 finding (the sign-out quarantine's destructive retry captures live credentials without pinning them to the pending revocation's account) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: strict revival boundary, pinned quarantine retry, sibling retry markers Round-23 review fixes: - The revival boundary is STRICT: only a server write after the tombstone's stamp counts. Forgetting a currently-online Mac whose backup was mirrored seconds earlier is the common case, and the skew allowance let those pre-forget writes bypass suppression and retire the intent. The residual (phone clock behind the server) fails in the recoverable direction: the revival is deleted once and the other device's next mirror re-uploads it with a fresh server stamp. - The sign-out quarantine's destructive retry pins its credentials to the pending revocation's account through the atomic session snapshot, failing closed if the user switched accounts between the guard and the credential capture. - A partial batch failure records a hidden marker for every SURVIVING failed scope in its own team, so an undisplayed sibling with a revoked binding keeps a durable Hidden Computers retry entry even offline — where the account-wide parked intent cannot yet finish the cleanup. Once any restore completes it, the marker turns rowless and the existing migration clears it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-24 review finding The tombstone stamp is floored to whole seconds while server write times carry milliseconds, so a server write from the same second but BEFORE the forget classifies as a post-forget revival: the intent retires and the stale record restores instead of being deleted. Of the two sibling round-24 findings: the forget deadline race is fixed in the same round (the throwing task group structurally awaits an unresponsive cancelled child past the deadline; it lives in the iOS-only cmuxFeature target with no host-runnable test), and the retained-teams dictionary finding is factually incorrect — assigning a String? through the subscript wraps it (Swift removes only when the assigned expression is already the subscript's doubly-optional type), which the passing restoreEchoTracksTheRetainedTeamlessRow regression proves — but the code switches to updateValue(_:forKey:) to make the retention explicit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: millisecond forget boundary, non-blocking deadline, explicit retention Round-24 review fixes: - Tombstone stamps carry epoch MILLISECONDS with an explicit `ms` unit marker in the encoding (bare-integer third fields from earlier builds decode as whole seconds). Flooring to seconds classified a server write from the same second but before the forget as a revival, retiring the intent and restoring the stale record. - The forget deadline no longer structurally awaits the losing racer: a throwing task group waits for every child, so a revoke suspended on a dependency that ignores cooperative cancellation kept the forget busy past the deadline — the exact stalled-request case it exists to recover from. Unstructured racers resolve a one-shot gate; the deadline returns immediately, cancellation is still requested, and the stalled work unwinds in the background. - The restore's retained-row map uses updateValue(_:forKey:) so the retention of a TEAM-LESS row is explicit rather than relying on optional-wrapping subscript semantics (behavior unchanged — the routed-delete regression already proved the entry was stored). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-25 review findings (bounded pair) - One tagged instance's revival retires the whole DEVICE-WIDE tombstone, dropping suppression and deletion for a stale different-tag record that exists only in another team's backup. - The account-wide parked record stores a nil local team, so offline crash recovery replays only nil-team rows: a concrete-team row whose local delete never landed survives every offline launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact revival retirement; parked records carry their row's team Round-25 review fixes (the two bounded findings): - A revival retires only its EXACT pairing's intent, and the revive-clear mirrors it: one tagged instance returning no longer retires the device-wide tombstone (or clears it on local re-pair), so a stale different-tag record in another team's backup keeps its suppression and still receives its delete. Per-record revival classification lets the revived pairing through everywhere, so retaining the wildcard intent costs the revival nothing; deletes explicitly spare records every covering intent classifies as revived. - Account-wide parked records preserve the captured ROW's local team, so offline crash recovery replays the exact delete for concrete-team rows (a nil local team replayed only nil-team rows). Coverage semantics are unchanged — suppression and echo matching key on the pairing id alone, and the revive-clear cancels the pairing's intents regardless of the recorded team. The two remaining round-25 findings are deferred with rationale in the PR discussion: cross-clock revival ordering (a sound fix needs server-issued causal revisions — a worker protocol change reintroducing a form of server-side tombstones, which this codebase deliberately retired; the strict boundary fails only in the recoverable direction) and post-deadline task abandonment (every dependency in the revoke path is URLSession-backed and cancellation-aware; the detached racer is cancellation-requested and cannot outlive its own bounded requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: widen developmentStoreDirectory to fileprivate for the evidence probe The DEBUG same-device evidence probe struct lives at file scope in MobileIrohRuntimeComposition.swift and cannot reach a type-scoped private static. Caught by the on-device build; host-side SwiftPM tests do not compile the iOS-only cmuxFeature target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop committed review logs from the branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore main's ghostty submodule pin (theme picker fix from #9218) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 1 个月前 | |
iOS: stable Keychain device id + Forget computer (iroh re-key client) (#8888) * iOS: stable Keychain device id + Forget computer (iroh re-key client) Client complement to the broker binding re-key (manaflow-ai/cmux#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy. * iOS: fail closed on unreadable device id, alert on Forget failure, pin account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: harden iroh re-key client per review (device-id, session snapshot) Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @MainActor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured scope, not the live team Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: converge device identity under races, gate snapshot during token transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured team scope, no visibleScope re-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: hidden-computer unhide spinner tracks its own task, not forget's The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget deleting wrong paired-Mac scope Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes each pairing's own captured scope, not the live display scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — session snapshot pairs stale access with rotated refresh authenticatedSessionSnapshot() reads the access and refresh tokens through two separate awaits (currentTokens()), so a concurrent force refresh can rotate the pair between them and hand the broker an old access token with a new refresh token. Neither snapshot guard trips on a plain token rotation. The test scripts that torn store state and asserts the snapshot returns the access minted for the captured refresh, not the stale stored access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: session snapshot derives access from the captured refresh token authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(), which captures the refresh token once and mints the access token FOR that exact refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access always belongs to the returned refresh, so a concurrent forceRefreshAccessToken() can no longer hand the iroh broker an old access token paired with a rotated refresh token. currentTokens() is unchanged for its broader callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget routes backup tombstone to display team A team-less row's backup was uploaded under the row's own (nil) team scope, but forgetting it routes the tombstone to whatever team it happened to be displayed under. The tombstone lands in the wrong per-team backup scope: the row's real backup survives (and a restore under the row's own scope can resurrect the forgotten row), while a same-device record in the displayed team's backup can be wrongly deleted. Replaces the previous test, which asserted the display-team routing as the desired behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route forget backup tombstone to the row's own team scope The forget path routed the backup delete to the team the row was displayed under. For a team-less row that team is arbitrary (legacy visibility shows it under every selected team), while upsert stamps the row and uploads its backup under one resolved team, so the row's own team_id is the only client-side value tied to where the backup lives. Display-team routing also split the pending- delete lifecycle across two scopes: the tombstone was written and flushed under the display team's outbox scope, but a restore under the row's own (team-less) scope never saw it and could resurrect the forgotten row locally. Route the tombstone to the row's own captured team, the same scope the backup was uploaded under, keeping outbox key, local apply, flush, and restore- suppression on one scope. This removes the removeExactScope(backupTeamID:) variant entirely; the 4-arg exact-scope delete already carries the row's own team. Residual: a row uploaded while no team was selected client-side had its backup scope resolved server-side, and that resolution is not echoed back or persisted, so no client-only routing can name that scope with certainty. The symmetric nil route re-resolves through the same server path as the upload. Persisting a server-echoed backup team is a cross-stack follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — pending-delete replay deletes a surviving sibling row A forget whose backup upload fails leaves its tombstone in the outbox; the next read replays it through the broad remove path. TeamScopedPairedMacStore's remove re-resolves the device under the scope's team, which also returns team-less legacy rows, so with the exact row already deleted locally the replay resolves a SURVIVING unrelated alias of the same device and deletes it — the exact over-deletion the exact-scope forget path exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: replay pending backup tombstones through the exact-scope delete A pending tombstone names one exact pairing and its outbox scope key pins the exact (account, team) it was deleted under, so the replay's only job is to finish or confirm that one deletion. Replaying through the broad remove re-resolved visibility on the way down: TeamScopedPairedMacStore looks the device up under the scope's team (which also returns team-less legacy rows) and the build-scope decorator's broad remove drops its team-less fallback alias. In the common failed-upload case the exact row is already deleted, so the broad replay resolved a surviving unrelated alias of the same device and deleted it. Replaying via removeExactScope is a no-op there and, after a crash between the tombstone write and the local delete, removes exactly the named row. Residual: a crash-interrupted BROAD remove now replays exact too, so a team-less build-fallback alias can outlive that narrow window in dev builds; it resurfaces visibly and the next hide drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — wildcard forget leaves the device's sibling rows saved A row with no instance tag cannot name its broker binding, so forgetting it revokes EVERY binding for the device. The local cleanup deleted only the exact nil-tag row, leaving the device's coexisting tagged rows saved locally while their bindings were just revoked: dead entries that resurface in the computer list until the Mac happens to re-register. A tag-known forget stays narrow on both sides (second test, passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: match wildcard forget's local cleanup to its revoke breadth A tag-less row cannot name its own broker binding, so forgetting it revokes every binding of the device for the pinned account. Local cleanup deleted only the exact nil-tag row, stranding the device's coexisting tagged rows as dead entries whose bindings were just revoked. After the wildcard revoke the forget now also deletes the device's tagged sibling rows visible in the captured display scope and owned by the pinned account, each through the same exact-scope removal as the primary row. Tag-known forgets stay narrow on both sides. Rows in other teams' scopes are not enumerable through the scoped store rail and self-heal when the Mac re-registers; rows owned by other accounts keep their live bindings and survive. Closes https://github.com/manaflow-ai/cmux/issues/9078. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — forget mints a Stack token for every broker leg The forget flow captures one coherent session snapshot up front, but the broker token source re-snapshots on every request, and each snapshot now mints a fresh access token over the network. Discovery plus every sequential revoke each add a Stack round-trip, so forgetting a computer with many bindings can stall for minutes and fail during a Stack outage even though the pinned credentials in hand are valid. The test drives a forget across four broker legs through a broker fake that fetches one credential pair per request, exactly like the real client, and expects a single mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: reuse the forget's pinned credential pair for every broker leg The forget captures one coherent session snapshot up front; the broker token source now returns that pinned pair after only the cheap local session check (generation + account), instead of re-capturing a snapshot per request. Each snapshot performs a network token mint, so the old path added a Stack round-trip for the discovery and for every sequential revoke: forgetting a computer with many bindings could stall for minutes and fail during a Stack outage despite holding valid credentials. The pinned pair is coherent by construction, and the access token always travels with its refresh token, so the server can re-mint server-side if it expires mid-operation. A mid-forget sign-out or account switch still fails the check and yields nil, so the revoke fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — tombstone ignores the server-reported backup team A team-less row uploads with a nil team and the SERVER resolves which per-team Durable Object stores it; that resolution is not derivable client-side and can drift by the time the row is forgotten. The new uploadReportingResolvedTeam seam (default: echo unknown) lets a transport report the verified team an upload was stored under; the failing test shows the backing-up store discards the echo and re-resolves nil at delete time, so the tombstone can land in a different team's backup than the record it is meant to delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route delete tombstones to the server-reported backup team A team-less row uploads with a nil team and the presence worker resolves which per-team Durable Object stores it. That resolution is not derivable client-side and can drift by the time the row is forgotten, so re-resolving nil at delete time could send the tombstone to a different team's backup: the forgotten Mac's record survived and restored later, and a same-device record in the wrong team could be deleted. The worker now echoes its verified resolved team in the backup POST and GET responses (from the DO, which receives the verified value). The client persists the echo per pairing in a UserDefaults-backed map owned by the backing-up store, and the tombstone flush groups pending deletes by each pairing's persisted backup team (falling back to the scope's own team when no echo was ever seen), uploading each group to the backup its records actually live in. A flushed pairing's mapping is dropped with its backup record. Legacy rows converge on their next successful upload; restores still fetch the live scope (read-path residual, benign). Closes https://github.com/manaflow-ai/cmux/issues/9076. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling Two gaps in the round-4 fixes. Restored rows never pass through the upload path, so the reinstall case (empty mapping store, rows arriving via restore) loses the server's statement of where their backups live: a later forget re-resolves nil and the wrong-backup deletion returns for exactly the restored rows. The snapshot now carries the worker's echoed resolved team so the restore can persist it. And the wildcard forget's cleanup refreshes the paired list per deleted sibling, re-running the backup restore fetch each time — up to the 256-binding snapshot limit of sequential round-trips for one tap; the new test pins the whole cleanup to at most one refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: persist the restore snapshot's backup team; batch wildcard cleanup The restore path now records the worker's echoed resolved team for EVERY live record in the snapshot (not just locally-written ones — each record lives in that team's backup regardless of the local merge outcome), so a row restored after a reinstall and forgotten later routes its delete tombstone to the backup it actually lives in instead of re-resolving nil at delete time. The wildcard forget now deletes all of the device's rows first and runs ONE refresh (paired list + registry + reconnect hint) after the batch, instead of reloading per deleted sibling — each per-row reload also re-ran the backup restore fetch because the removal clears the restore memo, so a forget covering many bindings issued that many sequential network round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: make the coherent credential pair the broker token source's only input CmxIrohBrokerTokenSource previously accepted independent access and refresh closures with the coherent pair optional. Several production constructions (iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and their two closures each called auth.currentTokens() separately, so a session transition between the two reads could assemble one session's access token with another's refresh token and fail registration, discovery, or revocation. The pair closure is now the ONLY construction input, so a two-source token assembly is no longer expressible; the single-token accessors are derived from the pair. Every construction site provides a coherent capture: pinned-session pairs for the forget flow, pairs captured together up front for sign-out revokes, and a single currentTokens() call per fetch for the runtime paths. The performRequest legacy two-closure branch is gone. No new regression test: the removed hazard is inexpressible at compile time, and CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly one atomic capture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-5 review findings A wildcard forget must delete the device's same-account rows in OTHER teams (their bindings were revoked account-wide and an offline Mac cannot re-register to self-heal); the activation broker's credentials must fail closed after an account switch instead of vending the new session's tokens against the old activation; and a legacy device-id whose Keychain migration cannot persist is NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes the adopt-legacy-despite-failed-persist test and the scope-flip test's sibling-survives assertion, both of which pinned the rejected contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id Round-5 review fixes. The activation path now captures one coherent session snapshot, verifies it belongs to the activating account, and pins the broker token source to it (same helper as the forget path): a mid-activation account switch makes every later leg fail closed instead of mutating the new account's broker state against the old activation's endpoint identity. Wildcard forget cleanup now enumerates the device through a new cross-team loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator forwards it verbatim (its live-team substitution is exactly what the cleanup must see past), the build-scope decorator bounds it to its own build scope, and the backup decorator forwards without triggering a restore. Every same-account row of the device is deleted by its own exact scope, matching the account-wide revoke. DeviceRegistryService no longer reports a legacy UserDefaults id as durable when the Keychain migration write fails: the store was readable (id absent) but nothing durable holds the id, so binding activation defers and retries instead of registering a slot a reinstall would strand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-6 review findings A valid stored access token must be reusable without a network mint (forcing a mint made the session snapshot, and with it broker activation, fail offline despite a usable stored pair); and the persisted backup-team echo must be keyed by the row's own team — the local store deliberately allows the same (account, device, tag) pairing under several teams, so a team-agnostic key let team B's upload overwrite team A's destination and route A's tombstone into B's backup. Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's mint expectation drops to zero accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline Round-6 review fixes, one architectural piece plus three scoped ones. coherentTokenPair() replaces the always-minting snapshot read: capture the refresh token, resolve a usable access token FOR it (the SDK reuses a valid stored access without the network and mints only otherwise), then re-read the refresh — an unchanged refresh proves no rotation crossed the window, a changed one retries. It runs inside the coordinator's bounded token-touching phase. The session snapshot, the iOS quarantine-recovery source, and the macOS host activation source all read through it, so no torn two-await assembly remains and an offline launch with a valid stored pair succeeds. Activation no longer freezes an activation-time pair for the runtime's lifetime (ordinary force-refresh rotation does not bump the session generation, so a frozen pair went stale and stranded relay refresh and discovery until an unrelated reconcile). The activation gate is now a cheap local identity check — no token read, so offline activation still reaches the cached relay/offline-policy recovery — and every broker request re-checks the account/generation pin and re-reads a coherent pair from the store. The backup-team echo mapping key now includes the row's own team, and the forget revoke loop gets a 60-second operation deadline (deadlineExceeded surfaces the failure; applied revokes stand and a retry re-discovers what remains) instead of up to 256 sequential broker timeouts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-7 review findings An ordinary same-account foreground revalidation must not advance the session generation (every generation-pinned broker source would starve after the first foreground), and a UserDefaults device-id mirror must never be adopted when the Keychain authoritatively reports the id absent — the mirror travels in device backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so adoption would make two physical devices fight over one (user, device, tag) slot on every phone upgrade. Also pins persist-and-reuse of refreshed access tokens across repeated coherent captures (contract coverage: the ephemeral side-store defect is not expressible through the fake), and reworks the fakes to model the live store's stale-refresh-persist semantics. Supersedes the legacy-mirror-adoption migration test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: round-7 identity and credential lifecycle fixes Same-account revalidation no longer bumps the session generation: the bump now happens only on a genuine transition (signed-out -> signed-in, or a different account), so generation-pinned broker sources survive ordinary foreground returns while sign-out/sign-in still fences stale flows. The device id is minted fresh when the Keychain authoritatively reports it absent, never adopted from the UserDefaults mirror (which migrates in phone backups and would collide two physical devices onto one binding slot); the mirror remains trusted only while the Keychain is temporarily unreadable. This deliberately drops the seamless pre-Keychain upgrade migration — a one-time re-pair for existing installs — to prevent a permanent cross-device identity collision on every phone upgrade. The coherent pair now resolves the access token through the LIVE store inside the refresh bracket, so a stale token is refreshed once, persisted, and deduplicated by the SDK instead of re-minted per capture through an ephemeral side store. The long-lived activation source reads a full authenticated snapshot per request (atomic identity+credential capture, transition-checked) validated against the activation pin, closing the check-then-read race. Both credential containers get redacted descriptions so reflection cannot copy live tokens into logs or crash reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-8 review findings An in-place upgrade (Keychain absent, mirror holding the id the live binding already uses, no witness recorded) must ADOPT the mirror — minting there changes every existing installation's identity once and strands all of their bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a restored backup) must still mint fresh, and a witness matching this phone adopts. These pin the provenance mechanism that separates the two cases the last two rounds traded against each other. (The tests reference the new witness parameter, so this commit is red at compile time without the fix.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: device-witness provenance for the id mirror; pin the macOS broker source The UserDefaults device-id mirror now carries a per-device witness (identifierForVendor — a value a restored phone does not inherit), written on every mirror update. On authoritative Keychain absence the mirror is adopted only when the witness proves it was recorded on THIS device or predates the mechanism (the in-place upgrade population, whose mirror holds the id their live binding already uses); a mismatched witness means a backup restored onto another phone, which mints fresh so two physical devices never share one (user, device, tag) slot. The locked-Keychain fallback applies the same test. Residual: restoring a PRE-witness backup onto a new phone is indistinguishable from an upgrade and adopts — bounded to backups taken before this ships. The macOS host runtime's broker source now mirrors the iOS one: activation verifies the live account, captures the generation, and every request reads an atomic authenticated snapshot validated against that pin, so an A-to-B account switch fails the old runtime's requests closed instead of registering B's credentials against A's endpoint state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-9 review findings A wildcard forget's tombstones must travel in ONE request per destination (a device can carry 256 bindings, and per-row flushes each burn a request timeout); a pending tombstone must be visible to restores of its DESTINATION scope, which must both suppress the deleted record and retry the flush; an unmapped team-less tombstone must PARK instead of shipping with a guessed nil team the server would re-resolve from current account state; and a failed cross-team sibling enumeration is a cleanup failure, not silent success. Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team routing test is superseded by the parked contract, and the crash-intent test becomes the mapping-recovery test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure Round-9 review fixes. Pending backup tombstones are now keyed by their DESTINATION scope — the team whose Durable Object actually holds the record (the persisted echo, else the row's own concrete team) — with the row's LOCAL team encoded in each record for exact local replay. A restore of the destination therefore both suppresses the deleted record while its upload is pending and retries the flush, closing the resurrect-and-never-retry gap of local-scope keying. A team-less row with NO verified destination is parked under the nil-team scope and never uploaded with a guessed nil team; parked intents migrate to their destination and flush once a restore's echo recovers the verified mapping. Legacy single-field records decode as local==scope, preserving old outboxes. Residual, documented in code: while parked, a restore of a different team's scope cannot see the intent and may resurrect the record there; re-forgetting that row routes exactly, which is recoverable — unlike a misrouted destructive delete. removeExactScopes batches several rows: local deletes and outbox writes first, then ONE tombstone flush per destination, replacing the per-row flush that gave a wildcard forget up to one network round-trip per row. The composite deletes the primary and all wildcard siblings through one batch and clears markers only after it succeeds, and a failed sibling enumeration now fails the forget instead of silently claiming success after an account-wide revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-10 review findings A TAGGED forget's revoke is also account-wide for that (device, tag) binding, so same-tag rows in other teams must be cleaned too while different-tag rows survive; and reviving one team's row must clear only THAT row's pending tombstone — the destination-keyed outbox can hold same-pairing records for different local teams, and cancelling them all lets another team's forgotten record survive in the backup and restore later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget: a tagged revoke kills the (device, tag) binding account-wide, so other teams' same-tag rows are dead and get cleaned, while different-tag rows keep their own live bindings and survive; the tag-less wildcard keeps its every-tag breadth. And a revive clears only the pending tombstone whose LOCAL team matches the re-added row — same-pairing records for other local teams in the same destination stay pending, so their forgotten backup records still get deleted instead of surviving to restore later. Legacy unscoped records decode their local team from the scope they sit in and so match only in the re-added row's own scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-11 review findings Three confirmed defects, each with a failing test: - A wildcard forget's exact-scope cleanup silently skips rows whose instance tag is incompatible with this build, while the tombstone still flushes and the forget reports success; the revoked-binding row survives to resurface as a dead entry. - Forget clears hidden markers only in the display scope; markers are stored per (user, team), so another team's marker survives its row's deletion and keeps a re-registering Mac unexpectedly hidden there. - A whitespace-only persisted device identity classifies as .found, so the corrupt-item repair deadlocks: the mint path re-reads and adopts the same whitespace value and every launch advertises an invalid opaque device id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact-scope deletes match wildcard breadth; markers and identity repair Round-11 review fixes: - The build-compatibility store no longer guards exact-scope deletes. An exact-scope delete targets a row the cleanup explicitly captured from loadAllInstances, and the broker's wildcard revoke is tag-blind, so the local cleanup must cover incompatible tags too; the guard let the tombstone flush and the forget report success while the revoked-binding row survived. Ambient verbs keep the guard. - Forget clears each deleted row's hidden marker in that row's OWN team scope in addition to the display scope. Markers are stored per (user, team); clearing only the display scope left another team's marker to keep a re-registering Mac unexpectedly hidden there. - KeychainDeviceIdentityStore classifies a whitespace-only item as corrupt (.absent), so the duplicate-item repair path overwrites it instead of endlessly re-adopting it as .found; the in-memory test double mirrors the contract, now documented on DeviceIdentityStoring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-12 review findings - A pre-witness UserDefaults mirror is adopted on authoritative Keychain absence with no proof this is the same physical device; a backup taken before the witness shipped restores onto a new phone and clones the old phone's (user, device, tag) binding slot. - A concrete-team restore neither suppresses nor resolves a PARKED unknown-destination tombstone, so the supposedly forgotten computer is resurrected locally and its backup survives every future restore. - A partially failed batched cleanup still runs the post-forget refresh, whose rowless-marker migration clears the deleted primary's hidden marker — the retry entry disappears while the failed sibling row keeps its already-revoked binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve Round-12 review fixes: - Pre-witness mirror adoption now requires device-continuity evidence: a non-migrating artifact proving the install continues on this hardware. The probe is the iroh endpoint identity — in Release an AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a backup, and one every install with a live binding necessarily has. A restored pre-witness backup on a new phone lacks it and mints fresh (no more cloned (user, device, tag) slots); an in-place upgrade with a binding has it and keeps its id; an install that never activated iroh mints harmlessly. Both production device-id callers pass the same probe so concurrent resolutions agree, and the locked-Keychain mirror branch defers instead of trusting a possibly-restored mirror. - Every restore's suppression list now includes the account's PARKED (unknown-destination) tombstones, and a verified team's snapshot echo resolves any parked intent whose pairing it contains: the mapping is recorded under the parked record's own key and the parked scope flushes, migrating the intent to its destination and deleting the backup. A forget the user was told succeeded can no longer be resurrected by the next restore. FakeBackup now honors successful delete uploads in its snapshot, mirroring the server. - The post-forget refresh runs only after COMPLETE cleanup, so a partial batch failure keeps the hidden entry as the retry owner instead of letting the rowless-marker migration clear it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-13 review finding A forget's cleanup enumerates only the LOCAL store, but backups live in per-team Durable Objects and only the selected team's backup has been restored on this phone. The same device's records in another team's backup get no tombstone even though the wildcard revoke killed their bindings account-wide; switching to that team later restores the supposedly forgotten computer as a dead entry. FakeBackup gains a per-team-bucket mode to model the server's per-team storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-wide forget tombstones; device-id resolution off the UI actor Round-13 review fixes: - A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in addition to the routed per-row intents. Backups are per-team Durable Objects and only restored teams have local rows, so the local enumeration cannot match the broker revoke's account-wide breadth; the parked intent suppresses the pairing in EVERY team's restore, each verified snapshot that proves its team holds the pairing gets a direct delete (a tag-less intent is the device-wide wildcard and matches every tag, with the snapshot supplying the concrete tags), and the intent persists until a re-pair revives the pairing. Parked intents no longer migrate to a single destination — no single team could retire an account-wide tombstone. - Durable device-id resolution moved off the MainActor for activation: a private actor captures the identifierForVendor witness with one MainActor hop and runs the Keychain reads/writes, defaults mirror, and continuity probe on its own executor, restoring the off-UI-actor guarantee the merge reconciliation had dropped. DeviceRegistryService gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for such callers, and currentDeviceWitness() is public. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-14 review findings - Parked (account-wide) tombstones replay their local delete only when the nil-team scope itself is requested, so an offline launch after a crash keeps showing the supposedly forgotten computer: crash recovery must be network-independent. - The parked tombstone set retires only on revive and grows by every forget forever — unbounded persisted size and per-restore scan work; retention must be bounded. The forget-deadline scope finding (discovery and in-flight broker calls can suspend past the deadline) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: network-independent parked replay, bounded retention, full forget deadline Round-14 review fixes: - Both restore entry points now replay the account's PARKED tombstones locally before any backup fetch, so crash recovery (outbox written, local delete never landed) works offline instead of depending on the restore's suppression list reaching the network. - The parked account-wide tombstone set is bounded at 256 entries (matching the discovery wire cap): intents are deduped by identity, stamped with a coarse insertion time via an injected clock, and evicted oldest-first when over the cap — an evicted intent's forget has had the longest time to propagate, and losing one degrades to the pre-account-wide behavior for that single pairing. Routed records' encodings are unchanged, so exact-string outbox clearing still works. - The forget deadline now bounds the WHOLE operation: forgetComputer races credential capture, discovery, backpressure waits, and every revoke against a cancellable sleeper, cancelling in-flight broker work at the deadline instead of only checking between revokes; the per-revoke clock checks remain as a cheap early exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature Round-15 review findings — both compile errors in the iOS-only targets (no host-runnable or CI compile covers them, so no regression test is practical): - deviceLocalIrohIdentityExists (and its directory helper) are nonisolated so the off-main resolver actor's synchronous continuity probe closure can call them without a MainActor hop. - The sign-out test fake still optional-bound credentialPair from before it became the token source's only, non-optional input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: forget deadline sleeper becomes static — extensions cannot hold storage Round-16 review finding: the cancellable sleeper was declared as an instance stored property inside the extension that hosts the forget flow, which does not compile. Static storage keeps the bounded-timeout shape unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-17 review finding A completed same-account sign-in (fresh credential exchange while already authenticated) preserves the session generation, so operations pinned to the prior session — the forget flow's frozen credential pair, the activation runtime's pinned source — keep passing the session fence with the replaced session's authority. The sibling round-17 finding (the activation path creates the iroh endpoint identity before the device-id continuity probe checks for it, so a restored pre-witness backup sees its own moments-old identity as continuity evidence) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: sign-in always advances the session generation; probe before identity Round-17 review fixes: - applySignedInUser now takes an explicit SessionPublication reason: a completed credential exchange (.signIn) always advances the session generation, even for the same account, because the token session was replaced and prior-session pins must fail closed; only .revalidation (foreground/startup re-checks of the already-published session) preserves the generation for the same account. - The activation path resolves the durable device id BEFORE creating the iroh endpoint identity. The continuity probe treats a device-local identity as proof the install continues on this hardware; creating the identity first handed a phone restored from a pre-witness backup its own moments-old identity as evidence and adopted the migrated mirror id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: drop @MainActor child annotation the isolation checker cannot verify The hosted iOS build fails on the forget-deadline task group: "pattern that the region-based isolation checker does not understand how to check" at the @MainActor-annotated child. The plain child hops to the MainActor implicitly at the revokeMatchingBindings call, which is exactly what the annotation expressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-19 review findings - The upload echo is keyed by the live display team, but loadAll's legacy visibility can match a TEAM-LESS row: the forget then looks the mapping up under the row's own nil team, misses it, and parks the tombstone — undeliverable when the network is down at echo time. - A parked delete suspended in its upload can race a concurrent re-pair on the reentrant actor: the revive clears the intent and uploads the record, the older delete lands after it, and nothing repairs the wiped backup. - A partially failed batch cleanup returns before clearing ANY markers; rows deleted before the failure can never be re-enumerated on retry, so their per-team hidden markers keep a re-registering Mac hidden. FakeBackup gains an on-delete-upload hook (to interleave a mutation inside the uploader's suspension window), record-op application to its buckets, and a post-construction fetch-failure switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup Round-19 review fixes: - The upload echo's mapping is keyed by the ROW's stored team (mac.teamID), not the live display scope: loadAll's legacy visibility matches team-less rows under a selected team, and the forget looks the mapping up under the row's own team — a display-keyed echo was never found, leaving the tombstone parked and undeliverable offline. - Both delete uploaders (the concrete-scope flush and the parked echo resolver) now fence against the actor's reentrancy: any sent tombstone whose outbox record vanished during the upload suspension was revived by a concurrent re-pair, so its current local row is re-uploaded — the stale delete can no longer silently wipe the just-revived backup. The concrete flush also retires only the records it SENT, so intents added during the suspension survive to their own flush, and revived records keep their freshly re-saved mapping. - A partially failed batch cleanup clears the markers of rows it DID delete — narrowly: only the deleted row's own team key and the user-wide key, never the display scope, which the failed scope (the retry owner) shares. Rows deleted before the failure can never be re-enumerated on retry, so this is the only moment their markers can be cleared. FakeBackup applies record uploads to its per-team buckets only; the legacy single-bucket mode serves its seeded list to every team, so applying uploads there would leak one team's mirror into every other team's restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-20 review findings - The account-wide parked intent is inserted only AFTER the batch's local deletes have awaited; a Mac re-registering during that window clears the routed tombstone but cannot clear the not-yet-created parked intent, which then suppresses the revived pairing forever. - The flush retires sent tombstones by set subtraction computed AFTER its post-upload awaits; a re-pair plus second forget during those awaits re-adds the identical encoded record, which the subtraction silently consumes — an undelivered second tombstone loses its retry. - The persisted backup-team mapping grows without bound: entries retire only when THIS device delivers the pairing's tombstone. Test doubles: a paired-Mac store and a team-mapping store that fire a one-shot hook inside their suspension windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: park before deletes, atomic flush retirement, bounded team mapping Round-20 review fixes: - removeExactScopes resolves accounts and persists the account-wide parked intents BEFORE the first local-delete suspension, so a Mac re-registering during a delete clears every tombstone covering its pairing — routed and parked alike — instead of leaving a stale account-wide intent that would suppress the revived pairing forever. The parked scope now also dedupes by identity in addPendingDelete and applies the same oldest-first cap there, so a row intent never stacks a second encoding beside its account-wide twin and single exact-scope removes cannot grow the scope unbounded. - The concrete flush retires its sent tombstones atomically in one actor turn right after the upload (synchronous cache read + write), before the mapping-cleanup and repair awaits: a re-pair plus second forget interleaving those awaits re-adds its identical record AFTER retirement and keeps its own retry. - The persisted backup-team mapping is bounded at 512 entries with move-to-newest insertion order and oldest-first eviction; losing an evicted mapping degrades that pairing's next forget to the parked, echo-recovered path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-21 review findings - A parked intent matches later snapshots solely by pairing id and is cleared only by a LOCAL re-pair: when another device re-creates the record, this phone deletes the revival on every restore and keeps the intent forever, making cross-device re-pairing impossible to persist. - The restore echo records every snapshot mapping under the restore team, but LWW can retain a NEWER team-less local row un-stamped; the later forget looks the mapping up under the row's actual nil team, misses, and parks — undeliverable when the network drops. The third round-21 finding (a same-account sign-in advances the session generation but the long-lived activation runtimes stay pinned to the old generation and return nil credentials until restart) is fixed in the same round; it lives in the iOS-only and macOS app targets, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes Round-21 review fixes: - The LONG-LIVED activation runtimes (iOS composition and the macOS host) pin their broker token sources to the ACCOUNT only, not the session generation: every completed sign-in now advances the generation, and a same-account re-sign-in must keep the runtime serviceable — it is the same user, so serving the new session's credentials via the atomic snapshot is correct, where the generation pin stranded the runtime on nil credentials until relaunch. The forget's short-lived frozen pair stays strictly generation-pinned. - The restore echo now fires AFTER the merge and carries, per snapshot record, the RETAINED local row's actual team and the record's creation time. Mappings are keyed by the retained row's own scope (LWW can keep a newer team-less row un-stamped, and the forget looks the mapping up under the row's real team), falling back to the restore scope for records with no local row (the reinstall case). - A snapshot record CREATED after a parked intent's stamp is a REVIVAL — another device re-paired the Mac — and retires the intent instead of feeding it a delete; without this the forgetting phone deleted the revival on every restore forever. Unstamped legacy intents keep the old delete behavior (no boundary is known for them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-22 review findings - A revived record is recognized only AFTER suppression already filtered it out of the merge; with the completed restore memoized, the re-paired Mac stays missing locally until relaunch. - The revival signal compared client-authored createdAt, which another phone preserves across a re-pair; the genuine revival misclassifies as stale and is deleted on every restore. The record model gains the SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never uploaded). - Restore echoes persist mappings one save per record; the production store rewrites its whole state per save, so a large restore does quadratic UserDefaults work. The mapping protocol gains a batched saveAll (default forwards per entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: server-authored revival signal, in-merge revivals, batched mappings Round-22 review fixes: - The worker now surfaces the sync machinery's server-authored per-record write time as serverUpdatedAtMs on the restore read (never accepted from clients — sanitize strips it). Revival classification compares THAT against the tombstone's stamp through a shared skew-margined rule biased toward revival: client-authored createdAt is preserved across re-pairs on other phones and proves nothing. - Restore suppression is now stamp-aware: run() takes suppression entries (pairing + tombstone stamp), and a record every covering tombstone sees as revived MERGES in the same restore instead of being filtered out and stranded behind the completed-restore memo until relaunch. The post-merge echo then retires the covering intents. - Restore echoes persist their mappings through one batched saveAll — the UserDefaults store performs a single read-modify-write of its dictionary and ordering for the whole snapshot instead of a full-state rewrite per record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-23 review findings - The revival skew allowance accepts server writes up to a minute BEFORE the forget as revivals. Forgetting a currently-online Mac whose backup was route-mirrored seconds earlier is the COMMON case; the allowance bypasses suppression, retires the intent, and the supposedly forgotten Mac restores instead of receiving its delete. - A partial batch failure never records a hidden marker for a FAILED undisplayed sibling: the deleted primary's marker turns rowless and is migrated away, so the sibling — with its already-revoked binding — resurfaces as a normal computer with no Hidden Computers entry left to retry from. The third round-23 finding (the sign-out quarantine's destructive retry captures live credentials without pinning them to the pending revocation's account) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: strict revival boundary, pinned quarantine retry, sibling retry markers Round-23 review fixes: - The revival boundary is STRICT: only a server write after the tombstone's stamp counts. Forgetting a currently-online Mac whose backup was mirrored seconds earlier is the common case, and the skew allowance let those pre-forget writes bypass suppression and retire the intent. The residual (phone clock behind the server) fails in the recoverable direction: the revival is deleted once and the other device's next mirror re-uploads it with a fresh server stamp. - The sign-out quarantine's destructive retry pins its credentials to the pending revocation's account through the atomic session snapshot, failing closed if the user switched accounts between the guard and the credential capture. - A partial batch failure records a hidden marker for every SURVIVING failed scope in its own team, so an undisplayed sibling with a revoked binding keeps a durable Hidden Computers retry entry even offline — where the account-wide parked intent cannot yet finish the cleanup. Once any restore completes it, the marker turns rowless and the existing migration clears it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-24 review finding The tombstone stamp is floored to whole seconds while server write times carry milliseconds, so a server write from the same second but BEFORE the forget classifies as a post-forget revival: the intent retires and the stale record restores instead of being deleted. Of the two sibling round-24 findings: the forget deadline race is fixed in the same round (the throwing task group structurally awaits an unresponsive cancelled child past the deadline; it lives in the iOS-only cmuxFeature target with no host-runnable test), and the retained-teams dictionary finding is factually incorrect — assigning a String? through the subscript wraps it (Swift removes only when the assigned expression is already the subscript's doubly-optional type), which the passing restoreEchoTracksTheRetainedTeamlessRow regression proves — but the code switches to updateValue(_:forKey:) to make the retention explicit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: millisecond forget boundary, non-blocking deadline, explicit retention Round-24 review fixes: - Tombstone stamps carry epoch MILLISECONDS with an explicit `ms` unit marker in the encoding (bare-integer third fields from earlier builds decode as whole seconds). Flooring to seconds classified a server write from the same second but before the forget as a revival, retiring the intent and restoring the stale record. - The forget deadline no longer structurally awaits the losing racer: a throwing task group waits for every child, so a revoke suspended on a dependency that ignores cooperative cancellation kept the forget busy past the deadline — the exact stalled-request case it exists to recover from. Unstructured racers resolve a one-shot gate; the deadline returns immediately, cancellation is still requested, and the stalled work unwinds in the background. - The restore's retained-row map uses updateValue(_:forKey:) so the retention of a TEAM-LESS row is explicit rather than relying on optional-wrapping subscript semantics (behavior unchanged — the routed-delete regression already proved the entry was stored). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-25 review findings (bounded pair) - One tagged instance's revival retires the whole DEVICE-WIDE tombstone, dropping suppression and deletion for a stale different-tag record that exists only in another team's backup. - The account-wide parked record stores a nil local team, so offline crash recovery replays only nil-team rows: a concrete-team row whose local delete never landed survives every offline launch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact revival retirement; parked records carry their row's team Round-25 review fixes (the two bounded findings): - A revival retires only its EXACT pairing's intent, and the revive-clear mirrors it: one tagged instance returning no longer retires the device-wide tombstone (or clears it on local re-pair), so a stale different-tag record in another team's backup keeps its suppression and still receives its delete. Per-record revival classification lets the revived pairing through everywhere, so retaining the wildcard intent costs the revival nothing; deletes explicitly spare records every covering intent classifies as revived. - Account-wide parked records preserve the captured ROW's local team, so offline crash recovery replays the exact delete for concrete-team rows (a nil local team replayed only nil-team rows). Coverage semantics are unchanged — suppression and echo matching key on the pairing id alone, and the revive-clear cancels the pairing's intents regardless of the recorded team. The two remaining round-25 findings are deferred with rationale in the PR discussion: cross-clock revival ordering (a sound fix needs server-issued causal revisions — a worker protocol change reintroducing a form of server-side tombstones, which this codebase deliberately retired; the strict boundary fails only in the recoverable direction) and post-deadline task abandonment (every dependency in the revoke path is URLSession-backed and cancellation-aware; the detached racer is cancellation-requested and cannot outlive its own bounded requests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: widen developmentStoreDirectory to fileprivate for the evidence probe The DEBUG same-device evidence probe struct lives at file scope in MobileIrohRuntimeComposition.swift and cannot reach a type-scoped private static. Caught by the on-device build; host-side SwiftPM tests do not compile the iOS-only cmuxFeature target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop committed review logs from the branch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore main's ghostty submodule pin (theme picker fix from #9218) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 1 个月前 | |
Device presence service: Cloudflare Durable Objects realtime layer over the device registry (#5792) * Add cmux-presence Cloudflare Worker: per-team Durable Object presence service Realtime device presence (online/offline) layered over the durable devices/device_app_instances registry. POST /v1/presence/heartbeat, GET /v1/presence/snapshot, GET /v1/presence/subscribe (WebSocket or SSE), Stack bearer auth mirroring web/services/vms/auth.ts, alarm-driven timeout-offline transitions (15s heartbeat / 45s timeout), 24h prune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence worker local end-to-end proof script Drives wrangler dev with real dev-Stack credentials through the full lifecycle: 401 unauthenticated, heartbeat online, SSE + WebSocket subscribe, seen tick, goodbye offline, and alarm-driven timeout offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence deploy-on-push workflow and service docs Path-filtered GitHub Actions: typecheck + unit tests + wrangler dry-run on PRs, wrangler deploy on push to main (DO migrations applied atomically with the deploy). docs/presence-service.md carries the DO-vs-RivetKit decision memo and the ephemeral-presence migration story. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add flagged Mac presence heartbeat sender and iOS typed presence client stub Mac: PresenceHeartbeatClient follows the DeviceRegistryClient pattern (same device UUID and tag, best-effort, auth-gated), default OFF behind the presenceHeartbeatEnabled + presenceServiceURL defaults keys, with a server-owned cadence and a clean-quit goodbye. iOS: PresenceWire typed models + WebSocket subscribe stub in CmuxMobileShell, the seam for the device tree (https://github.com/manaflow-ai/cmux/pull/5648). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound subscribe streams to token expiry and harden request reading Subscribe streams now carry a worker-computed deadline (verified token expiry, capped at 15 minutes) enforced by the DO at delivery and via the alarm, so a revoked token or removed team member cannot keep an old stream alive; clients reconnect with a fresh token and get a fresh snapshot. Adds a per-team subscriber cap (64) and drops stalled SSE readers instead of buffering unboundedly. readBoundedJson now reads the body incrementally and aborts the moment it crosses the 16 KiB cap, so a chunked or lying-Content-Length body can never over-buffer; covered by new unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bind presence devices to their first authenticated owner Mirrors the device-registry ownership guard (a device row pins the registering userId and rejects other users' writes): the first authenticated team member to announce a deviceId owns it in DO storage, and a co-member's heartbeat for that device is rejected with 403 device_owner_mismatch, so presence cannot be forged online or force-cleared offline by another member who learned the device id from snapshots or the registry. Owner pins are pruned with the same 24h alarm pass that bounds the instance map. The local proof now exercises the guard with a real second Stack account in a shared team. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make device-owner pins durable and harden the local proof script Owner pins are no longer pruned with the 24h presence tail (an idle device could be re-claimed by a co-member through the prune window), and new pins are bounded by MAX_OWNERS_PER_TEAM. The proof script keeps secrets off argv via curl config files and skips the owner-guard step with an explanation when both accounts resolve to the same Stack user instead of mistaking a legitimate same-owner 200 for a guard failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split presence Swift types one-per-file with DocC coverage Flatten the PresenceWire namespace into top-level PresenceInstance / PresenceDevice / PresenceSnapshot / PresenceOfflineReason / PresenceUpdate / PresenceClientError / PresenceTokenSource files, each holding one documented major type, and decode the tagged wire frame via PresenceUpdate's custom Decodable (CodingKeys) instead of function-local payload structs. The Mac client moves PresenceSettings to its own pbxproj-wired file and reads the server interval with JSONSerialization to keep PresenceHeartbeatClient single-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Republish attach routes on network path changes The mobile-host listener stays bound when the Mac moves networks or Tailscale flips, and .mobileHostStatusDidChange only fired on listener and connection transitions, so the advertised route set (and the team device registry DeviceRegistryClient mirrors from statusUpdates()) kept the old network's routes until the next listener restart. An NWPathMonitor now runs for the listener's lifetime: a changed path signature (status + interfaces + gateways, order-insensitive) invalidates the resolved-Tailscale-host cache and republishes routes through the same two-phase publish the listener-ready handler uses. A generation guard in MobileRouteResolver discards a resolution that raced the invalidation, so old-path hosts can never land late in the cache. Route-level dedup downstream means path flaps that do not change the route set produce no registry write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document the live cmux-presence-dev staging instance cmux-presence-dev is deployed on the team Cloudflare account with dev Stack Worker secrets provisioned; record its URL, the manual redeploy command, and how to point a dev Mac build at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mirror the registry's real per-team caps in the presence DO The DO capped instances and owner pins at a flat 5000 per team, so one authenticated member could mint thousands of fake deviceIds or tags, bloat every snapshot, and starve legitimate devices out of the budget. checkPresenceCaps (pure, unit-tested) now mirrors the registry route's actual limits: 200 devices per team (owner pins) and 25 instances per device, which structurally bounds the instance map at 5000 without an aggregate check since every stored instance's device holds a pin. Counts are fetched lazily with bounded list() calls only on new-device or new-instance heartbeats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject expired subscribe deadlines and republish on first path observation Two review findings. The DO treated a forwarded x-presence-expires-at that was already past as missing and minted a fresh 15-minute window, so a token that expired between worker verification and DO handling could keep a stream open; resolveSubscribeDeadline (pure, unit-tested) now rejects missing/garbled/past deadlines with 401 and defensively re-caps the rest. The Mac path monitor treated its initial callback as a silent baseline, which swallowed a path change that landed between the listener-ready route publish and the monitor's first observation; the first observation now republishes too (deduped downstream), and only duplicate consecutive observations are skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound the iOS presence stream buffer and scrub proof-script tokens The subscribe AsyncThrowingStream used the default unbounded buffering while the receive loop yields every frame (including the team's 15s seen ticks), so a stalled consumer would grow memory without limit; bufferingNewest(256) bounds it, and a dropped frame at worst leaves the map stale until the snapshot the deadline-bounded resubscribe protocol already guarantees. The local proof script kept $WORK for transcript logs but its curl configs carry live Stack bearer tokens; the cleanup trap now scrubs every token-bearing file on all exit paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * End the presence stream on buffer overflow instead of dropping silently Presence is a stateful snapshot+delta protocol, so a silently dropped transition frame could render wrong live state until the next reconnect (up to the 15-minute deadline). The receive loop now checks the yield result: a .dropped frame finishes the stream with the new PresenceClientError.updatesDropped, so the consumer's reconnect delivers a fresh snapshot first, and .terminated stops the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deterministic handshake in the stale-resolution race test async let does not guarantee the child task entered the resolver and captured the old cache generation before the invalidation runs, so the test could nondeterministically exercise the wrong interleaving. A started semaphore now proves the resolution is in flight before the invalidation, and the gate holds it there until after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract network path observation into MobileHostNetworkPathMonitor MobileHostService owned both the republish action and the raw NWPathMonitor observation (signature computation, duplicate suppression, baseline state). The observation concerns now live in a small dedicated type with the same tested pure functions, so the service keeps a single responsibility: deciding what to do when the path changes. Behavior is unchanged; the existing path-refresh tests now target the monitor type directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Push route changes through presence: heartbeat routes + routes event Heartbeats now carry the instance's attach routes (tri-state: absent = unchanged, [] = no routes), the DO stores them on the presence record as a live cache of the registry row, and a changed set on an online instance broadcasts a 'routes' event so subscribed phones reconnect on the fresh port/IP without polling the registry. Entry filtering and the 16-route bound mirror the registry route; a non-array routes value is rejected rather than coerced so a client bug can never silently wipe pushed routes. * Mac presence heartbeats carry attach routes and beat immediately on change The heartbeat is the realtime twin of the registry write-through: every beat states the full current route set from MobileHostService (empty means pairing off), and a route-set change observed via statusUpdates() fires one immediate out-of-cadence beat so the presence DO can push the fresh port/IP to subscribed phones within a round trip. Debug builds now default the gate on against the dev/staging worker (dev Stack identity matches what cmux-presence-dev verifies), keeping Release default off; both stay explicitly overridable via defaults/env. * Phone subscribes to live presence: device tree online/offline + pushed-route reconnect The phone-side half of the presence service. MobileShellComposite owns one presence subscription (PresenceSubscribing seam, PresenceClient transport) that follows the session: starts on sign-in, tears down with a blanked map on sign-out, restarts from foreground refresh. Stream frames reduce into a pure PresenceMap (snapshot replaces, events upsert) that the device tree overlays on registry rows as live Online/Offline instead of last-seen guesses (en+ja). Route pushes (routes/online events and reconcile snapshots) write through to the local paired-Mac store via the same selectReconnectRoutes merge the registry refresh uses, and kick a reconnect when the active Mac is online but the phone sits disconnected, so a port change reattaches without re-pairing. PresenceInstance decodes routes with per-entry leniency (unknown kinds drop, frames never fail), matching the registry contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence doc reflects shipped clients; deploy job names missing CF secrets explicitly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: offline alarm defers to prune deadline; snapshot route sync is one batch Greptile P1: ensureAlarmFor scheduled offline instances at the 45s offline timeout, so every goodbye burned one no-op DO alarm before the real 24h prune alarm. Delegate to core's nextAlarmTime so the deadline rule lives in one place. Greptile P2: the presence snapshot fanned out one Task per online instance, so a multi-tag Mac could queue duplicate recoverMobileConnection kicks (a late one lands as a spurious resync after reconnect succeeds) with nondeterministic route-upsert order. Process the snapshot's instances sequentially in one task and kick at most one reconnect per delivery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh swift file length budget for presence client growth MobileShellComposite +176 (presence subscription lifecycle), MobileHostService +49 (network path monitor wiring), AppDelegate +4 (heartbeat client). Known debt accepted; MobileHostNetworkPathMonitor was already extracted to its own file to bound the growth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Autoreview fixes: heartbeat test asserts real wire shape; explicit empty route push clears the tree The heartbeat body test read host/port at the route's top level, but mobileHostJSONObject nests them under endpoint, so the assertions could never pass once the suite ran. Assert the nested shape (the same wire contract the registry POST and iOS parser use). applyPushedRoutes treated routes nil and [] identically and returned before touching registryDevices, so an explicit empty push (host advertises no routes) left stale Connect affordances in the device tree. nil now means "not announced" (no-op); an announced set, including [], mirrors to the tree, while the paired-Mac store still keeps last-known-good reconnect routes and only updates on non-empty pushes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route sync discards stale frames after sign-out or account switch The unstructured sync task can suspend in loadPairedMacs/upsert and resume after a different user signed in. Re-check isSignedIn plus the captured requesting user after every suspension, mirroring refreshRegistryDevices' account-switch guard, so a stale frame can never write routes into or kick reconnects for the next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Path observation always invalidates the Tailscale host cache; PresenceMap rollups are per-device A pre-ready initial path observation advanced the monitor's dedup baseline but returned before invalidating the resolver cache, so the .ready publish could reuse TTL-fresh hosts from the previous network with no further path callback coming (toggle pairing off, move networks, toggle on). Invalidate on every observation, before the no-port early return. PresenceMap stored instances flat by deviceId:tag, so deviceSummary scanned the whole team map; the device tree recomputes every visible row's summary per heartbeat mutation, making row projection O(devices x all instances). Group storage by device so a rollup only touches that device's instances (25 max). Adds direct PresenceMap reduction tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route pushes respect the registry's multi-instance ambiguity guard The paired-Mac store is device-level (no tag); the registry refresh only substitutes reconnect routes when exactly one instance advertises any, but the presence push path wrote every instance's routes through, so a tagged debug build's push could repoint the phone's persisted reconnect routes at the wrong build. Gate the store write on PresenceMap's new soleRouteAdvertisingInstance(deviceId:) (exactly one online route-bearing instance, and it is the pusher). The per-tag device-tree mirror stays unconditional. Covered in PresenceMapTests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound cumulative serialized route bytes per heartbeat Route entries were individually unbounded (only the 16KiB request cap applied), so one authenticated member could fill the admitted 200x25 instance caps with near-16KiB route payloads (~78MiB) and blow the Workers isolate memory budget whenever snapshot/alarm materialize the team map, DoSing presence for the team. Cap cumulative serialized routes at 2KiB per instance (worst-case team state ~10MiB), dropping entries past the budget so the host's preferred-first prefix survives. Real route sets are ~100-200 bytes per entry and fit untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope presence service-resolution statics onto PresenceClient (conventions lint) The caseless namespace enum tripped the package-conventions namespace-enum rule; the members now live directly on the owning type. Covariant Self in the default argument replaced with the concrete type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve production presence at presence.cmux.dev custom_domain route in wrangler.toml (cmux.dev zone is on the same Cloudflare account, so the deploy provisions DNS + TLS). Release clients keep a nil default service URL; flipping them to this domain is a follow-up gated on the first production deploy and dogfood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serialize presence route deliveries on the paired-Mac write chain Greptile P1: the per-delivery fire-and-forget task raced on reconnect (snapshot immediately followed by online/routes for the same device), producing concurrent pairedMacStore upserts for one Mac and a possible double reconnect kick. Deliveries now run through performSerializedPairedMacWrite, which appends synchronously on the main actor, so they execute strictly in arrival order; userIsCurrent doubles as the chain's ifStillCurrent entry check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Negative-cache rejected presence auth tokens (security audit MED) An opaque (non-JWT) bearer token skips the client-side expiry short-circuit, so every request carrying a bad token forced an outbound Stack /users/me subrequest — an unauthenticated amplification vector against Stack's rate limits and CF subrequest budget. Rejected tokens are now cached for 10s (bounded by the token's own exp), keyed by token hash like the positive cache. Test asserts 3 rejected requests cost 1 Stack call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix test fetch cast for typecheck * Refresh swift file length budget after rebase onto main Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Path signature includes local IPv4 addresses so same-gateway network moves republish routes Codex review (P2) on the presence PR: two networks can present the same interface name and gateway (two LANs both en0 + 192.168.1.1) while assigning a different local address; the old signature deduped that move and never invalidated/republished routes. The signature now includes the machine's local IPv4 addresses (getifaddrs, up non-loopback interfaces), injectable for tests. IPv6 is excluded deliberately: temporary-address rotation would cause spurious republish churn. Also corrects the reconnect-kick comment in MobileShellComposite: under the multi-instance ambiguity guard, pushed routes are deliberately not persisted and the reconnect uses stored last-known-good routes (cursor bot flagged the old comment's claim that routes were always persisted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(presence): how to upgrade running Durable Objects safely Class migrations vs data-schema: class migrations manage the DO class registry (append-only, atomic with deploy); they do not migrate the shape of stored data. Running objects keep old code until evicted, then hydrate new code against persisted storage, so upgrades = make new code read old data (additive fields, schemaVersion + lazy upgrade, rollout-window tolerance). For presence only the never-pruned owner pins need that care; the live map self-heals via 15s re-announce. * Refresh swift file length budget for post-rebase file sizes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 3 个月前 | |
Add a dev target to the presence deploy workflow (#9161) * Add a dev target to the presence deploy workflow The shared cmux-presence-dev worker could only be redeployed with a personal Cloudflare login on the org account, which most of the team does not have (and local wrangler OAuth tokens rot). presence.yml already holds the org's deploy token as repo secrets for prod, so a `target` dispatch input (prod default, dev = wrangler.dev.toml) lets anyone keep the shared dev baseline current with `gh workflow run presence.yml -f target=dev`. Also corrects the README, which claimed deploys run on push to main; the workflow is manual dispatch only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fail closed on unknown deploy targets, pass target via env CodeRabbit: interpolating inputs.target into the script is a template-injection pattern (API dispatch is not limited to the UI's choice list), and unknown values fell through to the prod branch. The target now reaches the shell as an env var and anything but dev/prod errors out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 1 个月前 | |
Migrate TS typecheck to tsgo (TypeScript 7 native preview) (#6733) Switch the web, webviews, and presence typecheck commands from tsc to the Go-native tsgo compiler via @typescript/native-preview 7.0.0-dev.20260616.1 (the RC-era build that satisfies the repo's 7-day minimum-release-age install policy; the 7.0.1-rc tag and newer nightlies are still inside the window). This is a side-by-side migration: each package keeps its existing typescript dependency so Next.js, Vite, and eslint continue using a stable programmatic API, and only the dedicated --noEmit typecheck runs on tsgo. ci.yml web-typecheck now calls `bun run typecheck` instead of `bun tsc --noEmit`; presence.yml and react-apps-check already route through the package typecheck scripts. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 2 个月前 | |
Fix Iroh relay policy Vercel deployment (#8118) * Add regression coverage for Vercel build inputs * Fix Iroh relay policy Vercel deployment * Update relay catalog workflow paths * Make relay retry test deterministic * Fix Iroh authorization test module import * Account for Iroh test import --------- Co-authored-by: cmux reload-cloud <cmux-reload-cloud@users.noreply.github.com> | 2 个月前 | |
Device presence service: Cloudflare Durable Objects realtime layer over the device registry (#5792) * Add cmux-presence Cloudflare Worker: per-team Durable Object presence service Realtime device presence (online/offline) layered over the durable devices/device_app_instances registry. POST /v1/presence/heartbeat, GET /v1/presence/snapshot, GET /v1/presence/subscribe (WebSocket or SSE), Stack bearer auth mirroring web/services/vms/auth.ts, alarm-driven timeout-offline transitions (15s heartbeat / 45s timeout), 24h prune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence worker local end-to-end proof script Drives wrangler dev with real dev-Stack credentials through the full lifecycle: 401 unauthenticated, heartbeat online, SSE + WebSocket subscribe, seen tick, goodbye offline, and alarm-driven timeout offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence deploy-on-push workflow and service docs Path-filtered GitHub Actions: typecheck + unit tests + wrangler dry-run on PRs, wrangler deploy on push to main (DO migrations applied atomically with the deploy). docs/presence-service.md carries the DO-vs-RivetKit decision memo and the ephemeral-presence migration story. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add flagged Mac presence heartbeat sender and iOS typed presence client stub Mac: PresenceHeartbeatClient follows the DeviceRegistryClient pattern (same device UUID and tag, best-effort, auth-gated), default OFF behind the presenceHeartbeatEnabled + presenceServiceURL defaults keys, with a server-owned cadence and a clean-quit goodbye. iOS: PresenceWire typed models + WebSocket subscribe stub in CmuxMobileShell, the seam for the device tree (https://github.com/manaflow-ai/cmux/pull/5648). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound subscribe streams to token expiry and harden request reading Subscribe streams now carry a worker-computed deadline (verified token expiry, capped at 15 minutes) enforced by the DO at delivery and via the alarm, so a revoked token or removed team member cannot keep an old stream alive; clients reconnect with a fresh token and get a fresh snapshot. Adds a per-team subscriber cap (64) and drops stalled SSE readers instead of buffering unboundedly. readBoundedJson now reads the body incrementally and aborts the moment it crosses the 16 KiB cap, so a chunked or lying-Content-Length body can never over-buffer; covered by new unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bind presence devices to their first authenticated owner Mirrors the device-registry ownership guard (a device row pins the registering userId and rejects other users' writes): the first authenticated team member to announce a deviceId owns it in DO storage, and a co-member's heartbeat for that device is rejected with 403 device_owner_mismatch, so presence cannot be forged online or force-cleared offline by another member who learned the device id from snapshots or the registry. Owner pins are pruned with the same 24h alarm pass that bounds the instance map. The local proof now exercises the guard with a real second Stack account in a shared team. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make device-owner pins durable and harden the local proof script Owner pins are no longer pruned with the 24h presence tail (an idle device could be re-claimed by a co-member through the prune window), and new pins are bounded by MAX_OWNERS_PER_TEAM. The proof script keeps secrets off argv via curl config files and skips the owner-guard step with an explanation when both accounts resolve to the same Stack user instead of mistaking a legitimate same-owner 200 for a guard failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split presence Swift types one-per-file with DocC coverage Flatten the PresenceWire namespace into top-level PresenceInstance / PresenceDevice / PresenceSnapshot / PresenceOfflineReason / PresenceUpdate / PresenceClientError / PresenceTokenSource files, each holding one documented major type, and decode the tagged wire frame via PresenceUpdate's custom Decodable (CodingKeys) instead of function-local payload structs. The Mac client moves PresenceSettings to its own pbxproj-wired file and reads the server interval with JSONSerialization to keep PresenceHeartbeatClient single-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Republish attach routes on network path changes The mobile-host listener stays bound when the Mac moves networks or Tailscale flips, and .mobileHostStatusDidChange only fired on listener and connection transitions, so the advertised route set (and the team device registry DeviceRegistryClient mirrors from statusUpdates()) kept the old network's routes until the next listener restart. An NWPathMonitor now runs for the listener's lifetime: a changed path signature (status + interfaces + gateways, order-insensitive) invalidates the resolved-Tailscale-host cache and republishes routes through the same two-phase publish the listener-ready handler uses. A generation guard in MobileRouteResolver discards a resolution that raced the invalidation, so old-path hosts can never land late in the cache. Route-level dedup downstream means path flaps that do not change the route set produce no registry write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document the live cmux-presence-dev staging instance cmux-presence-dev is deployed on the team Cloudflare account with dev Stack Worker secrets provisioned; record its URL, the manual redeploy command, and how to point a dev Mac build at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mirror the registry's real per-team caps in the presence DO The DO capped instances and owner pins at a flat 5000 per team, so one authenticated member could mint thousands of fake deviceIds or tags, bloat every snapshot, and starve legitimate devices out of the budget. checkPresenceCaps (pure, unit-tested) now mirrors the registry route's actual limits: 200 devices per team (owner pins) and 25 instances per device, which structurally bounds the instance map at 5000 without an aggregate check since every stored instance's device holds a pin. Counts are fetched lazily with bounded list() calls only on new-device or new-instance heartbeats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject expired subscribe deadlines and republish on first path observation Two review findings. The DO treated a forwarded x-presence-expires-at that was already past as missing and minted a fresh 15-minute window, so a token that expired between worker verification and DO handling could keep a stream open; resolveSubscribeDeadline (pure, unit-tested) now rejects missing/garbled/past deadlines with 401 and defensively re-caps the rest. The Mac path monitor treated its initial callback as a silent baseline, which swallowed a path change that landed between the listener-ready route publish and the monitor's first observation; the first observation now republishes too (deduped downstream), and only duplicate consecutive observations are skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound the iOS presence stream buffer and scrub proof-script tokens The subscribe AsyncThrowingStream used the default unbounded buffering while the receive loop yields every frame (including the team's 15s seen ticks), so a stalled consumer would grow memory without limit; bufferingNewest(256) bounds it, and a dropped frame at worst leaves the map stale until the snapshot the deadline-bounded resubscribe protocol already guarantees. The local proof script kept $WORK for transcript logs but its curl configs carry live Stack bearer tokens; the cleanup trap now scrubs every token-bearing file on all exit paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * End the presence stream on buffer overflow instead of dropping silently Presence is a stateful snapshot+delta protocol, so a silently dropped transition frame could render wrong live state until the next reconnect (up to the 15-minute deadline). The receive loop now checks the yield result: a .dropped frame finishes the stream with the new PresenceClientError.updatesDropped, so the consumer's reconnect delivers a fresh snapshot first, and .terminated stops the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deterministic handshake in the stale-resolution race test async let does not guarantee the child task entered the resolver and captured the old cache generation before the invalidation runs, so the test could nondeterministically exercise the wrong interleaving. A started semaphore now proves the resolution is in flight before the invalidation, and the gate holds it there until after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract network path observation into MobileHostNetworkPathMonitor MobileHostService owned both the republish action and the raw NWPathMonitor observation (signature computation, duplicate suppression, baseline state). The observation concerns now live in a small dedicated type with the same tested pure functions, so the service keeps a single responsibility: deciding what to do when the path changes. Behavior is unchanged; the existing path-refresh tests now target the monitor type directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Push route changes through presence: heartbeat routes + routes event Heartbeats now carry the instance's attach routes (tri-state: absent = unchanged, [] = no routes), the DO stores them on the presence record as a live cache of the registry row, and a changed set on an online instance broadcasts a 'routes' event so subscribed phones reconnect on the fresh port/IP without polling the registry. Entry filtering and the 16-route bound mirror the registry route; a non-array routes value is rejected rather than coerced so a client bug can never silently wipe pushed routes. * Mac presence heartbeats carry attach routes and beat immediately on change The heartbeat is the realtime twin of the registry write-through: every beat states the full current route set from MobileHostService (empty means pairing off), and a route-set change observed via statusUpdates() fires one immediate out-of-cadence beat so the presence DO can push the fresh port/IP to subscribed phones within a round trip. Debug builds now default the gate on against the dev/staging worker (dev Stack identity matches what cmux-presence-dev verifies), keeping Release default off; both stay explicitly overridable via defaults/env. * Phone subscribes to live presence: device tree online/offline + pushed-route reconnect The phone-side half of the presence service. MobileShellComposite owns one presence subscription (PresenceSubscribing seam, PresenceClient transport) that follows the session: starts on sign-in, tears down with a blanked map on sign-out, restarts from foreground refresh. Stream frames reduce into a pure PresenceMap (snapshot replaces, events upsert) that the device tree overlays on registry rows as live Online/Offline instead of last-seen guesses (en+ja). Route pushes (routes/online events and reconcile snapshots) write through to the local paired-Mac store via the same selectReconnectRoutes merge the registry refresh uses, and kick a reconnect when the active Mac is online but the phone sits disconnected, so a port change reattaches without re-pairing. PresenceInstance decodes routes with per-entry leniency (unknown kinds drop, frames never fail), matching the registry contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence doc reflects shipped clients; deploy job names missing CF secrets explicitly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: offline alarm defers to prune deadline; snapshot route sync is one batch Greptile P1: ensureAlarmFor scheduled offline instances at the 45s offline timeout, so every goodbye burned one no-op DO alarm before the real 24h prune alarm. Delegate to core's nextAlarmTime so the deadline rule lives in one place. Greptile P2: the presence snapshot fanned out one Task per online instance, so a multi-tag Mac could queue duplicate recoverMobileConnection kicks (a late one lands as a spurious resync after reconnect succeeds) with nondeterministic route-upsert order. Process the snapshot's instances sequentially in one task and kick at most one reconnect per delivery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh swift file length budget for presence client growth MobileShellComposite +176 (presence subscription lifecycle), MobileHostService +49 (network path monitor wiring), AppDelegate +4 (heartbeat client). Known debt accepted; MobileHostNetworkPathMonitor was already extracted to its own file to bound the growth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Autoreview fixes: heartbeat test asserts real wire shape; explicit empty route push clears the tree The heartbeat body test read host/port at the route's top level, but mobileHostJSONObject nests them under endpoint, so the assertions could never pass once the suite ran. Assert the nested shape (the same wire contract the registry POST and iOS parser use). applyPushedRoutes treated routes nil and [] identically and returned before touching registryDevices, so an explicit empty push (host advertises no routes) left stale Connect affordances in the device tree. nil now means "not announced" (no-op); an announced set, including [], mirrors to the tree, while the paired-Mac store still keeps last-known-good reconnect routes and only updates on non-empty pushes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route sync discards stale frames after sign-out or account switch The unstructured sync task can suspend in loadPairedMacs/upsert and resume after a different user signed in. Re-check isSignedIn plus the captured requesting user after every suspension, mirroring refreshRegistryDevices' account-switch guard, so a stale frame can never write routes into or kick reconnects for the next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Path observation always invalidates the Tailscale host cache; PresenceMap rollups are per-device A pre-ready initial path observation advanced the monitor's dedup baseline but returned before invalidating the resolver cache, so the .ready publish could reuse TTL-fresh hosts from the previous network with no further path callback coming (toggle pairing off, move networks, toggle on). Invalidate on every observation, before the no-port early return. PresenceMap stored instances flat by deviceId:tag, so deviceSummary scanned the whole team map; the device tree recomputes every visible row's summary per heartbeat mutation, making row projection O(devices x all instances). Group storage by device so a rollup only touches that device's instances (25 max). Adds direct PresenceMap reduction tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route pushes respect the registry's multi-instance ambiguity guard The paired-Mac store is device-level (no tag); the registry refresh only substitutes reconnect routes when exactly one instance advertises any, but the presence push path wrote every instance's routes through, so a tagged debug build's push could repoint the phone's persisted reconnect routes at the wrong build. Gate the store write on PresenceMap's new soleRouteAdvertisingInstance(deviceId:) (exactly one online route-bearing instance, and it is the pusher). The per-tag device-tree mirror stays unconditional. Covered in PresenceMapTests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound cumulative serialized route bytes per heartbeat Route entries were individually unbounded (only the 16KiB request cap applied), so one authenticated member could fill the admitted 200x25 instance caps with near-16KiB route payloads (~78MiB) and blow the Workers isolate memory budget whenever snapshot/alarm materialize the team map, DoSing presence for the team. Cap cumulative serialized routes at 2KiB per instance (worst-case team state ~10MiB), dropping entries past the budget so the host's preferred-first prefix survives. Real route sets are ~100-200 bytes per entry and fit untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope presence service-resolution statics onto PresenceClient (conventions lint) The caseless namespace enum tripped the package-conventions namespace-enum rule; the members now live directly on the owning type. Covariant Self in the default argument replaced with the concrete type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve production presence at presence.cmux.dev custom_domain route in wrangler.toml (cmux.dev zone is on the same Cloudflare account, so the deploy provisions DNS + TLS). Release clients keep a nil default service URL; flipping them to this domain is a follow-up gated on the first production deploy and dogfood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serialize presence route deliveries on the paired-Mac write chain Greptile P1: the per-delivery fire-and-forget task raced on reconnect (snapshot immediately followed by online/routes for the same device), producing concurrent pairedMacStore upserts for one Mac and a possible double reconnect kick. Deliveries now run through performSerializedPairedMacWrite, which appends synchronously on the main actor, so they execute strictly in arrival order; userIsCurrent doubles as the chain's ifStillCurrent entry check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Negative-cache rejected presence auth tokens (security audit MED) An opaque (non-JWT) bearer token skips the client-side expiry short-circuit, so every request carrying a bad token forced an outbound Stack /users/me subrequest — an unauthenticated amplification vector against Stack's rate limits and CF subrequest budget. Rejected tokens are now cached for 10s (bounded by the token's own exp), keyed by token hash like the positive cache. Test asserts 3 rejected requests cost 1 Stack call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix test fetch cast for typecheck * Refresh swift file length budget after rebase onto main Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Path signature includes local IPv4 addresses so same-gateway network moves republish routes Codex review (P2) on the presence PR: two networks can present the same interface name and gateway (two LANs both en0 + 192.168.1.1) while assigning a different local address; the old signature deduped that move and never invalidated/republished routes. The signature now includes the machine's local IPv4 addresses (getifaddrs, up non-loopback interfaces), injectable for tests. IPv6 is excluded deliberately: temporary-address rotation would cause spurious republish churn. Also corrects the reconnect-kick comment in MobileShellComposite: under the multi-instance ambiguity guard, pushed routes are deliberately not persisted and the reconnect uses stored last-known-good routes (cursor bot flagged the old comment's claim that routes were always persisted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(presence): how to upgrade running Durable Objects safely Class migrations vs data-schema: class migrations manage the DO class registry (append-only, atomic with deploy); they do not migrate the shape of stored data. Running objects keep old code until evicted, then hydrate new code against persisted storage, so upgrades = make new code read old data (additive fields, schemaVersion + lazy upgrade, rollout-window tolerance). For presence only the never-pruned owner pins need that care; the live map self-heals via 15s re-announce. * Refresh swift file length budget for post-rebase file sizes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 3 个月前 | |
design+phase1: local-first device list (Durable Object source + local SQLite cache + extensible sync protocol) (#6120) * Add DESIGN.md for local-first sync (device list first consumer) Generic local SQLite <-> presence DO sync substrate; device list is the first consumer. Protocol nails snapshot+delta with a per-(team,collection) rev logical clock, contiguous-prefix cursor advanced per atomic frame, rev-filtered snapshots with concurrent deltas queued, tombstone GC floor for forced resync, server-authoritative LWW, and an extensibility contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: additive sync/v1 substrate + device-list projection (phase 1 worker) The cloud half of the local-first sync layer designed in plans/feat-do-device-list/DESIGN.md. Additive to the live TeamPresence DO: new storage keys only, presence path untouched, old instances ignore sync.hello and clients fall back to the registry under the flag. - sync.ts: pure sync/v1 wire layer (SyncRecord, hello/snapshot/delta/tick frames, rev as a per-(team,collection) logical clock, snapshot paging, resolveHello floor decision, tombstone GC predicate, schemaVersion stamp). - syncStorage.ts: storage-bound orchestration over a minimal SyncStorage interface (unit-testable with a Map fake): per-collection rev head, upsert-if-shape-changed (quiet cursor on steady heartbeat), tombstone + rev-ordered synctomb: index, gcTombstones raising syncgcfloor:, rev-filtered snapshots, delta catch-up, schemaVersion lazy upgrade. - syncDevices.ts: the devices collection (first consumer). Derives a DeviceRecord projection from presence instances + owner pins; reconciles the whole collection on each write (upsert living, tombstone departed). - do.ts: wires reconciliation onto BOTH write paths (heartbeat + alarm), GC in the alarm, sync.hello over the existing presence WS, sync delta broadcast on the same socket. Single SyncStorage narrowing cast. - tests: 119 pass. syncStorage.test.ts covers the three protocol holes DESIGN flags (frame-atomic cursor, snapshot-races-delete, gc-floor forced resync) plus schemaVersion lazy upgrade, tombstone GC, and derivation idempotency (seen tick / online-offline flip do NOT bump rev; routes/identity/membership do). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: CmuxSyncStore local-first sync package (phase 1 client) The phone half of the local-first sync layer (DESIGN.md §4/§6/§9/§10/§12). A new raw-SQLite3 package mirroring MobilePairedMacStore exactly (actor over a FULLMUTEX connection, CmuxSyncStoring protocol seam, CmuxSyncStoreError enum, PRAGMA user_version lazy migrations, BindValue binder), generalized to one generic sync_records + sync_cursors schema. - CmuxSyncStore: generic local store. Atomic-per-frame apply (applyDelta / applySnapshot commit records + cursor in one transaction = the contiguous-prefix watermark), local.rev>=r.rev stale guard, snapshot rev>=1 reconciliation that EXEMPTS provisional rev=0 rows, monotone cursor, team-scoped keys, the single wire-ms -> stored-seconds boundary. - SyncProtocol: sync/v1 frame codec mirroring the worker; presence frames on the shared socket parse as .unknown (cleanly ignored). - SyncFrameApplier: client apply state machine — snapshot paging buffer + concurrent-delta queue so a delete racing a snapshot is applied after the commit (no ghost), tick advances cursor when idle. - SyncClient: generic transport-agnostic driver (sends sync.hello with the persisted cursors, feeds frames to the applier). - DeviceSyncFacade: typed devices facade -> SyncedDeviceRecord and the existing RegistryDevice UI shape (no new UI model); skips undecodable rows. - PairedMacMigration: transparent, idempotent local->local-cache seeding of existing paired Macs as provisional rev=0 records for instant first render. - MobileDeviceListLocalFirst: the flag (DEBUG-on/Release-off, env + UserDefaults overridable), same seam as PresenceServiceConfiguration. - tests: 27 pass. Covers apply guard, snapshot reconciliation (incl. the rev=0 exemption), cursor monotonicity, paging + concurrent-delete race, local-first render with no network, migration idempotency, ms/seconds boundary, frame codec, flag, and an end-to-end SyncClient hello->apply. Compiles for macOS and iOS arm64 simulator. Shell UI wiring (loadRegistryDevices local-first branch via DeviceSyncFacade.registryDevices, behind the flag with registry fallback) is the final integration step, landing on architecture approval; the facade mapping is in place and tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: fix sync integration regressions from review Three fixes to the worker sync wiring (autoreview P1/P1/P2): 1. Sync frames no longer reach legacy presence-only sockets. A socket is marked sync-subscribed (its negotiated collections persisted on the WS attachment) only after it sends sync.hello; broadcastSync skips any socket not subscribed to the frame's collection. Without this, a list-shape heartbeat would push a sync.delta to old iOS clients whose PresenceUpdate decoder throws on unknown message types, killing their subscribe stream. 2. The heartbeat path reconciles ONLY the heartbeating device (reconcileSingleDevice over its own inst:<deviceId>: instances + owner), not the whole team. Previously every ~15s beat scanned all instances, all owners, and all stored sync records — O(team) per beat / O(N^2) per interval on a hot DO path. Full-collection reconcile (with the pruned- device tombstone sweep) stays on the periodic alarm only. 3. The alarm now includes the next tombstone-GC deadline (nextTombstoneGcTime) in its next-fire calculation, so a fully-offline team still wakes to GC tombstones and advance syncgcfloor past the 7-day retention window. Previously, with no instances left, no alarm was scheduled and tombstones lingered forever. Tests: 124 pass (added reconcileSingleDevice bounded-work + tombstone + single-device-isolation cases, and nextTombstoneGcTime). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix sync client correctness from review Three CmuxSyncStore fixes (autoreview P1/P2/P2): 1. Malformed sync frames no longer silently advance the cursor. A sync.delta/sync.snapshot whose `records` field is missing or not an array now throws (SyncFrameCodec.requireRecords) instead of being treated as an empty frame. Previously a broken delta at rev=N could move the durable cursor to N without applying records, and a broken snapshot could reconcile against an empty set — durably losing records. The client now reconnects/resyncs instead. 2. The sync UI-invalidation callback fires only on an actual commit. SyncFrameApplier.apply now returns whether the store was written / cursor advanced; SyncClient gates onApplied on it. A presence frame (.unknown), an incomplete snapshot page, or a delta queued during paging returns false, so high-frequency presence `seen` traffic on the shared socket no longer drives spurious SQLite reloads and UI invalidations. 3. The migration marker is keyed by (account, team), matching the team scope of the rows it seeds. Previously a single account-only marker suppressed seeding for the same account in a different team, and survived clear(teamID). The marker key is now `migrated:<teamId>:<accountId>` and clear(teamID) deletes the team's markers, so a different team seeds and a re-sign-in after sign-out re-seeds the fallback. Tests: 31 pass (added malformed-frame-throws, apply commit-flag, cross-team re-seed, and clear-removes-marker cases). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: resync on malformed frames, resilient route decode, atomic head writes Round 2 of review fixes (autoreview P1/P1/P1): 1. SyncClient resyncs on a malformed sync frame instead of skipping it. run() now rethrows a SyncFrameParseError.malformed (a frame that claims to be sync but is structurally broken) after resetting in-flight state, so the session reconnects and re-hellos to fill the gap. Only .notJSON / presence noise is skipped. Skipping a malformed delta could leave a rev permanently absent while a later tick advanced the cursor past it. 2. The device facade decodes routes failably per entry. A future route kind or one malformed route no longer drops the whole device row (which would hide a device the registry/presence paths still render). SyncedDeviceRecord .InstanceRecord has a custom decoder that keeps valid routes and skips bad ones, matching the existing per-route decode contract. 3. The DO sync writes are atomic. upsertRecord / tombstoneRecord / lazyUpgradeRecord now commit the record + head (+ tombstone GC index) via a single DurableObjectStorage.put(entries) multi-key write, so storage can never hold a record whose rev exceeds the head (which would make it invisible to catch-up deltas and rev-filtered snapshots). SyncStorage gained the batched-put overload; the Map fake mirrors it. Tests: 124 worker + 32 swift pass (added malformed-frame-resync, one-bad-route keeps device, atomic-write coverage). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: crash-safe tombstone GC floor + raw migration key Round 3 of review fixes (autoreview P1/P2): 1. Tombstone GC raises the resync floor BEFORE deleting any tombstone (and only when it advances). If the alarm is interrupted between the floor write and the deletes, the tombstones linger and are re-GC'd next pass (idempotent) while the floor already forces a client whose cursor predates a GC'd deletion onto a full snapshot — so a missed delete can never be silently lost. Previously the floor was raised last, so a crash after the delete left a stale floor and a permanent ghost device. GC is now a decide-then-mutate two pass. 2. The migration marker stores the RAW team id in its sync_meta key and escapes the team id only when building the LIKE pattern in clear(teamID). Previously the key stored an escaped team id AND clear escaped again, so a team id with `_`/`%`/`\` (e.g. team_1) never matched its own stored key and clear left the marker behind, blocking re-seed on re-sign-in. Tests: 124 worker + 33 swift pass (added a clear-re-seeds test for a team id with LIKE metacharacters). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: snapshot reconciliation tombstones instead of hard-deleting Round 4 review fix (autoreview P1): snapshot missing-record reconciliation preserves the per-record rev watermark. When a completed snapshot omits a local authoritative record, the store now writes a TOMBSTONE at rev = snapshotRev for that id instead of hard-deleting the row. A hard delete dropped the local.rev watermark applyOneRecord relies on, so a delayed/duplicate delta with rev <= snapshotRev (e.g. a queued delta from a reconnect/snapshot overlap) would resurrect the record the snapshot just proved deleted, producing a ghost device. The tombstone is excluded from the live read and its rev makes the guard ignore any later rev <= snapshotRev delta, while a genuinely newer delta (rev > snapshotRev) can still legitimately bring the device back. Provisional rev = 0 rows remain exempt. Tests: 34 swift pass (added staleDeltaCannotResurrectSnapshotDeletedRecord). iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence/ci: backfill on hello, skip sync on idle ticks, wire tests into CI Round 5 review fixes (autoreview P2/P2/P2): 1. Rollout backfill on sync.hello. An existing DO has inst:* presence but no synced:devices:* projection until a heartbeat/alarm rebuilds it after this deploys. handleSyncHello now backfills the projection from the live presence map (syncDeviceRecords) when the devices head is still 0, before resolving frames, so a client subscribing in that window sees currently- present devices instead of an empty snapshot. Additive + idempotent. 2. Steady-state heartbeats do zero sync storage work. The heartbeat path now calls syncOneDevice only when the beat could change list-shape (heartbeatMayChangeListShape: new instance, owner pin, routes/identity change). A pure `seen` tick on a known instance with unchanged identity — the common ~15s beat for every instance — skips the prefix-list + owner read + compare entirely, instead of doing it per instance per interval for no possible delta. 3. CmuxSyncStore tests run in CI. Added a `swift test --package-path Packages/CmuxSyncStore` step to test-ios.yml and added the package to the should_run change detector, so the new sync-store coverage is a real PR gate (the worktree CLAUDE.md warns that unwired tests pass with 0 executed). Tests: 124 worker pass; CmuxSyncStore swift test (34) green via the exact CI command. Worker bundles; typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make sync codec + flag instantiable (package-conventions lint) Round 6 review fix (autoreview P1/P1): the new public all-static namespace types SyncFrameCodec and MobileDeviceListLocalFirst failed the repo-wide namespace-type rule that package-conventions-lint enforces for any Packages/ change (now triggered for this PR by the test-ios workflow edit). - SyncFrameCodec is now an instantiable `struct` with `init()` and instance parse/encodeHello methods (matching CmxAttachTicketCompactCoder). Callers hold one instance (SyncClient gained a stored codec). - MobileDeviceListLocalFirst is now a resolved value: `struct` with an `isEnabled` property and a `resolved(environment:defaults:isDebugBuild:)` factory, instead of a static `isEnabled(...)` namespace. `./scripts/lint-ios-package-conventions.sh` passes. Tests: 34 swift pass. iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: complete rollout backfill + resnapshot ahead-of-head cursors Round 7 review fixes (autoreview P1/P2): 1. The rollout backfill now uses an explicit one-time marker (syncbackfill:<collection>), not head !== 0. A single device's list-shape change makes the head nonzero while other devices that only seen-heartbeat were never projected, so head !=0 did not prove the projection was complete and a sync.hello could serve a partial device set. handleSyncHello now runs the full syncDeviceRecords backfill once, gated on the marker, so every pre-existing presence instance is projected before the first hello resolves. 2. resolveHello forces a snapshot when cursor > head. A client whose cursor exceeds the current DO head (storage reset, rollback, or a cache from a previous DO history) was treated as current — delta mode sent nothing (head <= cursor) and stale/deleted devices persisted forever. cursor > head now triggers a full snapshot + reconciliation so the client converges to current state. Tests: 127 worker pass (added cursor>head resnapshot and backfill-marker independence cases). Typecheck clean; worker bundles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: guard huge JSON revs, project goodbye-with-routes changes Round 8 review fixes (autoreview P1/P2): 1. SyncFrameCodec.intValue no longer traps on an out-of-range JSON number. A valid JSON sync frame with a huge `rev`/`snapshotRev` (e.g. 1e100) used to crash on Int(d); it now goes through intFromDouble which requires finite, integral, in-Int-range values and returns nil otherwise, so the frame surfaces .malformed and the client resyncs (the broken-sync-frame contract). 2. The heartbeat sync gate compares routes directly instead of relying on the `routes` event. A stopping goodbye emits only an `offline` event but can carry new routes (e.g. an empty set); the old gate skipped sync on it, leaving the synced record's attach routes stale until a later alarm. heartbeatMayChangeListShape now returns true when existing.routes != instance.routes (via core routesEqual), so a goodbye-with-routes projects. Tests: 127 worker + 35 swift pass (added huge-rev-is-malformed case). Typecheck clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: cursor 0 always snapshots; fix 2^63 Int boundary Round 9 review fixes (autoreview P1/P2): 1. resolveHello forces a snapshot for cursor 0 (was returning a delta when the GC floor was also 0). A first-time client has nothing and needs the paged snapshot + reconciliation, not an unpaged catch-up delta. This also matches the documented protocol (DESIGN.md §3.5) and fixes a stale-row hole: a client whose cursor was reset to 0 while local records survived now gets a full snapshot that tombstones the stale authoritative rows. 2. intFromDouble compares against the exactly-representable 2^63 with a strict `<`, not `Double(Int.max)`. Int.max (2^63-1) rounds UP to 2^63 as a Double, so `9223372036854775808` passed the old `<= Double(Int.max)` guard and then trapped on Int(d). The new bound rejects it as malformed. Tests: 127 worker + 35 swift pass (updated the cursor-0 resolveHello / resolveHelloFrames expectations to snapshot; added the 2^63 boundary case). Typecheck clean; iOS + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: reset-aware snapshot recovers from a DO history reset Round 10 review fix (autoreview P1): the worker forces a snapshot when a client's cursor is ahead of the DO head (a storage reset/rollback), but the client's monotone apply path could not consume that lower-rev snapshot — present records were ignored (localRev >= snapshotRev), absent records fell outside the [1, snapshotRev] reconciliation, and setCursor's MAX kept the stale ahead cursor — so stale/deleted devices survived forever behind an unrecoverable cursor. applySnapshot now detects a reset (local cursor > snapshotRev) and treats the snapshot as the new ground truth: it force-applies snapshot records unconditionally, reconciles ALL authoritative rows (any rev, not capped at snapshotRev) absent from the snapshot into tombstones, and forces the cursor DOWN to snapshotRev. Provisional rev-0 rows stay exempt. The normal (non-reset) path is unchanged. Tests: 37 swift pass (added reset-recovers-from-ahead-cursor and reset-keeps-provisional). iOS arm64 + macOS compile clean; conventions lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: add collection epoch to detect equal-head DO resets Round 11 review fix (autoreview P1): a DO storage reset/rollback that backfills to the SAME head as a client's cached old history aliased the old rev space — resolveHello returned an empty delta (cursor == head), the client never reconciled, and stale devices survived forever. Adds a collection-history epoch (syncepoch:<collection>, minted once per DO- storage lifetime, re-minted on a reset). It rides every sync.snapshot frame and is sent back in sync.hello: - Worker: readOrMintEpoch/readEpoch; resolveHello forces a snapshot when the client epoch != the server epoch even at an equal head; snapshot frames and resolveHelloFrames carry the epoch; the hello parses an optional epoch. - iOS: SyncWireRecord snapshot frame carries epoch; sync_cursors gains an epoch column; applySnapshot treats an epoch change (or cursor > snapshotRev) as a reset (force-apply records, reconcile all authoritative rows, force cursor + epoch down); the store exposes epoch(); SyncClient sends cursor + epoch in the hello. Tests: 132 worker + 38 swift pass (added epoch mint/stability, snapshot-carries- epoch, resolveHello epoch-mismatch, and the iOS equal-head-epoch-change reset). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: mint epoch on first write; reset tombstones dominate old-history revs Round 12 review fixes (autoreview P1/P1): 1. The collection epoch is minted on the FIRST collection write (prior head 0), atomically with the head, not only lazily on a snapshot read. After a reset wipes storage to head 0, the rollout backfill / first heartbeat rebuild now mints a fresh epoch immediately, so a stale-epoch client at an equal head is still force-snapshotted (previously serverEpoch could read 0, disabling the guard). resolveHelloFrames also mints when head > 0 but epoch 0, covering pre-epoch records written before this shipped. 2. Reset reconciliation tombstones a stale row at max(snapshotRev, localRev), not snapshotRev. A reset drops to a low head while old-history rows carry high revs; tombstoning at the low snapshotRev let a queued old-history delta (rev > snapshotRev) pass the monotone guard and resurrect the row. The higher tombstone rev now dominates any old-history delta for that id. Tests: 134 worker + 39 swift pass (added first-write-mints-epoch, pre-epoch-record minting, and reset-tombstone-blocks-old-history-delta). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: treat a nonzero epoch vs local epoch 0 as a reset Round 13 review fix (autoreview P1): reset detection now triggers on any nonzero incoming snapshot epoch that differs from the local epoch, INCLUDING when the local epoch is 0 (a pre-epoch cache or pre-migration state). The worker force-snapshots a clientEpoch-0 client against a real (epoch-aware) server, but the client previously treated that snapshot as non-reset, so a same-id/same-rev record with a changed payload was skipped by the monotone guard and stale routes/metadata survived the forced resync. The snapshot is now applied authoritatively in that case. A pure first sync (no local rows) is unaffected, and provisional rev-0 rows stay exempt. Tests: 40 swift pass (added nonzero-epoch-vs-local-epoch-0 same-rev-changed- payload reset). iOS arm64 + macOS compile clean; lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: reject live records with no payload; cover package in convention guard Round 14 review fixes (autoreview P1/P?): 1. A LIVE (non-deleted) wire record whose payload is missing or unserializable now throws .malformed instead of being stored as `{}`. The `{}` fallback produced a row the device facade cannot decode (hidden from the list) while the cursor advanced past it — a durably lost row with no resync. The client now resyncs. Tombstones legitimately keep `{}` and are unaffected. 2. Added Packages/CmuxSyncStore to the lint-ios-package-conventions.sh SCOPES so the architecture guard (singleton/Combine/locks/Dispatch/timers/KVO/ free-function/untyped rules) covers the new package, not just the repo-wide namespace-type check. Lint passes (only the expected [String: Any] WARNs for JSON wire parsing, matching the existing MobileCoreRPCSession pattern). Tests: 41 swift pass (added live-record-without-payload-is-malformed; tombstone without payload still parses). iOS arm64 + macOS compile clean; lint passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: bound inbound sync.hello size before parsing Round 15 review fix (autoreview P1): the DO's webSocketMessage parsed client-controlled JSON (the sync.hello) on the live presence DO without an input size bound, a resource-exhaustion vector. The message byte length is now checked against MAX_SYNC_HELLO_BYTES (4 KiB) before JSON.parse; an over-large frame is dropped silently like any other non-hello message. A real hello (a few short collection names + integer cursors/epochs) is well under the cap, and parseHello already bounds the collection-list count, so the two caps together bound the work the DO does per inbound frame. Typecheck/test/bundle clean (134 worker tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: one sync.hello per collection per connection Round 16 review fix (autoreview P2): a repeated sync.hello for an already-subscribed collection is now ignored. Previously every well-formed hello ran the full resolution path (backfill check, storage scan, snapshot serialization + send), so an authenticated member could spam tiny <4 KiB hellos and force repeated full device snapshots on the live DO. The socket already records its subscribed collections on the attachment; handleSyncHello now skips a collection already present there. A client resubscribes/resyncs by reconnecting, which the snapshot-first-on-connect protocol already supports. Typecheck/test/bundle clean (134 worker tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: isolate sync from presence path; reject bool/negative revs Round 17 review fixes (autoreview P1/P2): 1. Sync projection is best-effort and cannot fail the presence path. The heartbeat wraps syncOneDevice in try/catch (presence already succeeded, so a DO storage hiccup or bad stored payload must not turn the live heartbeat RPC into a 5xx for existing hosts). The alarm wraps the sync projection + GC so a sync failure never aborts the alarm before it closes expired subscribers and reschedules — the presence-critical alarm duties. Matches the DESIGN §5 "presence path untouched / additive" guarantee. 2. The Swift codec rejects boolean and negative integer fields. rev/snapshotRev/ cursor/epoch are non-negative; a JSON boolean (bridged to a CFBoolean NSNumber) no longer parses as 1, and a negative value is rejected. These drive SQLite revs and cursor advancement, so an invalid value now forces .malformed/resync instead of persisting an impossible cursor. Tests: 134 worker + 42 swift pass (added boolean/negative-rev malformed cases). Typecheck/lint/bundle clean; iOS arm64 + macOS compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: bound client snapshot-page + queued-delta buffers (DoS hardening) A compromised or misbehaving DO could stream an endless run of `complete: false` snapshot pages, or flood deltas while stalling a never-completing snapshot, growing SyncFrameApplier's in-flight buffers without limit. Cap both (defaults far above the server-bounded record cardinality) and on overflow drop the in-flight build + surface a malformed frame so the transport tears down and re-hellos, the same recovery path as any other structurally broken frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: split CmuxSyncStore + tests under the 500-line file guard Move the low-level SQLite statement helpers (BindValue, exec/bind/ transaction, user_version + cursor/tombstone writers) into CmuxSyncStore+SQLite.swift, and split the test suites into SyncFrameAndProtocolTests.swift, so both the store (490) and each test file (<500) stay under the swift-file-length-budget threshold without a budget bump. Pure code-motion; behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: close 3 autoreview P2 trust-boundary gaps 1. Reject records whose rev exceeds the frame head (SyncProtocol). A forged delta carrying rev=5 with a record at rev=1000000 would persist the poison-high rev and the per-record monotone guard would then ignore every legitimate future update for that id until the server head caught up (durable local-cache poisoning). requireRecords now throws .malformed when any record.rev > head, forcing a clean resync. 2. Dedup repeated collection names within one sync.hello (parseHello). The DO's per-connection guard only dedups across separate hellos; a single hello repeating 'devices' N times amplified into N backfills + N snapshot serializations. parseHello now keeps the first occurrence per name; handleSyncHello also marks each name seen immediately (defense in depth). 3. Bound queued deltas by total RETAINED RECORDS, not frame count (SyncFrameApplier). The prior frame-count bound let one oversized multi-record delta blow past the ceiling. Now sums records across the queue and rejects before append. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: tighten queued-delta default bound to 10k records Queued deltas during snapshot paging are transient overhead the completing snapshot subsumes, so cut a stalled-snapshot producer off an order of magnitude sooner here than on the snapshot pages. The legitimate count is tiny (devices collection is presence-capped well under 10k). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: keep CmuxSyncStore one actor-private file; budget it instead of splitting Autoreview flagged that splitting the store into a +SQLite extension forced the nonisolated(unsafe) sqlite3 handle from file-private to module-internal, weakening the actor-isolation invariant (any future module file could touch the raw handle off-actor). Revert the store split to keep `db` private to the actor, and accept the 619-line file as documented known debt in swift-file-length-budget.tsv (the guard's explicit escape). The test-file split is kept (pure test code, no concurrency invariant). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: add frame-count bound to queued deltas (close empty-delta flood bypass) Autoreview P1: switching the queued-delta guard to a records-only bound opened a bypass — a producer can hold a snapshot open and flood empty (records: []) deltas, each growing queuedDeltas by one entry while adding 0 to the record count, so the bound never trips (unbounded memory). Add an independent frame-count bound (default 10k) enforced alongside the record bound; either overflow drops the build and forces a resync. Test covers the empty-delta flood. Rebalanced the two test files to stay under the 500-line guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: allowlist collections in the applier (bound unrequested-collection growth) Autoreview P2: the per-collection buffer/cursor bounds did not bound the NUMBER of collections, so a misbehaving endpoint could stream incomplete snapshots/deltas/ticks for many distinct collection names (each just under the per-collection ceiling) and grow `builds` + create local cursor state for collections the client never requested. SyncFrameApplier now takes an allowedCollections set and rejects any frame outside it as .malformed (routing through SyncClient's reset+rethrow). SyncClient documents that the composition root must build the applier with allowedCollections matching its subscribed list. Test covers rejection + no leaked cursor state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: enforce collection allowlist in SyncClient by construction Autoreview P2 follow-up: the applier's allowlist defaulted to accept-all, making the unrequested-collection bound opt-in (a production caller could forget to pass it). SyncClient now derives the allowlist from its subscribed `collections` and rejects any inbound frame for a collection outside that set in run() directly, independent of the injected applier's config — the safety invariant is enforced by the client API, not left to each caller. Test uses a default (accept-all) applier and proves the client still rejects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * sync: extract SyncDatabase so the SQLite handle is file-private AND files split Resolves the tension between the two prior review preferences: a +SQLite extension widened the raw handle to module-internal (actor-isolation weakening), while keeping one 619-line file tripped the file-length guard. Extract a SyncDatabase type that owns the raw sqlite3 OpaquePointer as a PRIVATE member and exposes only the binder/exec/transaction/prepare helpers; CmuxSyncStore holds one as a private let. The handle is now never module-visible (isolation invariant holds by construction), and the package splits into focused files (store 492, SyncDatabase 120, row-decode 32) so no budget exception is needed. Pure code-motion + indirection; behavior unchanged, all 49 package tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
iOS: don't lose saved hosts/IPs on upgrade (paired-Mac backup + restore) (#6405) * ios: failing test — paired-Mac store strands data on future schema version Adds the paired-Mac backup/restore design doc and a red regression test: when an older build opens a paired-macs.sqlite3 whose user_version was bumped by a newer build, the store currently throws unknownSchemaVersion and every read fails, surfacing as total loss of the user's saved hosts even though the rows are still on disk. The fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: don't strand the paired-Mac store on a newer on-disk schema version runMigrations threw unknownSchemaVersion when user_version exceeded this build's, failing ensureReady and every read — so a user who upgraded (future schema vN) and then ran an older build saw all saved hosts as gone, though the rows were intact. Schema migrations are additive by contract, so older builds can still read the columns/tables they know. Degrade gracefully: log and read existing rows, never reset user_version (no destructive downgrade marker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: per-user pairedMacs backup collection (server) Adds the first client-owned sync collection. The phone backs up its local saved-host list (including manually typed host/IPs, which live only on-device today) so it survives an app upgrade, bundle-id change, or reinstall. - New POST /v1/sync/paired-macs route → DO RPC backupPairedMacs(teamId, userId, ops), mirroring the trusted heartbeat RPC rather than expanding the live WS inbound surface. - Per-user privacy scoping by physical collection name pairedMacs:<userId> (userId is verified, never client input); outgoing frames are relabeled to the logical `pairedMacs` so the client never sees the suffix. Reuses the whole generic snapshot/delta/tombstone/GC machinery unchanged. - Subscribe forwards the verified x-presence-user-id; the DO pins it on the WS attachment and serves/broadcasts pairedMacs scoped to that user. - Per-user record cap, op bounds, route byte budget (mirrors heartbeat). - bun tests: parse bounds, per-user isolation, cap, relabel, tombstone, no-op idempotency. Full suite 144 pass; typecheck + wrangler dry-run clean. Additive and live-safe: new collection keys only, no class migration, old DO instances ignore the new RPC/collection during rollout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: GET /v1/sync/paired-macs restore path Adds the read side of the per-user backup: DO RPC listPairedMacs returns the live (non-tombstone) saved-host records newest-first, served by GET on the same authenticated, user-scoped route. The phone fetches this on sign-in to restore saved hosts after a reinstall or bundle-id change. Decouples restore from the WS sync client (which is built but not yet wired into the live app). bun test for list ordering + per-user isolation; strict test typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: shape-aware equality for pairedMacs (no rev churn on timestamp drift) A backup upsert whose routes/name/active are unchanged but whose lastSeenAt advanced (every route refresh, and every full reconcile push on sign-in) must not re-mint a rev or broadcast a delta. Compare list-shape only, ignoring timestamps, mirroring the device-list collection. Stored lastSeenAt then tracks the last shape change (correct as-of-rev semantics for restore ordering). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: paired-Mac backup uploader + restore-on-sign-in Wires the iOS side of saved-host durability behind the mobilePairedMacBackup flag (DEBUG-on/Release-off, env/UserDefaults overridable): - PairedMacBackupClient: HTTP client for /v1/sync/paired-macs (POST ops, GET restore), auth mirrors PresenceClient/DeviceRegistryService. - BackingUpPairedMacStore: a MobilePairedMacStoring decorator so EVERY paired-Mac mutation flows through one seam — upsert/remove mirror to the DO best-effort (local stays authoritative); the sign-out wipe (removeAll) is NOT mirrored so the server backup survives for the next sign-in. - PairedMacRestore: on the first signed-in read, merge the backup into the local store — LWW by lastSeenAt (never clobber a newer local edit), insert missing hosts, and honor the backup's active host only when local has none (fresh install), so restore never hijacks the device's current active selection. - Composition root wraps the local store with the decorator when the flag is on and a presence URL resolves. Restore goes over HTTP (GET) rather than the WS sync client, which is built but not yet wired into the live app, so this feature is self-contained. No new user-facing strings (silent background backup/restore). swift test: 7 new tests pass (decorator mirroring, restore LWW/active rules, flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make PairedMacRestore an injectable struct (package conventions) The iOS package-conventions lint forbids caseless enums with only static members (namespace-enum/namespace-type). Convert PairedMacRestore to a struct that takes the store + backup as injected dependencies with an instance run(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: address review — restore memoization, retry, team scope, setActive mirror Fixes from Cursor Bugbot / CodeRabbit / Greptile on the backup decorator: - removeAll (sign-out wipe) now resets the restore memo, so a same-launch re-sign-in restores again instead of returning an empty list (this was the exact sign-out→sign-in path; it was silently broken). - fetchAll returns nil on transport/auth failure (vs [] for genuinely empty), and restore is memoized only on a successful fetch — a transient first-launch failure now retries on the next read instead of stranding restore until restart. - Restore is scoped per (account, team), not per account: the backup DO is per-team, so switching teams re-restores (teamIDProvider injected). - Concurrent first reads share one in-flight restore Task, so a second read can't slip past the memo and observe a half-merged store. - setActive now mirrors the affected account scope to the DO (accurate records read back from the local store), so "select a host without connecting, then reinstall" no longer restores a stale active host. markActive upserts mirror the scope too, preserving the single-active invariant in the backup. - remove only mirrors a delete while signed in (no auth-failing noise for anonymous removals). - Migration test asserts user_version is left untouched (no downgrade marker). swift test: 11 backup + 5 migration tests pass; package-conventions lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): auto-publish this Mac's route to the user's pairedMacs backup DEV-only convenience so a fresh dev iOS build never needs a manual host entry. MacPairedMacBackupPublisher (DEBUG-on, env/UserDefaults overridable) registers the iOS-pairing-listener default on (so an attach route exists without toggling a setting), observes MobileHostService.statusUpdates(), and POSTs this Mac's deviceId+displayName+routes (active) to /v1/sync/paired-macs whenever routes change and the user is signed in. Routes are encoded via CmxAttachRoute so the iOS restore decodes them identically. Best-effort and Release-noop, mirroring PresenceHeartbeatClient. Bridges the dev gap where the registry (localhost) and presence devices projection don't deliver the Mac's route to the dev iOS build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): default iOS-pairing listener ON in DEBUG; drop runtime register The dev self-publisher needs the pairing listener bound so an attach route exists. Registering a UserDefaults fallback at runtime was clobbered by the settings runtime registering the catalog default, so move the default to the source: MobileCatalogSection.iOSPairingHost defaults true in DEBUG, false in Release (an explicit user toggle still wins). Release behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac: wire MacPairedMacBackupPublisher.swift into cmux.xcodeproj The new file was never added to the Xcode project, so it didn't compile, the AppDelegate reference was an undefined symbol, and every macOS build failed (reload-cloud kept the stale binary; CI would fail too). Add the four pbxproj entries (PBXBuildFile + PBXFileReference + Cloud group + app-target Sources phase), mirroring PresenceHeartbeatClient.swift. Verified: the dev Mac now auto-publishes its route to the user's pairedMacs backup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh AppDelegate Swift file-length budget for the publisher wiring The one-line MacPairedMacBackupPublisher.shared.configure(auth:) call (+ its comment) at the composition root grew AppDelegate.swift by 4 lines, tripping the file-length budget guard. Accept the minor known debt: the wiring belongs next to the other client configures. 17593 -> 17597. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: surface restored saved Macs on the disconnected screen Restoring saved Macs into the local store wasn't visible: the disconnected screen only auto-reconnected and otherwise jumped straight to "add device", so a restored Mac (e.g. on a fresh dev build, or when auto-reconnect can't reach it) never showed. Now the disconnected screen loads saved Macs (which also triggers the backup restore) and lists them for one-tap reconnect, only auto-presenting the pairing sheet when there are none to pick. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tapping a saved Mac dialed the phone's own loopback instead of Tailscale A restored/published Mac advertises both a debug_loopback route (127.0.0.1, priority 0) and a tailscale route. DEBUG builds keep .debugLoopback in supportedRouteKinds even on a physical device (for the on-device XCUITest mock host), so firstReconnectHostPortRoute, which picks the lowest-priority supported route, chose 127.0.0.1 — the phone's own loopback — and the connect silently failed without ever trying Tailscale. That made tapping a saved/restored Mac (switchToMac) and stored-Mac reconnect not connect on a device. Fix in route selection, not supportedKinds (XCUITests still need loopback): add preferNonLoopback (true on physical devices, false on the simulator where 127.0.0.1 IS the Mac). When set, a real route always wins over a .debugLoopback route regardless of priority; loopback is used only when it's the sole supported route. Tests cover device-prefers-tailscale, device-loopback-only fallback, and simulator-keeps-loopback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P1): tag workspaces with their Mac (macDeviceID) Foundation for the aggregated multi-Mac workspace list + machine filtering. Adds macDeviceID to MobileWorkspacePreview (additive, defaulted) and stamps it from the connected Mac's ticket where the workspace list is built. Invisible today (single Mac), but every workspace now records which Mac it's from, which P3 (aggregation) and P4 (group/filter by machine) build on. Design in plans/feat-ios-multi-mac-workspaces/DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4a): compound workspace filter (read-state × machine) Replaces the single-dimension All/Unread filter enum with a composable struct: readState (all/unread) × machines (Set<macDeviceID>, empty = all), passing both only when a row satisfies both. Expresses "unread on Mac X and Mac Y" directly. The filter menu gains a machine multi-select section that appears once more than one machine is present (single-Mac users see the unchanged All/Unread control); the list views compile unchanged since .all/.matches/.isActive/.emptyStateText are preserved on the struct. en+ja localized. 6 model tests incl. the compound case. Machine names are wired in once aggregation (P3) provides multiple Macs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4b): machine-list derivation + prune for the filter Pure, tested helpers the filter UI and aggregation need: machineIDs(in:) gives the distinct machines present in a workspace list (first-appearance order, skips unknown-machine rows) to populate the filter's machine multi-select, and pruneMachines(notIn:) drops selections for machines that vanished so a stale machine filter never silently hides everything. Full model suite 52 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P2): per-Mac connection pool foundation Introduces MacConnection {macDeviceID, ticket, route, client, generation} and a connections:[macDeviceID:MacConnection] pool + foregroundMacDeviceID on the composite. The foreground attach now records its entry in the pool and teardown clears it. Additive and behavior-preserving (single-Mac == a pool of one); anonymous (empty-id) tickets are not pooled. This is the structure P3 builds on to open read-only connections to the user's other Macs and aggregate their workspaces. Compiles; route + backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3a): read-only secondary-Mac workspace fetch fetchSecondaryWorkspaceList(for:) opens a short-lived client to another paired Mac (reusing the manualHostTicket + workspace.list path, loopback-deprioritized on device) and returns its workspaces tagged with that Mac's macDeviceID, never touching the foreground connection. refreshSecondaryMacWorkspaces() populates secondaryWorkspacesByMac for every signed-in non-foreground Mac. Additive: not yet merged into the published list, so the single-Mac flow is untouched. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3b): merge other Macs' workspaces into the list (flag-gated) Foreground connect now kicks a background refreshSecondaryMacWorkspaces(), and publishAggregatedWorkspaces() merges the other Macs' rows after the foreground Mac's (de-duped by id, per-Mac order preserved). Gated by multiMacAggregation (env/UserDefaults, DEBUG on / Release off) and a no-op when there are no secondaries, so the single-Mac list is byte-for-byte unchanged. Cleared on teardown. Compiles; route/backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4): surface the machine multi-select in the filter WorkspaceListView derives the machines present in the (aggregated) workspace list and passes them to the filter menu, so the read-state × machine compound filter's machine section appears once more than one Mac has workspaces. Names come from the device tree; single-Mac shows the unchanged All/Unread control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P5): cross-Mac open switches the foreground connection openWorkspace now detects when the tapped workspace belongs to a Mac other than the current foreground connection (aggregated list) and switches the foreground to that Mac before selecting, so the terminal attaches to the right Mac. Gated by multiMacAggregation; no-op for single-Mac. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh MobileShellComposite file-length budget for multi-Mac code The P2-P5 multi-Mac connection pool + aggregation + cross-Mac open added ~196 lines to MobileShellComposite.swift. The methods call private connect/ticket helpers so they can't move to a separate-file extension; accept the known debt. 5566 -> 5762. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh paired-Mac routes from backup before multi-Mac aggregation The aggregated multi-Mac workspace list only showed the foreground Mac's workspaces because secondary Macs' stored routes went stale: refreshSecondary- MacWorkspaces read the local paired-Mac store, but that store is only restored from the backup once per launch (memoized scope). When a secondary Mac relaunched on a new port and republished its route to the per-user backup, the iPhone never re-read it, so the read-only workspace fetch dialed a dead port and that Mac silently dropped out of the list. Fix: add PairedMacBackupRefreshing.refreshFromBackup(stackUserID:) on BackingUpPairedMacStore, which forces a backup re-fetch + LWW merge (bypassing the once-per-launch memo, coalescing with any in-flight restore). refreshSecondary- MacWorkspaces calls it before loadAll, so secondary routes are current before the fetch. LWW by lastSeenAt means the live foreground route is never clobbered. Principled: routes are kept fresh from the authoritative per-user backup at aggregation time, instead of relying on a single sign-in-time restore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect first reachable Mac so home opens on the integrated list The home fell back to the "Your Macs" picker whenever there was no Mac marked active (or the active Mac's stored route was stale), forcing a manual tap before any workspaces showed. Rework the launch auto-connect so the home comes up connected to all Macs and shows one integrated list, without the picker: - Refresh saved-Mac routes from the per-user backup before dialing (LWW), so a Mac that relaunched on a new port is still reachable instead of failing to the picker. - Connect the explicitly-active Mac when reachable, otherwise the FIRST saved Mac with a usable route, instead of bailing when nothing is marked active. The other Macs are aggregated read-only (refreshSecondaryMacWorkspaces) into the same list, so the home is one integrated cross-Mac workspace list. The picker now only appears as the genuinely-offline fallback (no saved Mac has a usable route). Principled: auto-connect targets any reachable saved Mac with fresh routes, rather than depending on a single persisted "active" selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh routes from backup before manual Mac switch too switchToMac dialed the in-memory snapshot's routes, so manually switching to a Mac that had relaunched on a new port could fail on a stale route. Apply the same backup-refresh used by auto-connect and aggregation: refresh the per-user backup, re-read the target from the store, then dial its fresh route (falling back to the snapshot if the re-read yields nothing). Completes route-freshness across every saved-Mac connect path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: prefer IP-literal routes over MagicDNS hostnames for Mac connect/aggregation The multi-Mac aggregated list showed only the foreground Mac because the read-only secondary fetch to another Mac timed out. Root cause (confirmed on device via console diagnostics): a Mac can advertise three attach routes — debug_loopback, a MagicDNS hostname (e.g. <node>.<tailnet>.ts.net), and the raw tailscale IP. firstReconnectHostPortRoute picked the first non-loopback route, which was the MagicDNS hostname. MagicDNS doesn't resolve on every client (the phone here), so the attach-ticket request to the hostname timed out and that Mac was silently dropped from the aggregated list. A Mac that only advertises an IP route (no hostname) connected fine, which is why one Mac showed and the other didn't. Fix: among non-loopback routes, prefer one whose host is a numeric IP literal (IPv4/IPv6) over a hostname, since an IP is dialable without DNS. Falls back to a hostname route when no IP route exists, and loopback only as last resort. firstReconnectHostPortRoute is the shared selector for reconnect, manual switch, and secondary aggregation, so this fixes tap-to-connect to hostname-route Macs too. Added isIPLiteralHost + 3 route-selection tests incl. the exact repro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: re-aggregate other Macs on pull-to-refresh and app foreground The aggregated multi-Mac list fetched each other Mac's workspaces once, on foreground attach. Workspaces created on a secondary Mac afterwards never appeared, because the read-only secondary list is a snapshot, not a live subscription (only the foreground Mac streams workspace.updated). Re-run refreshSecondaryMacWorkspaces from the two natural refresh points: - refreshWorkspaces() (pull-to-refresh) now re-aggregates after reloading the foreground list. - resumeForegroundRefresh() (app returns to foreground) re-aggregates when connected, so switching back to the app surfaces newly-created remote workspaces without a manual pull. Both gated on multiMacAggregationEnabled + an active foreground connection, so single-Mac behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: add transport-agnostic per-Mac workspace state + pure derivation Foundation for deriving the aggregated multi-Mac workspace list from a single source of truth instead of imperatively merging a live foreground list with stale secondary snapshots. MacWorkspaceState is the phone's view of ONE Mac's workspaces (workspaces + groups + liveness), keyed by macDeviceID, carrying NO transport/connection detail. MobileWorkspaceAggregation derives the flat ordered de-duplicated list (foreground first, then by display name) and the group sections as pure functions of [macID: MacWorkspaceState]. Same model + derivation whether each entry is fed by N direct phone->Mac connections (now) or one phone->Durable Object stream delivering per-Mac deltas (planned), so that migration is a transport swap, not a data-model change. 6 derivation tests. Not yet wired into the composite (next commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: derive the workspace list from per-Mac state (slice 2) Wire the transport-agnostic data structure in: workspacesByMac is now the only stored workspace state, and `workspaces`/`workspaceGroups` are materialized derivations (private(set), assigned only by recomputeDerivedWorkspaceState). The foreground sync stream, secondary fetch, optimistic create-workspace/ terminal, preview, and reset all write per-Mac entries; the derived list recomputes via didSet. Anonymous/manual-ticket foreground uses a sentinel key. Deletes the two-sources-of-truth machinery: publishAggregatedWorkspaces (the re-merge band-aid) and secondaryWorkspacesByMac (the snapshot store). The foreground-update-overwrites-then-re-merges race is gone by construction: each Mac owns its entry, the aggregate is a pure function of them. clearRemoteConnection- Context keeps the offline foreground entry and drops only secondaries. Tests: 56 model+composite tests green (incl. new derivation + create/terminal/ preview paths). Test seam setWorkspacesForTesting replaces direct workspaces assignment. The 6 remaining failures are the pre-existing flaky render-grid timing tests, unchanged by this commit. Next (slice 3): per-Mac live workspace subscriptions feed workspacesByMac so remote-created workspaces appear with no refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: live per-Mac workspace subscriptions (slice 3) Each non-foreground Mac now holds a persistent read-only connection with its own live workspace.updated subscription that re-fetches its list on each change and writes its workspacesByMac entry, so a workspace created on another Mac appears with no pull-to-refresh. The derived list recomputes automatically. SecondaryMacSubscription holds the client + a fresh per-connection stream id + the consumer Task. refreshSecondaryMacWorkspaces is now an idempotent reconciler: establish a subscription for each newly-present secondary Mac, drop ones that disappeared or became the foreground. Fully best-effort and additive: any failure (no route, ticket/connect error, stream end) tears that entry down and the pull-to-refresh / foreground re-aggregate path remains the fallback, so a secondary subscription can never crash or block the foreground. Subscriptions are torn down on disconnect/sign-out (teardownSecondaryMacSubscriptions in clearRemoteConnectionContext). This is the N-persistent-connections model approved for now; the same per-Mac entries would later be fed by one phone->Durable Object stream (transport swap, no data-model change). 62 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: never show the Your Macs picker when Macs are saved The auto-connect made the home connect, but the root still fell back to the DisconnectedWorkspaceShellView picker whenever the foreground was not yet connected (initial connect window, or a failed/slow reconnect). Eliminate that: show the integrated workspace list whenever there are saved Macs, auto-connecting in the background, and only show the add-device flow when there are NO saved Macs at all. The list renders whatever has aggregated (foreground + live secondary subscriptions) and its toolbar carries settings/devices/sign-out, so nothing is lost by dropping the picker. Opening a workspace attaches its Mac on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect falls through to the next Mac when one is offline reconnectActiveMacIfAvailable picked a single target (active Mac, else first with a route) and connected once; if that Mac had a stored route but was actually down, the connect failed and the home showed "Mac offline" without trying any other reachable Mac. Build an ordered candidate list (active first, then every other Mac with a usable route) and try each via connectManualHost until one connects, so a single offline Mac never blocks the others. The restoring-gate deadline still caps the UI; the loop keeps trying in the background. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview findings (active-mac deactivation, stale secondary refresh, backup body cap) P1: PairedMacRestore deactivated the currently-active Mac when refreshFromBackup brought a fresher record for it (route refresh before reconnect/aggregation), losing the user's selection. Preserve the existing local active flag when updating an existing record; only honor the backup's active for records missing locally on a fresh install. Regression test added. P2: refreshSecondaryMacWorkspaces (foreground/pull) skipped Macs that already had a subscription, so a suspended/never-pushing secondary stream left a stale snapshot forever. Explicit refresh now reseeds existing secondary clients (and recreates dead ones), so a pull/foreground always updates the aggregate. P2: the paired-Mac backup POST reused the 16 KiB heartbeat cap while accepting up to 200 ops x 2 KiB routes, so legitimate backups 413'd and the best-effort client silently dropped them, staleing the server backup. readBoundedJson now takes a maxBytes; the backup route uses MAX_PAIRED_MAC_BACKUP_BYTES sized to the declared limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 2 (sign-out restore race, stale machine filter blanks list) P1: BackingUpPairedMacStore.removeAll (sign-out wipe) cleared the inFlight map but did not cancel the restore tasks, so a backup fetch suspended across the wipe could resume and re-upsert the previous account's Macs into the emptied local store (privacy boundary). removeAll now cancels in-flight restores, and PairedMacRestore.run checks Task.isCancelled after its fetch and skips all writes. Regression test added. P2: the machine filter was never pruned, so when a filtered Mac left the aggregated list (a secondary disconnected, or fewer than two machines so the filter menu's machine section hid) the stale machine id rejected every row and stranded the user on a blank list with no visible control to clear it. This is the likely "blank black screen" after reconnect churn. WorkspaceListView now prunes filter.machines whenever the present machine set changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-machine avatar color + fix autoreview round 3 (wrong foreground key, secondary refetch storm, offline dead-end) Feature: workspaces from the same Mac now share one avatar color in the aggregated list (MachineAvatarPalette, keyed to macDeviceID with a workspace-id fallback and djb2 spread); the symbol still encodes terminal count. Unit-tested. P1: applyRemoteWorkspaceList wrote the foreground Mac's workspaces under the PREVIOUS foreground key because foregroundMacDeviceID was assigned after the apply. On a Mac A->B switch this stored B's list under A's key and the derived list went stale/empty once the id flipped. Set foregroundMacDeviceID before applying. P1: every secondary workspace.updated push awaited a full workspace.list with no coalescing, so a title/progress churn stream queued repeated full scans and MainActor aggregate updates. Added a per-Mac leading+trailing coalesced refresh (SecondaryMacSubscription.refreshTask/refreshPending) — bounded, no cancel/restart starvation. P2: an offline returning user whose auto-reconnect failed fell through to a workspace list whose only affordance (pull-to-refresh) no-ops while disconnected, with no reconnect control — a dead end. Added store.reconnectOrRefresh (reconnect when offline, refresh when connected), wired pull-to-refresh to it, and added a Reconnect button to the offline status row (localized en/ja). Keeps the integrated list as the only surface — no picker screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 4 (sign-out aggregation race, backup freshness on republish) P1: refreshSecondaryMacWorkspaces captured the account then awaited backup refresh, store load, client creation, and per-Mac fetches before mutating secondaryMacSubscriptions/workspacesByMac, while callers launched it in untracked Tasks. An in-flight pass could resume after sign-out/account switch and write the previous user's Macs/workspaces into the new UI. Added an isAggregationScopeValid guard (signed-in + same account + not cancelled) re-checked after every await before any mutation/connection, routed the pass through a tracked secondaryAggregationTask, and cancel it (plus tear down live secondary subscriptions) on sign-out and full reset. P1: a same-shape backup republish (Mac re-confirming its current live route) no-op'd without advancing the stored lastSeenAt, so the iOS LWW restore skipped the backup and kept dialing a stale local route. upsertRecord gained an opt-in freshnessOf; the paired-Mac path now refreshes lastSeenAt in place (same rev, no delta/broadcast) so restore sees the republish as fresh. Test extended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 5 (unscoped aggregation read, restore-memo race, unbounded paired-Mac tombstones) P1: refreshSecondaryMacWorkspaces allowed a nil/empty account, so loadAll(stackUserID: nil) would read EVERY locally stored Mac across Stack accounts and could publish another account's workspaces into the UI. Now requires a concrete signed-in user before any load/connection (mirrors loadPairedMacs), keeping the post-await scope checks. P1: per-user paired-Mac delete tombstones were never garbage-collected — the alarm only GC'd the devices collection — so an authenticated client churning create/delete grew synced:/synctomb: storage without bound (the live-record cap resets on delete). Added listTombstonedCollections; the alarm now GCs every per-user pairedMacs:<userId> collection that holds tombstones and folds each next-GC deadline into its schedule. P2: a restore suspended at `await task.value` across a sign-out wipe could resume and re-insert restoredScopes (or clobber a post-wipe inFlight entry), making a same-launch re-sign-in skip the backup restore and show an empty list. Added a resetGeneration bumped by removeAll; both restore paths bail if it changed across the await. Tests: paired-Mac tombstone discovery+GC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 6 (restore cancellation per-await, switchToMac stale cache) P1: PairedMacRestore checked Task.isCancelled only once after fetchAll, so a sign-out wipe landing during loadAll or any later upsert let the loop reinsert the previous account's Macs into the wiped store. Now re-checks after the load and before every write, bailing with completed: false. P1: switchToMac hard-failed unless the target was in the in-memory pairedMacs cache, but the multi-Mac aggregation reads Macs straight from the store, so tapping a freshly-restored secondary Mac's workspace no-op'd and stranded the user on a workspace whose Mac never connected. switchToMac now resolves the target from the store (after the backup refresh), falling back to the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: redesign devices screen as Computers management view (no connect step) Since workspaces from every Mac now appear together automatically, the device tree's "connect to a device" step is obsolete. Replace it with a Computers screen that manages the Macs signed in to the account: - One row per computer: machine-colored avatar (same color its workspaces use in the list, via the new shared MachineAvatarColors), name, online/last-seen status from durable-object presence, and workspace count. - Remove a computer via swipe or context menu (confirmed) -> forgetMac. - Add a computer via a toolbar + that opens the existing pairing flow (showAddDevice plumbed root -> shell -> list -> screen). - Drop the instance/tag/workspace expansion tree and Connect affordances; delete the now-dead DeviceTreeExpansionStore (+ tests) and the unused tree row snapshots, keeping only DeviceTreePresence. - New mobile.computers.* strings localized en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 7 (Release preview compile break, ineffective computer remove) P1: rootContent referenced WorkspaceListLayoutPreviewView directly, but that type is compiled only under `os(iOS) && DEBUG`, so a Release/iOS archive failed to type-check the branch ("cannot find ... in scope"). Added a DEBUG-wrapped workspaceListLayoutPreview helper (mirroring terminalLayoutPreview) so Release never names the gated type. P2: the Computers list was built from deviceTreeDevices (prefers the team registry), but Remove calls forgetMac, which only deletes the local paired-Mac backup row — so a registry-backed computer reappeared on the next registry load and Remove looked broken. Build the list from pairedMacs instead: this feature's source of truth, the same set that feeds the workspace aggregation and the exact rows forgetMac removes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: track CMUXMobileRootView in swift file-length budget (preview helper) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 8 (restore not cancelled on sign-out, idle-team tombstone GC, stale secondary rows) P1: signOut() never cancelled in-flight paired-Mac restores (it does not call removeAll), so the cancellation guards could not fire on the normal sign-out path; a restore suspended at its backup fetch could resume — possibly authorized with the next account's live token — and write rows for the previous account. Added PairedMacBackupRefreshing.cancelInFlightRestores (cancel tasks + bump reset generation, without wiping the per-user rows) and call it from signOut. P1: backupPairedMacs created delete tombstones but never scheduled an alarm, so an idle team (no presence instances/subscribers) would never wake to GC them and a create/delete churn grew DO storage unbounded. It now schedules the next tombstone-GC deadline for the user's collection after applying ops. P2: when a secondary Mac's event stream ended, the subscription was removed but its workspacesByMac entry stayed marked connected, leaving dead rows in the aggregate that taps routed into. The stream-end teardown now downgrades that Mac's state to unavailable so the rows show offline until a refresh re-establishes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 9 (forget leaves secondary subscription, failed switch still opens workspace) P1: forgetMac removed the store row but, for a SECONDARY Mac, left its live read-only subscription and workspacesByMac entry intact, so the Computers screen's Remove left the forgotten Mac's workspaces in the list (still updating, tappable) until a later aggregation pass. forgetMac now cancels secondaryMacSubscriptions and clears workspacesByMac for that Mac. P2: openWorkspace awaited switchToMac for a cross-Mac workspace but selected the workspace even when the switch failed (no route / failed connect / fell back to the previous Mac), focusing a workspace whose Mac is not the live connection so terminal input targeted the wrong client. switchToMac now returns whether the foreground connection targets that Mac; openWorkspace bails (leaving the user on the list, with the Reconnect affordance) when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: pop the compact stack when a cross-Mac workspace open fails (autoreview round 10 P1) The tap selects a workspace and pushes its detail synchronously, and openWorkspace runs from that detail's task — so an early return on a failed switchToMac left the user inside a workspace whose Mac never became the foreground connection (terminal input would route to the wrong live client). On switch failure, roll the selection back (selectedWorkspaceID = nil) so the compact stack pops to the list, where the offline row's Reconnect / next aggregation pass recovers the Mac. Known follow-ups (autoreview round 10, narrow edges not on the dogfood path): - sign-out-during-restore cancellation is fire-and-forget; the residual race needs the restore fetch bound to the captured account/team in the backup client. - an empty-macDeviceID QR connect keeps the foreground under the anonymous key and does not migrate workspacesByMac/foregroundMacDeviceID when the real id is adopted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: color computers by distinct position, not a colliding hash (fix two Macs both yellow) The avatar color hashed macDeviceID into 8 slots, so two Macs collided on one color ~1/8 of the time — Lawrence's two real device ids both hashed to slot 2 (yellow). Assign a DISTINCT color index per Mac by sorted device id in the aggregation (MobileWorkspaceAggregation.machineColorIndex), stamp it onto each derived workspace (machineColorIndex), and color the Computers rows from the same store map. Different Macs are now guaranteed distinct up to the palette size; the id hash remains only as a fallback outside the aggregated list. Tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: promote the live secondary connection on cross-Mac open instead of re-dialing (root-cause fix for "Mac offline") Architectural fix. Tapping a secondary Mac's workspace ran openWorkspace -> switchToMac -> connectManualHost -> connect(), which threw away the already-live, authorized read-only client in secondaryMacSubscriptions and re-dialed the foreground from scratch. That re-dial pipeline has several independent failure points — route re-derivation via refreshFromBackup LWW, the offline preflight, and connect()'s connectionGeneration supersession race — any of which strands the user as "Mac offline" even though a working client to that exact Mac exists. switchToMac now first tries promoteSecondaryToForeground: probe the live secondary client, and on success take ownership of it as the foreground connection (reuse the client/route/ticket, start terminal polling, re-aggregate the demoted Mac) with no re-dial. Falls back to the existing re-dial only when no live connection exists. This makes "offline on a reachable, already-aggregated Mac" unrepresentable. First cut of the larger unification (one MacConnection per Mac, foreground as a selector); the write-only `connections` pool and the duplicate connect path collapse in the follow-up. Orthogonal dev-only gap remains: a secondary on an ephemeral port the phone can't refresh (no dev registry) has no live connection to promote. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: Computers screen — drive the dot from the phone's real connection, show presence + route as diagnostics, refresh live while open The connection dot mixed two sources: the phone's live RPC status for the foreground Mac, but the Durable Object presence worker (the Mac's own heartbeat, not the phone's connection) for every other Mac. So a Mac the phone is actively connected to as a SECONDARY showed not-green because presence (unreliable on dev) didn't report it — exactly the "MacBook Pro not green" case. Now the dot is driven by the phone's own per-Mac connection (store.macConnectionStatuses, derived from each MacWorkspaceState.status: green=connected foreground/secondary, orange=reconnecting, grey=not connected), which updates reactively as subscriptions connect/drop. Presence and the dialable route (host:port) move to a separate diagnostic line, so a mismatch — "online via presence but the phone can't connect" — is a visible tailscale/route signal, and the user can see the exact endpoint. While the sheet is open it re-aggregates every 4s so a dropped Mac reconnects quickly. New strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tap a computer for a comprehensive detail/debug sheet Tapping a row on the Computers screen now pushes MacComputerDetailView with the full per-Mac picture, separated so a connection problem is diagnosable: - Connection: the PHONE's live status to this Mac + workspace count + whether it is the active foreground. - Presence (from the Durable Object presence worker): online/offline + last seen, or "unknown", with a footer explaining that presence is the Mac's heartbeat, not the phone's connection, and that online-but-not-connected = a Tailscale/route problem. - Routes the phone can dial: every saved route (kind + host:port), selectable. - Identity: device id, paired-since, route-updated. - Actions: Reconnect, Remove. Rows are NavigationLinks into the sheet; strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-Mac custom name, color, and icon — synced across the user's devices Users can rename a computer and give it a custom color (8 swatches or any color) and icon (curated SF Symbols or any emoji) from the computer's detail sheet. The override wins over the Mac-reported name and the automatic color/icon everywhere: the workspace list avatars, the Computers screen, and the detail sheet. Persistence + sync reuse the existing per-user Durable Object paired-Mac backup: - MobilePairedMac + customName/customColor/customIcon; SQLite store v2 migration (additive nullable columns) + setCustomization (preserves the Mac's reported name/routes/active, bumps lastSeenAt for LWW). - PairedMacBackupRecord (Swift + worker) carries the fields; parse + bounds + pairedMacShapeEqual treat them as shape so a change mints a rev and broadcasts. - BackingUpPairedMacStore uploads the COMPLETE current record on every write (so a route refresh never clobbers a customization) and mirrors setCustomization. - PairedMacRestore applies the fields (LWW) so an edit on device A appears on B. - store.updateMacCustomization persists + uploads + re-derives; the aggregation stamps custom color/icon onto each workspace preview. Color is "palette:<n>" or "#RRGGBB"; icon is an SF Symbol name or an emoji (classified by non-ASCII). Strings localized en/ja. Tests: worker customization sync + shape; restore-applies + setCustomization-preserves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: show a Reconnecting/Reconnect overlay on the terminal when disconnected (fix recurring "black screen") Recurring report: the phone drops its connection (dev route staleness with no registry to refresh) and the workspace detail keeps showing the now-dead terminal surface — an unrendered black screen with only a tiny status pill. The connection is fine to re-establish, but nothing tells the user that or offers an action. WorkspaceDetailView now overlays the terminal with TerminalDisconnectedOverlay whenever macConnectionStatus != .connected: a spinner for .reconnecting, and an offline icon + host + a Reconnect button (-> store.reconnectOrRefresh) for .unavailable. So a dropped connection reads as "Reconnecting…" with a clear action instead of a black void. Localized (reuses mobile.workspace.reconnect). Note: the underlying dev route-refresh gap (a secondary Mac on an ephemeral port the phone can't relearn without the registry) still requires a re-pair on dev; this makes that state visible + recoverable instead of silent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: add wrangler.dev.toml for safe cmux-presence-dev deploys Deploying the dev instance with `wrangler deploy --name cmux-presence-dev` inherits the production presence.cmux.dev custom domain from wrangler.toml (--name only overrides the worker name), STEALING the prod domain from cmux-presence and breaking prod auth (the dev worker uses the dev Stack project). Add a dedicated wrangler.dev.toml (workers_dev = true, no custom domain) so the dev instance stays on its *.workers.dev URL, and point the README at it with a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: encode a per-developer isolated-worker pattern for concurrent dev Problem: cmux-presence-dev is a SINGLE shared worker — last deploy wins, and an unmerged feature (the paired-Mac backup lives only on its branch) exists only on whoever deployed last, so two people working on the worker clobber each other. Pattern: each developer deploys their own cmux-presence-dev-<slug> via scripts/deploy-dev.sh. Each named worker has its OWN Durable Object namespace, so presence + paired-Mac-backup state is fully isolated per dev — any number of people dogfood worker changes at once without collision. Builds point at it via CMUX_PRESENCE_BASE_URL; the shared cmux-presence-dev stays the integration baseline (the script refuses reserved/prod names). To make a tapped iOS DEVICE build honor the override (it sees no shell env), the resolver now also reads an Info.plist key CMUXPresenceBaseURL — precedence env → UserDefaults → Info.plist → Debug default (tested). README documents the full pattern + guardrails; the remaining wiring (reload baking CMUXPresenceBaseURL into the tagged Info.plist next to CMUXDevTag) is flagged as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: track PresenceServiceConfiguration in swift file-length budget Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden paired-Mac v2 migration + bound Computers-screen polling Autoreview findings on the multi-Mac PR: [P1] v2 SQLite migration was neither idempotent nor atomic: it ran the three ADD COLUMN statements then bumped user_version separately, so a kill / disk-full / SQLite error after a partial apply stranded the DB at v1 with some v2 columns present. The next launch re-ran ADD COLUMN custom_name and failed with a duplicate-column error, bricking the paired-Mac store. Now each migration step runs inside one transaction (SQLite DDL + PRAGMA user_version are both transactional, so a partial apply rolls back and retries cleanly), and migrateToV2 only adds columns missing from PRAGMA table_info, which also recovers any dogfood device already left half-migrated by the earlier build. Adds a regression test that seeds a partially-applied v2 schema and asserts recovery. [P2] The Computers sheet polled store.reconnectOrRefresh() every 4s while open, which pulled the DO backup over the network and, when disconnected, re-dialed offline Macs on a fixed timer (battery/network fan-out). The online dots (presence) and secondary workspace lists are already push-driven, so the timer now calls a bounded refreshComputersScreen() (local row reload + coalesced foreground refresh only) on a gentler 10s cadence and leaves offline-Mac dialing to presence-push recovery and the explicit pull-to-refresh / per-Mac Reconnect button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Route multi-Mac workspace mutations + reconnect to the owning Mac Round 2 autoreview findings on the multi-Mac aggregation: [P1] promoteSecondaryToForeground reused a live secondary connection as the new foreground but never started its terminal event stream: it called cancelRemoteOperationTasks() (which does NOT clear terminalEventListenerTask/ ID) and then startTerminalRefreshPolling(), which no-ops while a listener task is still installed. The promoted client got no terminal/workspace/ notification push events, so output stalled until another path restarted the stream. Now stop+start the listener (the existing == listenerID defer guard makes the old listener's async teardown safe). [P2] Aggregated workspace rows can belong to a secondary Mac, but rename/pin/ unread/close all sent to the single foreground remoteClient — wrong Mac, and with a colliding id could mutate a foreground workspace. sendWorkspaceMutation now resolves the workspace's owning Mac (workspaceMutationTarget) and routes to that Mac's client: foreground -> remoteClient + refreshWorkspaces(); a live secondary -> its client + scheduleSecondaryRefresh(); a known offline owner -> no send + snap back (never misroute to foreground). A failed secondary write no longer marks the foreground connection unavailable. [P2] The per-computer detail Reconnect button called reconnectOrRefresh() (foreground/active Mac) and ignored the computer being viewed. It now calls switchToMac(macDeviceID:), which promotes a live secondary to this Mac or re-dials it specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix foreground-Mac ownership: stale rows, switch fast path, test double Round 3 autoreview findings: [P1] Adding setCustomization to MobilePairedMacStoring broke the CmuxSyncStore test target: FakePairedStore conformed with only the old methods. Added a no-op setCustomization to the fake. [P1] On a foreground Mac change (connect A->B, promotion, or a real connect after an anonymous/sign-out session) the previous foreground/anonymous entry was left in workspacesByMac. recomputeDerivedWorkspaceState derives over every entry, so stale rows kept showing and could route actions/opens through stale ownership (regressing the old workspaces = remoteWorkspaces full replacement). Added dropStalePreviousForeground(): on the foreground flip it removes only the old foreground key (never a live/offline secondary, which aggregation re-adds), wired into both the connect path and promoteSecondaryToForeground. [P1] switchToMac's already-foreground fast path trusted the persisted isActive flag, which lags the live connection (promoteSecondaryToForeground writes it via an unawaited Task; stale during reconnect/switch races). It could return success without switching and leave input/mutations on the wrong Mac. Now gates on the live foregroundMacDeviceID == macDeviceID identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix offline-Mac dot + bake iOS presence override; doc team-scope limit Round 4 autoreview findings: [P2] clearRemoteConnectionContext set the global status unavailable but left the retained offline foreground entry in workspacesByMac with status .connected. macConnectionStatuses (the Computers screen's per-Mac dots) derives from those per-Mac states, so a just-disconnected Mac kept showing a green connected dot. Now downgrade the retained entry to .unavailable. [P2] The CMUXPresenceBaseURL Info.plist override was read by PresenceServiceConfiguration but never baked, so a tapped dev device build ignored a per-developer isolated worker. Wired the bake end to end: added the CMUX_PRESENCE_BASE_URL build setting to ios/Config/Shared.xcconfig (empty default) + the CMUXPresenceBaseURL key in ios/Config/Info.plist, and ios/scripts/reload.sh now passes $CMUX_PRESENCE_BASE_URL at both xcodebuild sites (next to CMUX_DEV_TAG). Release/TestFlight stay empty -> unaffected. Updated the worker README (no longer a TODO). [P2] Documented the per-(account, team) backup vs account-scoped local rows scope gap inline at mirrorAccountScope. Solo/single-team users are unaffected; proper multi-team isolation needs a team_id store column (v3 migration), tracked as a follow-up rather than expanding this upgrade-safety PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound Computers timer, key manual reconnects by real id, stop backup clobber Round 5 autoreview findings: [P1] refreshComputersScreen() (the open-sheet 10s timer) delegated to refreshWorkspaces(), which fans out refreshSecondaryMacWorkspaces() to every saved Mac and re-establishes/re-dials missing (offline) subscriptions — the reconnect storm the screen is meant to avoid (my earlier bounding fix was incomplete). It now does a foreground-only reload (riding any in-flight pull-to-refresh) and never initiates the secondary fan-out; recovery stays on presence-push + explicit pull/Reconnect. [P1] A Mac without mobile.attach_ticket.create connects via a synthetic manual-<host>:<port> ticket, and connect() keyed foreground state by ticket.macDeviceID. So a switch/reconnect to such a Mac stamped foreground workspaces with the synthetic id; filters, Computers rows, mutation routing, and aggregation no longer recognized the real Mac as foreground (and could open a duplicate secondary). connect()/connectManualHost now take the real pairedMacDeviceID hint (threaded from switchToMac, reconnect, device-row paths) and key foreground state + the connection pool under it. [P2] The Mac route-publisher omits customName/color/icon, but the worker treated absent fields as part of the record shape, so every Mac heartbeat minted a rev that wiped the user's iOS-set customizations and the next restore cleared them. Fix: iOS uploads now ALWAYS emit the three custom keys (null = reset-to-Auto, authoritative) via a custom encoder; the worker preserves stored customizations for any key an upload OMITS (the Mac), while a present key (iOS) still sets/clears it. Tests on both sides. (Dev worker needs redeploy for dogfood.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stamp foreground rows with real Mac id; migrate test off private workspaces Round 6 autoreview findings: [P1] remoteWorkspacesPreservingSnapshots stamps each foreground workspace with activeTicket?.macDeviceID (the synthetic manual-<host>:<port> id for an attach-ticket-less Mac), and setForegroundWorkspaceState only restamped nil ids — so round 5's real-id foreground KEY did not reach the rows. The same machine then looked like a different Mac (wrong counts/customizations; openWorkspace tried to switch to a nonexistent Mac). setForegroundWorkspaceState now stamps ALL foreground rows with the resolved foregroundMacDeviceID. [P1] The aggregation refactor made public private(set), but the iOS cmuxFeatureTests still assigned store.workspaces directly (7 sites), breaking the feature test target compile. Migrated them to the existing setWorkspacesForTesting DEBUG seam (reachable via @testable import), which writes the foreground per-Mac state so the derived list recomputes identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drain in-flight restores before sign-out wipe (privacy race) Round 7 autoreview finding: [P1] removeAll() (sign-out wipe) cleared the local store BEFORE cancelling in-flight restores. A restore can pass its Task.isCancelled check, suspend inside inner.upsert, then the wipe runs and only afterwards cancels — but cancellation does not withdraw the already-queued upsert, so the previous account's Mac could be written back into the just-emptied store after sign-out (privacy boundary). removeAll now cancels AND DRAINS (awaits) the in-flight restores before wiping, so every pending write completes first and the wipe is final. Adds a deterministic regression test (GatedUpsertStore) that suspends a restore inside upsert across the wipe and asserts the store ends empty; it fails under the old wipe-then-cancel ordering. (The QuickLook finding the reviewer raised is out-of-scope: it comes from the origin/main merge, not this PR's diff, and the helper flagged it as ignored.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Team-safe backup mirror, non-loopback secondary dial, QR identity re-key Round 8 autoreview findings: [P1] mirrorAccountScope uploaded the WHOLE account's local rows into whichever team the backup client targets, so a multi-team user activating a host could copy other-team hosts into the selected team's per-team DO. Removed the whole-account mirror: upsert(markActive)/setActive now upload only the two records whose active flag actually changes (the newly-active host + the previously-active one, now cleared), preserving the backup's single-active invariant without dumping the account. (Local rows still carry no team id; a full team-scoped store is a separate v3-migration follow-up, but the leak vector is gone.) [P1] makeSecondaryClient proved a non-loopback route to fetch the attach ticket but then dialed supportedRoutes.first, which on a physical phone can be a higher-priority debugLoopback (127.0.0.1) — every secondary subscription dialed the phone itself, so the Mac was unreachable and dropped from aggregation. Now dials the proven route (exact host/port match, else any non-loopback, else first). [P2] A compact/anonymous QR pairing connects with an empty macDeviceID, so foreground state lands under the anonymous key with foregroundMacDeviceID nil. applyHostReportedIdentity adopted the real id into activeTicket but never updated the aggregate key, so the Computers screen showed the Mac as not-connected and aggregation (which excludes foregroundMacDeviceID) could open a DUPLICATE secondary to the same Mac. Added adoptForegroundMacIdentity to move/restamp the foreground per-Mac state and connection-pool entry to the reported id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stack teams: team-scoped paired-Mac data + lazy re-scope + nav drawer Implements full Stack-team support on iOS (the team-scope gap autoreview kept flagging is now closed by real per-team scoping rather than a documented caveat). A) Team-scoped local paired-Mac data - v3 SQLite migration adds a nullable team_id column (idempotent, mirrors v2); legacy/pre-v3 rows have NULL team and stay visible under EVERY team (loadAll filter is ) so an upgrade never hides existing hosts. - MobilePairedMac gains teamID; protocol upsert/loadAll/activeMac gain a teamID param with convenience overloads (teamID:nil) so existing call sites compile unchanged. markActive/setActive clear the active flag per (user, team) so activating in team A never deactivates team B. - BackingUpPairedMacStore injects the current team (teamIDProvider) into inner upsert/loadAll/activeMac; PairedMacRestore stamps restored rows with the team whose DO they came from. Multi-team users now only see/dial the active team's Macs. Tests: v2→v3 migration legacy visibility, per-team isolation, decorator injection. B) Lazy re-scope on team switch (keep the live terminal) - MobileShellComposite.currentTeamDidChange() re-subscribes presence, tears down secondary aggregation, invalidates the restore memo, and clears the pairedMacs/registryDevices caches — but never touches the foreground connection, so switching teams does NOT drop the live terminal. Rebuild is lazy (next foreground / Computers .task / pull). CMUXMobileRootView observes selectedTeamID (single mutation path). Test: foreground workspaces survive. C) Left-edge-swipe nav drawer - New MobileNavDrawerView (account header, Stack team list with current checked, Settings, Sign out) + EdgeSwipeDrawerContainer (leading-edge drag + scrim; toolbar button is the primary/accessible entry). Mounted in WorkspaceShellView over both layouts; WorkspaceListView gains a leading drawer button. Tapping a team only writes AuthCoordinator.selectedTeamID (the root re-scopes). en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix sign-out foreground reset, cumulative backup cap, iOS drawer type ref Autoreview round on the teams feature: [P1] signOut seeded the anonymous preview workspacesByMac entry but left foregroundMacDeviceID at the old real Mac id. The next connect() then captured that stale id as previousForegroundKey, so dropStalePreviousForeground (the round-6 stale-row fix) dropped the WRONG key and the preview rows survived alongside the newly-connected Mac. signOut now clears foregroundMacDeviceID and the foreground connection pool before seeding the anonymous entry, so foregroundMacKey matches the seeded key and the next connect drops the anonymous preview correctly. [P1] The new /v1/sync/paired-macs write path capped only LIVE records, so create→delete→repeat churn with fresh ids grew the DO unbounded across the tombstone GC window. Added MAX_PAIRED_MAC_RECORDS_PER_USER (5× live): a brand-new id is refused at the cumulative (live + retained-tombstone) cap; reviving a tombstoned id reuses its slot. Test churns to the cap and asserts new ids are refused while a revive is allowed. Also fixed the iOS archive compile error: MobileNavDrawerView named CMUXAuthTeam (from CMUXAuthCore, not a direct dep of CmuxMobileShellUI). Pass the team's id/displayName fields instead of the type, which also keeps the @Observable off the drawer's row closures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix iOS drawer compile: .rect arg order + gate drawer to iOS - EdgeSwipeDrawerContainer: UnevenRoundedRectangle .rect() wants bottomTrailingRadius before topTrailingRadius. - MobileNavDrawerView uses .listStyle(.insetGrouped) (iOS-only) and is only used on iOS, so gate the whole file behind #if os(iOS) (the package also compiles for macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix WorkspaceListView call: openDrawer must match declaration order openDrawer is declared right after store, so pass it there in both call sites (Swift requires call arguments in declaration order). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drawer edge swipe: use native UIScreenEdgePanGestureRecognizer The SwiftUI DragGesture edge-strip fought the workspace list's scroll + row swipe actions (SwiftUI gestures don't coordinate with UIScrollView) and felt broken. Replace it with UIKit's UIScreenEdgePanGestureRecognizer — the same system recognizer behind the interactive back gesture — installed on the hosting view via a representable. It has screen-edge priority and coordinates with the scroll view automatically, and now drives the drawer INTERACTIVELY (the panel tracks the finger; commit on release by threshold/velocity). Gated to the compact root list only (isEdgeSwipeEnabled): a pushed detail uses the left edge for the system back swipe and the split layout has its own sidebar gesture, so the edge swipe would conflict there. The ☰ toolbar button opens the drawer in every state regardless (primary, accessible entry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace team drawer with a native inline team picker in Settings Per dogfood feedback, the left-edge swipe drawer felt wrong (Apple also discourages hamburger drawers). Removed it entirely (EdgeSwipeDrawerContainer + MobileNavDrawerView deleted; WorkspaceShellView/WorkspaceListView reverted to the plain layout + the existing top-left Settings button) and put the team picker where it belongs: an INLINE Picker in the Settings sheet's account area — each Stack team is a row with a checkmark on the current one, one tap to switch. The team-scoped data + lazy re-scope (selectedTeamID observed by the root) are unchanged; only the entry point moved from a custom drawer to native Settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers screen: show each Mac's build channel (DEV+tag / Nightly / Stable) The Computers screen now labels which build each Mac runs, to debug 'which build is this host'. Full vertical: - Mac heartbeat (PresenceHeartbeatClient) now sends the app's bundleId alongside the existing CMUX_TAG. - Presence worker (validate/core/do.ts) parses, stores, and echoes bundleId on the instance (optional, bounded; a change re-syncs the device row). - iOS PresenceInstance decodes bundleId; PresenceMap.deviceSummary derives a build label via the new MacBuildChannel helper (a non-default tag => 'DEV · <tag>'; else the bundle-id suffix => Nightly/RC/Staging/Stable). - Computers UI: a small tinted pill next to each Mac's name (MacComputerRow) and a 'Build' row in the detail's Presence section (MacComputerDetailView). Tests: MacBuildChannel label derivation, worker bundleId carry, the Mac heartbeat body emits bundleId. en+ja localization for the new strings. Needs a dev-worker redeploy + mac & iOS rebuild for the value to flow on dogfood. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Build-channel label: component-based parse + handle future RC Align MacBuildChannel with the canonical SocketPathMarkerFiles.variant mapping: the channel is the component right AFTER com.cmuxterm.app (a tagged channel build appends a further .slug, e.g. com.cmuxterm.app.nightly.my-feature), so match the component, not a naive suffix. Adds 'rc' -> 'RC' so a future release-candidate desktop build (com.cmuxterm.app.rc) is labeled correctly the moment it ships, plus debug/dev -> DEV and an unknown future component -> no guess. Tests cover RC, slugged channel bundles, and the dev-tag-wins case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers: don't show contradictory 'presence unknown' when connected A Mac the phone is actively connected to (green, with workspaces) was still showing 'Presence: unknown' on its row — contradictory and confusing, since the live connection already proves the Mac is up. Presence is a SEPARATE signal (the Mac's heartbeat to the presence worker), and a dev phone watching the dev worker won't see a Mac that heartbeats to prod — so 'unknown' is common and meaningless next to 'Connected'. Row: when connected and the presence worker has no record, drop the 'Presence: unknown' and show just the route (real presence data still shows). Detail's 'Presence (from server)' section: when connected, say 'no heartbeat (connected directly)' instead of a bare 'unknown'. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers detail: a connected Mac reads 'Online' (connection is the truth) Follow-up to the presence-unknown fix: the root cause is that presence heartbeat is currently a DEV-only feature — stable cmux Macs don't announce presence (Release default OFF, no prod presence URL shipped), so a Mac you're connected to genuinely has no server heartbeat. Showing 'no heartbeat' for a Mac you're actively using reads as broken. Now, when the phone is connected, the detail's Presence section leads with 'Reports: Online' (the live connection proves it) plus a 'Source: this phone's connection (no server heartbeat)' clarifier, and the footer explains presence is a dev-only signal today. The row already shows just the route when connected. en+ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make presence production-ready: prod URL + follow the mobile toggle Presence was dev-only (Release default OFF, no prod URL). Make it ship on stable, gated on the mobile feature per the desired model: default OFF, ON when the user enables mobile. - PresenceSettings.isEnabled: an explicit override still wins, but with no stored value presence now FOLLOWS MobileHostService.isListeningEnabled (the iOS-pairing master switch). Default (mobile off) => off for privacy; turning on mobile pairing turns on presence automatically. Replaces the old DEBUG-on/Release-off. - Mac resolvedServiceURL: Release now defaults to the production worker (presence.cmux.dev) instead of nil, so a stable Mac with mobile on heartbeats to prod. Debug still uses the dev worker. - iOS PresenceServiceConfiguration: Release now defaults to the production worker too, so a stable iOS app subscribes to the same service stable Macs report to (env/UserDefaults/Info.plist overrides unchanged). On merge, CI (presence.yml) deploys the updated worker (bundleId + customization merge + tombstone cap) to prod, completing the production path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address iOS policy review cleanup * Fix paired Mac team scoping and aggregation guards * Satisfy paired Mac autoreview policy gate * Fix paired Mac team scope ownership * Fix quit confirmation reentrancy * Fix scoped backup and workspace action gates * Fix paired Mac legacy claim and selection remap * Fix team active legacy scope * Fix anonymous aggregation and backup actives * Fix visible legacy Mac customization scope * Fix legacy Mac active clearing scope * Make paired Mac backup decode tolerant * Fix stale route writes across team switches * Fix notification deeplink scope and backup URL joining * Provision secrets for isolated presence workers * Propagate paired Mac backup tombstones * Keep stale team loads from clearing current lists * Fix foreground suppression and secondary downgrades * Fix paired Mac backup review findings * Fix paired Mac scope and dismiss flush races * Satisfy iOS package convention lint * Fix visual line copy mode Ghostty API usage --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
Device presence service: Cloudflare Durable Objects realtime layer over the device registry (#5792) * Add cmux-presence Cloudflare Worker: per-team Durable Object presence service Realtime device presence (online/offline) layered over the durable devices/device_app_instances registry. POST /v1/presence/heartbeat, GET /v1/presence/snapshot, GET /v1/presence/subscribe (WebSocket or SSE), Stack bearer auth mirroring web/services/vms/auth.ts, alarm-driven timeout-offline transitions (15s heartbeat / 45s timeout), 24h prune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence worker local end-to-end proof script Drives wrangler dev with real dev-Stack credentials through the full lifecycle: 401 unauthenticated, heartbeat online, SSE + WebSocket subscribe, seen tick, goodbye offline, and alarm-driven timeout offline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add presence deploy-on-push workflow and service docs Path-filtered GitHub Actions: typecheck + unit tests + wrangler dry-run on PRs, wrangler deploy on push to main (DO migrations applied atomically with the deploy). docs/presence-service.md carries the DO-vs-RivetKit decision memo and the ephemeral-presence migration story. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add flagged Mac presence heartbeat sender and iOS typed presence client stub Mac: PresenceHeartbeatClient follows the DeviceRegistryClient pattern (same device UUID and tag, best-effort, auth-gated), default OFF behind the presenceHeartbeatEnabled + presenceServiceURL defaults keys, with a server-owned cadence and a clean-quit goodbye. iOS: PresenceWire typed models + WebSocket subscribe stub in CmuxMobileShell, the seam for the device tree (https://github.com/manaflow-ai/cmux/pull/5648). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound subscribe streams to token expiry and harden request reading Subscribe streams now carry a worker-computed deadline (verified token expiry, capped at 15 minutes) enforced by the DO at delivery and via the alarm, so a revoked token or removed team member cannot keep an old stream alive; clients reconnect with a fresh token and get a fresh snapshot. Adds a per-team subscriber cap (64) and drops stalled SSE readers instead of buffering unboundedly. readBoundedJson now reads the body incrementally and aborts the moment it crosses the 16 KiB cap, so a chunked or lying-Content-Length body can never over-buffer; covered by new unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bind presence devices to their first authenticated owner Mirrors the device-registry ownership guard (a device row pins the registering userId and rejects other users' writes): the first authenticated team member to announce a deviceId owns it in DO storage, and a co-member's heartbeat for that device is rejected with 403 device_owner_mismatch, so presence cannot be forged online or force-cleared offline by another member who learned the device id from snapshots or the registry. Owner pins are pruned with the same 24h alarm pass that bounds the instance map. The local proof now exercises the guard with a real second Stack account in a shared team. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make device-owner pins durable and harden the local proof script Owner pins are no longer pruned with the 24h presence tail (an idle device could be re-claimed by a co-member through the prune window), and new pins are bounded by MAX_OWNERS_PER_TEAM. The proof script keeps secrets off argv via curl config files and skips the owner-guard step with an explanation when both accounts resolve to the same Stack user instead of mistaking a legitimate same-owner 200 for a guard failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split presence Swift types one-per-file with DocC coverage Flatten the PresenceWire namespace into top-level PresenceInstance / PresenceDevice / PresenceSnapshot / PresenceOfflineReason / PresenceUpdate / PresenceClientError / PresenceTokenSource files, each holding one documented major type, and decode the tagged wire frame via PresenceUpdate's custom Decodable (CodingKeys) instead of function-local payload structs. The Mac client moves PresenceSettings to its own pbxproj-wired file and reads the server interval with JSONSerialization to keep PresenceHeartbeatClient single-type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Republish attach routes on network path changes The mobile-host listener stays bound when the Mac moves networks or Tailscale flips, and .mobileHostStatusDidChange only fired on listener and connection transitions, so the advertised route set (and the team device registry DeviceRegistryClient mirrors from statusUpdates()) kept the old network's routes until the next listener restart. An NWPathMonitor now runs for the listener's lifetime: a changed path signature (status + interfaces + gateways, order-insensitive) invalidates the resolved-Tailscale-host cache and republishes routes through the same two-phase publish the listener-ready handler uses. A generation guard in MobileRouteResolver discards a resolution that raced the invalidation, so old-path hosts can never land late in the cache. Route-level dedup downstream means path flaps that do not change the route set produce no registry write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document the live cmux-presence-dev staging instance cmux-presence-dev is deployed on the team Cloudflare account with dev Stack Worker secrets provisioned; record its URL, the manual redeploy command, and how to point a dev Mac build at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mirror the registry's real per-team caps in the presence DO The DO capped instances and owner pins at a flat 5000 per team, so one authenticated member could mint thousands of fake deviceIds or tags, bloat every snapshot, and starve legitimate devices out of the budget. checkPresenceCaps (pure, unit-tested) now mirrors the registry route's actual limits: 200 devices per team (owner pins) and 25 instances per device, which structurally bounds the instance map at 5000 without an aggregate check since every stored instance's device holds a pin. Counts are fetched lazily with bounded list() calls only on new-device or new-instance heartbeats. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject expired subscribe deadlines and republish on first path observation Two review findings. The DO treated a forwarded x-presence-expires-at that was already past as missing and minted a fresh 15-minute window, so a token that expired between worker verification and DO handling could keep a stream open; resolveSubscribeDeadline (pure, unit-tested) now rejects missing/garbled/past deadlines with 401 and defensively re-caps the rest. The Mac path monitor treated its initial callback as a silent baseline, which swallowed a path change that landed between the listener-ready route publish and the monitor's first observation; the first observation now republishes too (deduped downstream), and only duplicate consecutive observations are skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound the iOS presence stream buffer and scrub proof-script tokens The subscribe AsyncThrowingStream used the default unbounded buffering while the receive loop yields every frame (including the team's 15s seen ticks), so a stalled consumer would grow memory without limit; bufferingNewest(256) bounds it, and a dropped frame at worst leaves the map stale until the snapshot the deadline-bounded resubscribe protocol already guarantees. The local proof script kept $WORK for transcript logs but its curl configs carry live Stack bearer tokens; the cleanup trap now scrubs every token-bearing file on all exit paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * End the presence stream on buffer overflow instead of dropping silently Presence is a stateful snapshot+delta protocol, so a silently dropped transition frame could render wrong live state until the next reconnect (up to the 15-minute deadline). The receive loop now checks the yield result: a .dropped frame finishes the stream with the new PresenceClientError.updatesDropped, so the consumer's reconnect delivers a fresh snapshot first, and .terminated stops the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deterministic handshake in the stale-resolution race test async let does not guarantee the child task entered the resolver and captured the old cache generation before the invalidation runs, so the test could nondeterministically exercise the wrong interleaving. A started semaphore now proves the resolution is in flight before the invalidation, and the gate holds it there until after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extract network path observation into MobileHostNetworkPathMonitor MobileHostService owned both the republish action and the raw NWPathMonitor observation (signature computation, duplicate suppression, baseline state). The observation concerns now live in a small dedicated type with the same tested pure functions, so the service keeps a single responsibility: deciding what to do when the path changes. Behavior is unchanged; the existing path-refresh tests now target the monitor type directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Push route changes through presence: heartbeat routes + routes event Heartbeats now carry the instance's attach routes (tri-state: absent = unchanged, [] = no routes), the DO stores them on the presence record as a live cache of the registry row, and a changed set on an online instance broadcasts a 'routes' event so subscribed phones reconnect on the fresh port/IP without polling the registry. Entry filtering and the 16-route bound mirror the registry route; a non-array routes value is rejected rather than coerced so a client bug can never silently wipe pushed routes. * Mac presence heartbeats carry attach routes and beat immediately on change The heartbeat is the realtime twin of the registry write-through: every beat states the full current route set from MobileHostService (empty means pairing off), and a route-set change observed via statusUpdates() fires one immediate out-of-cadence beat so the presence DO can push the fresh port/IP to subscribed phones within a round trip. Debug builds now default the gate on against the dev/staging worker (dev Stack identity matches what cmux-presence-dev verifies), keeping Release default off; both stay explicitly overridable via defaults/env. * Phone subscribes to live presence: device tree online/offline + pushed-route reconnect The phone-side half of the presence service. MobileShellComposite owns one presence subscription (PresenceSubscribing seam, PresenceClient transport) that follows the session: starts on sign-in, tears down with a blanked map on sign-out, restarts from foreground refresh. Stream frames reduce into a pure PresenceMap (snapshot replaces, events upsert) that the device tree overlays on registry rows as live Online/Offline instead of last-seen guesses (en+ja). Route pushes (routes/online events and reconcile snapshots) write through to the local paired-Mac store via the same selectReconnectRoutes merge the registry refresh uses, and kick a reconnect when the active Mac is online but the phone sits disconnected, so a port change reattaches without re-pairing. PresenceInstance decodes routes with per-entry leniency (unknown kinds drop, frames never fail), matching the registry contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence doc reflects shipped clients; deploy job names missing CF secrets explicitly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Review fixes: offline alarm defers to prune deadline; snapshot route sync is one batch Greptile P1: ensureAlarmFor scheduled offline instances at the 45s offline timeout, so every goodbye burned one no-op DO alarm before the real 24h prune alarm. Delegate to core's nextAlarmTime so the deadline rule lives in one place. Greptile P2: the presence snapshot fanned out one Task per online instance, so a multi-tag Mac could queue duplicate recoverMobileConnection kicks (a late one lands as a spurious resync after reconnect succeeds) with nondeterministic route-upsert order. Process the snapshot's instances sequentially in one task and kick at most one reconnect per delivery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh swift file length budget for presence client growth MobileShellComposite +176 (presence subscription lifecycle), MobileHostService +49 (network path monitor wiring), AppDelegate +4 (heartbeat client). Known debt accepted; MobileHostNetworkPathMonitor was already extracted to its own file to bound the growth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Autoreview fixes: heartbeat test asserts real wire shape; explicit empty route push clears the tree The heartbeat body test read host/port at the route's top level, but mobileHostJSONObject nests them under endpoint, so the assertions could never pass once the suite ran. Assert the nested shape (the same wire contract the registry POST and iOS parser use). applyPushedRoutes treated routes nil and [] identically and returned before touching registryDevices, so an explicit empty push (host advertises no routes) left stale Connect affordances in the device tree. nil now means "not announced" (no-op); an announced set, including [], mirrors to the tree, while the paired-Mac store still keeps last-known-good reconnect routes and only updates on non-empty pushes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route sync discards stale frames after sign-out or account switch The unstructured sync task can suspend in loadPairedMacs/upsert and resume after a different user signed in. Re-check isSignedIn plus the captured requesting user after every suspension, mirroring refreshRegistryDevices' account-switch guard, so a stale frame can never write routes into or kick reconnects for the next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Path observation always invalidates the Tailscale host cache; PresenceMap rollups are per-device A pre-ready initial path observation advanced the monitor's dedup baseline but returned before invalidating the resolver cache, so the .ready publish could reuse TTL-fresh hosts from the previous network with no further path callback coming (toggle pairing off, move networks, toggle on). Invalidate on every observation, before the no-port early return. PresenceMap stored instances flat by deviceId:tag, so deviceSummary scanned the whole team map; the device tree recomputes every visible row's summary per heartbeat mutation, making row projection O(devices x all instances). Group storage by device so a rollup only touches that device's instances (25 max). Adds direct PresenceMap reduction tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Presence route pushes respect the registry's multi-instance ambiguity guard The paired-Mac store is device-level (no tag); the registry refresh only substitutes reconnect routes when exactly one instance advertises any, but the presence push path wrote every instance's routes through, so a tagged debug build's push could repoint the phone's persisted reconnect routes at the wrong build. Gate the store write on PresenceMap's new soleRouteAdvertisingInstance(deviceId:) (exactly one online route-bearing instance, and it is the pusher). The per-tag device-tree mirror stays unconditional. Covered in PresenceMapTests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound cumulative serialized route bytes per heartbeat Route entries were individually unbounded (only the 16KiB request cap applied), so one authenticated member could fill the admitted 200x25 instance caps with near-16KiB route payloads (~78MiB) and blow the Workers isolate memory budget whenever snapshot/alarm materialize the team map, DoSing presence for the team. Cap cumulative serialized routes at 2KiB per instance (worst-case team state ~10MiB), dropping entries past the budget so the host's preferred-first prefix survives. Real route sets are ~100-200 bytes per entry and fit untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope presence service-resolution statics onto PresenceClient (conventions lint) The caseless namespace enum tripped the package-conventions namespace-enum rule; the members now live directly on the owning type. Covariant Self in the default argument replaced with the concrete type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve production presence at presence.cmux.dev custom_domain route in wrangler.toml (cmux.dev zone is on the same Cloudflare account, so the deploy provisions DNS + TLS). Release clients keep a nil default service URL; flipping them to this domain is a follow-up gated on the first production deploy and dogfood. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serialize presence route deliveries on the paired-Mac write chain Greptile P1: the per-delivery fire-and-forget task raced on reconnect (snapshot immediately followed by online/routes for the same device), producing concurrent pairedMacStore upserts for one Mac and a possible double reconnect kick. Deliveries now run through performSerializedPairedMacWrite, which appends synchronously on the main actor, so they execute strictly in arrival order; userIsCurrent doubles as the chain's ifStillCurrent entry check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Negative-cache rejected presence auth tokens (security audit MED) An opaque (non-JWT) bearer token skips the client-side expiry short-circuit, so every request carrying a bad token forced an outbound Stack /users/me subrequest — an unauthenticated amplification vector against Stack's rate limits and CF subrequest budget. Rejected tokens are now cached for 10s (bounded by the token's own exp), keyed by token hash like the positive cache. Test asserts 3 rejected requests cost 1 Stack call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix test fetch cast for typecheck * Refresh swift file length budget after rebase onto main Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Path signature includes local IPv4 addresses so same-gateway network moves republish routes Codex review (P2) on the presence PR: two networks can present the same interface name and gateway (two LANs both en0 + 192.168.1.1) while assigning a different local address; the old signature deduped that move and never invalidated/republished routes. The signature now includes the machine's local IPv4 addresses (getifaddrs, up non-loopback interfaces), injectable for tests. IPv6 is excluded deliberately: temporary-address rotation would cause spurious republish churn. Also corrects the reconnect-kick comment in MobileShellComposite: under the multi-instance ambiguity guard, pushed routes are deliberately not persisted and the reconnect uses stored last-known-good routes (cursor bot flagged the old comment's claim that routes were always persisted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(presence): how to upgrade running Durable Objects safely Class migrations vs data-schema: class migrations manage the DO class registry (append-only, atomic with deploy); they do not migrate the shape of stored data. Running objects keep old code until evicted, then hydrate new code against persisted storage, so upgrades = make new code read old data (additive fields, schemaVersion + lazy upgrade, rollout-window tolerance). For presence only the never-pruned owner pins need that care; the live map self-heals via 15s re-announce. * Refresh swift file length budget for post-rebase file sizes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> | 3 个月前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 3 个月前 | ||
| 1 个月前 | ||
| 1 个月前 | ||
| 3 个月前 | ||
| 1 个月前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 |