diff --git a/src/main/hosted-sandbox/cloud-create-from-github.test.ts b/src/main/hosted-sandbox/cloud-create-from-github.test.ts new file mode 100644 index 00000000000..1783d99d019 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-create-from-github.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { HostedEntitlementDeniedError } from './hosted-environment-provider' +import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver' +import { + createCloudWorkspaceFromGitHub, + githubWorkspaceId, + type CreateFromGitHubClient +} from './cloud-create-from-github' + +const READY = { + sandbox: { + id: 'sbx_1', + workspaceId: 'web-00000000', + status: 'running' as const, + machineSize: 'standard' as const, + region: 'iad1', + session: null, + createdAt: 1 + }, + connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' } +} + +function client(overrides: Partial = {}): CreateFromGitHubClient { + 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 })), + ...overrides + } +} + +const INPUT = { + slug: 'acme/web', + title: 'Fix login', + provisionId: 'c-1234', + idempotencyKey: 'create:c-1234' +} + +describe('GitHub-only create on the build sheet (GH-CREATE-SHEET)', () => { + it('reports the same steps as an owned create: start, boot plan, clone, connecting', async () => { + const statuses = [ + { state: 'running' as const, percent: 40, stage: 'download' as const }, + { state: 'done' as const } + ] + const api = client({ + create: vi.fn(async () => ({ ...READY, clone: { state: 'running' as const } })), + cloneStatus: vi.fn(async () => statuses.shift() ?? { state: 'done' as const }) + }) + const log: string[] = [] + const result = await createCloudWorkspaceFromGitHub(api, INPUT, { + onLog: (chunk) => log.push(chunk) + }) + expect(result).toEqual({ ok: true, value: { sandboxId: 'sbx_1' } }) + expect(log[0]).toBe('[orca-cloud] provisioning\n') + expect(log).toContain('[orca-cloud] plan-clone\n') + expect(log).toContain('[orca-cloud] cloning\n') + expect(log).toContain('[orca-cloud] clone-progress 40 download\n') + expect(log.at(-1)).toBe('[orca-cloud] connecting\n') + }) + + it('sends the key and a workspace id fixed per creation, so a Retry replays the same create', async () => { + const api = client() + await createCloudWorkspaceFromGitHub(api, INPUT) + await createCloudWorkspaceFromGitHub(api, INPUT) + const [first, second] = vi.mocked(api.create).mock.calls + expect(first?.[0]).toEqual({ + workspaceId: githubWorkspaceId('acme/web', 'c-1234'), + machineSize: 'standard', + title: 'Fix login', + repo: { url: 'https://github.com/acme/web.git' } + }) + expect(first?.[2]).toBe('create:c-1234') + // Same body both times: the server replays an Idempotency-Key only for the same request. + expect(second?.[0]).toEqual(first?.[0]) + expect(githubWorkspaceId('acme/web', 'c-1234')).toMatch(/^web-[0-9a-f]{8}$/) + expect(githubWorkspaceId('acme/web', 'c-5678')).not.toBe( + githubWorkspaceId('acme/web', 'c-1234') + ) + }) + + it.each([ + ['no_subscription'], + ['included_hours_exhausted'], + ['overage_cap_reached'], + ['payment_past_due'] + ])( + 'a billing refusal (%s) logs its reason line and comes back as cloudPlanRequired', + async (reason) => { + const api = client({ + create: vi.fn(async () => { + throw new HostedEntitlementDeniedError('denied', reason) + }) + }) + const log: string[] = [] + const result = await createCloudWorkspaceFromGitHub(api, INPUT, { + onLog: (chunk) => log.push(chunk) + }) + // The sheet classifies these exactly as an owned create's (one shared classifier). + expect(log).toContain(`Orca Cloud: ${reason}\n`) + expect(result).toMatchObject({ + ok: false, + code: 'entitlement_denied', + reason: 'cloudPlanRequired' + }) + } + ) + + it('an unreachable control plane reads as unavailable', async () => { + const api = client({ + create: vi.fn(async () => { + throw new EnvironmentProviderError('unavailable', 'Orca Cloud is unreachable.') + }) + }) + const result = await createCloudWorkspaceFromGitHub(api, INPUT) + expect(result).toMatchObject({ ok: false, code: 'unavailable', reason: 'cloudUnavailable' }) + }) + + it('a create that answers after Cancel deletes its sandbox instead of leaving it running', async () => { + const controller = new AbortController() + const api = client({ + create: vi.fn(async () => { + controller.abort() + return READY + }) + }) + const result = await createCloudWorkspaceFromGitHub(api, INPUT, { signal: controller.signal }) + expect(result).toMatchObject({ ok: false }) + expect(api.destroy).toHaveBeenCalledWith('sbx_1') + }) +}) diff --git a/src/main/hosted-sandbox/cloud-create-from-github.ts b/src/main/hosted-sandbox/cloud-create-from-github.ts new file mode 100644 index 00000000000..1809caa686c --- /dev/null +++ b/src/main/hosted-sandbox/cloud-create-from-github.ts @@ -0,0 +1,97 @@ +// "Start in Orca Cloud" for a GitHub repository with no clone here (cloud-start D4), with the same +// build sheet as an owned create (GH-CREATE-SHEET): the same progress lines (boot plan, start, +// clone or update with git's percent) and the same failure lines and codes, so the window's sheet +// reads both alike. +import { createHash, randomBytes } from 'node:crypto' +import type { + CloudWorkspaceErrorCode, + CloudWorkspaceResult +} from '../../shared/hosted-sandbox/cloud-workspace-api' +import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' +import { createSandboxWithProgress } from './hosted-sandbox-driver' +import { hostedCreateFailure, hostedProgressLine } from './hosted-sandbox-recipe' + +/** The server's Idempotency-Key format. */ +export const CLOUD_CREATE_IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{8,128}$/ +/** A creation id from the window: the build sheet's log and its Cancel are keyed by it. */ +export const CLOUD_CREATE_PROVISION_ID = /^[A-Za-z0-9_.:-]{1,128}$/ + +export type CreateFromGitHubInput = { + /** `owner/repo`. */ + slug: string + /** The name typed in the composer; empty for none. */ + title: string + provisionId: string | null + idempotencyKey: string | null +} + +export type CreateFromGitHubClient = Pick< + HostedSandboxControlPlaneClient, + 'create' | 'bootPlan' | 'cloneStatus' | 'destroy' +> + +/** + * The workspace id: `-`, like the phone's. Fixed per creation so a Retry sends the same + * body as the first attempt; the server replays an Idempotency-Key only for the same body. + */ +export function githubWorkspaceId(slug: string, provisionId: string | null): string { + const name = (slug.split('/').pop() ?? 'workspace').replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 48) + const suffix = provisionId + ? createHash('sha256').update(provisionId).digest('hex').slice(0, 8) + : randomBytes(4).toString('hex') + return `${name}-${suffix}` +} + +const CODE_BY_FAILURE: Partial> = { + cloudSignInRequired: 'unauthorized', + cloudPlanRequired: 'entitlement_denied', + cloudConcurrencyLimit: 'entitlement_denied', + cloudUnavailable: 'unavailable' +} + +/** + * Starts the sandbox and waits for its clone, reporting each step through `onLog`. A failure comes + * back with the failure code as `reason` (e.g. `cloudPlanRequired`) after its reason line was logged. + */ +export async function createCloudWorkspaceFromGitHub( + client: CreateFromGitHubClient, + input: CreateFromGitHubInput, + io: { signal?: AbortSignal; onLog?: (chunk: string) => void } = {} +): Promise> { + try { + const ready = await createSandboxWithProgress( + client, + { + workspaceId: githubWorkspaceId(input.slug, input.provisionId), + machineSize: 'standard', + repo: { url: `https://github.com/${input.slug}.git` }, + ...(input.title ? { workspaceName: input.title } : {}), + ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}) + }, + { + ...(io.signal ? { signal: io.signal } : {}), + 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) + if (result.stderr) { + io.onLog?.(result.stderr) + } + const errorCode = result.failure?.errorCode ?? null + return { + ok: false, + code: (errorCode ? CODE_BY_FAILURE[errorCode] : undefined) ?? 'failed', + message: + result.failure?.error ?? + (result.stderr.trim() || (error instanceof Error ? error.message : String(error))), + reason: errorCode + } + } +} diff --git a/src/main/hosted-sandbox/hosted-account-ipc.test.ts b/src/main/hosted-sandbox/hosted-account-ipc.test.ts index b4df5abe6c5..f0249045c51 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.test.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.test.ts @@ -38,7 +38,13 @@ const REPO = 'https://github.com/acme/web' const githubUser = { githubUser: async () => ({ connected: false as const }), connectGitHubUser: async () => ({ authorizeUrl: 'https://github.com/login/oauth/authorize' }), - disconnectGitHubUser: async () => ({ connected: false as const }) + disconnectGitHubUser: async () => ({ connected: false as const }), + // The GitHub-only create's progress (GH-CREATE-SHEET): an older control plane has no boot plan. + bootPlan: async (): Promise<'clone' | 'update'> => { + throw new Error('not_found') + }, + cloneStatus: async () => ({ state: 'done' as const }), + destroy: async (id: string) => ({ id, status: 'destroyed' as const }) } const unusedCreate = async (): Promise => { throw new Error('not called') @@ -72,11 +78,15 @@ it('starts a sandbox that clones a GitHub-only repository (D4), and refuses anyt ok: true, value: { sandboxId: 'sbx_1' } }) - expect(create).toHaveBeenLastCalledWith({ - workspaceId: expect.stringMatching(/^web-[0-9a-f]{8}$/), - machineSize: 'standard', - repo: { url: 'https://github.com/acme/web.git' } - }) + expect(create).toHaveBeenLastCalledWith( + { + workspaceId: expect.stringMatching(/^web-[0-9a-f]{8}$/), + machineSize: 'standard', + repo: { url: 'https://github.com/acme/web.git' } + }, + undefined, + undefined + ) expect( await handlers.get(CH.createFromGitHub)?.({}, { repoUrl: 'https://gitlab.com/acme/web' }) ).toMatchObject({ ok: false }) @@ -108,13 +118,15 @@ it('a GitHub-only create keeps the typed name as the workspace title (NAME-DROPP { repoIds: async () => null } ) await handlers.get(CH.createFromGitHub)?.({}, { repoUrl: REPO, title: ' ik-paired ' }) - expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'ik-paired' })) + expect(create.mock.lastCall?.[0]).toEqual(expect.objectContaining({ title: 'ik-paired' })) // No name typed: no title (lists fall back to the repository's name), never an empty one. await handlers.get(CH.createFromGitHub)?.({}, { repoUrl: REPO, title: ' ' }) - expect(create).toHaveBeenLastCalledWith(expect.not.objectContaining({ title: expect.anything() })) + expect(create.mock.lastCall?.[0]).toEqual( + expect.not.objectContaining({ title: expect.anything() }) + ) // The control plane caps titles at 120. await handlers.get(CH.createFromGitHub)?.({}, { repoUrl: REPO, title: 'x'.repeat(200) }) - expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'x'.repeat(120) })) + expect(create.mock.lastCall?.[0]).toEqual(expect.objectContaining({ title: 'x'.repeat(120) })) }) const SESSION = 'cs_abcdefghijklmnopqrstuv' diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts index 90721076d6b..cc217b53bd7 100644 --- a/src/main/hosted-sandbox/hosted-account-ipc.ts +++ b/src/main/hosted-sandbox/hosted-account-ipc.ts @@ -1,12 +1,17 @@ // Account-level Orca Cloud calls for the window: product config (measured timings), GitHub connect, // repository access (cloud-start N2) and the repositories the account's GitHub installations reach. -import { randomBytes } from 'node:crypto' import { ipcMain, shell } from 'electron' import { ORCA_CLOUD_WORKSPACE_CHANNELS as CH } from '../../shared/hosted-sandbox/cloud-workspace-api' import type { HostedSandboxConfigResponse } from '../../shared/hosted-sandbox/hosted-sandbox-wire' import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client' import { failure } from './hosted-workspace-runtime' import { githubRepoSlug, lookupGitHubRepoIds, type GitHubRepoIds } from './github-repo-ids' +import { + CLOUD_CREATE_IDEMPOTENCY_KEY, + CLOUD_CREATE_PROVISION_ID, + createCloudWorkspaceFromGitHub +} from './cloud-create-from-github' +import { activeProvisionControllers } from '../ipc/provision-controllers' import { HOSTED_GITHUB_USER_CHANNELS, registerHostedGitHubUserHandlers @@ -47,6 +52,9 @@ export function registerHostedAccountHandlers( | 'githubRepoAccess' | 'githubRepositories' | 'create' + | 'bootPlan' + | 'cloneStatus' + | 'destroy' | 'githubUser' | 'connectGitHubUser' | 'disconnectGitHubUser' @@ -126,30 +134,50 @@ export function registerHostedAccountHandlers( }) // D4: a repository the account's GitHub installations reach, with no clone here. The sandbox - // clones it; nothing touches this computer's disk. The id reads like the phone's: -. - ipcMain.handle( - CH.createFromGitHub, - async (_event, args?: { repoUrl?: unknown; title?: unknown }) => { - const slug = typeof args?.repoUrl === 'string' ? githubRepoSlug(args.repoUrl) : null - if (!slug) { - return { ok: false, code: 'failed', message: 'Not a GitHub repository.' } - } - const name = (slug.split('/').pop() ?? 'workspace') - .replace(/[^A-Za-z0-9_.-]/g, '-') - .slice(0, 48) - // The name typed in the composer, as the owned create sends it; else lists show the repo name. - const title = typeof args?.title === 'string' ? args.title.trim().slice(0, 120) : '' - try { - const ready = await client.create({ - workspaceId: `${name}-${randomBytes(4).toString('hex')}`, - machineSize: 'standard', - repo: { url: `https://github.com/${slug}.git` }, - ...(title ? { title } : {}) - }) - return { ok: true, value: { sandboxId: ready.sandbox.id } } - } catch (error) { - return failure(error) + // clones it; nothing touches this computer's disk. With a provision id the window shows the build + // sheet: progress streams on the build log channel and Cancel aborts it (GH-CREATE-SHEET). + ipcMain.handle(CH.createFromGitHub, async (event, args?: CreateFromGitHubArgs) => { + const slug = typeof args?.repoUrl === 'string' ? githubRepoSlug(args.repoUrl) : null + if (!slug) { + return { ok: false, code: 'failed', message: 'Not a GitHub repository.' } + } + // The name typed in the composer, as the owned create sends it; else lists show the repo name. + const title = typeof args?.title === 'string' ? args.title.trim().slice(0, 120) : '' + const provisionId = matching(args?.provisionId, CLOUD_CREATE_PROVISION_ID) + const idempotencyKey = matching(args?.idempotencyKey, CLOUD_CREATE_IDEMPOTENCY_KEY) + const controller = provisionId ? new AbortController() : null + if (provisionId && controller) { + activeProvisionControllers.set(provisionId, controller) + } + try { + return await createCloudWorkspaceFromGitHub( + client, + { slug, title, provisionId, idempotencyKey }, + { + ...(controller ? { signal: controller.signal } : {}), + onLog: (chunk) => { + if (provisionId && !event.sender.isDestroyed()) { + event.sender.send('ephemeralVm:provisionEvent', { + provisionId, + stream: 'stderr', + chunk + }) + } + } + } + ) + } finally { + if (provisionId && activeProvisionControllers.get(provisionId) === controller) { + activeProvisionControllers.delete(provisionId) } } - ) + }) +} + +type CreateFromGitHubArgs = + | { repoUrl?: unknown; title?: unknown; provisionId?: unknown; idempotencyKey?: unknown } + | undefined + +function matching(value: unknown, pattern: RegExp): string | null { + return typeof value === 'string' && pattern.test(value) ? value : null } diff --git a/src/main/hosted-sandbox/hosted-sandbox-driver.ts b/src/main/hosted-sandbox/hosted-sandbox-driver.ts index 36ac6af2dba..cb0ccc76f5c 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-driver.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-driver.ts @@ -11,6 +11,7 @@ import { EnvironmentProviderError, isEnvironmentProviderError, type EnvironmentProviderCallContext, + type EnvironmentCreateSpec, type EnvironmentHandle, type EnvironmentStatus } from '../environment-providers/environment-provider-driver' @@ -61,35 +62,7 @@ export function createHostedSandboxDriver( capabilities: HOSTED_SANDBOX_CAPABILITIES, async create(spec, ctx) { - const machineSize = toMachineSize(spec.machineSize) - ctx?.onProgress?.({ phase: 'provisioning' }) - // Names the repository step before the create answers (no relabel mid-run). Best effort: an - // older control plane answers 404 and the step is named when the create answers. - if (spec.repo) { - void client - .bootPlan(spec.repo.url, ctx?.signal) - .then((plan) => - ctx?.onProgress?.({ phase: plan === 'update' ? 'plan-update' : 'plan-clone' }) - ) - .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 - ) - if (ready.clone) { - await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' }) - } - ctx?.onProgress?.({ phase: 'connecting' }) - return toReady(ready) + return toReady(await createSandboxWithProgress(client, spec, ctx)) }, async resume(handle, ctx) { @@ -149,6 +122,47 @@ export function createHostedSandboxDriver( } } +/** + * One create with the build sheet's progress: the boot plan names the repository step, then the + * create, then git's clone or update until it is done. Shared by the owned create (the recipe) and + * the GitHub-only create (GH-CREATE-SHEET), so both sheets read the same phases. + */ +export async function createSandboxWithProgress( + client: Pick, + spec: EnvironmentCreateSpec, + ctx?: EnvironmentProviderCallContext +): Promise { + const machineSize = toMachineSize(spec.machineSize) + ctx?.onProgress?.({ phase: 'provisioning' }) + // Names the repository step before the create answers (no relabel mid-run). Best effort: an + // older control plane answers 404 and the step is named when the create answers. + if (spec.repo) { + void client + .bootPlan(spec.repo.url, ctx?.signal) + .then((plan) => + ctx?.onProgress?.({ phase: plan === 'update' ? 'plan-update' : 'plan-clone' }) + ) + .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 + ) + if (ready.clone) { + await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' }) + } + ctx?.onProgress?.({ phase: 'connecting' }) + return ready +} + /** A clone whose progress has not moved this long is stuck (git's own silent stretches are ~20 s). */ export const CLONE_STALL_MS = 5 * 60_000 const CLONE_POLL_MS = 1_000 diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts index 578969efb9e..03d8071e6bb 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts @@ -85,7 +85,8 @@ function ok(stdout: string): RecipeStepProviderResult { return { stdout, stderr: '', exitCode: 0, signal: null } } -function failed(error: unknown): RecipeStepProviderResult { +/** A control-plane failure as the build sheet reads it: a reason line and a failure code. */ +export function hostedCreateFailure(error: unknown): RecipeStepProviderResult { const base = { stdout: '', exitCode: 1, signal: null } if ( isHostedEntitlementDenied(error) && @@ -142,6 +143,15 @@ function failed(error: unknown): RecipeStepProviderResult { return { ...base, stderr: `${error instanceof Error ? error.message : String(error)}\n` } } +type HostedProgress = { phase: string; percent?: number; message?: string } + +/** `clone-progress ` lines drive the sheet's clone step; the plain log hides them. */ +export function hostedProgressLine({ phase, percent, message }: HostedProgress): string { + return (phase === 'cloning' || phase === 'updating') && percent !== undefined + ? `[orca-cloud] clone-progress ${percent} ${message === 'checkout' ? 'checkout' : 'download'}\n` + : `[orca-cloud] ${phase}\n` +} + export function createHostedSandboxStepProvider( driver: HostedEnvironmentProviderDriver, hooks: { @@ -159,21 +169,7 @@ export function createHostedSandboxStepProvider( return async ({ mode, repoPath, context, stdin, signal, onStderr }) => { const ctx = { signal, - // `clone-progress ` lines drive the sheet's clone step; the plain log hides them. - onProgress: ({ - phase, - percent, - message - }: { - phase: string - percent?: number - message?: string - }) => - onStderr?.( - (phase === 'cloning' || phase === 'updating') && percent !== undefined - ? `[orca-cloud] clone-progress ${percent} ${message === 'checkout' ? 'checkout' : 'download'}\n` - : `[orca-cloud] ${phase}\n` - ) + onProgress: (progress: HostedProgress) => onStderr?.(hostedProgressLine(progress)) } try { if (mode === 'create') { @@ -230,7 +226,7 @@ export function createHostedSandboxStepProvider( hooks.onStopped?.(handle.environmentId) return ok('') } catch (error) { - const result = failed(error) + const result = hostedCreateFailure(error) // Only streamed stderr reaches the build log, so the control plane's reason must be streamed too. if (result.stderr) { onStderr?.(result.stderr) diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index e29e15fdfb3..99063930d7f 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -60,8 +60,7 @@ import { type EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy' import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env' - -const activeProvisionControllers = new Map() +import { activeProvisionControllers } from './provision-controllers' export type EphemeralVmProvisionIpcResult = | { diff --git a/src/main/ipc/provision-controllers.ts b/src/main/ipc/provision-controllers.ts new file mode 100644 index 00000000000..d8da3bfa2d9 --- /dev/null +++ b/src/main/ipc/provision-controllers.ts @@ -0,0 +1,4 @@ +// Cancel handles for builds the window shows on a build sheet, by provision (creation) id. +// `ephemeralVm:cancelProvision` aborts whichever build registered the id: an owned VM or Orca Cloud +// create, or a GitHub-only Orca Cloud create (GH-CREATE-SHEET). +export const activeProvisionControllers = new Map() diff --git a/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx b/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx index 5831c82940d..cc2916aec00 100644 --- a/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx +++ b/src/renderer/src/components/cloud-secrets-ask/VmBuildSecretsRow.tsx @@ -49,7 +49,11 @@ export function VmBuildSecretsRow({ }): React.JSX.Element | null { const cloud = isOrcaCloudBuild(entry) const sourceRepoId = entry.request.ephemeralVmRecipe?.sourceRepoId + const githubName = entry.request.githubSource?.fullName const repoKey = useAppStore((state) => { + if (githubName) { + return `github.com/${githubName.toLowerCase()}` + } const repo = state.repos.find((candidate) => candidate.id === sourceRepoId) return repo ? cloudSecretRepoKey(repo) : null }) diff --git a/src/renderer/src/components/new-workspace/composer-modal-data.ts b/src/renderer/src/components/new-workspace/composer-modal-data.ts index 44b945c08da..188bfae6e71 100644 --- a/src/renderer/src/components/new-workspace/composer-modal-data.ts +++ b/src/renderer/src/components/new-workspace/composer-modal-data.ts @@ -25,6 +25,8 @@ export type ComposerModalData = { prefilledName?: string initialRepoId?: string initialEphemeralVmRecipeId?: string + /** Orca Cloud: a GitHub repository with no clone here, picked again (GH-CREATE-SHEET's Back to draft). */ + initialCloudGitHubRepo?: string initialProjectGroupId?: string linkedWorkItem?: LinkedWorkItemSummary | null initialGitHubWorkItem?: GitHubWorkItem | null diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts index 1378325d091..0da7398689a 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-only-repo.ts @@ -29,11 +29,14 @@ export type CloudOnlyRepo = { export function useCloudOnlyRepo({ repoId, + initialPicked = null, cloudSelected, cloudOffered, signedIn }: { repoId: string + /** `owner/repo` picked when the composer opens (Back to draft from its build sheet). */ + initialPicked?: string | null /** Orca Cloud is the Run on target. */ cloudSelected: boolean /** This build offers Orca Cloud (the Run on segment is enabled). */ @@ -45,7 +48,7 @@ export function useCloudOnlyRepo({ // D4 rows show while Orca Cloud is picked, or for a folder, which can only start from GitHub. const enabled = (cloudSelected || folderProject) && cloudOffered const { repos, showMore } = useOrcaCloudGitHubRepositories(enabled && signedIn) - const [pickedName, setPickedName] = useState(null) + const [pickedName, setPickedName] = useState(initialPicked) const picked = useMemo( () => (enabled ? (repos?.find((repo) => repo.fullName === pickedName) ?? null) : null), [enabled, pickedName, repos] @@ -73,10 +76,11 @@ export function useCloudOnlyRepo({ ? startCloudWorkspaceFromGitHub({ repoUrl: picked.url, fullName: picked.fullName, + composerRepoId: repoId, ...request }) : Promise.resolve(false), - [picked] + [picked, repoId] ) return { repos, diff --git a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts index d16b86d780f..8980071e0ee 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-cloud-start-composer.ts @@ -40,6 +40,7 @@ export function useCloudStartComposer({ selectedRecipeId, setRecipeId, repoId, + initialGitHubRepo = null, prompt, agent, initialTargetFixed, @@ -55,6 +56,8 @@ export function useCloudStartComposer({ selectedRecipeId: string | null setRecipeId: ((recipeId: string | null) => void) | undefined repoId: string + /** A GitHub-only repository to start with picked (`owner/repo`). */ + initialGitHubRepo?: string | null prompt: string agent: string | null /** The composer was opened on an explicit target (Landing card, a recovery). */ @@ -88,6 +91,7 @@ export function useCloudStartComposer({ const githubProjectCount = useAppStore((s) => s.repos.filter(isGitHubBackedProject).length) const cloudOnly = useCloudOnlyRepo({ repoId, + initialPicked: initialGitHubRepo, cloudSelected, cloudOffered: segment === 'enabled', signedIn: availability !== 'needs-sign-in' && access.state !== 'signed-out' diff --git a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts index 20dd1e818eb..cdef73ffa5d 100644 --- a/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts +++ b/src/renderer/src/components/new-workspace/run-on/use-composer-cloud-start.ts @@ -59,6 +59,7 @@ export function useComposerCloudStart({ selectedRecipeId: cardProps.selectedEphemeralVmRecipeId ?? null, setRecipeId: cardProps.onEphemeralVmRecipeChange, repoId: cardProps.repoId, + initialGitHubRepo: modalData.initialCloudGitHubRepo ?? null, prompt: cardProps.name, agent: quickAgent, initialTargetFixed: Boolean( diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index 4c3fa5d365f..272d8dc5a79 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -162,7 +162,8 @@ export function VmBuildSheet({ // Unknown roles get the owner's copy; only a known member is told to ask someone else. const canManageBilling = billing.result?.status !== 'ok' || billing.result.entitlement.canManageBilling - const derived = deriveVmBuildSteps(entry, now, { cloud }).map((step) => + const github = entry.request.githubSource + const derived = deriveVmBuildSteps(entry, now, { cloud, checks: !github }).map((step) => nothingStarted(failure) && step.state === 'failed' ? { ...step, state: 'pending' as const, elapsedMs: null } : step @@ -190,7 +191,7 @@ export function VmBuildSheet({ const sourceRepo = useAppStore((state) => recipe ? state.repos.find((repo) => repo.id === recipe.sourceRepoId) : undefined ) - const repoName = orcaCloudRepoName(sourceRepo) + const repoName = github?.fullName ?? orcaCloudRepoName(sourceRepo) const [connectingGitHub, setConnectingGitHub] = React.useState(false) const connectGitHub = (): void => { setConnectingGitHub(true) @@ -322,7 +323,7 @@ export function VmBuildSheet({ : Boolean(entry.request.ephemeralVmRuntimeId) } firstErrorLine={firstErrorLine} - canFixWithAgent={Boolean(recipe)} + canFixWithAgent={Boolean(recipe) && !github} canManageBilling={canManageBilling} signingIn={signingIn} connectingGitHub={connectingGitHub} diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx index b82389f69c7..abfba458117 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx @@ -1,6 +1,4 @@ import { defineFixture } from '../../component-fixtures/component-fixture' -import { recordBillingStatus } from '../billing/billing-summary-store' -import { sampleTeam } from '../billing/plan-billing-fixtures' import { CLOUD_FAILED_LOG, CLOUD_LOG, @@ -10,28 +8,12 @@ import { LOG, frame, seedFixtureRepo, + stubBillingRole, T0 } from './vm-build-sheet-fixture-support' // Orca Cloud refusals and failures, one card each. -function stubBillingRole(canManageBilling: boolean, plan: Record = {}): void { - // A team with overage on at a $25 cap (so the spend-cap blocker suggests $50); no plan numbers - // unless the fixture gives them (ENTITLEMENTS-C). - const entitlement = { - ...sampleTeam, - canManageBilling, - offers: null, - overage: { ...sampleTeam.overage, optedIn: true, spendCapCents: 2500 }, - ...plan - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture plans override only entitlement fields the sheet reads. - const result = { status: 'ok', entitlement } as never - window.api.orcaProfiles.billingStatus = () => Promise.resolve(result) - // The caps come from the last billing read the app made. - recordBillingStatus(result) -} - // An owner unless stated: the card's billing fix depends on the role billing reports. function cloudFailure( errorCode: string | undefined, diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx new file mode 100644 index 00000000000..2292e4a3429 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx @@ -0,0 +1,105 @@ +// GH-CREATE-SHEET: Orca Cloud from a GitHub repository with no clone here uses the same build sheet. +// The logs are the lines main streams for it (cloud-create-from-github.ts), so the steps and the +// billing cards come from the same parsing and classification as an owned create's. +import { defineFixture } from '../../component-fixtures/component-fixture' +import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation' +import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github' +import { sheet, stampsFor, stubBillingRole, T0 } from './vm-build-sheet-fixture-support' + +const 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' +}) + +const STARTING = ['[orca-cloud] provisioning', '[orca-cloud] plan-clone'] +const CLONING = [...STARTING, '[orca-cloud] cloning', '[orca-cloud] clone-progress 42 download'] +const CLONED = [...CLONING, '[orca-cloud] clone-progress 100 checkout', '[orca-cloud] connecting'] + +function github(lines: readonly string[], patch: Partial = {}) { + const log = lines.join('\n') + // One line every ~1.3 s from T0 (stampsFor), so the clone starts a few seconds in. + return { + creationId: 'fixture', + phase: 'provisioning-vm' as const, + status: 'creating' as const, + startedAt: T0, + indeterminate: false, + loaderVisible: true, + provisioningLog: log, + provisioningLogStamps: stampsFor(lines.length), + phaseLog: [{ phase: 'provisioning-vm' as const, at: T0 }], + request: REQUEST, + ...patch + } +} + +function fixture( + value: PendingWorktreeCreation, + now: number, + { + error = null, + canManageBilling = true + }: { error?: string | null; canManageBilling?: boolean } = {} +) { + return defineFixture({ + width: 760, + setup: () => stubBillingRole(canManageBilling), + render: () => sheet(value, error, now) + }) +} + +export const starting = fixture(github(STARTING), T0 + 2_500) + +export const cloning = fixture(github(CLONING), T0 + 9_000) + +export const opening = fixture( + github(CLONED, { + phase: 'creating', + phaseLog: [ + { phase: 'provisioning-vm', at: T0 }, + { phase: 'creating', at: T0 + 7_000 } + ] + }), + T0 + 8_000 +) + +export const openFailed = fixture( + github(CLONED, { + phase: 'creating', + status: 'error', + errorAt: T0 + 12_000, + phaseLog: [ + { phase: 'provisioning-vm', at: T0 }, + { phase: 'creating', at: T0 + 7_000 } + ] + }), + T0 + 12_000, + { + error: + 'Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.' + } +) + +// Billing refusals: main logs the control plane's reason line and returns cloudPlanRequired. +function refused(reason: string, canManageBilling = true) { + return fixture( + github(['[orca-cloud] provisioning', `Orca Cloud: ${reason}`], { + status: 'error', + errorCode: 'cloudPlanRequired', + errorAt: T0 + 2_000 + }), + T0 + 2_000, + { error: 'Orca Cloud workspaces need a plan with cloud hours.', canManageBilling } + ) +} + +export const refusedPlan = refused('no_subscription') +export const refusedHoursUsed = refused('included_hours_exhausted') +export const refusedSpendCap = refused('overage_cap_reached') +export const refusedPayment = refused('payment_past_due') +// A member can't fix billing: the card names who can, with no "See plans". +export const refusedPlanMember = refused('no_subscription', false) +export const refusedHoursUsedMember = refused('included_hours_exhausted', false) diff --git a/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts index ab74de0f3ee..d50217b88cf 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-composer-return.test.ts @@ -47,3 +47,38 @@ describe('composer data for a create billing refused', () => { expect(composerDataFromRefusedCreate(entry, { local: true }).prefilledName).toBeUndefined() }) }) + +describe('Back to draft from a GitHub-only create (GH-CREATE-SHEET)', () => { + const github = { + ...request, + repoId: '', + note: '', + githubSource: { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: 'Fix login', + composerRepoId: 'repo-1' + } + } + + it('reopens the composer on Orca Cloud with the GitHub repository picked and the prompt kept', () => { + expect(typedComposerPrompt({ request: github })).toBe('Fix login') + expect(composerDataFromRefusedCreate({ request: github }, { local: false })).toEqual({ + initialRepoId: 'repo-1', + initialEphemeralVmRecipeId: 'cloud', + initialCloudGitHubRepo: 'acme/web', + prefilledName: 'Fix login', + recovery: { note: '', agent: 'claude' }, + telemetrySource: 'sidebar' + }) + }) + + it('"Create locally" keeps the composer’s own project, with no cloud repository', () => { + expect(composerDataFromRefusedCreate({ request: github }, { local: true })).toEqual({ + initialRepoId: 'repo-1', + prefilledName: 'Fix login', + recovery: { note: '', agent: 'claude' }, + telemetrySource: 'sidebar' + }) + }) +}) diff --git a/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts b/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts index 1db89f80ec2..e5774256370 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-composer-return.ts @@ -5,7 +5,7 @@ type ReturnSource = Pick /** What the user typed for the agent: a prompt queued during the build, else the composer's. */ export function typedComposerPrompt(entry: ReturnSource): string { - return entry.heldChatMessage ?? entry.request.note + return entry.heldChatMessage ?? entry.request.githubSource?.prompt ?? entry.request.note } /** @@ -19,6 +19,23 @@ export function composerDataFromRefusedCreate( ): ComposerModalData { const { request } = entry const recipe = request.ephemeralVmRecipe + const github = request.githubSource + if (github) { + // The GitHub repository has no clone here: back on Orca Cloud with it picked again; "locally" + // keeps the composer's own project. + return { + ...(github.composerRepoId ? { initialRepoId: github.composerRepoId } : {}), + ...(local + ? {} + : { + initialEphemeralVmRecipeId: recipe?.recipeId, + initialCloudGitHubRepo: github.fullName + }), + prefilledName: entry.heldChatMessage ?? github.prompt, + recovery: { note: '', agent: request.agent }, + telemetrySource: request.telemetrySource ?? 'unknown' + } + } return { initialRepoId: recipe?.sourceRepoId ?? request.repoId, ...(!local && recipe ? { initialEphemeralVmRecipeId: recipe.recipeId } : {}), diff --git a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx index 879850cdce2..079dfb21e4a 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx +++ b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx @@ -7,6 +7,8 @@ import { formatElapsedTime } from './worktree-creation-clock' import type { VmBuildStepTimings } from './vm-build-timing' import { useAppStore } from '@/store' import type { Repo } from '../../../../shared/repo-types' +import { recordBillingStatus } from '../billing/billing-summary-store' +import { sampleTeam } from '../billing/plan-billing-fixtures' export const LOG_LINES = [ 'Creating sandbox from snapshot snap_7f2c…', @@ -115,3 +117,24 @@ export const CLOUD_FAILED_LOG = [ 'Orca Cloud refused the request (provider_unavailable).' ].join('\n') export const FAILED_LOG = `${LOG}\nError: snapshot snap_7f2c not found` + +/** Billing as the sheet reads it: an owner or a member of a team with overage on at a $25 cap. */ +export function stubBillingRole( + canManageBilling: boolean, + plan: Record = {} +): void { + // A team with overage on at a $25 cap (so the spend-cap blocker suggests $50); no plan numbers + // unless the fixture gives them (ENTITLEMENTS-C). + const entitlement = { + ...sampleTeam, + canManageBilling, + offers: null, + overage: { ...sampleTeam.overage, optedIn: true, spendCapCents: 2500 }, + ...plan + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture plans override only entitlement fields the sheet reads. + const result = { status: 'ok', entitlement } as never + window.api.orcaProfiles.billingStatus = () => Promise.resolve(result) + // The caps come from the last billing read the app made. + recordBillingStatus(result) +} diff --git a/src/renderer/src/components/worktree-creation/vm-build-steps.test.ts b/src/renderer/src/components/worktree-creation/vm-build-steps.test.ts index 61a0eed0fa4..9b3ed412bc7 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-steps.test.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-steps.test.ts @@ -410,3 +410,28 @@ describe('Orca Cloud clone step', () => { expect(humanizeOrcaCloudLogLine('[orca-cloud] clone-progress 40 download', t)).toBeNull() }) }) + +describe('GitHub-only Orca Cloud create (GH-CREATE-SHEET)', () => { + it('has no environment checks step: start, clone, then open', () => { + const log = '[orca-cloud] provisioning\n[orca-cloud] cloning\n[orca-cloud] connecting' + const steps = deriveVmBuildSteps( + { + ...base, + phase: 'creating', + phaseLog: [ + { phase: 'provisioning-vm', at: 1000 }, + { phase: 'creating', at: 6000 } + ], + provisioningLog: log, + provisioningLogStamps: [1000, 3000, 5000] + }, + 7000, + { cloud: true, checks: false } + ) + expect(steps.map((step) => [step.id, step.state])).toEqual([ + ['provision', 'done'], + ['clone', 'done'], + ['create', 'running'] + ]) + }) +}) diff --git a/src/renderer/src/components/worktree-creation/vm-build-steps.ts b/src/renderer/src/components/worktree-creation/vm-build-steps.ts index 2a4797f4057..9bda9f62b80 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-steps.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-steps.ts @@ -104,7 +104,14 @@ function runningClone( export function deriveVmBuildSteps( entry: VmBuildEntry, now: number, - { cloud = false }: { cloud?: boolean } = {} + { + cloud = false, + checks: withChecks = true + }: { + cloud?: boolean + /** False for a GitHub-only Orca Cloud create: it is paired, so no environment checks run. */ + checks?: boolean + } = {} ): VmBuildStep[] { const check = entry.environmentCheck const failed = entry.status === 'error' @@ -177,5 +184,7 @@ export function deriveVmBuildSteps( percent: getVisibleCheckoutProgress(entry)?.percent ?? null }) - return cloneStep ? [provision, cloneStep, checks, create] : [provision, checks, create] + return [provision, cloneStep, withChecks ? checks : null, create].filter( + (candidate): candidate is VmBuildStep => candidate !== null + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f59c898270e..ae712356397 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -23915,7 +23915,6 @@ "addRepository": "Add a GitHub repository", "moreAccess": "Give Orca Cloud access to more repositories…", "startFailed": "Couldn’t start the cloud workspace. Nothing was created, and you weren’t charged.", - "starting": "Starting {{repo}} in Orca Cloud…", "openLater": "Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.", "showMore": "Show more repositories…", "folderDetail": "Orca Cloud starts from a GitHub repository. This project is a folder on this computer.", diff --git a/src/renderer/src/lib/orca-cloud/github-cloud-create-key.test.ts b/src/renderer/src/lib/orca-cloud/github-cloud-create-key.test.ts new file mode 100644 index 00000000000..5ff07a47397 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/github-cloud-create-key.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + forgetGitHubCreateKey, + githubCreateIdempotencyKey, + noteGitHubCreateOutcome, + resetGitHubCreateKeysForTest +} from './github-cloud-create-key' + +beforeEach(() => resetGitHubCreateKeysForTest()) + +describe('GitHub-only create Idempotency-Key (GH-CREATE-SHEET)', () => { + it('a Retry after a lost answer resends the same key, so it gets the same sandbox back', () => { + const first = githubCreateIdempotencyKey('c1') + noteGitHubCreateOutcome('c1', true) + expect(githubCreateIdempotencyKey('c1')).toBe(first) + noteGitHubCreateOutcome('c1', true) + expect(githubCreateIdempotencyKey('c1')).toBe(first) + }) + + it('a Retry after an answered failure is a new attempt with a new key', () => { + const first = githubCreateIdempotencyKey('c1') + noteGitHubCreateOutcome('c1', false) + const second = githubCreateIdempotencyKey('c1') + expect(second).toBe(`${first}:retry-1`) + // Its answer is then lost: the next Retry resends that second key. + noteGitHubCreateOutcome('c1', true) + expect(githubCreateIdempotencyKey('c1')).toBe(second) + }) + + it('keys are per creation and within the server format', () => { + const a = githubCreateIdempotencyKey('c1') + const b = githubCreateIdempotencyKey('c2') + expect(a).not.toBe(b) + expect(a).toMatch(/^[A-Za-z0-9_.:-]{8,128}$/) + forgetGitHubCreateKey('c1') + expect(githubCreateIdempotencyKey('c1')).toBe(a) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/github-cloud-create-key.ts b/src/renderer/src/lib/orca-cloud/github-cloud-create-key.ts new file mode 100644 index 00000000000..b7f68c68afc --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/github-cloud-create-key.ts @@ -0,0 +1,41 @@ +// The Idempotency-Key a GitHub-only Orca Cloud create sends (GH-CREATE-SHEET). One per creation. +// A Retry after a lost answer (the control plane never replied) resends the same key, so the server +// hands back the sandbox the first attempt may have made instead of starting a second one. A Retry +// after an answered failure is a new attempt with its own key: the server already released a key +// that created nothing, and one whose sandbox was deleted (a failed clone) must not replay it. + +type KeyState = { key: string; attempts: number; lastUnanswered: boolean } + +const keys = new Map() + +/** The key for the next attempt of this creation. */ +export function githubCreateIdempotencyKey(creationId: string): string { + const state = keys.get(creationId) + if (!state) { + const key = `create:${creationId}` + keys.set(creationId, { key, attempts: 0, lastUnanswered: false }) + return key + } + if (state.lastUnanswered) { + return state.attempts === 0 ? state.key : `${state.key}:retry-${state.attempts}` + } + state.attempts += 1 + return `${state.key}:retry-${state.attempts}` +} + +/** How the last attempt ended: `unanswered` when the control plane's answer never arrived. */ +export function noteGitHubCreateOutcome(creationId: string, unanswered: boolean): void { + const state = keys.get(creationId) + if (state) { + state.lastUnanswered = unanswered + } +} + +export function forgetGitHubCreateKey(creationId: string): void { + keys.delete(creationId) +} + +/** Tests only. */ +export function resetGitHubCreateKeysForTest(): void { + keys.clear() +} diff --git a/src/renderer/src/lib/orca-cloud/github-create-source.ts b/src/renderer/src/lib/orca-cloud/github-create-source.ts new file mode 100644 index 00000000000..98b66c1db7f --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/github-create-source.ts @@ -0,0 +1,10 @@ +/** What a GitHub-only Orca Cloud create starts from (D4, GH-CREATE-SHEET). */ +export type GitHubCreateSource = { + repoUrl: string + /** `owner/repo`. */ + fullName: string + /** The composer's text: the workspace's title and the agent's first prompt. */ + prompt: string + /** The project the composer had selected, for Back to draft. */ + composerRepoId: string +} diff --git a/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.test.ts b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.test.ts new file mode 100644 index 00000000000..c1deaf4a381 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/start-cloud-workspace-from-github.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +// GH-CREATE-SHEET: a GitHub-only Orca Cloud create runs on the same build sheet as any cloud +// create (a pending creation with the Orca Cloud recipe), streams its build log there, and its Retry +// never makes a second sandbox: after a lost answer it resends the same Idempotency-Key, and after a +// failed open it only opens the sandbox it already has. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +const mocks = vi.hoisted(() => ({ + open: vi.fn( + async (): Promise< + { ok: true; worktreeId: string | null } | { ok: false; message: string } + > => ({ + ok: true, + worktreeId: null + }) + ) +})) + +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: vi.fn() +})) + +import { + retryGitHubCloudCreate, + startCloudWorkspaceFromGitHub +} from './start-cloud-workspace-from-github' +import { resetGitHubCreateKeysForTest } from './github-cloud-create-key' + +type CreateArgs = { repoUrl: string; title?: string; provisionId?: string; idempotencyKey?: string } +type ProvisionListener = (event: { provisionId: string; stream: string; chunk: string }) => void + +let listener: ProvisionListener | null = null +const createFromGitHub = vi.fn<(args: CreateArgs) => Promise>() + +beforeEach(() => { + resetGitHubCreateKeysForTest() + useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null }) + Object.assign(window, { + api: { + ...window.api, + orcaCloudWorkspace: { createFromGitHub }, + cloudWorkspaces: { + get: vi.fn(async (id: string) => ({ ok: true, value: { id } })) + }, + ephemeralVm: { + onProvisionEvent: (callback: ProvisionListener) => { + listener = callback + return () => { + listener = null + } + }, + cancelProvision: vi.fn(async () => ({ cancelled: true })) + } + } + }) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +const REQUEST = { + repoUrl: 'https://github.com/acme/web', + fullName: 'acme/web', + prompt: 'Fix login', + agent: null, + composerRepoId: 'repo-1' +} + +function onlyCreation() { + const entries = Object.values(useAppStore.getState().pendingWorktreeCreations) + expect(entries).toHaveLength(1) + return entries[0]! +} + +describe('GitHub-only create on the build sheet', () => { + it('opens the sheet as an Orca Cloud build and streams its log there', async () => { + let finish: (value: unknown) => void = () => {} + createFromGitHub.mockImplementation( + (args: CreateArgs) => + new Promise((resolve) => { + listener?.({ + provisionId: args.provisionId ?? '', + stream: 'stderr', + chunk: '[orca-cloud] provisioning\n' + }) + finish = resolve + }) + ) + const started = startCloudWorkspaceFromGitHub(REQUEST) + const entry = onlyCreation() + expect(useAppStore.getState().activePendingCreationId).toBe(entry.creationId) + expect(entry.request.ephemeralVmRecipe?.provider).toBe('orca-cloud') + expect(entry.request.githubSource).toMatchObject({ + fullName: 'acme/web', + composerRepoId: 'repo-1' + }) + expect(entry.request.displayName).toBe('Fix login') + await vi.waitFor(() => + expect(onlyCreation().provisioningLog).toContain('[orca-cloud] provisioning') + ) + expect(createFromGitHub).toHaveBeenCalledWith({ + repoUrl: REQUEST.repoUrl, + title: 'Fix login', + provisionId: entry.creationId, + idempotencyKey: `create:${entry.creationId}` + }) + finish({ ok: true, value: { sandboxId: 'sbx_1' } }) + expect(await started).toBe(true) + // Opened: the workspace replaces the sheet. + expect(useAppStore.getState().pendingWorktreeCreations).toEqual({}) + }) + + it('a billing refusal stays on the sheet with its code for the blocker', async () => { + createFromGitHub.mockResolvedValue({ + ok: false, + code: 'entitlement_denied', + message: 'denied', + reason: 'cloudPlanRequired' + }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + expect(onlyCreation()).toMatchObject({ status: 'error', errorCode: 'cloudPlanRequired' }) + }) + + it('Retry after a lost answer resends the same key, so no second sandbox is made', async () => { + createFromGitHub.mockRejectedValueOnce(new Error('ipc dropped')) + await startCloudWorkspaceFromGitHub(REQUEST) + const { creationId } = onlyCreation() + createFromGitHub.mockResolvedValueOnce({ ok: true, value: { sandboxId: 'sbx_1' } }) + retryGitHubCloudCreate(creationId) + await vi.waitFor(() => expect(createFromGitHub).toHaveBeenCalledTimes(2)) + const keys = createFromGitHub.mock.calls.map((call) => call[0].idempotencyKey) + expect(keys[1]).toBe(keys[0]) + }) + + it('Retry after an answered failure is a new attempt with a new key', async () => { + createFromGitHub.mockResolvedValueOnce({ ok: false, code: 'failed', message: 'clone failed' }) + await startCloudWorkspaceFromGitHub(REQUEST) + const { creationId } = onlyCreation() + createFromGitHub.mockResolvedValueOnce({ ok: true, value: { sandboxId: 'sbx_2' } }) + retryGitHubCloudCreate(creationId) + await vi.waitFor(() => expect(createFromGitHub).toHaveBeenCalledTimes(2)) + const keys = createFromGitHub.mock.calls.map((call) => call[0].idempotencyKey) + expect(keys[1]).toBe(`${keys[0]}:retry-1`) + }) + + it('Retry after a failed open opens the same sandbox and never creates again', async () => { + createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } }) + mocks.open.mockResolvedValueOnce({ ok: false, message: 'pairing failed' }) + expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false) + const failed = onlyCreation() + expect(failed).toMatchObject({ status: 'error', phase: 'creating' }) + retryGitHubCloudCreate(failed.creationId) + await vi.waitFor(() => expect(useAppStore.getState().pendingWorktreeCreations).toEqual({})) + expect(createFromGitHub).toHaveBeenCalledTimes(1) + expect(mocks.open).toHaveBeenCalledTimes(2) + }) +}) 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 9bb99da5859..4dffd096d87 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 @@ -1,7 +1,11 @@ // "Start in Orca Cloud" for a repository with no clone here (cloud-start D4): the sandbox clones it // from GitHub, this computer pairs to it like any cloud workspace, and the composer's prompt goes // to the chosen agent there. Nothing is written to this computer's disk. -import { toast } from 'sonner' +// +// GH-CREATE-SHEET: it shows the same build sheet as an Orca Cloud create from a local clone: Start +// sandbox, Clone repository (git's progress), Open workspace, with the same failure cards, billing +// blocker, Retry and Back to draft. The sheet is a pending creation like any other; this module +// runs it instead of the worktree create. import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace' @@ -10,58 +14,211 @@ import { createNonSecureContextUuid } from '../../../../shared/non-secure-contex import { resolveDefaultAgentForWorktree } from '@/lib/agent-session/launch/shortcuts/agent-tab-shortcuts' import { captureAgentTabLaunchContext } from '@/components/agent-tab-launch-context' import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' +import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id' +import type { WorktreeCreationRequest } from '@/lib/workspace/creation/pending-worktree-creation' +import { appendProvisioningLog } from '@/lib/workspace/creation/pending-creation-provisioning-log' +import { trackOrcaCloudCreateStep } from '@/lib/orca-cloud-cancel-cleanup' +import { + forgetGitHubCreateKey, + githubCreateIdempotencyKey, + noteGitHubCreateOutcome +} from './github-cloud-create-key' export type StartFromGitHubRequest = { repoUrl: string fullName: string prompt: string agent: string | null + /** The project the composer had selected, for Back to draft. */ + composerRepoId?: string } -const failedCopy = (): string => - translate( - 'components.newWorkspace.cloudOnly.startFailed', - 'Couldn’t start the cloud workspace. Nothing was created, and you weren’t charged.' - ) +const NETWORK_FAILURE = + /\b(ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|fetch failed|network (error|unreachable))\b/i -/** Resolves true once the workspace is open (its agent may still be starting). */ -export async function startCloudWorkspaceFromGitHub( - request: StartFromGitHubRequest -): Promise { - const toastId = toast.loading( - translate('components.newWorkspace.cloudOnly.starting', 'Starting {{repo}} in Orca Cloud…', { - repo: request.fullName +// Sandboxes a creation already started, so a Retry after a failed open opens it, never makes another. +const startedSandboxes = new Map() + +/** The pending creation's request: the sheet's title, Orca Cloud copy, Retry and Back to draft. */ +export function githubCreateRequest(request: StartFromGitHubRequest): WorktreeCreationRequest { + const prompt = request.prompt.trim() + const repoName = request.fullName.split('/').pop() ?? request.fullName + return { + repoId: '', + // The composer's name field is the workspace's name (NAME-DROPPED); the repository's otherwise. + name: prompt.slice(0, 120) || repoName, + displayName: prompt.slice(0, 120) || repoName, + displayNameKind: prompt ? 'user' : 'generated', + setupDecision: 'inherit', + agent: isTuiAgent(request.agent) ? request.agent : null, + pendingFirstAgentMessageRename: false, + note: '', + startupPlan: null, + quickPrompt: request.prompt, + quickTelemetry: null, + telemetrySource: 'unknown', + ephemeralVmRecipe: { + sourceRepoId: '', + recipeId: ORCA_CLOUD_RECIPE_ID, + projectId: '', + provider: 'orca-cloud' + }, + githubSource: { + repoUrl: request.repoUrl, + fullName: request.fullName, + prompt: request.prompt, + composerRepoId: request.composerRepoId ?? '' + } + } +} + +/** Opens the build sheet and starts; resolves true once the workspace is open. */ +export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest): Promise { + const creationId = createNonSecureContextUuid() + const store = useAppStore.getState() + store.beginPendingWorktreeCreation({ + creationId, + phase: 'provisioning-vm', + status: 'creating', + startedAt: Date.now(), + indeterminate: false, + loaderVisible: true, + provisioningLog: '', + provisioningLogStamps: [], + request: githubCreateRequest(request) + }) + store.setActiveView('terminal') + store.setSidebarOpen(true) + return runGitHubCloudCreate(creationId) +} + +/** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */ +export function retryGitHubCloudCreate(creationId: string): void { + const store = useAppStore.getState() + if (!store.pendingWorktreeCreations[creationId]?.request.githubSource) { + return + } + const opening = startedSandboxes.has(creationId) + store.updatePendingWorktreeCreation(creationId, { + status: 'creating', + loaderVisible: true, + error: undefined, + errorCode: undefined, + // The sandbox is up: only opening it is tried again, so its steps stay done. + ...(opening + ? { phase: 'creating' } + : { + startedAt: Date.now(), + phase: 'provisioning-vm', + provisioningLog: '', + provisioningLogStamps: [] + }) + }) + store.setActivePendingWorktreeCreation(creationId) + store.setActiveView('terminal') + void runGitHubCloudCreate(creationId) +} + +function current(creationId: string) { + return useAppStore.getState().pendingWorktreeCreations[creationId] +} + +function fail(creationId: string, error: string, errorCode?: string | null): false { + if (current(creationId)) { + useAppStore.getState().updatePendingWorktreeCreation(creationId, { + status: 'error', + error, + errorCode: errorCode ?? undefined }) + } + return false +} + +async function startSandbox(creationId: string): Promise { + const source = current(creationId)?.request.githubSource + if (!source) { + return null + } + const unsubscribe = window.api.ephemeralVm.onProvisionEvent?.((event) => { + if (event.provisionId === creationId && event.stream === 'stderr') { + appendProvisioningLog(creationId, event.chunk) + } + }) + const title = source.prompt.trim().slice(0, 120) + const created = await trackOrcaCloudCreateStep( + creationId, + window.api.orcaCloudWorkspace + .createFromGitHub({ + repoUrl: source.repoUrl, + ...(title ? { title } : {}), + provisionId: creationId, + idempotencyKey: githubCreateIdempotencyKey(creationId) + }) + .catch(() => null) + ).finally(() => unsubscribe?.()) + noteGitHubCreateOutcome( + creationId, + !created || + (!created.ok && (created.code === 'unavailable' || NETWORK_FAILURE.test(created.message))) ) - // The composer's name field is the workspace's name: send it as the title (NAME-DROPPED). - const title = request.prompt.trim().slice(0, 120) - const created = await window.api.orcaCloudWorkspace - .createFromGitHub({ repoUrl: request.repoUrl, ...(title ? { title } : {}) }) - .catch(() => null) if (!created?.ok) { - toast.error(failedCopy(), { id: toastId }) + fail( + creationId, + created?.message ?? + translate( + 'components.newWorkspace.cloudOnly.startFailed', + 'Couldn’t start the cloud workspace. Nothing was created, and you weren’t charged.' + ), + created?.reason + ) + return null + } + startedSandboxes.set(creationId, created.value.sandboxId) + return created.value.sandboxId +} + +async function runGitHubCloudCreate(creationId: string): Promise { + const request = current(creationId)?.request + const source = request?.githubSource + if (!request || !source) { return false } - // The sandbox exists from here on: a failure below must never offer to create it again. - const workspace = await window.api.cloudWorkspaces.get(created.value.sandboxId).catch(() => null) + const sandboxId = startedSandboxes.get(creationId) ?? (await startSandbox(creationId)) + if (!sandboxId) { + return false + } + if (!current(creationId)) { + // Cancelled while it started: main stopped the create and deleted what it had made. + startedSandboxes.delete(creationId) + 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 if (!opened?.ok) { - toast.error( - translate( - 'components.newWorkspace.cloudOnly.openLater', - 'Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.' - ), - { id: toastId } + // The sandbox exists from here on: Retry opens it and never creates it again. + return fail( + creationId, + opened?.message ?? + translate( + 'components.newWorkspace.cloudOnly.openLater', + 'Your cloud workspace started, but this computer couldn’t open it yet. Open it from Cloud workspaces.' + ) ) - return true } - toast.dismiss(toastId) - const worktreeId = opened.worktreeId - if (!worktreeId || !request.prompt.trim()) { - return true + startedSandboxes.delete(creationId) + forgetGitHubCreateKey(creationId) + // Opened: the workspace replaces the sheet. Nothing to clean up, the sandbox is the workspace. + useAppStore.getState().removePendingWorktreeCreation(creationId, { cleanupVm: false }) + if (opened.worktreeId && source.prompt.trim()) { + void launchPrompt(opened.worktreeId, source.prompt, request.agent) } - const agent = isTuiAgent(request.agent) - ? request.agent + return true +} + +async function launchPrompt(worktreeId: string, prompt: string, picked: string | null) { + const agent = isTuiAgent(picked) + ? picked : await resolveDefaultAgentForWorktree(useAppStore.getState, worktreeId).catch(() => null) if (agent) { launchAgentInNewTabWithOutcome({ @@ -69,10 +226,9 @@ export async function startCloudWorkspaceFromGitHub( ...captureAgentTabLaunchContext(worktreeId), agent, worktreeId, - prompt: request.prompt, + prompt, promptDelivery: 'submit-after-ready', launchSource: 'unknown' }) } - return true } 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 59d2a98a93a..6b8a9f043b4 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -30,6 +30,7 @@ import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' import type { EnvironmentDoctorCheck } from '../../../../../shared/environment-doctor-types' import type { NativeChatComposerDraftImage } from '@/components/native-chat/drafts/native-chat-composer-draft-storage' +import type { GitHubCreateSource } from '@/lib/orca-cloud/github-create-source' import type { AgentJournalSkillRef } from '../../../../../shared/agent-session/journal/agent-session-journal-types' /** Creation phases include renderer preflight, base fetch and worktree add; remote/runtime can skip git phases, while VM recipes provision before runtime creation. */ @@ -66,6 +67,11 @@ export type WorktreeCreationRequest = { /** Set for the built-in hosted provider, whose build sheet speaks in plain language. */ provider?: 'orca-cloud' } + /** + * Orca Cloud from a GitHub repository with no clone here (D4, GH-CREATE-SHEET): the sandbox clones + * it and this computer pairs to it. Same build sheet; Retry and Back to draft use this. + */ + githubSource?: GitHubCreateSource // Capture local/runtime progress routing at submit so focus changes cannot alter Retry behavior. worktreeCreateProgressMode?: WorktreeCreationProgressMode name: string diff --git a/src/renderer/src/lib/workspace/creation/worktree-creation-flow.ts b/src/renderer/src/lib/workspace/creation/worktree-creation-flow.ts index 5ff2bc3fa8d..93310aa0325 100644 --- a/src/renderer/src/lib/workspace/creation/worktree-creation-flow.ts +++ b/src/renderer/src/lib/workspace/creation/worktree-creation-flow.ts @@ -177,6 +177,13 @@ export function retryBackgroundWorktreeCreation(creationId: string): void { if (!entry) { return } + if (entry.request.githubSource) { + // GH-CREATE-SHEET: no worktree create here; the sandbox clones from GitHub and is paired. + void import('@/lib/orca-cloud/start-cloud-workspace-from-github').then( + ({ retryGitHubCloudCreate }) => retryGitHubCloudCreate(creationId) + ) + return + } if (entry.chatInputHandedOff && entry.worktreeId) { store.updatePendingWorktreeCreation(creationId, { status: 'creating', 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 3ac855a0cf8..177eb7bf2f6 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,7 +149,13 @@ export function createRemovePendingWorktreeCreation( .catch(() => undefined) } const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get) - if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) { + // 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 + ) { beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], { runtimeId: removedEntry.request.ephemeralVmRuntimeId, createStartedAt: removedEntry.startedAt, diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index 70b49baf94d..bb4973574ad 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -116,6 +116,13 @@ export type OrcaCloudWorkspaceApi = { repoUrl: string /** The name typed in the composer; becomes the workspace's title (NAME-DROPPED). */ title?: string + /** + * The window's creation id (GH-CREATE-SHEET): progress streams as `ephemeralVm:provisionEvent` + * under it, and `ephemeralVm.cancelProvision` with it cancels the create. + */ + provisionId?: string + /** One per Create press; a retry after a lost answer reuses it and gets the same sandbox. */ + idempotencyKey?: string }) => Promise> onWakeProgress: (callback: (event: CloudWakeProgressEvent) => void) => () => void onOom: (callback: (event: CloudOomEvent) => void) => () => void