mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): deletes never drop a row for a sandbox Orca Cloud can't confirm; asleep cloud delete skips the wake; creator can delete across teams; picker sign-in selects Orca Cloud; signed-out create shows the sign-in card
This commit is contained in:
@@ -1082,8 +1082,12 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
|
||||
const owner = row !== null && row.account_id === caller.accountId && row.user_id === caller.userId
|
||||
const adminAction =
|
||||
(route === 'DELETE ' || route === 'POST suspend') && row?.account_id === caller.accountId && isOrgAdmin(caller)
|
||||
// The creator can always see and delete their own sandbox, whichever team is active now: a team
|
||||
// switch must never leave a billed VM that its owner's Delete can't reach. Nothing that bills
|
||||
// (resume, rollover) crosses accounts.
|
||||
const creatorStop = (route === 'GET ' || route === 'DELETE ') && row !== null && row.user_id === caller.userId
|
||||
// Another account's (or a teammate's) sandbox is indistinguishable from a missing one.
|
||||
if (!row || !(owner || adminAction)) {
|
||||
if (!row || !(owner || adminAction || creatorStop)) {
|
||||
return fail('not_found', 404)
|
||||
}
|
||||
switch (route) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HostedSandboxReadyResponseSchema } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
|
||||
import { createHarness } from './harness'
|
||||
|
||||
const create = { workspaceId: 'ws-cross-team', machineSize: 'standard' }
|
||||
|
||||
describe('deleting a sandbox after a team switch', () => {
|
||||
it('lets the creator see and delete their sandbox while another team is active', async () => {
|
||||
const h = await createHarness()
|
||||
const { sandbox } = HostedSandboxReadyResponseSchema.parse(
|
||||
(await h.call('POST', '/v1/sandboxes', { body: create })).body
|
||||
)
|
||||
// Same user, different active team (W9 D-2: create on personal, switch to a team, Delete).
|
||||
const team = await h.token('alice', 'acme')
|
||||
expect((await h.call('GET', `/v1/sandboxes/${sandbox.id}`, { bearer: team })).status).toBe(200)
|
||||
const deleted = await h.call('DELETE', `/v1/sandboxes/${sandbox.id}`, { bearer: team })
|
||||
expect(deleted.status).toBe(200)
|
||||
expect(deleted.body).toMatchObject({ sandbox: { status: 'destroyed' } })
|
||||
expect(h.vercel.boxes.size).toBe(0)
|
||||
})
|
||||
|
||||
it('still hides the sandbox from other users, and the switched team cannot resume it', async () => {
|
||||
const h = await createHarness()
|
||||
const { sandbox } = HostedSandboxReadyResponseSchema.parse(
|
||||
(await h.call('POST', '/v1/sandboxes', { body: create })).body
|
||||
)
|
||||
expect((await h.call('DELETE', `/v1/sandboxes/${sandbox.id}`, { as: 'mallory' })).status).toBe(
|
||||
404
|
||||
)
|
||||
const team = await h.token('alice', 'acme')
|
||||
expect(
|
||||
(await h.call('POST', `/v1/sandboxes/${sandbox.id}/resume`, { bearer: team })).status
|
||||
).toBe(404)
|
||||
expect(h.vercel.boxes.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
|
||||
import type { HostedSandboxView } from '../../shared/hosted-sandbox/hosted-sandbox-wire'
|
||||
import { preflightCloudWorkspaceDelete } from './cloud-delete-preflight'
|
||||
|
||||
function view(status: HostedSandboxView['status']): HostedSandboxView {
|
||||
return {
|
||||
id: 'sbx_1',
|
||||
workspaceId: 'ws',
|
||||
status,
|
||||
machineSize: 'standard',
|
||||
region: 'iad1',
|
||||
session: null,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
const answering = (status: HostedSandboxView['status']) => ({ get: async () => view(status) })
|
||||
const failing = (error: unknown) => ({
|
||||
get: async (): Promise<HostedSandboxView> => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
describe('preflightCloudWorkspaceDelete', () => {
|
||||
it('passes non-cloud workspaces straight through', async () => {
|
||||
expect(await preflightCloudWorkspaceDelete(undefined, null)).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('keeps the row when the signed-in identity cannot see the sandbox (W9 D-2)', async () => {
|
||||
const result = await preflightCloudWorkspaceDelete(
|
||||
'sbx_1',
|
||||
failing(new EnvironmentProviderError('not_found', 'gone'))
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, errorCode: 'cloudDeleteNotConfirmed' })
|
||||
})
|
||||
|
||||
it('asks to sign in, and fails closed when Orca Cloud is unreachable', async () => {
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete(
|
||||
'sbx_1',
|
||||
failing(new EnvironmentProviderError('unauthorized', 'no'))
|
||||
)
|
||||
).toMatchObject({ ok: false, errorCode: 'cloudSignInRequired' })
|
||||
expect(
|
||||
await preflightCloudWorkspaceDelete('sbx_1', failing(new Error('offline')))
|
||||
).toMatchObject({ ok: false, errorCode: 'cloudUnavailable' })
|
||||
expect(await preflightCloudWorkspaceDelete('sbx_1', null)).toMatchObject({
|
||||
ok: false,
|
||||
errorCode: 'cloudUnavailable'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an asleep sandbox so Delete can skip waking it', async () => {
|
||||
expect(await preflightCloudWorkspaceDelete('sbx_1', answering('suspended'))).toEqual({
|
||||
ok: true,
|
||||
asleep: true
|
||||
})
|
||||
expect(await preflightCloudWorkspaceDelete('sbx_1', answering('running'))).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Before Delete removes an Orca Cloud workspace's row, prove the signed-in identity can reach its
|
||||
// sandbox. A row removed first and a destroy that then fails leaves a billed VM nobody can see.
|
||||
import { ephemeralVmFailure, type EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
|
||||
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
|
||||
import { cloudSandboxesByEnvironment } from './hosted-remote-desktop'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
|
||||
/** `asleep`: the sandbox has no running server to remove its checkout; destroying it is enough. */
|
||||
export type CloudDeletePreflight =
|
||||
| { ok: true; asleep?: boolean }
|
||||
| ({ ok: false } & EphemeralVmFailure)
|
||||
|
||||
const ASLEEP_STATUSES = new Set(['suspending', 'suspended', 'archived'])
|
||||
|
||||
export function cloudSandboxIdForEnvironment(
|
||||
userDataPath: string,
|
||||
runtimeEnvironmentId: string
|
||||
): string | undefined {
|
||||
return cloudSandboxesByEnvironment(userDataPath).get(runtimeEnvironmentId)
|
||||
}
|
||||
|
||||
export async function preflightCloudWorkspaceDelete(
|
||||
sandboxId: string | undefined,
|
||||
client: Pick<HostedSandboxControlPlaneClient, 'get'> | null
|
||||
): Promise<CloudDeletePreflight> {
|
||||
if (!sandboxId) {
|
||||
// Not an Orca Cloud workspace (or its sandbox is already cleaned up): nothing to prove.
|
||||
return { ok: true }
|
||||
}
|
||||
if (!client) {
|
||||
return { ok: false, ...ephemeralVmFailure('cloudUnavailable') }
|
||||
}
|
||||
try {
|
||||
const view = await client.get(sandboxId)
|
||||
return ASLEEP_STATUSES.has(view.status) ? { ok: true, asleep: true } : { ok: true }
|
||||
} catch (error) {
|
||||
if (isEnvironmentProviderError(error, 'not_found')) {
|
||||
return { ok: false, ...ephemeralVmFailure('cloudDeleteNotConfirmed') }
|
||||
}
|
||||
if (isEnvironmentProviderError(error, 'unauthorized')) {
|
||||
return { ok: false, ...ephemeralVmFailure('cloudSignInRequired') }
|
||||
}
|
||||
// Fail closed: deleting the row without reaching Orca Cloud could orphan a billed VM.
|
||||
return { ok: false, ...ephemeralVmFailure('cloudUnavailable') }
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ import { attachEphemeralVmRuntimeToWorkspace } from '../ephemeral-vm/runtime-att
|
||||
import { forgetInterruptedEphemeralVmProvision } from '../ephemeral-vm/runtime-provisioning-persistence'
|
||||
import { installCloudSsh } from '../cloud-ssh/cloud-ssh-main'
|
||||
import { pendingVmCheckouts } from '../ephemeral-vm/pending-vm-checkouts'
|
||||
import {
|
||||
cloudSandboxIdForEnvironment,
|
||||
preflightCloudWorkspaceDelete,
|
||||
type CloudDeletePreflight
|
||||
} from '../hosted-sandbox/cloud-delete-preflight'
|
||||
import { getHostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-install'
|
||||
import {
|
||||
parsePendingVmCheckouts,
|
||||
PENDING_VM_CHECKOUTS_CHANGED_CHANNEL
|
||||
@@ -112,6 +118,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
ipcMain.removeHandler('ephemeralVm:attachWorkspace')
|
||||
ipcMain.removeHandler('ephemeralVm:listRuntimes')
|
||||
ipcMain.removeHandler('ephemeralVm:cleanup')
|
||||
ipcMain.removeHandler('ephemeralVm:preflightCloudDelete')
|
||||
ipcMain.removeHandler('ephemeralVm:stopCleanup')
|
||||
ipcMain.removeHandler('ephemeralVm:forgetRuntime')
|
||||
ipcMain.removeHandler('ephemeralVm:getCleanupCommand')
|
||||
@@ -133,6 +140,18 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'ephemeralVm:preflightCloudDelete',
|
||||
(_event, args: { runtimeEnvironmentId: string }): Promise<CloudDeletePreflight> =>
|
||||
preflightCloudWorkspaceDelete(
|
||||
cloudSandboxIdForEnvironment(
|
||||
app.getPath('userData'),
|
||||
String(args?.runtimeEnvironmentId ?? '')
|
||||
),
|
||||
getHostedSandboxControlPlaneClient()
|
||||
)
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'ephemeralVm:cleanup',
|
||||
async (_event, args: { runtimeId: string }): Promise<EphemeralVmRuntimeRecord> => {
|
||||
|
||||
@@ -40,7 +40,12 @@ import {
|
||||
} from './ephemeral-vm-recipe-context'
|
||||
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
|
||||
import type { PluginService } from '../plugins/plugin-service'
|
||||
import { installHostedSandbox, withProviderRecipes } from '../hosted-sandbox/hosted-sandbox-install'
|
||||
import {
|
||||
getOrcaCloudAvailability,
|
||||
installHostedSandbox,
|
||||
withProviderRecipes
|
||||
} from '../hosted-sandbox/hosted-sandbox-install'
|
||||
import { missingRecipeFailure } from './orca-cloud-missing-recipe'
|
||||
import { refuseOrcaCloudWithoutGithub } from '../hosted-sandbox/hosted-sandbox-recipe'
|
||||
import { resolveProvisionedRootSource } from '../ephemeral-vm/provisioned-root-source'
|
||||
import { getRuntimeEnvironmentRegistryRoot } from './runtime-environments/runtime-environment-registry-root'
|
||||
@@ -132,7 +137,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
|
||||
await withProviderRecipes(pluginService)
|
||||
)
|
||||
if (!recipe) {
|
||||
return provisionFailure(ephemeralVmFailure('recipeNotFound', { recipeId: args.recipeId }))
|
||||
return provisionFailure(await missingRecipeFailure(args.recipeId, getOrcaCloudAvailability))
|
||||
}
|
||||
const refused = await refuseOrcaCloudWithoutGithub(recipe, repo.repo.path)
|
||||
if (refused) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCA_CLOUD_RECIPE_ID } from '../../shared/hosted-sandbox/orca-cloud-recipe-id'
|
||||
import { missingRecipeFailure } from './orca-cloud-missing-recipe'
|
||||
|
||||
describe('missingRecipeFailure', () => {
|
||||
it('asks to sign in when an Orca Cloud create runs after the session ended', async () => {
|
||||
const failure = await missingRecipeFailure(ORCA_CLOUD_RECIPE_ID, async () => 'needs-sign-in')
|
||||
expect(failure.errorCode).toBe('cloudSignInRequired')
|
||||
})
|
||||
|
||||
it('routes an unentitled account to plans and an unreachable service to try again', async () => {
|
||||
expect(
|
||||
(await missingRecipeFailure(ORCA_CLOUD_RECIPE_ID, async () => 'needs-capability')).errorCode
|
||||
).toBe('cloudPlanRequired')
|
||||
expect(
|
||||
(await missingRecipeFailure(ORCA_CLOUD_RECIPE_ID, async () => 'unavailable')).errorCode
|
||||
).toBe('cloudUnavailable')
|
||||
})
|
||||
|
||||
it('keeps recipe-not-found for other recipes without asking Orca Cloud', async () => {
|
||||
let asked = false
|
||||
const failure = await missingRecipeFailure('plugin:vm', async () => {
|
||||
asked = true
|
||||
return 'needs-sign-in'
|
||||
})
|
||||
expect(failure.errorCode).toBe('recipeNotFound')
|
||||
expect(asked).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ephemeralVmFailure, type EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
|
||||
import { ORCA_CLOUD_RECIPE_ID } from '../../shared/hosted-sandbox/orca-cloud-recipe-id'
|
||||
import type { OrcaCloudAvailability } from '../../shared/orca-yaml-hook-types'
|
||||
|
||||
/**
|
||||
* Why a provision found no recipe. Orca Cloud is only listed while it's offered, so a create picked
|
||||
* before a sign-out (or a session that expired) says "sign in", not a generic "recipe not found".
|
||||
*/
|
||||
export function missingRecipeFailure(
|
||||
recipeId: string,
|
||||
orcaCloud: () => Promise<OrcaCloudAvailability>
|
||||
): Promise<EphemeralVmFailure> | EphemeralVmFailure {
|
||||
if (recipeId !== ORCA_CLOUD_RECIPE_ID) {
|
||||
return ephemeralVmFailure('recipeNotFound', { recipeId })
|
||||
}
|
||||
return orcaCloud().then((availability) => {
|
||||
switch (availability) {
|
||||
case 'needs-sign-in':
|
||||
return ephemeralVmFailure('cloudSignInRequired')
|
||||
case 'needs-capability':
|
||||
return ephemeralVmFailure('cloudPlanRequired')
|
||||
case 'unavailable':
|
||||
return ephemeralVmFailure('cloudUnavailable')
|
||||
case 'offered':
|
||||
return ephemeralVmFailure('recipeNotFound', { recipeId })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -104,6 +104,10 @@ export type EphemeralVmApi = {
|
||||
/** Fires after any runtime record write; re-list to read the new lifecycle status. */
|
||||
onRuntimesChanged: (callback: () => void) => () => void
|
||||
cleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
|
||||
/** Orca Cloud: before Delete drops the row, prove the signed-in identity can reach the sandbox. */
|
||||
preflightCloudDelete?: (args: {
|
||||
runtimeEnvironmentId: string
|
||||
}) => Promise<{ ok: true; asleep?: boolean } | { ok: false; error: string; errorCode?: string }>
|
||||
stopCleanup: (args: { runtimeId: string }) => Promise<EphemeralVmRuntimeRecord>
|
||||
/** Drops an interrupted write-ahead record that never received a recipe result. */
|
||||
forgetRuntime: (args: { runtimeId: string }) => Promise<void>
|
||||
|
||||
@@ -56,6 +56,7 @@ export const ephemeralVmApi = {
|
||||
return () => ipcRenderer.removeListener(EPHEMERAL_VM_RUNTIMES_CHANGED_CHANNEL, listener)
|
||||
},
|
||||
cleanup: (args) => ipcRenderer.invoke('ephemeralVm:cleanup', args),
|
||||
preflightCloudDelete: (args) => ipcRenderer.invoke('ephemeralVm:preflightCloudDelete', args),
|
||||
stopCleanup: (args) => ipcRenderer.invoke('ephemeralVm:stopCleanup', args),
|
||||
forgetRuntime: (args) => ipcRenderer.invoke('ephemeralVm:forgetRuntime', args),
|
||||
getCleanupCommand: (args) => ipcRenderer.invoke('ephemeralVm:getCleanupCommand', args),
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing'
|
||||
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
|
||||
import { useAppStore } from '@/store/index'
|
||||
import {
|
||||
cancelOrcaCloudSelectionAfterSignIn,
|
||||
requestOrcaCloudSelectionAfterSignIn
|
||||
} from '@/lib/orca-cloud-select-after-sign-in'
|
||||
|
||||
/** Signing in from the row selects Orca Cloud once main offers it (the recipe list reloads). */
|
||||
function signInThenSelectOrcaCloud(): void {
|
||||
requestOrcaCloudSelectionAfterSignIn()
|
||||
void useAppStore
|
||||
.getState()
|
||||
.connectCurrentOrcaProfile()
|
||||
.then((result) => {
|
||||
if (result?.status !== 'connected') {
|
||||
cancelOrcaCloudSelectionAfterSignIn()
|
||||
}
|
||||
})
|
||||
.catch(cancelOrcaCloudSelectionAfterSignIn)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the Run-on picker knows about the account's Orca Cloud access.
|
||||
@@ -66,7 +84,7 @@ export type OrcaCloudPick =
|
||||
export function getOrcaCloudPick(access: OrcaCloudRunTargetAccess): OrcaCloudPick {
|
||||
switch (access.state) {
|
||||
case 'signed-out':
|
||||
return { kind: 'act', run: () => void useAppStore.getState().connectCurrentOrcaProfile() }
|
||||
return { kind: 'act', run: signInThenSelectOrcaCloud }
|
||||
case 'no-plan':
|
||||
return { kind: 'act', run: () => openPlansPage('sandbox') }
|
||||
case 'out-of-hours':
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '@/store'
|
||||
import type { EphemeralVmFailure } from '../../../shared/ephemeral-vm-failure-copy'
|
||||
import { ephemeralVmErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
|
||||
import { takeOrcaCloudSelectionAfterSignIn } from '@/lib/orca-cloud-select-after-sign-in'
|
||||
|
||||
type EphemeralVmRecipeOptionsArgs = {
|
||||
enabled: boolean
|
||||
@@ -75,6 +76,10 @@ export function useEphemeralVmRecipeOptions(args: EphemeralVmRecipeOptionsArgs):
|
||||
? args.initialRecipeId
|
||||
: null
|
||||
}
|
||||
const afterSignIn = takeOrcaCloudSelectionAfterSignIn(nextRecipes)
|
||||
if (afterSignIn) {
|
||||
return afterSignIn
|
||||
}
|
||||
return current && nextRecipes.some((recipe) => recipe.id === current) ? current : null
|
||||
})
|
||||
const diagnosticMessages = (result.diagnostics ?? []).map((diagnostic) => {
|
||||
|
||||
@@ -1646,7 +1646,9 @@
|
||||
},
|
||||
"createdWithoutParentNesting": "Created without nesting under \"{{value0}}\"",
|
||||
"createdWithoutParentNestingUnnamed": "Created without nesting under the selected parent",
|
||||
"createdWithoutParentNestingDetail": "The parent workspace was no longer available. You can set it from the workspace menu."
|
||||
"createdWithoutParentNestingDetail": "The parent workspace was no longer available. You can set it from the workspace menu.",
|
||||
"cloudDeleteFailedTitle": "Couldn’t delete the sandbox",
|
||||
"cloudDeleteTryAgain": "Try again"
|
||||
},
|
||||
"repos": {
|
||||
"2975400634": "Update Orca server to open non-Git folders on this runtime.",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '../../../shared/execution-host'
|
||||
import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
import { refreshRuntimeEnvironmentCatalog } from '@/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh'
|
||||
import { reportCloudSandboxDestroyFailure } from '@/store/slices/worktrees/teardown/cloud-sandbox-delete-guard'
|
||||
|
||||
/** Destroy runtimes for removed workspaces and runtime-owned SSH targets. Share with project removal because SSH-mode main worktrees delete through that path; classify targets so callers purge only confirmed destruction. */
|
||||
export type EphemeralVmCleanupSummary = {
|
||||
@@ -61,6 +62,7 @@ export async function cleanupEphemeralVmRuntimesForDeleted(args: {
|
||||
for (const runtime of matchingRuntimes) {
|
||||
try {
|
||||
const cleaned = await window.api.ephemeralVm.cleanup({ runtimeId: runtime.id })
|
||||
reportCloudSandboxDestroyFailure(cleaned)
|
||||
releasedServer ||=
|
||||
runtime.runtimeEnvironmentId !== undefined || runtime.sshTargetId !== undefined
|
||||
if (runtime.sshTargetId) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cancelOrcaCloudSelectionAfterSignIn,
|
||||
requestOrcaCloudSelectionAfterSignIn,
|
||||
takeOrcaCloudSelectionAfterSignIn
|
||||
} from './orca-cloud-select-after-sign-in'
|
||||
|
||||
const local = { id: 'plugin:vm', provider: 'plugin' }
|
||||
const cloud = { id: 'cloud', provider: 'orca-cloud' }
|
||||
|
||||
describe('Orca Cloud selection after signing in from the picker', () => {
|
||||
afterEach(() => cancelOrcaCloudSelectionAfterSignIn())
|
||||
|
||||
it('selects Orca Cloud once the reloaded recipe list offers it, then only once', () => {
|
||||
requestOrcaCloudSelectionAfterSignIn(1_000)
|
||||
// Before main sees the sign-in the list has no Orca Cloud recipe: keep waiting.
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([local], 2_000)).toBeNull()
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([local, cloud], 3_000)).toBe('cloud')
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([local, cloud], 4_000)).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing without a sign-in from the row, after a cancel, or once the intent is stale', () => {
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([cloud], 1_000)).toBeNull()
|
||||
requestOrcaCloudSelectionAfterSignIn(1_000)
|
||||
cancelOrcaCloudSelectionAfterSignIn()
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([cloud], 2_000)).toBeNull()
|
||||
requestOrcaCloudSelectionAfterSignIn(1_000)
|
||||
expect(takeOrcaCloudSelectionAfterSignIn([cloud], 1_000 + 11 * 60_000)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
// Signing in from the Run-on picker's Orca Cloud row should leave Orca Cloud selected. The recipe
|
||||
// only appears once main sees the sign-in, so the pick leaves an intent the recipe list consumes.
|
||||
|
||||
/** An intent older than this belongs to an abandoned sign-in and is ignored. */
|
||||
const INTENT_TTL_MS = 10 * 60_000
|
||||
|
||||
let requestedAt: number | null = null
|
||||
|
||||
export function requestOrcaCloudSelectionAfterSignIn(now = Date.now()): void {
|
||||
requestedAt = now
|
||||
}
|
||||
|
||||
export function cancelOrcaCloudSelectionAfterSignIn(): void {
|
||||
requestedAt = null
|
||||
}
|
||||
|
||||
/** The Orca Cloud recipe id to select now, consuming the intent; null when there's nothing to do. */
|
||||
export function takeOrcaCloudSelectionAfterSignIn(
|
||||
recipes: readonly { id: string; provider?: string }[],
|
||||
now = Date.now()
|
||||
): string | null {
|
||||
if (requestedAt === null) {
|
||||
return null
|
||||
}
|
||||
if (now - requestedAt > INTENT_TTL_MS) {
|
||||
requestedAt = null
|
||||
return null
|
||||
}
|
||||
const orcaCloud = recipes.find((recipe) => recipe.provider === 'orca-cloud')
|
||||
if (!orcaCloud) {
|
||||
return null
|
||||
}
|
||||
requestedAt = null
|
||||
return orcaCloud.id
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { ephemeralVmCleanupErrorText, ephemeralVmErrorText } from '@/lib/ephemeral-vm-error-copy'
|
||||
import type { EphemeralVmRuntimeRecord } from '../../../../../../shared/ephemeral-vm-runtimes'
|
||||
import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target'
|
||||
import type { RemoveWorktreeOptions } from './worktree-removal-options'
|
||||
|
||||
/**
|
||||
* Orca Cloud: the row goes only once its sandbox is reachable for the destroy that follows. An
|
||||
* asleep sandbox has no server to remove its checkout from; the destroy takes the disk, so the
|
||||
* row is forgotten locally instead of waking the sandbox.
|
||||
*/
|
||||
export async function prepareCloudSandboxDelete(
|
||||
policy: { forgetLocalOnly: boolean; options: RemoveWorktreeOptions | undefined },
|
||||
target: RuntimeClientTarget
|
||||
): Promise<{ dispatchOptions: RemoveWorktreeOptions | undefined }> {
|
||||
const cloudSandboxAsleep =
|
||||
!policy.forgetLocalOnly && target.kind === 'environment'
|
||||
? await assertCloudSandboxDeletable(target.environmentId)
|
||||
: false
|
||||
return {
|
||||
dispatchOptions: cloudSandboxAsleep
|
||||
? { ...policy.options, mode: 'forget-local' }
|
||||
: policy.options
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws (so Delete keeps the row and shows the error) when Orca Cloud can't reach this
|
||||
* workspace's sandbox as the signed-in identity. Non-cloud environments pass straight through.
|
||||
* Returns true when the sandbox is asleep: its checkout goes with the destroy, no wake needed.
|
||||
*/
|
||||
export async function assertCloudSandboxDeletable(runtimeEnvironmentId: string): Promise<boolean> {
|
||||
const preflight = window.api.ephemeralVm?.preflightCloudDelete
|
||||
if (!preflight) {
|
||||
return false
|
||||
}
|
||||
const result = await preflight({ runtimeEnvironmentId })
|
||||
if (!result.ok) {
|
||||
throw new Error(ephemeralVmErrorText(result))
|
||||
}
|
||||
return result.asleep === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The row is already gone when a sandbox destroy fails after Delete; never let that pass silently,
|
||||
* because the sandbox may still be running and billing. Offers one retry.
|
||||
*/
|
||||
export function reportCloudSandboxDestroyFailure(runtime: EphemeralVmRuntimeRecord): void {
|
||||
if (runtime.recipe?.provider !== 'orca-cloud' || runtime.status !== 'cleanup_failed') {
|
||||
return
|
||||
}
|
||||
toast.error(
|
||||
translate('auto.store.slices.worktrees.cloudDeleteFailedTitle', 'Couldn’t delete the sandbox'),
|
||||
{
|
||||
description: ephemeralVmCleanupErrorText(runtime) ?? undefined,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: translate('auto.store.slices.worktrees.cloudDeleteTryAgain', 'Try again'),
|
||||
onClick: () => {
|
||||
void window.api.ephemeralVm
|
||||
.cleanup({ runtimeId: runtime.id })
|
||||
.then(reportCloudSandboxDestroyFailure)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../../../types'
|
||||
import type { RuntimeEnvironmentCallRequest } from '../../../../runtime/rpc/testing/runtime-compatibility-test-fixture'
|
||||
import { makeWorktree } from '../worktree.test-fixture'
|
||||
import '../../../../../../shared/constants'
|
||||
import {
|
||||
createTestStore,
|
||||
mockApi,
|
||||
resetRemoteRuntimeMocks,
|
||||
resetWorktreeSliceModuleMemory,
|
||||
runtimeEnvironmentCall
|
||||
} from '../worktrees-slice-test-harness'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(resetWorktreeSliceModuleMemory)
|
||||
|
||||
function seedCloudWorkspace() {
|
||||
const store = createTestStore()
|
||||
const wt = makeWorktree({
|
||||
id: 'repo1::/vercel/sandbox/project',
|
||||
repoId: 'repo1',
|
||||
path: '/vercel/sandbox/project',
|
||||
hostId: 'runtime:cloud-env',
|
||||
runtimeOwnerEnvironmentId: 'cloud-env'
|
||||
})
|
||||
store.setState({ worktreesByRepo: { repo1: [wt] } } as Partial<AppState>)
|
||||
return { store, wt }
|
||||
}
|
||||
|
||||
function calledMethods(): string[] {
|
||||
return runtimeEnvironmentCall.mock.calls.map(
|
||||
([call]) => (call as RuntimeEnvironmentCallRequest).method
|
||||
)
|
||||
}
|
||||
|
||||
describe('Orca Cloud workspace delete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetRemoteRuntimeMocks()
|
||||
runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) =>
|
||||
Promise.resolve(
|
||||
args.method === 'repo.hooksCheck'
|
||||
? {
|
||||
id: 'rpc-hooks',
|
||||
ok: true,
|
||||
result: { hasHooks: false, hooks: null, mayNeedUpdate: false },
|
||||
_meta: { runtimeId: 'cloud-runtime' }
|
||||
}
|
||||
: { id: 'rpc-rm', ok: true, result: {}, _meta: { runtimeId: 'cloud-runtime' } }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the row and destroys nothing when Orca Cloud cannot confirm the sandbox (W9 D-2)', async () => {
|
||||
const { store, wt } = seedCloudWorkspace()
|
||||
const preflight = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'not confirmed',
|
||||
errorCode: 'cloudDeleteNotConfirmed'
|
||||
})
|
||||
Object.assign(mockApi.ephemeralVm, { preflightCloudDelete: preflight })
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(preflight).toHaveBeenCalledWith({ runtimeEnvironmentId: 'cloud-env' })
|
||||
expect(calledMethods()).not.toContain('worktree.rm')
|
||||
expect(mockApi.ephemeralVm.cleanup).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo.repo1?.map((entry) => entry.id)).toEqual([wt.id])
|
||||
})
|
||||
|
||||
it('deletes an asleep sandbox without waking it: forget the row locally, then destroy', async () => {
|
||||
const { store, wt } = seedCloudWorkspace()
|
||||
Object.assign(mockApi.ephemeralVm, {
|
||||
preflightCloudDelete: vi.fn().mockResolvedValue({ ok: true, asleep: true })
|
||||
})
|
||||
mockApi.ephemeralVm.listRuntimes.mockResolvedValue([
|
||||
{ id: 'runtime-cloud', workspaceId: wt.id, cleanupStatus: 'not_started' }
|
||||
] as never)
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: wt.id, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(calledMethods()).not.toContain('worktree.rm')
|
||||
expect(mockApi.worktrees.forgetLocal).toHaveBeenCalled()
|
||||
expect(mockApi.ephemeralVm.cleanup).toHaveBeenCalledWith({ runtimeId: 'runtime-cloud' })
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
settleLostWorktreeRemovalReply
|
||||
} from './host-worktree-removal-state'
|
||||
import { worktreeRemovalReplyTimeoutMs } from '../../../../../../shared/worktree/archive-hook-removal-gate'
|
||||
import { prepareCloudSandboxDelete } from './cloud-sandbox-delete-guard'
|
||||
|
||||
/** Carry hostId across every removal transport: repoId::path alone repeats across hosts and cannot identify the confirmed copy (STA-4343). */
|
||||
export async function dispatchWorktreeRemoval(args: {
|
||||
@@ -25,7 +26,11 @@ export async function dispatchWorktreeRemoval(args: {
|
||||
assertCurrent: () => void
|
||||
}): Promise<RemoveWorktreeResult> {
|
||||
try {
|
||||
const result = await requestWorktreeRemoval(args)
|
||||
const cloudDelete = await prepareCloudSandboxDelete(
|
||||
{ forgetLocalOnly: args.options?.mode === 'forget-local', options: args.options },
|
||||
args.target
|
||||
)
|
||||
const result = await requestWorktreeRemoval({ ...args, options: cloudDelete.dispatchOptions })
|
||||
showNestedWorktreePreservedBranchesToast(result?.nestedPreservedBranches)
|
||||
return result
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user