mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user