468 lines
36 KiB
Markdown
468 lines
36 KiB
Markdown
# Nucleic — Multi-Device Mesh + Session Transfer
|
||
|
||
> **Status (2026-07-04): in progress.** Phases 1, 2 & 4 done — the relay data path is live
|
||
> end-to-end against the deployed `nucleic-edge` Worker; **Phase 5 feature-complete** except the two-Mac memory-carry spike (engine +
|
||
> destination + source-driver/UI + moved-session visibility + relaunch recovery + bulk hand-off +
|
||
> arrived-from provenance + stranded-arrival "activate anyway"); **Phase 3 in progress** —
|
||
> iOS build unbroken (was failing since the P4/P5 merge) + `IdentityStore` multi-host registry +
|
||
> migration + host switcher + the *simultaneous* `[HostID: HostConnection]` multiplexer (compiles +
|
||
> demo-verified; live two-Mac test + host-qualified notifications remain). Test counts below are green as of this writing (Swift:
|
||
> 775 core + 107 protocol; edge: 50). The iOS app builds + runs in the simulator (`xcodebuild`). Design
|
||
> derived from a 6-lane code explore + 3 design passes + adversarial critique; full working notes in
|
||
> the session plan `lively-puzzling-cloud.md`.
|
||
|
||
## Goal
|
||
|
||
Any Nucleic device (Mac, iPhone, future cloud `nucleicd`) connects to any other over the
|
||
user-selected set of connection methods, picking the best available path automatically — the user
|
||
chooses methods via multi-select (Local network / Tailnet / Covalence), and the UI
|
||
strongly recommends at least Tailnet or Relay. Plus **session transfer between Macs** (take the
|
||
laptop to work → hand sessions to a Mac that stays home). A stepping stone toward Nucleic Cloud
|
||
([CLOUD_RUNTIME.md](CLOUD_RUNTIME.md)), not a fork.
|
||
|
||
## Locked invariants
|
||
|
||
1. **N hosts × M viewers over the one wire protocol.** No replication/consensus; each session has
|
||
exactly one owning host (single-writer `SessionController`). A Mac is both a host (existing
|
||
`SyncHost`) and a client (dials sibling Macs with the same `SyncClient` the iPhone uses).
|
||
2. **`SyncProtocol.version` stays 1.** Everything additive + capability-gated. `ClientMsg` decoding
|
||
throws on unknown tags → a client must not send a new verb unless the host advertised the
|
||
matching `WireCapabilities` flag. New `HostMsg` tags are safe (`.unknown` fallback). New struct
|
||
fields use `decodeIfPresent` defaults (the `canFetchDiff` pattern).
|
||
3. **`HostID` = full SHA-256 hex of the host static key** (`DeviceIdentity.hostID`), never the
|
||
display name. Multi-host clients key all state by `(hostID, sessionID)`; the wire stays
|
||
unqualified (one connection = one host). The relay `roomId` is the same string.
|
||
4. **Transfer = 2-phase commit, tombstone-before-commit.** Git state moves as a `git bundle` over
|
||
the E2EE channel (no shared-remote requirement). Tombstone = `moved_to_device_id` columns +
|
||
`archived`, **not** a new `SessionStatus` case (that would silently break shipped iPhones' list
|
||
decode). Agent-memory carry (native transcript + `backendSessionID`) is best-effort with a
|
||
fresh-context fallback; credentials never transfer.
|
||
5. **Every difference is a declared capability, never an assumption.**
|
||
|
||
### Canonical names
|
||
|
||
`PeerKind` (raw-string: mac/iphone/cloud) · `PeerCapabilities {canHost, canRunAgents}` ·
|
||
`PeerAddresses {lanHint, tailnet, relayRoomID}` · `SyncTransportSet` (key `nucleic.sync.transports`)
|
||
· `Hello.deviceKind` + `Hello.clientCaps: WireClientCapabilities {mesh, canHost, canRunAgents}` ·
|
||
`WireCapabilities.canListPeers` / `.canReceiveSessionTransfer` · Mac dialer `PeerClient` · iOS store
|
||
key `nucleic.pairedHosts`.
|
||
|
||
---
|
||
|
||
## Phase status
|
||
|
||
### ✅ Phase 1 — Multi-select connection methods (Mac) — DONE, tested
|
||
|
||
- `SyncTransportSet` in [TailnetTransport.swift](../Sources/NucleicCore/Sync/TailnetTransport.swift)
|
||
(`resolveEnabled`/`saveEnabled`, key `nucleic.sync.transports`, env `NUCLEIC_SYNC_TRANSPORTS`),
|
||
migrates the legacy single-choice value (`tailnet` → `{lan, tailnet}`).
|
||
- `CompositeSyncListener` ([SyncTransport.swift](../Sources/NucleicCore/Sync/SyncTransport.swift))
|
||
is now **partial-failure tolerant** with an `onChildStartFailure` callback; only a total failure
|
||
throws. A pending Tailscale login no longer takes down LAN.
|
||
- [AppStore.swift](../Sources/NucleicCore/AppStore.swift) `startSyncServer` listens on every enabled
|
||
method at once; per-method health in `syncActiveTransports` / `syncTransportHealth`; pairing QR
|
||
carries all live hints; `HostInfo.hostID` fixed to `DeviceIdentity.hostID`.
|
||
- [RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift): three method toggles
|
||
(Local network / Tailnet / Covalence — relay disabled until Phase 2), status dots, a
|
||
non-removable last method, and a persistent **LAN-only recommendation banner**.
|
||
- Tests: `SyncTransportSettingTests`, `CompositeSyncListenerTests`.
|
||
|
||
### ✅ Phase 2 — Relay data path — DONE; live relay smoke-tested end-to-end
|
||
|
||
**Done + verified (security-critical):**
|
||
- Worker hardening in `cloud/nucleic-edge`:
|
||
[relayEnroll.ts](../cloud/nucleic-edge/src/relayEnroll.ts) adds **X25519 proof-of-possession**
|
||
enrollment — a host must prove it holds the static private key that derives its `roomId`, closing
|
||
the hole where anyone holding the *public* host key could enroll and evict the real host.
|
||
Server-side `roomId = sha256(staticKey)` derivation; token minting bound to the PoP-proven room.
|
||
[room.ts](../cloud/nucleic-edge/src/room.ts) replaces broadcast-to-all with **role-routed per-peer
|
||
forwarding** (a `[0x01][8-byte deviceId tag]` envelope, one-host-per-room eviction, 20 MiB frame
|
||
cap). Tests: `test/relayEnroll.test.ts`, `test/room.test.ts` (edge suite 50 green, typecheck +
|
||
wrangler dry-run clean).
|
||
- Cross-stack contracts pinned to test vectors so a deploy can't silently break:
|
||
[RelayEnrollment.swift](../Sources/NucleicProtocol/Sync/RelayEnrollment.swift) (PoP proof matches
|
||
the Worker byte-for-byte), [RelayEnvelope.swift](../Sources/NucleicProtocol/Sync/RelayEnvelope.swift)
|
||
(routing tag + wrap/unwrap match `room.ts`), `PairingPayload` relay fields (roomID / token / URL,
|
||
additive). Tests: `RelayEnrollmentTests`, `RelayEnvelopeTests`, `PairingPayloadTransportTests`.
|
||
|
||
**Formerly deploy-gated remainder — landed once `nucleic-edge` went live (relay.nucleic.blakeslee.xyz):**
|
||
- Shared client leg: [RelayTransport.swift](../Sources/NucleicProtocol/Sync/RelayTransport.swift) —
|
||
`RelayAPI` (one base URL for REST + WS; `NUCLEIC_RELAY_URL` dev override), `RelayWebSocket`
|
||
(ordered sends, ping keepalive, ping-confirmed connect), `RelayFrameChannel` (membership →
|
||
connection token trade, `WireFraming` prefix inside WS binary messages, fail-fast when presence
|
||
says the room has no host), `RelayPresence`.
|
||
- Host side: [RelayAccess.swift](../Sources/NucleicCore/Sync/RelayAccess.swift) (PoP enrollment via
|
||
`RelayEnrollment`, room credential in the login Keychain — separate from the push credential —
|
||
membership minting with re-enroll-on-401) and
|
||
[RelayListener.swift](../Sources/NucleicCore/Sync/RelayListener.swift) (`SyncListener` demuxing
|
||
the room socket into per-tag virtual `FrameChannel`s via `RelayEnvelope`, presence-driven channel
|
||
reaping, capped-backoff redial). Wired into `AppStore.startSyncServer` behind the `.relay`
|
||
transport checkbox (now enabled in `RemoteAccessView`); `currentLocalAddresses` advertises
|
||
`relayRoomID`.
|
||
- Credential distribution, both routes: the pairing QR carries a bootstrap membership
|
||
(`beginPairing` mints for a fresh `pair-…` id, so first contact can ride the relay), and a new
|
||
additive `HostMsg.relayMembership` (`WireRelayMembership`) is pushed after every hello
|
||
(`SyncHost.register` → mint bound to the device's real id) — which is how devices paired
|
||
*before* the relay adopt it and how the ~90-day token refreshes. Old phones ignore the unknown
|
||
tag (`.unknown` fallback).
|
||
- iOS: relay is a real dial candidate (always last — direct paths win) in `HostConnection`
|
||
pair + reconnect; `PairedHost` persists `relayRoomID`/`relayMembershipToken`/`relayURL`
|
||
(decode-defaulted); the `relayMembership` push updates the registry in place (no reordering);
|
||
10 s handshake watchdog on the relay leg mirrors the LAN one.
|
||
- Verified: `RelayTransportTests` (URL building, presence, membership codec),
|
||
`RelayListenerTests` (demux/reap/redial over a fake room socket), and a **live smoke test**
|
||
against the deployed Worker — PoP enroll (server room derivation matches), host+client token
|
||
mints/trades, and a two-socket frame round-trip through the Room DO with correct envelope
|
||
tagging both directions.
|
||
|
||
**Known limits:** host-side revoke-on-unpair isn't wired (`/v1/relay/revoke` is admin-only today;
|
||
membership tokens age out in ≤90 days and Noise still gates content). A device that reconnects
|
||
mid-session keeps its tag, so the host converges through the fresh handshake failing the stale
|
||
handler (one retry) rather than instantly. Mac↔Mac (`PeerClient`) doesn't dial the relay yet —
|
||
`PeerAddresses.relayRoomID` is advertised, but sibling Macs hold no membership tokens.
|
||
|
||
### ✅ Phase 4 — Peer model, Mac↔Mac pairing, presence — DONE, tested
|
||
|
||
**Foundation (P4a):**
|
||
- [PeerTypes.swift](../Sources/NucleicProtocol/Sync/PeerTypes.swift): `PeerKind`,
|
||
`PeerCapabilities`, `WireClientCapabilities`, `PeerSummary`.
|
||
- `Hello.deviceKind` + `clientCaps` (optional); `WireCapabilities.canListPeers`;
|
||
`ClientMsg.listPeers` → `HostMsg.peerList([PeerSummary])`; `PairedDevice.kind` + `capabilities`
|
||
(decode-defaulted custom `init(from:)`, so pre-mesh JSON stores load unchanged).
|
||
- `ConnectionHandler` records `deviceKind` at pairing; `SyncHost.connectedDeviceIDs()`;
|
||
`AppStore.peerSummaries()` advertising `canListPeers`; `SyncClient` threads
|
||
`deviceKind`/`clientCaps` + surfaces a `.peerList` event; iOS `RemoteStore.meshPeers`.
|
||
- Tests: `WireMessageTests` (codec + backcompat), `SyncHostTests` (loopback `listPeers` + device-kind
|
||
at pairing), `FilePairedDeviceStoreTests` (pre-mesh decode defaults).
|
||
|
||
**Remainder (P4b) — done + verified:**
|
||
- **Addresses on the wire:** `PeerAddresses {lanHint, tailnet, relayRoomID, updatedAt}`
|
||
([PeerTypes.swift](../Sources/NucleicProtocol/Sync/PeerTypes.swift)); optional
|
||
`Hello.addresses` / `Welcome.addresses`; `ClientMsg.addressUpdate(PeerAddresses)` gated on new
|
||
`WireCapabilities.canUpdateAddresses` (the sender refreshes its *own* store row);
|
||
`PairedDevice.addresses` (decode-defaulted). The relay hint is populated whenever the
|
||
relay method is up (P2).
|
||
- **`PeerClient`** ([PeerClient.swift](../Sources/NucleicCore/Sync/PeerClient.swift)): the Mac
|
||
dials paired sibling Macs with the same `SyncClient` the iPhone uses — `.control` claim,
|
||
`deviceKind: mac`, `clientCaps {mesh: 1, canHost, canRunAgents}` — serial LAN→tailnet dial
|
||
with per-candidate timeout, capped backoff, live presence stream, `listPeers` only when
|
||
advertised. Dial seam ([PeerDialer.swift](../Sources/NucleicCore/Sync/PeerDialer.swift)):
|
||
`MacPeerDialer` = new outbound `LANDialChannel` (dial-side `LANChannel` with a TCP-ready
|
||
timeout) + `TailnetNode.shared.dial` off the node the listener already runs.
|
||
- **Symmetric pairing, one store.** `pair(with:)` (pasted `nucleic://pair` link) pins the
|
||
remote Mac into the *same* `PairedDeviceStore` the host pins inbound devices into — and the
|
||
accepting Mac's ordinary hello path pins the joiner — so one paste makes **both directions**
|
||
dialable (what P5's "source dials destination" needs regardless of who pasted). Records with
|
||
`kind.canOwnSessions && capabilities.canHost` are the dial targets; revoke = one store row.
|
||
- **Accepting-Mac confirm (locked decision #4):** mac-kind first pairings consult
|
||
`SyncHostBridge.approveMacPairing` (defaulted true for tests/minimal hosts); `AppStore`
|
||
publishes `pendingMacPairRequest` → confirm dialog on the QR sheet, 120 s fail-closed
|
||
timeout, denied on sheet close/server stop. Phones keep pure TOFU.
|
||
- **`SyncHost` per-deviceID dedup:** keep-newest with a configurable grace
|
||
(default 2 s) — the old handler is closed after the grace; registry keyed post-hello only.
|
||
- **UI** ([RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift)): "Paired
|
||
Macs" section (presence dot, transport, revoke, per-peer error), "Pair another Mac…"
|
||
paste-link sheet, QR sheet doubles as copy-link ("Add device…"), confirm dialog.
|
||
`AppStore.meshPeers` / `inboundConnectedDeviceIDs` publish live presence
|
||
(`SyncHostBridge.meshPeersChanged` fires on pair/connect/disconnect/addressUpdate, which
|
||
also fixed the stale `pairedDevices` list).
|
||
- Tests: `WireMessageTests` (PeerAddresses codec, legacy tolerance, addressUpdate, capability
|
||
flag), `SyncHostTests` (dedup keep-newest + distinct-device coexistence, hello/welcome
|
||
address exchange, addressUpdate → store, mac-confirm decline/phone-skip),
|
||
`PeerClientTests` (pair-via-link pins both sides, IK reconnect after drop, `listPeers`
|
||
gating vs a non-advertising host, unpair, self-pair/garbage-code rejects, endpoint
|
||
derivation incl. bracketed IPv6), `FilePairedDeviceStoreTests` (foundation-era JSON without
|
||
addresses still loads).
|
||
|
||
**Hardening (adversarial review pass):** a 4-dimension × 2-refuter review surfaced and fixed —
|
||
the mac-pairing confirm is now enforced on the *reconnect* promotion path too (a device can't
|
||
pair as a phone then reconnect claiming `deviceKind: mac` to skip the confirm), and the
|
||
existing-device hello branch requires the just-authenticated static key to match the pin (a
|
||
pairing party can't claim another device's `deviceID` to hijack its record/registry slot); the
|
||
`startPeerClient` post-commit awaits are guarded against a racing `stopSyncServer` (host-token
|
||
check); the mac-pairing confirm timer is stored and cancelled on resolve (no stale timer denying
|
||
a later attempt); `pair()` treats a pre-welcome `wireError` as terminal (no re-prompting the
|
||
accepting user per hinted endpoint) and classifies decline vs unreachable; `consume()`/`pair()`
|
||
have a handshake deadline (a silent endpoint no longer wedges the manage loop); `setPresence`
|
||
won't resurrect an unpaired peer from an in-flight event; `meshPeersChanged` no-ops once the
|
||
server is down (a closing handler no longer wipes `pairedDevices`); the pairing UI cancels an
|
||
in-flight `pair()` on dismiss; `LANDialChannel` cancels on `.failed` and enables TCP keepalive.
|
||
|
||
**Known limits:** `addressUpdate` fires only when this Mac's own addresses change (the pre-pair
|
||
refresh + `setLocalAddresses`); it's not yet wired to a live listener-port watcher. Mac↔Mac
|
||
revoke is one-sided — the revoked Mac keeps dialing and reads as offline (it can't distinguish
|
||
revocation from the host being down). Half-open detection is TCP-keepalive on the LAN path only;
|
||
a tailnet session's liveness is the tsnet layer's concern.
|
||
|
||
### ◐ Phase 3 — iOS multi-host — FOUNDATION STARTED
|
||
|
||
**iOS build unbroken first (prereq):** the P4/P5 mesh work added `ClientMsg` /
|
||
`SyncClient.Event` cases that were never handled in `RemoteStore`'s two exhaustive switches (the
|
||
demo-simulator `ClientMsg` handler + the event handler), so `NucleicRemote` didn't compile —
|
||
`swift build` never builds the iOS target, so it landed unnoticed. Fixed (the new cases are inert on
|
||
a phone). The iOS app now builds for the simulator (`xcodebuild … BUILD SUCCEEDED`), so Phase 3 work
|
||
is compile-verifiable here.
|
||
|
||
**Paired-host registry (done + iOS-build-verified):** `IdentityStore`
|
||
([IdentityStore.swift](../ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift)) single
|
||
`nucleic.pairedHost` slot → a `nucleic.pairedHosts` registry (ordered `[PairedHost]`, keyed by
|
||
`fingerprint` = hostID) with a one-time migration of the legacy value. New API
|
||
(`pairedHosts`/`pairedHost(id:)`/`upsertPairedHost`/`removePairedHost`) for the coming multiplexer; a
|
||
single-host bridge keeps every caller + behavior unchanged (`loadPairedHost` = active/most-recent,
|
||
`savePairedHost` = upsert+activate, `clearPairedHost` = remove active). `SettingsView` gains a
|
||
"Paired Macs" list (each host + fingerprint + "Active" + per-host remove) — the visible artifact +
|
||
the removal path now that a new pairing keeps (not overwrites) the prior Mac.
|
||
|
||
**Host switcher + 2-host demo (done + simulator-verified):** the safe, verifiable slice — switch
|
||
*which* paired Mac the phone views, reusing the existing (proven) single-connection reconnect rather
|
||
than rewriting the connection state machine. `RemoteStore`
|
||
([RemoteStore.swift](../ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift)) gains
|
||
`activeHostID` (the Mac the flat `sessions`/`hostName`/`dashboard` projection reflects), `hostChoices`
|
||
(paired registry live / mock hosts in demo), `switchHost(to:)` (live re-points via `reconnect()`;
|
||
demo swaps the mock host, preserving in-demo edits); `reconnect()`/`pair()` set the active host and
|
||
`unpair()` forgets the *active* Mac and falls back to a remaining one. Demo seeds two mock Macs; a
|
||
toolbar host-switcher menu in `SessionsView` shows when >1 Mac. Verified in the iOS Simulator (demo):
|
||
the menu lists both Macs and switching swaps the whole session projection + the tab badge. Single-host
|
||
behavior is unchanged (one Mac ⇒ switcher hidden ⇒ flat state exactly as before).
|
||
|
||
**Multiplexer (done, live path pending a two-Mac test):** `HostConnection`
|
||
([HostConnection.swift](../ios/NucleicRemote/NucleicRemote/Models/HostConnection.swift)) is the
|
||
per-host connection engine lifted out of `RemoteStore` — one instance owns a single Mac's
|
||
`SyncClient` + LAN→tailnet chain + reconnect + tailnet-node lifecycle + event stream, and keeps that
|
||
Mac's projection, talking back through a `Callbacks` struct. `RemoteStore` now owns
|
||
`connections: [HostID: HostConnection]` and **connects every paired Mac at once**: `reconnect()`
|
||
dials all (dropping since-unpaired ones), `pair()` adds a connection + makes it active while others
|
||
run, `unpair()` drops the active and switches to a remaining one. The flat `@Published` state mirrors
|
||
the *active* connection via callbacks, so **`switchHost()` is instant** (every Mac is already
|
||
connected — no reconnect). Aggregate concerns merge across hosts: the app badge counts needs-you
|
||
across **all** Macs, Live Activity summarizes all live sessions, and a notification-answered approval
|
||
broadcasts to every connection (no `hostID` in the notification yet). ~450 lines of connection
|
||
machine moved out of `RemoteStore`. Compiles + the demo host switcher is simulator-verified;
|
||
**the live multi-connection path (simultaneous dials, reconnect/teardown, the shared embedded tailnet
|
||
node) is compile-verified only** — needs two real Macs.
|
||
|
||
**Remaining:** the prerequisite spike (~1 h — confirm one `TailnetNode.shared` dials two Macs on the
|
||
same tailnet) and a live two-Mac test of the multiplexer; then host-qualified notifications / Live
|
||
Activity (`userInfo` carries `hostID` so `respondFromNotification` targets the owning host instead of
|
||
broadcasting). Optional next: an *aggregate* session list (all hosts' sessions grouped by Mac) rather
|
||
than the switch-active model.
|
||
|
||
### ✓ Mac sidebar — unified local/remote representation (mesh session sync)
|
||
|
||
The **Mac app** now represents peer Macs' projects and sessions *identically* to local ones,
|
||
through one abstraction layer rather than a separate mesh section:
|
||
|
||
- **Model**: `ProjectSummary` (`Project.swift`) is the project analogue of `SessionSummary` — one
|
||
compact shape both origins map into, with `hostID`/`hostLabel` as the origin marker. The local
|
||
`SessionSummary` gained `hostID` + `init(wire:hostID:)`, so one row model (and one `SessionRow`)
|
||
serves both. Row identity is origin-qualified (`sidebarRowID`) because a moved session's local
|
||
tombstone and its live twin mirrored back from the new owner share a raw `SessionID`.
|
||
- **Lists**: `AppStore.sidebarProjects` interleaves every connected peer's projects with local ones
|
||
(name-sorted, like the local list); `summaries(for:)`/`archivedSummaries(for:)` serve remote
|
||
projects from the peer's mirrored session list through the same comparators; Recents and the
|
||
recent-count union the mesh. `projectSummary(_:)` is the origin-agnostic `project(_:)`.
|
||
- **One project, one row** (whatever device it's on): every device in a mesh holds the same
|
||
projects, so a peer's copy of a project we also hold is *never* a second section — its chats fold
|
||
into ours and carry the origin themselves (`SessionSummary.foldedHostLabel` → a globe on the row).
|
||
Devices register projects independently, so the same repo has a different `ProjectID` on each one;
|
||
`AppStore.localProjectID(forPeerProject:on:named:)` maps a peer's id onto ours via
|
||
`ProjectDescriptor.matches` (UUID → root-commit SHA → normalized remote) read from the peer's
|
||
presence card, falling back to the project *name* for the window before any card is published
|
||
(presence is cast-backed, so a LAN-only pair may have none). A runner's clone that matches on
|
||
none of those is still folded by its `CovalenceSessionMirror` record. Only a project a peer holds
|
||
and we genuinely don't gets a header of its own, so its chats aren't lost. Tests:
|
||
`MeshUnifiedSidebarTests.peerCopyOfALocalProjectFoldsIntoOneSection`,
|
||
`.peerProjectFoldsByRepoIdentityWhenNamesDiffer`, `.sameProjectOnTwoOriginsSharesOneSection`.
|
||
- **Selection**: `openSessionID.didSet` routes remote sessions itself (via
|
||
`remoteSessionLocation`, which prefers local records so tombstones aren't hijacked) — every
|
||
writer of `openSessionID` opens local and remote identically. `openSession(_ summary:)` is the
|
||
row-level opener that honors the row's own origin.
|
||
- **Verbs**: mesh Macs grant each other `.control`, so the sidebar's context menu / swipes and the
|
||
chat header dispatch the same wire verbs a phone uses (`PeerClient.sendCommand`): favorite,
|
||
archive, delete, interrupt, rename, model/effort/auto/ship, and `startChat` into a remote project
|
||
(new-chat composer picks across the mesh; `RemoteProjectView` is the overview page). Owner-side
|
||
fixes make mirrors converge: `mutateSession`(+`ForRemote`) broadcast `sessionUpdated`, and
|
||
`deleteSession` pushes a fresh `sessionList` (deletions had no broadcast at all).
|
||
- **Still local-only** (hidden, not inert, for remote): Mark Done (the wire verb
|
||
`markSessionDone` exists — the phone's Sessions list and chat ⋯ menu use it, gated on
|
||
`WireCapabilities.canMarkSessionDone` — but this sidebar doesn't track that capability
|
||
per peer yet, so peer rows stay hidden), project-level rename/archive/delete/convert,
|
||
branch/worktree/attachments/ship in the composer, and the worktree-rooted chrome
|
||
(panels, Build/Run, git ops).
|
||
|
||
### ◐ Phase 5 — Session transfer Mac→Mac — ENGINE + DESTINATION DONE; source-driver + UI pending
|
||
|
||
**Engine (done + tested):** the full 2-phase-commit transfer engine, proven end-to-end by a
|
||
loopback test (real temp git repos + real GRDB + in-memory channel):
|
||
- **Wire** ([TransferMessages.swift](../Sources/NucleicProtocol/Sync/TransferMessages.swift)):
|
||
`TransferOffer`/`Accept`/`Reject`/`Chunk`/`ChunkAck` payloads, `SessionTransferRecord`,
|
||
`ProjectDescriptor` (matches by UUID / root commit / normalized remote), `TransferRejectReason`
|
||
(raw-string, forward-compatible). `ClientMsg` source→dest verbs + `HostMsg` dest→source replies;
|
||
the replies **also** map to `SyncClient.Event` + `messageLoop` (the source receives them as a
|
||
client). `WireCapabilities.canReceiveSessionTransfer` gates every source send.
|
||
- **Persistence (v23):** `moved_to_device_id`/`moved_at` tombstone columns (rides the existing
|
||
`archived` read/sweep path — **not** a new `SessionStatus` case) + a `session_transfer` table with
|
||
a UNIQUE-while-active index; store methods `beginTransfer`/`updateTransferState`/`activeTransfer`/
|
||
`allActiveTransfers`/`tombstoneSession`/`activateTransferredSession` (atomic + idempotent).
|
||
- **Git** ([TransferGit.swift](../Sources/NucleicCore/Transfer/TransferGit.swift) +
|
||
[WorktreeManager.swift](../Sources/NucleicCore/Git/WorktreeManager.swift)): `createTransferBundle`
|
||
(prereq-based, prereq-free fallback), `createFromTransfer` (verify + fetch + worktree add),
|
||
`discardAfterTransfer`, `presentSHAs`/`branchExists`; `TransferGitProbe` for repo shape
|
||
(shallow/partial/LFS/submodule) + identity. git-bundle round-trip verified against real repos.
|
||
- **Transcript:** `SessionHeader.maxSupported` + `TranscriptHeaderRewriter` (repoint paths / drop
|
||
`backendSessionID` for the fresh-context fallback) — the first consumer of transcript versioning.
|
||
- **Coordinator/Importer/Package** ([Transfer/](../Sources/NucleicCore/Transfer/)):
|
||
`SessionTransferCoordinator` (source, tombstone-before-commit, windowed chunk acks, typed
|
||
errors), `SessionTransferImporter` (dest, stage-non-runnable → activate-on-commit, best-effort
|
||
native memory carry with fresh-context fallback), `SessionCarbonCopy` (the Carbon Copy replica —
|
||
items + per-item sha256, bundle built post-accept vs `haveSHAs`).
|
||
|
||
**Destination integration (done + tested):** `ConnectionHandler` dispatches the transfer verbs
|
||
gated on `.control` + mac peer (`peerKind` now persisted); `AppStore` owns the importer via a
|
||
`StandardTransferImportEnvironment` (project resolution by UUID/identity, native-path computation,
|
||
`activateTransferredSession` → reconstruct controller + broadcast), advertises the capability, and
|
||
teaches the archived-worktree sweep to skip a mid-transfer session. Tested through the real
|
||
`AppStore` bridge (capability advertised, unknown-project reject, known-project accept w/ haveSHAs).
|
||
|
||
**Hardening (adversarial review, 4 dims × 2 refuters):** fixed the critical
|
||
"never-runs-in-two-places" gap — a DB tombstone alone didn't make the source session inert, so
|
||
during the post-tombstone commit round-trip a connected peer could still `sendInput`/list/snapshot
|
||
it. Now a moving session is held in `transferringSessions` (hidden from `sessionSummaries`/
|
||
`snapshot`, refuses `sendInput`) for the whole transfer, `loadSessions` never rebuilds a controller
|
||
for a `movedToDeviceID != nil` session (relaunch safety), and any post-tombstone commit failure
|
||
surfaces as `committedButUnconfirmed` — which tears the source down (inert) and keeps the lock at
|
||
`.tombstoned` (worktree preserved) for recovery, never reviving or double-running it. Also fixed:
|
||
path-traversal in the importer (validate `transferID`/`sessionID`/branch as safe components before
|
||
building any filesystem path), the source now checks `canReceiveSessionTransfer` before sending,
|
||
`tearDownMovedSession` fully shuts the controller + reaps its container (no leak), and
|
||
`resolveTransferProject`'s UUID fast-path no longer matches an archived project.
|
||
|
||
**Source driver + UI (done + tested):** `PeerClient.openTransferChannel(to:)` binds a
|
||
`PeerClientTransferChannel` to the destination's live `SyncClient`, routing the transfer-reply
|
||
`SyncClient.Event`s through per-peer sinks (and `.disconnected` on a mid-transfer drop);
|
||
`PeerPresence.canReceiveSessionTransfer` is learned from the welcome. `AppStore.moveSession(_:to:)`
|
||
classifies transferability (`TransferClassifier` for status / nvrsion / nested-or-parent /
|
||
no-worktree + `TransferGitProbe` repo shape), quiesces via `finalize(.auto("nucleic: transfer
|
||
snapshot"))`, packages the branch + transcript (+ best-effort native memory) and the
|
||
`ProjectDescriptor`, then runs the coordinator over the peer channel; on success the source is
|
||
tombstoned + torn down. A "Move to Mac" submenu on the sidebar row lists connected peer Macs that
|
||
advertise the capability. Tested end-to-end over a **real** `PeerClient` channel + `SyncHost`
|
||
(`PeerTransferChannelTests`), not just the in-memory loopback.
|
||
|
||
**Moved-session visibility (done + tested):** a moved session no longer silently vanishes.
|
||
`SessionSummary.movedTo: MovedDestination? {deviceID, deviceName}`
|
||
([WireMessages.swift](../Sources/NucleicProtocol/Sync/WireMessages.swift), additive /
|
||
decode-defaulted, trailing init param) carries where it went. The source keeps the tombstone as a
|
||
read-only row: `AppStore.movedDestination(for:)` resolves the destination's name from the
|
||
paired-device store; `wireSummary` stamps it; `finalizeMovedTeardown` re-upserts an archived+moved
|
||
Core summary; `loadSessions` re-surfaces moved tombstones on relaunch **without** rebuilding a
|
||
runnable controller; and `sessionSummaries()` includes them (from the DB) so a (re)subscribing
|
||
phone sees them. The Mac sidebar renders "Moved to <Mac>" under Archived (name resolved live from
|
||
`pairedDevices`, so it upgrades past the persisted fallback once the sync server loads), suppresses
|
||
the mutating row actions, and the iOS row renders the host-baked name. Tests:
|
||
`WireMessageTests.sessionSummaryCarriesMovedTo` (+ legacy tolerance),
|
||
`AppStoreTests.movedSessionSurfacesAsTombstoneAfterRelaunch` (relaunch surfacing, no revive,
|
||
wire marker). iOS view edits not compiled here (separate Xcode target).
|
||
|
||
**Relaunch recovery (done + tested):** `AppStore.recoverInterruptedTransfers()` reconciles any
|
||
transfer a crash/quit left mid-flight — driven from launch (after `loadSessions` in
|
||
[NucleicApp.swift](../Sources/NucleicApp/NucleicApp.swift)) and again on every peer (re)connect (a
|
||
fire-and-forget, single-flight call in `meshPeersChanged`). Abandoned pre-tombstone outbound locks
|
||
(`.offering`/`.streaming`/`.awaitingReady`) are cleared (the source session lives here again, so the
|
||
UNIQUE-while-active guard frees for a re-move); orphaned inbound `.staging` is dropped
|
||
(`discardStaleInboundTransfers`); and a durably-`tombstoned` outbound source finishes its 2-phase
|
||
commit via `SessionTransferCoordinator.recoverTombstoned` — a bounded, idempotent re-send of
|
||
`transferCommit` to the destination peer that, on the confirm, discards the stale source worktree
|
||
and clears the lock (a destination that also relaunched and lost its staging just leaves the lock at
|
||
`.tombstoned`, never reviving the source). Tests: `SessionTransferTests.recoverTombstoned*` (both the
|
||
reconnect-completes and destination-lost-staging paths, over the real loopback + GRDB) +
|
||
`AppStoreSyncBridgeTests.recoverInterruptedTransfersCleansUpAbandonedLocks`.
|
||
|
||
**Bulk hand-off (done + tested):** the "take the laptop home, hand its sessions to the Mac that
|
||
stays" flow. `AppStore.transferableSessions()` lists the eligible live chats (session-level
|
||
`TransferClassifier`, excluding archived / mid-transfer / mid-turn / nested / no-worktree);
|
||
`moveSessionsToPeer(_:to:label:)` runs the selected chats through the same single-session path in
|
||
sequence (one per-peer channel at a time), folding per-chat failures into one `lastError` rollup and
|
||
counting a `committedButUnconfirmed` as moved. UI: a "Hand off…" button on each connected,
|
||
transfer-capable Paired-Mac row ([RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift))
|
||
opens a `HandoffSheet` — a select-all-by-default checklist of eligible chats with a "Move N chats"
|
||
action. Test: `AppStoreSyncBridgeTests.bulkHandoffListsEligibleAndRollsUpFailures` (eligibility,
|
||
archived exclusion, unreachable-peer rollup + restore).
|
||
|
||
**Arrived-from provenance (done + tested):** the mirror of moved-to, on the destination. GRDB **v24**
|
||
adds `arrived_from_device_id` / `arrived_at` to `session`; the importer stamps them at staging (source
|
||
= the inbound offer's device id); `Session` carries the fields. Wire: additive
|
||
`SessionSummary.arrivedFrom: ArrivedFrom? {deviceID, deviceName}` (decode-defaulted). The Core summary
|
||
carries the *raw* source id (it rides every live-session summary, so no call site threads it) and the
|
||
Mac sidebar resolves the name live from `pairedDevices`, showing a subtle inbound chevron with the
|
||
source Mac in its tooltip; the wire summary bakes the resolved name (`AppStore.arrivedSource(for:)`)
|
||
so a phone renders "Arrived from <Mac>" (iOS folds it into the row subtext). Tests:
|
||
`WireMessageTests.sessionSummaryCarriesArrivedFrom`, `SessionTransferTests.happyPath…`
|
||
(real GRDB v24 round-trip of the provenance), `AppStoreTests.arrivedSessionSurfacesProvenanceInSummaries`.
|
||
|
||
**Stranded-arrival recovery — "Activate anyway" (done + tested):** the last 2PC gap — a destination
|
||
that relaunches after staging (`.ready`) but before commit. At `.ready` the importer now writes the
|
||
staged `Session` to `…/transfers/<id>/staged-session.json`
|
||
([SessionTransferImporter.swift](../Sources/NucleicCore/Transfer/SessionTransferImporter.swift)),
|
||
so a relaunch (which loses the in-memory offer/staged session) can rebuild it:
|
||
`recoverableInboundTransfers()` scans `.ready` inbound locks + reads each manifest (dropping any whose
|
||
manifest is gone as unrecoverable); `activateRecoveredTransfer(_:)` inserts the session + flips the
|
||
lock to `.activated` (reusing the commit path) + notifies the env to build the controller;
|
||
`clearInboundStaging(_:)` drops the staging for a discard. `AppStore.recoverInterruptedTransfers`
|
||
surfaces these as `pendingArrivedTransfers`; `activateArrivedSession`/`discardArrivedSession` drive
|
||
the choice (discard also tears down the imported worktree/branch + placed transcript). UI: an
|
||
"Interrupted arrivals" section in [RemoteAccessView.swift](../Sources/NucleicApp/RemoteAccessView.swift)
|
||
with per-chat Activate / Discard. Tests: `SessionTransferTests.recoverAndActivateStrandedInboundAfterDestRelaunch`
|
||
+ `.discardStrandedInboundClearsStagingAndLock` (real fresh-importer "relaunch" over the same
|
||
store/stagingRoot), `AppStoreSyncBridgeTests.recoverInterruptedTransfersSurfacesStrandedArrival`.
|
||
|
||
**Transfer from either end — "bring it here" / brokered peer→peer (done + tested):** the menu used to
|
||
appear only on a chat this Mac owns, because the wire had no way to ask another Mac to let go of one.
|
||
`ClientMsg.requestSessionTransfer(SessionID, deviceID)` closes that: the requester only *names* the
|
||
destination and the **owner runs its own `moveSession`**, so every transfer still originates at the
|
||
machine holding the worktree — the 2PC contract above is untouched. Gated by
|
||
`WireCapabilities.canTransferOnRequest` (field-additive; an older owner decodes `false` and the menu
|
||
stays hidden) plus the same `.control`-scope + session-owning-peer check as the transfer verbs, and
|
||
run off the message loop so packaging a session doesn't stall the connection.
|
||
`AppStore.transferDestinations(for:)` answers for either origin: a local chat lists the connected
|
||
session-owning peers as before; a peer's chat lists **this Mac** (always — it's connected to the owner
|
||
by definition) plus the owner's own online session-owning peers, which is what makes a peer→peer move
|
||
this Mac merely brokers possible. `AppStore.transferSession(_:to:)` routes accordingly.
|
||
Tests: `WireMessageTests.transferClientMessagesRoundTrip` + `.transferOnRequestCapabilityToleratesOlderHosts`,
|
||
`MeshUnifiedSidebarTests.remoteSessionOffersThisMacAndTheOwnersOtherPeers`.
|
||
|
||
**Covalence fold on transfer (done + tested):** moving a chat *to a cloud runner* records the same
|
||
`CovalenceSessionMirror` a runner **dispatch** does (`AppStore.noteTransferLanding`, on both commit
|
||
paths and on tombstone recovery) — the backstop for a runner clone whose repo identity doesn't match
|
||
ours, where the general one-project-one-row fold above can't tie the two together and the user would
|
||
see the project twice, once local and once globe-badged. Test:
|
||
`MeshUnifiedSidebarTests.transferToCloudRunnerFoldsUnderTheOriginProject`.
|
||
|
||
**Remaining:** the prerequisite spike (hand-copy a native transcript between two Macs, try
|
||
`claude --resume`) still gates whether memory carry stays on by default or the importer forces the
|
||
fresh-context fallback — the only Phase 5 item left, and it needs two physical Macs. (Nice-to-have
|
||
follow-up: clear the arrived-from marker once the user sends their first message in an adopted session.)
|
||
|
||
---
|
||
|
||
## Compatibility & migrations
|
||
|
||
| Store | Old → New | Phase |
|
||
|---|---|---|
|
||
| Mac transport | `nucleic.sync.transport` single → `nucleic.sync.transports` set | 1 ✅ |
|
||
| PairedDevice JSON | + `kind` / `capabilities`, decode-defaulted | 4 ✅ |
|
||
| PairedDevice JSON | + `addresses` (`PeerAddresses`), decode-defaulted | 4 ✅ |
|
||
| PairingPayload | + optional relay `roomID`/`token`/`URL` CBOR keys | 2 ✅ |
|
||
| iOS hosts | `nucleic.pairedHost` single → `nucleic.pairedHosts` registry | 3 ☐ |
|
||
| GRDB | + `moved_to_device_id`/`moved_at`; + `session_transfer` table (v23) | 5 ✅ |
|
||
| GRDB | + `arrived_from_device_id`/`arrived_at` (v24, transfer provenance) | 5 ✅ |
|
||
|
||
## Known test-isolation note
|
||
|
||
Headless `swift test` could deadlock: `AppStore` first-launch set the global
|
||
`nucleic.container.serviceEnabled`, flipping parallel `SessionController` tests into the sandboxed
|
||
path → a real Keychain read → an invisible ACL prompt → hang holding `ClaudeLoginKeychain.lock`. A
|
||
proper fix (task-local container settings + Keychain guard) has since landed on `dev`. If a run still
|
||
hangs, pre-seed the `swiftpm-testing-helper` defaults domain (`serviceEnabled=0`,
|
||
`firstLaunchCompleted=1`).
|