Merge pull request #167 from nwparker/cloud/w5-cli-worktree-on

feat(cli): orca worktree create --on cloud (private build) with --env, JSON output and clear plan/at-cap/sign-in errors
This commit is contained in:
Neil
2026-11-09 21:13:22 -08:00
committed by GitHub
9 changed files with 326 additions and 5 deletions
@@ -79,6 +79,8 @@ const CreateWorkspaceBody = z
repo: z.object({ url: RepoUrl, ref: Ref.optional() }).strict().optional(),
machineSize: Size.optional(),
region: Region.optional(),
/** Team environment to boot from: `env_…` (its active build) or a specific build `bld_…`. */
environment: z.string().regex(/^(env|bld)_[A-Za-z0-9_-]{4,64}$/).optional(),
/** Optional first message: queued and delivered as soon as the VM is up. */
message: z.object({ text: z.string().min(1).max(MAX_MESSAGE_CHARS), agent: z.enum(AGENTS).optional() }).strict().optional()
})
@@ -123,6 +125,24 @@ function originFor(caller: ApiCaller): { origin: WorkspaceOrigin; originRef: str
return { origin: 'api', originRef: caller.via === 'user' ? (caller.keyId ?? null) : null }
}
/** `env_…` → that team environment's active build; `bld_…` passes through (lifecycle checks it). */
async function resolveEnvironmentBuild(db: D1Database, accountId: string, id: string): Promise<string> {
if (id.startsWith('bld_')) {
return id
}
const row = await db
.prepare('SELECT active_build_id FROM environments WHERE id = ? AND account_id = ? AND deleted_at IS NULL')
.bind(id, accountId)
.first<{ active_build_id: string | null }>()
if (!row) {
throw new ApiError(404, 'not_found', 'environment')
}
if (!row.active_build_id) {
throw new ApiError(409, 'conflict', 'environment_not_built')
}
return row.active_build_id
}
function messageBucket(caller: ApiCaller): string {
return caller.via === 'workspace' ? `msg:ws:${caller.sandboxId}` : `msg:${caller.accountId}:${caller.userId}`
}
@@ -383,6 +403,7 @@ async function route(request: Request, url: URL, caller: ApiCaller, ctx: ApiCont
await consumeRate(ctx.db, messageBucket(caller), MESSAGES_PER_MINUTE, now)
}
const project = body.projectId ? await getProject(ctx.db, caller.accountId, body.projectId) : null
const environmentBuildId = body.environment ? await resolveEnvironmentBuild(ctx.db, caller.accountId, body.environment) : undefined
await consumeCreateToken(life.db, life.config.guardrails!, caller.accountId, now)
const { origin, originRef } = originFor(caller)
const started = performance.now()
@@ -392,6 +413,7 @@ async function route(request: Request, url: URL, caller: ApiCaller, ctx: ApiCont
repo: body.repo,
machineSize: body.machineSize,
region: body.region,
...(environmentBuildId ? { environmentBuildId } : {}),
origin,
originRef
})
@@ -142,6 +142,8 @@ export async function createWorkspace(
name?: string
project?: ProjectRow | null
repo?: { url: string; ref?: string }
/** Boot from this environment build (bld_…); default: the project's pinned build, else the repo's active one. */
environmentBuildId?: string
machineSize?: MachineSize
region?: string
origin: WorkspaceOrigin
@@ -157,7 +159,8 @@ export async function createWorkspace(
workspaceId: input.workspaceId ?? randomId(input.origin === 'routine' ? 'rtn' : 'api'),
machineSize: input.machineSize ?? input.project?.machine_size ?? 'standard',
...((input.region ?? input.project?.region) ? { region: (input.region ?? input.project?.region)! } : {}),
...(repo ? { repo } : {})
...(repo ? { repo } : {}),
...((input.environmentBuildId ?? input.project?.build_id) ? { environmentBuildId: (input.environmentBuildId ?? input.project?.build_id)! } : {})
})
await setWorkspaceMeta(life.db, ready.row.id, {
name: input.name ?? null,
@@ -104,3 +104,14 @@ describe('teammate names', () => {
expect(messages[0]).toMatchObject({ sentBy: 'bob', sentByName: 'Bob Byte' })
})
})
describe('workspace create with a team environment', () => {
it('rejects an unknown environment before booting anything, and a malformed id as invalid', async () => {
const h = await harness()
const missing = await h.call('POST', '/v1/workspaces', { body: { environment: 'env_missing01' } })
expect(missing.status).toBe(404)
expect(missing.body).toEqual({ error: 'not_found', reason: 'environment' })
expect((await h.call('POST', '/v1/workspaces', { body: { environment: 'prod' } })).status).toBe(400)
expect(h.vercel.boxes.size).toBe(0)
})
})
+43
View File
@@ -131,6 +131,49 @@ export function cloudApiError(
data([])
)
}
if (
code === 'entitlement_denied' &&
(reason === 'concurrency_limit' || reason === 'member_concurrency_limit')
) {
return new RuntimeClientError(
'cloud_at_capacity',
reason === 'member_concurrency_limit'
? "You're at your limit of running sandboxes. Nothing was created."
: "You're at your team's limit of running sandboxes. Nothing was created.",
data([
'Put one to sleep or delete it, then try again: `orca cloud workspace list`, then `orca cloud workspace sleep <id>`.'
])
)
}
if (code === 'entitlement_denied' || code === 'billing_required' || status === 402) {
const outOfHours = reason === 'included_hours_exhausted' || reason === 'overage_cap_reached'
const payment = reason === 'payment_past_due' || reason === 'payment_grace_expired'
if (payment) {
return new RuntimeClientError(
'cloud_payment_failed',
"Payment failed, so new sandboxes can't start. Nothing was created.",
data(['Ask an owner or admin to update the payment method.'])
)
}
if (reason === 'machine_size_not_in_plan') {
return new RuntimeClientError(
'cloud_plan_required',
"That sandbox size isn't in your team's plan. Nothing was created.",
data(['Use --size standard.'])
)
}
return new RuntimeClientError(
outOfHours ? 'cloud_out_of_hours' : 'cloud_plan_required',
outOfHours
? 'Your team is out of sandbox hours (or at its monthly spend cap). Nothing was created, and your files are safe.'
: 'Orca Cloud needs a Team plan. Nothing was created, and you weren’t charged.',
data([
outOfHours
? 'Ask an owner or admin to turn on overage or raise the spend cap.'
: 'Ask an owner or admin to start a Team plan in Orca → Settings → Plan & billing.'
])
)
}
if (status === 403 && code === 'insufficient_scope') {
return new RuntimeClientError(
'cloud_forbidden',
+2
View File
@@ -152,6 +152,8 @@ export type CloudWorkspaceCreateInput = {
name?: string
projectId?: string
repo?: { url: string; ref?: string }
/** Team environment: `env_…` (its active build) or a build `bld_…`. */
environment?: string
machineSize?: 'standard' | 'large'
region?: string
message?: { text: string; agent?: (typeof CLOUD_AGENTS)[number] }
@@ -0,0 +1,187 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { callMock, runtimeClientConstructorMock, serveOrcaAppMock, getDefaultUserDataPathMock } =
vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
}))
vi.mock('../runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('../index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
import { main } from '../index'
let home: string
let out: string[]
let err: string[]
let stdoutSpy: { mock: { calls: unknown[][] }; mockClear: () => void }
/** Everything the CLI printed: console.log lines plus raw stdout writes. */
const printed = () => out.join('') + stdoutSpy.mock.calls.map((call) => String(call[0])).join('')
const fetchMock = vi.fn<typeof fetch>()
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'orca-cloud-main-'))
out = []
err = []
vi.spyOn(console, 'log').mockImplementation((line: unknown) => void out.push(String(line)))
vi.spyOn(console, 'error').mockImplementation((line: unknown) => void err.push(String(line)))
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
vi.stubEnv('ORCA_CLOUD_CREDENTIALS_FILE', join(home, 'credentials.json'))
vi.stubEnv('ORCA_CLOUD_TOKEN', 'orca_ws_test')
vi.stubEnv('ORCA_CLOUD_API_URL', 'https://cloud.test')
vi.stubGlobal('fetch', fetchMock)
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
fetchMock.mockReset()
runtimeClientConstructorMock.mockReset()
rmSync(home, { recursive: true, force: true })
process.exitCode = undefined
})
describe('orca worktree create --on cloud via main()', () => {
it('creates an Orca Cloud workspace through the API, without the Orca runtime', async () => {
fetchMock.mockResolvedValue(
Response.json(
{ workspace: { id: 'sbx_on1', name: 'fix', status: 'running', origin: 'vm' } },
{ status: 201 }
)
)
await main(
[
'worktree',
'create',
'--on',
'cloud',
'--name',
'fix',
'--repo',
'https://github.com/acme/app',
'--agent',
'claude',
'--prompt',
'hi',
'--json'
],
home
)
expect(process.exitCode).toBeUndefined()
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('https://cloud.test/v1/workspaces')
expect(JSON.parse(String(init?.body))).toEqual({
name: 'fix',
repo: { url: 'https://github.com/acme/app' },
message: { text: 'hi', agent: 'claude' }
})
expect(JSON.parse(printed())).toMatchObject({
ok: true,
schemaVersion: 1,
result: { host: { id: 'sbx_on1', origin: { target: 'cloud' } } }
})
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
it('boots from a team environment with --env and reports a clear error for bad ids', async () => {
fetchMock.mockResolvedValue(
Response.json(
{ workspace: { id: 'sbx_on2', name: 'e', status: 'running', origin: 'api' } },
{ status: 201 }
)
)
await main(
[
'worktree',
'create',
'--on',
'cloud',
'--repo',
'https://github.com/acme/app',
'--env',
'env_abcd1234',
'--json'
],
home
)
expect(process.exitCode).toBeUndefined()
const [, init] = fetchMock.mock.calls[0]!
expect(JSON.parse(String(init?.body))).toMatchObject({ environment: 'env_abcd1234' })
fetchMock.mockReset()
out = []
stdoutSpy.mockClear()
await main(['worktree', 'create', '--on', 'cloud', '--env', 'prod', '--json'], home)
expect(process.exitCode).not.toBeUndefined()
expect(JSON.parse(printed())).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(fetchMock).not.toHaveBeenCalled()
})
it.each([
[
{ error: 'entitlement_denied', reason: 'concurrency_limit' },
'cloud_at_capacity',
"team's limit of running sandboxes"
],
[
{ error: 'entitlement_denied', reason: 'no_subscription' },
'cloud_plan_required',
'needs a Team plan'
],
[
{ error: 'entitlement_denied', reason: 'included_hours_exhausted' },
'cloud_out_of_hours',
'out of sandbox hours'
]
])('maps %o to %s with a next step', async (body, code, text) => {
fetchMock.mockResolvedValue(Response.json(body, { status: 403 }))
await main(
['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'],
home
)
expect(process.exitCode).not.toBeUndefined()
const payload = JSON.parse(printed())
expect(payload).toMatchObject({ ok: false, error: { code } })
expect(payload.error.message).toContain(text)
})
it('says how to sign in when there are no credentials', async () => {
vi.stubEnv('ORCA_CLOUD_TOKEN', '')
vi.stubEnv('ORCA_CLOUD_API_URL', '')
await main(
['worktree', 'create', '--on', 'cloud', '--repo', 'https://github.com/acme/app', '--json'],
home
)
expect(JSON.parse(printed())).toMatchObject({
ok: false,
error: { code: 'cloud_not_signed_in' }
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('refuses other --on targets in this build, before any request', async () => {
await main(['worktree', 'create', '--on', 'recipe:dev', '--name', 'x', '--json'], home)
expect(JSON.parse(printed())).toMatchObject({
ok: false,
error: { code: 'invalid_argument' }
})
expect(fetchMock).not.toHaveBeenCalled()
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
})
+35 -1
View File
@@ -35,6 +35,7 @@ const SUPPORTED = new Set([
'size',
'agent',
'prompt',
'env',
'progress',
'json',
'help'
@@ -135,7 +136,7 @@ export async function createWorktreeOnCloud(
if (unsupported.length > 0) {
throw new RuntimeClientError(
'invalid_argument',
`--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --size, --agent/--prompt.`,
`--${unsupported[0]} doesn't apply to --on cloud. Supported: --name, --repo, --base-branch, --env, --size, --agent/--prompt.`,
{ field: unsupported[0] }
)
}
@@ -178,8 +179,17 @@ export async function createWorktreeOnCloud(
}
const api = cloudClient(deps)
const name = getOptionalStringFlag(flags, 'name')
const environment = getOptionalStringFlag(flags, 'env')
if (environment !== undefined && !/^(env|bld)_[A-Za-z0-9_-]{4,64}$/.test(environment)) {
throw new RuntimeClientError(
'invalid_argument',
'--env takes a team environment id (env_…) or a build id (bld_…).',
{ field: 'env' }
)
}
const input: CloudWorkspaceCreateInput = {
...(name ? { name } : {}),
...(environment ? { environment } : {}),
...(target.project ? { projectId: await resolveCloudProjectId(api, target.project) } : {}),
...(repo ? { repo: { url: repo.url, ...(ref ? { ref } : {}) } } : {}),
...(machineSize ? { machineSize } : {}),
@@ -209,3 +219,27 @@ export async function createWorktreeOnCloud(
(value) => formatCloudWorkspace(value, deps.now())
)
}
/**
* Private build hook for `worktree create`: the only `--on` target here is Orca Cloud, which talks to
* the Orca Cloud API instead of the runtime. True when it handled the command.
*/
export async function maybeCreateWorktreeOnCloud(
context: Pick<HandlerContext, 'flags' | 'cwd' | 'json'>
): Promise<boolean> {
const { flags } = context
if (!flags.has('on') && !flags.has('env') && !flags.has('size') && !flags.has('progress')) {
return false
}
if (!readCloudOnTarget(flags)) {
throw new RuntimeClientError(
'invalid_argument',
flags.has('on')
? '--on supports `cloud` or `cloud:<project>` in this build.'
: '--env, --size and --progress apply to `--on cloud`.',
{ field: flags.has('on') ? 'on' : 'env' }
)
}
await createWorktreeOnCloud(context)
return true
}
+7 -2
View File
@@ -6,7 +6,7 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = {
repeatableFlags: ['reference'],
summary: 'Create a new Orca-managed worktree',
usage:
'orca worktree create --name <name> [--repo <selector>|--project <id-or-name> [--host <host>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch <ref>] [--branch <name>] [--reference <url-or-jira-key>...] [--reference-site <id>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector> [--parent-environment <environment>]] [--no-parent] [--run-hooks] [--activate] [--retry-launch <operationId>] [--json]',
'orca worktree create --name <name> [--repo <selector>|--project <id-or-name> [--host <host>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--model <id>] [--effort <level>] [--surface chat|terminal] [--setup run|skip|inherit] [--base-branch <ref>] [--branch <name>] [--reference <url-or-jira-key>...] [--reference-site <id>] [--issue <number>] [--pr <number>] [--linear-issue <identifier-or-url>] [--gitlab-issue <number-or-url>] [--gitlab-mr <number-or-url>] [--comment <text>] [--parent-worktree <selector> [--parent-environment <environment>]] [--no-parent] [--run-hooks] [--activate] [--retry-launch <operationId>] [--json]\n orca worktree create --on cloud[:<project>] [--name <name>] [--repo <git-url-or-checkout>] [--base-branch <ref>] [--env <env_…|bld_…>] [--size standard|large] [--agent claude|codex --prompt <text>] [--progress text|json|none] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'repo',
@@ -35,7 +35,12 @@ export const WORKTREE_CREATE_COMMAND_SPEC: CommandSpec = {
'parent-environment',
'no-parent',
'run-hooks',
'activate'
'activate',
// Private build: `--on cloud` creates an Orca Cloud workspace (src/cli/cloud/worktree-create-on-cloud.ts).
'on',
'env',
'size',
'progress'
],
notes: [
'Repeat --reference to attach full issue or review URLs, or Jira issue keys, before setup or agent startup. Use --reference-site to choose the Jira site for keys. Other providers require full URLs. Cannot combine with legacy link flags.',
+15 -1
View File
@@ -12,7 +12,21 @@ export const WORKTREE_HANDLER_GROUPS: readonly HandlerGroup[] = [
'worktree set',
'worktree rm'
],
load: async () => (await import('./handlers/worktree.js')).WORKTREE_HANDLERS
load: async () => {
const { WORKTREE_HANDLERS } = await import('./handlers/worktree.js')
// Private build: `worktree create --on cloud` goes to the Orca Cloud API, not the runtime.
const { maybeCreateWorktreeOnCloud } = await import('./cloud/worktree-create-on-cloud.js')
const create = WORKTREE_HANDLERS['worktree create']!
return {
...WORKTREE_HANDLERS,
'worktree create': async (context) => {
if (await maybeCreateWorktreeOnCloud(context)) {
return
}
return create(context)
}
}
}
},
{
name: 'worktree-activate',