mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
fix(runtime): preserve status evidence and address review gaps
This commit is contained in:
@@ -142,8 +142,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> => {
|
||||
const environment = resolveEnvironment(getUserDataPath(), args.selector)
|
||||
clearRuntimeEnvironmentManualDisconnect(environment.id)
|
||||
return getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id).refresh({
|
||||
timeoutMs: args.timeoutMs,
|
||||
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, {
|
||||
reconnect: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -59,6 +60,7 @@ describe('federated read RPC transport routing', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -39,10 +39,13 @@ it('publishes real same-socket verification after every authenticated reconnect'
|
||||
})
|
||||
await getRuntimeEnvironmentStatus(profile, environment.id)
|
||||
const owner = getRuntimeEnvironmentStatusOwner(profile, environment.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
})
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(owner.read()).toMatchObject({ transport: 'ready', verification: 'verified' })
|
||||
expect(server.requests).toHaveLength(2)
|
||||
},
|
||||
{ timeout: 3_000 }
|
||||
)
|
||||
expect(server.connectionCount()).toBe(2) // Bootstrap plus persistent control.
|
||||
runtimeId = 'host-after'
|
||||
server.closeClients()
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function getRuntimeEnvironmentStatus(
|
||||
userDataPath: string,
|
||||
selector: string,
|
||||
timeoutMs?: number,
|
||||
options?: { observeOnly?: true; signal?: AbortSignal }
|
||||
options?: { observeOnly?: true; signal?: AbortSignal; reconnect?: true }
|
||||
): Promise<RuntimeRpcResponse<RuntimeStatus>> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -118,6 +119,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -111,6 +112,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -131,6 +132,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -120,6 +121,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@@ -325,36 +327,41 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns shared-control diagnostics when saved remote runtime status throws', async () => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 2,
|
||||
lastConnectedAt: 123,
|
||||
lastClose: { code: 1006, reason: '' },
|
||||
lastError: 'closed'
|
||||
})
|
||||
sendRemoteRuntimeRequestMock.mockRejectedValue(new Error('socket closed'))
|
||||
it.each(['runtimeEnvironments:getStatus', 'runtimeEnvironments:connect'])(
|
||||
'preserves failure diagnostics and guidance on %s',
|
||||
async (channel) => {
|
||||
registerRuntimeEnvironmentHandlers(store as never)
|
||||
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue({
|
||||
state: 'reconnecting',
|
||||
pendingRequestCount: 0,
|
||||
subscriptionCount: 1,
|
||||
reconnectAttempt: 2,
|
||||
lastConnectedAt: 123,
|
||||
lastClose: { code: 1006, reason: '' },
|
||||
lastError: 'closed'
|
||||
})
|
||||
sendRemoteRuntimeRequestMock.mockRejectedValue(
|
||||
new Error('Could not connect to the remote Orca runtime.')
|
||||
)
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const getStatus = handler<
|
||||
{ selector: string; timeoutMs?: number },
|
||||
{ ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } }
|
||||
>('runtimeEnvironments:getStatus')
|
||||
const getStatus = handler<
|
||||
{ selector: string; timeoutMs?: number },
|
||||
{ ok: false; error: { message: string; data?: { remoteControl?: { state: string } } } }
|
||||
>(channel)
|
||||
|
||||
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
message: 'socket closed',
|
||||
data: { remoteControl: { state: 'reconnecting' } }
|
||||
}
|
||||
})
|
||||
})
|
||||
await expect(getStatus(null, { selector: 'desk' })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
message: expect.stringContaining('connect both devices to Tailscale'),
|
||||
data: { remoteControl: { state: 'reconnecting' } }
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -115,6 +116,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -114,6 +115,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetRuntimeEnvironmentStatusOwners } from './runtime-environment-request-connections'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -121,6 +122,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetRuntimeEnvironmentStatusOwners()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -16,14 +16,18 @@ export async function refreshRuntimeEnvironmentStatus(
|
||||
timeoutMs
|
||||
})
|
||||
if (window.api.runtimeEnvironments.getStatusSnapshots) {
|
||||
const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots()
|
||||
const snapshot = snapshots.find((entry) => entry.environmentId === environmentId)
|
||||
if (snapshot) {
|
||||
publish({
|
||||
snapshot,
|
||||
status: snapshot.verification === 'verified' ? snapshot.status : null,
|
||||
checkedAt: snapshot.checkedAt
|
||||
})
|
||||
try {
|
||||
const snapshots = await window.api.runtimeEnvironments.getStatusSnapshots()
|
||||
const snapshot = snapshots.find((entry) => entry.environmentId === environmentId)
|
||||
if (snapshot) {
|
||||
publish({
|
||||
snapshot,
|
||||
status: snapshot.verification === 'verified' ? snapshot.status : null,
|
||||
checkedAt: snapshot.checkedAt
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to read runtime host status snapshot:', error)
|
||||
}
|
||||
return response.ok
|
||||
}
|
||||
|
||||
@@ -723,6 +723,26 @@ describe('runtime-status slice', () => {
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.get('env-a')?.status).toBe(null)
|
||||
})
|
||||
|
||||
it('preserves successful reachability when reading its snapshot fails', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: {
|
||||
getStatus: vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a')),
|
||||
getStatusSnapshots: vi.fn().mockRejectedValue(new Error('IPC read failed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const store = createSliceStore()
|
||||
expect(await store.getState().refreshRuntimeEnvironmentStatus('env-a')).toBe(true)
|
||||
expect(store.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false)
|
||||
expect(log).toHaveBeenCalled()
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('hydrates saved environments through the single-environment refresh path', async () => {
|
||||
const getStatus = vi.fn().mockResolvedValue(createCompatibleRuntimeStatusResponse('runtime-a'))
|
||||
const list = vi.fn().mockResolvedValue([
|
||||
|
||||
@@ -34,11 +34,15 @@ it('primary browser status follows the authenticated socket and closing it retir
|
||||
status: { environmentId: 'browser', pairingRevision: 1, publish, verified: vi.fn() }
|
||||
})
|
||||
clients.push(client)
|
||||
await expect.poll(() => client.statusOwner?.read().verification).toBe('verified')
|
||||
await expect
|
||||
.poll(() => client.statusOwner?.read().verification, { timeout: 3_000 })
|
||||
.toBe('verified')
|
||||
expect(client.statusOwner?.read().status?.runtimeId).toBe('before')
|
||||
runtimeId = 'after'
|
||||
server.closeClients()
|
||||
await expect.poll(() => client.statusOwner?.read().status?.runtimeId).toBe('after')
|
||||
await expect
|
||||
.poll(() => client.statusOwner?.read().status?.runtimeId, { timeout: 3_000 })
|
||||
.toBe('after')
|
||||
expect(client.statusOwner?.read().transport).toBe('ready')
|
||||
client.close()
|
||||
expect(publish.mock.lastCall?.[0]).toMatchObject({ retired: true, verification: 'blocked' })
|
||||
|
||||
@@ -320,17 +320,15 @@ describe('execution host registry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('includes runtime hosts from repo ownership but marks them disconnected without live status', () => {
|
||||
it('keeps runtime hosts checking before their first status result', () => {
|
||||
const hosts = buildExecutionHostRegistry({
|
||||
repos: [{ connectionId: null, executionHostId: 'runtime:env-2' }],
|
||||
settings: { activeRuntimeEnvironmentId: null }
|
||||
})
|
||||
|
||||
// No live status means no evidence the Orca server is reachable, so it must
|
||||
// read 'disconnected' rather than defaulting to 'available'/"Connected".
|
||||
expect(hosts).toMatchObject([
|
||||
{ id: 'local', health: 'local' },
|
||||
{ id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'disconnected' }
|
||||
{ id: 'runtime:env-2', kind: 'runtime', label: 'env-2', health: 'connecting' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -373,3 +371,29 @@ describe('execution host registry', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an initial unknown-transport verification connecting', () => {
|
||||
const hosts = buildExecutionHostRegistry({
|
||||
repos: [],
|
||||
settings: null,
|
||||
runtimeEnvironments: [{ id: 'host', name: 'Host' }],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
[
|
||||
'host',
|
||||
{
|
||||
status: null,
|
||||
snapshot: {
|
||||
environmentId: 'host',
|
||||
pairingRevision: 1,
|
||||
sequence: 1,
|
||||
checkedAt: 0,
|
||||
status: null,
|
||||
verification: 'checking',
|
||||
transport: 'unknown'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
expect(hosts.find((host) => host.id === 'runtime:host')?.health).toBe('connecting')
|
||||
})
|
||||
|
||||
@@ -168,7 +168,10 @@ function addRuntimeHost(
|
||||
? 'disconnected'
|
||||
: snapshot?.verification === 'blocked'
|
||||
? 'blocked'
|
||||
: snapshot?.transport === 'disconnected' || snapshot?.transport === 'connecting'
|
||||
: !runtimeStatus ||
|
||||
snapshot?.verification === 'checking' ||
|
||||
snapshot?.transport === 'disconnected' ||
|
||||
snapshot?.transport === 'connecting'
|
||||
? 'connecting'
|
||||
: snapshot?.transport === 'ready'
|
||||
? compatibility?.kind === 'blocked'
|
||||
|
||||
@@ -82,21 +82,24 @@ for (const topology of ['desktop', 'headless'] as const) {
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const headless =
|
||||
topology === 'headless'
|
||||
? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true })
|
||||
: null
|
||||
const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page))
|
||||
await (headless
|
||||
? headless.client.call('repo.add', { path: testRepoPath })
|
||||
: page.evaluate(async (path) => {
|
||||
await window.api.repos.add({ path })
|
||||
await window.__store?.getState().fetchRepos()
|
||||
}, testRepoPath))
|
||||
const proxy = await interruptibleHost(offer)
|
||||
const client = await launchPairedElectronClient(offer, testInfo, 'Direct host')
|
||||
let headless: Awaited<ReturnType<typeof launchHeadlessPairedRuntimeHost>> | null = null
|
||||
let proxy: Awaited<ReturnType<typeof interruptibleHost>> | undefined
|
||||
let client: Awaited<ReturnType<typeof launchPairedElectronClient>> | undefined
|
||||
let browser: Awaited<ReturnType<typeof launchPairedWebClient>> | undefined
|
||||
try {
|
||||
headless =
|
||||
topology === 'headless'
|
||||
? await launchHeadlessPairedRuntimeHost({ pinnedServePort: true })
|
||||
: null
|
||||
const offer = headless?.offer ?? (await createRuntimeDesktopPairingOffer(page))
|
||||
await (headless
|
||||
? headless.client.call('repo.add', { path: testRepoPath })
|
||||
: page.evaluate(async (path) => {
|
||||
await window.api.repos.add({ path })
|
||||
await window.__store?.getState().fetchRepos()
|
||||
}, testRepoPath))
|
||||
proxy = await interruptibleHost(offer)
|
||||
client = await launchPairedElectronClient(offer, testInfo, 'Direct host')
|
||||
proxy.setOnline(false)
|
||||
const offlineId = await client.page.evaluate(async (pairingCode) => {
|
||||
const { environment } = await window.api.runtimeEnvironments.addFromPairingCode({
|
||||
@@ -109,16 +112,16 @@ for (const topology of ['desktop', 'headless'] as const) {
|
||||
return environment.id
|
||||
}, proxy.offer.pairingUrl)
|
||||
await expect
|
||||
.poll(() => statusEvidence(client.page, offlineId))
|
||||
.poll(() => statusEvidence(client!.page, offlineId))
|
||||
.toMatchObject({ verification: 'unavailable' })
|
||||
expect(await statusEvidence(client.page, client.environmentId)).toMatchObject({
|
||||
expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({
|
||||
verification: 'verified'
|
||||
})
|
||||
proxy.setOnline(true)
|
||||
await expect
|
||||
.poll(() => statusEvidence(client.page, offlineId), { timeout: 30_000 })
|
||||
.poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 })
|
||||
.toMatchObject({ verification: 'verified', transport: 'ready' })
|
||||
const initial = await statusEvidence(client.page, offlineId)
|
||||
const initial = await statusEvidence(client!.page, offlineId)
|
||||
await expect(client.page.getByText('Recovering host', { exact: true }).first()).toBeVisible()
|
||||
await client.page.screenshot({ path: testInfo.outputPath(`${topology}-recovered.png`) })
|
||||
browser = await launchPairedWebClient(electronApp, proxy.offer)
|
||||
@@ -127,20 +130,23 @@ for (const topology of ['desktop', 'headless'] as const) {
|
||||
.toMatchObject({ verification: 'verified', transport: 'ready' })
|
||||
proxy.setOnline(false)
|
||||
await expect
|
||||
.poll(() => statusEvidence(client.page, offlineId))
|
||||
.poll(() => statusEvidence(client!.page, offlineId))
|
||||
.toMatchObject({ transport: 'disconnected' })
|
||||
expect(await statusEvidence(client.page, client.environmentId)).toMatchObject({
|
||||
await expect
|
||||
.poll(() => statusEvidence(browser!.page), { timeout: 30_000 })
|
||||
.toMatchObject({ transport: 'disconnected' })
|
||||
expect(await statusEvidence(client!.page, client.environmentId)).toMatchObject({
|
||||
verification: 'verified',
|
||||
transport: 'ready'
|
||||
})
|
||||
proxy.setOnline(true)
|
||||
await expect
|
||||
.poll(() => statusEvidence(client.page, offlineId), { timeout: 30_000 })
|
||||
.poll(() => statusEvidence(client!.page, offlineId), { timeout: 30_000 })
|
||||
.toMatchObject({ verification: 'verified', transport: 'ready' })
|
||||
await expect
|
||||
.poll(() => statusEvidence(browser!.page), { timeout: 30_000 })
|
||||
.toMatchObject({ verification: 'verified', transport: 'ready' })
|
||||
expect((await statusEvidence(client.page, offlineId))!.sequence).toBeGreaterThan(
|
||||
expect((await statusEvidence(client!.page, offlineId))!.sequence).toBeGreaterThan(
|
||||
initial!.sequence
|
||||
)
|
||||
await browser.page.screenshot({
|
||||
@@ -150,12 +156,12 @@ for (const topology of ['desktop', 'headless'] as const) {
|
||||
await window.api.runtimeEnvironments.disconnect({ selector })
|
||||
}, offlineId)
|
||||
await expect
|
||||
.poll(() => statusEvidence(client.page, offlineId))
|
||||
.poll(() => statusEvidence(client!.page, offlineId))
|
||||
.toMatchObject({ verification: 'blocked', transport: 'disconnected' })
|
||||
} finally {
|
||||
await browser?.dispose()
|
||||
await client.dispose()
|
||||
await proxy.close()
|
||||
await client?.dispose()
|
||||
await proxy?.close()
|
||||
await headless?.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user