fix(orcad): drive Android emulators from headless orca serve hosts (#33701)

* fix(orcad): drive Android emulators from headless orca serve hosts

orcad never installed an EmulatorBridge, so every `orca emulator` verb
failed with emulator_no_active once headless serve moved to orcad. The
emulator modules now read host paths and HTTP through the host ports
instead of importing Electron, and orcad installs the bridge and stops its
managed sessions on shutdown like the desktop does.

Fixes #33650

* fix(orcad): bound emulator teardown inside the shutdown deadline

* fix(orcad): keep idle exit from stopping Orca-started emulators; update emulator skill scope

* fix(orcad): count only still-running Orca-started emulators as idle-exit work

* fix(emulator): prune managed sessions only on a successful adb census

* fix(emulator): release a pruned device's stream and never prune a record reattached mid-census
This commit is contained in:
Neil
2026-10-25 17:28:06 -07:00
committed by GitHub
parent 79a63f3d9d
commit 8ee2f09089
30 changed files with 211 additions and 64 deletions
+3 -3
View File
@@ -29,9 +29,9 @@ device with `emulator_unsupported`. `tap`, `type`, `gesture`, `button`, `rotate`
`exec` work on both backends, with backend-specific output for `ax` — a `uiautomator` node
tree on Android, a serve-sim node tree on iOS.
Camera and sensor injection are not wrapped; Android virtual-scene is out of scope. Device
control is local to the host that owns the SDK, so remote and SSH device control is out of
scope.
Camera and sensor injection are not wrapped; Android virtual-scene is out of scope. Commands
drive the SDK and devices on the host that runs the Orca runtime, including a headless
`orca serve` or SSH host.
## Prerequisites
+2 -2
View File
@@ -24,8 +24,8 @@ unvalidated with the active device injected.
device with `emulator_unsupported`. `tap`, `type`, `gesture`, `button`, `rotate`, `ax`, and
`exec` work on both backends.
Emulator control is local to the Mac that owns the simulator; remote and SSH worktrees are
out of scope.
Commands drive devices on the host that runs the Orca runtime. Simulators need that host to
be a Mac, so a Linux `orca serve` or SSH host can drive only Android devices.
## Prerequisites
File diff suppressed because one or more lines are too long
@@ -4,6 +4,7 @@ import type { AndroidSdkPaths } from './android-sdk-discovery'
import {
findRunningAvdSerial,
listAndroidDevices,
listConfirmedAdbSerials,
mergeAndroidDevices
} from './android-device-inventory'
import { parseAdbDevices } from './adb-devices'
@@ -79,6 +80,19 @@ describe('listAndroidDevices', () => {
})
})
describe('listConfirmedAdbSerials', () => {
it('lists every attached serial, and null when adb itself fails', async () => {
const answering: AndroidCommandRunner = async () =>
ok('List of devices attached\nemulator-5554\tdevice\nemulator-5556\toffline')
expect(await listConfirmedAdbSerials(answering, SDK)).toEqual([
'emulator-5554',
'emulator-5556'
])
const failing: AndroidCommandRunner = async () => ({ stdout: '', stderr: 'boom', code: 1 })
expect(await listConfirmedAdbSerials(failing, SDK)).toBeNull()
})
})
describe('findRunningAvdSerial', () => {
it('returns the serial whose AVD name matches', async () => {
const fake = runner((binary, a) =>
@@ -12,6 +12,15 @@ export async function listRunningAdbDevices(
return parseAdbDevices(result.stdout).filter((device) => device.state === 'device')
}
/** Null when adb fails, so a failed census never reads as every device gone. */
export async function listConfirmedAdbSerials(
runner: AndroidCommandRunner,
sdk: AndroidSdkPaths
): Promise<string[] | null> {
const result = await runner(sdk.adb, adbDevicesArgs)
return result.code === 0 ? parseAdbDevices(result.stdout).map((device) => device.serial) : null
}
export async function resolveRunningAvdNames(
runner: AndroidCommandRunner,
sdk: AndroidSdkPaths,
@@ -1,9 +1,9 @@
import { app } from 'electron'
import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { get } from 'node:https'
import { pipeline } from 'node:stream/promises'
import type { IncomingMessage } from 'node:http'
import { getAppEnvironment } from '../../../shared/app-environment'
import { EmulatorError } from '../emulator-errors'
import { emulatorProbe, emulatorProbeError } from '../emulator-probe'
import { SCRCPY_SERVER_VERSION } from './scrcpy-server-deploy'
@@ -13,7 +13,11 @@ const DOWNLOAD_URL = `https://github.com/Genymobile/scrcpy/releases/download/v${
const MIN_VALID_BYTES = 10_000
export function scrcpyServerJarPath(): string {
return join(app.getPath('userData'), 'scrcpy', `scrcpy-server-v${SCRCPY_SERVER_VERSION}.jar`)
return join(
getAppEnvironment().getPath('userData'),
'scrcpy',
`scrcpy-server-v${SCRCPY_SERVER_VERSION}.jar`
)
}
export function isScrcpyServerJarReady(): boolean {
@@ -27,6 +27,7 @@ import { ensureAdbOk } from '../android/android-adb-result'
import {
findRunningAvdSerial,
listAndroidDevices,
listConfirmedAdbSerials,
listRunningAdbDevices
} from '../android/android-device-inventory'
import {
@@ -139,6 +140,11 @@ export class AndroidEmulatorBackend implements EmulatorBackend {
return sdk ? listAndroidDevices(this.runner, sdk) : []
}
async listRunningDeviceIds(): Promise<string[] | null> {
const sdk = this.sdkState.resolve()
return sdk ? listConfirmedAdbSerials(this.runner, sdk) : null
}
async ownsDevice(id: string): Promise<boolean> {
if (!this.sdkState.resolve()) {
return false
@@ -47,6 +47,8 @@ export type EmulatorBackend = {
isSupportedOnHost(): boolean
checkAvailability(): Promise<BackendAvailability>
listDevices(): Promise<EmulatorDevice[]>
/** Ids of devices confirmed running now; null when the host could not answer. */
listRunningDeviceIds?(): Promise<string[] | null>
ownsDevice(id: string): Promise<boolean>
resolveDeviceId(deviceOrName: string): Promise<string>
@@ -27,7 +27,9 @@ const {
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
vi.mock('../../network/http-client', () => ({
getMainHttpClient: () => ({ fetch: netFetchMock })
}))
vi.mock('../serve-sim-execution', async (importOriginal) => ({
execServeSimCommand: execServeSimCommandMock,
+1 -1
View File
@@ -22,7 +22,7 @@ const {
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
vi.mock('../network/http-client', () => ({ getMainHttpClient: () => ({ fetch: netFetchMock }) }))
vi.mock('./serve-sim-execution', async (importOriginal) => ({
execServeSimCommand: execServeSimCommandMock,
+4 -4
View File
@@ -254,6 +254,10 @@ export class EmulatorBridge {
return udid
}
hasLiveManagedSessions(): Promise<boolean> {
return this.sessionRegistry.pruneGoneManagedSessions((kind) => this.backendForKind(kind))
}
async destroyAllSessions(): Promise<void> {
const promises: Promise<unknown>[] = []
for (const session of this.sessionRegistry.listSessions()) {
@@ -275,10 +279,6 @@ export class EmulatorBridge {
this.sessionRegistry.clear()
}
async onAppQuit(): Promise<void> {
await this.destroyAllSessions()
}
private async resolveTarget(
opts?: EmulatorTargetOpts
): Promise<{ backend: EmulatorBackend; device: string }> {
@@ -33,3 +33,52 @@ describe('EmulatorSessionRegistry backend tagging', () => {
expect(info?.streamCodec).toBe('h264')
})
})
describe('EmulatorSessionRegistry managed-session liveness', () => {
function registryWith(...serials: string[]): EmulatorSessionRegistry {
const registry = new EmulatorSessionRegistry()
serials.forEach((serial, index) =>
registry.registerActive(
`wt${index}`,
{ deviceUdid: serial, wsUrl: '', streamUrl: '' },
{ managed: true, backend: 'android' }
)
)
return registry
}
const stopped: string[] = []
const census = (running: string[] | null) => () => ({
listRunningDeviceIds: async () => running,
stopHelperForDevice: async (deviceId: string) => {
stopped.push(deviceId)
}
})
it('drops a managed device that stopped outside Orca and keeps the running one', async () => {
const registry = registryWith('emulator-5554', 'emulator-5556')
expect(await registry.pruneGoneManagedSessions(census(['emulator-5554']))).toBe(true)
expect(registry.getActiveForWorktree('wt0')?.deviceUdid).toBe('emulator-5554')
expect(registry.getSession('emulator-5556')).toBeUndefined()
expect(registry.getActiveForWorktree('wt1')).toBeNull()
expect(stopped).toContain('emulator-5556')
})
it('reads no managed devices once every one is gone', async () => {
const registry = registryWith('emulator-5554')
expect(await registry.pruneGoneManagedSessions(census([]))).toBe(false)
expect(registry.listSessions()).toEqual([])
})
it('keeps the records when adb could not answer', async () => {
const registry = registryWith('emulator-5554')
expect(await registry.pruneGoneManagedSessions(census(null))).toBe(true)
expect(registry.getSession('emulator-5554')).toBeDefined()
})
it('keeps the records of a backend with no census', async () => {
const registry = registryWith('emulator-5554')
const noCensus = () => ({ stopHelperForDevice: async () => {} })
expect(await registry.pruneGoneManagedSessions(noCensus)).toBe(true)
expect(registry.getSession('emulator-5554')).toBeDefined()
})
})
@@ -1,5 +1,9 @@
import type { EmulatorBackendKind, EmulatorSessionInfo } from './emulator-types'
import type { EmulatorSessionState } from './emulator-bridge-types'
import type { EmulatorBackend } from './backends/emulator-backend'
import { withTimeout } from '../../shared/promise-timeout-fallback'
const DEVICE_LISTING_BUDGET_MS = 5_000
export class EmulatorSessionRegistry {
private readonly activeByWorktree = new Map<string, string>()
@@ -71,6 +75,33 @@ export class EmulatorSessionRegistry {
}
}
/** Drop managed records whose device stopped outside Orca; true while any managed device still runs. */
async pruneGoneManagedSessions(
backendForKind: (
kind: EmulatorBackendKind
) => Pick<EmulatorBackend, 'listRunningDeviceIds' | 'stopHelperForDevice'> | null
): Promise<boolean> {
const managed = this.listSessions().filter((session) => session.managed)
let live = false
for (const kind of new Set(managed.map((session) => session.backend))) {
const backend = backendForKind(kind)
const census = backend?.listRunningDeviceIds?.()
// A backend without a census, or one that failed or timed out, proves nothing; keep its records.
const running = census ? await withTimeout(census, DEVICE_LISTING_BUDGET_MS, null) : null
for (const session of managed.filter((candidate) => candidate.backend === kind)) {
// A reattach during the census replaced this record; the census predates it.
const replaced = this.sessions.get(session.deviceUdid) !== session
if (running === null || replaced || running.includes(session.deviceUdid)) {
live = live || this.sessions.has(session.deviceUdid)
} else {
this.clearSessionAndWorktrees(session.deviceUdid)
void backend?.stopHelperForDevice(session.deviceUdid).catch(() => {})
}
}
}
return live
}
clear(): void {
this.sessions.clear()
this.activeByWorktree.clear()
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() }))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
vi.mock('../network/http-client', () => ({ getMainHttpClient: () => ({ fetch: netFetchMock }) }))
import { requestServeSimAccessibilityTree } from './serve-sim-accessibility-tree'
@@ -1,4 +1,4 @@
import { net } from 'electron'
import { getMainHttpClient } from '../network/http-client'
import { EmulatorError } from './emulator-errors'
import { normalizeServeSimAxTree, type NormalizedAxNode } from './serve-sim-ax-normalization'
@@ -7,7 +7,7 @@ const MAX_ERROR_BODY_LENGTH = 512
export async function requestServeSimAccessibilityTree(axUrl: string): Promise<NormalizedAxNode[]> {
try {
const response = await net.fetch(axUrl, {
const response = await getMainHttpClient().fetch(axUrl, {
signal: AbortSignal.timeout(AX_REQUEST_TIMEOUT_MS)
})
const body = await response.text()
+9 -13
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const electronMocks = vi.hoisted(() => ({
const appEnvironmentMocks = vi.hoisted(() => ({
getAppPath: vi.fn(),
getPath: vi.fn(),
getVersion: vi.fn()
@@ -12,12 +12,8 @@ const materializerMocks = vi.hoisted(() => ({
materializeServeSimRuntime: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getAppPath: electronMocks.getAppPath,
getPath: electronMocks.getPath,
getVersion: electronMocks.getVersion
}
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => appEnvironmentMocks
}))
vi.mock('./serve-sim-runtime-materializer', () => materializerMocks)
@@ -54,9 +50,9 @@ beforeEach(async () => {
cleanupPaths.push(root)
setProcessProperty('platform', 'linux')
setProcessProperty('resourcesPath', join(root, 'resources'))
electronMocks.getAppPath.mockReset().mockReturnValue(join(root, 'app'))
electronMocks.getPath.mockReset().mockReturnValue(join(root, 'user-data'))
electronMocks.getVersion.mockReset().mockReturnValue('test-version')
appEnvironmentMocks.getAppPath.mockReset().mockReturnValue(join(root, 'app'))
appEnvironmentMocks.getPath.mockReset().mockReturnValue(join(root, 'user-data'))
appEnvironmentMocks.getVersion.mockReset().mockReturnValue('test-version')
materializerMocks.materializeServeSimRuntime.mockReset()
})
@@ -79,7 +75,7 @@ describe('resolveServeSimExecutable', () => {
baseArgs: [legacyEntry],
usesElectronAsNode: true
})
expect(electronMocks.getAppPath).not.toHaveBeenCalled()
expect(appEnvironmentMocks.getAppPath).not.toHaveBeenCalled()
})
it('uses the deduplicated Resources/node_modules/serve-sim package', async () => {
@@ -92,7 +88,7 @@ describe('resolveServeSimExecutable', () => {
baseArgs: [packagedEntry],
usesElectronAsNode: true
})
expect(electronMocks.getAppPath).not.toHaveBeenCalled()
expect(appEnvironmentMocks.getAppPath).not.toHaveBeenCalled()
})
it('materializes either packaged resource location on macOS', async () => {
@@ -113,7 +109,7 @@ describe('resolveServeSimExecutable', () => {
})
it('preserves the app node_modules development fallback', async () => {
const appPackageDir = join(electronMocks.getAppPath(), 'node_modules', 'serve-sim')
const appPackageDir = join(appEnvironmentMocks.getAppPath(), 'node_modules', 'serve-sim')
const appEntry = await createServeSimPackage(appPackageDir)
expect(resolveServeSimExecutable()).toEqual({
+6 -6
View File
@@ -1,8 +1,8 @@
import { runProcess } from '@orca/process-host'
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { app } from 'electron'
import { platform, tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { EmulatorError } from './emulator-errors'
import { materializeServeSimRuntime } from './serve-sim-runtime-materializer'
@@ -68,8 +68,8 @@ function resolveMaterializedServeSimPackageDir(bundledPackageDir: string): strin
}
materializedServeSimPackageDir = materializeServeSimRuntime({
bundledPackageDir,
targetRootDir: join(app.getPath('userData'), 'serve-sim-runtime'),
version: app.getVersion()
targetRootDir: join(getAppEnvironment().getPath('userData'), 'serve-sim-runtime'),
version: getAppEnvironment().getVersion()
})
if (materializedServeSimPackageDir === null) {
console.warn(
@@ -84,8 +84,8 @@ export function resolveServeSimExecutable(): ServeSimExecutable {
const bundledResourcesPath =
process.resourcesPath ??
(process.platform === 'darwin'
? join(app.getPath('exe'), '..', '..', 'Resources')
: join(app.getPath('exe'), '..', 'resources'))
? join(getAppEnvironment().getPath('exe'), '..', '..', 'Resources')
: join(getAppEnvironment().getPath('exe'), '..', 'resources'))
// Prefer the legacy Resources/serve-sim path for already-installed bundles.
for (const bundledPackageDir of [
join(bundledResourcesPath, 'serve-sim'),
@@ -109,7 +109,7 @@ export function resolveServeSimExecutable(): ServeSimExecutable {
return { command: process.execPath, baseArgs: [bundledEntry], usesElectronAsNode: true }
}
const nodeModulesPackageDir = join(app.getAppPath(), 'node_modules', 'serve-sim')
const nodeModulesPackageDir = join(getAppEnvironment().getAppPath(), 'node_modules', 'serve-sim')
const nodeModulesEntry = join(nodeModulesPackageDir, 'dist', 'serve-sim.js')
if (existsSync(nodeModulesEntry)) {
return { command: process.execPath, baseArgs: [nodeModulesEntry], usesElectronAsNode: true }
@@ -5,8 +5,6 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ app: {} }))
import { serveSimKillArgs } from './serve-sim-execution'
const SERVE_SIM_ENTRY = join(
@@ -134,6 +134,7 @@ vi.mock('../runtime/orca-runtime', () => ({
attachAgentStatusStore() {
return () => {}
}
setEmulatorBridge() {}
getRuntimeId() {
return 'repair-runtime'
}
+20
View File
@@ -0,0 +1,20 @@
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime'
import { EmulatorBridge } from '../emulator/emulator-bridge'
import { withTimeout } from '../../shared/promise-timeout-fallback'
// A stalled adb must not hold the final profile flush past orcad's shutdown deadline.
const EMULATOR_TEARDOWN_BUDGET_MS = 3_000
/** Android drives adb/emulator on any host; iOS backends report their own host-support errors. */
export function installOrcadEmulatorBridge(
runtime: OrcaRuntimeService,
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
): void {
const bridge = new EmulatorBridge()
runtime.setEmulatorBridge(bridge)
// Like desktop quit: stop the helpers and devices Orca started.
registerCleanup(() =>
withTimeout(bridge.destroyAllSessions(), EMULATOR_TEARDOWN_BUDGET_MS, undefined)
)
}
+6 -18
View File
@@ -1,14 +1,10 @@
// Compose the Node-host runtime; paired desktop pages work even without a local browser engine.
import { join } from 'node:path'
import process from 'node:process'
import { getAppEnvironment } from '../../shared/app-environment'
import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths'
import { prepareOrcadCliLauncher } from './orcad-cli-launcher'
import {
installOrcadHostAdapters,
prepareOrcadSecretStore,
runOrcadQuitHandlers
} from './orcad-host-adapters'
import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-adapters'
import { prepareOrcadSecretStoreAndMigrate } from './orcad-secret-store-startup'
import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address'
import {
flushOrcadProfileStoreForShutdown,
@@ -63,18 +59,6 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop }
}
async function prepareOrcadSecretStoreAndMigrate(userDataPath: string): Promise<void> {
const { getSecretStore } = await import('../../shared/secret-store')
const { reportSecretProtectionGap } = await import('../host/secret-protection-report')
const { migratePlaintextCredentials, orcadCredentialRoot } =
await import('./orcad-plaintext-credential-migration')
// Keep sealing keys outside disposable ORCA_USER_DATA roots.
if (await prepareOrcadSecretStore(orcadCredentialRoot(), userDataPath)) {
migratePlaintextCredentials(getSecretStore())
}
reportSecretProtectionGap({ dataFile: join(userDataPath, 'orca-data.json') })
}
async function startOrcadRuntime(
options: OrcadOptions,
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
@@ -222,6 +206,8 @@ async function startOrcadRuntime(
paneGridsForShutdown = runtime
accountLaunch?.attachToRuntime(runtime)
const { installOrcadEmulatorBridge } = await import('./orcad-emulator-bridge')
installOrcadEmulatorBridge(runtime, registerCleanup)
// Restore prior desktop-pushed account selection before automations or CLI launches.
const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store')
@@ -335,6 +321,8 @@ async function startOrcadRuntime(
agentStates: () => agentHookServer.getStatusSnapshot(),
hasStagedMigration: () => profileStore.hasStagedOrcadMigrationCatalog(),
automationsBusy: () => orcadAutomationsKeepHostBusy(profileStore),
emulatorsBusy: async () =>
(await runtime.getEmulatorBridge()?.hasLiveManagedSessions()) ?? false,
registerCleanup
})
return { readiness }
@@ -44,6 +44,7 @@ function idleMonitor() {
agentStates: () => [{ state: 'blocked' }],
hasStagedMigration: () => false,
automationsBusy: () => false,
emulatorsBusy: async () => false,
activationFenceExists: async () => false
}
)
@@ -32,6 +32,7 @@ type OrcadIdleExitRuntimePorts = {
agentStates: () => readonly { state: string }[]
hasStagedMigration: () => boolean
automationsBusy: () => boolean
emulatorsBusy: () => Promise<boolean>
/** Stop the monitor before recording signal-driven shutdown. */
registerCleanup: (cleanup: () => void) => void
}
@@ -80,6 +81,7 @@ async function startOrcadManagedIdleExit(
hasChatProviders,
hasStagedMigration: input.hasStagedMigration,
automationsBusy: input.automationsBusy,
emulatorsBusy: input.emulatorsBusy,
activationFenceExists
}
// Client/terminal evidence is already checked; recheck other work exit would destroy.
@@ -63,6 +63,7 @@ it.each(['provider', 'client', 'request', 'recent request'])(
agentStates: () => [],
hasStagedMigration: () => false,
automationsBusy: () => false,
emulatorsBusy: async () => false,
registerCleanup: () => {}
})
const installed = vi.mocked(installOrcadManagedIdleExit).mock.calls.at(-1)?.[0]
@@ -73,6 +73,7 @@ async function stopWithReturningClient(
agentStates: () => [],
hasStagedMigration: () => false,
automationsBusy: () => automationsBusy,
emulatorsBusy: async () => false,
registerCleanup: () => {}
})
const installed = vi.mocked(installOrcadManagedIdleExit).mock.calls.at(-1)?.[0]
@@ -26,6 +26,7 @@ function idlePorts(): OrcadManagedIdleExitPorts {
hasChatProviders: () => false,
hasStagedMigration: () => false,
automationsBusy: () => false,
emulatorsBusy: async () => false,
activationFenceExists: async () => false
}
}
@@ -77,6 +78,7 @@ describe('createOrcadIdleProbes', () => {
'chat-providers': 'idle',
migration: 'idle',
automations: 'idle',
emulators: 'idle',
activation: 'idle'
})
})
@@ -102,6 +104,7 @@ describe('createOrcadIdleProbes', () => {
['a loaded chat provider', { hasChatProviders: () => true }, 'chat-providers'],
['a staged migration', { hasStagedMigration: () => true }, 'migration'],
['an enabled or running automation', { automationsBusy: () => true }, 'automations'],
['an Orca-started emulator', { emulatorsBusy: async () => true }, 'emulators'],
['a held activation fence', { activationFenceExists: async () => true }, 'activation']
])('reads %s as busy', async (_label, override, probe) => {
expect((await verdicts({ ...idlePorts(), ...override }))[probe]).toBe('busy')
@@ -40,6 +40,8 @@ export type OrcadManagedIdleExitPorts = {
hasStagedMigration: () => boolean
/** Enabled schedules or unsettled runs would be lost on exit. */
automationsBusy: () => boolean
/** Exit would shut down the emulators Orca started for a workspace. */
emulatorsBusy: () => Promise<boolean>
activationFenceExists: (root: string) => Promise<boolean>
}
@@ -77,6 +79,7 @@ export function createOrcadIdleProbes(
{ name: 'chat-providers', read: () => verdict(ports.hasChatProviders()) },
{ name: 'migration', read: () => verdict(ports.hasStagedMigration()) },
{ name: 'automations', read: () => verdict(ports.automationsBusy()) },
{ name: 'emulators', read: async () => verdict(await ports.emulatorsBusy()) },
{
name: 'activation',
read: async () => verdict(await ports.activationFenceExists(config.activationRoot))
@@ -133,6 +133,7 @@ vi.mock('../runtime/orca-runtime', () => ({
attachAgentStatusStore() {
return () => {}
}
setEmulatorBridge() {}
getRuntimeId() {
return 'headless-runtime'
}
@@ -0,0 +1,14 @@
import { join } from 'node:path'
import { prepareOrcadSecretStore } from './orcad-host-adapters'
export async function prepareOrcadSecretStoreAndMigrate(userDataPath: string): Promise<void> {
const { getSecretStore } = await import('../../shared/secret-store')
const { reportSecretProtectionGap } = await import('../host/secret-protection-report')
const { migratePlaintextCredentials, orcadCredentialRoot } =
await import('./orcad-plaintext-credential-migration')
// Keep sealing keys outside disposable ORCA_USER_DATA roots.
if (await prepareOrcadSecretStore(orcadCredentialRoot(), userDataPath)) {
migratePlaintextCredentials(getSecretStore())
}
reportSecretProtectionGap({ dataFile: join(userDataPath, 'orca-data.json') })
}
@@ -40,11 +40,6 @@ const DECLARED_HOST_DIFFERENCES: readonly DeclaredDifference[] = [
missingOn: 'orcad',
reason: 'orcad installs browser automation through the runtime factory when Chromium exists.'
},
{
wiring: 'runtime.setEmulatorBridge',
missingOn: 'orcad',
reason: 'Device emulators are desktop-only.'
},
{
wiring: 'runtime.setOffscreenBrowserBackend',
missingOn: 'orcad',
@@ -175,7 +170,11 @@ const ROOT_SOURCES: Record<Root, readonly string[]> = {
'src/main/startup/main-process-runtime-launch.ts',
'src/main/window/attach-main-window-services.ts'
],
orcad: ['src/main/orcad/orcad-entry.ts', 'src/main/orcad/orcad-account-services.ts']
orcad: [
'src/main/orcad/orcad-entry.ts',
'src/main/orcad/orcad-account-services.ts',
'src/main/orcad/orcad-emulator-bridge.ts'
]
}
const HOST_PORT_SOURCES: Record<Root, readonly string[]> = {
@@ -428,7 +427,9 @@ describe('runtime composition parity (Electron vs orcad)', () => {
'host:setSecretStore',
'host:setRuntimeBrowserCommandsFactory',
// #31174: without it every speech call refuses with speech_unavailable.
'host:setSpeechServiceFactories'
'host:setSpeechServiceFactories',
// #33650: Android emulators run on headless Linux hosts too.
'runtime.setEmulatorBridge'
]) {
expect(electron, shared).toContain(shared)
expect(orcad, shared).toContain(shared)