From 77660a07e605b79acd7711edc86c280bcbae51b4 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 02:06:22 +0200 Subject: [PATCH 1/2] feat(deploy): support deploy input overrides --- docs/plans/deploy-v1.md | 6 + packages/cli/src/deploy-command.test.ts | 73 +++++ packages/cli/src/deploy-command.ts | 25 +- packages/deploy/src/index.ts | 93 +++++- packages/deploy/src/modes/cloud.ts | 54 +++- .../deploy/src/modes/input-values.test.ts | 275 ++++++++++++++++++ packages/deploy/src/types.ts | 4 + 7 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/deploy-command.test.ts create mode 100644 packages/deploy/src/modes/input-values.test.ts diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index a11ab435..b18930ff 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -185,6 +185,10 @@ export const KNOWN_TRIGGERS = { Unknown trigger names log a yellow warning but don't fail deploy. The cloud runtime is the source of truth; we don't want to be a gating bottleneck. +### 3.8 Deploy-time persona inputs + +Existing `persona.inputs` remain the declaration point for non-secret runtime values. `workforce deploy` supplies deploy-time overrides with repeatable `--input KEY=value` flags; the CLI rejects keys that the persona did not declare and requires each value to be a string. In `--mode dev` and `--mode sandbox`, accepted values are injected into the runner environment as `WORKFORCE_INPUT_`. In `--mode cloud`, the same map is sent in the deployment POST body as `inputs`. + --- ## 4. Runtime substrate — `@agentworkforce/runtime` @@ -282,11 +286,13 @@ workforce deploy [--detach] # background the runner [--bundle-out ] # emit bundle without launching [--dry-run] # validate only + [--input =] # override declared persona input (repeatable) ``` Flow: 1. **Resolve persona**: load the JSON via `parsePersonaSpec` (extended schema). Fail fast on schema errors with field-pointed messages. + Deploy-time persona input overrides come from repeated `--input KEY=value` flags. Each key must be declared by `persona.inputs`; values are non-secret strings passed through to the runner as `WORKFORCE_INPUT_` and included in cloud deployment requests. 2. **Login check**: if no workforce auth token in keychain, prompt `workforce login` (browser OAuth via existing relayauth flow). 3. **Workspace check**: ensure user has a workspace; offer to create one (`relay workspaces create ` semantics, called via SDK not subprocess). 4. **Integrations**: for each `persona.integrations` key, check if connected to the active workspace. If not, **prompt the user before each** (`Connect github now? (Y/n)`). On yes, call `RelayfileSetup.connectIntegration({ allowedIntegrations: [key] })` and open the browser. Block until callback. On no, fail with a clear message. diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts new file mode 100644 index 00000000..99da602f --- /dev/null +++ b/packages/cli/src/deploy-command.test.ts @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { parseDeployArgs } from './deploy-command.js'; + +interface ExitTrap { + exits: number[]; + stderr: string; + restore: () => void; +} + +function trapExit(): ExitTrap { + const trap: ExitTrap = { + exits: [], + stderr: '', + restore: () => { + /* replaced below */ + } + }; + const origExit = process.exit; + const origErr = process.stderr.write.bind(process.stderr); + const fakeExit = ((code?: number) => { + trap.exits.push(code ?? 0); + throw new Error(`__exit_trap__:${code ?? 0}`); + }) as typeof process.exit; + + process.exit = fakeExit; + process.stderr.write = ((chunk: string | Uint8Array) => { + trap.stderr += typeof chunk === 'string' ? chunk : chunk.toString(); + return true; + }) as typeof process.stderr.write; + + trap.restore = () => { + process.exit = origExit; + process.stderr.write = origErr; + }; + return trap; +} + +test('parseDeployArgs: single --input parses and forwards', () => { + const parsed = parseDeployArgs(['./persona.json', '--input', 'TOPIC=Deploy v1']); + + assert.equal(parsed.personaPath, path.resolve('./persona.json')); + assert.deepEqual(parsed.inputs, { TOPIC: 'Deploy v1' }); +}); + +test('parseDeployArgs: multiple --input flags accumulate', () => { + const parsed = parseDeployArgs([ + './persona.json', + '--input', + 'TOPIC=Deploy v1', + '--input=REGION=us-east-1' + ]); + + assert.deepEqual(parsed.inputs, { + TOPIC: 'Deploy v1', + REGION: 'us-east-1' + }); +}); + +test('parseDeployArgs: malformed --input exits with clean error', () => { + const trap = trapExit(); + try { + assert.throws( + () => parseDeployArgs(['./persona.json', '--input', 'foo']), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /--input: expected =; got "foo"/); + } finally { + trap.restore(); + } +}); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index e9e37e9f..5af4074c 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -83,6 +83,7 @@ Flags: --bundle-out Emit the bundle to and exit (no launch) --dry-run Validate the persona and exit before any side effects --cloud-url Override the workforce cloud base URL + --input = Override a declared persona input (repeatable) -h, --help Print this message `; @@ -105,6 +106,7 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { let bundleOut: string | undefined; let dryRun = false; let cloudUrl: string | undefined; + const inputs: Record = {}; for (let i = 0; i < args.length; i += 1) { const a = args[i]; @@ -131,6 +133,10 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { dryRun = true; } else if (a === '--cloud-url') { cloudUrl = expectValue('--cloud-url', args[++i]); + } else if (a === '--input') { + parseDeployInputValue(expectDeployInputValue(args[++i]), inputs); + } else if (a.startsWith('--input=')) { + parseDeployInputValue(a.slice('--input='.length), inputs); } else if (a.startsWith('--')) { die(`deploy: unknown flag "${a}"`); } else if (!personaPath) { @@ -153,10 +159,27 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { ...(detach ? { detach: true } : {}), ...(bundleOut ? { bundleOut } : {}), ...(dryRun ? { dryRun: true } : {}), - ...(cloudUrl ? { cloudUrl } : {}) + ...(cloudUrl ? { cloudUrl } : {}), + ...(Object.keys(inputs).length > 0 ? { inputs } : {}) }; } +function expectDeployInputValue(value: string | undefined): string { + if (typeof value !== 'string' || value.length === 0 || value.startsWith('--')) { + die('--input: expected ='); + } + return value; +} + +function parseDeployInputValue(raw: string, inputs: Record): void { + const eq = raw.indexOf('='); + if (eq <= 0) { + die(`--input: expected =; got "${raw}"`); + } + const key = raw.slice(0, eq); + inputs[key] = raw.slice(eq + 1); +} + function expectValue(flag: string, value: string | undefined): string { if (typeof value !== 'string' || !value.trim()) { die(`${flag}: missing value`); diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 34d21735..cf85c081 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -1,5 +1,19 @@ -export { deploy, pickMode, type DeployResolvers } from './deploy.js'; -export { preflightPersona } from './preflight.js'; +import { deploy as deployInternal, pickMode } from './deploy.js'; +import type { DeployResolvers } from './deploy.js'; +import { preflightPersona } from './preflight.js'; +import { devLauncher } from './modes/dev.js'; +import { sandboxLauncher } from './modes/sandbox.js'; +import { cloudLauncher } from './modes/cloud.js'; +import type { + DeployOptions, + DeployResult, + ModeLaunchInput, + ModeLauncher +} from './types.js'; + +export { pickMode }; +export type { DeployResolvers }; +export { preflightPersona }; export { connectIntegrations, envIntegrationResolver, @@ -29,3 +43,78 @@ export type { ModeLaunchInput, ModeLauncher } from './types.js'; + +const INPUT_ENV_PREFIX = 'WORKFORCE_INPUT_'; + +export async function deploy( + opts: DeployOptions, + resolvers: DeployResolvers = {} +): Promise { + const inputs = opts.inputs && Object.keys(opts.inputs).length > 0 ? opts.inputs : undefined; + if (!inputs) return deployInternal(opts, resolvers); + + const preflight = await preflightPersona(opts.personaPath); + validateDeployInputs(inputs, preflight.persona.inputs); + return deployInternal(opts, wrapInputResolvers(resolvers, inputs, opts.cloudUrl)); +} + +function validateDeployInputs( + inputs: Record, + declared: DeployPreflightPersonaInputs +): void { + const declaredKeys = Object.keys(declared ?? {}); + for (const [key, value] of Object.entries(inputs)) { + if (typeof value !== 'string') { + throw new Error(`Input '${key}' must be a string`); + } + if (!Object.prototype.hasOwnProperty.call(declared ?? {}, key)) { + const list = declaredKeys.length > 0 ? declaredKeys.join(', ') : '(none)'; + throw new Error(`Unknown input '${key}'; persona declares: ${list}`); + } + } +} + +type DeployPreflightPersonaInputs = NonNullable< + Awaited>['persona']['inputs'] +> | undefined; + +function wrapInputResolvers( + resolvers: DeployResolvers, + inputs: Record, + cloudUrl: string | undefined +): DeployResolvers { + return { + ...resolvers, + modes: { + dev: wrapLauncher(resolvers.modes?.dev ?? devLauncher, inputs, cloudUrl), + sandbox: wrapLauncher(resolvers.modes?.sandbox ?? sandboxLauncher, inputs, cloudUrl), + cloud: wrapLauncher(resolvers.modes?.cloud ?? cloudLauncher, inputs, cloudUrl) + } + }; +} + +function wrapLauncher( + launcher: ModeLauncher, + inputs: Record, + cloudUrl: string | undefined +): ModeLauncher { + return { + async launch(input: ModeLaunchInput) { + return launcher.launch({ + ...input, + env: { + ...(input.env ?? {}), + ...toInputEnv(inputs) + }, + inputs, + ...(cloudUrl ? { cloudUrl } : {}) + }); + } + }; +} + +function toInputEnv(inputs: Record): Record { + return Object.fromEntries( + Object.entries(inputs).map(([key, value]) => [`${INPUT_ENV_PREFIX}${key}`, value]) + ); +} diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index f4167596..4f34478c 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import type { ModeLaunchInput, ModeLaunchHandle, @@ -20,9 +21,60 @@ import type { * 4. Return a handle whose `stop()` calls DELETE on the deployment. */ export const cloudLauncher: ModeLauncher = { - async launch(_input: ModeLaunchInput): Promise { + async launch(input: ModeLaunchInput): Promise { + const cloudUrl = (input.cloudUrl ?? process.env.WORKFORCE_CLOUD_URL)?.replace(/\/$/, ''); + const token = process.env.WORKFORCE_WORKSPACE_TOKEN; + if (cloudUrl && token) { + return postCloudDeployment(input, cloudUrl, token); + } throw new Error( '--mode cloud is not yet available: the workforce cloud deployments endpoint is in progress. Use --mode sandbox (Daytona) or --mode dev (local) today.' ); } }; + +async function postCloudDeployment( + input: ModeLaunchInput, + cloudUrl: string, + workspaceToken: string +): Promise { + const res = await fetch( + `${cloudUrl}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/deployments`, + { + method: 'POST', + headers: { + authorization: `Bearer ${workspaceToken}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + persona: input.persona, + bundle: { + runner: await readFile(input.bundle.runnerPath, 'utf8'), + agent: await readFile(input.bundle.bundlePath, 'utf8'), + packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8')) + }, + ...(input.inputs && Object.keys(input.inputs).length > 0 ? { inputs: input.inputs } : {}) + }) + } + ); + if (!res.ok) { + throw new Error(`Cloud deploy failed: ${res.status} ${await res.text()}`); + } + const body = (await res.json()) as { + agentId?: string; + deploymentId?: string; + status?: string; + }; + const id = body.deploymentId ?? body.agentId; + if (!id) { + throw new Error(`Cloud deploy failed: response missing deploymentId/agentId`); + } + input.io.info(`cloud: ${body.status ?? 'submitted'}`); + return { + id, + async stop() { + throw new Error('cloud deployment stop is not wired yet'); + }, + done: Promise.resolve({ code: body.status === 'failed' ? 1 : 0 }) + }; +} diff --git a/packages/deploy/src/modes/input-values.test.ts b/packages/deploy/src/modes/input-values.test.ts new file mode 100644 index 00000000..28dc8a91 --- /dev/null +++ b/packages/deploy/src/modes/input-values.test.ts @@ -0,0 +1,275 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { deploy } from '../index.js'; +import { createBufferedIO } from '../io.js'; +import { cloudLauncher } from './cloud.js'; +import type { + BundleResult, + BundleStager, + IntegrationConnectResolver, + ModeLaunchInput, + ModeLauncher, + WorkspaceAuth +} from '../index.js'; + +function personaJson(overrides: Record = {}): Record { + return { + id: 'demo', + intent: 'documentation', + tags: ['documentation'], + description: 'test persona', + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + schedules: [{ name: 'weekly', cron: '0 9 * * 6' }], + onEvent: './agent.ts', + inputs: { + TOPIC: { default: 'AI' }, + REGION: { optional: true } + }, + ...overrides + }; +} + +async function withTempPersona( + persona: Record +): Promise<{ dir: string; personaPath: string; cleanup: () => Promise }> { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-deploy-inputs-')); + const personaPath = path.join(dir, 'persona.json'); + await writeFile(personaPath, JSON.stringify(persona, null, 2), 'utf8'); + await writeFile(path.join(dir, 'agent.ts'), 'export default async () => {};', 'utf8'); + return { + dir, + personaPath, + cleanup: () => rm(dir, { recursive: true, force: true }) + }; +} + +function testWorkspaceAuth(): WorkspaceAuth { + return { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; +} + +function connectedIntegrations(): IntegrationConnectResolver { + return { + async isConnected() { + return true; + }, + async connect() { + throw new Error('connect should not be called'); + } + }; +} + +function testBundleStager(runnerSource = ''): BundleStager { + return { + async stage(input) { + await mkdir(input.outDir, { recursive: true }); + const runner = path.join(input.outDir, 'runner.mjs'); + const bundle = path.join(input.outDir, 'agent.bundle.mjs'); + const personaCopy = path.join(input.outDir, 'persona.json'); + const pkg = path.join(input.outDir, 'package.json'); + await Promise.all([ + writeFile(runner, runnerSource, 'utf8'), + writeFile(bundle, '', 'utf8'), + writeFile(personaCopy, '{}', 'utf8'), + writeFile(pkg, '{}', 'utf8') + ]); + return { + runnerPath: runner, + bundlePath: bundle, + personaCopyPath: personaCopy, + packageJsonPath: pkg, + sizeBytes: 2 + }; + } + }; +} + +test('deploy inputs validate against persona spec and forward to mode env', async () => { + const { personaPath, cleanup } = await withTempPersona(personaJson()); + const io = createBufferedIO(); + let launchInput: ModeLaunchInput | undefined; + const launcher: ModeLauncher = { + async launch(input) { + launchInput = input; + return { + id: 'pid-1', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + }; + + try { + await deploy( + { + personaPath, + mode: 'dev', + io, + inputs: { TOPIC: 'Deploy v1', REGION: 'us-east-1' } + }, + { + workspaceAuth: testWorkspaceAuth(), + integrations: connectedIntegrations(), + bundle: testBundleStager(), + modes: { dev: launcher } + } + ); + + assert.deepEqual(launchInput?.inputs, { TOPIC: 'Deploy v1', REGION: 'us-east-1' }); + assert.equal(launchInput?.env?.WORKFORCE_INPUT_TOPIC, 'Deploy v1'); + assert.equal(launchInput?.env?.WORKFORCE_INPUT_REGION, 'us-east-1'); + } finally { + await cleanup(); + } +}); + +test('deploy inputs reach the dev launcher child process env', async () => { + const { dir, personaPath, cleanup } = await withTempPersona(personaJson()); + const observedPath = path.join(dir, 'observed-env.json'); + const runnerSource = [ + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${JSON.stringify(observedPath)}, JSON.stringify({`, + ' topic: process.env.WORKFORCE_INPUT_TOPIC ?? null,', + ' region: process.env.WORKFORCE_INPUT_REGION ?? null,', + ' workspace: process.env.WORKFORCE_WORKSPACE_ID ?? null', + "}), 'utf8');" + ].join('\n'); + + try { + const result = await deploy( + { + personaPath, + mode: 'dev', + io: createBufferedIO(), + inputs: { TOPIC: 'Deploy v1', REGION: 'eu-west-1' } + }, + { + workspaceAuth: testWorkspaceAuth(), + integrations: connectedIntegrations(), + bundle: testBundleStager(runnerSource) + } + ); + const handle = result.runHandle as { done: Promise<{ code: number }> } | undefined; + const exit = await handle?.done; + + assert.equal(exit?.code, 0); + assert.deepEqual(JSON.parse(await readFile(observedPath, 'utf8')), { + topic: 'Deploy v1', + region: 'eu-west-1', + workspace: 'ws-test' + }); + } finally { + await cleanup(); + } +}); + +test('deploy inputs reject undeclared keys with declared input list', async () => { + const { personaPath, cleanup } = await withTempPersona(personaJson()); + try { + await assert.rejects( + deploy({ + personaPath, + mode: 'dev', + io: createBufferedIO(), + inputs: { UNKNOWN: 'x' } + }), + /Unknown input 'UNKNOWN'; persona declares: TOPIC, REGION/ + ); + } finally { + await cleanup(); + } +}); + +test('deploy inputs reject non-string values with clean error', async () => { + const { personaPath, cleanup } = await withTempPersona(personaJson()); + try { + await assert.rejects( + deploy({ + personaPath, + mode: 'dev', + io: createBufferedIO(), + inputs: { TOPIC: 42 } as unknown as Record + }), + /Input 'TOPIC' must be a string/ + ); + } finally { + await cleanup(); + } +}); + +test('cloud launcher includes inputs in persona bundle POST body', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-cloud-inputs-')); + const oldToken = process.env.WORKFORCE_WORKSPACE_TOKEN; + const oldFetch = globalThis.fetch; + const calls: Array<{ url: string; body: unknown }> = []; + try { + const bundle = await writeBundle(dir); + process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-cloud'; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: typeof input === 'string' ? input : input.toString(), + body: init?.body ? JSON.parse(String(init.body)) : undefined + }); + return new Response( + JSON.stringify({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'starting' }), + { status: 201, headers: { 'content-type': 'application/json' } } + ); + }) as typeof fetch; + + const handle = await cloudLauncher.launch({ + persona: personaJson() as never, + bundle, + workspace: 'ws-test', + cloudUrl: 'https://cloud.example.com', + inputs: { TOPIC: 'Deploy v1' }, + io: createBufferedIO() + }); + + assert.equal(handle.id, 'dep-1'); + assert.equal( + calls[0]?.url, + 'https://cloud.example.com/api/v1/workspaces/ws-test/deployments' + ); + assert.deepEqual((calls[0]?.body as { inputs?: unknown }).inputs, { TOPIC: 'Deploy v1' }); + } finally { + if (oldToken === undefined) { + delete process.env.WORKFORCE_WORKSPACE_TOKEN; + } else { + process.env.WORKFORCE_WORKSPACE_TOKEN = oldToken; + } + globalThis.fetch = oldFetch; + await rm(dir, { recursive: true, force: true }); + } +}); + +async function writeBundle(dir: string): Promise { + const runnerPath = path.join(dir, 'runner.mjs'); + const bundlePath = path.join(dir, 'agent.bundle.mjs'); + const personaCopyPath = path.join(dir, 'persona.json'); + const packageJsonPath = path.join(dir, 'package.json'); + await Promise.all([ + writeFile(runnerPath, 'runner', 'utf8'), + writeFile(bundlePath, 'agent', 'utf8'), + writeFile(personaCopyPath, '{}', 'utf8'), + writeFile(packageJsonPath, '{"type":"module"}', 'utf8') + ]); + return { + runnerPath, + bundlePath, + personaCopyPath, + packageJsonPath, + sizeBytes: 1 + }; +} diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 64643fa3..075a4479 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -21,6 +21,8 @@ export interface DeployOptions { dryRun?: boolean; /** Override the WORKFORCE_CLOUD_URL; defaults to env or production. */ cloudUrl?: string; + /** Deploy-time persona input overrides, supplied as `--input KEY=value`. */ + inputs?: Record; /** Override stdout writer for tests + structured outputs. */ io?: DeployIO; } @@ -99,6 +101,8 @@ export interface ModeLaunchInput { bundle: BundleResult; workspace: string; env?: Record; + inputs?: Record; + cloudUrl?: string; io: DeployIO; detach?: boolean; /** From 8fb4518cb84b67053895e4c9e6f843e340efe84d Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 13 May 2026 08:30:22 +0200 Subject: [PATCH 2/2] fix(deploy): forward cloudUrl in no-input path and time out cloud POST - Pass opts.cloudUrl through launcher.launch in deployInternal so --cloud-url reaches the cloud launcher even when --input is not used. - Wrap the cloud deploy fetch in an AbortController with a 30s timeout so a stalled network request can no longer hang `workforce deploy` indefinitely. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/deploy.ts | 1 + packages/deploy/src/modes/cloud.ts | 51 ++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 198bf099..8b5abcd7 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -179,6 +179,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { bundle, workspace, io, + ...(opts.cloudUrl ? { cloudUrl: opts.cloudUrl } : {}), ...(opts.detach ? { detach: true } : {}), ...(opts.byoSandbox ? { byoSandbox: true } : {}) }); diff --git a/packages/deploy/src/modes/cloud.ts b/packages/deploy/src/modes/cloud.ts index 4f34478c..a581c592 100644 --- a/packages/deploy/src/modes/cloud.ts +++ b/packages/deploy/src/modes/cloud.ts @@ -33,30 +33,47 @@ export const cloudLauncher: ModeLauncher = { } }; +const CLOUD_DEPLOY_TIMEOUT_MS = 30_000; + async function postCloudDeployment( input: ModeLaunchInput, cloudUrl: string, workspaceToken: string ): Promise { - const res = await fetch( - `${cloudUrl}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/deployments`, - { - method: 'POST', - headers: { - authorization: `Bearer ${workspaceToken}`, - 'content-type': 'application/json' - }, - body: JSON.stringify({ - persona: input.persona, - bundle: { - runner: await readFile(input.bundle.runnerPath, 'utf8'), - agent: await readFile(input.bundle.bundlePath, 'utf8'), - packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8')) + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CLOUD_DEPLOY_TIMEOUT_MS); + let res: Response; + try { + res = await fetch( + `${cloudUrl}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/deployments`, + { + method: 'POST', + headers: { + authorization: `Bearer ${workspaceToken}`, + 'content-type': 'application/json' }, - ...(input.inputs && Object.keys(input.inputs).length > 0 ? { inputs: input.inputs } : {}) - }) + body: JSON.stringify({ + persona: input.persona, + bundle: { + runner: await readFile(input.bundle.runnerPath, 'utf8'), + agent: await readFile(input.bundle.bundlePath, 'utf8'), + packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8')) + }, + ...(input.inputs && Object.keys(input.inputs).length > 0 ? { inputs: input.inputs } : {}) + }), + signal: controller.signal + } + ); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error( + `Cloud deploy failed: request timed out after ${CLOUD_DEPLOY_TIMEOUT_MS / 1000}s` + ); } - ); + throw err; + } finally { + clearTimeout(timeout); + } if (!res.ok) { throw new Error(`Cloud deploy failed: ${res.status} ${await res.text()}`); }