diff --git a/cloud/apps/sandbox-control/src/create-timing.ts b/cloud/apps/sandbox-control/src/create-timing.ts new file mode 100644 index 00000000000..69644e0be97 --- /dev/null +++ b/cloud/apps/sandbox-control/src/create-timing.ts @@ -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 { + 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)) +} diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 3caf271cbe2..414e8bb55cf 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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: diff --git a/cloud/apps/sandbox-control/test/config.test.ts b/cloud/apps/sandbox-control/test/config.test.ts index 1d43a6d9da1..d58a40069b3 100644 --- a/cloud/apps/sandbox-control/test/config.test.ts +++ b/cloud/apps/sandbox-control/test/config.test.ts @@ -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() }) }) diff --git a/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.test.ts b/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.test.ts new file mode 100644 index 00000000000..8a24c9b655a --- /dev/null +++ b/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.test.ts @@ -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) + }) +}) diff --git a/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.ts b/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.ts new file mode 100644 index 00000000000..3e2e3939a85 --- /dev/null +++ b/src/main/ephemeral-vm/legacy-orca-cloud-environment-names.ts @@ -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 " VM ", so the sidebar showed it as a + * raw VM. Renames those to the " sandbox " 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() + 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 +} diff --git a/src/main/ephemeral-vm/runtime-provisioning-persistence.ts b/src/main/ephemeral-vm/runtime-provisioning-persistence.ts index 957bf90b87b..c0fd9636c84 100644 --- a/src/main/ephemeral-vm/runtime-provisioning-persistence.ts +++ b/src/main/ephemeral-vm/runtime-provisioning-persistence.ts @@ -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. */ diff --git a/src/main/hosted-sandbox/hosted-account-ipc.ts b/src/main/hosted-sandbox/hosted-account-ipc.ts new file mode 100644 index 00000000000..fcb9c52db80 --- /dev/null +++ b/src/main/hosted-sandbox/hosted-account-ipc.ts @@ -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 +): void { + // Timings move slowly and the Worker caches them for 5 minutes; one read per app session is plenty. + let config: Promise | 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) + } + }) +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-control-plane-client.ts b/src/main/hosted-sandbox/hosted-sandbox-control-plane-client.ts index 800c19b3771..4768d0597f9 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-control-plane-client.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-control-plane-client.ts @@ -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 export type HostedSandboxControlPlaneClient = { create( @@ -71,6 +77,10 @@ export type HostedSandboxControlPlaneClient = { ): Promise /** Starts the VM's remote desktop (idempotent); it listens on the VM's loopback only. */ startDesktop(sandboxId: string, signal?: AbortSignal): Promise + /** Product constants and measured timings (GET /v1/config). */ + config(signal?: AbortSignal): Promise + /** Starts binding a GitHub App installation to the account; the browser finishes it. */ + connectGitHub(signal?: AbortSignal): Promise } 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 + }) } } diff --git a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts index dd32beb345e..0d3cefe73e5 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-recipe.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-recipe.ts @@ -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, + repoPath: string, + startEmpty: boolean | undefined +): Promise { + 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 } } diff --git a/src/main/hosted-sandbox/hosted-workspace-service.ts b/src/main/hosted-sandbox/hosted-workspace-service.ts index 5a86ccb93e8..c471144c35e 100644 --- a/src/main/hosted-sandbox/hosted-workspace-service.ts +++ b/src/main/hosted-sandbox/hosted-workspace-service.ts @@ -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) { diff --git a/src/main/ipc/ephemeral-vm-orca-cloud-remote.test.ts b/src/main/ipc/ephemeral-vm-orca-cloud-remote.test.ts new file mode 100644 index 00000000000..2bc554abb84 --- /dev/null +++ b/src/main/ipc/ephemeral-vm-orca-cloud-remote.test.ts @@ -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 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()), + 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 = {}) => + 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 +} diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index acae3e96dca..1539c7e534d 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -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 } +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. */ diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 91b22536588..befd0bdb363 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -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 => { + async (_event, args: EphemeralVmProvisionArgs): Promise => { 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) diff --git a/src/preload/api/ephemeral-vm-api.ts b/src/preload/api/ephemeral-vm-api.ts index 2c32e253eb4..1518c91e691 100644 --- a/src/preload/api/ephemeral-vm-api.ts +++ b/src/preload/api/ephemeral-vm-api.ts @@ -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 diff --git a/src/preload/api/orca-cloud-workspace-bridge.ts b/src/preload/api/orca-cloud-workspace-bridge.ts index e0e01655ba3..b43872e8143 100644 --- a/src/preload/api/orca-cloud-workspace-bridge.ts +++ b/src/preload/api/orca-cloud-workspace-bridge.ts @@ -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) diff --git a/src/renderer/src/components/NativeChatResumeRuntimeModal.tsx b/src/renderer/src/components/NativeChatResumeRuntimeModal.tsx index 373a69643f5..6353ba2687f 100644 --- a/src/renderer/src/components/NativeChatResumeRuntimeModal.tsx +++ b/src/renderer/src/components/NativeChatResumeRuntimeModal.tsx @@ -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 { ))} diff --git a/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx b/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx index f62405cbfa1..d128804f98c 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card-summary.tsx @@ -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') { diff --git a/src/renderer/src/components/automations/use-automations-page-presentation-state.ts b/src/renderer/src/components/automations/use-automations-page-presentation-state.ts index eb694afb229..9a8ae93052b 100644 --- a/src/renderer/src/components/automations/use-automations-page-presentation-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-presentation-state.ts @@ -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) diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts index f74338407ed..45bffb83a04 100644 --- a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -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) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 49d3805fa3c..fccfc97f648 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -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 /> ))} diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index f26680b1ba7..bce1b1966ee 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -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 ? { diff --git a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts index 70256b50b4f..221237bf4a4 100644 --- a/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts +++ b/src/renderer/src/components/status-bar/use-owner-usage-rate-limits.ts @@ -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(null) const ownedRef = useRef(null) diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx index 3225d55c0c0..ea889611cd4 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx @@ -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: , 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 ? : , + onClick: actions.onConnectGitHub, + disabled: connectingGitHub + }, + tryAgain + ], + more: [] + } + case 'noGithubRemote': + return { + primary: [ + { + key: 'startEmpty', + label: t( + 'auto.components.worktree.creation.VmBuildFailureCard.startEmpty', + 'Start empty' + ), + icon: , + onClick: actions.onStartEmpty + }, + { + key: 'pushToGitHub', + label: t( + 'auto.components.worktree.creation.VmBuildFailureCard.pushToGitHubFirst', + 'Push to GitHub first' + ), + icon: , + 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 ) diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx index b25324e8173..3b34cc9768a 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx @@ -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(null) const cardRef = React.useRef(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} /> diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx index 4a7742efea0..261968a21e3 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheet.tsx @@ -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) }} /> diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx index 783470780a7..26de3d6115a 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetCloudFailures.fixture.tsx @@ -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) { + 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) => { diff --git a/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx b/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx index 359d2042f61..d3ba56c50b0 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildStepRow.tsx @@ -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) } )} ) : null} diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index 544cfe48521..c2fb198362e 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -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': diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts index e88d68b9764..3b84bdc93f0 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts @@ -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' diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts b/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts new file mode 100644 index 00000000000..e7b7d9cdd03 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts @@ -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) => + fallback.replace(/\{\{(\w+)\}\}/g, (_slot, name: string) => + String(params?.[name] ?? '') + )) as Parameters[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') + }) +}) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure.ts b/src/renderer/src/components/worktree-creation/vm-build-failure.ts index 968f05b436d..9e9d28d59db 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure.ts @@ -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> = { 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: `. @@ -94,7 +97,8 @@ const ACCOUNT_GATES: ReadonlySet = new Set([ 'signIn', 'concurrency', 'memberConcurrency', - 'githubNotConnected' + 'githubNotConnected', + 'noGithubRemote' ]) // Nothing started, so no step failed: steps stay pending instead of showing a red failure. diff --git a/src/renderer/src/components/worktree-creation/vm-build-repo-actions.ts b/src/renderer/src/components/worktree-creation/vm-build-repo-actions.ts new file mode 100644 index 00000000000..33d6282f136 --- /dev/null +++ b/src/renderer/src/components/worktree-creation/vm-build-repo-actions.ts @@ -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 | 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 { + 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.' + ) + ) + } +} diff --git a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx index 0e406cd369d..31c43833920 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx +++ b/src/renderer/src/components/worktree-creation/vm-build-sheet-fixture-support.tsx @@ -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} /> ) } +// 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' diff --git a/src/renderer/src/components/worktree-creation/vm-build-timing.ts b/src/renderer/src/components/worktree-creation/vm-build-timing.ts index bdfa11da25a..6c96630350e 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-timing.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-timing.ts @@ -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 -/** - * 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> = { - provision: { p50Ms: 2_900, p95Ms: 4_000 } +export type VmBuildStepTimings = Partial> + +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 | null = null + +function loadMeasuredTimings(): Promise { + 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(() => + 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)) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 60a39dcf119..f13aa7c202f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e1bd92b56c8..38178fbb076 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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}}.", diff --git a/src/renderer/src/lib/ephemeral-vm-error-copy.ts b/src/renderer/src/lib/ephemeral-vm-error-copy.ts index 7c8fc0c118c..fd8feb8f1f7 100644 --- a/src/renderer/src/lib/ephemeral-vm-error-copy.ts +++ b/src/renderer/src/lib/ephemeral-vm-error-copy.ts @@ -129,7 +129,9 @@ const COPY: Record 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 { diff --git a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts index ad0f768f2a1..0af833e0ee4 100644 --- a/src/renderer/src/lib/ephemeral-vm-workspace-target.ts +++ b/src/renderer/src/lib/ephemeral-vm-workspace-target.ts @@ -28,6 +28,7 @@ export type PrepareEphemeralVmWorkspaceTargetArgs = { branch?: string ref?: string provisionId?: string + startEmpty?: boolean setupExistingFolder: ( args: ProjectHostSetupExistingFolderArgs ) => Promise @@ -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 { diff --git a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts index 884b80fc3f1..dd53897d0d2 100644 --- a/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts +++ b/src/renderer/src/lib/ephemeral-vm-worktree-creation.ts @@ -70,6 +70,7 @@ async function prepareEphemeralVmRecipeRequest( } : {}), provisionId: creationId, + ...(recipe.startEmpty ? { startEmpty: true } : {}), setupExistingFolder: async (args) => { const setup = await store.setupProjectExistingFolder(args) if (setup) { diff --git a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts index 7ac2b331c29..6c5c05fd076 100644 --- a/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts +++ b/src/renderer/src/lib/orca-cloud/orca-cloud-workspace-store.test.ts @@ -38,6 +38,8 @@ function stubApi(overrides: Partial = {}): 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 diff --git a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts index bc4baedd76a..94cef24ba12 100644 --- a/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts +++ b/src/renderer/src/lib/workspace/creation/pending-worktree-creation.ts @@ -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 diff --git a/src/renderer/src/store/slices/runtime-status/runtime-environment-disconnect-toast.ts b/src/renderer/src/store/slices/runtime-status/runtime-environment-disconnect-toast.ts index 60a8dceba72..1d026692ac2 100644 --- a/src/renderer/src/store/slices/runtime-status/runtime-environment-disconnect-toast.ts +++ b/src/renderer/src/store/slices/runtime-status/runtime-environment-disconnect-toast.ts @@ -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() 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', diff --git a/src/shared/ephemeral-vm-failure-copy.ts b/src/shared/ephemeral-vm-failure-copy.ts index 592cd732984..1fb003790f8 100644 --- a/src/shared/ephemeral-vm-failure-copy.ts +++ b/src/shared/ephemeral-vm-failure-copy.ts @@ -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 diff --git a/src/shared/execution-host/execution-host-registry.ts b/src/shared/execution-host/execution-host-registry.ts index deb8e4dacf9..59ed1c1c96b 100644 --- a/src/shared/execution-host/execution-host-registry.ts +++ b/src/shared/execution-host/execution-host-registry.ts @@ -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 ) diff --git a/src/shared/hosted-sandbox/cloud-workspace-api.ts b/src/shared/hosted-sandbox/cloud-workspace-api.ts index 87a1ade907a..b369e2dc025 100644 --- a/src/shared/hosted-sandbox/cloud-workspace-api.ts +++ b/src/shared/hosted-sandbox/cloud-workspace-api.ts @@ -4,6 +4,7 @@ import type { HostedArchivePullRequest, HostedArchiveResponse, + HostedSandboxConfigResponse, HostedWorkspaceStatus } from './hosted-sandbox-wire' @@ -59,6 +60,10 @@ export type OrcaCloudWorkspaceApi = { }) => Promise> /** Workspace ids of every Orca Cloud workspace this desktop knows (the sidebar dot / hover card). */ listCloudWorkspaceIds: () => Promise + /** Product constants and measured timings; null when the control plane can't be reached. */ + getConfig: () => Promise + /** Opens GitHub in the browser to give Orca Cloud access to the account's repositories. */ + connectGitHub: () => Promise> 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 diff --git a/src/shared/hosted-sandbox/hosted-sandbox-wire.ts b/src/shared/hosted-sandbox/hosted-sandbox-wire.ts index 133f36be511..587871a76de 100644 --- a/src/shared/hosted-sandbox/hosted-sandbox-wire.ts +++ b/src/shared/hosted-sandbox/hosted-sandbox-wire.ts @@ -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 + /** 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 + +/** 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. diff --git a/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.test.ts b/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.test.ts new file mode 100644 index 00000000000..50c3c4e62fa --- /dev/null +++ b/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.test.ts @@ -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') + }) +}) diff --git a/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.ts b/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.ts new file mode 100644 index 00000000000..eec877ba10b --- /dev/null +++ b/src/shared/remote-runtime/host-catalog/orca-cloud-environment-name.ts @@ -0,0 +1,40 @@ +import type { RuntimeEnvironmentSource } from './runtime-environments' + +// Saved server names must be unique, so an Orca Cloud server is stored as +// " sandbox ". 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 " VM "; 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 +}