mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): EP-8 Delete works when this computer can't reach the sandbox (PR 1 of 2) (#683)
* fix(cloud): EP-8 Delete of a running sandbox this computer can't reach (revoked pairing) goes through Orca Cloud with Force delete, never waits silently * fix(cloud): EP-8 refusal copy says sandbox and this computer (design-squad)
This commit is contained in:
@@ -62,4 +62,40 @@ describe('preflightCloudWorkspaceDelete', () => {
|
||||
gone: true
|
||||
})
|
||||
})
|
||||
|
||||
it("EP-8: a running sandbox whose server refuses or doesn't answer this computer is unreachable", async () => {
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'rejected')
|
||||
).toEqual({ ok: true, unreachable: 'rejected' })
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'no_answer')
|
||||
).toEqual({ ok: true, unreachable: 'no_answer' })
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
).toEqual({ ok: true, unreachable: 'no_answer' })
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete('sbx_1', answering('running'), async () => 'ok')
|
||||
).toEqual({
|
||||
ok: true
|
||||
})
|
||||
})
|
||||
|
||||
it('EP-8: never probes an asleep or destroyed sandbox', async () => {
|
||||
let probed = 0
|
||||
const probe = async (): Promise<'ok'> => {
|
||||
probed += 1
|
||||
return 'ok'
|
||||
}
|
||||
expect(await preflightCloudWorkspaceDelete('sbx_1', answering('suspended'), probe)).toEqual({
|
||||
ok: true,
|
||||
asleep: true
|
||||
})
|
||||
expect(await preflightCloudWorkspaceDelete('sbx_1', answering('destroyed'), probe)).toEqual({
|
||||
ok: true,
|
||||
gone: true
|
||||
})
|
||||
expect(probed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,16 @@ import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-p
|
||||
/**
|
||||
* `asleep`: no running server to remove its checkout; destroying the sandbox takes the disk.
|
||||
* `gone`: Orca Cloud already destroyed it (or it never started); only the row is left.
|
||||
* `unreachable` (EP-8): running, but this computer can't reach its server (its pairing was revoked,
|
||||
* e.g. by an owner change, or it doesn't answer). Like asleep: Delete goes through Orca Cloud.
|
||||
*/
|
||||
export type CloudDeletePreflight =
|
||||
| { ok: true; asleep?: boolean; gone?: boolean }
|
||||
| { ok: true; asleep?: boolean; gone?: boolean; unreachable?: 'rejected' | 'no_answer' }
|
||||
| ({ ok: false } & EphemeralVmFailure)
|
||||
|
||||
/** Can this computer reach the workspace's server right now? A short probe, never a wake. */
|
||||
export type CloudServerProbe = () => Promise<'ok' | 'rejected' | 'no_answer'>
|
||||
|
||||
const ASLEEP_STATUSES = new Set(['suspending', 'suspended', 'archived'])
|
||||
const GONE_STATUSES = new Set(['destroying', 'destroyed', 'failed'])
|
||||
|
||||
@@ -25,7 +30,8 @@ export function cloudSandboxIdForEnvironment(
|
||||
|
||||
export async function preflightCloudWorkspaceDelete(
|
||||
sandboxId: string | undefined,
|
||||
client: Pick<HostedSandboxControlPlaneClient, 'get'> | null
|
||||
client: Pick<HostedSandboxControlPlaneClient, 'get'> | null,
|
||||
probe?: CloudServerProbe
|
||||
): Promise<CloudDeletePreflight> {
|
||||
if (!sandboxId) {
|
||||
// Not an Orca Cloud workspace (or its sandbox is already cleaned up): nothing to prove.
|
||||
@@ -39,7 +45,12 @@ export async function preflightCloudWorkspaceDelete(
|
||||
if (GONE_STATUSES.has(view.status)) {
|
||||
return { ok: true, gone: true }
|
||||
}
|
||||
return ASLEEP_STATUSES.has(view.status) ? { ok: true, asleep: true } : { ok: true }
|
||||
if (ASLEEP_STATUSES.has(view.status)) {
|
||||
return { ok: true, asleep: true }
|
||||
}
|
||||
// Running on Orca Cloud: a Delete through a server this computer can't reach would wait forever.
|
||||
const reach = probe ? await probe().catch(() => 'no_answer' as const) : 'ok'
|
||||
return reach === 'ok' ? { ok: true } : { ok: true, unreachable: reach }
|
||||
} catch (error) {
|
||||
if (isEnvironmentProviderError(error, 'not_found')) {
|
||||
return { ok: false, ...ephemeralVmFailure('cloudDeleteNotConfirmed') }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyProbeFailure, probeCloudServer } from './cloud-server-probe'
|
||||
|
||||
describe('probeCloudServer (EP-8)', () => {
|
||||
it('tells a refused pairing apart from a server that does not answer', () => {
|
||||
expect(classifyProbeFailure('unauthorized', '')).toBe('rejected')
|
||||
expect(classifyProbeFailure('device_revoked', '')).toBe('rejected')
|
||||
expect(classifyProbeFailure(undefined, 'Remote Orca runtime rejected the pairing token.')).toBe(
|
||||
'rejected'
|
||||
)
|
||||
expect(classifyProbeFailure('timeout', 'Request timed out')).toBe('no_answer')
|
||||
})
|
||||
|
||||
it('asks the server for its status, briefly', async () => {
|
||||
const calls: unknown[][] = []
|
||||
const ok = async (...args: unknown[]) => {
|
||||
calls.push(args)
|
||||
return { id: 'status.get', ok: true as const, result: {}, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
expect(await probeCloudServer('/data', 'env-1', ok)).toBe('ok')
|
||||
expect(calls[0]?.slice(1, 5)).toEqual(['env-1', 'status.get', undefined, 5_000])
|
||||
const refused = async () => {
|
||||
throw Object.assign(new Error('Remote Orca runtime rejected the pairing token.'), {
|
||||
code: 'unauthorized'
|
||||
})
|
||||
}
|
||||
expect(await probeCloudServer('/data', 'env-1', refused)).toBe('rejected')
|
||||
expect(await probeCloudServer('/data', '', ok)).toBe('no_answer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// EP-8: can this computer reach an Orca Cloud workspace's server right now? A short status call over
|
||||
// its saved pairing. A refused pairing (revoked by an owner change or a removal) is told apart from a
|
||||
// server that just doesn't answer, so the caller can say which.
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5_000
|
||||
const REJECTED_CODES = new Set(['unauthorized', 'device_revoked', 'forbidden'])
|
||||
|
||||
export function classifyProbeFailure(
|
||||
code: string | undefined,
|
||||
message: string
|
||||
): 'rejected' | 'no_answer' {
|
||||
return (code && REJECTED_CODES.has(code)) || /rejected the pairing token/i.test(message)
|
||||
? 'rejected'
|
||||
: 'no_answer'
|
||||
}
|
||||
|
||||
export async function probeCloudServer(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
call: typeof callRuntimeEnvironment = callRuntimeEnvironment
|
||||
): Promise<'ok' | 'rejected' | 'no_answer'> {
|
||||
if (!environmentId) {
|
||||
return 'no_answer'
|
||||
}
|
||||
try {
|
||||
const response = await call(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'status.get',
|
||||
undefined,
|
||||
PROBE_TIMEOUT_MS
|
||||
)
|
||||
return response.ok
|
||||
? 'ok'
|
||||
: classifyProbeFailure(response.error?.code, response.error?.message ?? '')
|
||||
} catch (error) {
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: undefined
|
||||
return classifyProbeFailure(code, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
preflightCloudWorkspaceDelete,
|
||||
type CloudDeletePreflight
|
||||
} from '../hosted-sandbox/cloud-delete-preflight'
|
||||
import { probeCloudServer } from '../hosted-sandbox/cloud-server-probe'
|
||||
import { getHostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-install'
|
||||
import {
|
||||
parsePendingVmCheckouts,
|
||||
@@ -157,7 +158,8 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
app.getPath('userData'),
|
||||
String(args?.runtimeEnvironmentId ?? '')
|
||||
),
|
||||
getHostedSandboxControlPlaneClient()
|
||||
getHostedSandboxControlPlaneClient(),
|
||||
() => probeCloudServer(app.getPath('userData'), String(args?.runtimeEnvironmentId ?? ''))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export type EphemeralVmApi = {
|
||||
preflightCloudDelete?: (args: {
|
||||
runtimeEnvironmentId: string
|
||||
}) => Promise<
|
||||
| { ok: true; asleep?: boolean; gone?: boolean }
|
||||
| { ok: true; asleep?: boolean; gone?: boolean; unreachable?: 'rejected' | 'no_answer' }
|
||||
| { ok: false; error: string; errorCode?: string }
|
||||
>
|
||||
stopCleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ephemeralVmCleanupErrorText, ephemeralVmErrorText } from '@/runtime/ephemeral-vm/ephemeral-vm-error-copy'
|
||||
import {
|
||||
ephemeralVmCleanupErrorText,
|
||||
ephemeralVmErrorText
|
||||
} from '@/runtime/ephemeral-vm/ephemeral-vm-error-copy'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../../../../../shared/ephemeral-vm-runtimes'
|
||||
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
|
||||
import { CLOUD_ASLEEP_DELETE_REFUSAL } from '../../../../../../shared/worktree/removal'
|
||||
import {
|
||||
CLOUD_ASLEEP_DELETE_REFUSAL,
|
||||
CLOUD_UNREACHABLE_DELETE_REFUSAL
|
||||
} from '../../../../../../shared/worktree/removal'
|
||||
import type { RemoveWorktreeOptions } from './worktree-removal-options'
|
||||
import type { WorktreeSliceGet } from '../listing/worktree-slice-types'
|
||||
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
|
||||
@@ -54,7 +60,10 @@ export async function cloudSandboxSkipsServer(
|
||||
return false
|
||||
}
|
||||
const result = await preflightCloudDelete(runtimeEnvironmentId)?.catch(() => null)
|
||||
return result?.ok === true && (result.asleep === true || result.gone === true)
|
||||
return (
|
||||
result?.ok === true &&
|
||||
(result.asleep === true || result.gone === true || result.unreachable !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,12 +117,18 @@ export async function prepareCloudSandboxDelete(args: {
|
||||
if (!result.ok) {
|
||||
throw new Error(ephemeralVmErrorText(result))
|
||||
}
|
||||
if (!result.asleep && !result.gone) {
|
||||
// EP-8: running, but its server won't answer this computer (a revoked pairing): asking it would
|
||||
// wait forever, so Delete goes through Orca Cloud like an asleep one, and says so.
|
||||
const unreachable = result.unreachable !== undefined
|
||||
if (!result.asleep && !result.gone && !unreachable) {
|
||||
return args.options
|
||||
}
|
||||
if (result.asleep && !args.force) {
|
||||
throw new Error(CLOUD_ASLEEP_DELETE_REFUSAL)
|
||||
}
|
||||
if (unreachable && !args.force) {
|
||||
throw new Error(CLOUD_UNREACHABLE_DELETE_REFUSAL)
|
||||
}
|
||||
// forget-local skips the transport's own pre-send checks, so run them here.
|
||||
args.assertBeforeDispatch()
|
||||
return { ...args.options, mode: 'forget-local' }
|
||||
|
||||
@@ -122,4 +122,36 @@ describe('Orca Cloud workspace delete', () => {
|
||||
expect(calledMethods()).not.toContain('worktree.rm')
|
||||
expect(mockApi.worktrees.forgetLocal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('EP-8: a running sandbox this computer cannot reach asks for Force delete, never waits on it', async () => {
|
||||
const { store, wt } = seedCloudWorkspace()
|
||||
Object.assign(mockApi.ephemeralVm, {
|
||||
preflightCloudDelete: vi.fn().mockResolvedValue({ ok: true, unreachable: 'rejected' })
|
||||
})
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
// Nothing went to the server whose pairing was refused (it would never answer).
|
||||
expect(calledMethods()).toEqual([])
|
||||
expect(mockApi.worktrees.forgetLocal).not.toHaveBeenCalled()
|
||||
expect(store.getState().deleteStateByWorktreeId[wt.id]?.canForceDelete).toBe(true)
|
||||
})
|
||||
|
||||
it('EP-8: Force delete of an unreachable sandbox destroys it through Orca Cloud', async () => {
|
||||
const { store, wt } = seedCloudWorkspace()
|
||||
Object.assign(mockApi.ephemeralVm, {
|
||||
preflightCloudDelete: vi.fn().mockResolvedValue({ ok: true, unreachable: 'no_answer' })
|
||||
})
|
||||
mockApi.ephemeralVm.listRuntimes.mockImplementation(async () => [
|
||||
{ id: 'runtime-cloud', workspaceId: wt.id, cleanupStatus: 'not_started' }
|
||||
])
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null }, true)
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(calledMethods()).toEqual([])
|
||||
expect(mockApi.worktrees.forgetLocal).toHaveBeenCalled()
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-cloud' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,6 +121,13 @@ const FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN =
|
||||
export const CLOUD_ASLEEP_DELETE_REFUSAL =
|
||||
'This workspace is asleep, so Orca can’t check it for unsaved changes. Use Force delete to delete it and its disk without waking it.'
|
||||
|
||||
/**
|
||||
* EP-8: a running Orca Cloud sandbox this computer can't reach (its pairing was revoked, or its server
|
||||
* doesn't answer) can't be checked for unsaved work either; Force delete deletes it through Orca Cloud.
|
||||
*/
|
||||
export const CLOUD_UNREACHABLE_DELETE_REFUSAL =
|
||||
'Orca can’t reach this workspace’s sandbox from this computer, so it can’t check it for unsaved changes. Use Force delete to delete it and its disk through Orca Cloud.'
|
||||
|
||||
export function classifyWorktreeForceDeleteReason(
|
||||
error: string,
|
||||
force = false,
|
||||
@@ -154,6 +161,7 @@ export function classifyWorktreeForceDeleteReason(
|
||||
}
|
||||
if (
|
||||
error === CLOUD_ASLEEP_DELETE_REFUSAL ||
|
||||
error === CLOUD_UNREACHABLE_DELETE_REFUSAL ||
|
||||
error.includes('Worktree has uncommitted or untracked changes') ||
|
||||
error.includes('contains modified or untracked files') ||
|
||||
FORMATTED_DIRTY_WORKTREE_REMOVAL_PATTERN.test(error)
|
||||
|
||||
Reference in New Issue
Block a user