feat(cloud-create): a GitHub-only Orca Cloud create shows the full build sheet (GH-CREATE-SHEET)

Starting a cloud workspace from a GitHub repository with no clone here showed only a
'Starting <repo> in Orca Cloud…' toast. It now opens the same build sheet as a cloud
create from a local clone: Start sandbox, Clone repository with git's progress, Open
workspace; the same failure cards and JA-21b billing blocker (plan, hours, spend cap,
payment; members get no See plans); Retry; and Back to draft (composer back on Orca
Cloud with the GitHub repository picked and the prompt kept).

Main: createFromGitHub runs the hosted driver's create path (createSandboxWithProgress,
shared with the owned create): boot plan, create, waitForClone, with the same
[orca-cloud] progress lines streamed on ephemeralVm:provisionEvent under the window's
creation id, the same failure reason lines and codes (hostedCreateFailure), Cancel via
ephemeralVm:cancelProvision, and an Idempotency-Key. The workspace id is fixed per
creation so a Retry sends the same body.

Renderer: the GitHub start is a pending creation with the Orca Cloud recipe and a
githubSource; retryBackgroundWorktreeCreation routes it to its own runner. A Retry
after a lost answer resends the same key (same sandbox back); after an answered failure
it is a new attempt; after a failed open it only opens the sandbox it already has.
The paired workspace has no environment-checks step.
This commit is contained in:
Neil
2026-11-12 15:26:26 -08:00
parent 77efed3630
commit ac06c09040
31 changed files with 1072 additions and 147 deletions
@@ -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> = {}): 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')
})
})
@@ -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: `<repo>-<hex>`, 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<Record<string, CloudWorkspaceErrorCode>> = {
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<CloudWorkspaceResult<{ sandboxId: string }>> {
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
}
}
}
@@ -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<never> => {
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'
+53 -25
View File
@@ -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: <repo>-<hex>.
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
}
@@ -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<HostedSandboxControlPlaneClient, 'create' | 'bootPlan' | 'cloneStatus' | 'destroy'>,
spec: EnvironmentCreateSpec,
ctx?: EnvironmentProviderCallContext
): Promise<HostedSandboxReadyResponse> {
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
@@ -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 <percent> <stage>` 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 <percent> <stage>` 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)
+1 -2
View File
@@ -60,8 +60,7 @@ import {
type EphemeralVmFailure
} from '../../shared/ephemeral-vm-failure-copy'
import { registerRecipeForwardedEnvHandler } from './ephemeral-vm-forwarded-env'
const activeProvisionControllers = new Map<string, AbortController>()
import { activeProvisionControllers } from './provision-controllers'
export type EphemeralVmProvisionIpcResult =
| {
+4
View File
@@ -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<string, AbortController>()
@@ -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
})
@@ -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
@@ -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<string | null>(null)
const [pickedName, setPickedName] = useState<string | null>(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,
@@ -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'
@@ -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(
@@ -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}
@@ -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<string, unknown> = {}): 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,
@@ -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<PendingWorktreeCreation> = {}) {
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)
@@ -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'
})
})
})
@@ -5,7 +5,7 @@ type ReturnSource = Pick<PendingWorktreeCreation, 'heldChatMessage' | 'request'>
/** 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 } : {}),
@@ -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<string, unknown> = {}
): 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)
}
@@ -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']
])
})
})
@@ -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
)
}
-1
View File
@@ -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.",
@@ -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)
})
})
@@ -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<string, KeyState>()
/** 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()
}
@@ -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
}
@@ -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<unknown>>()
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)
})
})
@@ -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<boolean> {
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<string, string>()
/** 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<boolean> {
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<string | null> {
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<boolean> {
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
}
@@ -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
@@ -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',
@@ -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,
@@ -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<CloudWorkspaceResult<{ sandboxId: string }>>
onWakeProgress: (callback: (event: CloudWakeProgressEvent) => void) => () => void
onOom: (callback: (event: CloudOomEvent) => void) => () => void