mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #575 from nwparker/create-speed/gh-create-sheet
feat(cloud-create): GitHub-only Orca Cloud create shows the full build sheet (GH-CREATE-SHEET)
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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
-19
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23919,7 +23919,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
|
||||
|
||||
Reference in New Issue
Block a user