diff --git a/cloud/apps/sandbox-control/src/environment-builds.ts b/cloud/apps/sandbox-control/src/environment-builds.ts index a1279699d8a..d36716ed836 100644 --- a/cloud/apps/sandbox-control/src/environment-builds.ts +++ b/cloud/apps/sandbox-control/src/environment-builds.ts @@ -32,6 +32,8 @@ const STARTING_STUCK_MS = 5 * 60_000 const SNAPSHOTTING_STUCK_MS = 3 * 60_000 /** A secret value shorter than this is not redacted from logs (too likely to match ordinary text). */ const MIN_REDACTED_SECRET = 6 +/** Event-stream keepalive; desktop watchers treat ~90 s of silence as a dead connection. */ +const HEARTBEAT_MS = 20_000 /** Succeeded builds whose snapshot is kept (rollback window); older ones keep their record and log. */ export const KEPT_BUILDS = 5 const MAX_LOG_BYTES = 2 * 1024 * 1024 @@ -726,6 +728,11 @@ export function streamBuildEvents( const { readable, writable } = new TransformStream() const writer = writable.getWriter() const send = (event: unknown) => writer.write(encoder.encode(`${JSON.stringify(event)}\n`)) + // A quiet step (snapshot, a long compile) prints nothing for minutes: a heartbeat tells watchers the + // stream is alive so they don't reconnect (and replay the whole log) for nothing. + const heartbeat = setInterval(() => { + send({ type: 'heartbeat', at: deps.now() }).catch(() => clearInterval(heartbeat)) + }, HEARTBEAT_MS) const run = async () => { let row = initial await send({ type: 'build', build: await view(row) }) @@ -809,7 +816,10 @@ export function streamBuildEvents( log('environment_build_stream_failed', { buildId: initial.id, error: String(error).slice(0, 200) }) return send({ type: 'error', error: 'stream_failed' }).catch(() => undefined) }) - .finally(() => writer.close().catch(() => undefined)) + .finally(() => { + clearInterval(heartbeat) + return writer.close().catch(() => undefined) + }) return readable } diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index 9cc19bf9f86..9fd4f3ead36 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -233,6 +233,17 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['diagnostics memory', 'terminal-daemon status', 'terminal-daemon restart'], load: async () => (await import('./handlers/diagnostics.js')).DIAGNOSTICS_HANDLERS }, + { + name: 'cloud-env', + keys: [ + 'cloud env show', + 'cloud env set-install-script', + 'cloud env build', + 'cloud env builds', + 'cloud env logs' + ], + load: async () => (await import('./handlers/cloud-env.js')).CLOUD_ENV_HANDLERS + }, { name: 'speech', keys: ['speech openai-key status', 'speech openai-key set', 'speech openai-key clear'], diff --git a/src/cli/handlers/cloud-env-format.ts b/src/cli/handlers/cloud-env-format.ts new file mode 100644 index 00000000000..6dab6f290e1 --- /dev/null +++ b/src/cli/handlers/cloud-env-format.ts @@ -0,0 +1,123 @@ +// Human-readable output for `orca cloud env ...`. +import type { + CloudEnvironmentBuildsData, + CloudEnvironmentShowData +} from '../../shared/hosted-sandbox/hosted-environment-cli-contract' +import type { HostedEnvironmentBuildView } from '../../shared/hosted-sandbox/hosted-environment-wire' + +export function formatOutcome(build: HostedEnvironmentBuildView): string { + if (build.status === 'succeeded') { + return `Build #${build.number} succeeded in ${formatDuration(build.durationMs)}.${build.active ? ' It is now the active build.' : ''}` + } + if (build.status === 'canceled') { + return `Build #${build.number} was canceled.` + } + if (build.status === 'failed') { + const step = build.failedStep ? ` at the ${build.failedStep} step` : '' + return `Build #${build.number} failed${step}: ${build.error ?? 'unknown error'}.` + } + return `Build #${build.number} is ${build.status}.` +} + +export function formatShow(data: CloudEnvironmentShowData): string { + const env = data.environment + const lines = [ + `Environment: ${env.name} (${env.id})`, + `Sandbox size: ${env.machineSize === 'large' ? 'Large (8 vCPU · 16 GB)' : 'Standard (4 vCPU · 8 GB)'} Revision: ${env.revision}${data.canAdmin ? '' : ' (read-only: ask an owner or admin to change it)'}`, + 'Repos:', + ...(env.repos.length > 0 + ? env.repos.map( + (repo, index) => + ` ${repo.url}${repo.defaultBranch ? ` (${repo.defaultBranch})` : ''}${index === 0 ? ' <- install script runs here' : ''}` + ) + : [' (none)']), + `Variables from Cloud secrets (Shared) passed to the install script: ${env.envVarNames.length > 0 ? env.envVarNames.join(', ') : '(none)'}`, + `Active build: ${data.activeBuild ? describeBuild(data.activeBuild) : '(none)'}`, + `Latest build: ${data.latestBuild ? describeBuild(data.latestBuild) : '(none)'}` + ] + if (data.staleness) { + lines.push( + `Freshness: ${describeStaleness(data.staleness.recommendation, data.staleness.configChanged)}` + ) + } + lines.push('Install script:') + lines.push(env.installScript ? indent(env.installScript) : ' (empty)') + if (data.environments.length > 1) { + lines.push( + `Other environments (pick with --env): ${data.environments + .filter((other) => other.id !== env.id) + .map((other) => `${other.name} (${other.id})`) + .join(', ')}` + ) + } + return lines.join('\n') +} + +function describeStaleness(recommendation: string, configChanged: boolean): string { + switch (recommendation) { + case 'no_build': + return 'no active build yet' + case 'deps_changed': + return 'a lockfile changed since the active build; rebuild recommended' + case 'config_changed': + return 'the configuration changed since the active build; rebuild recommended' + case 'image_updated': + return 'the base image was updated since the active build; rebuild recommended' + case 'branch_moved': + return 'the default branch moved since the active build' + case 'up_to_date': + return configChanged ? 'configuration changed since the active build' : 'up to date' + default: + return 'unknown' + } +} + +function describeBuild(build: HostedEnvironmentBuildView): string { + const parts = [`#${build.number} ${build.status}`] + if (build.status === 'failed') { + parts.push( + `${build.failedStep ? `at ${build.failedStep}` : ''}${build.error ? ` (${build.error})` : ''}`.trim() + ) + } + if (build.durationMs !== null) { + parts.push(formatDuration(build.durationMs)) + } + parts.push(new Date(build.createdAt).toISOString()) + parts.push(build.id) + return parts.filter(Boolean).join(' ') +} + +export function formatBuilds(data: CloudEnvironmentBuildsData): string { + if (data.builds.length === 0) { + return `${data.environment.name} has no builds yet. Start one with: orca cloud env build --wait` + } + const outcomes = data.builds.map((build) => + build.status === 'failed' + ? `failed${build.failedStep ? ` at ${build.failedStep}` : ''}${build.error ? ` (${build.error})` : ''}` + : build.status + ) + // Column width from the widest outcome, so a long failure never shifts the columns after it. + const width = Math.max(...outcomes.map((outcome) => outcome.length)) + const rows = data.builds.map((build, index) => { + const outcome = outcomes[index]! + return `${build.active ? '*' : ' '} #${String(build.number).padEnd(4)} ${outcome.padEnd(width)} ${formatDuration(build.durationMs).padEnd(8)} ${new Date(build.createdAt).toISOString()} ${build.id}` + }) + return [`Builds of ${data.environment.name} (newest first, * = active):`, ...rows].join('\n') +} + +function formatDuration(ms: number | null): string { + if (ms === null) { + return '-' + } + const seconds = Math.round(ms / 1000) + return seconds < 60 + ? `${seconds}s` + : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` +} + +function indent(text: string): string { + return text + .split('\n') + .map((line) => ` ${line}`) + .join('\n') +} diff --git a/src/cli/handlers/cloud-env.test.ts b/src/cli/handlers/cloud-env.test.ts new file mode 100644 index 00000000000..a8a9ab1b805 --- /dev/null +++ b/src/cli/handlers/cloud-env.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLOUD_ENV_HANDLERS } from './cloud-env' +import type { HandlerContext } from '../dispatch' +import { CLOUD_ENVIRONMENT_METHODS } from '../../shared/hosted-sandbox/hosted-environment-cli-contract' + +const ENV = { id: 'env_03b89d4bf1c74787a7ce', name: 'Web' } +const build = (status: string, extra: Record = {}) => ({ + id: 'bld_0123456789abcdef', + number: 7, + status, + active: false, + durationMs: 61_000, + failedStep: null, + error: null, + ...extra +}) + +function context( + responder: (method: string, params: Record) => unknown, + flags: Record = {} +): HandlerContext { + return { + flags: new Map(Object.entries(flags)), + cwd: '/tmp', + json: flags.json === true, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cloud env handlers only use client.call + client: { + call: vi.fn(async (method: string, params: Record) => ({ + id: '1', + ok: true, + result: responder(method, params), + _meta: { runtimeId: 'r' } + })) + } as unknown as HandlerContext['client'] + } +} + +let stdout: string +let stderr: string +beforeEach(() => { + stdout = '' + stderr = '' + process.exitCode = undefined + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk) + return true + }) + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk) + return true + }) + vi.spyOn(console, 'log').mockImplementation((value) => { + stdout += `${String(value)}\n` + }) +}) +afterEach(() => { + vi.restoreAllMocks() + process.exitCode = undefined +}) + +describe('orca cloud env build --wait', () => { + it('streams the log and exits non-zero with the failed step', async () => { + const polls = [ + { + chunks: [{ stream: 'stdout', data: '::orca-step:: install\n' }], + done: false, + build: build('running') + }, + { + chunks: [{ stream: 'stderr', data: 'npm ERR! missing\n' }], + done: true, + build: build('failed', { failedStep: 'install', error: 'install_failed' }) + } + ] + let poll = 0 + const ctx = context( + (method) => { + if (method === CLOUD_ENVIRONMENT_METHODS.startBuild) { + return { ok: true, data: { environment: ENV, build: build('running') } } + } + const next = polls[poll++] + return { + ok: true, + data: { environment: ENV, cursor: poll, truncated: false, error: null, ...next } + } + }, + { wait: true } + ) + await CLOUD_ENV_HANDLERS['cloud env build'](ctx) + expect(stdout).toContain('::orca-step:: install') + expect(stderr).toContain('npm ERR! missing') + expect(stderr).toContain('Build #7 failed at the install step: install_failed.') + expect(process.exitCode).toBe(1) + }) + + it('surfaces control-plane refusals as errors', async () => { + const ctx = context(() => ({ + ok: false, + error: { + code: 'conflict', + reason: 'build_in_progress', + message: 'A build is already running.' + } + })) + await expect(CLOUD_ENV_HANDLERS['cloud env build'](ctx)).rejects.toMatchObject({ + code: 'cloud_conflict', + message: 'A build is already running.' + }) + }) +}) diff --git a/src/cli/handlers/cloud-env.ts b/src/cli/handlers/cloud-env.ts new file mode 100644 index 00000000000..022f6b673c7 --- /dev/null +++ b/src/cli/handlers/cloud-env.ts @@ -0,0 +1,289 @@ +// `orca cloud env ...`: lets a local agent read the org environment, replace its install script and +// run builds. The desktop app holds the Orca Cloud session, so every call goes through its runtime. +import { readFile, stat } from 'node:fs/promises' +import { resolve } from 'node:path' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { RuntimeClientError } from '../runtime-client' +import { + CLOUD_ENVIRONMENT_METHODS, + type CloudEnvironmentBuildsData, + type CloudEnvironmentFollowData, + type CloudEnvironmentRpcResult, + type CloudEnvironmentSetInstallScriptData, + type CloudEnvironmentShowData, + type CloudEnvironmentStartBuildData +} from '../../shared/hosted-sandbox/hosted-environment-cli-contract' +import { HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS } from '../../shared/hosted-sandbox/hosted-environment-wire' +import { formatBuilds, formatOutcome, formatShow } from './cloud-env-format' + +const FOLLOW_WAIT_MS = 10_000 +const FOLLOW_CALL_TIMEOUT_MS = 30_000 +// `--wait --json` reports this much of the end of the log, enough to see why a build failed. +const JSON_LOG_TAIL_CHARS = 16_000 +// A transient desktop hiccup should not abandon a 10-minute build. +const MAX_FOLLOW_FAILURES = 5 + +type Client = Pick + +async function callCloud( + client: Client, + method: string, + params: Record, + timeoutMs?: number +): Promise { + let response + try { + response = await client.call>( + method, + params, + timeoutMs ? { timeoutMs } : undefined + ) + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'method_not_found') { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca app does not support Orca Cloud environments yet. Update Orca and try again.' + ) + } + throw error + } + const result = response.result + if (!result.ok) { + throw new RuntimeClientError( + `cloud_${result.error.code}`, + result.error.message, + result.error.reason ? { reason: result.error.reason } : undefined + ) + } + return result.data +} + +function envParam(flags: HandlerContext['flags']): { env?: string } { + const env = flags.get('env') + return typeof env === 'string' && env.trim() ? { env: env.trim() } : {} +} + +export const CLOUD_ENV_HANDLERS: Record = { + 'cloud env show': async ({ flags, client, json }) => { + const data = await callCloud(client, CLOUD_ENVIRONMENT_METHODS.show, { + ...envParam(flags), + ...(flags.get('refresh') === true ? { refresh: true } : {}) + }) + console.log(json ? JSON.stringify(data, null, 2) : formatShow(data)) + }, + + 'cloud env set-install-script': async ({ flags, client, cwd, json }) => { + const file = flags.get('file') + if (typeof file !== 'string' || !file) { + throw new RuntimeClientError( + 'invalid_argument', + 'Pass the script with --file (or --file - for stdin).' + ) + } + const script = file === '-' ? await readStdin() : await readScriptFile(resolve(cwd, file)) + if (script.length > HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS) { + throw new RuntimeClientError( + 'invalid_argument', + `The install script is ${script.length} characters; the limit is ${HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS}.` + ) + } + const data = await callCloud( + client, + CLOUD_ENVIRONMENT_METHODS.setInstallScript, + { ...envParam(flags), script } + ) + if (json) { + console.log(JSON.stringify(data, null, 2)) + return + } + const flag = flags.get('env') ? ` --env ${data.environment.id}` : '' + console.log( + `Saved the install script for ${data.environment.name} (${script.length} chars, revision ${data.environment.revision}).\nBuild it with: orca cloud env build${flag} --wait` + ) + }, + + 'cloud env build': async ({ flags, client, json }) => { + const started = await callCloud( + client, + CLOUD_ENVIRONMENT_METHODS.startBuild, + envParam(flags) + ) + if (flags.get('wait') !== true) { + console.log( + json + ? JSON.stringify(started, null, 2) + : `Started build #${started.build.number} (${started.build.id}) for ${started.environment.name}.\nFollow it with: orca cloud env logs ${started.build.number}${flags.get('env') ? ` --env ${started.environment.id}` : ''}` + ) + return + } + if (!json) { + process.stderr.write( + `Started build #${started.build.number} (${started.build.id}) for ${started.environment.name}. Streaming its log...\n` + ) + } + const final = await followBuild(client, started.environment.id, started.build.id, { json }) + reportFinal(final, json) + }, + + 'cloud env builds': async ({ flags, client, json }) => { + const data = await callCloud( + client, + CLOUD_ENVIRONMENT_METHODS.builds, + envParam(flags) + ) + console.log(json ? JSON.stringify(data, null, 2) : formatBuilds(data)) + }, + + 'cloud env logs': async ({ flags, client, json }) => { + const build = flags.get('build') + if (typeof build !== 'string' || !build) { + throw new RuntimeClientError( + 'invalid_argument', + 'Pass a build number or id: orca cloud env logs .' + ) + } + // Resolve the number to an id once, so polls skip the lookups. + const first = await callCloud( + client, + CLOUD_ENVIRONMENT_METHODS.followBuild, + { ...envParam(flags), build, cursor: 0, waitMs: 0 }, + FOLLOW_CALL_TIMEOUT_MS + ) + // Before the stream's first event there is no build view yet; the loop picks it up. + const final = await followBuild(client, first.environment.id, first.build?.id ?? build, { + json, + initial: first + }) + reportFinal(final, json, { quietSuccess: true }) + } +} + +type FollowOutcome = { + data: CloudEnvironmentFollowData + logTail: string +} + +async function followBuild( + client: Client, + envId: string, + build: string, + options: { json: boolean; initial?: CloudEnvironmentFollowData } +): Promise { + let tail = '' + const take = (data: CloudEnvironmentFollowData): void => { + if (data.truncated && !options.json) { + process.stderr.write( + '[orca] some log output was lost while following (dropped from the buffer, or the log restarted and lines may repeat)\n' + ) + } + for (const chunk of data.chunks) { + if (options.json) { + tail = (tail + chunk.data).slice(-JSON_LOG_TAIL_CHARS) + } else if (chunk.stream === 'stderr') { + process.stderr.write(chunk.data) + } else { + process.stdout.write(chunk.data) + } + } + } + let data = options.initial + if (data) { + take(data) + } + let cursor = data?.cursor ?? 0 + let failures = 0 + while (!data?.done) { + try { + data = await callCloud( + client, + CLOUD_ENVIRONMENT_METHODS.followBuild, + { env: envId, build: data?.build?.id ?? build, cursor, waitMs: FOLLOW_WAIT_MS }, + FOLLOW_CALL_TIMEOUT_MS + ) + failures = 0 + } catch (error) { + // Cloud-side refusals (not found, signed out) are final; transport hiccups get retried. + if ( + ++failures > MAX_FOLLOW_FAILURES || + (error instanceof RuntimeClientError && error.code.startsWith('cloud_')) + ) { + throw error + } + await new Promise((r) => setTimeout(r, 2_000 * failures)) + continue + } + take(data) + cursor = data.cursor + } + return { data, logTail: tail } +} + +function reportFinal( + outcome: FollowOutcome, + json: boolean, + options: { quietSuccess?: boolean } = {} +): void { + const { data } = outcome + const build = data.build + const succeeded = build?.status === 'succeeded' + if (json) { + console.log( + JSON.stringify( + { + environment: data.environment, + build, + ok: succeeded, + ...(data.error ? { followError: data.error } : {}), + logTail: outcome.logTail + }, + null, + 2 + ) + ) + } else if (data.error) { + process.stderr.write(`\n[orca] ${data.error}\n`) + } else if (build && !(succeeded && options.quietSuccess)) { + process.stderr.write(`\n${formatOutcome(build)}\n`) + } + // `logs` of a finished build succeeds whatever the build's outcome; `build --wait` reports it. + if (data.error || (!options.quietSuccess && !succeeded)) { + process.exitCode = 1 + } +} + +async function readScriptFile(path: string): Promise { + const info = await stat(path).catch(() => null) + if (!info?.isFile()) { + throw new RuntimeClientError('invalid_argument', `No file at ${path}.`) + } + // Characters, not bytes, are limited; 4 bytes per char bounds the read. + if (info.size > HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS * 4) { + throw new RuntimeClientError( + 'invalid_argument', + `${path} is too large for an install script (limit ${HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS} characters).` + ) + } + return readFile(path, 'utf8') +} + +async function readStdin( + stdin: AsyncIterable & { isTTY?: boolean } = process.stdin +): Promise { + if (stdin.isTTY) { + throw new RuntimeClientError( + 'invalid_argument', + 'Pipe the script on stdin when using --file -.' + ) + } + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + bytes += buffer.length + if (bytes > HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS * 4) { + throw new RuntimeClientError('invalid_argument', 'The install script on stdin is too large.') + } + chunks.push(buffer) + } + return Buffer.concat(chunks).toString('utf8') +} diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index 10e3f99ae0a..9264f64581d 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -79,6 +79,11 @@ export const ROOT_HELP_TEXT_PRIMARY = [ 'Environment Recipes:', ' vm recipe doctor Validate a per-workspace environment recipe', ' vm env doctor Check that this environment can run agents', + ' cloud env show Show the Orca Cloud environment and its builds', + ' cloud env set-install-script Replace the Orca Cloud install script from a file', + ' cloud env build Build the Orca Cloud environment (--wait streams the log)', + ' cloud env builds List recent Orca Cloud environment builds', + ' cloud env logs Print or follow an Orca Cloud build log', '', 'Automations:', ' automations list List scheduled Orca automations', diff --git a/src/cli/specs/cloud-env.ts b/src/cli/specs/cloud-env.ts new file mode 100644 index 00000000000..5268ac9aed0 --- /dev/null +++ b/src/cli/specs/cloud-env.ts @@ -0,0 +1,69 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +const ENV_NOTE = + 'With several environments in the org, pick one with --env ; without it the first one is used.' + +export const CLOUD_ENV_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['cloud', 'env', 'show'], + summary: 'Show the Orca Cloud environment: repos, install script, builds and freshness', + usage: 'orca cloud env show [--env ] [--refresh] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'env', 'refresh'], + notes: [ + 'Needs the Orca desktop app signed in to Orca Cloud; the CLI asks it.', + ENV_NOTE, + '--refresh re-checks the repos on GitHub instead of using the minute-old answer.' + ], + examples: ['orca cloud env show', 'orca cloud env show --env web --json'] + }, + { + path: ['cloud', 'env', 'set-install-script'], + summary: 'Replace the environment install script with the contents of a file', + usage: 'orca cloud env set-install-script --file [--env ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'env', 'file'], + notes: [ + 'The script is bash; each build runs it once with `set -euo pipefail` in the first repo, with passwordless sudo. The log’s first line names the base image and its package manager (currently Ubuntu with apt-get).', + 'Only the install script changes. Repos, secrets and machine size are edited in Settings by org owners and admins.', + 'Fails with "Someone else saved changes; reload." when the environment changed since it was read.', + ENV_NOTE + ], + examples: [ + 'orca cloud env set-install-script --file /tmp/install.sh', + 'cat install.sh | orca cloud env set-install-script --file -' + ] + }, + { + path: ['cloud', 'env', 'build'], + summary: 'Start a build of the environment, optionally waiting for it and streaming its log', + usage: 'orca cloud env build [--wait] [--env ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'env', 'wait'], + notes: [ + 'A build boots a VM, clones the repos, runs the install script and snapshots the result. A succeeded build becomes the active one new workspaces start from.', + '--wait streams the log to stdout/stderr and exits 1 unless the build succeeds, printing the failed step and error. With --json it prints the final build and the end of the log instead.', + 'Interrupting --wait leaves the build running; follow it again with `orca cloud env logs `.', + ENV_NOTE + ], + examples: ['orca cloud env build --wait', 'orca cloud env build --wait --json'] + }, + { + path: ['cloud', 'env', 'builds'], + summary: 'List the environment’s recent builds, newest first', + usage: 'orca cloud env builds [--env ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'env'], + notes: [ENV_NOTE], + examples: ['orca cloud env builds', 'orca cloud env builds --json'] + }, + { + path: ['cloud', 'env', 'logs'], + summary: 'Print a build’s log, following it until the build finishes', + usage: 'orca cloud env logs [--env ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'env', 'build'], + positionalArgs: ['build'], + notes: [ + 'Lines starting with `::orca-step::` mark build steps; `::orca-error::` lines are Orca’s explanation of a failure.', + ENV_NOTE + ], + examples: ['orca cloud env logs 12', 'orca cloud env logs bld_0123456789abcdef'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index cd52155778b..010bf1dfbcc 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -24,6 +24,7 @@ import { JIRA_COMMAND_SPECS } from './jira' import { LINEAR_COMMAND_SPECS } from './linear' import { VM_COMMAND_SPECS } from './vm' import { VM_ENV_COMMAND_SPECS } from './vm-env' +import { CLOUD_ENV_COMMAND_SPECS } from './cloud-env' import { CLOUD_SSH_COMMAND_SPECS } from './cloud-ssh' import { SKILL_COMMAND_SPECS } from './skills' import { ARTIFACT_COMMAND_SPECS } from './artifacts' @@ -62,6 +63,7 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...LINEAR_COMMAND_SPECS, ...VM_COMMAND_SPECS, ...VM_ENV_COMMAND_SPECS, + ...CLOUD_ENV_COMMAND_SPECS, ...CLOUD_SSH_COMMAND_SPECS, ...EMULATOR_COMMAND_SPECS, ...SKILL_COMMAND_SPECS, diff --git a/src/main/hosted-sandbox/environments/hosted-environment-build-followers.test.ts b/src/main/hosted-sandbox/environments/hosted-environment-build-followers.test.ts new file mode 100644 index 00000000000..24be4d1314e --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-build-followers.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + createHostedEnvironmentBuildFollowers, + type HostedEnvironmentBuildFollowers +} from './hosted-environment-build-followers' +import { + BUILD_ID, + ENV_ID, + buildView, + line, + ndjsonBody +} from './hosted-environment-fixtures.test-support' + +const finishedBuild = async () => buildView({ status: 'succeeded' }) + +let followers: HostedEnvironmentBuildFollowers | null = null +afterEach(() => followers?.dispose()) + +async function drain( + f: HostedEnvironmentBuildFollowers, + client: Parameters[0] +): Promise<{ text: string; status: string | undefined; polls: number }> { + let cursor = 0 + let text = '' + for (let polls = 1; polls < 50; polls++) { + const result = await f.poll(client, ENV_ID, BUILD_ID, cursor, 1_000) + text += result.chunks.map((chunk) => chunk.data).join('') + cursor = result.cursor + if (result.done) { + return { text, status: result.build?.status, polls } + } + } + throw new Error('never finished') +} + +describe('createHostedEnvironmentBuildFollowers', () => { + it('long-polls the log and skips what a reconnect replays', async () => { + let pass = 0 + const client = { + getBuild: finishedBuild, + openBuildEvents: async () => + pass++ === 0 + ? ndjsonBody([ + line({ type: 'build', build: buildView() }), + line({ type: 'log', stream: 'stdout', data: 'one\n' }), + line({ type: 'log', stream: 'stdout', data: 'tw' }) + ]) + : ndjsonBody([ + line({ type: 'build', build: buildView() }), + line({ type: 'log', stream: 'stdout', data: 'one\ntwo\n' }), + line({ type: 'log', stream: 'stderr', data: 'three\n' }), + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'end' }) + ]) + } + followers = createHostedEnvironmentBuildFollowers({ backoffMs: [1] }) + const result = await drain(followers, client) + expect(result.text).toBe('one\ntwo\nthree\n') + expect(result.status).toBe('succeeded') + }) + + it('reports a watch that gave up as done with its error', async () => { + followers = createHostedEnvironmentBuildFollowers() + const client = { + getBuild: finishedBuild, + openBuildEvents: async () => { + const { HostedEnvironmentRequestError } = await import('./hosted-environment-client') + throw new HostedEnvironmentRequestError('not_found', null, 'gone', 404) + } + } + let cursor = 0 + for (;;) { + const result = await followers.poll(client, ENV_ID, BUILD_ID, cursor, 1_000) + cursor = result.cursor + if (result.done) { + expect(result.error).toBe('That environment or build no longer exists.') + break + } + } + }) + + it('reuses one watch for concurrent pollers of the same build', async () => { + let opens = 0 + const client = { + getBuild: finishedBuild, + openBuildEvents: async () => { + opens++ + return ndjsonBody([ + line({ type: 'build', build: buildView({ status: 'failed', error: 'install_failed' }) }), + line({ type: 'log', stream: 'stdout', data: 'x' }), + line({ type: 'end' }) + ]) + } + } + followers = createHostedEnvironmentBuildFollowers() + const [a, b] = await Promise.all([drain(followers, client), drain(followers, client)]) + expect(a.text).toBe('x') + expect(b.text).toBe('x') + expect(opens).toBe(1) + }) + + it('restarts from the first byte when a cursor outlived its follower', async () => { + const client = { + getBuild: finishedBuild, + openBuildEvents: async () => + ndjsonBody([ + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'log', stream: 'stdout', data: 'a' }), + line({ type: 'log', stream: 'stdout', data: 'b' }), + line({ type: 'end' }) + ]) + } + followers = createHostedEnvironmentBuildFollowers() + // A CLI still holding cursor 40 from a follower that was swept and recreated. + const result = await followers.poll(client, ENV_ID, BUILD_ID, 40, 1_000) + expect(result.truncated).toBe(true) + let text = result.chunks.map((chunk) => chunk.data).join('') + let cursor = result.cursor + let done = result.done + while (!done) { + const next = await followers.poll(client, ENV_ID, BUILD_ID, cursor, 1_000) + expect(next.truncated).toBe(false) + text += next.chunks.map((chunk) => chunk.data).join('') + cursor = next.cursor + done = next.done + } + expect(text).toBe('ab') + }) + + it('caps the log buffered across every follower', async () => { + const chunk = 'x'.repeat(100) + const client = { + getBuild: finishedBuild, + openBuildEvents: async () => + ndjsonBody([ + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + ...Array.from({ length: 10 }, () => line({ type: 'log', stream: 'stdout', data: chunk })), + line({ type: 'end' }) + ]) + } + followers = createHostedEnvironmentBuildFollowers({ maxTotalBufferedChars: 500 }) + const other = 'bld_fedcba9876543210' + // Let both watches finish before reading, so both buffers filled up. + await followers.poll(client, ENV_ID, BUILD_ID, 0, 0) + await followers.poll(client, ENV_ID, other, 0, 0) + await new Promise((resolve) => setTimeout(resolve, 50)) + const a = await followers.poll(client, ENV_ID, BUILD_ID, 0, 0) + const b = await followers.poll(client, ENV_ID, other, 0, 0) + const total = [...a.chunks, ...b.chunks].reduce((sum, entry) => sum + entry.data.length, 0) + expect(total).toBeLessThanOrEqual(500) + expect(a.truncated || b.truncated).toBe(true) + // Each keeps at least its newest chunk. + expect(a.chunks.length).toBeGreaterThan(0) + expect(b.chunks.length).toBeGreaterThan(0) + }) +}) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-build-followers.ts b/src/main/hosted-sandbox/environments/hosted-environment-build-followers.ts new file mode 100644 index 00000000000..5fb814f63cd --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-build-followers.ts @@ -0,0 +1,281 @@ +// Pull-based build following for the CLI: the runtime RPC is request/response, so main keeps one +// watch per build, buffers its log (bounded), and answers cursor polls. Reconnect replays are +// deduplicated here, so a CLI tailing the log never prints a line twice. +import type { HostedEnvironmentBuildView } from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { watchHostedEnvironmentBuild } from './hosted-environment-build-watch' +import type { HostedEnvironmentClient } from './hosted-environment-client' + +export type HostedEnvironmentBuildLogChunk = { stream: 'stdout' | 'stderr'; data: string } + +export type HostedEnvironmentBuildFollowPoll = { + build: HostedEnvironmentBuildView | null + chunks: HostedEnvironmentBuildLogChunk[] + /** Pass back on the next poll. */ + cursor: number + /** Output was lost between polls: the cursor fell behind the buffer (older log dropped), or the + * follower was recreated and the log restarts from its first byte (some lines may repeat). */ + truncated: boolean + /** Final status reached and the whole log delivered (or the watch failed: see `error`). */ + done: boolean + error: string | null +} + +type Follower = { + chunks: { seq: number; chunk: HostedEnvironmentBuildLogChunk }[] + bufferedChars: number + nextSeq: number + build: HostedEnvironmentBuildView | null + done: boolean + error: string | null + lastPollAt: number + stop: () => void + waiters: Set<() => void> +} + +export type HostedEnvironmentBuildFollowers = { + poll( + client: Pick, + envId: string, + buildId: string, + cursor: number, + waitMs: number + ): Promise + dispose(): void +} + +const MAX_BUFFERED_CHARS = 4 * 1024 * 1024 +// Across every follower: 16 followers at their own cap would otherwise hold 64 MB. +const MAX_TOTAL_BUFFERED_CHARS = 16 * 1024 * 1024 +const MAX_CHUNKS_PER_POLL = 2_000 +const MAX_FOLLOWERS = 16 +// A CLI that stopped polling (Ctrl-C) lets its follower go after this. +const ABANDONED_AFTER_MS = 60_000 + +export function createHostedEnvironmentBuildFollowers( + options: { + now?: () => number + idleTimeoutMs?: number + backoffMs?: readonly number[] + maxTotalBufferedChars?: number + } = {} +): HostedEnvironmentBuildFollowers { + const now = options.now ?? Date.now + const maxTotal = options.maxTotalBufferedChars ?? MAX_TOTAL_BUFFERED_CHARS + const followers = new Map() + let totalChars = 0 + + const dropOldest = (follower: Follower): boolean => { + // The newest chunk stays so a poller always has something to resume from. + if (follower.chunks.length <= 1) { + return false + } + const dropped = follower.chunks.shift() + const size = dropped?.chunk.data.length ?? 0 + follower.bufferedChars -= size + totalChars -= size + return true + } + + const enforceTotalCap = (): void => { + while (totalChars > maxTotal) { + let largest: Follower | null = null + for (const candidate of followers.values()) { + if ( + candidate.chunks.length > 1 && + (!largest || candidate.bufferedChars > largest.bufferedChars) + ) { + largest = candidate + } + } + if (!largest || !dropOldest(largest)) { + return + } + } + } + + const remove = (key: string): void => { + const follower = followers.get(key) + if (follower) { + followers.delete(key) + totalChars -= follower.bufferedChars + follower.stop() + for (const wake of follower.waiters) { + wake() + } + } + } + + const sweep = (): void => { + for (const [key, follower] of followers) { + if (now() - follower.lastPollAt > ABANDONED_AFTER_MS) { + remove(key) + } + } + } + + const makeRoom = (): void => { + while (followers.size >= MAX_FOLLOWERS) { + const oldest = followers.keys().next().value + if (oldest === undefined) { + break + } + remove(oldest) + } + } + + // Followers whose CLI went away keep streaming until swept, so sweep on a slow beat too. + let sweeper: ReturnType | null = null + const ensureSweeper = (): void => { + if (sweeper) { + return + } + sweeper = setInterval(() => { + sweep() + if (followers.size === 0 && sweeper) { + clearInterval(sweeper) + sweeper = null + } + }, ABANDONED_AFTER_MS / 2) + sweeper.unref?.() + } + + const start = ( + client: Pick, + envId: string, + buildId: string + ): Follower => { + const follower: Follower = { + chunks: [], + bufferedChars: 0, + nextSeq: 0, + build: null, + done: false, + error: null, + lastPollAt: now(), + stop: () => undefined, + waiters: new Set() + } + // Chars of the log already buffered across passes, and how far the current replay has reached. + let deliveredChars = 0 + let replayChars = 0 + const wake = (): void => { + for (const resolve of follower.waiters) { + resolve() + } + follower.waiters.clear() + } + const push = (chunk: HostedEnvironmentBuildLogChunk): void => { + follower.chunks.push({ seq: follower.nextSeq++, chunk }) + follower.bufferedChars += chunk.data.length + totalChars += chunk.data.length + while (follower.bufferedChars > MAX_BUFFERED_CHARS && dropOldest(follower)) { + // Trimmed one chunk. + } + enforceTotalCap() + } + const watch = watchHostedEnvironmentBuild({ + client, + envId, + buildId, + ...(options.idleTimeoutMs ? { idleTimeoutMs: options.idleTimeoutMs } : {}), + ...(options.backoffMs ? { backoffMs: options.backoffMs } : {}), + onEvent: (event) => { + switch (event.type) { + case 'build': + follower.build = event.build + break + case 'reset': + replayChars = 0 + break + case 'reconnecting': + break + case 'log': { + // Skip the part of the replay already delivered, then pass the rest through. + const start = replayChars + replayChars += event.data.length + if (replayChars <= deliveredChars) { + return + } + const fresh = + start >= deliveredChars ? event.data : event.data.slice(deliveredChars - start) + deliveredChars = replayChars + push({ stream: event.stream, data: fresh }) + break + } + case 'end': + follower.done = true + break + case 'error': + follower.done = true + follower.error = event.message + break + } + wake() + } + }) + follower.stop = watch.stop + return follower + } + + return { + async poll(client, envId, buildId, requestedCursor, waitMs) { + const key = `${envId}/${buildId}` + sweep() + ensureSweeper() + let follower = followers.get(key) + if (!follower) { + makeRoom() + follower = start(client, envId, buildId) + followers.set(key, follower) + } + follower.lastPollAt = now() + // A cursor from a previous follower (swept or evicted, then recreated) points past this one's + // log: restart from its first byte and say output may repeat. + const restarted = requestedCursor > follower.nextSeq + const cursor = restarted ? 0 : requestedCursor + const hasNews = (): boolean => follower.done || (follower.chunks.at(-1)?.seq ?? -1) >= cursor + if (!hasNews() && waitMs > 0) { + await new Promise((resolve) => { + const timer = setTimeout(done, waitMs) + timer.unref?.() + function done(): void { + clearTimeout(timer) + follower!.waiters.delete(done) + resolve() + } + follower!.waiters.add(done) + }) + } + const first = follower.chunks[0]?.seq ?? follower.nextSeq + const truncated = restarted || cursor < first + const pending = follower.chunks.filter((entry) => entry.seq >= cursor) + const page = pending.slice(0, MAX_CHUNKS_PER_POLL) + const last = page.at(-1) + const nextCursor = last ? last.seq + 1 : Math.max(cursor, first) + const drained = page.length === pending.length + const result: HostedEnvironmentBuildFollowPoll = { + build: follower.build, + chunks: page.map((entry) => entry.chunk), + cursor: nextCursor, + truncated, + done: follower.done && drained, + error: follower.done && drained ? follower.error : null + } + if (result.done) { + // Keep it briefly so a retried final poll still answers; the sweep drops it later. + follower.lastPollAt = now() - ABANDONED_AFTER_MS + 5_000 + } + return result + }, + dispose() { + if (sweeper) { + clearInterval(sweeper) + sweeper = null + } + // Deleting the current key while iterating a Map is safe. + for (const key of followers.keys()) { + remove(key) + } + } + } +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-build-watch.test.ts b/src/main/hosted-sandbox/environments/hosted-environment-build-watch.test.ts new file mode 100644 index 00000000000..0139ff06def --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-build-watch.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaCloudEnvironmentBuildStreamEvent } from '../../../shared/hosted-sandbox/hosted-environment-api' +import { watchHostedEnvironmentBuild } from './hosted-environment-build-watch' +import { HostedEnvironmentRequestError } from './hosted-environment-client' +import { + BUILD_ID, + ENV_ID, + buildView, + line, + ndjsonBody +} from './hosted-environment-fixtures.test-support' + +const noSleep = async (): Promise => undefined +// The build route the idle probe asks; by default the build already finished (no vouching). +const finishedBuild = async () => buildView({ status: 'succeeded' }) + +function summarize(events: OrcaCloudEnvironmentBuildStreamEvent[]): string[] { + return events.map((event) => { + switch (event.type) { + case 'build': + return `build:${event.build.status}` + case 'log': + return `log:${event.stream}:${event.data}` + case 'error': + return `error:${event.message}` + case 'end': + case 'reset': + case 'reconnecting': + return event.type + } + }) +} + +describe('watchHostedEnvironmentBuild', () => { + it('parses lines split across chunks and stops at end once the build is final', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const openBuildEvents = vi.fn(async () => + ndjsonBody([ + line({ type: 'build', build: buildView() }).slice(0, 20), + line({ type: 'build', build: buildView() }).slice(20), + `${line({ type: 'log', stream: 'stdout', data: 'hello\n', at: 5 })}not json\n`, + line({ type: 'log', stream: 'stderr', data: 'warn\n' }), + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'end' }) + ]) + ) + const watch = watchHostedEnvironmentBuild({ + client: { openBuildEvents, getBuild: finishedBuild }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + sleep: noSleep + }) + await watch.done + expect(summarize(events)).toEqual([ + 'build:running', + 'log:stdout:hello\n', + 'log:stderr:warn\n', + 'build:succeeded', + 'end' + ]) + expect(events[1]).toMatchObject({ at: 5 }) + expect(openBuildEvents).toHaveBeenCalledTimes(1) + expect(openBuildEvents).toHaveBeenCalledWith(ENV_ID, BUILD_ID, expect.any(AbortSignal)) + }) + + it('reconnects with a reset when the stream drops before a final status', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const passes = [ + [ + line({ type: 'build', build: buildView() }), + line({ type: 'log', stream: 'stdout', data: 'a' }) + ], + // The control plane ended early (build still booting): follow again. + [ + line({ type: 'build', build: buildView() }), + line({ type: 'error', error: 'stream_failed' }) + ], + [ + line({ type: 'build', build: buildView() }), + line({ type: 'log', stream: 'stdout', data: 'ab' }), + line({ + type: 'build', + build: buildView({ status: 'failed', failedStep: 'install', error: 'install_failed' }) + }), + line({ type: 'end' }) + ] + ] + const sleep = vi.fn(noSleep) + let pass = 0 + const watch = watchHostedEnvironmentBuild({ + client: { openBuildEvents: async () => ndjsonBody(passes[pass++]), getBuild: finishedBuild }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + sleep + }) + await watch.done + expect(summarize(events)).toEqual([ + 'build:running', + 'log:stdout:a', + 'reconnecting', + 'reset', + 'build:running', + 'reconnecting', + 'reset', + 'build:running', + 'log:stdout:ab', + 'build:failed', + 'end' + ]) + expect(sleep).toHaveBeenCalledTimes(2) + }) + + it('treats an end before a final status as a drop', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + let pass = 0 + const watch = watchHostedEnvironmentBuild({ + client: { + getBuild: finishedBuild, + openBuildEvents: async () => + ndjsonBody( + pass++ === 0 + ? [ + line({ type: 'build', build: buildView({ status: 'starting' }) }), + line({ type: 'end' }) + ] + : [ + line({ type: 'build', build: buildView({ status: 'canceled' }) }), + line({ type: 'end' }) + ] + ) + }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + sleep: noSleep + }) + await watch.done + expect(summarize(events)).toEqual([ + 'build:starting', + 'reconnecting', + 'reset', + 'build:canceled', + 'end' + ]) + }) + + it('reconnects after the idle timeout when the build route says the build finished', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + let pass = 0 + const watch = watchHostedEnvironmentBuild({ + client: { + getBuild: finishedBuild, + openBuildEvents: async (_env, _build, signal) => + pass++ === 0 + ? ndjsonBody([line({ type: 'build', build: buildView() })], { hang: true, signal }) + : ndjsonBody([ + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'end' }) + ]) + }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + idleTimeoutMs: 30, + sleep: noSleep + }) + await watch.done + expect(summarize(events)).toEqual([ + 'build:running', + 'reconnecting', + 'reset', + 'build:succeeded', + 'end' + ]) + }) + + it('gives up on a final refusal and after repeated drops without progress', async () => { + const refused: OrcaCloudEnvironmentBuildStreamEvent[] = [] + await watchHostedEnvironmentBuild({ + client: { + getBuild: finishedBuild, + openBuildEvents: async () => { + throw new HostedEnvironmentRequestError('not_found', null, 'gone', 404) + } + }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => refused.push(event), + sleep: noSleep + }).done + expect(summarize(refused)).toEqual(['error:That environment or build no longer exists.']) + + const flaky: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const openBuildEvents = vi.fn(async () => { + throw new HostedEnvironmentRequestError('unavailable', null, 'down') + }) + await watchHostedEnvironmentBuild({ + client: { openBuildEvents, getBuild: finishedBuild }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => flaky.push(event), + maxStalledReconnects: 3, + sleep: noSleep + }).done + expect(openBuildEvents).toHaveBeenCalledTimes(4) + expect(flaky.map((event) => event.type)).toEqual([ + 'reconnecting', + 'reconnecting', + 'reconnecting', + 'error' + ]) + }) + + it('stops quietly on stop() while the stream is open', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const watch = watchHostedEnvironmentBuild({ + client: { + getBuild: finishedBuild, + openBuildEvents: async (_env, _build, signal) => + ndjsonBody([line({ type: 'build', build: buildView() })], { hang: true, signal }) + }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => { + events.push(event) + watch.stop() + }, + sleep: noSleep + }) + await watch.done + expect(summarize(events)).toEqual(['build:running']) + }) + + it('drops a line longer than the bound instead of buffering it', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const huge = 'x'.repeat(1024 * 1024 + 10) + await watchHostedEnvironmentBuild({ + client: { + getBuild: finishedBuild, + openBuildEvents: async () => + ndjsonBody([ + huge, + `${huge}\n`, + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'end' }) + ]) + }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + sleep: noSleep + }).done + expect(summarize(events)).toEqual(['build:succeeded', 'end']) + }) + + it('treats heartbeats as liveness and never forwards them', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const getBuild = vi.fn(finishedBuild) + const encoder = new TextEncoder() + const openBuildEvents = vi.fn(async () => { + let beats = 0 + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(line({ type: 'build', build: buildView() }))) + }, + // Six heartbeats 15 ms apart span three idle windows of 30 ms without a single log line. + async pull(controller) { + await new Promise((resolve) => setTimeout(resolve, 15)) + if (beats++ < 6) { + controller.enqueue(encoder.encode(line({ type: 'heartbeat', at: beats }))) + return + } + controller.enqueue( + encoder.encode( + line({ type: 'build', build: buildView({ status: 'succeeded' }) }) + + line({ type: 'end' }) + ) + ) + controller.close() + } + }) + }) + await watchHostedEnvironmentBuild({ + client: { openBuildEvents, getBuild }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + idleTimeoutMs: 30, + sleep: noSleep + }).done + expect(summarize(events)).toEqual(['build:running', 'build:succeeded', 'end']) + expect(openBuildEvents).toHaveBeenCalledTimes(1) + expect(getBuild).not.toHaveBeenCalled() + }) + + it('keeps a silent stream open while the build route says the build is running', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + const getBuild = vi.fn(async () => buildView()) + const encoder = new TextEncoder() + const openBuildEvents = vi.fn( + async () => + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(line({ type: 'build', build: buildView() }))) + // An old Worker: no heartbeat, then output after two idle windows. + setTimeout(() => { + controller.enqueue( + encoder.encode( + line({ type: 'log', stream: 'stdout', data: 'done installing\n' }) + + line({ type: 'build', build: buildView({ status: 'succeeded' }) }) + + line({ type: 'end' }) + ) + ) + controller.close() + }, 70) + } + }) + ) + await watchHostedEnvironmentBuild({ + client: { openBuildEvents, getBuild }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + idleTimeoutMs: 30, + sleep: noSleep + }).done + expect(summarize(events)).toEqual([ + 'build:running', + 'log:stdout:done installing\n', + 'build:succeeded', + 'end' + ]) + expect(openBuildEvents).toHaveBeenCalledTimes(1) + expect(getBuild).toHaveBeenCalled() + }) + + it('never gives up on a quiet build the API keeps vouching for', async () => { + const events: OrcaCloudEnvironmentBuildStreamEvent[] = [] + let opens = 0 + const openBuildEvents = vi.fn(async (_env: string, _build: string, signal: AbortSignal) => + // Every pass replays the same status and no new bytes, then goes silent. + ++opens <= 6 + ? ndjsonBody([line({ type: 'build', build: buildView() })], { hang: true, signal }) + : ndjsonBody([ + line({ type: 'build', build: buildView({ status: 'succeeded' }) }), + line({ type: 'end' }) + ]) + ) + await watchHostedEnvironmentBuild({ + client: { openBuildEvents, getBuild: async () => buildView() }, + envId: ENV_ID, + buildId: BUILD_ID, + onEvent: (event) => events.push(event), + idleTimeoutMs: 5, + maxQuietProbes: 1, + maxStalledReconnects: 2, + sleep: noSleep + }).done + expect(openBuildEvents).toHaveBeenCalledTimes(7) + expect(events.at(-1)).toEqual({ type: 'end' }) + expect(events.some((event) => event.type === 'error')).toBe(false) + }) +}) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-build-watch.ts b/src/main/hosted-sandbox/environments/hosted-environment-build-watch.ts new file mode 100644 index 00000000000..0d4fb1a4794 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-build-watch.ts @@ -0,0 +1,321 @@ +// Follows one environment build's ndjson event stream until the build is final. The control plane +// always replays the log from byte 0, so a reconnect first emits `reset` for consumers to clear. +import type { OrcaCloudEnvironmentBuildStreamEvent } from '../../../shared/hosted-sandbox/hosted-environment-api' +import { hostedEnvironmentErrorMessage } from '../../../shared/hosted-sandbox/hosted-environment-errors' +import { + HostedEnvironmentBuildStreamLineSchema, + isTerminalHostedEnvironmentBuildStatus, + type HostedEnvironmentBuildStatus +} from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { + HostedEnvironmentRequestError, + type HostedEnvironmentClient +} from './hosted-environment-client' + +export type HostedEnvironmentBuildWatch = { + stop(): void + /** Settles after the final event (`end` or `error`) or after stop(). */ + done: Promise +} + +export type HostedEnvironmentBuildWatchOptions = { + client: Pick + envId: string + buildId: string + onEvent: (event: OrcaCloudEnvironmentBuildStreamEvent) => void + /** No bytes (heartbeats included) for this long: ask the build route whether it is still alive. */ + idleTimeoutMs?: number + /** Quiet periods in a row the build route vouched for before reopening the stream anyway. */ + maxQuietProbes?: number + /** Delay before reconnect attempt N (1-based), clamped to the last entry. */ + backoffMs?: readonly number[] + /** Reconnects in a row that brought no new status or log before giving up. */ + maxStalledReconnects?: number + sleep?: (ms: number, signal: AbortSignal) => Promise +} + +const DEFAULT_IDLE_TIMEOUT_MS = 90_000 +const DEFAULT_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const +const DEFAULT_MAX_STALLED_RECONNECTS = 12 +// Older control planes send no heartbeat: a silent install is normal, so a quiet stream whose build +// the API still reports as running is kept; only after this many quiet periods is it reopened. +const DEFAULT_MAX_QUIET_PROBES = 3 +// One ndjson line holds one log chunk; anything bigger is dropped rather than buffered. +const MAX_LINE_CHARS = 1024 * 1024 +const FATAL_CODES = new Set([ + 'not_found', + 'forbidden', + 'unauthorized', + 'invalid_request', + 'not_configured', + 'entitlement_denied' +]) + +type PassOutcome = + | { kind: 'end' } + | { kind: 'fatal'; message: string } + | { kind: 'dropped' } + | { kind: 'stopped' } + +export function watchHostedEnvironmentBuild( + options: HostedEnvironmentBuildWatchOptions +): HostedEnvironmentBuildWatch { + const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + const backoff = options.backoffMs ?? DEFAULT_BACKOFF_MS + const maxStalled = options.maxStalledReconnects ?? DEFAULT_MAX_STALLED_RECONNECTS + const sleep = options.sleep ?? abortableSleep + const maxQuietProbes = options.maxQuietProbes ?? DEFAULT_MAX_QUIET_PROBES + const stopper = new AbortController() + + let lastStatus: HostedEnvironmentBuildStatus | null = null + let emittedAny = false + // Progress is measured against the longest replay so far: a replay of the same bytes is no progress. + let bestLogChars = 0 + let passLogChars = 0 + let passStatusChanged = false + // The build route confirmed the build alive during a quiet pass: its reconnect is not a stall. + let passVouched = false + + const emit = (event: OrcaCloudEnvironmentBuildStreamEvent): void => { + if (stopper.signal.aborted) { + return + } + try { + options.onEvent(event) + } catch (error) { + console.warn('[cloud-environments] build event listener threw:', error) + } + } + + const handleLine = (line: string): 'end' | 'error' | null => { + if (!line.trim()) { + return null + } + let raw: unknown + try { + raw = JSON.parse(line) + } catch { + return null + } + const parsed = HostedEnvironmentBuildStreamLineSchema.safeParse(raw) + if (!parsed.success) { + return null + } + const value = parsed.data + switch (value.type) { + case 'build': + if (value.build.status !== lastStatus) { + passStatusChanged = true + } + lastStatus = value.build.status + emittedAny = true + emit({ type: 'build', build: value.build }) + return null + case 'log': + passLogChars += value.data.length + emittedAny = true + emit({ + type: 'log', + stream: value.stream, + data: value.data, + ...(value.at === undefined ? {} : { at: value.at }) + }) + return null + case 'heartbeat': + // The read already re-armed the idle timer; nothing for consumers. + return null + case 'end': + return 'end' + case 'error': + return 'error' + } + } + + const runPass = async (): Promise => { + passLogChars = 0 + passStatusChanged = false + passVouched = false + const pass = new AbortController() + const onStop = (): void => pass.abort() + stopper.signal.addEventListener('abort', onStop, { once: true }) + let idle: ReturnType | null = null + let quietProbes = 0 + // A quiet stream is not a dead one: ask the build route before tearing the stream down. + const onIdle = async (): Promise => { + idle = null + if (pass.signal.aborted) { + return + } + quietProbes += 1 + const build = await options.client + .getBuild(options.envId, options.buildId, pass.signal) + .catch(() => null) + if (pass.signal.aborted) { + return + } + const alive = build !== null && !isTerminalHostedEnvironmentBuildStatus(build.status) + if (alive) { + passVouched = true + } + if (alive && build.status === lastStatus && quietProbes < maxQuietProbes) { + armIdle(false) + return + } + // Finished, changed status, unreachable, or quiet too long: reopen to replay the truth. + pass.abort() + } + const armIdle = (fromData = true): void => { + if (idle) { + clearTimeout(idle) + } + if (fromData) { + quietProbes = 0 + } + idle = setTimeout(() => void onIdle(), idleTimeoutMs) + idle.unref?.() + } + try { + armIdle() + let body: ReadableStream + try { + body = await options.client.openBuildEvents(options.envId, options.buildId, pass.signal) + } catch (error) { + if (stopper.signal.aborted) { + return { kind: 'stopped' } + } + if (error instanceof HostedEnvironmentRequestError && FATAL_CODES.has(error.code)) { + return { kind: 'fatal', message: hostedEnvironmentErrorMessage(error.code, error.reason) } + } + return { kind: 'dropped' } + } + if (emittedAny) { + emit({ type: 'reset' }) + } + const reader = body.getReader() + const decoder = new TextDecoder() + let buffered = '' + let discarding = false + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + armIdle() + buffered += decoder.decode(value, { stream: true }) + let newline = buffered.indexOf('\n') + while (newline !== -1) { + const line = buffered.slice(0, newline) + buffered = buffered.slice(newline + 1) + if (discarding) { + discarding = false + } else { + const verdict = handleLine(line) + if (verdict === 'end') { + return lastStatus && isTerminalHostedEnvironmentBuildStatus(lastStatus) + ? { kind: 'end' } + : // Ended before a final status (still booting): follow again. + { kind: 'dropped' } + } + if (verdict === 'error') { + return { kind: 'dropped' } + } + } + newline = buffered.indexOf('\n') + } + if (buffered.length > MAX_LINE_CHARS) { + buffered = '' + discarding = true + } + } + const tail = discarding ? '' : buffered + decoder.decode() + if ( + tail && + handleLine(tail) === 'end' && + lastStatus && + isTerminalHostedEnvironmentBuildStatus(lastStatus) + ) { + return { kind: 'end' } + } + } catch { + // Aborted by idle timer or stop, or the socket broke. + } finally { + reader.cancel().catch(() => undefined) + } + return stopper.signal.aborted ? { kind: 'stopped' } : { kind: 'dropped' } + } finally { + if (idle) { + clearTimeout(idle) + } + stopper.signal.removeEventListener('abort', onStop) + pass.abort() + } + } + + const run = async (): Promise => { + let stalled = 0 + for (;;) { + const outcome = await runPass() + if (outcome.kind === 'stopped' || stopper.signal.aborted) { + return + } + if (outcome.kind === 'end') { + emit({ type: 'end' }) + return + } + if (outcome.kind === 'fatal') { + emit({ type: 'error', message: outcome.message }) + return + } + // A replay of the same status and bytes is no progress, unless the API vouched the build alive. + const progressed = passStatusChanged || passVouched || passLogChars > bestLogChars + bestLogChars = Math.max(bestLogChars, passLogChars) + stalled = progressed ? 1 : stalled + 1 + if (stalled > maxStalled) { + emit({ + type: 'error', + message: 'Lost the connection to this build’s log. Reopen it to retry.' + }) + return + } + emit({ type: 'reconnecting' }) + try { + await sleep(backoff[Math.min(stalled, backoff.length) - 1] ?? 1_000, stopper.signal) + } catch { + return + } + if (stopper.signal.aborted) { + return + } + } + } + + const done = run().catch((error: unknown) => { + console.warn('[cloud-environments] build watch failed:', error) + emit({ type: 'error', message: 'Could not follow this build.' }) + }) + + return { + stop: () => stopper.abort(), + done + } +} + +function abortableSleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new Error('aborted')) + return + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + timer.unref?.() + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('aborted')) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.test.ts b/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.test.ts new file mode 100644 index 00000000000..af140e5223a --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { + followCloudEnvironmentBuild, + resolveCloudEnvironment, + setCloudEnvironmentInstallScript, + showCloudEnvironment +} from './hosted-environment-cli-operations' +import type { HostedEnvironmentBuildFollowers } from './hosted-environment-build-followers' +import type { HostedEnvironmentClient } from './hosted-environment-client' +import { toHostedEnvironmentError } from './hosted-environment-service' +import { + BUILD_ID, + ENV_ID, + buildView, + environmentEntry +} from './hosted-environment-fixtures.test-support' + +const OTHER_ID = 'env_ffffffffffffffffffff' + +function fakeClient(overrides: Partial = {}): HostedEnvironmentClient { + const client: HostedEnvironmentClient = { + list: vi.fn(async () => ({ + environments: [environmentEntry(), environmentEntry({ id: OTHER_ID, name: 'Mobile' })], + canAdmin: true + })), + get: vi.fn(async () => ({ + environment: environmentEntry(), + builds: [buildView({ number: 4, id: 'bld_aaaaaaaaaaaaaaaa' }), buildView()], + canAdmin: true + })), + create: vi.fn(), + update: vi.fn(async () => environmentEntry({ revision: 3 })), + remove: vi.fn(), + startBuild: vi.fn(), + getBuild: vi.fn(), + cancelBuild: vi.fn(), + activateBuild: vi.fn(), + staleness: vi.fn(async () => { + throw new Error('github down') + }), + openBuildEvents: vi.fn(), + ...overrides + } + return client +} + +describe('hosted environment CLI operations', () => { + it('picks the first environment by default, else by id or case-insensitive name', async () => { + const client = fakeClient() + expect((await resolveCloudEnvironment(client, undefined)).entry.id).toBe(ENV_ID) + expect((await resolveCloudEnvironment(client, OTHER_ID)).entry.name).toBe('Mobile') + expect((await resolveCloudEnvironment(client, 'mobile')).entry.id).toBe(OTHER_ID) + const missing = await resolveCloudEnvironment(client, 'nope').catch((e: unknown) => e) + expect(toHostedEnvironmentError(missing).message).toBe( + 'No environment named "nope". Known: Web, Mobile.' + ) + }) + + it('sends only the install script, guarded by the revision it read', async () => { + const client = fakeClient() + await setCloudEnvironmentInstallScript(client, undefined, 'npm ci\n') + expect(client.update).toHaveBeenCalledWith(ENV_ID, { + installScript: 'npm ci\n', + expectedRevision: 2 + }) + }) + + it('shows the environment even when the staleness check fails', async () => { + const data = await showCloudEnvironment(fakeClient(), undefined) + expect(data.staleness).toBeNull() + expect(data.environment).not.toHaveProperty('activeBuild') + expect(data.environments).toEqual([ + { id: ENV_ID, name: 'Web' }, + { id: OTHER_ID, name: 'Mobile' } + ]) + }) + + it('resolves a build number to its id before following', async () => { + const poll = vi.fn(async () => ({ + build: null, + chunks: [], + cursor: 0, + truncated: false, + done: false, + error: null + })) + const followers: HostedEnvironmentBuildFollowers = { poll, dispose: vi.fn() } + const client = fakeClient() + await followCloudEnvironmentBuild(client, followers, { build: '3' }) + expect(poll).toHaveBeenLastCalledWith(client, ENV_ID, BUILD_ID, 0, 0) + const unknown = await followCloudEnvironmentBuild(client, followers, { build: '99' }).catch( + (e: unknown) => e + ) + expect(toHostedEnvironmentError(unknown).code).toBe('not_found') + }) +}) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.ts b/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.ts new file mode 100644 index 00000000000..1dd2625c70e --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-cli-operations.ts @@ -0,0 +1,156 @@ +// What `orca cloud env ...` may do, resolved against the control plane. Deliberately narrow: an agent +// configuring the environment reads it, replaces the install script and runs builds. Repos, env var +// names, machine size and secrets stay with people in Settings. +import type { + CloudEnvironmentBuildsData, + CloudEnvironmentFollowData, + CloudEnvironmentRef, + CloudEnvironmentSetInstallScriptData, + CloudEnvironmentShowData, + CloudEnvironmentStartBuildData +} from '../../../shared/hosted-sandbox/hosted-environment-cli-contract' +import { + isHostedEnvironmentBuildId, + type HostedEnvironmentListEntry +} from '../../../shared/hosted-sandbox/hosted-environment-wire' +import type { HostedEnvironmentBuildFollowers } from './hosted-environment-build-followers' +import { + HostedEnvironmentUserError, + type HostedEnvironmentClient +} from './hosted-environment-client' + +const knownEnvironmentNames = new Map() + +export async function resolveCloudEnvironment( + client: Pick, + selector: string | undefined +): Promise<{ entry: HostedEnvironmentListEntry; all: CloudEnvironmentRef[]; canAdmin: boolean }> { + const { environments, canAdmin } = await client.list() + const all = environments.map((environment) => ({ id: environment.id, name: environment.name })) + knownEnvironmentNames.clear() + for (const environment of all) { + knownEnvironmentNames.set(environment.id, environment.name) + } + if (environments.length === 0) { + throw new HostedEnvironmentUserError( + 'not_found', + 'no_environments', + 'This org has no environment yet. Create one in Settings > Orca Cloud.' + ) + } + if (!selector) { + return { entry: environments[0], all, canAdmin } + } + const lowered = selector.toLowerCase() + const entry = + environments.find((environment) => environment.id === selector) ?? + environments.find((environment) => environment.name.toLowerCase() === lowered) + if (!entry) { + throw new HostedEnvironmentUserError( + 'not_found', + 'environment_not_found', + `No environment named "${selector.slice(0, 128)}". Known: ${all.map((env) => env.name).join(', ')}.` + ) + } + return { entry, all, canAdmin } +} + +const ref = (entry: { id: string; name: string }): CloudEnvironmentRef => ({ + id: entry.id, + name: entry.name +}) + +export async function showCloudEnvironment( + client: HostedEnvironmentClient, + selector: string | undefined, + refresh = false +): Promise { + const { entry, all, canAdmin } = await resolveCloudEnvironment(client, selector) + const { activeBuild, latestBuild, ...environment } = entry + // Staleness asks GitHub; a failure there should not hide the environment itself. + const staleness = await client.staleness(entry.id, refresh).catch(() => null) + return { environment, activeBuild, latestBuild, staleness, canAdmin, environments: all } +} + +export async function setCloudEnvironmentInstallScript( + client: HostedEnvironmentClient, + selector: string | undefined, + script: string, + expectedRevision?: number +): Promise { + const { entry } = await resolveCloudEnvironment(client, selector) + // Only the script: this surface must never carry repos, env var names or machine size. + const environment = await client.update(entry.id, { + installScript: script, + expectedRevision: expectedRevision ?? entry.revision + }) + return { environment } +} + +export async function startCloudEnvironmentBuild( + client: HostedEnvironmentClient, + selector: string | undefined +): Promise { + const { entry } = await resolveCloudEnvironment(client, selector) + return { environment: ref(entry), build: await client.startBuild(entry.id) } +} + +export async function listCloudEnvironmentBuilds( + client: HostedEnvironmentClient, + selector: string | undefined +): Promise { + const { entry } = await resolveCloudEnvironment(client, selector) + const detail = await client.get(entry.id) + return { environment: ref(entry), builds: detail.builds } +} + +export async function followCloudEnvironmentBuild( + client: HostedEnvironmentClient, + followers: HostedEnvironmentBuildFollowers, + args: { env?: string; build: string; cursor?: number; waitMs?: number } +): Promise { + // Polls repeat every few seconds: once the CLI holds both ids, skip the list round trip. + const envId = args.env + const known = envId ? knownEnvironmentNames.get(envId) : undefined + const environment = + envId && known !== undefined && isHostedEnvironmentBuildId(args.build) + ? { id: envId, name: known } + : ref((await resolveCloudEnvironment(client, args.env)).entry) + const buildId = await resolveBuildId(client, environment.id, args.build) + const poll = await followers.poll( + client, + environment.id, + buildId, + args.cursor ?? 0, + args.waitMs ?? 0 + ) + return { environment, ...poll } +} + +async function resolveBuildId( + client: Pick, + envId: string, + selector: string +): Promise { + if (isHostedEnvironmentBuildId(selector)) { + return selector + } + const number = /^#?(\d{1,9})$/.exec(selector)?.[1] + if (!number) { + throw new HostedEnvironmentUserError( + 'invalid_request', + 'build', + 'Pass a build number (e.g. 12) or a build id (bld_...).' + ) + } + const { builds } = await client.get(envId) + const build = builds.find((candidate) => candidate.number === Number(number)) + if (!build) { + throw new HostedEnvironmentUserError( + 'not_found', + 'build_not_found', + `Build #${number} is not among the 30 most recent builds; pass its id instead.` + ) + } + return build.id +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-client.test.ts b/src/main/hosted-sandbox/environments/hosted-environment-client.test.ts new file mode 100644 index 00000000000..e1664a83c09 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-client.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from 'vitest' +import { EnvironmentProviderError } from '../../environment-providers/environment-provider-driver' +import { + createHostedEnvironmentClient, + HostedEnvironmentRequestError +} from './hosted-environment-client' +import { toHostedEnvironmentError } from './hosted-environment-service' +import { + BUILD_ID, + ENV_ID, + buildView, + environmentEntry +} from './hosted-environment-fixtures.test-support' + +const BASE = 'https://control.example.test' + +function client(handler: (url: URL, init: RequestInit) => Response | Promise) { + const fetchMock = vi.fn(async (input: URL, init: RequestInit) => handler(new URL(input), init)) + return { + fetchMock, + client: createHostedEnvironmentClient({ + baseUrl: BASE, + getAccessToken: async () => 'tok', + fetch: fetchMock + }) + } +} + +const json = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +describe('createHostedEnvironmentClient', () => { + it('lists environments with the bearer token and never follows redirects', async () => { + const { client: c, fetchMock } = client(() => + json({ environments: [{ ...environmentEntry(), futureField: 1 }], canAdmin: true }) + ) + const result = await c.list() + expect(result.canAdmin).toBe(true) + expect(result.environments[0].name).toBe('Web') + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe(`${BASE}/v1/environments`) + expect(init?.redirect).toBe('manual') + expect(init?.headers).toMatchObject({ authorization: 'Bearer tok' }) + }) + + it('maps each route to its method and path', async () => { + const calls: string[] = [] + const { client: c } = client((url, init) => { + calls.push(`${init.method} ${url.pathname}${url.search}`) + if (url.pathname.endsWith('/staleness')) { + return json({ + staleness: { + checkedAt: 1, + activeBuildId: null, + configChanged: false, + recommendation: 'something_new', + repos: [] + } + }) + } + if ( + url.pathname.endsWith('/activate') || + init.method === 'PATCH' || + (init.method === 'POST' && url.pathname === '/v1/environments') + ) { + return json({ environment: environmentEntry() }) + } + if (init.method === 'DELETE') { + return json({ deleted: true }) + } + if (url.pathname.includes('/builds')) { + return json({ build: buildView() }) + } + return json({ environment: environmentEntry(), builds: [buildView()], canAdmin: false }) + }) + await c.get(ENV_ID) + await c.create({ name: 'Web', repos: [{ url: 'acme/web' }] }) + await c.update(ENV_ID, { installScript: 'x', expectedRevision: 2 }) + await c.remove(ENV_ID) + await c.startBuild(ENV_ID) + await c.getBuild(ENV_ID, BUILD_ID) + await c.cancelBuild(ENV_ID, BUILD_ID) + await c.activateBuild(ENV_ID, BUILD_ID) + const staleness = await c.staleness(ENV_ID, true) + expect(staleness.recommendation).toBe('unknown') + expect(calls).toEqual([ + `GET /v1/environments/${ENV_ID}`, + 'POST /v1/environments', + `PATCH /v1/environments/${ENV_ID}`, + `DELETE /v1/environments/${ENV_ID}`, + `POST /v1/environments/${ENV_ID}/builds`, + `GET /v1/environments/${ENV_ID}/builds/${BUILD_ID}`, + `POST /v1/environments/${ENV_ID}/builds/${BUILD_ID}/cancel`, + `POST /v1/environments/${ENV_ID}/builds/${BUILD_ID}/activate`, + `GET /v1/environments/${ENV_ID}/staleness?refresh=1` + ]) + }) + + it('refuses ids that could change the path', async () => { + const { client: c, fetchMock } = client(() => json({})) + await expect(c.get('env_../../v1/sandboxes')).rejects.toMatchObject({ code: 'invalid_request' }) + await expect(c.cancelBuild(ENV_ID, 'bld_x/../y')).rejects.toMatchObject({ + code: 'invalid_request' + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('maps control-plane refusals to friendly errors', async () => { + const { client: c } = client(() => json({ error: 'conflict', reason: 'stale_revision' }, 409)) + const error = await c.update(ENV_ID, { installScript: 'x' }).catch((e: unknown) => e) + expect(error).toBeInstanceOf(HostedEnvironmentRequestError) + expect(toHostedEnvironmentError(error)).toEqual({ + code: 'conflict', + reason: 'stale_revision', + message: 'Someone else saved changes. Reload to see them.' + }) + const admin = createHostedEnvironmentClient({ + baseUrl: BASE, + getAccessToken: async () => 'tok', + fetch: async () => json({ error: 'forbidden', reason: 'admin_required' }, 403) + }) + expect( + toHostedEnvironmentError(await admin.startBuild(ENV_ID).catch((e: unknown) => e)) + ).toMatchObject({ + message: 'Only owners and admins can change the environment. Ask an owner or admin.' + }) + }) + + it('treats redirects, oversized and malformed bodies as errors', async () => { + const redirect = client( + () => new Response(null, { status: 302, headers: { location: 'https://evil.test' } }) + ) + await expect(redirect.client.list()).rejects.toMatchObject({ code: 'invalid_response' }) + const huge = client(() => new Response('x'.repeat(3 * 1024 * 1024))) + await expect(huge.client.list()).rejects.toMatchObject({ code: 'invalid_response' }) + const wrong = client(() => json({ environments: 'nope' })) + await expect(wrong.client.list()).rejects.toMatchObject({ code: 'invalid_response' }) + }) + + it('reports sign-in problems from token minting', async () => { + const c = createHostedEnvironmentClient({ + baseUrl: BASE, + getAccessToken: async () => { + throw new EnvironmentProviderError('unauthorized', 'Sign in to Orca to use Orca Cloud.') + }, + fetch: async () => json({}) + }) + expect(toHostedEnvironmentError(await c.list().catch((e: unknown) => e))).toMatchObject({ + code: 'unauthorized', + message: 'Sign in to Orca to use Orca Cloud.' + }) + }) + + it('requires an https control plane', () => { + expect(() => + createHostedEnvironmentClient({ + baseUrl: 'http://control.example.test', + getAccessToken: async () => '' + }) + ).toThrow(/HTTPS/) + }) +}) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-client.ts b/src/main/hosted-sandbox/environments/hosted-environment-client.ts new file mode 100644 index 00000000000..9fb3a3ee02e --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-client.ts @@ -0,0 +1,252 @@ +// HTTPS client for org environments on the Orca sandbox control plane (/v1/environments). Same +// bearer as /v1/sandboxes; the desktop holds only a short-lived Orca token. +import type { z } from 'zod' +import { + HostedEnvironmentBuildResponseSchema, + HostedEnvironmentDeletedResponseSchema, + HostedEnvironmentDetailResponseSchema, + HostedEnvironmentListResponseSchema, + HostedEnvironmentResponseSchema, + HostedEnvironmentStalenessResponseSchema, + isHostedEnvironmentBuildId, + isHostedEnvironmentId, + type HostedEnvironmentBuildView, + type HostedEnvironmentCreateInput, + type HostedEnvironmentDetailResponse, + type HostedEnvironmentListResponse, + type HostedEnvironmentStaleness, + type HostedEnvironmentUpdateInput, + type HostedEnvironmentView +} from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { + errorFromResponse, + HostedEnvironmentRequestError, + parseBaseUrl, + readJson, + transportError +} from './hosted-environment-http' + +export { + HostedEnvironmentRequestError, + HostedEnvironmentUserError +} from './hosted-environment-http' + +export type HostedEnvironmentClient = { + list(signal?: AbortSignal): Promise + get(envId: string, signal?: AbortSignal): Promise + create(input: HostedEnvironmentCreateInput, signal?: AbortSignal): Promise + update( + envId: string, + patch: HostedEnvironmentUpdateInput, + signal?: AbortSignal + ): Promise + remove(envId: string, signal?: AbortSignal): Promise<{ deleted: true }> + startBuild(envId: string, signal?: AbortSignal): Promise + getBuild( + envId: string, + buildId: string, + signal?: AbortSignal + ): Promise + cancelBuild( + envId: string, + buildId: string, + signal?: AbortSignal + ): Promise + activateBuild( + envId: string, + buildId: string, + signal?: AbortSignal + ): Promise + staleness( + envId: string, + refresh?: boolean, + signal?: AbortSignal + ): Promise + /** Opens the ndjson event stream; the caller owns the body and its cancellation via `signal`. */ + openBuildEvents( + envId: string, + buildId: string, + signal: AbortSignal + ): Promise> +} + +export type HostedEnvironmentClientOptions = { + baseUrl: string + getAccessToken: (signal?: AbortSignal) => Promise + fetch?: (input: URL, init: RequestInit) => Promise + /** Per-call ceiling for JSON routes; the event stream has none (the watcher runs an idle timer). */ + timeoutMs?: number +} + +const DEFAULT_TIMEOUT_MS = 60_000 + +export function createHostedEnvironmentClient( + options: HostedEnvironmentClientOptions +): HostedEnvironmentClient { + const base = parseBaseUrl(options.baseUrl) + const doFetch = options.fetch ?? fetch + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + + async function send( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + init: { body?: unknown; signal?: AbortSignal; accept?: string; timeout?: AbortSignal } + ): Promise { + const signals = [init.signal, init.timeout].filter((s): s is AbortSignal => s !== undefined) + const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0] + try { + const token = await options.getAccessToken(signal) + return await doFetch(new URL(path, base), { + method, + headers: { + authorization: `Bearer ${token}`, + accept: init.accept ?? 'application/json', + ...(init.body === undefined ? {} : { 'content-type': 'application/json' }) + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + // Why: a redirect could forward the bearer token to another origin. + redirect: 'manual', + signal + }) + } catch (error) { + throw transportError(error, init.signal, init.timeout) + } + } + + async function call( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + schema: z.ZodType, + init: { body?: unknown; signal?: AbortSignal } = {} + ): Promise { + const timeout = AbortSignal.timeout(timeoutMs) + const response = await send(method, path, { ...init, timeout }) + let payload: unknown + try { + payload = await readJson(response) + } catch (error) { + throw transportError(error, init.signal, timeout) + } + if (!response.ok) { + throw errorFromResponse(response.status, payload) + } + const parsed = schema.safeParse(payload) + if (!parsed.success) { + throw new HostedEnvironmentRequestError( + 'invalid_response', + null, + 'Orca Cloud returned an unexpected response.', + response.status + ) + } + return parsed.data + } + + return { + list: (signal) => + call('GET', 'v1/environments', HostedEnvironmentListResponseSchema, { signal }), + get: async (envId, signal) => + call('GET', envPath(envId), HostedEnvironmentDetailResponseSchema, { signal }), + create: async (input, signal) => + ( + await call('POST', 'v1/environments', HostedEnvironmentResponseSchema, { + body: input, + signal + }) + ).environment, + update: async (envId, patch, signal) => + ( + await call('PATCH', envPath(envId), HostedEnvironmentResponseSchema, { + body: patch, + signal + }) + ).environment, + remove: async (envId, signal) => { + await call('DELETE', envPath(envId), HostedEnvironmentDeletedResponseSchema, { signal }) + return { deleted: true } + }, + startBuild: async (envId, signal) => + ( + await call('POST', envPath(envId, '/builds'), HostedEnvironmentBuildResponseSchema, { + signal + }) + ).build, + getBuild: async (envId, buildId, signal) => + ( + await call('GET', buildPath(envId, buildId), HostedEnvironmentBuildResponseSchema, { + signal + }) + ).build, + cancelBuild: async (envId, buildId, signal) => + ( + await call( + 'POST', + buildPath(envId, buildId, '/cancel'), + HostedEnvironmentBuildResponseSchema, + { signal } + ) + ).build, + activateBuild: async (envId, buildId, signal) => + ( + await call( + 'POST', + buildPath(envId, buildId, '/activate'), + HostedEnvironmentResponseSchema, + { + signal + } + ) + ).environment, + staleness: async (envId, refresh, signal) => + ( + await call( + 'GET', + envPath(envId, refresh ? '/staleness?refresh=1' : '/staleness'), + HostedEnvironmentStalenessResponseSchema, + { signal } + ) + ).staleness, + openBuildEvents: async (envId, buildId, signal) => { + // Only the response headers are bounded; the body streams for the whole build. + const headerTimeout = AbortSignal.timeout(timeoutMs) + const controller = new AbortController() + const onAbort = (): void => controller.abort() + signal.addEventListener('abort', onAbort, { once: true }) + headerTimeout.addEventListener('abort', onAbort, { once: true }) + let response: Response + try { + response = await send('GET', buildPath(envId, buildId, '/events'), { + signal: controller.signal, + accept: 'application/x-ndjson' + }) + } catch (error) { + throw transportError(error, signal, headerTimeout) + } finally { + headerTimeout.removeEventListener('abort', onAbort) + } + if (!response.ok || !response.body) { + const payload = await readJson(response).catch(() => null) + throw errorFromResponse(response.status, payload) + } + return response.body + } + } +} + +function envPath(envId: string, suffix = ''): string { + if (!isHostedEnvironmentId(envId)) { + throw new HostedEnvironmentRequestError( + 'invalid_request', + 'environment_id', + 'Invalid environment id.' + ) + } + return `v1/environments/${envId}${suffix}` +} + +function buildPath(envId: string, buildId: string, suffix = ''): string { + if (!isHostedEnvironmentBuildId(buildId)) { + throw new HostedEnvironmentRequestError('invalid_request', 'build_id', 'Invalid build id.') + } + return envPath(envId, `/builds/${buildId}${suffix}`) +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-fixtures.test-support.ts b/src/main/hosted-sandbox/environments/hosted-environment-fixtures.test-support.ts new file mode 100644 index 00000000000..4eaf75ba946 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-fixtures.test-support.ts @@ -0,0 +1,91 @@ +import type { + HostedEnvironmentBuildView, + HostedEnvironmentListEntry +} from '../../../shared/hosted-sandbox/hosted-environment-wire' + +export const ENV_ID = 'env_03b89d4bf1c74787a7ce' +export const BUILD_ID = 'bld_0123456789abcdef' + +export function buildView( + overrides: Partial = {} +): HostedEnvironmentBuildView { + return { + id: BUILD_ID, + number: 3, + status: 'running', + active: false, + createdBy: 'user_1', + createdAt: 1_700_000_000_000, + startedAt: 1_700_000_002_000, + snapshotStartedAt: null, + finishedAt: null, + durationMs: null, + activatable: false, + error: null, + failedStep: null, + revision: 2, + machineSize: 'standard', + repos: [{ url: 'https://github.com/acme/web.git', branch: 'main', commit: null }], + diskUsedBytes: null, + diskTotalBytes: null, + logBytes: 0, + logTruncated: false, + ...overrides + } +} + +export function environmentEntry( + overrides: Partial = {} +): HostedEnvironmentListEntry { + return { + id: ENV_ID, + name: 'Web', + repos: [{ url: 'https://github.com/acme/web.git', defaultBranch: 'main' }], + installScript: 'pnpm install', + envVarNames: ['NPM_TOKEN'], + machineSize: 'standard', + activeBuildId: null, + revision: 2, + createdBy: 'user_1', + updatedBy: 'user_1', + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + activeBuild: null, + latestBuild: null, + ...overrides + } +} + +/** A body that yields the given ndjson chunks, then either closes or stays open until aborted. */ +export function ndjsonBody( + chunks: readonly string[], + options: { hang?: boolean; signal?: AbortSignal } = {} +): ReadableStream { + const encoder = new TextEncoder() + let index = 0 + return new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++])) + return + } + if (!options.hang) { + controller.close() + return + } + return new Promise((resolve) => { + const fail = (): void => { + controller.error(new Error('aborted')) + resolve() + } + if (options.signal?.aborted) { + fail() + } else { + options.signal?.addEventListener('abort', fail, { once: true }) + } + }) + } + }) +} + +export const line = (value: unknown): string => `${JSON.stringify(value)}\n` diff --git a/src/main/hosted-sandbox/environments/hosted-environment-http.ts b/src/main/hosted-sandbox/environments/hosted-environment-http.ts new file mode 100644 index 00000000000..1f1f97aa5d2 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-http.ts @@ -0,0 +1,115 @@ +// Transport helpers for the environment client: bounded JSON reads and error mapping. +import { HostedEnvironmentErrorResponseSchema } from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { EnvironmentProviderError } from '../../environment-providers/environment-provider-driver' + +// A detail view carries 30 builds plus a 20k-char script; list carries every environment. +const MAX_RESPONSE_BYTES = 2 * 1024 * 1024 + +/** `code` is the control plane's error (`forbidden`, `conflict`, ...) or a transport one. */ +export class HostedEnvironmentRequestError extends Error { + constructor( + readonly code: string, + readonly reason: string | null, + message: string, + readonly status: number | null = null + ) { + super(message) + this.name = 'HostedEnvironmentRequestError' + } +} + +/** A desktop-side refusal whose message is already written for the user (or the agent). */ +export class HostedEnvironmentUserError extends HostedEnvironmentRequestError {} + +export function parseBaseUrl(value: string): URL { + const url = new URL(value.endsWith('/') ? value : `${value}/`) + const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new Error('Orca Cloud control plane must be HTTPS.') + } + if (url.username || url.password || url.search || url.hash) { + throw new Error('Orca Cloud control plane URL must not carry credentials, query or fragment.') + } + return url +} + +/** Reads at most MAX_RESPONSE_BYTES; a larger body reads as unparseable instead of buffering it all. */ +export async function readJson(response: Response): Promise { + if (!response.body) { + return null + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) { + break + } + total += value.byteLength + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined) + return null + } + chunks.push(value) + } + const text = Buffer.concat(chunks).toString('utf8') + try { + return text ? JSON.parse(text) : null + } catch { + return null + } +} + +export function errorFromResponse(status: number, payload: unknown): HostedEnvironmentRequestError { + const parsed = HostedEnvironmentErrorResponseSchema.safeParse(payload) + if (parsed.success) { + return new HostedEnvironmentRequestError( + parsed.data.error, + parsed.data.reason ?? null, + `Orca Cloud refused the request (${parsed.data.error}${parsed.data.reason ? `: ${parsed.data.reason}` : ''}).`, + status + ) + } + if (status >= 300 && status < 400) { + return new HostedEnvironmentRequestError( + 'invalid_response', + null, + 'Orca Cloud answered with a redirect.', + status + ) + } + return new HostedEnvironmentRequestError( + status === 401 + ? 'unauthorized' + : status === 403 + ? 'forbidden' + : status === 404 + ? 'not_found' + : 'unavailable', + null, + `Orca Cloud request failed (HTTP ${status}).`, + status + ) +} + +export function transportError( + error: unknown, + callerSignal: AbortSignal | undefined, + timeoutSignal: AbortSignal | undefined +): HostedEnvironmentRequestError { + if (error instanceof HostedEnvironmentRequestError) { + return error + } + // Token minting reports sign-in problems as provider errors. + if (error instanceof EnvironmentProviderError) { + return new HostedEnvironmentRequestError(error.code, error.reason ?? null, error.message) + } + if (callerSignal?.aborted) { + return new HostedEnvironmentRequestError('aborted', null, 'Orca Cloud request was cancelled.') + } + if (timeoutSignal?.aborted) { + return new HostedEnvironmentRequestError('timeout', null, 'Orca Cloud did not answer in time.') + } + return new HostedEnvironmentRequestError('unavailable', null, 'Could not reach Orca Cloud.') +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-install.ts b/src/main/hosted-sandbox/environments/hosted-environment-install.ts new file mode 100644 index 00000000000..59571c2d9fc --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-install.ts @@ -0,0 +1,26 @@ +import { app } from 'electron' +import { withCloudBuildDefaults } from '../../cloud-build-defaults' +import { getHostedSandboxConfig } from '../hosted-sandbox-config' +import { + createHostedEnvironmentService, + getHostedEnvironmentService, + registerHostedEnvironmentService, + type HostedEnvironmentService +} from './hosted-environment-service' + +/** One service per process, built on first use so startup never reads the profile for it. */ +export function installHostedEnvironmentService(): HostedEnvironmentService { + const existing = getHostedEnvironmentService() + if (existing) { + return existing + } + const service = createHostedEnvironmentService(() => + getHostedSandboxConfig( + app.getPath('userData'), + withCloudBuildDefaults(process.env), + app.isPackaged + ) + ) + registerHostedEnvironmentService(service) + return service +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.test.ts b/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.test.ts new file mode 100644 index 00000000000..09b2d00e520 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.test.ts @@ -0,0 +1,69 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readRepoSetupImports } from './hosted-environment-repo-imports' +import { toHostedEnvironmentError } from './hosted-environment-service' + +let base: string +let repo: string +let outside: string + +beforeEach(async () => { + base = await mkdtemp(join(tmpdir(), 'orca-env-imports-')) + repo = join(base, 'repo') + outside = join(base, 'outside') + await mkdir(repo) + await mkdir(outside) +}) + +afterEach(async () => { + await rm(base, { recursive: true, force: true }) +}) + +const devcontainer = (name: string) => + JSON.stringify({ postCreateCommand: 'npm ci', containerEnv: { [name]: 'value' } }) + +describe('readRepoSetupImports', () => { + it('reads names and a setup script from a registered repo', async () => { + await mkdir(join(repo, '.devcontainer')) + await writeFile(join(repo, '.devcontainer/devcontainer.json'), devcontainer('NPM_TOKEN')) + const result = await readRepoSetupImports(repo, [repo]) + expect(result.envVarNames).toEqual(['NPM_TOKEN']) + expect(result.installScript).toContain('npm ci') + expect(result.sources).toEqual([ + 'Dev Container (.devcontainer/devcontainer.json)', + 'Environment variable names (.devcontainer/devcontainer.json)' + ]) + }) + + it('refuses a folder that is not a project in Orca', async () => { + const error = await readRepoSetupImports(outside, [repo]).catch((e: unknown) => e) + expect(toHostedEnvironmentError(error)).toMatchObject({ + code: 'not_found', + reason: 'repo_not_registered' + }) + const relative = await readRepoSetupImports('repo', [repo]).catch((e: unknown) => e) + expect(toHostedEnvironmentError(relative).code).toBe('invalid_request') + }) + + it('ignores config reached through a symlink that leaves the repo', async () => { + await mkdir(join(outside, 'dc')) + await writeFile(join(outside, 'dc/devcontainer.json'), devcontainer('LEAKED_NAME')) + await symlink(join(outside, 'dc'), join(repo, '.devcontainer')) + await writeFile(join(outside, 'secrets.env'), 'OUTSIDE_SECRET=1\n') + await symlink(join(outside, 'secrets.env'), join(repo, '.env.example')) + const result = await readRepoSetupImports(repo, [repo]) + expect(result.envVarNames).toEqual([]) + expect(result.installScript).toBeUndefined() + expect(result.sources).toEqual([]) + }) + + it('follows a symlink that stays inside the repo', async () => { + await mkdir(join(repo, 'config')) + await writeFile(join(repo, 'config/env.example'), 'INSIDE_NAME=\n') + await symlink(join(repo, 'config/env.example'), join(repo, '.env.example')) + const result = await readRepoSetupImports(repo, [repo]) + expect(result.envVarNames).toEqual(['INSIDE_NAME']) + }) +}) diff --git a/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.ts b/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.ts new file mode 100644 index 00000000000..6f66155bdd8 --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-repo-imports.ts @@ -0,0 +1,81 @@ +// Seeds a new org environment from a local checkout: the first setup command Orca's importers find +// (Conductor, dev container, Cursor, ...) and the env var NAMES those configs expect. No values. +import { readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import type { OrcaCloudRepoSetupImports } from '../../../shared/hosted-sandbox/hosted-environment-api' +import { HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS } from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { inspectSetupEnvVarNames } from '../../../shared/workspace-setup/setup-env-var-name-imports' +import { SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES } from '../../../shared/workspace-setup/setup-script-import-limits' +import { inspectSetupScriptImportCandidates } from '../../../shared/workspace-setup/setup-script-imports' +import { HostedEnvironmentUserError } from './hosted-environment-client' + +/** + * `registeredRepoPaths` are the local checkouts in main's repo store; the renderer can only name + * one of them. Every file is resolved through realpath and must stay inside the repo, so a + * symlinked `.devcontainer/` or `.conductor/` cannot point the importer elsewhere on disk. + */ +export async function readRepoSetupImports( + repoPath: unknown, + registeredRepoPaths: readonly string[] +): Promise { + if (typeof repoPath !== 'string' || !isAbsolute(repoPath) || repoPath.length > 4096) { + throw new HostedEnvironmentUserError('invalid_request', 'repo_path', 'Invalid repository path.') + } + const root = await realpath(repoPath).catch(() => null) + const registered = await Promise.all( + registeredRepoPaths.map((path) => realpath(path).catch(() => null)) + ) + if (!root || !registered.includes(root)) { + throw new HostedEnvironmentUserError( + 'not_found', + 'repo_not_registered', + 'That folder is not a project in Orca. Add it as a project first.' + ) + } + if (!(await stat(root).catch(() => null))?.isDirectory()) { + throw new HostedEnvironmentUserError('not_found', null, 'Repository folder not found.') + } + + /** The file's real path when it is a regular file inside the repo, else null. */ + const resolveInside = async (relativePath: string): Promise => { + if (isAbsolute(relativePath)) { + return null + } + const real = await realpath(join(root, relativePath)).catch(() => null) + if (!real) { + return null + } + const rel = relative(root, real) + if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + return null + } + const info = await stat(real).catch(() => null) + return info?.isFile() && info.size <= SETUP_SCRIPT_IMPORT_FILE_MAX_BYTES ? real : null + } + const fileExists = async (relativePath: string): Promise => + (await resolveInside(relativePath)) !== null + const read = async (relativePath: string): Promise => { + const real = await resolveInside(relativePath) + return real ? readFile(real, 'utf8').catch(() => null) : null + } + + const [candidates, envNames] = await Promise.all([ + inspectSetupScriptImportCandidates(read, { fileExists }), + inspectSetupEnvVarNames(read) + ]) + const sources: string[] = [] + const first = candidates.find( + (candidate) => candidate.setup.length <= HOSTED_ENVIRONMENT_MAX_INSTALL_SCRIPT_CHARS + ) + if (first) { + sources.push(`${first.label} (${first.files.join(', ')})`) + } + for (const file of envNames.files) { + sources.push(`Environment variable names (${file})`) + } + return { + envVarNames: envNames.names, + ...(first ? { installScript: first.setup } : {}), + sources + } +} diff --git a/src/main/hosted-sandbox/environments/hosted-environment-service.ts b/src/main/hosted-sandbox/environments/hosted-environment-service.ts new file mode 100644 index 00000000000..233a4d82fbb --- /dev/null +++ b/src/main/hosted-sandbox/environments/hosted-environment-service.ts @@ -0,0 +1,92 @@ +// The desktop's one handle on org environments: a lazily built control-plane client plus the +// Result mapping the IPC and runtime RPC surfaces share. +import type { + OrcaCloudEnvironmentError, + OrcaCloudEnvironmentResult +} from '../../../shared/hosted-sandbox/hosted-environment-api' +import { hostedEnvironmentError } from '../../../shared/hosted-sandbox/hosted-environment-errors' +import type { HostedSandboxConfig } from '../hosted-sandbox-config' +import { + createHostedEnvironmentClient, + HostedEnvironmentRequestError, + HostedEnvironmentUserError, + type HostedEnvironmentClient +} from './hosted-environment-client' + +export type HostedEnvironmentService = { + isConfigured(): boolean + /** Throws HostedEnvironmentRequestError('not_configured') when this build has no control plane. */ + client(): HostedEnvironmentClient +} + +export function createHostedEnvironmentService( + loadConfig: () => HostedSandboxConfig | null, + fetchImpl?: typeof fetch +): HostedEnvironmentService { + let resolved: { client: HostedEnvironmentClient | null } | null = null + const resolve = (): HostedEnvironmentClient | null => { + if (!resolved) { + const config = loadConfig() + resolved = { + client: config + ? createHostedEnvironmentClient({ + baseUrl: config.controlPlaneUrl, + getAccessToken: config.getAccessToken, + fetch: fetchImpl + }) + : null + } + } + return resolved.client + } + return { + isConfigured: () => resolve() !== null, + client: () => { + const client = resolve() + if (!client) { + throw new HostedEnvironmentRequestError( + 'not_configured', + null, + 'Orca Cloud is not set up in this build of Orca.' + ) + } + return client + } + } +} + +export function toHostedEnvironmentError(error: unknown): OrcaCloudEnvironmentError { + if (error instanceof HostedEnvironmentUserError) { + return { + code: error.code, + ...(error.reason ? { reason: error.reason } : {}), + message: error.message + } + } + if (error instanceof HostedEnvironmentRequestError) { + return hostedEnvironmentError(error.code, error.reason) + } + console.warn('[cloud-environments] unexpected failure:', error) + return hostedEnvironmentError('unavailable') +} + +export async function hostedEnvironmentResult( + work: () => Promise +): Promise> { + try { + return { ok: true, data: await work() } + } catch (error) { + return { ok: false, error: toHostedEnvironmentError(error) } + } +} + +let registered: HostedEnvironmentService | null = null + +/** Desktop main publishes the service for the runtime RPC (CLI); a headless host has none. */ +export function registerHostedEnvironmentService(service: HostedEnvironmentService | null): void { + registered = service +} + +export function getHostedEnvironmentService(): HostedEnvironmentService | null { + return registered +} diff --git a/src/main/hosted-sandbox/hosted-sandbox-install.ts b/src/main/hosted-sandbox/hosted-sandbox-install.ts index 5d38a08eb28..a00846d3c98 100644 --- a/src/main/hosted-sandbox/hosted-sandbox-install.ts +++ b/src/main/hosted-sandbox/hosted-sandbox-install.ts @@ -25,6 +25,10 @@ import { import { createHostedSessionRegistry, type HostedSessionRegistry } from './hosted-session-registry' import { registerCloudShareHandlers } from './hosted-sandbox-share-ipc' import { setHostedSessionSync } from './hosted-session-sync' +import { + registerOrcaCloudEnvironmentHandlers, + type OrcaCloudEnvironmentRepoSource +} from '../ipc/cloud-environments/orca-cloud-environment-handlers' import { installCloudTransferHandlers } from './cloud-transfer' import { installCloudOfflineService, setCloudOfflineService } from './cloud-offline-service' import { @@ -46,7 +50,10 @@ export function getHostedSandboxControlPlaneClient(): HostedSandboxControlPlaneC return controlPlaneClient } -export function installHostedSandbox(): void { +/** `repos` is main's repo store: org-environment repo imports only read checkouts listed there. */ +export function installHostedSandbox(repos?: OrcaCloudEnvironmentRepoSource): void { + // Idempotent; answers isConfigured() = false itself when this build has no control plane. + registerOrcaCloudEnvironmentHandlers(repos ? { repos } : {}) if (installed) { return } diff --git a/src/main/ipc/cloud-environments/orca-cloud-environment-handlers.ts b/src/main/ipc/cloud-environments/orca-cloud-environment-handlers.ts new file mode 100644 index 00000000000..5ac91a6cef7 --- /dev/null +++ b/src/main/ipc/cloud-environments/orca-cloud-environment-handlers.ts @@ -0,0 +1,281 @@ +// IPC for window.api.orcaCloudEnvironments. Every call answers a Result so the renderer never sees +// a thrown control-plane error; build watches stream to the window that opened them. +import { randomUUID } from 'node:crypto' +import { ipcMain, type IpcMainInvokeEvent, type WebContents } from 'electron' +import { z } from 'zod' +import { getRepoDialableSshTargetId } from '../../../shared/execution-host' +import type { Repo } from '../../../shared/repo-types' +import { + ORCA_CLOUD_ENVIRONMENT_BUILD_EVENT_CHANNEL, + type OrcaCloudEnvironmentBuildEvent, + type OrcaCloudEnvironmentResult +} from '../../../shared/hosted-sandbox/hosted-environment-api' +import { hostedEnvironmentError } from '../../../shared/hosted-sandbox/hosted-environment-errors' +import { + HOSTED_ENVIRONMENT_BUILD_ID, + HOSTED_ENVIRONMENT_ID, + HostedEnvironmentCreateInputSchema, + HostedEnvironmentUpdateInputSchema +} from '../../../shared/hosted-sandbox/hosted-environment-wire' +import { watchHostedEnvironmentBuild } from '../../hosted-sandbox/environments/hosted-environment-build-watch' +import { installHostedEnvironmentService } from '../../hosted-sandbox/environments/hosted-environment-install' +import { readRepoSetupImports } from '../../hosted-sandbox/environments/hosted-environment-repo-imports' +import { + hostedEnvironmentResult, + type HostedEnvironmentService +} from '../../hosted-sandbox/environments/hosted-environment-service' + +const EnvId = z.string().regex(HOSTED_ENVIRONMENT_ID) +const BuildId = z.string().regex(HOSTED_ENVIRONMENT_BUILD_ID) +// A settings page shows one build log at a time; more than this per window is a leak. +const MAX_WATCHES_PER_SENDER = 8 + +const CHANNELS = [ + 'orcaCloudEnvironments:isConfigured', + 'orcaCloudEnvironments:list', + 'orcaCloudEnvironments:get', + 'orcaCloudEnvironments:create', + 'orcaCloudEnvironments:update', + 'orcaCloudEnvironments:remove', + 'orcaCloudEnvironments:startBuild', + 'orcaCloudEnvironments:cancelBuild', + 'orcaCloudEnvironments:activateBuild', + 'orcaCloudEnvironments:staleness', + 'orcaCloudEnvironments:watchBuild', + 'orcaCloudEnvironments:unwatchBuild', + 'orcaCloudEnvironments:readRepoSetupImports' +] as const + +type Watch = { + senderId: number + stop: () => void + /** Sends one last event to the renderer that owns the watch. */ + notify: (event: OrcaCloudEnvironmentBuildEvent['event']) => void +} + +export type OrcaCloudEnvironmentRepoSource = { getRepos(): Repo[] } + +const EVICTED_MESSAGE = + 'Stopped following this build: too many build logs are open in this window. Reopen it to resume.' + +// Main's repo store, set by whoever wires the handlers; without it no repo can be imported from. +let repoSource: OrcaCloudEnvironmentRepoSource | null = null + +/** Local checkouts the user added to Orca; SSH-backed repos never fall through to local reads. */ +function registeredLocalRepoPaths(): string[] { + return (repoSource?.getRepos() ?? []) + .filter((repo) => !getRepoDialableSshTargetId(repo)) + .map((repo) => repo.path) +} + +function invalid(field: string): OrcaCloudEnvironmentResult { + return { ok: false, error: hostedEnvironmentError('invalid_request', field) } +} + +let registered = false + +/** Idempotent: startup may wire Orca Cloud more than once. */ +export function registerOrcaCloudEnvironmentHandlers( + options: { repos?: OrcaCloudEnvironmentRepoSource; service?: HostedEnvironmentService } = {} +): void { + if (options.repos) { + repoSource = options.repos + } + if (registered) { + return + } + const service = options.service ?? installHostedEnvironmentService() + registered = true + for (const channel of CHANNELS) { + ipcMain.removeHandler(channel) + } + const watches = new Map() + const trackedSenders = new Set() + + const stopWatch = (watchId: string): void => { + const watch = watches.get(watchId) + if (watch) { + watches.delete(watchId) + watch.stop() + } + } + + const trackSender = (sender: WebContents): void => { + if (trackedSenders.has(sender.id)) { + return + } + trackedSenders.add(sender.id) + const senderId = sender.id + const cleanup = (): void => { + trackedSenders.delete(senderId) + for (const [watchId, watch] of watches) { + if (watch.senderId === senderId) { + stopWatch(watchId) + } + } + } + sender.once('destroyed', cleanup) + // A reload keeps the WebContents but drops every renderer listener. + sender.on('did-start-navigation', (details) => { + if (details.isMainFrame && !details.isSameDocument) { + for (const [watchId, watch] of watches) { + if (watch.senderId === senderId) { + stopWatch(watchId) + } + } + } + }) + } + + ipcMain.handle('orcaCloudEnvironments:isConfigured', () => service.isConfigured()) + + ipcMain.handle('orcaCloudEnvironments:list', () => + hostedEnvironmentResult(() => service.client().list()) + ) + + ipcMain.handle('orcaCloudEnvironments:get', (_event, envId: unknown) => { + const id = EnvId.safeParse(envId) + return id.success + ? hostedEnvironmentResult(() => service.client().get(id.data)) + : invalid('environment_id') + }) + + ipcMain.handle('orcaCloudEnvironments:create', (_event, input: unknown) => { + const parsed = HostedEnvironmentCreateInputSchema.safeParse(input) + if (!parsed.success) { + return invalid(parsed.error.issues[0]?.path.join('.') || 'input') + } + return hostedEnvironmentResult(async () => ({ + environment: await service.client().create(parsed.data) + })) + }) + + ipcMain.handle('orcaCloudEnvironments:update', (_event, envId: unknown, patch: unknown) => { + const id = EnvId.safeParse(envId) + const parsed = HostedEnvironmentUpdateInputSchema.safeParse(patch) + if (!id.success) { + return invalid('environment_id') + } + if (!parsed.success) { + return invalid(parsed.error.issues[0]?.path.join('.') || 'patch') + } + return hostedEnvironmentResult(async () => ({ + environment: await service.client().update(id.data, parsed.data) + })) + }) + + ipcMain.handle('orcaCloudEnvironments:remove', (_event, envId: unknown) => { + const id = EnvId.safeParse(envId) + return id.success + ? hostedEnvironmentResult(() => service.client().remove(id.data)) + : invalid('environment_id') + }) + + ipcMain.handle('orcaCloudEnvironments:startBuild', (_event, envId: unknown) => { + const id = EnvId.safeParse(envId) + return id.success + ? hostedEnvironmentResult(async () => ({ build: await service.client().startBuild(id.data) })) + : invalid('environment_id') + }) + + ipcMain.handle( + 'orcaCloudEnvironments:cancelBuild', + (_event, envId: unknown, buildId: unknown) => { + const id = EnvId.safeParse(envId) + const bid = BuildId.safeParse(buildId) + if (!id.success || !bid.success) { + return invalid(id.success ? 'build_id' : 'environment_id') + } + return hostedEnvironmentResult(async () => ({ + build: await service.client().cancelBuild(id.data, bid.data) + })) + } + ) + + ipcMain.handle( + 'orcaCloudEnvironments:activateBuild', + (_event, envId: unknown, buildId: unknown) => { + const id = EnvId.safeParse(envId) + const bid = BuildId.safeParse(buildId) + if (!id.success || !bid.success) { + return invalid(id.success ? 'build_id' : 'environment_id') + } + return hostedEnvironmentResult(async () => ({ + environment: await service.client().activateBuild(id.data, bid.data) + })) + } + ) + + ipcMain.handle('orcaCloudEnvironments:staleness', (_event, envId: unknown, refresh: unknown) => { + const id = EnvId.safeParse(envId) + return id.success + ? hostedEnvironmentResult(async () => ({ + staleness: await service.client().staleness(id.data, refresh === true) + })) + : invalid('environment_id') + }) + + ipcMain.handle( + 'orcaCloudEnvironments:watchBuild', + (event: IpcMainInvokeEvent, envId: unknown, buildId: unknown) => { + const id = EnvId.safeParse(envId) + const bid = BuildId.safeParse(buildId) + if (!id.success || !bid.success) { + return invalid(id.success ? 'build_id' : 'environment_id') + } + return hostedEnvironmentResult(async () => { + const client = service.client() + const sender = event.sender + trackSender(sender) + const senderWatches = [...watches].filter(([, watch]) => watch.senderId === sender.id) + // Oldest first (Map keeps insertion order): drop the ones a leaky caller forgot. + for (const [watchId, evicted] of senderWatches.slice( + 0, + Math.max(0, senderWatches.length - MAX_WATCHES_PER_SENDER + 1) + )) { + // Tell the evicted log why it went quiet before stopping it. + evicted.notify({ type: 'error', message: EVICTED_MESSAGE }) + stopWatch(watchId) + } + const watchId = randomUUID() + const send = (streamEvent: OrcaCloudEnvironmentBuildEvent['event']): void => { + if (sender.isDestroyed()) { + stopWatch(watchId) + return + } + const payload: OrcaCloudEnvironmentBuildEvent = { + watchId, + envId: id.data, + buildId: bid.data, + event: streamEvent + } + sender.send(ORCA_CLOUD_ENVIRONMENT_BUILD_EVENT_CHANNEL, payload) + } + const watch = watchHostedEnvironmentBuild({ + client, + envId: id.data, + buildId: bid.data, + onEvent: send + }) + watches.set(watchId, { senderId: sender.id, stop: watch.stop, notify: send }) + void watch.done.finally(() => { + if (watches.get(watchId)?.stop === watch.stop) { + watches.delete(watchId) + } + }) + return { watchId } + }) + } + ) + + ipcMain.handle('orcaCloudEnvironments:unwatchBuild', (event, watchId: unknown) => { + // Only the window that opened a watch may stop it. + if (typeof watchId === 'string' && watches.get(watchId)?.senderId === event.sender.id) { + stopWatch(watchId) + } + }) + + ipcMain.handle('orcaCloudEnvironments:readRepoSetupImports', (_event, repoPath: unknown) => + hostedEnvironmentResult(() => readRepoSetupImports(repoPath, registeredLocalRepoPaths())) + ) +} diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 5f8e9736172..91b22536588 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -77,7 +77,7 @@ export type EphemeralVmProvisionIpcResult = } & EphemeralVmFailure) export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void { - installHostedSandbox() + installHostedSandbox(store) ipcMain.removeHandler('ephemeralVm:listRecipes') ipcMain.removeHandler('ephemeralVm:listRecipeCatalog') ipcMain.removeHandler('ephemeralVm:doctor') diff --git a/src/main/orcad/orcad-disk-usage.test.ts b/src/main/orcad/orcad-disk-usage.test.ts new file mode 100644 index 00000000000..7bc128e4106 --- /dev/null +++ b/src/main/orcad/orcad-disk-usage.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { readOrcadDiskUsage, type DiskUsageFs } from './orcad-disk-usage' + +const GIB = 1024 ** 3 + +function fakeFs( + mounts: Record +): DiskUsageFs & { statfsCalls: string[] } { + const statfsCalls: string[] = [] + return { + statfsCalls, + stat: vi.fn(async (path: string) => { + const mount = mounts[path] + if (!mount) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + return { dev: mount.dev } + }), + statfs: vi.fn(async (path: string) => { + statfsCalls.push(path) + const mount = mounts[path]! + return { bsize: 4096, blocks: mount.blocks, bfree: mount.bfree, bavail: mount.bavail } + }) + } +} + +const blocks = (bytes: number): number => bytes / 4096 + +describe('readOrcadDiskUsage', () => { + it('reports total, free, available and df-style used percent', async () => { + const fs = fakeFs({ + '/vercel/sandbox': { + dev: 1, + blocks: blocks(32 * GIB), + bfree: blocks(2 * GIB), + bavail: blocks(1 * GIB) + } + }) + const result = await readOrcadDiskUsage(['/vercel/sandbox'], { fs, now: () => 42 }) + expect(result).toEqual({ + checkedAt: 42, + volumes: [ + { + path: '/vercel/sandbox', + totalBytes: 32 * GIB, + freeBytes: 2 * GIB, + availableBytes: 1 * GIB, + // used 30 / (used 30 + avail 1) + usedPercent: (30 / 31) * 100 + } + ] + }) + }) + + it('folds paths on one filesystem into one volume and keeps a distinct home', async () => { + const fs = fakeFs({ + '/vercel/sandbox': { dev: 1, blocks: 100, bfree: 50, bavail: 50 }, + '/vercel/sandbox/repo': { dev: 1, blocks: 100, bfree: 50, bavail: 50 }, + '/home/user': { dev: 2, blocks: 200, bfree: 10, bavail: 10 } + }) + const result = await readOrcadDiskUsage( + ['/vercel/sandbox', '/vercel/sandbox/repo', '/vercel/sandbox', '/home/user'], + { fs } + ) + expect(result.volumes.map((v) => v.path)).toEqual(['/vercel/sandbox', '/home/user']) + expect(fs.statfsCalls).toEqual(['/vercel/sandbox', '/home/user']) + }) + + it('measures a missing workspace dir at its nearest existing ancestor', async () => { + const fs = fakeFs({ '/home/user': { dev: 2, blocks: 100, bfree: 90, bavail: 90 } }) + const result = await readOrcadDiskUsage(['/home/user/orca/workspaces'], { fs }) + expect(result.volumes.map((v) => v.path)).toEqual(['/home/user']) + }) + + it('skips paths whose statfs fails without failing the probe', async () => { + const fs = fakeFs({ + '/a': { dev: 1, blocks: 100, bfree: 1, bavail: 1 }, + '/b': { dev: 2, blocks: 100, bfree: 1, bavail: 1 } + }) + fs.statfs = vi.fn(async (path: string) => { + if (path === '/a') { + throw new Error('EACCES') + } + return { bsize: 4096, blocks: 100, bfree: 1, bavail: 1 } + }) + const result = await readOrcadDiskUsage(['/a', '/b', null, undefined, ''], { fs }) + expect(result.volumes.map((v) => v.path)).toEqual(['/b']) + }) + + it('reads 0% used for an empty filesystem rather than dividing by zero', async () => { + const fs = fakeFs({ '/': { dev: 1, blocks: 0, bfree: 0, bavail: 0 } }) + const result = await readOrcadDiskUsage(['/'], { fs }) + expect(result.volumes[0]!.usedPercent).toBe(0) + }) + + it('statfs-es the real filesystem by default', async () => { + const result = await readOrcadDiskUsage([process.cwd()]) + expect(result.volumes).toHaveLength(1) + expect(result.volumes[0]!.totalBytes).toBeGreaterThan(0) + }) +}) diff --git a/src/main/orcad/orcad-disk-usage.ts b/src/main/orcad/orcad-disk-usage.ts new file mode 100644 index 00000000000..4a8e428a320 --- /dev/null +++ b/src/main/orcad/orcad-disk-usage.ts @@ -0,0 +1,84 @@ +// Free space on the filesystems a runtime host writes to, for the desktop's low-disk banner. +// statfs/stat only: this runs on a poll from every connected desktop and must stay cheap. +import { stat, statfs } from 'node:fs/promises' +import { dirname } from 'node:path' +import type { OrcadDiskUsageResult, OrcadDiskVolumeUsage } from '../../shared/orcad-disk-usage' + +export type DiskUsageFs = { + statfs: ( + path: string + ) => Promise<{ bsize: number; blocks: number; bfree: number; bavail: number }> + /** Only `dev` is read, to fold paths on one filesystem into one volume. */ + stat: (path: string) => Promise<{ dev: number }> +} + +const nodeFs: DiskUsageFs = { + statfs: (path) => statfs(path), + stat: (path) => stat(path) +} + +// Why a cap: repo lists can be long and repos almost always share the workspace filesystem. +const MAX_PROBED_PATHS = 16 + +/** The path itself, else its nearest existing ancestor (a configured workspace dir may not exist yet). */ +async function existingAncestor(fs: DiskUsageFs, path: string): Promise { + let current = path + for (;;) { + try { + await fs.stat(current) + return current + } catch { + const parent = dirname(current) + if (parent === current) { + return null + } + current = parent + } + } +} + +function toVolume( + path: string, + stats: { bsize: number; blocks: number; bfree: number; bavail: number } +): OrcadDiskVolumeUsage { + const totalBytes = stats.blocks * stats.bsize + const freeBytes = stats.bfree * stats.bsize + const availableBytes = stats.bavail * stats.bsize + // df's Use%: used over (used + available), so root-reserved blocks don't read as free. + const used = Math.max(0, totalBytes - freeBytes) + const denominator = used + availableBytes + const usedPercent = denominator > 0 ? Math.min(100, (used / denominator) * 100) : 0 + return { path, totalBytes, freeBytes, availableBytes, usedPercent } +} + +/** `paths` in priority order (workspace root first); unreadable paths are skipped, not fatal. */ +export async function readOrcadDiskUsage( + paths: readonly (string | null | undefined)[], + options: { fs?: DiskUsageFs; now?: () => number } = {} +): Promise { + const fs = options.fs ?? nodeFs + const seenPaths = new Set() + const seenDevices = new Set() + const volumes: OrcadDiskVolumeUsage[] = [] + for (const candidate of paths) { + if (!candidate || seenPaths.has(candidate) || seenPaths.size >= MAX_PROBED_PATHS) { + continue + } + seenPaths.add(candidate) + try { + const path = await existingAncestor(fs, candidate) + if (!path) { + continue + } + const { dev } = await fs.stat(path) + if (seenDevices.has(dev)) { + continue + } + seenDevices.add(dev) + volumes.push(toVolume(path, await fs.statfs(path))) + } catch { + // A path that vanished or denies access says nothing about free space elsewhere. + } + } + return { volumes, checkedAt: (options.now ?? Date.now)() } +} diff --git a/src/main/runtime/rpc/methods/cloud-environments/cloud-environments.ts b/src/main/runtime/rpc/methods/cloud-environments/cloud-environments.ts new file mode 100644 index 00000000000..bb8ca07124e --- /dev/null +++ b/src/main/runtime/rpc/methods/cloud-environments/cloud-environments.ts @@ -0,0 +1,80 @@ +import { CLOUD_ENVIRONMENT_METHODS } from '../../../../../shared/hosted-sandbox/hosted-environment-cli-contract' +import { hostedEnvironmentError } from '../../../../../shared/hosted-sandbox/hosted-environment-errors' +import { + CloudEnvironmentFollowBuildParams, + CloudEnvironmentSelectParams, + CloudEnvironmentSetInstallScriptParams, + CloudEnvironmentShowParams +} from '../../../../../shared/rpc-contract/cloud-environment-params' +import { createHostedEnvironmentBuildFollowers } from '../../../../hosted-sandbox/environments/hosted-environment-build-followers' +import { + followCloudEnvironmentBuild, + listCloudEnvironmentBuilds, + setCloudEnvironmentInstallScript, + showCloudEnvironment, + startCloudEnvironmentBuild +} from '../../../../hosted-sandbox/environments/hosted-environment-cli-operations' +import type { HostedEnvironmentClient } from '../../../../hosted-sandbox/environments/hosted-environment-client' +import { + getHostedEnvironmentService, + hostedEnvironmentResult +} from '../../../../hosted-sandbox/environments/hosted-environment-service' +import { defineMethod } from '../../core' + +const followers = createHostedEnvironmentBuildFollowers() + +/** The desktop holds the Orca Cloud session; a headless server has no service registered. */ +function withClient(work: (client: HostedEnvironmentClient) => Promise) { + const service = getHostedEnvironmentService() + if (!service) { + return Promise.resolve({ + ok: false as const, + error: { + ...hostedEnvironmentError('not_configured'), + message: + 'Orca Cloud environments are managed from the Orca desktop app; run this where it is open.' + } + }) + } + return hostedEnvironmentResult(() => work(service.client())) +} + +// `workspace` like other agent-facing integrations; the surface itself can only edit the install +// script and run builds (see hosted-environment-cli-operations.ts). +export const CLOUD_ENVIRONMENT_METHODS_RPC = [ + defineMethod({ + name: CLOUD_ENVIRONMENT_METHODS.show, + permission: 'workspace', + params: CloudEnvironmentShowParams, + handler: ({ env, refresh }) => + withClient((client) => showCloudEnvironment(client, env, refresh === true)) + }), + defineMethod({ + name: CLOUD_ENVIRONMENT_METHODS.setInstallScript, + permission: 'workspace', + params: CloudEnvironmentSetInstallScriptParams, + handler: ({ env, script, expectedRevision }) => + withClient((client) => + setCloudEnvironmentInstallScript(client, env, script, expectedRevision) + ) + }), + defineMethod({ + name: CLOUD_ENVIRONMENT_METHODS.startBuild, + permission: 'workspace', + params: CloudEnvironmentSelectParams, + handler: ({ env }) => withClient((client) => startCloudEnvironmentBuild(client, env)) + }), + defineMethod({ + name: CLOUD_ENVIRONMENT_METHODS.builds, + permission: 'workspace', + params: CloudEnvironmentSelectParams, + handler: ({ env }) => withClient((client) => listCloudEnvironmentBuilds(client, env)) + }), + defineMethod({ + name: CLOUD_ENVIRONMENT_METHODS.followBuild, + permission: 'workspace', + params: CloudEnvironmentFollowBuildParams, + handler: (params) => + withClient((client) => followCloudEnvironmentBuild(client, followers, params)) + }) +] diff --git a/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.test.ts b/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.test.ts new file mode 100644 index 00000000000..913503dce5a --- /dev/null +++ b/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' + +const read = vi.hoisted(() => vi.fn(async () => ({ volumes: [], checkedAt: 1 }))) +vi.mock('../../../../orcad/orcad-disk-usage', () => ({ readOrcadDiskUsage: read })) +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => '/home/user' +})) + +import { ORCAD_DISK_USAGE_METHODS, orcadDiskUsageProbePaths } from './orcad-disk-usage-method' +import { ORCAD_DISK_USAGE_METHOD } from '../../../../../shared/orcad-disk-usage' + +describe('orcad.diskUsage', () => { + it('registers under the shared name with workspace permission', () => { + expect(ORCAD_DISK_USAGE_METHODS[0]!.name).toBe(ORCAD_DISK_USAGE_METHOD) + expect(ORCAD_DISK_USAGE_METHODS[0]!.permission).toBe('workspace') + }) + + it('probes the workspace dir, local repos and home', async () => { + const runtime = { + requireStore: () => ({ + getSettings: () => ({ workspaceDir: '/vercel/sandbox/workspaces' }), + getRepos: () => [ + { path: '/vercel/sandbox/app', connectionId: null }, + { path: '/remote/repo', connectionId: 'ssh-1' } + ] + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only the store members faked above. + const result = await ORCAD_DISK_USAGE_METHODS[0]!.handler({} as never, { runtime } as never) + expect(result).toEqual({ volumes: [], checkedAt: 1 }) + expect(read).toHaveBeenCalledWith([ + '/vercel/sandbox/workspaces', + '/vercel/sandbox/app', + '/home/user' + ]) + }) +}) + +describe('orcadDiskUsageProbePaths', () => { + it('drops SSH, runtime-owned and relative repos', () => { + expect( + orcadDiskUsageProbePaths({ + workspaceDir: null, + repos: [ + { path: '/a', connectionId: null, executionHostId: 'local' }, + { path: '/b', connectionId: 'ssh-1' }, + { path: '/c', executionHostId: 'runtime:env-1' }, + { path: 'relative' } + ], + home: '/home/user' + }) + ).toEqual(['/a', '/home/user']) + }) +}) diff --git a/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.ts b/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.ts new file mode 100644 index 00000000000..6bf0b3377a3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orcad/orcad-disk-usage-method.ts @@ -0,0 +1,45 @@ +import { homedir } from 'node:os' +import { isAbsolute } from 'node:path' +import { defineMethod } from '../../core' +import { + ORCAD_DISK_USAGE_METHOD, + OrcadDiskUsageParamsSchema +} from '../../../../../shared/orcad-disk-usage' +import type { Repo } from '../../../../../shared/repo-types' +import { readOrcadDiskUsage } from '../../../../orcad/orcad-disk-usage' + +/** Workspace root first so it leads the result; then local repo checkouts and $HOME. */ +export function orcadDiskUsageProbePaths(args: { + workspaceDir: string | null | undefined + repos: readonly Pick[] + home: string +}): string[] { + const repoPaths = args.repos + .filter( + (repo) => + !repo.connectionId && + (!repo.executionHostId || repo.executionHostId === 'local') && + isAbsolute(repo.path) + ) + .map((repo) => repo.path) + return [args.workspaceDir ?? '', ...repoPaths, args.home].filter(Boolean) +} + +export const ORCAD_DISK_USAGE_METHODS = [ + defineMethod({ + name: ORCAD_DISK_USAGE_METHOD, + // Why workspace: free space is no more sensitive than the file listings this caller can already read. + permission: 'workspace', + params: OrcadDiskUsageParamsSchema, + handler: async (_params, { runtime }) => { + const store = runtime.requireStore() + return readOrcadDiskUsage( + orcadDiskUsageProbePaths({ + workspaceDir: store.getSettings().workspaceDir, + repos: store.getRepos(), + home: homedir() + }) + ) + } + }) +] diff --git a/src/main/runtime/rpc/methods/rpc-method-registry.ts b/src/main/runtime/rpc/methods/rpc-method-registry.ts index 15d7da0dffb..a39991b62d0 100644 --- a/src/main/runtime/rpc/methods/rpc-method-registry.ts +++ b/src/main/runtime/rpc/methods/rpc-method-registry.ts @@ -2,6 +2,7 @@ import { STATUS_METHODS } from './host-management/status' import { SIDEBAR_INPUT_METHODS } from './sidebar-inputs/sidebar-inputs' import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad/orcad-terminal-census' import { ORCAD_WORK_VERDICT_METHODS } from './orcad/orcad-work-verdict-method' +import { ORCAD_DISK_USAGE_METHODS } from './orcad/orcad-disk-usage-method' import { ORCAD_MIGRATION_METHODS } from './orcad/orcad-migration' import { SESSION_TERMINAL_SCROLLBACK_METHODS } from './terminal/session-terminal-scrollback' import { AI_VAULT_METHODS } from './history/ai-vault' @@ -46,6 +47,7 @@ import { RECIPE_VM_WAKE_METHODS } from './host-management/recipe-vm-wake' import { ORCA_CLOUD_DEVICE_ENROLLMENT_METHODS } from './orca-cloud/orca-cloud-device-enrollment' import { CLOUD_SSH_METHODS } from './cloud-ssh/cloud-ssh-methods' import { ENVIRONMENT_DOCTOR_METHODS } from './host-management/environment-doctor' +import { CLOUD_ENVIRONMENT_METHODS_RPC } from './cloud-environments/cloud-environments' import { SPEECH_METHODS } from './speech/speech' import { CLIENT_UI_METHODS } from './client-state/client-ui' import { CLIENT_EVENT_METHODS } from './client-events/client-events' @@ -90,6 +92,7 @@ export const ALL_RPC_METHODS = [ ...STATUS_METHODS, ...ORCAD_TERMINAL_CENSUS_METHODS, ...ORCAD_WORK_VERDICT_METHODS, + ...ORCAD_DISK_USAGE_METHODS, ...ORCAD_MIGRATION_METHODS, ...SESSION_TERMINAL_SCROLLBACK_METHODS, ...AGENT_HOOK_METHODS, @@ -147,6 +150,7 @@ export const ALL_RPC_METHODS = [ ...ORCA_CLOUD_DEVICE_ENROLLMENT_METHODS, ...CLOUD_SSH_METHODS, ...ENVIRONMENT_DOCTOR_METHODS, + ...CLOUD_ENVIRONMENT_METHODS_RPC, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, ...LINK_PREVIEW_METHODS, diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 63748cdab54..17fd31bf2f9 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -13,6 +13,7 @@ export const CLI_COMMAND_NAMES = [ 'check', 'claude-teams', 'clear', + 'cloud', 'click', 'clipboard', 'computer', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 980f59e7ba6..43b3290ac25 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -48,6 +48,7 @@ import type { MobileApi } from './api/mobile-api' import type { NativeChatApi } from './api/native-chat-api' import type { OnboardingApi, StarNagApi } from './api/onboarding-api' import type { OrcaProfileApi } from './api/orca-profile-api' +import type { OrcaCloudEnvironmentsApi } from '../shared/hosted-sandbox/hosted-environment-api' import type { ComputerUsePermissionsApi, DeveloperPermissionsApi, @@ -88,6 +89,7 @@ type Merged = { [K in keyof T]: T[K] } export type PreloadApi = { app: AppApi orcaProfiles: OrcaProfileApi + orcaCloudEnvironments: OrcaCloudEnvironmentsApi platform: PlatformApi e2e: E2EApi repos: RepositoryApi diff --git a/src/preload/api/orca-cloud-environments-bridge.ts b/src/preload/api/orca-cloud-environments-bridge.ts new file mode 100644 index 00000000000..b2bc017498a --- /dev/null +++ b/src/preload/api/orca-cloud-environments-bridge.ts @@ -0,0 +1,35 @@ +import { ipcRenderer } from 'electron' +import type { PreloadApi } from '../api-types' +import { + ORCA_CLOUD_ENVIRONMENT_BUILD_EVENT_CHANNEL, + type OrcaCloudEnvironmentBuildEvent +} from '../../shared/hosted-sandbox/hosted-environment-api' + +export const orcaCloudEnvironmentsApi = { + isConfigured: () => ipcRenderer.invoke('orcaCloudEnvironments:isConfigured'), + list: () => ipcRenderer.invoke('orcaCloudEnvironments:list'), + get: (envId) => ipcRenderer.invoke('orcaCloudEnvironments:get', envId), + create: (input) => ipcRenderer.invoke('orcaCloudEnvironments:create', input), + update: (envId, patch) => ipcRenderer.invoke('orcaCloudEnvironments:update', envId, patch), + remove: (envId) => ipcRenderer.invoke('orcaCloudEnvironments:remove', envId), + startBuild: (envId) => ipcRenderer.invoke('orcaCloudEnvironments:startBuild', envId), + cancelBuild: (envId, buildId) => + ipcRenderer.invoke('orcaCloudEnvironments:cancelBuild', envId, buildId), + activateBuild: (envId, buildId) => + ipcRenderer.invoke('orcaCloudEnvironments:activateBuild', envId, buildId), + staleness: (envId, refresh) => + ipcRenderer.invoke('orcaCloudEnvironments:staleness', envId, refresh === true), + watchBuild: (envId, buildId) => + ipcRenderer.invoke('orcaCloudEnvironments:watchBuild', envId, buildId), + unwatchBuild: async (watchId) => { + await ipcRenderer.invoke('orcaCloudEnvironments:unwatchBuild', watchId) + }, + onBuildEvent: (callback: (event: OrcaCloudEnvironmentBuildEvent) => void): (() => void) => { + const listener = (_event: unknown, payload: OrcaCloudEnvironmentBuildEvent): void => + callback(payload) + ipcRenderer.on(ORCA_CLOUD_ENVIRONMENT_BUILD_EVENT_CHANNEL, listener) + return () => ipcRenderer.removeListener(ORCA_CLOUD_ENVIRONMENT_BUILD_EVENT_CHANNEL, listener) + }, + readRepoSetupImports: (repoPath) => + ipcRenderer.invoke('orcaCloudEnvironments:readRepoSetupImports', repoPath) +} satisfies PreloadApi['orcaCloudEnvironments'] diff --git a/src/preload/index.ts b/src/preload/index.ts index e7fa5d5549a..6cc4d785126 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { PreloadApi } from './api-types' import { installBrowserFindListener } from './preload-runtime-support' import { appApi } from './api/app-bridge' import { orcaProfilesApi } from './api/orca-profiles-bridge' +import { orcaCloudEnvironmentsApi } from './api/orca-cloud-environments-bridge' import { platformApi } from './api/platform-bridge' import { wslApi } from './api/wsl-bridge' import { wslMoveApi } from './api/wsl-move-bridge' @@ -114,6 +115,7 @@ const telemetryGetConsentStateApi: PreloadApi['telemetryGetConsentState'] = () = const api = { app: appApi, orcaProfiles: orcaProfilesApi, + orcaCloudEnvironments: orcaCloudEnvironmentsApi, platform: platformApi, wsl: wslApi, wslMove: wslMoveApi, diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index 4b890eccee7..df5297cb06d 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -9,6 +9,7 @@ import RightSidebar from '../components/right-sidebar' import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' import { FloatingTerminalToggleButton } from '../components/floating-terminal/FloatingTerminalToggleButton' import { TerminalWorkbenchContainer } from '../components/TerminalWorkbenchContainer' +import { WorkspaceLowDiskBanner } from '../components/low-disk/WorkspaceLowDiskBanner' import type { VirtualizedScrollAnchor } from '../hooks/useVirtualizedScrollAnchor' import { TitlebarLeftControls } from './TitlebarLeftControls' import { RightSidebarToggle, TitlebarMainStrip } from './TitlebarMainStrip' @@ -227,6 +228,9 @@ export function AppWorkspaceShell(props: { ) : null} + {layout.shouldMountTerminalWorkbench ? ( + + ) : null} actor + +export function BuildHistoryList({ + builds, + selectedBuildId, + canAdmin, + busyBuildId, + now: fixedNow, + actorName = identityActor, + onSelect, + onCancel, + onActivate +}: { + builds: readonly EnvironmentBuildView[] + selectedBuildId: string | null + canAdmin: boolean + /** A cancel/activate request is in flight for this build. */ + busyBuildId: string | null + /** Fixed clock (fixtures); otherwise the list ticks itself while a build runs. */ + now?: number + actorName?: (actor: string) => string + onSelect: (build: EnvironmentBuildView) => void + onCancel: (build: EnvironmentBuildView) => void + onActivate: (build: EnvironmentBuildView) => void +}): React.JSX.Element { + const t = useTranslate() + const intlLocale = useIntlLocale() + // The 1 s tick lives here, not in the panel, so the form and log don't re-render with it. + const anyInProgress = builds.some((build) => isBuildInProgress(build.status)) + const tickingNow = useNow(anyInProgress && fixedNow === undefined) + const now = fixedNow ?? tickingNow + if (builds.length === 0) { + return ( +

+ {t('cloudEnvironment.history.empty', 'No builds yet.')} +

+ ) + } + const multiRepo = builds.some((build) => build.repos.length > 1) + return ( +
    + {builds.map((build) => { + const selected = build.id === selectedBuildId + const inProgress = isBuildInProgress(build.status) + const busy = busyBuildId === build.id + const duration = + build.durationMs ?? (inProgress ? Math.max(0, now - build.createdAt) : null) + return ( +
  • + {selected ? ( + + ) : null} + + {canAdmin ? ( +
    + {inProgress ? ( + + + + ) : build.status === 'succeeded' && !build.active ? ( + + ) : null} +
    + ) : null} +
  • + ) + })} +
+ ) +} + +function ActivateButton({ + build, + busy, + onActivate +}: { + build: EnvironmentBuildView + busy: boolean + onActivate: (build: EnvironmentBuildView) => void +}): React.JSX.Element { + const t = useTranslate() + const button = ( + + ) + return ( + + {button} + + ) +} + +/** A disabled control that says why: the span keeps the tooltip reachable by pointer and keyboard. */ +function DisabledReason({ + reason, + children +}: { + reason: string | null + children: React.ReactElement +}): React.JSX.Element { + if (!reason) { + return children + } + return ( + + + + {children} + + + {reason} + + ) +} diff --git a/src/renderer/src/components/cloud-environment/BuildLogRows.tsx b/src/renderer/src/components/cloud-environment/BuildLogRows.tsx new file mode 100644 index 00000000000..eee7e178e93 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/BuildLogRows.tsx @@ -0,0 +1,352 @@ +import { memo } from 'react' +import type { ITheme } from '@xterm/xterm' +import { AlertTriangle, ChevronRight } from 'lucide-react' +import { getBuiltinTheme } from '@/lib/terminal-theme' +import { cn } from '@/lib/utils' +import { useTranslate, type Translate } from '@/i18n/use-translate' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { FOCUS_RING } from '../orca-cloud/cloud-focus' +import { + DEFAULT_TERMINAL_THEME_DARK, + DEFAULT_TERMINAL_THEME_LIGHT +} from '../../../../shared/terminal-themes/terminal-theme-selection' +import { parseAnsiSegments, type AnsiColor } from '../editor/notebook/outputs/ipynb-ansi' +import type { LogLine } from './build-log-model' +import { formatLineTimestamp, formatStepDuration, type BuildStepView } from './build-steps' +import { BuildStatusIcon } from './BuildStatusIcon' +import type { BuildLogStreamState } from './BuildLogViewer' +import { buildErrorCopy, failedBuildSummary, stepLabel } from './cloud-environment-copy' +import type { EnvironmentBuildStep, EnvironmentBuildView } from './cloud-environment-types' + +/** Lines longer than this render truncated (the copy keeps them whole); minified bundles exist. */ +const MAX_RENDERED_LINE_CHARS = 2000 + +const ANSI_PALETTE_KEYS = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] as const satisfies readonly (keyof ITheme)[] + +const ANSI_PALETTES = { + dark: ANSI_PALETTE_KEYS.map((key) => getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK)?.[key]), + light: ANSI_PALETTE_KEYS.map((key) => getBuiltinTheme(DEFAULT_TERMINAL_THEME_LIGHT)?.[key]) +} + +function AnsiLine({ text, dark }: { text: string; dark: boolean }): React.JSX.Element { + const palette = ANSI_PALETTES[dark ? 'dark' : 'light'] + const resolve = (color: AnsiColor | undefined): string | undefined => + typeof color === 'number' ? palette[color] : color + const clipped = + text.length > MAX_RENDERED_LINE_CHARS ? `${text.slice(0, MAX_RENDERED_LINE_CHARS)}…` : text + return ( + <> + {parseAnsiSegments(clipped).map((segment, index) => + segment.fg === undefined && segment.bg === undefined && !segment.bold ? ( + {segment.text} + ) : ( + + {segment.text} + + ) + )} + + ) +} + +export const LogLineRow = memo(function LogLineRow({ + line, + number, + origin, + showTimestamps, + dark +}: { + line: LogLine + number: number + origin: number + showTimestamps: boolean + dark: boolean +}): React.JSX.Element { + if (line.kind === 'orca-error') { + return ( +
+
+ + {line.plain} +
+
+ ) + } + return ( +
+ + {number} + + {showTimestamps ? ( + + {formatLineTimestamp(line.at, origin)} + + ) : null} + + + +
+ ) +}) + +export function StepHeaderRow({ + step, + expanded, + lineCount, + paused = false, + onToggle +}: { + step: BuildStepView + expanded: boolean + lineCount: number + /** The stream is down: show a running step frozen, not spinning. */ + paused?: boolean + onToggle: (step: EnvironmentBuildStep) => void +}): React.JSX.Element { + const t = useTranslate() + const muted = step.status === 'pending' || step.status === 'skipped' + return ( + + ) +} + +function stepStatusWord( + status: BuildStepView['status'], + t: ReturnType +): string { + switch (status) { + case 'running': + return t('cloudEnvironment.stepStatus.running', 'running') + case 'succeeded': + return t('cloudEnvironment.stepStatus.succeeded', 'done') + case 'failed': + return t('cloudEnvironment.stepStatus.failed', 'failed') + case 'canceled': + return t('cloudEnvironment.stepStatus.canceled', 'canceled') + case 'skipped': + return t('cloudEnvironment.stepStatus.skipped', 'skipped') + case 'pending': + return t('cloudEnvironment.stepStatus.pending', 'waiting') + } +} + +export function EmptyStepRow({ + step, + build, + streamState +}: { + step: EnvironmentBuildStep + build: EnvironmentBuildView + streamState: BuildLogStreamState +}): React.JSX.Element { + const t = useTranslate() + let copy: string + if (streamState === 'connecting') { + copy = t('cloudEnvironment.log.loading', 'Loading log…') + } else if (step === 'boot') { + copy = build.startedAt + ? t('cloudEnvironment.log.bootDone', 'Sandbox started.') + : t('cloudEnvironment.log.booting', 'Starting a fresh sandbox…') + } else if (step === 'snapshot') { + copy = build.finishedAt + ? t('cloudEnvironment.log.snapshotDone', 'Snapshot saved.') + : t('cloudEnvironment.log.snapshotting', 'Saving the snapshot…') + } else { + copy = t('cloudEnvironment.log.noOutput', 'No output.') + } + return ( +
+ {copy} +
+ ) +} + +/** "Build #7 failed — workspaces still use #6", the failed step in words, and Orca's own diagnosis. */ +export function FailureSummary({ + build, + activeBuildNumber, + onShowError +}: { + build: EnvironmentBuildView + activeBuildNumber: number | null + /** Scrolls to Orca's own error note in the log; null when the log has none. */ + onShowError: (() => void) | null +}): React.JSX.Element { + const t = useTranslate() + const summary = failedBuildSummary(build.number, build.failedStep, activeBuildNumber, t) + // Without an Orca note in the log, the plain-words reason is the only explanation there is. + const reason = onShowError ? null : buildErrorCopy(build.error, t) + return ( + + {summary.consequence} {reason} + + ) : ( + summary.consequence + ) + } + action={ + onShowError ? ( + + ) : undefined + } + /> + ) +} + +/** Stream health and truncation, below the log. Quiet unless something needs saying. */ +export function LogFooter({ + streamState, + streamError, + truncated, + onRetry +}: { + streamState: BuildLogStreamState + /** Main's plain-words reason when it stopped the watch. */ + streamError?: string | null + truncated: boolean + onRetry?: () => void +}): React.JSX.Element | null { + const t = useTranslate() + if (streamState === 'reconnecting' || streamState === 'error') { + return ( + + ) + } + if (!truncated) { + return null + } + return ( +

+ {t('cloudEnvironment.log.truncatedShort', 'Log truncated at its size limit.')} +

+ ) +} + +/** What screen readers hear as the build moves: one sentence per step change, never per line. */ +export function stepAnnouncement( + build: EnvironmentBuildView, + steps: readonly BuildStepView[], + activeBuildNumber: number | null, + t: Translate +): string { + if (build.status === 'failed') { + const summary = failedBuildSummary(build.number, build.failedStep, activeBuildNumber, t) + return t('cloudEnvironment.announce.failed', '{{headline}} {{consequence}}', summary) + } + if (build.status === 'succeeded') { + return t('cloudEnvironment.announce.succeeded', 'Build #{{number}} succeeded.', { + number: String(build.number) + }) + } + if (build.status === 'canceled') { + return t('cloudEnvironment.announce.canceled', 'Build #{{number}} was canceled.', { + number: String(build.number) + }) + } + const running = steps.find((step) => step.status === 'running') + return running + ? t('cloudEnvironment.announce.step', 'Build #{{number}}: {{step}}…', { + number: String(build.number), + step: stepLabel(running.id, t) + }) + : '' +} diff --git a/src/renderer/src/components/cloud-environment/BuildLogViewer.tsx b/src/renderer/src/components/cloud-environment/BuildLogViewer.tsx new file mode 100644 index 00000000000..10a856ad7ce --- /dev/null +++ b/src/renderer/src/components/cloud-environment/BuildLogViewer.tsx @@ -0,0 +1,440 @@ +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, + useSyncExternalStore +} from 'react' +import { ArrowDown, Check, Copy } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useDocumentDarkTheme } from '@/hooks/use-document-dark-theme' +import { useTranslate } from '@/i18n/use-translate' +import type { BuildLogBuffer } from './build-log-model' +import { + defaultExpanded, + failureScrollRow, + lastRowBoundary, + rowAtIndex, + type Row, + type Segment +} from './log-row-layout' +import { HEADER_ROW_PX, LINE_ROW_PX, useLogVirtualizer } from './use-log-virtualizer' +import { deriveBuildSteps, focusStep, formatStepDuration } from './build-steps' +import { + EmptyStepRow, + FailureSummary, + LogFooter, + LogLineRow, + stepAnnouncement, + StepHeaderRow +} from './BuildLogRows' +import { FOCUS_RING } from '../orca-cloud/cloud-focus' +import { cn } from '@/lib/utils' +import { BuildStatusIcon, stepStatusFromBuild, useNow } from './BuildStatusIcon' +import { buildStatusLabel, isBuildInProgress } from './cloud-environment-copy' +import type { EnvironmentBuildStep, EnvironmentBuildView } from './cloud-environment-types' +import { autoscrollReducer, initialAutoscrollState } from './log-autoscroll' + +export type BuildLogStreamState = 'connecting' | 'live' | 'reconnecting' | 'ended' | 'error' + +/** A whole number of lines; the viewport then stops at the last whole row below it. */ +const MAX_LOG_HEIGHT_PX = 23 * LINE_ROW_PX + +/** Memoized: the panel re-renders on polls and status changes that don't touch this build. */ +export const BuildLogViewer = memo(function BuildLogViewer({ + build, + buffer, + activeBuildNumber, + streamState, + streamError, + onRetryStream, + height = MAX_LOG_HEIGHT_PX, + now: fixedNow +}: { + build: EnvironmentBuildView + buffer: BuildLogBuffer + activeBuildNumber: number | null + streamState: BuildLogStreamState + streamError?: string | null + onRetryStream?: () => void + height?: number + /** Freeze the clock (fixtures). */ + now?: number +}): React.JSX.Element { + const t = useTranslate() + const dark = useDocumentDarkTheme() + const running = isBuildInProgress(build.status) + // While the stream is down the last-known state is stale: freeze the clock instead of guessing. + const disconnected = streamState === 'reconnecting' || streamState === 'error' + const tickingNow = useNow(running && !disconnected && fixedNow === undefined) + const now = fixedNow ?? tickingNow + const version = useSyncExternalStore(buffer.subscribe, buffer.getVersion) + const scrollRef = useRef(null) + // Follow the tail of a live build; a finished one opens at the top (or at its failure). + const [scroll, dispatch] = useReducer(autoscrollReducer, running, (live) => ({ + ...initialAutoscrollState, + following: live + })) + const [toggled, setToggled] = useState>>({}) + const [copied, setCopied] = useState(false) + const pending = useRef<{ copiedTimer: number | null; showErrorFrame: number | null }>({ + copiedTimer: null, + showErrorFrame: null + }) + useEffect(() => { + const handles = pending.current + return () => { + if (handles.copiedTimer !== null) { + window.clearTimeout(handles.copiedTimer) + } + if (handles.showErrorFrame !== null) { + cancelAnimationFrame(handles.showErrorFrame) + } + } + }, []) + + // `version` is the external-store tick: markers and line counts are read off the mutable buffer. + const steps = useMemo( + () => deriveBuildSteps(build, buffer.markers, now), + // eslint-disable-next-line react-hooks/exhaustive-deps -- buffer is mutable; version tracks it. + [build, buffer, version, now] + ) + const focus = focusStep(steps) + const lineTotal = buffer.lines.length + const showTimestamps = useMemo( + () => buffer.lines.length > 0 && buffer.lines[0].at !== null, + // eslint-disable-next-line react-hooks/exhaustive-deps -- buffer is mutable; version tracks it. + [buffer, version] + ) + + const segments = useMemo((): Segment[] => { + let row = 0 + return steps.map((step) => { + const range = buffer.stepRange(step.id) + const lineCount = range.end - range.start + const expanded = toggled[step.id] ?? defaultExpanded(step, focus, build) + const bodyRows = expanded ? Math.max(lineCount, step.status === 'pending' ? 0 : 1) : 0 + const segment = { + step, + firstRow: row, + rowCount: 1 + bodyRows, + lineStart: range.start, + lineCount, + expanded + } + row += segment.rowCount + return segment + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- buffer is mutable; version tracks it. + }, [steps, toggled, focus, build, buffer, version]) + + const rowCount = segments.reduce((sum, segment) => sum + segment.rowCount, 0) + + const rowAt = useCallback( + (index: number): Row => rowAtIndex(segments, buffer, index), + [segments, buffer] + ) + + const virtualizer = useLogVirtualizer(segments, buffer, rowCount, scrollRef) + + // Count lines that arrived while the user was reading history (feeds the pill). + const seenLines = useRef(lineTotal) + useLayoutEffect(() => { + const delta = lineTotal - seenLines.current + seenLines.current = lineTotal + if (delta > 0) { + dispatch({ type: 'append', count: delta }) + } else if (delta < 0) { + dispatch({ type: 'reset' }) + } + }, [lineTotal]) + + // Follow the tail: jump (not smooth-scroll) so a fast log never lags behind its own output. + useLayoutEffect(() => { + if (scroll.following && rowCount > 0) { + virtualizer.scrollToIndex(rowCount - 1, { align: 'end' }) + } + }, [scroll.following, rowCount, version, virtualizer]) + + // Hug short logs instead of leaving an empty slab; cap long ones on a row boundary. + const totalSize = virtualizer.getTotalSize() + const overflowing = totalSize > height + // Overflowing logs stop at the last whole row that fits from the top (rows are whole lines, but + // headers are two), so the resting view never shows half a row. + const viewportHeight = overflowing + ? lastRowBoundary(virtualizer.measurementsCache, height - (height % LINE_ROW_PX)) + : totalSize + + // A failed build opens on its failure, once its log is complete ('ended'): earlier, the log is + // still replaying and the error line may not exist yet, so the scroll would land short. + const failedStepId = build.status === 'failed' ? focus : null + const scrolledToFailure = useRef(null) + useLayoutEffect(() => { + if (!failedStepId || scrolledToFailure.current === build.id || streamState !== 'ended') { + return + } + scrolledToFailure.current = build.id + const row = failureScrollRow(segments, buffer, failedStepId, { + height: viewportHeight, + totalSize: virtualizer.getTotalSize(), + headerPx: HEADER_ROW_PX, + linePx: LINE_ROW_PX + }) + if (row !== null) { + dispatch({ type: 'release' }) + virtualizer.scrollToIndex(row, { align: 'start' }) + } + }, [failedStepId, build.id, segments, buffer, virtualizer, streamState, viewportHeight]) + + const onScroll = (): void => { + const element = scrollRef.current + if (element) { + dispatch({ + type: 'scroll', + scrollTop: element.scrollTop, + scrollHeight: element.scrollHeight, + clientHeight: element.clientHeight + }) + } + } + + const jumpToLatest = (): void => { + dispatch({ type: 'jump' }) + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + virtualizer.scrollToIndex(rowCount - 1, { + align: 'end', + behavior: reduceMotion ? 'auto' : 'smooth' + }) + } + + const onScrollerKeyDown = (event: React.KeyboardEvent): void => { + if (event.target !== event.currentTarget || rowCount === 0) { + return + } + if (event.key === 'Home') { + event.preventDefault() + dispatch({ type: 'release' }) + virtualizer.scrollToIndex(0, { align: 'start' }) + } else if (event.key === 'End') { + event.preventDefault() + dispatch({ type: 'jump' }) + virtualizer.scrollToIndex(rowCount - 1, { align: 'end' }) + } + } + + const showError = (): void => { + const index = buffer.orcaErrorLines.at(-1) + const segment = + index === undefined + ? undefined + : segments.find( + (entry) => index >= entry.lineStart && index < entry.lineStart + entry.lineCount + ) + if (index === undefined || !segment) { + return + } + dispatch({ type: 'release' }) + if (!segment.expanded) { + setToggled((previous) => ({ ...previous, [segment.step.id]: true })) + } + // After an expand the rows move; scroll on the next frame. + pending.current.showErrorFrame = requestAnimationFrame(() => { + pending.current.showErrorFrame = null + virtualizer.scrollToIndex(segment.firstRow + 1 + (index - segment.lineStart), { + align: 'start' + }) + }) + } + + const toggle = useCallback( + (stepId: EnvironmentBuildStep) => { + setToggled((previous) => { + const step = steps.find((entry) => entry.id === stepId) + const current = previous[stepId] ?? (step ? defaultExpanded(step, focus, build) : false) + return { ...previous, [stepId]: !current } + }) + }, + [steps, focus, build] + ) + + const copyLogs = (): void => { + // Machine-readable codes live here, not in the UI copy (they help support and agents). + const header = [ + `build=${build.id} number=${build.number} status=${build.status}`, + build.failedStep ? `failedStep=${build.failedStep}` : null, + build.error ? `error=${build.error}` : null, + streamError ? `stream=${streamError}` : null + ] + .filter(Boolean) + .join(' ') + void window.api.ui.writeClipboardText(`${header}\n${buffer.text()}`).then(() => { + setCopied(true) + if (pending.current.copiedTimer !== null) { + window.clearTimeout(pending.current.copiedTimer) + } + pending.current.copiedTimer = window.setTimeout(() => { + pending.current.copiedTimer = null + setCopied(false) + }, 1500) + }) + } + + const totalMs = build.durationMs ?? (running ? Math.max(0, now - build.createdAt) : null) + const statusWord = buildStatusLabel(build.status, t) + const failure = build.status === 'failed' + const items = virtualizer.getVirtualItems() + + return ( +
+
+ +
+

+ {t('cloudEnvironment.log.title', 'Build #{{number}}', { + number: String(build.number) + })} + + {' · '} + {disconnected && running + ? streamState === 'reconnecting' + ? t('cloudEnvironment.log.reconnectingShort', 'Reconnecting…') + : t('cloudEnvironment.log.disconnectedShort', '{{status}} · log disconnected', { + status: statusWord + }) + : statusWord} + +

+
+ + {formatStepDuration(totalMs)} + + +
+ +

+ {stepAnnouncement(build, steps, activeBuildNumber, t)} +

+ + {failure ? ( + 0 ? showError : null} + /> + ) : null} + +
+
{ + // Keyboard focus on a step header: stop following, or the next chunk scrolls it away. + if (event.target !== event.currentTarget) { + dispatch({ type: 'release' }) + } + }} + className={cn( + 'overflow-y-auto overscroll-contain bg-muted/30 font-mono text-xs text-foreground scrollbar-editor dark:bg-black/25', + FOCUS_RING, + 'focus-visible:ring-inset' + )} + style={{ height: viewportHeight }} + > +
+ {items.map((item) => { + const row = rowAt(item.index) + return ( +
+ {row.kind === 'header' ? ( + entry.step.id === row.step.id)?.expanded ?? false + } + paused={disconnected && running} + onToggle={toggle} + /> + ) : row.kind === 'line' ? ( + + ) : ( + + )} +
+ ) + })} +
+
+ {running && !scroll.following && scroll.awayFromBottom && rowCount > 0 ? ( +
+ +
+ ) : null} +
+ + +
+ ) +}) diff --git a/src/renderer/src/components/cloud-environment/BuildStatusIcon.tsx b/src/renderer/src/components/cloud-environment/BuildStatusIcon.tsx new file mode 100644 index 00000000000..ff8c51caba4 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/BuildStatusIcon.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react' +import { + Ban, + CircleCheck, + CircleDashed, + CircleDot, + CircleX, + Loader2, + MinusCircle, + Pause +} from 'lucide-react' +import { cn } from '@/lib/utils' +import type { StepStatus } from './build-steps' +import type { EnvironmentBuildStatus } from './cloud-environment-types' + +export function stepStatusFromBuild(status: EnvironmentBuildStatus): StepStatus { + if (status === 'starting' || status === 'running' || status === 'snapshotting') { + return 'running' + } + return status +} + +/** Decorative: callers always put the status in words next to it (or in an sr-only label). */ +export function BuildStatusIcon({ + status, + className +}: { + /** `paused`: the build may still run, but the live stream is down (frozen, not spinning). */ + status: StepStatus | 'paused' + className?: string +}): React.JSX.Element { + const base = cn('size-4 shrink-0', className) + switch (status) { + case 'running': + // Reduced motion gets a static dot: a frozen spinner reads as a hang. + return ( + <> + + + + ) + case 'paused': + return + case 'succeeded': + return + case 'failed': + return + case 'canceled': + return + case 'skipped': + return + case 'pending': + return + } +} + +/** Wall clock that ticks only while `enabled` (a running build); otherwise frozen at mount. */ +export function useNow(enabled: boolean, intervalMs = 1000): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!enabled) { + return + } + setNow(Date.now()) + const timer = window.setInterval(() => setNow(Date.now()), intervalMs) + return () => window.clearInterval(timer) + }, [enabled, intervalMs]) + return now +} diff --git a/src/renderer/src/components/cloud-environment/CloudEnvironment.fixture.tsx b/src/renderer/src/components/cloud-environment/CloudEnvironment.fixture.tsx new file mode 100644 index 00000000000..fdc385df394 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/CloudEnvironment.fixture.tsx @@ -0,0 +1,357 @@ +import { InlineError } from '@/components/ui/inline-error' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { BuildHistoryList } from './BuildHistoryList' +import { BuildLogViewer } from './BuildLogViewer' +import { + buildingLog, + failedLog, + FIXTURE_NOW, + longLog, + sampleBuild, + sampleEnvironment, + sampleHistory, + sampleStaleness, + succeededLog +} from './cloud-environment-fixture-data' +import { stalenessBannerCopy } from './cloud-environment-copy' +import { + CloudEnvironmentEmptyState, + CloudEnvironmentSkeleton, + CloudEnvironmentPanel +} from './CloudEnvironmentPanel' +import { draftFromEnvironment, emptyDraft } from './environment-draft' +import { EnvironmentForm, EnvironmentSummary } from './EnvironmentForm' +import type { LocalRepoSuggestion } from './EnvironmentFormFields' +import { StalenessBanner } from './StalenessBanner' + +const noop = (): void => {} + +const suggestions: LocalRepoSuggestion[] = [ + { repoId: 'r1', slug: 'acme/web', path: '/src/web', displayName: 'web' }, + { repoId: 'r2', slug: 'acme/api', path: '/src/api', displayName: 'api' }, + { + repoId: 'r3', + slug: 'acme/design-system', + path: '/src/ds', + displayName: 'design-system' + } +] + +const formHandlers = { + saving: false, + error: null, + conflict: false, + suggestions, + importProjects: suggestions, + onReadImports: async () => ({ + ok: true as const, + data: { + envVarNames: ['NPM_TOKEN', 'STRIPE_KEY'], + installScript: 'pnpm install', + sources: ['Conductor'] + } + }), + onSave: noop +} + +export const emptyState = defineFixture({ + width: 640, + render: () => +}) + +export const emptyStateMember = defineFixture({ + width: 640, + render: () => +}) + +export const loading = defineFixture({ + width: 680, + render: () => +}) + +export const networkError = defineFixture({ + width: 640, + render: () => ( + + ) +}) + +export const createForm = defineFixture({ + width: 680, + render: () => ( + + ) +}) + +export const formAdminUnsaved = defineFixture({ + width: 680, + render: () => { + const base = draftFromEnvironment(sampleEnvironment) + return ( + + ) + } +}) + +export const formAdminConflict = defineFixture({ + width: 680, + render: () => { + const base = draftFromEnvironment(sampleEnvironment) + return ( + + ) + } +}) + +export const formMemberReadOnly = defineFixture({ + width: 680, + render: () => +}) + +export const buildHistory = defineFixture({ + width: 680, + render: () => ( + + ) +}) + +export const buildHistoryMember = defineFixture({ + width: 680, + render: () => ( + + ) +}) + +const runningBuild = sampleHistory[0] +export const logBuilding = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +const okBuild = sampleBuild({ + number: 6, + active: true, + createdAt: FIXTURE_NOW - 26 * 60_000 +}) +export const logSucceeded = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +const failedBuild = sampleHistory[1] +export const logFailed = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +const longBuild = sampleBuild({ + number: 9, + createdAt: FIXTURE_NOW - 6 * 60_000, + snapshotStartedAt: FIXTURE_NOW - 6 * 60_000 + 240_000, + finishedAt: FIXTURE_NOW - 6 * 60_000 + 270_000, + durationMs: 270_000, + logBytes: 2_000_000, + logTruncated: true +}) +export const logLong = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +export const logReconnecting = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +export const logStreamError = defineFixture({ + width: 760, + render: () => ( + + ) +}) + +export const stalenessBanners = defineFixture({ + width: 680, + render: () => ( +
+ {[sampleStaleness.deps, sampleStaleness.config, sampleStaleness.branch].map((staleness) => { + const copy = stalenessBannerCopy(staleness, 6) + return copy ? ( + + ) : null + })} +
+ ) +}) + +export const panelOverviewFailed = defineFixture({ + width: 760, + render: () => { + const builds = sampleHistory.slice(1) + return ( + + ) + } +}) + +export const panelMember = defineFixture({ + width: 760, + render: () => { + const builds = sampleHistory.slice(2) + return ( + + ) + } +}) diff --git a/src/renderer/src/components/cloud-environment/CloudEnvironmentPanel.tsx b/src/renderer/src/components/cloud-environment/CloudEnvironmentPanel.tsx new file mode 100644 index 00000000000..b157c32ea21 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/CloudEnvironmentPanel.tsx @@ -0,0 +1,316 @@ +import { useState } from 'react' +import { Bot, Boxes, Hammer, Loader2, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { Skeleton } from '@/components/ui/skeleton' +import { useTranslate } from '@/i18n/use-translate' +import { cn } from '@/lib/utils' +import type { BuildLogBuffer } from './build-log-model' +import { BuildHistoryList } from './BuildHistoryList' +import { BuildLogViewer, type BuildLogStreamState } from './BuildLogViewer' +import { + isBuildInProgress, + repoDisplayName, + type StalenessBannerCopy +} from './cloud-environment-copy' +import type { + EnvironmentBuildView, + EnvironmentDraft, + EnvironmentView +} from './cloud-environment-types' +import { draftFromEnvironment } from './environment-draft' +import { EnvironmentForm, EnvironmentSummary, type EnvironmentFormProps } from './EnvironmentForm' +import { StalenessBanner } from './StalenessBanner' + +function SubsectionTitle({ + children, + action +}: { + children: React.ReactNode + action?: React.ReactNode +}) { + return ( +
+

+ {children} +

+ {action} +
+ ) +} + +export type CloudEnvironmentPanelProps = { + environment: EnvironmentView + /** The environment the form draft started from (defaults to `environment`). */ + formEnvironment?: EnvironmentView | null + builds: readonly EnvironmentBuildView[] + canAdmin: boolean + /** Fixed clock for fixtures; live views tick inside the history list only. */ + now?: number + selectedBuild: EnvironmentBuildView | null + log: { + buffer: BuildLogBuffer + state: BuildLogStreamState + error: string | null + onRetry: () => void + } | null + staleness: StalenessBannerCopy | null + starting: boolean + busyBuildId: string | null + actionError: string | null + actorName?: (actor: string) => string + /** Configure-with-agent is possible (a local project to run in exists). */ + agentAvailable: boolean + agentLaunching: boolean + form: Pick< + EnvironmentFormProps, + | 'saving' + | 'error' + | 'conflict' + | 'conflictEditor' + | 'suggestions' + | 'importProjects' + | 'onReadImports' + | 'onReload' + > & { + onSave: (draft: EnvironmentDraft) => void + initialDraft?: EnvironmentDraft + } + onBuild: () => void + onCancel: (build: EnvironmentBuildView) => void + onActivate: (build: EnvironmentBuildView) => void + onSelectBuild: (build: EnvironmentBuildView) => void + onConfigureWithAgent: () => void + /** Render the log viewer at a fixed clock (fixtures). */ + frozenNow?: number +} + +export function CloudEnvironmentPanel(props: CloudEnvironmentPanelProps): React.JSX.Element { + const t = useTranslate() + const { environment, builds, canAdmin, selectedBuild, log, staleness, starting, actionError } = + props + const activeBuild = builds.find((build) => build.active) ?? null + const inProgress = builds.some((build) => isBuildInProgress(build.status)) + const formEnvironment = props.formEnvironment ?? environment + const bannerOffersRebuild = staleness !== null && staleness.rebuild && !inProgress + const base = draftFromEnvironment(formEnvironment) + const [configOpen, setConfigOpen] = useState(builds.length === 0) + + return ( +
+
+
+ +
+
+

+ {activeBuild + ? t('cloudEnvironment.header.active', 'New workspaces start from build #{{number}}', { + number: String(activeBuild.number) + }) + : t( + 'cloudEnvironment.header.noActive', + 'No build yet: workspaces start from a fresh sandbox' + )} +

+

+ {environment.repos.map((repo) => repoDisplayName(repo.url)).join(', ')} + {' · '} + {environment.machineSize === 'large' + ? t('cloudEnvironment.header.large', 'Large') + : t('cloudEnvironment.header.standard', 'Standard')} +

+
+ {canAdmin ? ( +
+ + {/* One Rebuild per view: a staleness banner that offers one owns it. */} + {bannerOffersRebuild ? null : ( + + )} +
+ ) : null} +
+ + {actionError ? : null} + + {staleness && !inProgress ? ( + + ) : null} + + {selectedBuild && log ? ( + + ) : null} + +
+ + {t('cloudEnvironment.history.title', 'Builds')} + + +
+ +
+ setConfigOpen((open) => !open)} + > + {configOpen + ? t('cloudEnvironment.config.hide', 'Hide') + : canAdmin + ? t('cloudEnvironment.config.edit', 'Edit') + : t('cloudEnvironment.config.show', 'Show')} + + } + > + {t('cloudEnvironment.config.title', 'Configuration')} + +
+ {canAdmin ? ( + + ) : ( + + )} +
+
+
+ ) +} + +export function CloudEnvironmentEmptyState({ + canAdmin, + onStart +}: { + canAdmin: boolean + onStart: () => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+
+ +
+
+

+ {t('cloudEnvironment.empty.title', 'Create your environment')} +

+

+ {t( + 'cloudEnvironment.empty.description', + 'Install your repos’ dependencies once, snapshot the sandbox, and every new cloud workspace starts ready to run in seconds.' + )} +

+
+ {canAdmin ? ( + + ) : ( +

+ {t('cloudEnvironment.empty.memberAsk', 'Ask an owner or admin to set it up.')} +

+ )} +
+ ) +} + +/** Loading placeholder shaped like the panel (header row, banner, log). */ +export function CloudEnvironmentSkeleton(): React.JSX.Element { + const t = useTranslate() + return ( +
+ + {t('cloudEnvironment.loading', 'Loading environment…')} + +
+ +
+ + +
+ +
+ +
+ + +
+
+ ) +} diff --git a/src/renderer/src/components/cloud-environment/CloudEnvironmentSection.tsx b/src/renderer/src/components/cloud-environment/CloudEnvironmentSection.tsx new file mode 100644 index 00000000000..bed8b7d0c14 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/CloudEnvironmentSection.tsx @@ -0,0 +1,349 @@ +import { useEffect, useMemo, useState } from 'react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { InlineError } from '@/components/ui/inline-error' +import { SettingsGroupHeader } from '@/components/settings/section-layout/SettingsGroup' +import { useTranslate } from '@/i18n/use-translate' +import { + githubSlugForRepo, + launchCloudEnvironmentAgent, + pickCloudEnvironmentAgentRepo +} from '@/lib/cloud-environment-agent-launch' +import { useAppStore } from '@/store/index' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { OrcaOrgMember } from '../../../../shared/orca-profiles' +import { stalenessBannerCopy } from './cloud-environment-copy' +import type { + EnvironmentBuildView, + EnvironmentDraft, + EnvironmentsApi +} from './cloud-environment-types' +import { + CloudEnvironmentEmptyState, + CloudEnvironmentSkeleton, + CloudEnvironmentPanel +} from './CloudEnvironmentPanel' +import { emptyDraft } from './environment-draft' +import { EnvironmentForm } from './EnvironmentForm' +import type { LocalRepoSuggestion } from './EnvironmentFormFields' +import { useBuildLogStream } from './use-build-log-stream' +import { useCloudEnvironment } from './use-cloud-environment' + +const LOG_TAIL_LINES = 80 + +/** Hidden unless this build talks to an Orca Cloud control plane. */ +export function useOrcaCloudEnvironmentsConfigured(): boolean { + const [configured, setConfigured] = useState(false) + useEffect(() => { + let disposed = false + void window.api.orcaCloudEnvironments + ?.isConfigured() + .then((value) => { + if (!disposed) { + setConfigured(value) + } + }) + .catch(() => undefined) + return () => { + disposed = true + } + }, []) + return configured +} + +function useLocalRepoSuggestions(): { + suggestions: LocalRepoSuggestion[] + importProjects: LocalRepoSuggestion[] +} { + const repos = useAppStore((state) => state.repos) + return useMemo(() => { + const local = repos.filter((repo) => isGitRepoKind(repo) && !repo.connectionId) + const importProjects = local.map((repo) => ({ + repoId: repo.id, + slug: githubSlugForRepo(repo) ?? '', + path: repo.path, + displayName: repo.displayName + })) + return { + suggestions: importProjects.filter((entry) => entry.slug !== ''), + importProjects + } + }, [repos]) +} + +/** Settings block: Orca Cloud environment (repos + install script → snapshot builds). */ +export function CloudEnvironmentSection({ + orgId, + viewerUserId +}: { + orgId: string | null + viewerUserId: string | null +}): React.JSX.Element | null { + const t = useTranslate() + const configured = useOrcaCloudEnvironmentsConfigured() + if (!configured) { + return null + } + return ( +
+ + {t('cloudEnvironment.title', 'Cloud environment')} + + } + description={t( + 'cloudEnvironment.descriptionTeam', + 'Your team’s repos and install script, built once into a snapshot so every cloud workspace starts with dependencies installed.' + )} + /> + +
+ ) +} + +function useActorName( + orgId: string | null, + viewerUserId: string | null +): (actor: string) => string { + const t = useTranslate() + const [members, setMembers] = useState([]) + useEffect(() => { + if (!orgId) { + return + } + let disposed = false + void window.api.orcaProfiles + .orgMembersList({ orgId }) + .then((result) => { + if (!disposed && result.status === 'ok') { + setMembers(result.roster.members) + } + }) + .catch(() => undefined) + return () => { + disposed = true + } + }, [orgId]) + return useMemo(() => { + const you = t('cloudEnvironment.you', 'You') + return (actor: string) => { + if (actor === viewerUserId) { + return you + } + const member = members.find((entry) => entry.userId === actor) + return member ? member.displayName?.trim() || member.email : actor + } + }, [members, viewerUserId, t]) +} + +function CloudEnvironmentController({ + api, + orgId, + viewerUserId +}: { + api: EnvironmentsApi + orgId: string | null + viewerUserId: string | null +}): React.JSX.Element { + const t = useTranslate() + const confirm = useConfirmationDialog() + const env = useCloudEnvironment(api) + const { suggestions, importProjects } = useLocalRepoSuggestions() + const repos = useAppStore((state) => state.repos) + const activeRepoId = useAppStore((state) => state.activeRepoId) + const actorName = useActorName(orgId, viewerUserId) + const [creating, setCreating] = useState(false) + const [selectedBuildId, setSelectedBuildId] = useState(null) + const [agentLaunching, setAgentLaunching] = useState(false) + + const ready = env.load.phase === 'ready' ? env.load : null + const builds = ready?.builds ?? [] + const selectedBuild = builds.find((build) => build.id === selectedBuildId) ?? builds[0] ?? null + const stream = useBuildLogStream( + api, + ready?.environment.id ?? null, + selectedBuild?.id ?? null, + env.applyBuild, + env.refreshAfterBuildEnd + ) + + if (env.load.phase === 'loading') { + return + } + if (env.load.phase === 'error') { + const { error } = env.load + const entitlement = error.code === 'forbidden' && error.reason !== 'admin_required' + return ( + + ) + } + + const readImports = (path: string) => api.readRepoSetupImports(path) + + if (env.load.phase === 'none') { + if (!creating) { + return ( + setCreating(true)} + /> + ) + } + const prefill = pickCloudEnvironmentAgentRepo(repos, [], activeRepoId) + const prefillSlug = prefill ? githubSlugForRepo(prefill) : null + return ( + setCreating(false)} + onSave={(draft: EnvironmentDraft) => { + void env.create(draft).then((build) => { + if (build) { + setSelectedBuildId(build.id) + } + setCreating(false) + }) + }} + /> + ) + } + + const { environment, canAdmin } = env.load + const activeBuild = builds.find((build) => build.active) ?? null + // The stream's view of the selected build is fresher than the list until the next poll. + const streamedBuild = + stream.build && selectedBuild && stream.build.id === selectedBuild.id ? stream.build : null + const logBuild = streamedBuild ?? selectedBuild + const agentRepo = pickCloudEnvironmentAgentRepo( + repos, + environment.repos.map((repo) => repo.url), + activeRepoId + ) + + const configureWithAgent = (): void => { + if (!agentRepo) { + return + } + const latest = builds[0] + const failure = + latest && latest.status === 'failed' + ? { + buildNumber: latest.number, + failedStep: latest.failedStep, + error: latest.error, + log: + latest.id === logBuild?.id + ? stream.buffer.lines + .slice(-LOG_TAIL_LINES) + .map((line) => line.plain) + .join('\n') + : undefined + } + : undefined + setAgentLaunching(true) + void launchCloudEnvironmentAgent({ + repoId: agentRepo.id, + envId: environment.id, + environmentName: environment.name, + repos: environment.repos.map((repo) => repo.url), + envVarNames: environment.envVarNames, + ...(failure ? { failure } : {}) + }) + .catch(() => false) + .then(() => setAgentLaunching(false)) + } + + const activate = (build: EnvironmentBuildView): void => { + void confirm({ + title: t('cloudEnvironment.activate.title', 'Use build #{{number}}?', { + number: String(build.number) + }), + description: t( + 'cloudEnvironment.activate.description', + 'New workspaces will start from build #{{number}}. Existing workspaces keep theirs.', + { number: String(build.number) } + ), + confirmLabel: t('cloudEnvironment.activate.confirm', 'Use this build') + }).then((accepted) => { + if (accepted) { + void env.activate(build) + } + }) + } + + return ( + void env.save(env.formBase ?? environment, draft) + }} + onBuild={() => { + void env.startBuild(environment.id).then((build) => { + if (build) { + setSelectedBuildId(build.id) + } + }) + }} + onCancel={(build) => void env.cancel(build)} + onActivate={activate} + onSelectBuild={(build) => setSelectedBuildId(build.id)} + onConfigureWithAgent={configureWithAgent} + /> + ) +} diff --git a/src/renderer/src/components/cloud-environment/EnvironmentForm.tsx b/src/renderer/src/components/cloud-environment/EnvironmentForm.tsx new file mode 100644 index 00000000000..1dc3d6665a1 --- /dev/null +++ b/src/renderer/src/components/cloud-environment/EnvironmentForm.tsx @@ -0,0 +1,330 @@ +import { useId, useState } from 'react' +import { Download, Loader2, Lock } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { useTranslate } from '@/i18n/use-translate' +import { repoDisplayName } from './cloud-environment-copy' +import type { + EnvironmentDraft, + EnvironmentResult, + RepoSetupImports +} from './cloud-environment-types' +import { draftsEqual, MAX_INSTALL_SCRIPT_CHARS, mergeEnvVarNames } from './environment-draft' +import { + FieldLabel, + InstallScriptEditor, + RepoListEditor, + type LocalRepoSuggestion +} from './EnvironmentFormFields' +import { EnvVarChipEditor, MachineSizeControl } from './EnvironmentSettingsFields' +import { ImportFromRepoDialog, type ImportReview } from './ImportFromRepoDialog' + +export type EnvironmentFormProps = { + mode: 'create' | 'edit' + /** The saved state; the form is dirty when its draft differs. Remount (key) on a new revision. */ + base: EnvironmentDraft + /** Starting draft when it differs from `base` (fixtures, pre-filled create). */ + initialDraft?: EnvironmentDraft + saving: boolean + error: string | null + /** Someone else saved first (stale_revision): offer to reload. */ + conflict: boolean + /** Who saved the newer revision, when known. */ + conflictEditor?: string | null + suggestions: readonly LocalRepoSuggestion[] + /** Local projects "Import from repo" can read. */ + importProjects: readonly LocalRepoSuggestion[] + onReadImports: (path: string) => Promise> + onSave: (draft: EnvironmentDraft) => void + onReload?: () => void + onCancel?: () => void +} + +export function EnvironmentForm({ + mode, + base, + initialDraft, + saving, + error, + conflict, + conflictEditor, + suggestions, + importProjects, + onReadImports, + onSave, + onReload, + onCancel +}: EnvironmentFormProps): React.JSX.Element { + const t = useTranslate() + const scriptId = useId() + const [draft, setDraft] = useState(initialDraft ?? base) + const [review, setReview] = useState(null) + const [importing, setImporting] = useState(null) + const [importError, setImportError] = useState(null) + const dirty = mode === 'create' || !draftsEqual(draft, base) + const invalid = draft.repos.length === 0 || draft.installScript.length > MAX_INSTALL_SCRIPT_CHARS + + const readImports = async (project: LocalRepoSuggestion): Promise => { + setImporting(project.repoId) + setImportError(null) + const result = await onReadImports(project.path).catch( + (cause: unknown): EnvironmentResult => ({ + ok: false, + error: { code: 'unavailable', message: String(cause) } + }) + ) + setImporting(null) + if (result.ok) { + setReview({ projectName: project.displayName, imports: result.data }) + } else { + setImportError(result.error.message) + } + } + + return ( +
+ {conflict ? ( + void window.api.ui.writeClipboardText(draft.installScript)} + /> + ) : null} + setDraft((current) => ({ ...current, repos }))} + /> + +
+
+ + {t('cloudEnvironment.form.script', 'Install script')} + + {importProjects.length > 0 ? ( + + + + + + + {t( + 'cloudEnvironment.form.importHint', + 'Reads Conductor and dev container setup from a local project.' + )} + + {importProjects.map((project) => ( + void readImports(project)}> + {project.displayName} + {project.slug ? ( + + {project.slug} + + ) : null} + + ))} + + + ) : null} +
+ setDraft((current) => ({ ...current, installScript }))} + /> + {importError ? ( + + ) : null} +
+ + setDraft((current) => ({ ...current, envVarNames }))} + /> + + setDraft((current) => ({ ...current, machineSize }))} + /> + +
+ + {mode === 'edit' && dirty ? ( + + ) : null} + {mode === 'create' && onCancel ? ( + + ) : null} +

