mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(mobile): deliver live Android update recommendation
This commit is contained in:
@@ -123,6 +123,65 @@ describe('useHostStatusGates', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('observes a cold recommendation with one bounded follow-up request', async () => {
|
||||
vi.useFakeTimers()
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
protocolVersion: 3,
|
||||
minCompatibleMobileVersion: 2,
|
||||
capabilities: ['mobile.tasks.v1'],
|
||||
recommendedMobileAppVersionsPending: true
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
protocolVersion: 3,
|
||||
minCompatibleMobileVersion: 2,
|
||||
capabilities: ['mobile.tasks.v1'],
|
||||
recommendedMobileAppVersions: { android: '0.0.40' }
|
||||
}
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
let gates: HostStatusGates | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Probe(): null {
|
||||
gates = useHostStatusGates({ hostId: 'host-1', client, connState: 'connected' })
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Probe))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(gates).toMatchObject({
|
||||
hostCapabilities: ['mobile.tasks.v1'],
|
||||
recommendedMobileAppVersions: null,
|
||||
statusPending: false
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(6_000)
|
||||
})
|
||||
expect(gates?.recommendedMobileAppVersions).toEqual({ android: '0.0.40' })
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restore()
|
||||
renderer?.unmount()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed while the same client reconnects', async () => {
|
||||
let resolveReconnect: ((response: unknown) => void) | null = null
|
||||
const pendingReconnect = new Promise((resolve) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ type LoadedHostStatusGates = HostStatusGates & {
|
||||
}
|
||||
|
||||
const EMPTY_HOST_CAPABILITIES: string[] = []
|
||||
const RECOMMENDATION_REFRESH_DELAY_MS = 6_000
|
||||
|
||||
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
|
||||
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
|
||||
@@ -37,23 +38,29 @@ export function useHostStatusGates(args: {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
let recommendationRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const requestClient = client
|
||||
void (async () => {
|
||||
const setFallbackGates = () => {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false,
|
||||
recommendedMobileAppVersions: null
|
||||
})
|
||||
}
|
||||
const loadStatus = async (recommendationRefresh: boolean): Promise<void> => {
|
||||
try {
|
||||
const response = await requestClient.sendRequest('status.get')
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false,
|
||||
recommendedMobileAppVersions: null
|
||||
})
|
||||
if (!recommendationRefresh) {
|
||||
setFallbackGates()
|
||||
}
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
@@ -63,6 +70,7 @@ export function useHostStatusGates(args: {
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
const recommendedMobileAppVersions = readRecommendedMobileAppVersions(status)
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
@@ -70,7 +78,7 @@ export function useHostStatusGates(args: {
|
||||
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
|
||||
compatVerdict: verdict,
|
||||
statusPending: false,
|
||||
recommendedMobileAppVersions: readRecommendedMobileAppVersions(status)
|
||||
recommendedMobileAppVersions
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
|
||||
@@ -81,23 +89,32 @@ export function useHostStatusGates(args: {
|
||||
requiredDesktopVersion: verdict.requiredDesktopVersion
|
||||
})
|
||||
}
|
||||
if (
|
||||
!recommendationRefresh &&
|
||||
verdict.kind !== 'blocked' &&
|
||||
!recommendedMobileAppVersions &&
|
||||
status.recommendedMobileAppVersionsPending === true
|
||||
) {
|
||||
// The desktop fetch has a 5s deadline; one delayed read observes its cache
|
||||
// without polling or adding work for older hosts.
|
||||
recommendationRefreshTimer = setTimeout(() => {
|
||||
recommendationRefreshTimer = null
|
||||
void loadStatus(true)
|
||||
}, RECOMMENDATION_REFRESH_DELAY_MS)
|
||||
}
|
||||
} catch {
|
||||
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
|
||||
if (!cancelled) {
|
||||
setLoaded({
|
||||
hostId,
|
||||
client: requestClient,
|
||||
hostCapabilities: [],
|
||||
floatingWorkspaceEnabled: false,
|
||||
compatVerdict: { kind: 'ok' },
|
||||
statusPending: false,
|
||||
recommendedMobileAppVersions: null
|
||||
})
|
||||
if (!cancelled && !recommendationRefresh) {
|
||||
setFallbackGates()
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
void loadStatus(false)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (recommendationRefreshTimer) {
|
||||
clearTimeout(recommendationRefreshTimer)
|
||||
}
|
||||
}
|
||||
}, [client, connState, hostId])
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export type DesktopStatus = {
|
||||
ios?: string
|
||||
android?: string
|
||||
}
|
||||
// Why: new hosts use this to request one post-refresh status update; old hosts omit it.
|
||||
recommendedMobileAppVersionsPending?: boolean
|
||||
}
|
||||
|
||||
export type RepoSummary = {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractLatestAndroidVersion } from './mobile-android-release-feed'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
__resetAndroidReleaseFeedCacheForTests,
|
||||
__setAndroidReleaseFeedFetcherForTests,
|
||||
extractLatestAndroidVersion,
|
||||
getRecommendedAndroidVersion,
|
||||
isAndroidReleaseFeedRefreshPending
|
||||
} from './mobile-android-release-feed'
|
||||
|
||||
afterEach(() => {
|
||||
__resetAndroidReleaseFeedCacheForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('extractLatestAndroidVersion', () => {
|
||||
it('picks the newest stable mobile-android tag', () => {
|
||||
@@ -14,13 +25,14 @@ describe('extractLatestAndroidVersion', () => {
|
||||
expect(version).toBe('0.0.31')
|
||||
})
|
||||
|
||||
it('skips drafts and prereleases', () => {
|
||||
it('accepts the published mobile channel while skipping drafts', () => {
|
||||
const version = extractLatestAndroidVersion([
|
||||
{ tag_name: 'mobile-android-v0.0.40', prerelease: true },
|
||||
{ tag_name: 'mobile-android-v0.0.41', draft: true },
|
||||
{ tag_name: 'mobile-android-v0.0.31' }
|
||||
])
|
||||
expect(version).toBe('0.0.31')
|
||||
// Android workflow releases use --prerelease to avoid replacing desktop latest.
|
||||
expect(version).toBe('0.0.40')
|
||||
})
|
||||
|
||||
it('rejects non-semver and malformed tags, failing open to null', () => {
|
||||
@@ -34,4 +46,25 @@ describe('extractLatestAndroidVersion', () => {
|
||||
).toBeNull()
|
||||
expect(extractLatestAndroidVersion([])).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates a cold refresh and exposes its completion without blocking callers', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([{ tag_name: 'mobile-android-v0.0.40', prerelease: true }]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
)
|
||||
__setAndroidReleaseFeedFetcherForTests(fetchMock)
|
||||
|
||||
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
|
||||
expect(getRecommendedAndroidVersion(1_000)).toBeNull()
|
||||
expect(isAndroidReleaseFeedRefreshPending()).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(isAndroidReleaseFeedRefreshPending()).toBe(false)
|
||||
})
|
||||
expect(getRecommendedAndroidVersion(1_000)).toBe('0.0.40')
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,8 +12,7 @@ const FETCH_TIMEOUT_MS = 5000
|
||||
// Why: a mobile build ships ~monthly, so a long TTL keeps this to a few
|
||||
// unauthenticated GitHub calls per day per host while staying fresh enough.
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
||||
// Why: only stable Android tags count as a recommendation; skip anything with a
|
||||
// prerelease suffix so an rc APK never nudges stable users.
|
||||
// Why: only exact Android release tags count, so suffixed RC builds never nudge stable users.
|
||||
const ANDROID_TAG_RE = /^mobile-android-v(\d+\.\d+\.\d+)$/
|
||||
|
||||
type ReleaseFeedEntry = {
|
||||
@@ -27,14 +26,26 @@ type CacheState = {
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
type ReleaseFeedResponse = Pick<Response, 'ok' | 'json'>
|
||||
type ReleaseFeedFetcher = () => Promise<ReleaseFeedResponse>
|
||||
|
||||
const defaultReleaseFeedFetcher: ReleaseFeedFetcher = () =>
|
||||
net.fetch(RELEASES_API_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
// Module-level cache shared across every status.get on this host.
|
||||
let cache: CacheState | null = null
|
||||
let inFlight: Promise<void> | null = null
|
||||
let releaseFeedFetcher = defaultReleaseFeedFetcher
|
||||
|
||||
export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string | null {
|
||||
let latest: string | null = null
|
||||
for (const entry of entries) {
|
||||
if (entry.draft === true || entry.prerelease === true) {
|
||||
// Mobile releases intentionally use GitHub's prerelease flag so they do not
|
||||
// replace the desktop's latest release; the strict tag defines this channel.
|
||||
if (entry.draft === true) {
|
||||
continue
|
||||
}
|
||||
if (typeof entry.tag_name !== 'string') {
|
||||
@@ -54,10 +65,7 @@ export function extractLatestAndroidVersion(entries: ReleaseFeedEntry[]): string
|
||||
|
||||
async function fetchLatestAndroidVersion(): Promise<string | null> {
|
||||
try {
|
||||
const response = await net.fetch(RELEASES_API_URL, {
|
||||
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'orca-runtime' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
})
|
||||
const response = await releaseFeedFetcher()
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
@@ -103,10 +111,15 @@ export function getRecommendedAndroidVersion(nowMs: number): string | null {
|
||||
return cache?.version ?? null
|
||||
}
|
||||
|
||||
export function isAndroidReleaseFeedRefreshPending(): boolean {
|
||||
return inFlight !== null
|
||||
}
|
||||
|
||||
// Test-only: reset module cache between cases.
|
||||
export function __resetAndroidReleaseFeedCacheForTests(): void {
|
||||
cache = null
|
||||
inFlight = null
|
||||
releaseFeedFetcher = defaultReleaseFeedFetcher
|
||||
}
|
||||
|
||||
// Test-only: seed the cache with a fresh value so status.get reads it without
|
||||
@@ -115,3 +128,7 @@ export function __setAndroidReleaseFeedCacheForTests(version: string | null): vo
|
||||
cache = { version, fetchedAt: Date.now() }
|
||||
inFlight = null
|
||||
}
|
||||
|
||||
export function __setAndroidReleaseFeedFetcherForTests(fetcher: ReleaseFeedFetcher): void {
|
||||
releaseFeedFetcher = fetcher
|
||||
}
|
||||
|
||||
@@ -1724,20 +1724,22 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('omits the mobile recommendation until the release feed resolves, then reports Android', () => {
|
||||
__resetAndroidReleaseFeedCacheForTests()
|
||||
// Why: the feed is fetched lazily and fail-open, so a cold host advertises
|
||||
// nothing (no banner) rather than a fabricated version.
|
||||
expect(createRuntime().getStatus().recommendedMobileAppVersions).toBeUndefined()
|
||||
it('omits an unknown mobile recommendation, then reports cached Android', () => {
|
||||
__setAndroidReleaseFeedCacheForTests(null)
|
||||
try {
|
||||
expect(createRuntime().getStatus().recommendedMobileAppVersions).toBeUndefined()
|
||||
|
||||
// Once the feed has a value, status.get carries only Android — iOS defers to
|
||||
// the App Store, so it is never populated.
|
||||
__setAndroidReleaseFeedCacheForTests('0.0.42')
|
||||
const status = createRuntime().getStatus()
|
||||
expect(status.recommendedMobileAppVersions).toEqual({ android: '0.0.42' })
|
||||
expect(status.recommendedMobileAppVersions?.ios).toBeUndefined()
|
||||
expect(status.recommendedMobileAppVersions?.android).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
__resetAndroidReleaseFeedCacheForTests()
|
||||
// Once the feed has a value, status.get carries only Android — iOS defers to
|
||||
// the App Store, so it is never populated.
|
||||
__setAndroidReleaseFeedCacheForTests('0.0.42')
|
||||
const status = createRuntime().getStatus()
|
||||
expect(status.recommendedMobileAppVersions).toEqual({ android: '0.0.42' })
|
||||
expect(status.recommendedMobileAppVersionsPending).toBeUndefined()
|
||||
expect(status.recommendedMobileAppVersions?.ios).toBeUndefined()
|
||||
expect(status.recommendedMobileAppVersions?.android).toMatch(/^\d+\.\d+\.\d+$/)
|
||||
} finally {
|
||||
__resetAndroidReleaseFeedCacheForTests()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the configured Windows terminal shell on status', () => {
|
||||
|
||||
@@ -449,7 +449,10 @@ import {
|
||||
type ClaudeAgentTeamsMode
|
||||
} from '../../shared/claude-agent-teams-tmux-compat'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { getRecommendedAndroidVersion } from './mobile-android-release-feed'
|
||||
import {
|
||||
getRecommendedAndroidVersion,
|
||||
isAndroidReleaseFeedRefreshPending
|
||||
} from './mobile-android-release-feed'
|
||||
import { collectMemorySnapshot } from '../memory/collector'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
@@ -3373,6 +3376,7 @@ export class OrcaRuntimeService {
|
||||
if (canBrowse) {
|
||||
capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY)
|
||||
}
|
||||
const mobileRecommendation = buildMobileAppRecommendationStatus()
|
||||
return {
|
||||
runtimeId: this.runtimeId,
|
||||
rendererGraphEpoch: this.rendererGraphEpoch,
|
||||
@@ -3395,7 +3399,8 @@ export class OrcaRuntimeService {
|
||||
// Why: derived live from GitHub Releases (cached, fail-open) instead of a
|
||||
// hand-bumped constant, so the Android nudge can never go stale; omitted
|
||||
// entirely when unknown. iOS defers to the App Store (no soft nudge).
|
||||
recommendedMobileAppVersions: buildRecommendedMobileAppVersions(),
|
||||
recommendedMobileAppVersions: mobileRecommendation.versions,
|
||||
recommendedMobileAppVersionsPending: mobileRecommendation.pending,
|
||||
protocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION
|
||||
}
|
||||
@@ -31472,7 +31477,13 @@ function compareWorktreePs(
|
||||
// Why: only Android has a soft nudge (APK from GitHub Releases); iOS defers to
|
||||
// App Store auto-update. Returns undefined when the latest Android version is
|
||||
// unknown so status.get omits the field and mobile shows no banner.
|
||||
function buildRecommendedMobileAppVersions(): { android: string } | undefined {
|
||||
function buildMobileAppRecommendationStatus(): {
|
||||
versions: { android: string } | undefined
|
||||
pending: true | undefined
|
||||
} {
|
||||
const android = getRecommendedAndroidVersion(Date.now())
|
||||
return android ? { android } : undefined
|
||||
return {
|
||||
versions: android ? { android } : undefined,
|
||||
pending: !android && isAndroidReleaseFeedRefreshPending() ? true : undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export type RuntimeStatus = {
|
||||
// Why: per-platform release trains can diverge; absent on older hosts and
|
||||
// mobile fails open (no update nudge).
|
||||
recommendedMobileAppVersions?: RecommendedMobileAppVersions
|
||||
// Why: lets mobile re-read once after a cold background refresh without polling old hosts.
|
||||
recommendedMobileAppVersionsPending?: boolean
|
||||
// COMPAT(runtimeStatusMobileAliases): added 2026-05-15 for mobile builds
|
||||
// that still read these names; new desktop/CLI code uses the fields above.
|
||||
protocolVersion?: number
|
||||
|
||||
Reference in New Issue
Block a user