fix(cloud): build sheet round 4: glossary cards, GitHub not connected + no GitHub remote before any sandbox, measured start hint from /v1/config, Orca Cloud host label

- Account/billing cards use glossary titles (Out of sandbox hours, Spend cap reached, Payment failed,
  running-sandbox limits), role-aware next steps, and 'Nothing was created, and you weren't charged.'
- 409 github_not_connected: 'GitHub isn't connected for <owner/repo>' + Connect GitHub (POST
  /v1/github/connect, opens the GitHub install page) + Try again.
- A repo without a GitHub remote is refused in main before the control plane is called, with
  Start empty / Push to GitHub first.
- GET /v1/config serves createTiming (p50/p95 of recent create-to-ready); the sheet's 'usually ~Ns'
  reads it and shows no hint without a measurement.
- Orca Cloud servers display as 'Orca Cloud' in sidebar host labels and the usage segment; legacy
  '<repo> VM <id>' names are renamed at startup.
This commit is contained in:
Neil
2026-11-09 14:48:06 -08:00
parent 5696161da4
commit a8d6bb4698
48 changed files with 1093 additions and 136 deletions
@@ -0,0 +1,38 @@
// Measured sandbox start times for GET /v1/config, so the desktop's "usually ~Ns" hint is a real median.
import type { HostedSandboxTiming } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
/** Latest rows only: a cheap rowid scan, and the hint should follow the current pool and image. */
const RECENT_ROWS = 500
/** Fewer starts than this is noise, so clients show no hint rather than a guess. */
export const MIN_TIMING_SAMPLES = 10
/**
* `ready_at` moves on every wake, so a long gap is a later wake, not a slow start. Real starts
* finish inside the boot budget well before this.
*/
const MAX_START_MS = 120_000
function percentile(sorted: readonly number[], fraction: number): number {
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1))
return sorted[index]
}
export function summarizeStartTimes(durations: readonly number[]): HostedSandboxTiming | undefined {
const sorted = durations.filter((ms) => ms >= 0 && ms <= MAX_START_MS).sort((a, b) => a - b)
if (sorted.length < MIN_TIMING_SAMPLES) {
return undefined
}
return { p50Ms: percentile(sorted, 0.5), p95Ms: percentile(sorted, 0.95), samples: sorted.length }
}
/** Create-to-ready of the most recent sandboxes; undefined when too few have started. */
export async function recentCreateTiming(db: D1Database): Promise<HostedSandboxTiming | undefined> {
const { results } = await db
.prepare(
`SELECT ready_at - created_at AS ms FROM sandboxes
WHERE ready_at IS NOT NULL
ORDER BY rowid DESC LIMIT ?`
)
.bind(RECENT_ROWS)
.all<{ ms: number }>()
return summarizeStartTimes(results.map((row) => row.ms))
}
+7 -1
View File
@@ -33,6 +33,7 @@
import { ORCA_CLOUD_IDLE_SLEEP_MINUTES } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
import { z } from 'zod'
import { maintainPool, poolConfigFromEnv } from './pool'
import { recentCreateTiming } from './create-timing'
import { authenticateDesktop } from './auth'
import { authenticateApi } from './api/caller'
import { handleApi, handleApiAs, isApiPath, type ApiContext, type ApiExtension } from './api/router'
@@ -767,7 +768,12 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
}
if (url.pathname === '/v1/config' && request.method === 'GET') {
// Public product constants (no auth): every client reads these instead of hardcoding them.
return json({ idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES })
// A timing read failure must never take the constants down with it.
const createTiming = await recentCreateTiming(env.SANDBOX_DB).catch(() => undefined)
return Response.json(
{ idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, ...(createTiming ? { createTiming } : {}) },
{ headers: { 'cache-control': 'public, max-age=300' } }
)
}
if (url.pathname === '/v1/github/dev-connect' && env.SANDBOX_ENV !== 'prod') {
// Dev-only, link-shareable start of the user flow: mints a fresh 10-min state per click for a dev:<sub>
@@ -4,6 +4,7 @@ import {
ORCA_CLOUD_IDLE_SLEEP_MINUTES
} from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire'
import { DEFAULT_PLANS } from '../../billing/src/plans'
import { MIN_TIMING_SAMPLES, summarizeStartTimes } from '../src/create-timing'
import { createHarness } from './harness'
describe('GET /v1/config', () => {
@@ -14,5 +15,34 @@ describe('GET /v1/config', () => {
const body = HostedSandboxConfigResponseSchema.parse(await res.json())
expect(body.idleSleepMinutes).toBe(ORCA_CLOUD_IDLE_SLEEP_MINUTES)
expect(DEFAULT_PLANS.team.idleSleepMinutes).toBe(ORCA_CLOUD_IDLE_SLEEP_MINUTES)
// No sandbox has started yet, so there is no measured start time to advertise.
expect(body.createTiming).toBeUndefined()
})
it('advertises the measured start time once enough sandboxes have started', async () => {
const h = await createHarness()
for (let index = 0; index < MIN_TIMING_SAMPLES; index++) {
const created = await h.call('POST', '/v1/sandboxes', {
body: { workspaceId: `ws-${index}`, machineSize: 'standard' },
// One per user: the per-member running limit would refuse the fourth.
as: `user-${index}`
})
expect(created.status).toBe(200)
}
const body = HostedSandboxConfigResponseSchema.parse(
await (await h.send(new Request('https://sandbox.test/v1/config'))).json()
)
expect(body.createTiming?.samples).toBe(MIN_TIMING_SAMPLES)
})
})
describe('summarizeStartTimes', () => {
it('reports p50 and p95 and drops gaps that are later wakes, not starts', () => {
const starts = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10_000, 3_600_000]
expect(summarizeStartTimes(starts)).toEqual({ p50Ms: 5000, p95Ms: 10_000, samples: 10 })
})
it('shows no timing from too few starts', () => {
expect(summarizeStartTimes([1000, 2000])).toBeUndefined()
})
})
@@ -0,0 +1,67 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { encodePairingOffer } from '../../shared/remote-runtime/pairing/pairing'
import {
addEnvironmentFromPairingCode,
listEnvironments
} from '../../shared/remote-runtime/host-store/runtime-environment-store'
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
import { renameLegacyOrcaCloudEnvironments } from './legacy-orca-cloud-environment-names'
const CLOUD_RUNTIME = '6f1c2a9e-0b4d-4c11-9a3e-7d2f22b5b1e7'
const RECIPE_RUNTIME = '0aa1c2a9-0b4d-4c11-9a3e-7d2f9c0ffee1'
function runtime(
id: string,
runtimeEnvironmentId: string,
recipeId: string
): EphemeralVmRuntimeRecord {
return { id, recipeId, runtimeEnvironmentId } as EphemeralVmRuntimeRecord
}
describe('renameLegacyOrcaCloudEnvironments', () => {
let userDataPath: string
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-legacy-cloud-names-'))
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
function add(name: string, key: number): string {
return addEnvironmentFromPairingCode(userDataPath, {
name,
source: 'ephemeral-vm',
pairingCode: encodePairingOffer({
v: 2,
endpoint: 'wss://server.example/runtime',
publicKeyB64: Buffer.alloc(32, key).toString('base64'),
deviceToken: `token-${key}`
}),
now: 100
}).id
}
it('renames an Orca Cloud server saved as a VM and leaves recipe VMs alone', () => {
const cloud = add('repo VM 22b5b1e7', 1)
const recipe = add('repo VM 9c0ffee1', 2)
const renamed = renameLegacyOrcaCloudEnvironments(userDataPath, [
runtime(CLOUD_RUNTIME, cloud, 'cloud'),
runtime(RECIPE_RUNTIME, recipe, 'docker')
])
expect(renamed).toBe(1)
const names = Object.fromEntries(
listEnvironments(userDataPath).map((environment) => [environment.id, environment.name])
)
expect(names[cloud]).toBe('repo sandbox 22b5b1e7')
expect(names[recipe]).toBe('repo VM 9c0ffee1')
// Idempotent: a second start finds nothing left to rename.
expect(
renameLegacyOrcaCloudEnvironments(userDataPath, [runtime(CLOUD_RUNTIME, cloud, 'cloud')])
).toBe(0)
})
})
@@ -0,0 +1,55 @@
import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtimes'
import { ORCA_CLOUD_RECIPE_ID } from '../../shared/hosted-sandbox/orca-cloud-recipe-id'
import { normalizeTypedName } from '../../shared/typed-name-normalization'
import { legacyOrcaCloudEnvironmentName } from '../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
import {
readPersistedEnvironmentStore,
writeEnvironmentStore
} from '../../shared/remote-runtime/host-store/runtime-environment-store-file'
function isOrcaCloudRuntime(runtime: EphemeralVmRuntimeRecord): boolean {
return runtime.recipe?.provider === 'orca-cloud' || runtime.recipeId === ORCA_CLOUD_RECIPE_ID
}
/**
* Early builds saved an Orca Cloud server as "<repo> VM <id tail>", so the sidebar showed it as a
* raw VM. Renames those to the "<repo> sandbox <id tail>" form every surface shows as "Orca Cloud".
* Only servers an Orca Cloud runtime record points at are touched; custom recipe VMs keep "VM".
*/
export function renameLegacyOrcaCloudEnvironments(
userDataPath: string,
runtimes: readonly EphemeralVmRuntimeRecord[]
): number {
const renames = new Map<string, string>()
for (const runtime of runtimes) {
if (runtime.runtimeEnvironmentId && isOrcaCloudRuntime(runtime)) {
renames.set(runtime.runtimeEnvironmentId, runtime.id)
}
}
if (renames.size === 0) {
return 0
}
const store = readPersistedEnvironmentStore(userDataPath)
const taken = new Set(
store.environments.map((environment) => normalizeTypedName(environment.name))
)
let renamed = 0
const environments = store.environments.map((environment) => {
const runtimeId = renames.get(environment.id)
const next =
environment.source === 'ephemeral-vm' && runtimeId
? legacyOrcaCloudEnvironmentName(environment.name, runtimeId)
: null
// Names stay unique: never take one another saved server already has.
if (!next || taken.has(normalizeTypedName(next))) {
return environment
}
taken.add(normalizeTypedName(next))
renamed++
return { ...environment, name: next }
})
if (renamed > 0) {
writeEnvironmentStore(userDataPath, { ...store, environments })
}
return renamed
}
@@ -21,6 +21,7 @@ import type {
ProvisionEphemeralVmRuntimeResult
} from './runtime-service'
import { cleanupFailedEphemeralVmStart } from './failed-start-cleanup'
import { renameLegacyOrcaCloudEnvironments } from './legacy-orca-cloud-environment-names'
export type EphemeralVmWriteAhead = {
instanceId: string
@@ -160,6 +161,11 @@ export function reconcileInterruptedEphemeralVmProvisionsOnStartup(userDataPath:
} catch (error) {
console.warn('[ephemeral-vm] Could not reconcile interrupted provisions:', error)
}
try {
renameLegacyOrcaCloudEnvironments(userDataPath, listEphemeralVmRuntimes(userDataPath))
} catch (error) {
console.warn('[ephemeral-vm] Could not rename legacy Orca Cloud servers:', error)
}
}
/** Tracked VMs must go through Destroy; only result-less write-ahead rows can be dropped by hand. */
@@ -0,0 +1,37 @@
// Account-level Orca Cloud calls for the window: product config (measured timings) and GitHub connect.
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'
export const HOSTED_ACCOUNT_CHANNELS = [CH.getConfig, CH.connectGitHub] as const
export function registerHostedAccountHandlers(
client: Pick<HostedSandboxControlPlaneClient, 'config' | 'connectGitHub'>
): void {
// Timings move slowly and the Worker caches them for 5 minutes; one read per app session is plenty.
let config: Promise<HostedSandboxConfigResponse | null> | null = null
ipcMain.handle(CH.getConfig, () => {
config ??= client.config().catch(() => {
// Offline now says nothing about later: ask again next time.
config = null
return null
})
return config
})
ipcMain.handle(CH.connectGitHub, async () => {
try {
const { installUrl } = await client.connectGitHub()
// Only ever hand the browser a GitHub page, whatever the response says.
if (new URL(installUrl).origin !== 'https://github.com') {
return { ok: false, code: 'failed', message: 'Unexpected GitHub connect URL.' }
}
await shell.openExternal(installUrl)
return { ok: true, value: null }
} catch (error) {
return failure(error)
}
})
}
@@ -12,6 +12,9 @@ export {
} from './hosted-sandbox-request'
import {
HostedSandboxDesktopResponseSchema,
HostedSandboxConfigResponseSchema,
HostedGithubConnectResponseSchema,
type HostedSandboxConfigResponse,
HostedArchiveResponseSchema,
HostedWakeResponseSchema,
HostedWorkspaceStatusResponseSchema,
@@ -29,6 +32,9 @@ import {
type HostedSandboxReadyResponse,
type HostedSandboxView
} from '../../shared/hosted-sandbox/hosted-sandbox-wire'
import type { z } from 'zod'
type HostedGithubConnectResponse = z.infer<typeof HostedGithubConnectResponseSchema>
export type HostedSandboxControlPlaneClient = {
create(
@@ -71,6 +77,10 @@ export type HostedSandboxControlPlaneClient = {
): Promise<HostedSandboxSshAccess>
/** Starts the VM's remote desktop (idempotent); it listens on the VM's loopback only. */
startDesktop(sandboxId: string, signal?: AbortSignal): Promise<HostedSandboxDesktop>
/** Product constants and measured timings (GET /v1/config). */
config(signal?: AbortSignal): Promise<HostedSandboxConfigResponse>
/** Starts binding a GitHub App installation to the account; the browser finishes it. */
connectGitHub(signal?: AbortSignal): Promise<HostedGithubConnectResponse>
}
export function createHostedSandboxControlPlaneClient(
@@ -192,6 +202,13 @@ export function createHostedSandboxControlPlaneClient(
kind: 'control',
signal
})
).desktop
).desktop,
config: (signal) =>
call('GET', 'v1/config', HostedSandboxConfigResponseSchema, { kind: 'control', signal }),
connectGitHub: (signal) =>
call('POST', 'v1/github/connect', HostedGithubConnectResponseSchema, {
kind: 'control',
signal
})
}
}
@@ -2,7 +2,7 @@
// in-process through the HostedSandboxDriver, and answering like a recipe process would.
import { z } from 'zod'
import { EphemeralVmRecipeResultSchema } from '../../shared/ephemeral-vm-recipes'
import { ephemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
import { ephemeralVmFailure, type EphemeralVmFailure } from '../../shared/ephemeral-vm-failure-copy'
import { normalizeGitRemoteUrl } from '../../shared/git/remotes/git-remote-identity'
import type { OrcaVmRecipe } from '../../shared/orca-yaml-hook-types'
import {
@@ -61,6 +61,22 @@ export async function hostedSandboxRepoUrl(repoPath: string): Promise<string | n
return normalized?.startsWith('github.com/') ? `https://${normalized}.git` : null
}
/**
* The sandbox can only clone from GitHub, so a repo without a GitHub remote is refused before the
* control plane starts (and bills) a sandbox that would open without the code. `startEmpty` is the
* user choosing that empty workspace anyway.
*/
export async function refuseOrcaCloudWithoutGithub(
recipe: Pick<OrcaVmRecipe, 'provider'>,
repoPath: string,
startEmpty: boolean | undefined
): Promise<EphemeralVmFailure | null> {
if (recipe.provider !== 'orca-cloud' || startEmpty || (await hostedSandboxRepoUrl(repoPath))) {
return null
}
return ephemeralVmFailure('cloudNoGithubRemote')
}
function ok(stdout: string): RecipeStepProviderResult {
return { stdout, stderr: '', exitCode: 0, signal: null }
}
@@ -2,6 +2,7 @@
// progress and OOM notices. Maps the renderer's workspace id to its Orca Cloud runtime record.
import { failQueuedCloudPrompts } from './cloud-offline-service'
import { ipcMain, type BrowserWindow } from 'electron'
import { HOSTED_ACCOUNT_CHANNELS, registerHostedAccountHandlers } from './hosted-account-ipc'
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
@@ -168,7 +169,8 @@ export function installHostedWorkspaceService(deps: {
CH.archive,
CH.unarchive,
CH.getArchive,
CH.listCloudWorkspaceIds
CH.listCloudWorkspaceIds,
...HOSTED_ACCOUNT_CHANNELS
]) {
ipcMain.removeHandler(channel)
}
@@ -182,6 +184,8 @@ export function installHostedWorkspaceService(deps: {
.map((runtime) => runtime.workspaceId!)
)
registerHostedAccountHandlers(deps.client)
ipcMain.handle(CH.getStatus, async (_event, args: { workspaceId: string }) => {
const found = findCloudRuntime(deps.userDataPath, args.workspaceId)
if (!found) {
@@ -0,0 +1,99 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { registerEphemeralVmRecipeStepProvider } from '../../shared/ephemeral-vm-recipe-step-providers'
import { ORCA_CLOUD_RECIPE } from '../hosted-sandbox/hosted-sandbox-recipe'
const handlers = new Map<string, (_event: unknown, args: never) => unknown>()
const { getPathMock, handleMock } = vi.hoisted(() => ({
getPathMock: vi.fn(),
handleMock: vi.fn()
}))
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { getPath: getPathMock },
ipcMain: { handle: handleMock, removeHandler: vi.fn() }
}))
vi.mock('../hosted-sandbox/hosted-sandbox-install', async (importOriginal) => ({
...(await importOriginal<object>()),
withProviderRecipes: async () => [ORCA_CLOUD_RECIPE]
}))
import { registerEphemeralVmHandlers } from './ephemeral-vm'
const tempDirs: string[] = []
const unregister: (() => void)[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
for (const off of unregister.splice(0)) {
off()
}
vi.clearAllMocks()
})
function setup(remote: string | null) {
const userDataPath = makeTempDir('orca-cloud-remote-user-data-')
const repoPath = makeTempDir('orca-cloud-remote-repo-')
execFileSync('git', ['init', '-q'], { cwd: repoPath })
if (remote) {
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repoPath })
}
getPathMock.mockReturnValue(userDataPath)
handleMock.mockImplementation((channel: string, handler: never) => handlers.set(channel, handler))
const step = vi.fn(async () => ({
stdout: '',
stderr: 'stopped here\n',
exitCode: 1,
signal: null
}))
unregister.push(registerEphemeralVmRecipeStepProvider('orca-cloud', step))
const repo = { id: 'repo-1', path: repoPath, displayName: 'Repo', addedAt: 0 }
registerEphemeralVmHandlers({
getRepo: vi.fn((id: string) => (id === repo.id ? repo : null)),
getRepos: vi.fn(() => [repo]),
getSettings: vi.fn(() => ({ activeRuntimeEnvironmentId: null }))
} as never)
const provision = (extra: Record<string, unknown> = {}) =>
handlers.get('ephemeralVm:provision')?.(null, {
repoId: 'repo-1',
recipeId: ORCA_CLOUD_RECIPE.id,
...extra
} as never) as Promise<{ ok: boolean; errorCode?: string }>
return { step, provision }
}
it('refuses a repository with no GitHub remote before Orca Cloud is asked for a sandbox', async () => {
const { step, provision } = setup(null)
const result = await provision()
expect(result).toMatchObject({ ok: false, errorCode: 'cloudNoGithubRemote' })
expect(step).not.toHaveBeenCalled()
})
it('refuses a remote GitHub does not host, too', async () => {
const { step, provision } = setup('https://gitlab.com/acme/web.git')
expect(await provision()).toMatchObject({ ok: false, errorCode: 'cloudNoGithubRemote' })
expect(step).not.toHaveBeenCalled()
})
it('starts the sandbox when the user chose to start empty, or the repo is on GitHub', async () => {
const empty = setup(null)
await empty.provision({ startEmpty: true })
expect(empty.step).toHaveBeenCalledOnce()
const github = setup('git@github.com:acme/web.git')
await github.provision()
expect(github.step).toHaveBeenCalledOnce()
})
function makeTempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
+17 -1
View File
@@ -6,6 +6,7 @@ import type { EphemeralVmRuntimeRecord } from '../../shared/ephemeral-vm-runtime
import { isFolderRepo, isGitRepoKind } from '../../shared/repo-kind'
import type { OrcaCloudAvailability, OrcaVmRecipe } from '../../shared/orca-yaml-hook-types'
import type { PluginService } from '../plugins/plugin-service'
import { orcaCloudEnvironmentName } from '../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
import {
getOrcaCloudAvailability,
withProviderRecipes
@@ -26,6 +27,19 @@ export type EphemeralVmRecipeListResult = {
messageParams?: Record<string, string | number>
}
export type EphemeralVmProvisionArgs = {
repoId: string
recipeId: string
workspaceName?: string
projectId?: string
workspaceId?: string
branch?: string
ref?: string
provisionId?: string
/** Orca Cloud only: the user chose a workspace without their code (no GitHub remote). */
startEmpty?: boolean
}
export type EphemeralVmRecipeCatalogEntry = {
repoId: string
repoName: string
@@ -70,7 +84,9 @@ export function buildEphemeralEnvironmentName(
runtimeId: string,
recipe: { provider?: string }
): string {
return `${repo} ${recipe.provider === 'orca-cloud' ? 'sandbox' : 'VM'} ${runtimeId.slice(-8)}`
return recipe.provider === 'orca-cloud'
? orcaCloudEnvironmentName(repo, runtimeId)
: `${repo} VM ${runtimeId.slice(-8)}`
}
/** The Run-on picker's list, plus Orca Cloud availability so it can offer sign-in or an upsell. */
+8 -14
View File
@@ -35,11 +35,13 @@ import {
listPickerRecipes,
buildEphemeralEnvironmentName,
resolveRecipeForRepo,
type EphemeralVmRecipeCatalogEntry
type EphemeralVmRecipeCatalogEntry,
type EphemeralVmProvisionArgs
} from './ephemeral-vm-recipe-context'
import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers'
import type { PluginService } from '../plugins/plugin-service'
import { installHostedSandbox, withProviderRecipes } from '../hosted-sandbox/hosted-sandbox-install'
import { refuseOrcaCloudWithoutGithub } from '../hosted-sandbox/hosted-sandbox-recipe'
import { resolveProvisionedRootSource } from '../ephemeral-vm/provisioned-root-source'
import { getRuntimeEnvironmentRegistryRoot } from './runtime-environments/runtime-environment-registry-root'
import {
@@ -119,19 +121,7 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
ipcMain.handle(
'ephemeralVm:provision',
async (
_event,
args: {
repoId: string
recipeId: string
workspaceName?: string
projectId?: string
workspaceId?: string
branch?: string
ref?: string
provisionId?: string
}
): Promise<EphemeralVmProvisionIpcResult> => {
async (_event, args: EphemeralVmProvisionArgs): Promise<EphemeralVmProvisionIpcResult> => {
const repo = getRecipeRepo(store, args.repoId)
if (!repo.ok) {
return provisionFailure(repo.failure)
@@ -144,6 +134,10 @@ export function registerEphemeralVmHandlers(store: Store, pluginService?: Plugin
if (!recipe) {
return provisionFailure(ephemeralVmFailure('recipeNotFound', { recipeId: args.recipeId }))
}
const refused = await refuseOrcaCloudWithoutGithub(recipe, repo.repo.path, args.startEmpty)
if (refused) {
return provisionFailure(refused)
}
const controller = args.provisionId ? new AbortController() : null
if (args.provisionId && controller) {
activeProvisionControllers.set(args.provisionId, controller)
+2
View File
@@ -54,6 +54,8 @@ export type EphemeralVmApi = {
branch?: string
ref?: string
provisionId?: string
/** Orca Cloud only: start without the repository when it has no GitHub remote. */
startEmpty?: boolean
}) => Promise<
| {
ok: true
@@ -18,6 +18,8 @@ export const orcaCloudWorkspaceApi = {
unarchive: (args) => ipcRenderer.invoke(CH.unarchive, args),
getArchive: (args) => ipcRenderer.invoke(CH.getArchive, args),
listCloudWorkspaceIds: () => ipcRenderer.invoke(CH.listCloudWorkspaceIds),
getConfig: () => ipcRenderer.invoke(CH.getConfig),
connectGitHub: () => ipcRenderer.invoke(CH.connectGitHub),
onWakeProgress: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, event: CloudWakeProgressEvent): void =>
callback(event)
@@ -6,6 +6,7 @@ import {
import { NativeChatResumeOccurrenceModal } from './NativeChatResumeOnRestartModal'
import { useNativeChatRestartOfferEnabled } from './native-chat-restart-offer-gate'
import { useNativeChatRestartRuntimes } from './native-chat-restart-runtimes'
import { runtimeEnvironmentDisplayName } from '../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
export function NativeChatResumeOnRestartModal(): React.JSX.Element {
const enabled = useNativeChatRestartOfferEnabled()
@@ -17,7 +18,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element {
<NativeChatResumeRuntimeModal
key={environment.id}
store={store}
machineName={environment.name}
machineName={runtimeEnvironmentDisplayName(environment)}
offerEnabled
/>
))}
@@ -32,6 +32,7 @@ import { EventTime, ThreadAgentStateIndicator } from './activity-thread-controls
import { activityThreadRowCopy, threadAgentStateLabel } from './activity-thread-presentation'
import { getActivityThreadWorkspaceTitle } from '@/lib/activity-thread-display'
import type { AgentPaneThread } from './activity-thread-types'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
export function ActivityThreadHoverCardSummary({
thread,
@@ -59,7 +60,7 @@ export function ActivityThreadHoverCardSummary({
if (parsedHost?.kind === 'runtime') {
const environment = runtimeEnvironments.find((entry) => entry.id === parsedHost.environmentId)
if (environment?.name) {
return environment.name
return runtimeEnvironmentDisplayName(environment)
}
}
if (parsedHost?.kind === 'ssh') {
@@ -14,6 +14,7 @@ import type { AutomationsPageListState } from './use-automations-page-list-state
import type { AutomationsPageLocalState } from './use-automations-page-local-state'
import type { AutomationsPageStoreState } from './use-automations-page-store-state'
import { useTranslate } from '../../i18n/use-translate'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
/** Display labels and action availability derived from the selected host row. */
export function useAutomationsPagePresentationState({
@@ -96,7 +97,10 @@ export function useAutomationsPagePresentationState({
labels.set(`ssh:${encodeURIComponent(targetId)}`, label)
}
for (const environment of runtimeEnvironments) {
labels.set(`runtime:${encodeURIComponent(environment.id)}`, environment.name)
labels.set(
`runtime:${encodeURIComponent(environment.id)}`,
runtimeEnvironmentDisplayName(environment)
)
}
for (const [hostId, label] of hostLabelOverrides) {
labels.set(hostId, label)
@@ -19,6 +19,7 @@ import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-ov
import { isFolderRepo } from '../../../../shared/repo-kind'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
export type ActiveDashboardWorkspace = {
projectId: string
@@ -48,7 +49,7 @@ function buildHostLabelLookup(
labels.set(toSshExecutionHostId(targetId), label)
}
for (const environment of state.runtimeEnvironments ?? []) {
labels.set(toRuntimeExecutionHostId(environment.id), environment.name)
labels.set(toRuntimeExecutionHostId(environment.id), runtimeEnvironmentDisplayName(environment))
}
for (const [hostId, label] of getHostDisplayLabelOverrides(state.settings)) {
labels.set(hostId, label)
@@ -12,6 +12,7 @@ import {
import { useNativeChatRestartRuntimes } from '../native-chat-restart-runtimes'
import { resumeRunInFlight } from '../native-chat-resume-run'
import { resumeRunFailures, resumeRunView } from '../native-chat-resume-run-view'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
// Keep offers and in-flight/results visible after dialog snooze; statusBarItems must not hide the remaining resume surface.
@@ -153,7 +154,7 @@ export function NativeChatResumeStatusSegment({
key={environment.id}
iconOnly={iconOnly}
store={store}
machineName={environment.name}
machineName={runtimeEnvironmentDisplayName(environment)}
offerEnabled
/>
))}
@@ -42,6 +42,7 @@ import {
import { runtimeHostConnectionStateForEntry } from '@/runtime/hosts/connections/runtime-host-connection-state'
import { isConnectedRuntimeHostState } from '../../../../shared/remote-runtime/host-status/runtime-host-connection-state'
import { RuntimeRepairDialog } from '../runtime-access/RuntimeRepairDialog'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
export function SshStatusSegment({
compact,
@@ -110,7 +111,7 @@ export function SshStatusSegment({
const repairRoute = runtimePairingRepairRoute(environment)
return {
id: environment.id,
label: registryHost?.label || environment.name || environment.id,
label: registryHost?.label || runtimeEnvironmentDisplayName(environment) || environment.id,
blockedReason,
blocked: blockedReason
? {
@@ -16,6 +16,7 @@ import {
type OwnerUsageFailureReason,
type OwnerUsageState
} from './owner-usage-rate-limits'
import { runtimeEnvironmentDisplayName } from '../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
// Why: accounts.list({refreshUsage}) waits for the host's provider fetches.
const REMOTE_USAGE_REFRESH_TIMEOUT_MS = 30_000
@@ -59,9 +60,10 @@ export function useOwnerUsageRateLimits(local: RateLimitState): OwnerUsageRateLi
// Why: route by the focused workspace's owner, never by the Active Server setting.
const { target } = useActiveAccountOwner(LOCAL_TARGET)
const ownerId = runtimeTargetEnvironmentId(target)
const ownerName = useAppStore(
(s) => s.runtimeEnvironments.find((environment) => environment.id === ownerId)?.name ?? null
)
const ownerName = useAppStore((s) => {
const environment = s.runtimeEnvironments.find((entry) => entry.id === ownerId)
return environment ? runtimeEnvironmentDisplayName(environment) : null
})
const [owned, setOwned] = useState<OwnedUsage | null>(null)
const ownedRef = useRef<OwnedUsage | null>(null)
@@ -3,6 +3,8 @@ import {
Bot,
ChevronDown,
CreditCard,
FolderPlus,
Github,
Loader2,
LifeBuoy,
LogIn,
@@ -33,6 +35,9 @@ export type VmBuildFailureActions = {
onSignIn: () => void
onShowWorkspaces: () => void
onContactSupport: () => void
onConnectGitHub: () => void
onStartEmpty: () => void
onPushToGitHub: () => void
}
type Action = {
@@ -50,6 +55,7 @@ function failureActions(
canFixWithAgent: boolean,
canManageBilling: boolean,
signingIn: boolean,
connectingGitHub: boolean,
actions: VmBuildFailureActions,
t: Translate
): { primary: Action[]; more: Action[] } {
@@ -99,10 +105,15 @@ function failureActions(
'auto.components.worktree.creation.VmBuildFailureCard.updatePayment',
'Update payment method'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.manageBilling',
'Manage billing'
),
: failure.kind === 'spendCap'
? t(
'auto.components.worktree.creation.VmBuildFailureCard.raiseSpendCap',
'Raise spend cap'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.turnOnOverage',
'Turn on overage'
),
icon: <CreditCard />,
onClick: actions.onOpenBilling
}
@@ -192,6 +203,52 @@ function failureActions(
]
}
case 'githubNotConnected':
return {
primary: [
{
key: 'connectGitHub',
label: connectingGitHub
? t(
'auto.components.worktree.creation.VmBuildFailureCard.openingGitHub',
'Opening GitHub…'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.connectGitHub',
'Connect GitHub'
),
icon: connectingGitHub ? <Loader2 className="motion-safe:animate-spin" /> : <Github />,
onClick: actions.onConnectGitHub,
disabled: connectingGitHub
},
tryAgain
],
more: []
}
case 'noGithubRemote':
return {
primary: [
{
key: 'startEmpty',
label: t(
'auto.components.worktree.creation.VmBuildFailureCard.startEmpty',
'Start empty'
),
icon: <FolderPlus />,
onClick: actions.onStartEmpty
},
{
key: 'pushToGitHub',
label: t(
'auto.components.worktree.creation.VmBuildFailureCard.pushToGitHubFirst',
'Push to GitHub first'
),
icon: <SquareArrowOutUpRight />,
onClick: actions.onPushToGitHub
}
],
// After pushing, the same create works without leaving the sheet.
more: [tryAgain]
}
case 'unavailable':
case 'network':
case 'create':
@@ -206,6 +263,7 @@ export function FailureActionRow({
canFixWithAgent,
canManageBilling,
signingIn,
connectingGitHub,
actions,
t
}: {
@@ -215,6 +273,7 @@ export function FailureActionRow({
canFixWithAgent: boolean
canManageBilling: boolean
signingIn: boolean
connectingGitHub: boolean
actions: VmBuildFailureActions
t: Translate
}): React.JSX.Element {
@@ -224,6 +283,7 @@ export function FailureActionRow({
canFixWithAgent,
canManageBilling,
signingIn,
connectingGitHub,
actions,
t
)
@@ -18,6 +18,8 @@ export function VmBuildFailureCard({
canFixWithAgent,
canManageBilling,
signingIn,
connectingGitHub,
repoName,
actions
}: {
failure: VmBuildFailure | null
@@ -30,6 +32,9 @@ export function VmBuildFailureCard({
canFixWithAgent: boolean
canManageBilling: boolean
signingIn: boolean
connectingGitHub: boolean
/** How the user knows the repository, for cards that name it. */
repoName: string
actions: VmBuildFailureActions
}): React.JSX.Element {
const t = useTranslate()
@@ -48,7 +53,9 @@ export function VmBuildFailureCard({
setShown(failure)
}
const visible = failure ?? shown
const copy = visible ? vmBuildFailureCopy(visible, { cloud, canManageBilling }, t) : null
const copy = visible
? vmBuildFailureCopy(visible, { cloud, canManageBilling, repoName }, t)
: null
const headingRef = React.useRef<HTMLHeadingElement>(null)
const cardRef = React.useRef<HTMLDivElement>(null)
// Move focus to the card when it opens, so keyboard and screen-reader users land on the way forward.
@@ -114,6 +121,7 @@ export function VmBuildFailureCard({
canFixWithAgent={canFixWithAgent}
canManageBilling={canManageBilling}
signingIn={signingIn}
connectingGitHub={connectingGitHub}
actions={actions}
t={t}
/>
@@ -27,6 +27,13 @@ import {
} from './vm-build-failure'
import { deriveVmBuildSteps, isOrcaCloudBuild, type VmBuildStep } from './vm-build-steps'
import { useMinimumRunningSteps } from './use-minimum-running-steps'
import {
connectOrcaCloudGitHub,
orcaCloudRepoName,
PUSH_TO_GITHUB_URL,
startOrcaCloudCreateEmpty
} from './vm-build-repo-actions'
import { useVmBuildStepTimings, type VmBuildStepTimings } from './vm-build-timing'
import { headerTitle, stepLabel, type SheetState } from './vm-build-sheet-copy'
import { humanizeOrcaCloudLogLine, latestOrcaCloudPhaseLabel } from './orca-cloud-build-log'
@@ -79,7 +86,8 @@ export function VmBuildSheet({
onDismiss,
onEditRecipe,
agentSignInRow,
cloudAgentStep
cloudAgentStep,
stepTimings
}: {
creationId: string
entry: PendingWorktreeCreation
@@ -95,6 +103,8 @@ export function VmBuildSheet({
agentSignInRow?: React.ReactNode
/** Fixtures only: a fixed "Add an agent" step in place of the live one. */
cloudAgentStep?: React.ReactNode
/** Fixtures only: measured step timings in place of the control plane's. */
stepTimings?: VmBuildStepTimings
}): React.JSX.Element {
const t = useTranslate()
const logId = React.useId()
@@ -103,6 +113,8 @@ export function VmBuildSheet({
[entry.provisioningLog]
)
const cloud = isOrcaCloudBuild(entry)
const measuredTimings = useVmBuildStepTimings(cloud && stepTimings === undefined)
const timings = stepTimings ?? measuredTimings
const formatLine = React.useMemo(
() => (cloud ? (line: string) => humanizeOrcaCloudLogLine(line, t) : undefined),
[cloud, t]
@@ -131,6 +143,16 @@ export function VmBuildSheet({
: 'building'
const building = state === 'building'
const sourceRepo = useAppStore((state) =>
recipe ? state.repos.find((repo) => repo.id === recipe.sourceRepoId) : undefined
)
const repoName = orcaCloudRepoName(sourceRepo)
const [connectingGitHub, setConnectingGitHub] = React.useState(false)
const connectGitHub = (): void => {
setConnectingGitHub(true)
void connectOrcaCloudGitHub().finally(() => setConnectingGitHub(false))
}
const signIn = (): void => {
void useAppStore
.getState()
@@ -204,6 +226,7 @@ export function VmBuildSheet({
? latestOrcaCloudPhaseLabel(entry.provisioningLog ?? '', t)
: null
}
timing={timings[step.id] ?? null}
t={t}
/>
))}
@@ -226,6 +249,8 @@ export function VmBuildSheet({
canFixWithAgent={Boolean(recipe)}
canManageBilling={canManageBilling}
signingIn={signingIn}
connectingGitHub={connectingGitHub}
repoName={repoName}
actions={{
onFixWithAgent: () => {
if (recipe) {
@@ -251,7 +276,10 @@ export function VmBuildSheet({
},
onSignIn: signIn,
onShowWorkspaces: () => useAppStore.getState().setSidebarOpen(true),
onContactSupport: () => openAppHttpLink(SUPPORT_URL)
onContactSupport: () => openAppHttpLink(SUPPORT_URL),
onConnectGitHub: connectGitHub,
onStartEmpty: () => startOrcaCloudCreateEmpty(creationId, onRetry),
onPushToGitHub: () => openAppHttpLink(PUSH_TO_GITHUB_URL)
}}
/>
</div>
@@ -7,6 +7,7 @@ import {
FAILED_LOG,
LOG,
frame,
seedFixtureRepo,
T0
} from './vm-build-sheet-fixture-support'
@@ -56,22 +57,53 @@ export const cloudSpendCap = cloudFailure(
`${CLOUD_LOG}\nOrca Cloud: overage_cap_reached`
)
export const cloudHoursUsedMember = defineFixture({
...cloudFailure(
// A team member: billing is someone else's to change, so the card says who can.
function asMember(fixture: ReturnType<typeof cloudFailure>) {
return defineFixture({
...fixture,
setup: () => {
seedFixtureRepo()
window.api.orcaProfiles.billingStatus = () =>
Promise.resolve({
status: 'ok',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the card reads only canManageBilling.
entitlement: { canManageBilling: false } as never
})
}
})
}
export const cloudHoursUsedMember = asMember(
cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours.',
`${CLOUD_LOG}\nOrca Cloud: included_hours_exhausted`
),
setup: () => {
// A team member: billing is someone else's to change.
window.api.orcaProfiles.billingStatus = () =>
Promise.resolve({
status: 'ok',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the card reads only canManageBilling.
entitlement: { canManageBilling: false } as never
})
}
})
)
)
export const cloudSpendCapMember = asMember(
cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours.',
`${CLOUD_LOG}\nOrca Cloud: overage_cap_reached`
)
)
export const cloudPaymentFailedMember = asMember(
cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours.',
`${CLOUD_LOG}\nOrca Cloud: payment_past_due`
)
)
export const cloudPlanRequiredMember = asMember(
cloudFailure(
'cloudPlanRequired',
'Orca Cloud workspaces need a plan with cloud hours. Open Plan & billing to choose one.',
''
)
)
export const cloudMemberConcurrency = cloudFailure(
'cloudConcurrencyLimit',
@@ -85,6 +117,30 @@ export const cloudGithubNotConnected = cloudFailure(
`${CLOUD_LOG}\nOrca Cloud: github_not_connected`
)
export const cloudGithubNotConnectedHttps = defineFixture({
...cloudGithubNotConnected,
setup: () => seedFixtureRepo('https://github.com/nwparker/orca-sandbox-push-test.git')
})
// Refused on this computer before the control plane is asked: no sandbox, no charge.
export const cloudNoGithubRemote = defineFixture({
...cloudFailure(
'cloudNoGithubRemote',
'Orca Cloud clones your code from GitHub, and this repository has no GitHub remote.',
''
),
setup: () => seedFixtureRepo(null)
})
export const cloudNoGithubRemoteMore = defineFixture({
...cloudNoGithubRemote,
interact: (frameElement) => {
Array.from(frameElement.querySelectorAll('button'))
.find((button) => button.textContent?.includes('More'))
?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
}
})
export const cloudFailedLogsOpen = defineFixture({
...cloudFailure('cloudUnavailable', 'Orca Cloud is unavailable right now.', CLOUD_FAILED_LOG),
interact: (frameElement) => {
@@ -5,7 +5,7 @@ import type { Translate } from '@/i18n/use-translate'
import { Progress } from '@/components/ui/progress'
import { StepIcon } from './WorktreeSetupSteps'
import type { VmBuildStep } from './vm-build-steps'
import { vmBuildStepTiming } from './vm-build-timing'
import { typicalStepSeconds, type VmBuildStepTiming } from './vm-build-timing'
import { formatElapsedTime } from './worktree-creation-clock'
import { stepLabel, stepStateLabel } from './vm-build-sheet-copy'
@@ -14,18 +14,20 @@ export function VmBuildStepRow({
step,
cloud,
detail,
timing,
t
}: {
step: VmBuildStep
cloud: boolean
/** The step's measured duration, when there is one. */
timing: VmBuildStepTiming | null
/** What the running step is doing right now, in plain words. */
detail: string | null
t: Translate
}): React.JSX.Element {
const labelId = React.useId()
const timing = step.state === 'running' ? vmBuildStepTiming(step.id, cloud) : null
// A live percent already says how far along the step is.
const hint = timing && step.percent === null ? timing : null
const hint = step.state === 'running' && step.percent === null ? timing : null
const slow = hint !== null && step.elapsedMs !== null && step.elapsedMs > hint.p95Ms
return (
@@ -65,7 +67,7 @@ export function VmBuildStepRow({
: t(
'auto.components.worktree.creation.VmBuildSheet.usually',
'usually ~{{seconds}}s',
{ seconds: Math.max(1, Math.round(hint.p50Ms / 1000)) }
{ seconds: typicalStepSeconds(hint) }
)}
</span>
) : null}
@@ -3,6 +3,8 @@ import type { VmBuildFailure } from './vm-build-failure'
export type VmBuildFailureCopyContext = {
cloud: boolean
/** How the user knows the repository: its GitHub owner/name, else its name in Orca. */
repoName: string
/** False for team members who can't change billing; they are told to ask an owner or admin. */
canManageBilling: boolean
}
@@ -10,7 +12,7 @@ export type VmBuildFailureCopyContext = {
/** The card's cause-specific title and one plain-language sentence on what to do next. */
export function vmBuildFailureCopy(
failure: VmBuildFailure,
{ cloud, canManageBilling }: VmBuildFailureCopyContext,
{ cloud, canManageBilling, repoName }: VmBuildFailureCopyContext,
t: Translate
): { title: string; body: string | null } {
switch (failure.kind) {
@@ -40,93 +42,106 @@ export function vmBuildFailureCopy(
),
body: canManageBilling
? t(
'auto.components.worktree.creation.VmBuildFailureCard.planBody',
'auto.components.worktree.creation.VmBuildFailureCard.planBody2',
'Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.planBodyMember',
'auto.components.worktree.creation.VmBuildFailureCard.planBodyMember2',
'Ask an owner or admin to choose a Team plan. Nothing was created, and you weren’t charged.'
)
}
case 'hoursUsed':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.hoursUsedTitle',
'You’ve used this month’s included sandbox hours'
'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursTitle',
'Out of sandbox hours'
),
body: canManageBilling
? t(
'auto.components.worktree.creation.VmBuildFailureCard.hoursUsedBody',
'Turn on overage in Plan & billing to keep starting sandboxes. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody',
'Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.hoursUsedBodyMember',
'Ask an owner or admin to turn on overage. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember',
'Your team has used this month’s sandbox hours. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.'
)
}
case 'spendCap':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.spendCapTitle',
'You’ve reached this month’s spend cap'
'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedTitle',
'Spend cap reached'
),
body: canManageBilling
? t(
'auto.components.worktree.creation.VmBuildFailureCard.spendCapBody',
'Raise the monthly spend cap in Plan & billing to keep starting sandboxes. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody',
'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.spendCapBodyMember',
'Ask an owner or admin to raise the monthly spend cap. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember',
'Your team has reached this month’s spend cap. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.'
)
}
case 'payment':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.paymentTitle',
'Your Orca payment needs attention'
'auto.components.worktree.creation.VmBuildFailureCard.paymentFailedTitle',
'Payment failed'
),
body: canManageBilling
? t(
'auto.components.worktree.creation.VmBuildFailureCard.paymentBody',
'Update your payment method to start cloud workspaces again. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.paymentFailedBody',
'New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.'
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.paymentBodyMember',
'Ask an owner or admin to update the payment method. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.paymentFailedBodyMember',
'New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.'
)
}
case 'concurrency':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.concurrencyTitle',
'Your team is running as many cloud workspaces as its plan allows'
'auto.components.worktree.creation.VmBuildFailureCard.teamLimitTitle',
'You’re at your team’s limit of running sandboxes'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.concurrencyBody',
'Put a running cloud workspace to sleep or delete it, then try again. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'memberConcurrency':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.memberConcurrencyTitle',
'You’re running as many cloud workspaces as you can at once'
'auto.components.worktree.creation.VmBuildFailureCard.memberLimitTitle',
'You’re at your limit of running sandboxes'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.memberConcurrencyBody',
'Put one of yours to sleep or delete it, then try again. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'githubNotConnected':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.githubTitle',
'Connect GitHub to use this repository'
'auto.components.worktree.creation.VmBuildFailureCard.githubNotConnectedTitle',
'GitHub isn’t connected for {{repo}}',
{ repo: repoName }
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.githubBody',
'Orca Cloud needs access to GitHub to clone this private repository. Connect GitHub to your Orca account, then try again. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.githubNotConnectedBody',
'Orca Cloud clones your code from GitHub and needs access to this repository first. Connect GitHub, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'noGithubRemote':
return {
title: t(
'auto.components.worktree.creation.VmBuildFailureCard.noGithubRemoteTitle',
'{{repo}} isn’t on GitHub',
{ repo: repoName }
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.noGithubRemoteBody',
'Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub, or start with an empty workspace. Nothing was created, and you weren’t charged.'
)
}
case 'signIn':
@@ -136,8 +151,8 @@ export function vmBuildFailureCopy(
'Sign in to use Orca Cloud'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.signInBody2',
'Cloud workspaces run on your Orca account. Sign in and Orca starts this one for you. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.signInBody3',
'Cloud workspaces run on your Orca account. Sign in and Orca starts this one for you. Nothing was created, and you weren’t charged.'
)
}
case 'unavailable':
@@ -147,8 +162,8 @@ export function vmBuildFailureCopy(
'Orca Cloud is unavailable right now'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.unavailableBody',
'The problem is on our side, not in your project. Nothing was created. Try again in a moment.'
'auto.components.worktree.creation.VmBuildFailureCard.unavailableBody2',
'The problem is on our side, not in your project. Nothing was created, and you weren’t charged. Try again in a moment.'
)
}
case 'network':
@@ -158,8 +173,8 @@ export function vmBuildFailureCopy(
'Couldn’t reach Orca Cloud'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.networkBody2',
'Check your internet connection, then try again. Nothing was created.'
'auto.components.worktree.creation.VmBuildFailureCard.networkBody3',
'Check your internet connection, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'snapshotMissing':
@@ -169,8 +184,8 @@ export function vmBuildFailureCopy(
'This project’s saved environment is missing'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.snapshotBody2',
'The sandbox starts from a saved copy of your environment, and that copy couldn’t be found. Nothing was created. Try again, or contact support if it keeps happening.'
'auto.components.worktree.creation.VmBuildFailureCard.snapshotBody3',
'The sandbox starts from a saved copy of your environment, and that copy couldn’t be found. Nothing was created, and you weren’t charged. Try again, or contact support if it keeps happening.'
)
}
case 'provision':
@@ -5,6 +5,8 @@ import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-w
import { classifyVmBuildFailure } from './vm-build-failure'
import { vmBuildFailureCopy } from './vm-build-failure-copy'
import { isOrcaCloudBuild } from './vm-build-steps'
import { orcaCloudRepoName } from './vm-build-repo-actions'
import { useAppStore } from '@/store'
/** The build sheet's card as a toast, for a VM build that failed while its sheet was off screen. */
export function showVmBuildFailureToast(entry: PendingWorktreeCreation, error: string): void {
@@ -14,7 +16,17 @@ export function showVmBuildFailureToast(entry: PendingWorktreeCreation, error: s
return
}
// Unknown here; the card itself refines the copy for members once billing status loads.
const { title, body } = vmBuildFailureCopy(failure, { cloud, canManageBilling: true }, translate)
const recipe = entry.request.ephemeralVmRecipe
const repoName = orcaCloudRepoName(
recipe
? useAppStore.getState().repos.find((repo) => repo.id === recipe.sourceRepoId)
: undefined
)
const { title, body } = vmBuildFailureCopy(
failure,
{ cloud, canManageBilling: true, repoName },
translate
)
toast.error(title, {
...(body ? { description: body } : {}),
...(failure.kind === 'plan'
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import { classifyVmBuildFailure, isAccountGate, type VmBuildFailure } from './vm-build-failure'
import { vmBuildFailureCopy } from './vm-build-failure-copy'
import { orcaCloudRepoName } from './vm-build-repo-actions'
// English defaults with {{slots}} filled, like i18next with no catalog.
const t = ((_key: string, fallback: string, params?: Record<string, unknown>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_slot, name: string) =>
String(params?.[name] ?? '')
)) as Parameters<typeof vmBuildFailureCopy>[2]
function classify(
errorCode: string | undefined,
log = '',
error = 'failed'
): VmBuildFailure | null {
return classifyVmBuildFailure({
entry: {
phase: 'provisioning-vm',
errorCode,
provisioningLog: log,
environmentCheck: undefined
},
error,
cloud: true
})
}
describe('classifyVmBuildFailure', () => {
it('names each control-plane refusal from its streamed deny reason', () => {
const kinds = Object.fromEntries(
[
['cloudPlanRequired', 'included_hours_exhausted'],
['cloudPlanRequired', 'overage_cap_reached'],
['cloudPlanRequired', 'payment_grace_expired'],
['cloudConcurrencyLimit', 'member_concurrency_limit'],
['cloudConcurrencyLimit', 'concurrency_limit']
].map(([code, reason]) => [reason, classify(code, `Orca Cloud: ${reason}`)?.kind])
)
expect(kinds).toEqual({
included_hours_exhausted: 'hoursUsed',
overage_cap_reached: 'spendCap',
payment_grace_expired: 'payment',
member_concurrency_limit: 'memberConcurrency',
concurrency_limit: 'concurrency'
})
expect(classify('cloudPlanRequired')?.kind).toBe('plan')
})
it('treats GitHub refusals as gates that started nothing', () => {
for (const code of ['cloudGithubNotConnected', 'cloudNoGithubRemote']) {
expect(isAccountGate(classify(code))).toBe(true)
}
})
})
describe('vmBuildFailureCopy', () => {
const kinds: VmBuildFailure[] = [
{ kind: 'plan' },
{ kind: 'hoursUsed' },
{ kind: 'spendCap' },
{ kind: 'payment' },
{ kind: 'concurrency' },
{ kind: 'memberConcurrency' },
{ kind: 'githubNotConnected' },
{ kind: 'noGithubRemote' },
{ kind: 'signIn' },
{ kind: 'unavailable' },
{ kind: 'network' },
{ kind: 'snapshotMissing' },
{ kind: 'provision', error: 'Recipe exited with code 1.' },
{ kind: 'create', error: 'checkout failed' }
]
it('never shows codes, ids or banned glossary terms on an Orca Cloud card', () => {
for (const canManageBilling of [true, false]) {
for (const failure of kinds) {
const { title, body } = vmBuildFailureCopy(
failure,
{ cloud: true, canManageBilling, repoName: 'acme/web' },
t
)
const text = `${title} ${body ?? ''}`
expect(text).not.toMatch(
/[a-z]+_[a-z]+|\bVM\b|included hours|hours exhausted|overage limit/
)
// Nothing exists yet at create time, so there are no files to reassure about.
expect(text).not.toContain('Your files are safe')
}
}
})
it('uses the glossary titles and role-aware next steps for billing refusals', () => {
const copy = (kind: VmBuildFailure, canManageBilling: boolean) =>
vmBuildFailureCopy(kind, { cloud: true, canManageBilling, repoName: 'acme/web' }, t)
expect(copy({ kind: 'hoursUsed' }, true).title).toBe('Out of sandbox hours')
expect(copy({ kind: 'spendCap' }, true).title).toBe('Spend cap reached')
expect(copy({ kind: 'payment' }, true).title).toBe('Payment failed')
expect(copy({ kind: 'hoursUsed' }, false).body).toContain('Ask an owner or admin to add hours.')
expect(copy({ kind: 'payment' }, false).body).toContain(
'Ask an owner or admin to update the payment method.'
)
})
it('names the repository GitHub needs to reach', () => {
const { title } = vmBuildFailureCopy(
{ kind: 'githubNotConnected' },
{ cloud: true, canManageBilling: true, repoName: 'acme/web' },
t
)
expect(title).toBe('GitHub isn’t connected for acme/web')
})
})
describe('orcaCloudRepoName', () => {
it('prefers the GitHub owner/name, falling back to the name in Orca', () => {
expect(
orcaCloudRepoName({
displayName: 'web',
gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/web.git' }
} as never)
).toBe('acme/web')
expect(orcaCloudRepoName({ displayName: 'web', gitRemoteIdentity: null })).toBe('web')
})
})
@@ -10,6 +10,8 @@ export type VmBuildAccountFailureKind =
| 'concurrency'
| 'memberConcurrency'
| 'githubNotConnected'
/** Refused before the control plane was asked: the sandbox could not clone this repository. */
| 'noGithubRemote'
| 'signIn'
| 'unavailable'
| 'network'
@@ -28,7 +30,8 @@ const ERROR_CODE_KINDS: Partial<Record<string, VmBuildAccountFailureKind>> = {
cloudConcurrencyLimit: 'concurrency',
cloudSignInRequired: 'signIn',
cloudUnavailable: 'unavailable',
cloudGithubNotConnected: 'githubNotConnected'
cloudGithubNotConnected: 'githubNotConnected',
cloudNoGithubRemote: 'noGithubRemote'
}
// The hosted provider logs the control plane's deny reason as `Orca Cloud: <reason>`.
@@ -94,7 +97,8 @@ const ACCOUNT_GATES: ReadonlySet<VmBuildFailure['kind']> = new Set([
'signIn',
'concurrency',
'memberConcurrency',
'githubNotConnected'
'githubNotConnected',
'noGithubRemote'
])
// Nothing started, so no step failed: steps stay pending instead of showing a red failure.
@@ -0,0 +1,48 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { normalizeGitRemoteUrl } from '../../../../shared/git/remotes/git-remote-identity'
import type { Repo } from '../../../../shared/repo-types'
/** GitHub's guide to publishing a local repository: the fix for a repo Orca Cloud can't clone. */
export const PUSH_TO_GITHUB_URL =
'https://docs.github.com/en/migrations/importing-source-code/using-the-command-line-to-import-source-code/adding-locally-hosted-code-to-github'
/** The repository as the user knows it: its GitHub owner/name, else its name in Orca. */
export function orcaCloudRepoName(
repo: Pick<Repo, 'displayName' | 'gitRemoteIdentity'> | undefined
): string {
const remote = repo?.gitRemoteIdentity?.remoteUrl
const normalized = remote ? normalizeGitRemoteUrl(remote) : null
if (normalized?.startsWith('github.com/')) {
return normalized.slice('github.com/'.length)
}
return repo?.displayName ?? ''
}
/** Retries the create without the repository: the user chose an empty cloud workspace. */
export function startOrcaCloudCreateEmpty(creationId: string, retry: () => void): void {
const store = useAppStore.getState()
const entry = store.pendingWorktreeCreations[creationId]
const recipe = entry?.request.ephemeralVmRecipe
if (!entry || !recipe) {
return
}
store.updatePendingWorktreeCreation(creationId, {
request: { ...entry.request, ephemeralVmRecipe: { ...recipe, startEmpty: true } }
})
retry()
}
/** Opens GitHub so the account can give Orca Cloud access; the user comes back and tries again. */
export async function connectOrcaCloudGitHub(): Promise<void> {
const result = await window.api.orcaCloudWorkspace.connectGitHub().catch(() => null)
if (!result?.ok) {
toast.error(
translate(
'auto.components.worktree.creation.VmBuildFailureCard.connectGitHubFailed',
'Couldn’t open GitHub. Try again in a moment.'
)
)
}
}
@@ -4,6 +4,9 @@ import { defineFixture } from '../../component-fixtures/component-fixture'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
import { VmBuildSheet } from './VmBuildSheet'
import { formatElapsedTime } from './worktree-creation-clock'
import type { VmBuildStepTimings } from './vm-build-timing'
import { useAppStore } from '@/store'
import type { Repo } from '../../../../shared/repo-types'
export const LOG_LINES = [
'Creating sandbox from snapshot snap_7f2c…',
@@ -63,18 +66,37 @@ export function sheet(
onDismiss={() => {}}
onEditRecipe={() => {}}
agentSignInRow={agentSignInRow}
stepTimings={FIXTURE_STEP_TIMINGS}
/>
</div>
)
}
// What the dev control plane measured (GET /v1/config): starts p50 2.9 s, p95 4.0 s.
const FIXTURE_STEP_TIMINGS: VmBuildStepTimings = { provision: { p50Ms: 2_900, p95Ms: 4_000 } }
/** The repository a cloud card names: a GitHub remote reads as owner/name. */
export function seedFixtureRepo(remoteUrl: string | null = 'git@github.com:acme/web.git'): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sheet reads only id, displayName and gitRemoteIdentity.
const repo = {
id: 'repo',
displayName: 'web',
gitRemoteIdentity: remoteUrl ? { remoteUrl } : null
} as unknown as Repo
useAppStore.setState({ repos: [repo] })
}
export function frame(
value: PendingWorktreeCreation,
error: string | null = null,
now = NOW,
agentSignInRow?: React.ReactNode
) {
return defineFixture({ width: 760, render: () => sheet(value, error, now, agentSignInRow) })
return defineFixture({
width: 760,
setup: () => seedFixtureRepo(),
render: () => sheet(value, error, now, agentSignInRow)
})
}
export const CLOUD_LOG = '[orca-cloud] checking-entitlement\n[orca-cloud] provisioning'
@@ -1,17 +1,56 @@
import React from 'react'
import type { HostedSandboxTiming } from '../../../../shared/hosted-sandbox/hosted-sandbox-wire'
import type { VmBuildStepId } from './vm-build-steps'
/** p50 drives the "usually ~Ns" hint; past p95 the step reads as slower than usual. */
export type VmBuildStepTiming = { p50Ms: number; p95Ms: number }
export type VmBuildStepTiming = Pick<HostedSandboxTiming, 'p50Ms' | 'p95Ms'>
/**
* Measured Orca Cloud timings (dev, warm pool on): create p50 2.9 s / p95 4.0 s, of which the VM is
* ~0.26 s and orcad 2.1 s / 3.4 s; wake p50 4.7 s / p95 7.3 s. Steps without a measurement show no
* hint rather than a guess. Tune here.
*/
export const ORCA_CLOUD_STEP_TIMING: Partial<Record<VmBuildStepId, VmBuildStepTiming>> = {
provision: { p50Ms: 2_900, p95Ms: 4_000 }
export type VmBuildStepTimings = Partial<Record<VmBuildStepId, VmBuildStepTiming>>
const NONE: VmBuildStepTimings = {}
// One read per window: the control plane measures recent sandbox starts (GET /v1/config) and
// main caches the answer, so every build sheet shows the same median.
let measured: VmBuildStepTimings | null = null
let pending: Promise<VmBuildStepTimings> | null = null
function loadMeasuredTimings(): Promise<VmBuildStepTimings> {
pending ??= (window.api.orcaCloudWorkspace?.getConfig?.() ?? Promise.resolve(null))
.then((config) => {
// Only "Start sandbox" is measured. Without a measurement there is no hint, never a guess.
measured = config?.createTiming ? { provision: config.createTiming } : NONE
return measured
})
.catch(() => {
pending = null
return NONE
})
return pending
}
export function vmBuildStepTiming(stepId: VmBuildStepId, cloud: boolean): VmBuildStepTiming | null {
return cloud ? (ORCA_CLOUD_STEP_TIMING[stepId] ?? null) : null
/** Measured Orca Cloud step timings; empty for VM recipes and until the control plane answers. */
export function useVmBuildStepTimings(cloud: boolean): VmBuildStepTimings {
const [timings, setTimings] = React.useState<VmBuildStepTimings>(() =>
cloud ? (measured ?? NONE) : NONE
)
React.useEffect(() => {
if (!cloud || measured) {
return
}
let live = true
void loadMeasuredTimings().then((next) => {
if (live) {
setTimings(next)
}
})
return () => {
live = false
}
}, [cloud])
return cloud ? (measured ?? timings) : NONE
}
/** The hint in whole seconds, never "~0s". */
export function typicalStepSeconds(timing: VmBuildStepTiming): number {
return Math.max(1, Math.round(timing.p50Ms / 1000))
}
+1
View File
@@ -2122,6 +2122,7 @@
"cleanupStopped": "Cleanup stopped by user.",
"cloudConcurrencyLimit": "You already have as many Orca Cloud workspaces running as your plan allows. Sleep or delete one, then try again.",
"cloudGithubNotConnected": "Connect GitHub to your Orca account so Orca Cloud can clone this repository.",
"cloudNoGithubRemote": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub first, or start with an empty workspace.",
"cloudPlanRequired": "Orca Cloud workspaces need a plan with cloud hours. Open Plan & billing to choose one.",
"cloudSignInRequired": "Sign in to Orca to use Orca Cloud workspaces.",
"cloudUnavailable": "Orca Cloud is unavailable right now. Try again in a moment.",
+31 -23
View File
@@ -4063,16 +4063,8 @@
"createFailedTitle": "Couldn’t create workspace",
"checksTitleCloud": "The sandbox is up, but its environment checks failed",
"planTitle": "Orca Cloud needs a Team plan",
"planBody": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.",
"hoursUsedTitle": "You’ve used this month’s included sandbox hours",
"hoursUsedBody": "Turn on overage in Plan & billing to keep starting sandboxes. Nothing was created.",
"paymentTitle": "Your Orca payment needs attention",
"paymentBody": "Update your payment method to start cloud workspaces again. Nothing was created.",
"concurrencyTitle": "Your team is running as many cloud workspaces as its plan allows",
"concurrencyBody": "Put a running cloud workspace to sleep or delete it, then try again. Nothing was created.",
"signInTitle": "Sign in to use Orca Cloud",
"unavailableTitle": "Orca Cloud is unavailable right now",
"unavailableBody": "The problem is on our side, not in your project. Nothing was created. Try again in a moment.",
"networkTitle": "Couldn’t reach Orca Cloud",
"snapshotTitle": "This project’s saved environment is missing",
"provisionBodyCloud": "Something went wrong while starting the sandbox. Try again, or open the logs for details.",
@@ -4084,28 +4076,43 @@
"deleteSandbox": "Delete sandbox",
"deleteVm": "Delete VM",
"updatePayment": "Update payment method",
"manageBilling": "Manage billing",
"checksBodyCloud": "Open anyway to fix these inside the workspace, or retry the checks after changing your project.",
"showWorkspaces": "Show cloud workspaces",
"contactSupport": "Contact support",
"planBodyMember": "Ask an owner or admin to choose a Team plan. Nothing was created, and you weren’t charged.",
"hoursUsedBodyMember": "Ask an owner or admin to turn on overage. Nothing was created.",
"spendCapTitle": "You’ve reached this month’s spend cap",
"spendCapBody": "Raise the monthly spend cap in Plan & billing to keep starting sandboxes. Nothing was created.",
"spendCapBodyMember": "Ask an owner or admin to raise the monthly spend cap. Nothing was created.",
"paymentBodyMember": "Ask an owner or admin to update the payment method. Nothing was created.",
"memberConcurrencyTitle": "You’re running as many cloud workspaces as you can at once",
"memberConcurrencyBody": "Put one of yours to sleep or delete it, then try again. Nothing was created.",
"githubTitle": "Connect GitHub to use this repository",
"githubBody": "Orca Cloud needs access to GitHub to clone this private repository. Connect GitHub to your Orca account, then try again. Nothing was created.",
"signInBody2": "Cloud workspaces run on your Orca account. Sign in and Orca starts this one for you. Nothing was created.",
"networkBody2": "Check your internet connection, then try again. Nothing was created.",
"snapshotBody2": "The sandbox starts from a saved copy of your environment, and that copy couldn’t be found. Nothing was created. Try again, or contact support if it keeps happening.",
"provisionTitleCloud": "The sandbox didn’t start",
"provisionTitleVm": "The VM recipe failed",
"createTitleCloud": "Couldn’t open the workspace on the sandbox",
"createBodyCloud2": "The sandbox started, but Orca couldn’t open the workspace on it. Try again, or open the logs for details.",
"createTitleVm": "Couldn’t create the workspace on the VM"
"createTitleVm": "Couldn’t create the workspace on the VM",
"raiseSpendCap": "Raise spend cap",
"turnOnOverage": "Turn on overage",
"openingGitHub": "Opening GitHub…",
"connectGitHub": "Connect GitHub",
"startEmpty": "Start empty",
"pushToGitHubFirst": "Push to GitHub first",
"planBody2": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.",
"planBodyMember2": "Ask an owner or admin to choose a Team plan. Nothing was created, and you weren’t charged.",
"outOfHoursTitle": "Out of sandbox hours",
"outOfHoursBody": "Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.",
"outOfHoursBodyMember": "Your team has used this month’s sandbox hours. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.",
"spendCapReachedTitle": "Spend cap reached",
"spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.",
"spendCapReachedBodyMember": "Your team has reached this month’s spend cap. Ask an owner or admin to add hours. Nothing was created, and you weren’t charged.",
"paymentFailedTitle": "Payment failed",
"paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.",
"paymentFailedBodyMember": "New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.",
"teamLimitTitle": "You’re at your team’s limit of running sandboxes",
"limitBody": "Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
"memberLimitTitle": "You’re at your limit of running sandboxes",
"githubNotConnectedTitle": "GitHub isn’t connected for {{repo}}",
"githubNotConnectedBody": "Orca Cloud clones your code from GitHub and needs access to this repository first. Connect GitHub, then try again. Nothing was created, and you weren’t charged.",
"noGithubRemoteTitle": "{{repo}} isn’t on GitHub",
"noGithubRemoteBody": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub, or start with an empty workspace. Nothing was created, and you weren’t charged.",
"signInBody3": "Cloud workspaces run on your Orca account. Sign in and Orca starts this one for you. Nothing was created, and you weren’t charged.",
"unavailableBody2": "The problem is on our side, not in your project. Nothing was created, and you weren’t charged. Try again in a moment.",
"networkBody3": "Check your internet connection, then try again. Nothing was created, and you weren’t charged.",
"snapshotBody3": "The sandbox starts from a saved copy of your environment, and that copy couldn’t be found. Nothing was created, and you weren’t charged. Try again, or contact support if it keeps happening.",
"connectGitHubFailed": "Couldn’t open GitHub. Try again in a moment."
},
"VmBuildLog": {
"jumpToError": "Jump to first error",
@@ -25662,6 +25669,7 @@
"cloudSignInRequired": "Sign in to Orca to use Orca Cloud workspaces.",
"cloudUnavailable": "Orca Cloud is unavailable right now. Try again in a moment.",
"cloudGithubNotConnected": "Connect GitHub to your Orca account so Orca Cloud can clone this repository.",
"cloudNoGithubRemote": "Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub first, or start with an empty workspace.",
"recipeExitedWithoutCode": "Recipe exited without an exit code.",
"destroyExitedWithoutCode": "Destroy exited without an exit code.",
"suspendExited": "Suspend exited with code {{exitCode}}.",
@@ -129,7 +129,9 @@ const COPY: Record<EphemeralVmFailureCode, (params: Params | undefined) => strin
translate('ephemeralVm.errors.cloudSignInRequired', EN.cloudSignInRequired),
cloudUnavailable: () => translate('ephemeralVm.errors.cloudUnavailable', EN.cloudUnavailable),
cloudGithubNotConnected: () =>
translate('ephemeralVm.errors.cloudGithubNotConnected', EN.cloudGithubNotConnected)
translate('ephemeralVm.errors.cloudGithubNotConnected', EN.cloudGithubNotConnected),
cloudNoGithubRemote: () =>
translate('ephemeralVm.errors.cloudNoGithubRemote', EN.cloudNoGithubRemote)
}
function isEphemeralVmFailureCode(code: string): code is EphemeralVmFailureCode {
@@ -28,6 +28,7 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = {
branch?: string
ref?: string
provisionId?: string
startEmpty?: boolean
setupExistingFolder: (
args: ProjectHostSetupExistingFolderArgs
) => Promise<ProjectHostSetupResult | null>
@@ -62,7 +63,8 @@ export async function prepareEphemeralVmWorkspaceTarget(
workspaceName: args.workspaceName,
...(args.branch ? { branch: args.branch } : {}),
...(args.ref ? { ref: args.ref } : {}),
...(args.provisionId ? { provisionId: args.provisionId } : {})
...(args.provisionId ? { provisionId: args.provisionId } : {}),
...(args.startEmpty ? { startEmpty: true } : {})
})
if (!provisioned.ok) {
return {
@@ -70,6 +70,7 @@ async function prepareEphemeralVmRecipeRequest(
}
: {}),
provisionId: creationId,
...(recipe.startEmpty ? { startEmpty: true } : {}),
setupExistingFolder: async (args) => {
const setup = await store.setupProjectExistingFolder(args)
if (setup) {
@@ -38,6 +38,8 @@ function stubApi(overrides: Partial<OrcaCloudWorkspaceApi> = {}): OrcaCloudWorks
unarchive: vi.fn(async () => ok(null)),
getArchive: vi.fn(async () => ok(null)),
listCloudWorkspaceIds: vi.fn(async () => ['a', 'b']),
getConfig: vi.fn(async () => null),
connectGitHub: vi.fn(async () => ok(null)),
onWakeProgress: vi.fn(() => () => {}),
onOom: vi.fn(() => () => {}),
...overrides
@@ -64,6 +64,8 @@ export type WorktreeCreationRequest = {
checkoutMode?: 'orca-worktree' | 'provisioned-root'
/** Set for the built-in hosted provider, whose build sheet speaks in plain language. */
provider?: 'orca-cloud'
/** Orca Cloud: the user chose to start without the repository (it has no GitHub remote). */
startEmpty?: boolean
}
// Capture local/runtime progress routing at submit so focus changes cannot alter Retry behavior.
worktreeCreateProgressMode?: WorktreeCreationProgressMode
@@ -1,6 +1,7 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import type { RuntimeStatusSlice } from './runtime-status-types'
import { runtimeEnvironmentDisplayName } from '../../../../../shared/remote-runtime/host-catalog/orca-cloud-environment-name'
const activeRuntimeDisconnectedToasts = new Map<string, symbol>()
const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000
@@ -23,7 +24,7 @@ export function showRuntimeDisconnectedToast(
? translate(
'auto.store.slices.runtime.status.runtimeHostUnreachableNamed',
"Can't reach {{hostName}}",
{ hostName: environment.name }
{ hostName: runtimeEnvironmentDisplayName(environment) }
)
: translate(
'auto.store.slices.runtime.status.runtimeHostUnreachable',
+3 -1
View File
@@ -43,7 +43,9 @@ export const EPHEMERAL_VM_FAILURE_COPY = {
cloudSignInRequired: 'Sign in to Orca to use Orca Cloud workspaces.',
cloudUnavailable: 'Orca Cloud is unavailable right now. Try again in a moment.',
cloudGithubNotConnected:
'Connect GitHub to your Orca account so Orca Cloud can clone this repository.'
'Connect GitHub to your Orca account so Orca Cloud can clone this repository.',
cloudNoGithubRemote:
'Orca Cloud clones your code from GitHub, and this repository has no GitHub remote. Push it to GitHub first, or start with an empty workspace.'
} as const
export type EphemeralVmFailureCode = keyof typeof EPHEMERAL_VM_FAILURE_COPY
@@ -13,6 +13,7 @@ import {
import type { SshConnectionState } from '../ssh-types'
import type { RuntimeEnvironmentSource } from '../remote-runtime/host-catalog/runtime-environments'
import type { GlobalSettings } from '../global-settings-types'
import { runtimeEnvironmentDisplayName } from '../remote-runtime/host-catalog/orca-cloud-environment-name'
import type { Repo } from '../repo-types'
import { annotateManagedOrcadExecutionHosts } from './managed-orcad-execution-host'
import { getSshConnectionHealth, type EphemeralVmHostLifecycle } from './execution-host-health'
@@ -134,7 +135,11 @@ export function buildExecutionHostRegistry(args: {
addRuntimeHost(
hosts,
environmentId,
normalizeHostPart(environment.name) ?? environmentId,
(environment.name
? normalizeHostPart(
runtimeEnvironmentDisplayName({ name: environment.name, source: environment.source })
)
: null) ?? environmentId,
environment.source,
args.runtimeStatusByEnvironmentId
)
@@ -4,6 +4,7 @@
import type {
HostedArchivePullRequest,
HostedArchiveResponse,
HostedSandboxConfigResponse,
HostedWorkspaceStatus
} from './hosted-sandbox-wire'
@@ -59,6 +60,10 @@ export type OrcaCloudWorkspaceApi = {
}) => Promise<CloudWorkspaceResult<CloudArchiveInfo | null>>
/** Workspace ids of every Orca Cloud workspace this desktop knows (the sidebar dot / hover card). */
listCloudWorkspaceIds: () => Promise<string[]>
/** Product constants and measured timings; null when the control plane can't be reached. */
getConfig: () => Promise<HostedSandboxConfigResponse | null>
/** Opens GitHub in the browser to give Orca Cloud access to the account's repositories. */
connectGitHub: () => Promise<CloudWorkspaceResult<null>>
onWakeProgress: (callback: (event: CloudWakeProgressEvent) => void) => () => void
onOom: (callback: (event: CloudOomEvent) => void) => () => void
}
@@ -70,6 +75,8 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = {
unarchive: 'orcaCloudWorkspace:unarchive',
getArchive: 'orcaCloudWorkspace:getArchive',
listCloudWorkspaceIds: 'orcaCloudWorkspace:listIds',
getConfig: 'orcaCloudWorkspace:getConfig',
connectGitHub: 'orcaCloudWorkspace:connectGitHub',
wakeProgress: 'orcaCloudWorkspace:wakeProgress',
oom: 'orcaCloudWorkspace:oom'
} as const
@@ -9,9 +9,26 @@ import { z } from 'zod'
*/
export const ORCA_CLOUD_IDLE_SLEEP_MINUTES = 15
/** Median and slow-tail durations of a recent operation, as the control plane measured them. */
export const HostedSandboxTimingSchema = z.object({
p50Ms: z.number().nonnegative(),
p95Ms: z.number().nonnegative(),
samples: z.number().int().positive()
})
export type HostedSandboxTiming = z.infer<typeof HostedSandboxTimingSchema>
/** GET /v1/config: product constants clients (desktop, mobile, CLI, marketing) must not hardcode. */
export const HostedSandboxConfigResponseSchema = z.object({
idleSleepMinutes: z.number().int().positive()
idleSleepMinutes: z.number().int().positive(),
/** Recent sandbox starts (create until orcad is ready); absent until enough were measured. */
createTiming: HostedSandboxTimingSchema.optional()
})
export type HostedSandboxConfigResponse = z.infer<typeof HostedSandboxConfigResponseSchema>
/** POST /v1/github/connect: where the browser binds a GitHub App installation to the account. */
export const HostedGithubConnectResponseSchema = z.object({
installUrl: z.string().url(),
authorizeUrl: z.string().url()
})
// Billing sells exactly these; larger boxes need a Vercel Enterprise plan.
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
legacyOrcaCloudEnvironmentName,
orcaCloudEnvironmentName,
runtimeEnvironmentDisplayName
} from './orca-cloud-environment-name'
import { buildExecutionHostRegistry } from '../../execution-host/execution-host-registry'
const RUNTIME_ID = '6f1c2a9e-0b4d-4c11-9a3e-7d2f22b5b1e7'
describe('Orca Cloud server names', () => {
it('shows a cloud sandbox as Orca Cloud, never its id tail', () => {
const name = orcaCloudEnvironmentName('repo', RUNTIME_ID)
expect(name).toBe('repo sandbox 22b5b1e7')
expect(runtimeEnvironmentDisplayName({ name, source: 'ephemeral-vm' })).toBe('Orca Cloud')
})
it('leaves custom recipe VMs and user-named servers alone', () => {
expect(
runtimeEnvironmentDisplayName({ name: 'repo VM 22b5b1e7', source: 'ephemeral-vm' })
).toBe('repo VM 22b5b1e7')
expect(runtimeEnvironmentDisplayName({ name: 'repo sandbox 22b5b1e7', source: 'manual' })).toBe(
'repo sandbox 22b5b1e7'
)
})
it('renames only the legacy name built from this runtime', () => {
expect(legacyOrcaCloudEnvironmentName('repo VM 22b5b1e7', RUNTIME_ID)).toBe(
'repo sandbox 22b5b1e7'
)
expect(legacyOrcaCloudEnvironmentName('repo VM 00000000', RUNTIME_ID)).toBeNull()
expect(legacyOrcaCloudEnvironmentName(' VM 22b5b1e7', RUNTIME_ID)).toBeNull()
})
it('labels the sidebar host Orca Cloud', () => {
const hosts = buildExecutionHostRegistry({
repos: [],
settings: null,
runtimeEnvironments: [
{ id: 'env-1', name: 'repo sandbox 22b5b1e7', source: 'ephemeral-vm' },
{ id: 'env-2', name: 'build box', source: 'manual' }
]
})
const labels = Object.fromEntries(hosts.map((host) => [host.id, host.label]))
expect(labels['runtime:env-1']).toBe('Orca Cloud')
expect(labels['runtime:env-2']).toBe('build box')
})
})
@@ -0,0 +1,40 @@
import type { RuntimeEnvironmentSource } from './runtime-environments'
// Saved server names must be unique, so an Orca Cloud server is stored as
// "<repo> sandbox <runtime id tail>". People see "Orca Cloud": the workspace row already names
// the workspace, and the id tail means nothing to them.
const ORCA_CLOUD_ENVIRONMENT_NAME = /^.+ sandbox [0-9A-Za-z]{8}$/
/** The host label for every Orca Cloud server, like "Local Mac" for this computer. */
export const ORCA_CLOUD_HOST_LABEL = 'Orca Cloud'
/** The saved (unique) name of the server behind one Orca Cloud workspace. */
export function orcaCloudEnvironmentName(repoName: string, runtimeId: string): string {
return `${repoName} sandbox ${runtimeId.slice(-8)}`
}
/** Builds before the glossary named the server "<repo> VM <id tail>"; the runtime record proves it was Orca Cloud. */
export function legacyOrcaCloudEnvironmentName(name: string, runtimeId: string): string | null {
const suffix = ` VM ${runtimeId.slice(-8)}`
return name.endsWith(suffix) && name.length > suffix.length
? orcaCloudEnvironmentName(name.slice(0, -suffix.length), runtimeId)
: null
}
export function isOrcaCloudRuntimeEnvironment(environment: {
name?: string | null
source?: RuntimeEnvironmentSource
}): boolean {
return (
environment.source === 'ephemeral-vm' &&
ORCA_CLOUD_ENVIRONMENT_NAME.test(environment.name?.trim() ?? '')
)
}
/** The name to show for a saved server: "Orca Cloud" for a cloud sandbox, otherwise its saved name. */
export function runtimeEnvironmentDisplayName(environment: {
name: string
source?: RuntimeEnvironmentSource
}): string {
return isOrcaCloudRuntimeEnvironment(environment) ? ORCA_CLOUD_HOST_LABEL : environment.name
}