diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 7efb12d9b1f..b4400007cf4 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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) { diff --git a/cloud/apps/sandbox-control/test/cross-team-delete.test.ts b/cloud/apps/sandbox-control/test/cross-team-delete.test.ts new file mode 100644 index 00000000000..bec161a5814 --- /dev/null +++ b/cloud/apps/sandbox-control/test/cross-team-delete.test.ts @@ -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) + }) +}) diff --git a/src/main/hosted-sandbox/cloud-delete-preflight.test.ts b/src/main/hosted-sandbox/cloud-delete-preflight.test.ts new file mode 100644 index 00000000000..96eb80dcc89 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-delete-preflight.test.ts @@ -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 => { + 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 }) + }) +}) diff --git a/src/main/hosted-sandbox/cloud-delete-preflight.ts b/src/main/hosted-sandbox/cloud-delete-preflight.ts new file mode 100644 index 00000000000..2eb9f85621c --- /dev/null +++ b/src/main/hosted-sandbox/cloud-delete-preflight.ts @@ -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 | null +): Promise { + 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') } + } +} diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index 568afd83e39..2d1209de786 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -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 => + preflightCloudWorkspaceDelete( + cloudSandboxIdForEnvironment( + app.getPath('userData'), + String(args?.runtimeEnvironmentId ?? '') + ), + getHostedSandboxControlPlaneClient() + ) + ) + ipcMain.handle( 'ephemeralVm:cleanup', async (_event, args: { runtimeId: string }): Promise => { diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 91942f62517..b6c599f180d 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -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) { diff --git a/src/main/ipc/orca-cloud-missing-recipe.test.ts b/src/main/ipc/orca-cloud-missing-recipe.test.ts new file mode 100644 index 00000000000..035a331f896 --- /dev/null +++ b/src/main/ipc/orca-cloud-missing-recipe.test.ts @@ -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) + }) +}) diff --git a/src/main/ipc/orca-cloud-missing-recipe.ts b/src/main/ipc/orca-cloud-missing-recipe.ts new file mode 100644 index 00000000000..4bac8026c2e --- /dev/null +++ b/src/main/ipc/orca-cloud-missing-recipe.ts @@ -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 +): Promise | 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 }) + } + }) +} diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index d883172fca6..fdd00750f4f 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -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 + /** 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 /** Drops an interrupted write-ahead record that never received a recipe result. */ forgetRuntime: (args: { runtimeId: string }) => Promise diff --git a/src/preload/api/ephemeral-vm-bridge.ts b/src/preload/api/ephemeral-vm-bridge.ts index 53eb6bbabac..06027db3548 100644 --- a/src/preload/api/ephemeral-vm-bridge.ts +++ b/src/preload/api/ephemeral-vm-bridge.ts @@ -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), diff --git a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts index df18ac39ca9..aea4b541dd2 100644 --- a/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts +++ b/src/renderer/src/components/new-workspace/orca-cloud-run-target-access.ts @@ -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': diff --git a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts index 8348330997f..0bcec6bb25c 100644 --- a/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts +++ b/src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts @@ -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) => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0cf3e74ae87..ba48b07f4e8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts index e8994722b85..78b3a4a9350 100644 --- a/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts +++ b/src/renderer/src/lib/ephemeral-vm-runtime-cleanup.ts @@ -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) { diff --git a/src/renderer/src/lib/orca-cloud-select-after-sign-in.test.ts b/src/renderer/src/lib/orca-cloud-select-after-sign-in.test.ts new file mode 100644 index 00000000000..78fb817f669 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud-select-after-sign-in.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud-select-after-sign-in.ts b/src/renderer/src/lib/orca-cloud-select-after-sign-in.ts new file mode 100644 index 00000000000..9d05c7d2361 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud-select-after-sign-in.ts @@ -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 +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts b/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts new file mode 100644 index 00000000000..d80c69489d3 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/cloud-sandbox-delete-guard.ts @@ -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 { + 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) + } + } + } + ) +} diff --git a/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts b/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts new file mode 100644 index 00000000000..37cdb37c5e9 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/cloud-workspace-delete.test.ts @@ -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) + 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' }) + }) +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts index e77dd42fa52..27a75404c46 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts @@ -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 { 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) {