feat(mobile): protocol-version compat block on status.get

Extends status.get to return protocolVersion + minCompatibleMobileVersion.
Mobile reads them on connect, evaluates compat, and renders a hard-block
screen when a combo is declared incompatible. Today's compat constants are
wide-open (mobile=any, desktop=any) so nothing actually blocks; the wire
format is in place for the day we ship a genuinely-breaking change.

Routine drift (additive desktop changes) stays handled by mobile's existing
permissive parsing and per-method fallbacks.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-05 22:05:51 -04:00
co-authored by Orca
parent b4310ba78f
commit ec9a20da3a
10 changed files with 361 additions and 2 deletions
+8 -1
View File
@@ -10,6 +10,13 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
// Why: mobile/ owns its own bundle but pure-logic units (no React
// Native imports) can run under the root vitest harness so they
// get covered in CI alongside src/ tests.
'mobile/src/**/*.test.ts'
]
}
})
+61
View File
@@ -44,8 +44,10 @@ import { PickerModal, type PickerOption } from '../../../src/components/PickerMo
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
import {
loadPinnedIds,
savePinnedIds,
@@ -53,6 +55,15 @@ import {
savePreferences
} from '../../../src/storage/preferences'
// Why: locally-typed subset of the desktop's RuntimeStatus we read from
// `status.get`. Only the version fields matter to mobile today; everything
// else is opaque. Both fields are optional since pre-PR desktops won't
// return them — the compat evaluator handles undefined gracefully.
type DesktopStatus = {
protocolVersion?: number
minCompatibleMobileVersion?: number
}
type Worktree = {
worktreeId: string
repo: string
@@ -278,6 +289,7 @@ export default function HostScreen() {
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
@@ -339,6 +351,10 @@ export default function HostScreen() {
useEffect(() => {
setHostName('')
setError('')
setCompatVerdict({ kind: 'ok' })
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
if (freshCache) {
setWorktrees(freshCache)
@@ -420,6 +436,47 @@ export default function HostScreen() {
}
}, [connState, fetchWorktrees])
// Why: read desktop's protocol version from status.get on every connect
// and re-evaluate compatibility. If the desktop declares this mobile
// build too old (or vice versa via the local minimum), the host detail
// screen swaps to a hard-block screen instead of the worktree list.
// Today's compat constants are wide-open so this never blocks; the wire
// format is in place to flip a switch in a future release.
useEffect(() => {
if (connState !== 'connected' || !client) return
let cancelled = false
const requestClient = client
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled || clientRef.current !== requestClient) return
if (!response.ok) return
const status = (response as RpcSuccess).result as DesktopStatus
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
setCompatVerdict(verdict)
if (verdict.kind === 'blocked') {
// Why: deterministic breadcrumb so support can confirm a block
// actually fired (vs a render bug). No PII — just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
requiredMobileVersion: verdict.requiredMobileVersion,
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: rare path — sendRequest can throw on transport tear-down.
// Treat as transient; verdict stays at previous value.
}
})()
return () => {
cancelled = true
}
}, [connState, client])
useEffect(() => {
if (connState !== 'connected') return
const interval = setInterval(() => {
@@ -637,6 +694,10 @@ export default function HostScreen() {
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
@@ -0,0 +1,105 @@
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { router } from 'expo-router'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { CompatVerdict } from '../transport/protocol-compat'
import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version'
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
type Props = {
verdict: Extract<CompatVerdict, { kind: 'blocked' }>
}
export function ProtocolBlockScreen({ verdict }: Props) {
const isMobileTooOld = verdict.reason === 'mobile-too-old'
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop'
const body = isMobileTooOld
? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.`
: `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.`
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{/* Why: only desktop side has a deep-link target today —
App Store ID isn't published yet, so mobile-too-old is text-only. */}
{!isMobileTooOld ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(RELEASES_URL)
}}
>
<Text style={styles.primaryButtonText}>Open GitHub Releases</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
onPress={() => {
// Why: route back to the host list so the user can pair a
// different host instead of getting trapped on this screen.
router.replace('/')
}}
>
<Text style={styles.secondaryButtonText}>Pair a different host</Text>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
justifyContent: 'center',
paddingHorizontal: spacing.lg
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.borderSubtle
},
title: {
fontSize: typography.titleSize,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: spacing.sm
},
body: {
fontSize: typography.bodySize,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.lg
},
primaryButton: {
backgroundColor: colors.textPrimary,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center',
marginBottom: spacing.sm
},
primaryButtonText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.bgBase
},
secondaryButton: {
backgroundColor: colors.bgRaised,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button,
alignItems: 'center'
},
secondaryButtonText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textPrimary
},
pressed: {
opacity: 0.7
}
})
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { evaluateCompat } from './protocol-compat'
import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version'
describe('evaluateCompat', () => {
it('returns ok when both fields are undefined and constants are wide-open', () => {
const verdict = evaluateCompat({
desktopProtocolVersion: undefined,
desktopMinCompatibleMobileVersion: undefined
})
expect(verdict).toEqual({ kind: 'ok' })
})
it('returns ok when desktop reports version equal to MOBILE_PROTOCOL_VERSION', () => {
const verdict = evaluateCompat({
desktopProtocolVersion: MOBILE_PROTOCOL_VERSION,
desktopMinCompatibleMobileVersion: 0
})
expect(verdict).toEqual({ kind: 'ok' })
})
it('returns ok when desktop reports a newer version (additive changes assumed safe)', () => {
const verdict = evaluateCompat({
desktopProtocolVersion: MOBILE_PROTOCOL_VERSION + 5,
desktopMinCompatibleMobileVersion: 0
})
expect(verdict).toEqual({ kind: 'ok' })
})
it('blocks with mobile-too-old when desktop requires a newer mobile', () => {
const verdict = evaluateCompat({
desktopProtocolVersion: 5,
desktopMinCompatibleMobileVersion: MOBILE_PROTOCOL_VERSION + 1
})
expect(verdict).toEqual({
kind: 'blocked',
reason: 'mobile-too-old',
desktopVersion: 5,
requiredMobileVersion: MOBILE_PROTOCOL_VERSION + 1
})
})
it('coerces undefined desktopVersion to 0 in the verdict payload', () => {
const verdict = evaluateCompat({
desktopProtocolVersion: undefined,
desktopMinCompatibleMobileVersion: MOBILE_PROTOCOL_VERSION + 1
})
expect(verdict).toMatchObject({
kind: 'blocked',
reason: 'mobile-too-old',
desktopVersion: 0
})
})
it('mobile-too-old wins precedence when both constraints would fire', () => {
// Why: documents the intended kill-switch precedence — desktop's
// refusal of a too-old mobile takes priority over mobile's local
// refusal of a too-old desktop.
const verdict = evaluateCompat({
desktopProtocolVersion: -1, // would also trip desktop-too-old if we got there
desktopMinCompatibleMobileVersion: MOBILE_PROTOCOL_VERSION + 1
})
expect(verdict.kind).toBe('blocked')
expect((verdict as { reason: string }).reason).toBe('mobile-too-old')
})
it('today: MIN_COMPATIBLE_DESKTOP_VERSION = 0 lets every reported desktop pass', () => {
expect(MIN_COMPATIBLE_DESKTOP_VERSION).toBe(0)
for (const v of [0, 1, 2, 99]) {
expect(
evaluateCompat({
desktopProtocolVersion: v,
desktopMinCompatibleMobileVersion: 0
})
).toEqual({ kind: 'ok' })
}
})
})
+44
View File
@@ -0,0 +1,44 @@
import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version'
export type CompatVerdict =
| { kind: 'ok' }
| {
kind: 'blocked'
reason: 'mobile-too-old' | 'desktop-too-old'
desktopVersion: number
requiredMobileVersion?: number
requiredDesktopVersion?: number
}
export function evaluateCompat(input: {
desktopProtocolVersion: number | undefined
desktopMinCompatibleMobileVersion: number | undefined
}): CompatVerdict {
// Why: absent fields → 0 lets mobile keep talking to pre-PR desktops.
// Bumping MIN_COMPATIBLE_DESKTOP_VERSION above 0 will fence those
// older desktops alongside any explicitly-version-0 desktop, which
// is the intended kill-switch behavior.
const desktopVersion = input.desktopProtocolVersion ?? 0
const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0
// Why: mobile-too-old precedence — if desktop says "I refuse this
// mobile build" (kill switch), that wins over any local mobile
// judgment about desktop's age.
if (MOBILE_PROTOCOL_VERSION < requiredMobile) {
return {
kind: 'blocked',
reason: 'mobile-too-old',
desktopVersion,
requiredMobileVersion: requiredMobile
}
}
if (desktopVersion < MIN_COMPATIBLE_DESKTOP_VERSION) {
return {
kind: 'blocked',
reason: 'desktop-too-old',
desktopVersion,
requiredDesktopVersion: MIN_COMPATIBLE_DESKTOP_VERSION
}
}
return { kind: 'ok' }
}
+20
View File
@@ -0,0 +1,20 @@
// Why: declares the mobile's pairing protocol version and the minimum
// desktop version it can talk to. Duplicates the desktop's
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
// outside `mobile/`. Manual sync is acceptable — these constants are
// expected to bump less than once a quarter.
//
// Bump MOBILE_PROTOCOL_VERSION when:
// - You change the meaning of an RPC mobile sends.
// - You stop relying on a desktop-side feature in a way old desktops
// would notice.
// Do NOT bump for:
// - Adding new optional fields to outbound requests.
// - Reading new optional fields on incoming responses.
//
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
// desktop feature added at a specific desktop protocol version. This
// triggers a hard-block screen for users paired to older desktops.
export const MOBILE_PROTOCOL_VERSION = 1
export const MIN_COMPATIBLE_DESKTOP_VERSION = 0
+10
View File
@@ -209,6 +209,16 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getRuntimeId()).toBeTruthy()
})
it('reports protocol version and minimum compatible mobile version on status', () => {
const runtime = createRuntime()
const status = runtime.getStatus()
expect(typeof status.protocolVersion).toBe('number')
expect(typeof status.minCompatibleMobileVersion).toBe('number')
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0)
})
it('claims the first window as authoritative and ignores later windows', () => {
const runtime = createRuntime()
+7 -1
View File
@@ -21,6 +21,10 @@ import type {
WorktreeStartupLaunch
} from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import {
DESKTOP_PROTOCOL_VERSION,
MIN_COMPATIBLE_MOBILE_VERSION
} from '../../shared/protocol-version'
import type {
RuntimeGraphStatus,
RuntimeRepoSearchRefs,
@@ -594,7 +598,9 @@ export class OrcaRuntimeService {
graphStatus: this.graphStatus,
authoritativeWindowId: this.authoritativeWindowId,
liveTabCount: this.tabs.size,
liveLeafCount: this.leaves.size
liveLeafCount: this.leaves.size,
protocolVersion: DESKTOP_PROTOCOL_VERSION,
minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION
}
}
+23
View File
@@ -0,0 +1,23 @@
// Why: declares the desktop's mobile-pairing protocol version so mobile
// builds can detect declared-incompatible combos and hard-block at pair
// time. Today's values are wide-open (mobile=any, desktop=any), so
// nothing actually blocks; the wire format is ready for the day we
// ship a genuinely-breaking change.
//
// Bump DESKTOP_PROTOCOL_VERSION when:
// - You remove an RPC method or required parameter that mobile uses.
// - You change the meaning (units, nullability) of an existing field
// mobile reads.
// - You change encryption, framing, or the auth handshake.
// Do NOT bump for:
// - Adding new RPC methods.
// - Adding new optional fields on existing methods.
// - Adding new event types in `terminal.subscribe`.
//
// Bump MIN_COMPATIBLE_MOBILE_VERSION when desktop ships a change that
// requires a minimum mobile version to function safely. This is the
// "kill switch": desktop can refuse old mobile builds without needing
// a desktop release of mobile.
export const DESKTOP_PROTOCOL_VERSION = 1
export const MIN_COMPATIBLE_MOBILE_VERSION = 0
+5
View File
@@ -11,6 +11,11 @@ export type RuntimeStatus = {
authoritativeWindowId: number | null
liveTabCount: number
liveLeafCount: number
// Why: optional so mobile builds can read both new and pre-PR desktops.
// Absence is treated as 0 by mobile's compat evaluator. See
// src/shared/protocol-version.ts for bump discipline.
protocolVersion?: number
minCompatibleMobileVersion?: number
}
export type CliRuntimeState =