Skip to content

feat(x): rowboat-server vertical slice + iOS companion app - #758

Merged
Gagancreates merged 5 commits into
arch/server-client-separationfrom
feat/ios-app
Aug 21, 2026
Merged

feat(x): rowboat-server vertical slice + iOS companion app#758
Gagancreates merged 5 commits into
arch/server-client-separationfrom
feat/ios-app

Conversation

@Gagancreates

Copy link
Copy Markdown
Collaborator

What this is

The phone-client vertical slice of the server/client RFC (#700), working end-to-end: a minimal-but-end-state-correct rowboat-server transport hosted inside the Electron app, and an Expo iOS app that pairs with it over LAN — chat with live turn streaming, read-only notes, QR pairing. Verified live in the iOS simulator against real sessions (screenshots in the thread on request).

How it's shaped (for the server refactor)

The RFC's hard constraint this slice respects: exactly one core instance exists. apps/server is a transport library — it does not boot core. Its dependencies (handler map, turn/session buses, workspace resolver) are injected:

  • apps/x/apps/server/src/server.tscreateRowboatServer({handlers, events, resolveWorkspacePath, ...}) assembles HTTP router + WS hub + workspace file route on one node:http server (Hono + ws).
  • src/channels.ts — the allowlisted channel subset (RPC_CHANNELS). This is the strangler-fig frontier: migrating a channel group = adding names here + a handler in src/core-deps.ts (thin container.resolve pass-throughs, lifted verbatim from main's ipc.ts).
  • src/ws-hub.ts — wire protocol: per-connection monotonic seq, hello handshake (capability slot for RFC Q14), durable events broadcast to all clients, text/reasoning deltas per-connection per-turn ({type:'subscribe', topic:'turn-deltas', turnId}) — the network twin of main's turnDeltaSubs registry.
  • src/standalone.ts — the RFC end-state headless entrypoint (fallback DI services, pid lockfile against split-brain). Test/dev-only until the Phase 1 flip; the flip is boot-wiring only (main stops booting core, spawns this, becomes a WS client).
  • Temporary seams, clearly marked: main hosts the transport in-process (apps/main/src/server-host.ts) and keeps its own webContents.send fan-out alongside the hub (dual fan-out dies with the flip). The renderer's migrated channels cross real localhost HTTP via apps/main/src/rpc-forwarder.ts (ROWBOAT_FORWARD_MIGRATED, default on in dev, off in packaged builds) so the API is exercised daily.

Auth is the RFC Q8 scheme: server-key bearer (~/.rowboat/server-key, 0600), QR pairing from Settings → Phone app, 127.0.0.1 bind by default with LAN opt-in, key rotation revokes all clients (phones auto-return to pairing on 401/4401).

Client side

  • packages/client (@x/client): portable typed RPC + reconnecting WS client (backoff, seq-gap → resync callback, refcounted delta subs). Also the future implementation of main's forwarder.
  • apps/mobile (@x/mobile, Expo SDK 57): pairing (QR/manual/dev deep-link), sessions, chat (reuses followTurn + reduceTurn from @x/shared verbatim — turn-follower.ts moved renderer → shared unchanged), notes, PostHog (platform=mobile), EAS profiles.

Tests / verification

  • 26 new tests: transport unit tests + @x/client↔server integration incl. server-restart reconnect/resync (apps/server/src/*.test.ts)
  • Full suites green: shared 107, renderer 83 (turn-follower move covered), server 14; npm run typecheck green; expo export --platform ios bundles clean through pnpm
  • Verified live: curl'd auth/allowlist, real LLM turn streamed over WS with gapless seq + delta gating, simulator paired and chatting against real sessions

Not in this PR

Voice (needs a dev build + signed-in account — client code seams are ready), push notifications (RFC Q14 reverse calls), remaining ~200-channel migration, TLS/remote deployment.

🤖 Generated with Claude Code

@ramnique

Copy link
Copy Markdown
Contributor

Gagan — this is great work. It follows the RFC decision-by-decision, you even nailed the addendum stuff (delta gating, offset splice), and making the server a library that never boots core was exactly the right call. Two things I want before we merge, then ship it.

1. Turn forwarding on in packaged builds, now. Right now ROWBOAT_FORWARD_MIGRATED defaults off in prod, which means real users' desktops never touch the HTTP path — only phones do. That's the exact API-rot trap Q2 was written to avoid, and "temporary" setups like this have a way of becoming permanent. Flip the default so packaged builds forward the migrated channels too (keep the env var as a kill switch). And let's agree here in the thread on who's driving the rest of Phase 1 and roughly when the child-process flip lands — doesn't need to be in this PR, the strangler-fig plan is right, I just don't want it to drift. One rule from here on: any new or touched core channel goes into channels.ts + core-deps.ts and gets forwarded — the frontier only moves forward.

2. Fix the stale-turn-on-reconnect bug. If the phone's WS drops and the turn finishes while it's offline, the turn renders frozen mid-stream forever — onResync fires but use-live-turn.ts only clears the text overlay, and followTurn can't see the gap because no later event ever arrives. This is the exact flaky-phone case the RFC worried about. Should be a small fix: give the follower a refetch() (or re-key it on a resync counter) and call it from onResync.

Everything else can be follow-up issues: the split-brain lock is one-sided (main's in-process host never takes server.lock, and the port-fallback makes the collision silent instead of loud), token rides the WS URL as a query param, no Host check, and symlinks can escape the workspace route. None of it blocks merge.

@Gagancreates

Gagancreates commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Both fixed in 07f5b2c:

  1. Forwarding now defaults ON in packaged builds too — ROWBOAT_FORWARD_MIGRATED=0 stays as the emergency kill switch only.
  2. followTurn now returns { stop, refetch }; the phone calls refetch() from the events client's onResync, so a turn that finished during an outage re-converges on reconnect. Added a follower test reproducing exactly that scenario (snapshot mid-stream, feed silent, refetch → terminal state).

Filed the non-blocking items as #879 (one-sided lock + silent port fallback), #880 (WS token in query string), #881 (Host validation), #882 (symlink escape on the workspace route).

On Phase 1 ownership — want me to take the server-side piece (channel migration + the child-process flip), or do you want to drive it with your refactor? Happy either way; I'll keep driving the phone client (voice + TestFlight next) regardless, and +1 on the rule — anything we touch goes through channels.ts + core-deps.ts and gets forwarded.

@Gagancreates
Gagancreates changed the base branch from main to arch/server-client-separation August 20, 2026 15:00
Implements the phone-client slice of the server/client RFC (#700):

- apps/server (@x/server): HTTP transport generated from ipcSchemas
  (POST /rpc/{channel}, allowlisted subset), server-key bearer auth,
  WS /events hub (per-connection seq, durable broadcast + per-turn
  delta subscriptions, hello handshake), authenticated workspace file
  route, headless standalone entrypoint with pid lockfile
- main hosts the transport in-process on its single core instance and
  forwards migrated channels over localhost HTTP (strangler-fig,
  ROWBOAT_FORWARD_MIGRATED kill switch, on by default in dev)
- Phone-app settings tab: QR pairing, LAN opt-in, key rotation
- packages/client (@x/client): portable typed RPC + reconnecting WS
  client (seq-gap detection, refcounted delta subs, 401/4401 handling)
- apps/mobile (@x/mobile): Expo iOS app — QR/manual/dev-link pairing,
  sessions list, live chat (shared turn-follower + reduceTurn, delta
  streaming, permission/ask-human prompts), read-only notes browser,
  PostHog events (platform=mobile), EAS build profiles
- turn-follower moves from renderer into @x/shared (unchanged), so
  desktop and phone share the join/streaming protocol verbatim
The new packages are gitignored dist consumers like shared/core, but the
forge generateAssets hook and the root test/typecheck scripts didn't build
them: the Electron package smoke test failed resolving @x/server from
main, and the server vitest suite failed importing @x/client.
…h turns on WS reconnect

- rpc-forwarder: forwarding defaults ON everywhere (packaged builds
  included) so production desktops exercise the HTTP path daily;
  ROWBOAT_FORWARD_MIGRATED=0 stays as the kill switch
- turn-follower: followTurn now returns { stop, refetch }. refetch()
  forces a fresh snapshot — required for droppable transports where a
  turn that finished during an outage never emits another event, so
  offset-gap detection alone can leave it frozen mid-stream. The phone
  calls it from the events client's onResync; renderer behavior is
  unchanged. Covered by a new turn-follower test.
@Gagancreates
Gagancreates merged commit 122684f into arch/server-client-separation Aug 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants