diff --git a/src/main/hosted-sandbox/cloud-create-from-github.ts b/src/main/hosted-sandbox/cloud-create-from-github.ts index 1809caa686c..23dca09b989 100644 --- a/src/main/hosted-sandbox/cloud-create-from-github.ts +++ b/src/main/hosted-sandbox/cloud-create-from-github.ts @@ -73,11 +73,6 @@ export async function createCloudWorkspaceFromGitHub( onProgress: (progress) => io.onLog?.(hostedProgressLine(progress)) } ) - if (io.signal?.aborted) { - // Cancelled just as it answered: nothing will open it, so it must not keep running (and billing). - await client.destroy(ready.sandbox.id).catch(() => undefined) - return { ok: false, code: 'failed', message: 'Cancelled.' } - } return { ok: true, value: { sandboxId: ready.sandbox.id } } } catch (error) { const result = hostedCreateFailure(error) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts new file mode 100644 index 00000000000..b5686f8a2ee --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.test.ts @@ -0,0 +1,105 @@ +// GH-CREATE-CANCEL: a create cancelled in flight never leaves a sandbox running, for the owned +// create and the GitHub-only create alike (both use createSandboxWithProgress). +import { describe, expect, it, vi } from 'vitest' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { HostedEntitlementDeniedError } from './hosted-environment-provider' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' +import { createSandboxWithProgress } from './hosted-sandbox-driver' + +const READY = { + sandbox: { + id: 'sbx_1', + workspaceId: 'web-1', + status: 'running' as const, + machineSize: 'standard' as const, + region: 'iad1', + session: null, + createdAt: 1 + }, + connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' } +} +const BODY = { workspaceId: 'web-1', machineSize: 'standard' as const } +const quiet = { waitMs: 0, log: () => {} } + +function client() { + return { + create: vi.fn(async () => READY), + bootPlan: vi.fn(async () => 'clone' as const), + cloneStatus: vi.fn(async () => ({ state: 'done' as const })), + destroy: vi.fn(async (id: string) => ({ id, status: 'destroyed' as const })) + } +} + +describe('reclaimCancelledCreate', () => { + it('replays the create by its key and deletes the sandbox it gets back', async () => { + const api = client() + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledWith(BODY, undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('waits while the first create is still in progress (409), then deletes', async () => { + const api = client() + api.create + .mockRejectedValueOnce(new EnvironmentProviderError('conflict', 'idempotency_in_progress')) + .mockRejectedValueOnce(new EnvironmentProviderError('unavailable', 'try later')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.create).toHaveBeenCalledTimes(3) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('a refused replay means the cancelled create made nothing either', async () => { + const api = client() + api.create.mockRejectedValueOnce(new HostedEntitlementDeniedError('denied', 'no_subscription')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true) + expect(api.destroy).not.toHaveBeenCalled() + }) + + it('gives up (false) when it can never tell', async () => { + const api = client() + api.create.mockRejectedValue(new EnvironmentProviderError('unavailable', 'down')) + expect(await reclaimCancelledCreate(api, BODY, 'create:k1', { ...quiet, attempts: 3 })).toBe( + false + ) + }) +}) + +describe('createSandboxWithProgress cancel', () => { + const spec = { workspaceId: 'web-1', machineSize: 'standard', idempotencyKey: 'create:k1' } + + it('cancelled while the create was in flight: reclaims by key, then reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + throw new EnvironmentProviderError('aborted', 'Cancelled.') + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + // The replay (no signal, same key) found the sandbox and deleted it. + expect(api.create).toHaveBeenLastCalledWith(expect.anything(), undefined, 'create:k1') + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled just as the create answered: deletes the sandbox and reports the cancel', async () => { + const api = client() + const controller = new AbortController() + api.create.mockImplementationOnce(async () => { + controller.abort() + return READY + }) + await expect( + createSandboxWithProgress(api, spec, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 'aborted' }) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + expect(api.create).toHaveBeenCalledTimes(1) + }) + + it('not cancelled: no replay, no delete', async () => { + const api = client() + await expect(createSandboxWithProgress(api, spec, {})).resolves.toBe(READY) + expect(api.create).toHaveBeenCalledTimes(1) + expect(api.destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts new file mode 100644 index 00000000000..343360ff400 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-sandbox-create-reclaim.ts @@ -0,0 +1,64 @@ +// A create cancelled while its request was in flight may still have made a sandbox on the control +// plane, and a sandbox bills (GH-CREATE-CANCEL). The control plane keys creates by Idempotency-Key +// and body: sending the same create again hands back the sandbox the first one made (or makes one, if +// the first never arrived), and that sandbox is deleted. Shared by the owned create (the recipe) and +// the GitHub-only create, which both go through createSandboxWithProgress. +import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' + +type CreateBody = Parameters[0] + +const RECLAIM_ATTEMPTS = 8 +const RECLAIM_WAIT_MS = 5_000 + +/** The first create may still be running (409 in progress) or the service briefly unreachable. */ +function worthRetrying(error: unknown): boolean { + return ( + isEnvironmentProviderError(error, 'conflict') || + isEnvironmentProviderError(error, 'unavailable') || + isEnvironmentProviderError(error, 'timeout') || + !isEnvironmentProviderError(error) + ) +} + +/** + * Deletes whatever the cancelled create made. True once it is gone (or the control plane refused + * the create, so nothing was made); false when it still couldn't tell after every attempt. + */ +export async function reclaimCancelledCreate( + client: Pick, + body: CreateBody, + idempotencyKey: string, + { + attempts = RECLAIM_ATTEMPTS, + waitMs = RECLAIM_WAIT_MS, + log = (message: string) => console.warn(message) + }: { attempts?: number; waitMs?: number; log?: (message: string) => void } = {} +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + let sandboxId: string + try { + // No signal: this request is the cleanup and must not be cancelled with the create. + sandboxId = (await client.create(body, undefined, idempotencyKey)).sandbox.id + } catch (error) { + if (!worthRetrying(error)) { + // Refused (billing, sign-in, bad request): the cancelled create made nothing either. + return true + } + log( + `[orca-cloud] cancelled create: replay ${attempt}/${attempts} failed (${isEnvironmentProviderError(error) ? error.code : 'error'})` + ) + await new Promise((resolve) => setTimeout(resolve, waitMs)) + continue + } + try { + await client.destroy(sandboxId) + log(`[orca-cloud] cancelled create: deleted sandbox ${sandboxId}`) + return true + } catch { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + } + log('[orca-cloud] cancelled create: could not confirm the sandbox was deleted') + return false +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-driver.ts b/src/main/hosted-sandbox/hosted-sandbox-driver.ts index cb0ccc76f5c..088f92f5b89 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-driver.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-driver.ts @@ -25,6 +25,7 @@ import type { } from './hosted-environment-provider' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { takeSuspendReason } from './idle-sleep-requests' +import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim' export const HOSTED_SANDBOX_DRIVER_ID = 'orca-cloud' @@ -144,21 +145,34 @@ export async function createSandboxWithProgress( ) .catch(() => undefined) } - const ready = await client.create( - { - workspaceId: spec.workspaceId, - machineSize, - // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). - ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), - ...(spec.region ? { region: spec.region } : {}), - ...(spec.repo ? { repo: spec.repo } : {}) - }, - ctx?.signal, - spec.idempotencyKey - ) + const body = { + workspaceId: spec.workspaceId, + machineSize, + // Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8). + ...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}), + ...(spec.region ? { region: spec.region } : {}), + ...(spec.repo ? { repo: spec.repo } : {}) + } + let ready: HostedSandboxReadyResponse + try { + ready = await client.create(body, ctx?.signal, spec.idempotencyKey) + } catch (error) { + // GH-CREATE-CANCEL: cancelled in flight, the control plane may still have made it: find it by + // its key and delete it before reporting the cancel. + if (ctx?.signal?.aborted && spec.idempotencyKey) { + await reclaimCancelledCreate(client, body, spec.idempotencyKey) + } + throw error + } if (ready.clone) { + // A cancelled, failed or stalled clone deletes the sandbox itself. await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' }) } + if (ctx?.signal?.aborted) { + // Cancelled just as it answered: nothing will use it, so it must not keep running (and billing). + await client.destroy(ready.sandbox.id).catch(() => undefined) + throw new EnvironmentProviderError('aborted', 'Cancelled.') + } ctx?.onProgress?.({ phase: 'connecting' }) return ready } diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts new file mode 100644 index 00000000000..d64be4157e1 --- /dev/null +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-share-link.ts @@ -0,0 +1,68 @@ +// A cloud workspace's share link: the org slug + W-number link when known, else the sandbox-id form. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' +import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' + +// How long Copy link waits on the control plane before settling for the sandbox-id link. +const LINK_LOOKUP_MS = 3_000 + +async function lookUpLink(id: string): Promise { + let timer: ReturnType | undefined + try { + const fetched = await Promise.race([ + window.api.cloudWorkspaces.get(id), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) + }) + ]) + if (!fetched?.ok) { + return null + } + rememberCloudWorkspaces([fetched.value]) + return fetched.value.link ?? null + } catch { + return null + } finally { + clearTimeout(timer) + } +} + +/** + * The org slug + W-number link when the control plane gave one, else the sandbox id form, which + * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, + * offline, signed out) still gets a working link. + */ +export async function shareLinkFor(id: string, chatId?: string | null): Promise { + const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) + return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) +} + +export async function copyCloudWorkspaceLink( + id: string, + chatId?: string | null, + options: { quiet?: boolean } = {} +): Promise { + const link = await shareLinkFor(id, chatId) + try { + // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a + // focused document with live user activation; after the link lookup (or from the menu or a + // shortcut) Electron rejects it with "Document is not focused". + await window.api.ui.writeClipboardText(link) + } catch (error) { + console.warn('[cloud-workspaces] copy link failed', error) + toast.error( + translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") + ) + return false + } + if (options.quiet) { + return true + } + toast.success( + chatId + ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') + : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') + ) + return true +} diff --git a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts index e2b376a817e..0ae44d82588 100644 --- a/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts +++ b/src/renderer/src/components/cloud-workspaces/open-cloud-workspace.ts @@ -11,8 +11,6 @@ import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-a import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler' import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result' import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire' -import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity' import { getWorktreeExecutionHostId, toRuntimeExecutionHostId @@ -66,7 +64,20 @@ function isPairingRejected(error: unknown): boolean { return error instanceof Error && /\b(unauthori[sz]ed|device_revoked)\b/i.test(error.message) } -async function connectAndReveal(environmentId: string, name: string): Promise { +export type OpenCloudWorkspaceOptions = { + /** + * The server this open connects to, before any of its rows load. A GitHub-only create's sheet + * hides that server's rows until it hands off to them, so its pending row never doubles up. + */ + onHost?: (hostId: string) => void +} + +async function connectAndReveal( + environmentId: string, + name: string, + options: OpenCloudWorkspaceOptions = {} +): Promise { + options.onHost?.(toRuntimeExecutionHostId(environmentId)) const environments = await window.api.runtimeEnvironments.list() useAppStore.getState().setRuntimeEnvironments(environments) const environment = environments.find((e) => e.id === environmentId) @@ -147,7 +158,11 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise { +async function pairAndReveal( + workspace: CloudWorkspace, + name: string, + options: OpenCloudWorkspaceOptions +): Promise { const api = window.api.cloudWorkspaces const binding = (await api.environmentBindings())[workspace.id] if (binding) { @@ -161,7 +176,7 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise undefined) - return await connectAndReveal(added.environment.id, name) + return await connectAndReveal(added.environment.id, name, options) } -async function openOnce(workspace: CloudWorkspace): Promise { +async function openOnce( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions +): Promise { const name = cloudWorkspaceTitle(workspace) try { if (workspace.access === 'owner') { @@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise { ) } } - return await pairAndReveal(workspace, name) + return await pairAndReveal(workspace, name, options) } catch (error) { // Raw transport/RPC messages are not user copy; keep them for diagnostics only. console.warn('[cloud-workspaces] open failed', error) @@ -262,75 +280,18 @@ async function openOnce(workspace: CloudWorkspace): Promise { // instead of minting parallel grants. const opening = new Map>() -export function openCloudWorkspace(workspace: CloudWorkspace): Promise { +export function openCloudWorkspace( + workspace: CloudWorkspace, + options: OpenCloudWorkspaceOptions = {} +): Promise { const existing = opening.get(workspace.id) if (existing) { return existing } - const attempt = openOnce(workspace).finally(() => opening.delete(workspace.id)) + const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id)) opening.set(workspace.id, attempt) return attempt } -// How long Copy link waits on the control plane before settling for the sandbox-id link. -const LINK_LOOKUP_MS = 3_000 - -async function lookUpLink(id: string): Promise { - let timer: ReturnType | undefined - try { - const fetched = await Promise.race([ - window.api.cloudWorkspaces.get(id), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS) - }) - ]) - if (!fetched?.ok) { - return null - } - rememberCloudWorkspaces([fetched.value]) - return fetched.value.link ?? null - } catch { - return null - } finally { - clearTimeout(timer) - } -} - -/** - * The org slug + W-number link when the control plane gave one, else the sandbox id form, which - * opens for anyone with access. Never rejects: a device that didn't create the workspace (paired, - * offline, signed out) still gets a working link. - */ -export async function shareLinkFor(id: string, chatId?: string | null): Promise { - const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id)) - return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId) -} - -export async function copyCloudWorkspaceLink( - id: string, - chatId?: string | null, - options: { quiet?: boolean } = {} -): Promise { - const link = await shareLinkFor(id, chatId) - try { - // Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a - // focused document with live user activation; after the link lookup (or from the menu or a - // shortcut) Electron rejects it with "Document is not focused". - await window.api.ui.writeClipboardText(link) - } catch (error) { - console.warn('[cloud-workspaces] copy link failed', error) - toast.error( - translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.") - ) - return false - } - if (options.quiet) { - return true - } - toast.success( - chatId - ? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied') - : translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied') - ) - return true -} +// Copy link lives beside it; re-exported so callers keep one import. +export { copyCloudWorkspaceLink, shareLinkFor } from './cloud-workspace-share-link' diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts new file mode 100644 index 00000000000..b2bab1da297 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/projectless-pending-row.test.ts @@ -0,0 +1,51 @@ +// GH-CREATE-NO-ROW: a create with no project here yet (a GitHub-only Orca Cloud create) has a +// pending row atop the list when grouped by project, as in every other grouping; a create with a +// project still sits atop its own project. +import { describe, expect, it } from 'vitest' +import { repo, worktree } from './worktree-list-groups-test-fixtures' +import { buildRows } from './build-rows.test-fixture' + +function rows(groupBy: 'repo' | 'none', pending: { creationId: string; repoId: string }[]) { + return buildRows( + groupBy, + [{ ...worktree, lastActivityAt: 1 }], + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + 'manual', + {}, + undefined, + false, + undefined, + [], + new Set(), + new Map(), + new Map(), + pending + ) +} + +describe('pending row for a create with no project yet', () => { + it('by project: sits atop the list, before the first project header', () => { + const list = rows('repo', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + // No blank project header for it (it used to get one, keyed "repo:"). + expect(list.filter((row) => row.type === 'header')).toHaveLength(1) + }) + + it('by project: a create with a project still sits atop that project only', () => { + const list = rows('repo', [{ creationId: 'local-1', repoId: repo.id }]) + const header = list.findIndex((row) => row.type === 'header') + expect(header).toBeGreaterThanOrEqual(0) + expect(list.findIndex((row) => row.type === 'pending-creation')).toBeGreaterThan(header) + expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1) + }) + + it('ungrouped: unchanged, every pending row on top', () => { + const list = rows('none', [{ creationId: 'gh-1', repoId: '' }]) + expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx new file mode 100644 index 00000000000..38ef8a2cc50 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/PendingWorktreeRowGitHub.fixture.tsx @@ -0,0 +1,62 @@ +// GH-CREATE-NO-ROW: a GitHub-only Orca Cloud create has a sidebar row while it builds, atop the list +// (it has no project here yet), with the cloud glyph from the start; the paired workspace's rows +// replace it on hand-off. +import { getDefaultSettings } from '../../../../../../shared/constants' +import { defineFixture } from '../../../../component-fixtures/component-fixture' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { PendingWorktreeRow } from './PendingWorktreeRow' + +const T0 = 1_700_000_000_000 + +function creation( + creationId: string, + patch: Partial = {} +): PendingWorktreeCreation { + return { + creationId, + phase: 'provisioning-vm', + status: 'creating', + startedAt: T0, + indeterminate: false, + loaderVisible: true, + provisioningLog: '[orca-cloud] provisioning\n[orca-cloud] plan-clone', + provisioningLogStamps: [T0, T0 + 400], + request: githubCreateRequest({ + repoUrl: 'https://github.com/nwparker/orca-sandbox-push-test', + fullName: 'nwparker/orca-sandbox-push-test', + prompt: 'Add a push test', + agent: 'claude', + composerRepoId: 'repo' + }), + ...patch + } +} + +const rows = { + building: creation('building'), + opening: creation('opening', { phase: 'creating' }), + failed: creation('failed', { + status: 'error', + error: 'Orca Cloud workspaces need a plan with cloud hours.', + errorCode: 'cloudPlanRequired', + provisioningLog: '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted' + }) +} + +/** The sidebar's top while three GitHub-only creates are in flight: building, opening, refused. */ +export const githubOnlyPendingRows = defineFixture({ + width: 320, + store: { + settings: getDefaultSettings('/home/fixture'), + pendingWorktreeCreations: rows, + activePendingCreationId: 'building' + }, + render: () => ( +
+ {Object.keys(rows).map((creationId) => ( + + ))} +
+ ) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts new file mode 100644 index 00000000000..216791fa2c3 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.cancel.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom +// GH-CREATE-CANCEL and GH-CREATE-NO-ROW for a GitHub-only Orca Cloud create: Cancel at any step +// leaves no workspace (the sandbox is deleted, which drops the pairing too), and a create that +// opens hands its pending row off to the paired server's rows, which stay hidden until then. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +type OpenOptions = { onHost?: (hostId: string) => void } +type OpenOutcome = { ok: true; worktreeId: string | null; hostId?: string } + +const mocks = vi.hoisted(() => ({ + open: vi.fn<(workspace: unknown, options?: OpenOptions) => Promise>(), + launch: vi.fn() +})) + +vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({ + openCloudWorkspace: mocks.open +})) +vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({ + launchAgentInNewTabWithOutcome: mocks.launch +})) + +import { startCloudWorkspaceFromGitHub } from './start-cloud-workspace-from-github' +import { resetGitHubCreateKeysForTest } from './github-cloud-create-key' + +const createFromGitHub = vi.fn<(args: unknown) => Promise>() +const destroy = vi.fn(async () => ({ ok: true, value: undefined })) + +beforeEach(() => { + resetGitHubCreateKeysForTest() + useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null }) + createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } }) + mocks.open.mockResolvedValue({ ok: true, worktreeId: null }) + Object.assign(window, { + api: { + ...window.api, + orcaCloudWorkspace: { createFromGitHub }, + cloudWorkspaces: { + get: vi.fn(async (id: string) => ({ ok: true, value: { id } })), + destroy, + localWorkspaces: vi.fn(async () => []), + environmentBindings: vi.fn(async () => ({})) + }, + ephemeralVm: { + onProvisionEvent: () => () => {}, + cancelProvision: vi.fn(async () => ({ cancelled: true })), + // Cancel's cleanup looks for this create's runtime records; a GitHub-only create has none. + listRuntimes: vi.fn(async () => []) + } + } + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +const REQUEST = { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: '', + agent: null, + composerRepoId: 'repo-1' +} + +const creationId = (): string => + Object.keys(useAppStore.getState().pendingWorktreeCreations)[0] ?? '' +const cancel = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId()) + +describe('Cancel leaves no workspace (GH-CREATE-CANCEL)', () => { + it('cancelled after the sandbox started, before it opened: deleted, never opened', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: true, value: { sandboxId: 'sbx_1' } } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(mocks.open).not.toHaveBeenCalled() + expect(destroy).toHaveBeenCalledWith('sbx_1') + }) + + it('cancelled while it opened: the sandbox is deleted (its pairing and row go with it)', async () => { + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + cancel() + return { ok: true, worktreeId: 'w1', hostId: 'runtime:p' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(destroy).toHaveBeenCalledWith('sbx_1') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('cancelled while the create was in flight: main reclaims it; nothing opens here', async () => { + createFromGitHub.mockImplementationOnce(async () => { + cancel() + return { ok: false, code: 'failed', message: 'Cancelled.' } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(window.api.ephemeralVm.cancelProvision).toHaveBeenCalled() + expect(mocks.open).not.toHaveBeenCalled() + }) +}) + +describe('pending row hand-off (GH-CREATE-NO-ROW)', () => { + it('records the paired server while opening, and the hand-off drops the pending row', async () => { + let pairingHostIdDuringOpen: string | undefined + mocks.open.mockImplementationOnce(async (_workspace, options) => { + options?.onHost?.('runtime:p') + pairingHostIdDuringOpen = + useAppStore.getState().pendingWorktreeCreations[creationId()]?.pairingHostId + return { ok: true, worktreeId: null } + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(true) + expect(pairingHostIdDuringOpen).toBe('runtime:p') + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + expect(destroy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts index 982b05ebc14..dd3fdd7000e 100644 --- a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.ts @@ -9,6 +9,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace' +import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity' import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' @@ -87,7 +88,12 @@ export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest): }) store.setActiveView('terminal') store.setSidebarOpen(true) - return runGitHubCloudCreate(creationId) + return runTracked(creationId) +} + +/** Cancel's "Deleting the sandbox…" waits for every step that may still own a sandbox. */ +function runTracked(creationId: string): Promise { + return trackOrcaCloudCreateStep(creationId, runGitHubCloudCreate(creationId)) } /** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */ @@ -114,7 +120,7 @@ export function retryGitHubCloudCreate(creationId: string): void { }) store.setActivePendingWorktreeCreation(creationId) store.setActiveView('terminal') - void runGitHubCloudCreate(creationId) + void runTracked(creationId) } function current(creationId: string) { @@ -186,13 +192,27 @@ async function runGitHubCloudCreate(creationId: string): Promise { return false } if (!current(creationId)) { - // Cancelled while it started: main stopped the create and deleted what it had made. - startedSandboxes.delete(creationId) + // GH-CREATE-CANCEL: cancelled after the sandbox started but before it opened. + await discardSandbox(creationId, sandboxId) return false } useAppStore.getState().updatePendingWorktreeCreation(creationId, { phase: 'creating' }) const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null) - const opened = workspace?.ok ? await openCloudWorkspace(workspace.value) : null + const opened = workspace?.ok + ? await openCloudWorkspace(workspace.value, { + // GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off. + onHost: (hostId) => + useAppStore + .getState() + .updatePendingWorktreeCreation(creationId, { pairingHostId: hostId }) + }) + : null + if (!current(creationId)) { + // GH-CREATE-CANCEL: cancelled while it opened. Opening can't be stopped half way, so the + // sandbox is deleted, which also drops this computer's pairing and its row. + await discardSandbox(creationId, sandboxId) + return false + } if (!opened?.ok) { // The sandbox exists from here on: Retry opens it and never creates it again. return fail( @@ -219,3 +239,14 @@ async function runGitHubCloudCreate(creationId: string): Promise { }) return true } + +/** A cancelled create's sandbox: deleted, so nothing keeps running (or billing) behind the cancel. */ +async function discardSandbox(creationId: string, sandboxId: string): Promise { + startedSandboxes.delete(creationId) + forgetGitHubCreateKey(creationId) + const result = await window.api.cloudWorkspaces.destroy(sandboxId).catch(() => null) + if (!result?.ok) { + console.warn('[orca-cloud] cancelled GitHub create: could not delete sandbox', sandboxId) + } + void refreshCloudWorktreeBindings().catch(() => undefined) +} diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts index 3e296c962c2..39cc60b4eff 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.test.ts @@ -125,3 +125,26 @@ describe('paired workspaces scoped by account (PAIRED-ACCOUNT-FILTER)', () => { expect(hostsFor('team-two', paired()).hiddenHostIds.has('runtime:gh')).toBe(false) }) }) + +describe('GH-CREATE-NO-ROW hand-off', () => { + it('hides the server a GitHub-only create is pairing to until its pending row hands off', () => { + const pairing = (pairingHostId?: string) => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only these fields. + ({ + runtimeEnvironments: [{ id: 'p', name: 'web (cloud)', orcaCloudPaired: true }], + orcaProfileAuthStatus: { cloud: { userId: 'u1' } }, + pendingWorktreeCreations: { + gh: { + creationId: 'gh', + request: { githubSource: { fullName: 'acme/web' } }, + ...(pairingHostId ? { pairingHostId } : {}) + } + } + }) as unknown as Pick + const hidden = (state: Pick) => [ + ...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds + ] + expect(hidden(pairing())).toEqual([]) + expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p']) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts index ac77b801dd3..538b4601197 100644 --- a/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts +++ b/src/renderer/src/lib/orca-cloud/use-sidebar-hidden-checkouts.ts @@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime import type { Repo } from '../../../../shared/repo-types' type CloudScopeState = Pick & - Partial> + Partial< + Pick< + AppState, + 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId' | 'pendingWorktreeCreations' + > + > /** * Every Orca Cloud server this desktop runs, `+host` when the active team owns it and `-host` @@ -30,7 +35,12 @@ type CloudScopeState = Pick & export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const active = orcaCloudAccountId(state.orcaProfileAuthStatus?.cloud) const owners = state.cloudAccountIdByEnvironmentId ?? {} - return (state.runtimeEnvironments ?? []) + // GH-CREATE-NO-ROW: a GitHub-only create's pending row stands for its server's rows until the + // hand-off removes it; both change in one store update, so the row never shows twice. + const handingOff = Object.values(state.pendingWorktreeCreations ?? {}).flatMap((entry) => + entry.request.githubSource && entry.pairingHostId ? [`-${entry.pairingHostId}`] : [] + ) + const scoped = (state.runtimeEnvironments ?? []) .filter((environment) => isOrcaCloudRuntimeEnvironment(environment)) .flatMap((environment) => { const hostId = toRuntimeExecutionHostId(environment.id) @@ -41,7 +51,7 @@ export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string { const inTeam = active !== null && owners[environment.id] === active return [`${inTeam ? '+' : '-'}${hostId}`] }) - .join('\n') + return [...scoped, ...handingOff].join('\n') } export function parseOrcaCloudHostScopeKey(key: string): { diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index 6b8a9f043b4..d7b4b35f343 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -195,6 +195,8 @@ export type PendingWorktreeCreation = { resumedAt?: number /** VM creates: the provisioned VM's runtime environment, known once it answers. */ vmEnvironmentId?: string + /** GitHub-only Orca Cloud create: the server it is pairing to; its rows wait for the hand-off. */ + pairingHostId?: string // Local git checkout meter; absent when git emits none or creation runs over SSH/runtime. checkoutProgress?: WorktreeCheckoutProgress /** Phases entered so far with their start times, so the chat checklist can time each step. */ diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts index 177eb7bf2f6..3ac855a0cf8 100644 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts @@ -149,13 +149,7 @@ export function createRemovePendingWorktreeCreation( .catch(() => undefined) } const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get) - // A GitHub-only create past its start is opening a sandbox that stays; nothing is deleted. - const opening = Boolean(removedEntry.request.githubSource) && removedEntry.phase === 'creating' - if ( - removedEntry.status === 'creating' && - isOrcaCloudCreateRequest(removedEntry.request) && - !opening - ) { + if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) { beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], { runtimeId: removedEntry.request.ephemeralVmRuntimeId, createStartedAt: removedEntry.startedAt, diff --git a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts index f55349c25be..8fc823291c3 100644 --- a/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts +++ b/src/renderer/src/store/slices/worktrees/worktree-slice-contract.ts @@ -186,6 +186,7 @@ export type WorktreeSlice = { pausedMs?: number resumedAt?: number vmEnvironmentId?: string + pairingHostId?: string checkoutProgress?: PendingWorktreeCreation['checkoutProgress'] worktreeId?: string heldChatMessage?: string diff --git a/src/shared/sidebar-list/sidebar-row-plan.ts b/src/shared/sidebar-list/sidebar-row-plan.ts index 68dbcda8e62..195a5d30651 100644 --- a/src/shared/sidebar-list/sidebar-row-plan.ts +++ b/src/shared/sidebar-list/sidebar-row-plan.ts @@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) { const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items) const render = (collapsedGroups: Set): Row[] => { const result: Row[] = [] - if (groupBy !== 'repo') { - for (const creation of pendingCreations) { + // By project, a create sits atop its own project; one with no project here yet (a GitHub-only + // Orca Cloud create, GH-CREATE-NO-ROW) sits atop the list, as in every other grouping. + for (const creation of pendingCreations) { + if (groupBy !== 'repo' || !repoMap.has(creation.repoId)) { result.push(buildPendingCreationRow(creation, repoMap)) } } diff --git a/src/shared/sidebar-list/worktree-grouping.ts b/src/shared/sidebar-list/worktree-grouping.ts index ad1d8dd639b..387aa316f96 100644 --- a/src/shared/sidebar-list/worktree-grouping.ts +++ b/src/shared/sidebar-list/worktree-grouping.ts @@ -71,7 +71,8 @@ export function buildOrderedGroups(args: { ([repoId, candidate]) => [repoId, candidate.repo] as const ) ], - pendingRepoIds: pendingByRepo.keys(), + // A create with no project here yet sits atop the list instead (GH-CREATE-NO-ROW). + pendingRepoIds: [...pendingByRepo.keys()].filter((repoId) => repoMap.has(repoId)), repoOrder, projectOrderBy })