+ {conflict || error ? null : mode === 'edit' && dirty ? ( + + + {t( + 'cloudEnvironment.form.unsavedChanges', + 'Unsaved changes. Saving doesn’t rebuild; start a build when you’re ready.' + )} + + ) : draft.repos.length === 0 ? ( + t('cloudEnvironment.form.needRepo', 'Add at least one repository.') + ) : null} +

+
+ {error && !conflict ? ( + + ) : null} + setReview(null)} + onApply={({ envVarNames, installScript }) => { + setDraft((current) => ({ + ...current, + envVarNames: mergeEnvVarNames(current.envVarNames, envVarNames), + installScript: installScript ?? current.installScript + })) + setReview(null) + }} + /> +
+ ) +} + +/** What members (no admin role) see: the same facts, nothing editable. */ +export function EnvironmentSummary({ base }: { base: EnvironmentDraft }): React.JSX.Element { + const t = useTranslate() + const scriptId = useId() + return ( +
+

+ + {t( + 'cloudEnvironment.readOnlyAsk', + 'Ask an owner or admin to change the environment or start a build.' + )} +

+
+ {t('cloudEnvironment.form.repos', 'Repositories')} +
    + {base.repos.map((repo) => ( +
  • + {repoDisplayName(repo.url)} + + {repo.defaultBranch ?? t('cloudEnvironment.form.defaultBranch', 'default branch')} + +
  • + ))} +
+
+
+ + {t('cloudEnvironment.form.script', 'Install script')} + + +
+ +
+ {t('cloudEnvironment.size.label', 'Sandbox size')} +

+ {base.machineSize === 'large' + ? t('cloudEnvironment.size.large', 'Large (8 vCPU · 16 GB)') + : t('cloudEnvironment.size.standard', 'Standard (4 vCPU · 8 GB)')} +

+

+ {t('cloudEnvironment.size.rateNote', 'Large uses sandbox hours at 2× the rate.')} +

+
+
+ ) +} + +/** At the top, because Save is blocked until the user reloads (their draft would overwrite). */ +function ConflictNotice({ + editor, + onReload, + onCopyScript +}: { + editor: string | null + onReload?: () => void + onCopyScript: () => void +}): React.JSX.Element { + const t = useTranslate() + const [copied, setCopied] = useState(false) + return ( +
+ + {editor + ? t( + 'cloudEnvironment.form.conflictNamed', + '{{editor}} saved changes to this environment while you were editing. Reload to see them; your edits here will be discarded.', + { editor } + ) + : t( + 'cloudEnvironment.form.conflict', + 'Someone else saved changes to this environment. Reload to see them; your edits here will be discarded.' + )} + + + {onReload ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/cloud-environment/EnvironmentFormFields.tsx b/src/renderer/src/components/cloud-environment/EnvironmentFormFields.tsx new file mode 100644 index 00000000000..1635d180e3f --- /dev/null +++ b/src/renderer/src/components/cloud-environment/EnvironmentFormFields.tsx @@ -0,0 +1,330 @@ +import { useId, useRef, useState } from 'react' +import { GitBranch, Plus, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { useTranslate } from '@/i18n/use-translate' +import { normalizeRepoInput, repoDisplayName, repoKey } from './cloud-environment-copy' +import type { EnvironmentDraft } from './cloud-environment-types' +import { MAX_INSTALL_SCRIPT_CHARS } from './environment-draft' + +export type LocalRepoSuggestion = { + /** Orca project id. */ + repoId: string + /** `owner/repo` from the project's GitHub remote. */ + slug: string + path: string + displayName: string +} + +export function FieldLabel({ + htmlFor, + id, + children, + hint +}: { + htmlFor?: string + id?: string + children: React.ReactNode + hint?: React.ReactNode +}): React.JSX.Element { + return ( +
+ + {hint ?

{hint}

: null} +
+ ) +} + +export function RepoListEditor({ + repos, + onChange, + suggestions +}: { + repos: EnvironmentDraft['repos'] + onChange: (repos: EnvironmentDraft['repos']) => void + suggestions: readonly LocalRepoSuggestion[] +}): React.JSX.Element { + const t = useTranslate() + const inputId = useId() + const labelId = useId() + const [value, setValue] = useState('') + const [error, setError] = useState(null) + const present = new Set(repos.map((repo) => repoKey(repo.url))) + const offered = suggestions.filter((suggestion) => !present.has(suggestion.slug.toLowerCase())) + + const add = (raw: string): boolean => { + const slug = normalizeRepoInput(raw) + if (!slug) { + setError( + t( + 'cloudEnvironment.form.repoInvalid', + 'Enter a GitHub repository as owner/repo or a github.com URL.' + ) + ) + return false + } + if (present.has(slug.toLowerCase())) { + setError( + t('cloudEnvironment.form.repoDuplicate', '{{repo}} is already listed.', { repo: slug }) + ) + return false + } + setError(null) + onChange([...repos, { url: slug, defaultBranch: null }]) + return true + } + + const submit = (): void => { + if (add(value)) { + setValue('') + } + } + + return ( +
+ + {t('cloudEnvironment.form.repos', 'Repositories')} + + {repos.length > 0 ? ( +
    + {repos.map((repo, index) => { + const name = repoDisplayName(repo.url) + return ( +
  • + {name} + {index === 0 && repos.length > 1 ? ( + + {t('cloudEnvironment.form.primaryRepo', 'install script runs here')} + + ) : null} +
    + + { + const next = [...repos] + next[index] = { + ...repo, + defaultBranch: event.target.value + } + onChange(next) + }} + /> +
    + +
  • + ) + })} +
+ ) : null} +
+ { + setValue(event.target.value) + setError(null) + }} + placeholder={t( + 'cloudEnvironment.form.repoPlaceholder', + 'owner/repo or https://github.com/…' + )} + aria-invalid={error ? true : undefined} + aria-describedby={error ? `${inputId}-error` : undefined} + className="h-8 font-mono text-[13px] md:text-[13px]" + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + submit() + } + }} + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} + {offered.length > 0 ? ( +
+ + {t('cloudEnvironment.form.fromProjects', 'From your projects:')} + + {offered.slice(0, 8).map((suggestion) => ( + + ))} +
+ ) : null} +
+ ) +} + +/** Textarea with a synced line-number gutter; a full editor is overkill for a setup script. */ +export function InstallScriptEditor({ + value, + onChange, + readOnly = false, + id +}: { + value: string + onChange?: (value: string) => void + readOnly?: boolean + id?: string +}): React.JSX.Element { + const t = useTranslate() + const gutterRef = useRef(null) + const escaped = useRef(false) + const tabHintId = useId() + const lineCount = Math.max(1, value.split('\n').length) + const tooLong = value.length > MAX_INSTALL_SCRIPT_CHARS + return ( +
+
+
+ {Array.from({ length: lineCount }, (_, index) => ( +
{index + 1}
+ ))} +
+