Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/plans/deploy-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<KEY>`. In `--mode cloud`, the same map is sent in the deployment POST body as `inputs`.

---

## 4. Runtime substrate — `@agentworkforce/runtime`
Expand Down Expand Up @@ -282,11 +286,13 @@ workforce deploy <persona-path>
[--detach] # background the runner
[--bundle-out <dir>] # emit bundle without launching
[--dry-run] # validate only
[--input <key>=<value>] # 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_<KEY>` 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 <name>` 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.
Expand Down
73 changes: 73 additions & 0 deletions packages/cli/src/deploy-command.test.ts
Original file line number Diff line number Diff line change
@@ -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 <key>=<value>; got "foo"/);
} finally {
trap.restore();
}
});
25 changes: 24 additions & 1 deletion packages/cli/src/deploy-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Flags:
--bundle-out <dir> Emit the bundle to <dir> and exit (no launch)
--dry-run Validate the persona and exit before any side effects
--cloud-url <url> Override the workforce cloud base URL
--input <key>=<value> Override a declared persona input (repeatable)
-h, --help Print this message
`;

Expand All @@ -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<string, string> = {};

for (let i = 0; i < args.length; i += 1) {
const a = args[i];
Expand All @@ -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) {
Expand All @@ -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 <key>=<value>');
}
return value;
}

function parseDeployInputValue(raw: string, inputs: Record<string, string>): void {
const eq = raw.indexOf('=');
if (eq <= 0) {
die(`--input: expected <key>=<value>; 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`);
Expand Down
1 change: 1 addition & 0 deletions packages/deploy/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {})
});
Expand Down
93 changes: 91 additions & 2 deletions packages/deploy/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<DeployResult> {
const inputs = opts.inputs && Object.keys(opts.inputs).length > 0 ? opts.inputs : undefined;
if (!inputs) return deployInternal(opts, resolvers);
Comment on lines +53 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 --cloud-url CLI flag silently ignored when no --input flags are provided

The new deploy() wrapper in index.ts only forwards opts.cloudUrl to the cloud launcher through wrapLauncher (line 110), which is only invoked when inputs are present (line 58). When there are no inputs, the wrapper calls deployInternal(opts, resolvers) directly, and deployInternal at packages/deploy/src/deploy.ts:177-183 never passes cloudUrl in the launcher.launch(...) call. As a result, the cloud launcher at packages/deploy/src/modes/cloud.ts:25 sees input.cloudUrl as undefined and falls back to process.env.WORKFORCE_CLOUD_URL. This means workforce deploy persona.json --mode cloud --cloud-url https://custom.example.com (without --input) silently ignores --cloud-url, and if WORKFORCE_CLOUD_URL env var is not set, the deployment fails with the "not yet available" error despite a valid cloud URL being specified.

Prompt for agents
The deploy() wrapper in packages/deploy/src/index.ts only wraps launchers (to inject cloudUrl into ModeLaunchInput) when inputs are present. When no inputs are provided, it calls deployInternal directly, and deployInternal in packages/deploy/src/deploy.ts:177-183 does not pass opts.cloudUrl to the launcher.

The cloud launcher (packages/deploy/src/modes/cloud.ts:25) reads input.cloudUrl as its primary source, falling back to env var. So --cloud-url is silently lost in the no-inputs path.

Two possible approaches:
1. Have deployInternal (deploy.ts) pass cloudUrl to launcher.launch() from opts.cloudUrl, so it always reaches launchers regardless of the wrapper.
2. Ensure the deploy() wrapper in index.ts also wraps launchers for cloudUrl when opts.cloudUrl is set, not just when inputs are present. This would mean the wrapper calls wrapInputResolvers even when inputs are empty but cloudUrl is present.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const preflight = await preflightPersona(opts.personaPath);
validateDeployInputs(inputs, preflight.persona.inputs);
return deployInternal(opts, wrapInputResolvers(resolvers, inputs, opts.cloudUrl));
}

function validateDeployInputs(
inputs: Record<string, string>,
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<ReturnType<typeof preflightPersona>>['persona']['inputs']
> | undefined;

function wrapInputResolvers(
resolvers: DeployResolvers,
inputs: Record<string, string>,
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<string, string>,
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<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(inputs).map(([key, value]) => [`${INPUT_ENV_PREFIX}${key}`, value])
);
}
71 changes: 70 additions & 1 deletion packages/deploy/src/modes/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFile } from 'node:fs/promises';
import type {
ModeLaunchInput,
ModeLaunchHandle,
Expand All @@ -20,9 +21,77 @@ import type {
* 4. Return a handle whose `stop()` calls DELETE on the deployment.
*/
export const cloudLauncher: ModeLauncher = {
async launch(_input: ModeLaunchInput): Promise<ModeLaunchHandle> {
async launch(input: ModeLaunchInput): Promise<ModeLaunchHandle> {
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.'
);
}
};

const CLOUD_DEPLOY_TIMEOUT_MS = 30_000;

async function postCloudDeployment(
input: ModeLaunchInput,
cloudUrl: string,
workspaceToken: string
): Promise<ModeLaunchHandle> {
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'
},
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()}`);
}
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 })
};
}
Loading
Loading