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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ One-click deploy buttons for these live at the top of [`examples/`](examples/).
`--on` takes `github`, `linear`, `jira`, `shortcut` or `slack` with optional filters
(`github:labels=agent`, `jira:project=OPS`, `slack:channel=#eng`). `flows deployments` lists what is
listening; `flows undeploy <id>` stops it. Sign in once with `agent-relay cloud login`.
To ship a change to a deployed flow, run `flows deploy <file> --flow <name>`: it makes the next
version and keeps the listener's settings. `flows versions` and `flows rollback` show and move it.

# How Can I Run It?

Expand Down
26 changes: 26 additions & 0 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,32 @@ flows undeploy <deployment-id>
Optional flags: `--agents claude,codex`, `--name "Issue triage"`, `--draft`,
`--no-connect`, `--json`, and further `--on` sources.

### Versions: changing a deployed flow's source

A deployed flow's source is immutable. Changing it makes the next version, and
the listener points at it from then on. Its repository, triggers, approver,
agents and run budget stay as they are:

```sh
flows deploy issue-triage.flow.ts --flow 'Issue triage' # or --flow <listener-id>
# DEPLOYED <id> listening · version 4 (was 3)
flows versions 'Issue triage' # newest first, the active one marked
flows rollback 'Issue triage' 2 # move the pointer; nothing is rewritten
```

`--flow` takes the flow's name in the current workspace or its listener id.
`--repo`, `--on`, `--approver`, `--agents`, `--name` and `--draft` are refused
beside it, by name, because they belong to the listener rather than to a
version; change those in the Cloud dashboard. The create form redeployed
under the same name, by the same owner and with the same settings, also makes
a new version instead of refusing.

Bytes that match an earlier version re-activate that version rather than
adding one: `DEPLOYED <id> listening · re-activated version 2 (was 4; active
version went down)`. Identical bytes report `version 4 (unchanged)`. New runs
launch on the active version, and runs already started, including ones parked
on `f.human`, finish on the version they started with.

`flows deploy <flow.ts>` is the CLI form of the agentrelay.com onboarding's
deploy wizard: `POST /api/v1/flows/deploy` stores one self-contained authored
source and creates a proactive listener whose watch rules match the chosen
Expand Down
18 changes: 18 additions & 0 deletions packages/sdk/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,29 @@ export const CLI_VERBS = [
{ flags: '--name <name>', description: 'Name for the hosted listener' },
{ flags: '--draft', description: 'Create the listener without activating it' },
{ flags: '--plugin <ref>', description: 'Send-only GitHub flow-extension ref; repeatable. Does not write flows.json' },
{ flags: '--flow <name|listener-id>', description: 'Deploy the source as the next version of this flow; its settings stay unchanged' },
NO_CONNECT_OPTION,
JSON_OPTION,
],
variants: ['deploy', 'cloud-deploy'],
},
{
name: 'versions',
description: 'List a hosted flow’s source versions, the active one marked',
args: [{ name: 'flow', description: 'Flow name or listener id', required: true }],
options: [JSON_OPTION],
variants: ['versions'],
},
{
name: 'rollback',
description: 'Make an earlier (or later) recorded version of a hosted flow the active one',
args: [
{ name: 'flow', description: 'Flow name or listener id', required: true },
{ name: 'version', description: 'Version number to activate', required: true },
],
options: [JSON_OPTION],
variants: ['rollback'],
},
{
name: 'deployments',
description: 'List this workspace’s hosted trigger listeners',
Expand Down
15 changes: 15 additions & 0 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { checkTypeScriptFlow } from './cli/check-typescript.js';
import { runCloudCli } from './cli/cloud-run.js';
import { runCloudSyncCli } from './cli/cloud-sync.js';
import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
import { runCloudRollbackCli, runCloudVersionsCli } from './cli/cloud-versions.js';
import { parseCloudScheduleArgs, runCloudScheduleCli, runCloudSchedulesCli, runCloudUnscheduleCli, type CloudScheduleArgs } from './cli/cloud-schedule.js';
import { isAuthoredFlowPath } from './direct-input.js';
import { parseDeployArgs, runDeploy, type DeployArgs } from './cli/deploy.js';
Expand Down Expand Up @@ -97,6 +98,8 @@ export type ParsedArgs =
| CloudDeployArgs
| { command: 'deployments'; json: boolean }
| { command: 'undeploy'; agentId: string; json: boolean }
| { command: 'versions'; flow: string; json: boolean }
| { command: 'rollback'; flow: string; version: string; json: boolean }
| CloudScheduleArgs
| { command: 'schedules'; json: boolean }
| { command: 'unschedule'; scheduleId: string; json: boolean }
Expand All @@ -123,7 +126,10 @@ const USAGE = [
'flows build [--out <dir>] <flow.yaml|flow.ts>',
'flows build --verify <bundle-dir>',
'flows deploy <flow.ts> --repo <owner/name> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
'flows deploy <flow.ts> --flow <name|listener-id> [--plugin <ref>] [--no-connect] [--json]',
'flows deployments [--json]',
'flows versions [--json] <name|listener-id>',
'flows rollback [--json] <name|listener-id> <version>',
'flows undeploy [--json] <deployment-id>',
'flows schedule <flow.yaml|flow.ts> [--cron "<expr>" | --every <n><s|m|h|d>] [--tz <IANA>] [--input <inline-json-or-file>] [--name <name>] [--no-connect] [--json]',
'flows schedules [--json]',
Expand Down Expand Up @@ -244,6 +250,8 @@ export async function runCli(
if (parsed.command === 'cloud-deploy') return runCloudDeployCli(parsed, io);
if (parsed.command === 'deployments') return runCloudDeploymentsCli(parsed, io);
if (parsed.command === 'undeploy') return runCloudUndeployCli(parsed, io);
if (parsed.command === 'versions') return runCloudVersionsCli(parsed, io);
if (parsed.command === 'rollback') return runCloudRollbackCli(parsed, io);
if (parsed.command === 'schedule') return runCloudScheduleCli(parsed, io);
if (parsed.command === 'schedules') return runCloudSchedulesCli(parsed, io);
if (parsed.command === 'unschedule') return runCloudUnscheduleCli(parsed, io);
Expand Down Expand Up @@ -631,6 +639,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
if (json > 1 || rest.length !== 1 || rest[0]!.startsWith('-')) return undefined;
return { command: 'undeploy', agentId: rest[0]!, json: json === 1 };
}
if (command === 'versions' || command === 'rollback') {
const rest = args.slice(1).filter(a => a !== '--json');
const json = args.length - 1 - rest.length;
if (json > 1 || rest.some(a => a.startsWith('-'))) return undefined;
if (command === 'versions') return rest.length === 1 ? { command, flow: rest[0]!, json: json === 1 } : undefined;
return rest.length === 2 ? { command, flow: rest[0]!, version: rest[1]!, json: json === 1 } : undefined;
}
if (command === 'deployments') {
const rest = args.slice(1);
if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--json')) return undefined;
Expand Down
28 changes: 21 additions & 7 deletions packages/sdk/src/cli/cloud-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import {
type FlowTriggerSource,
} from '../cloud-deploy.js';
import { describeFlowRequirements } from '../flow-requirements.js';
import { describeVersionChange } from '../cloud-versions-wire.js';
import { runCloudDeployVersionCli } from './cloud-versions.js';
import { cliConnectPrompt, flowRequirementsForPath, harnessRemedy } from './cloud-connect-cli.js';
import type { CliIo } from '../cli.js';

export interface CloudDeployArgs {
command: 'cloud-deploy';
value: string;
repo: string;
/** Update form: the flow (name or listener id) this source becomes the next version of. */
flow: string | undefined;
repo: string | undefined;
on: string[];
approver: string | undefined;
name: string | undefined;
Expand All @@ -25,13 +29,19 @@ export interface CloudDeployArgs {
/**
* `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
* --approver <handle> [--name <n>] [--agents <list>] [--draft] [--no-connect] [--json]`
* `flows deploy <flow.ts> --flow <name-or-listener-id> [--plugin <ref>] [--no-connect] [--json]`
*
* The `--flow` form makes the source the next version of an existing flow and
* changes nothing else; listener settings given beside it are refused when run,
* by name, rather than silently ignored.
*
* Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
* share nothing but the word: the digest form copies a sealed bundle into a
* file bucket, this one creates a hosted listener. The positional decides.
*/
export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs | undefined {
let value: string | undefined;
let flow: string | undefined;
let repo: string | undefined;
let approver: string | undefined;
let name: string | undefined;
Expand Down Expand Up @@ -72,11 +82,12 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
i += 1;
continue;
}
if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on') {
if (arg === '--repo' || arg === '--approver' || arg === '--name' || arg === '--on' || arg === '--flow') {
const next = args[i + 1];
if (next === undefined || next.startsWith('-')) return undefined;
i += 1;
if (arg === '--on') { on.push(next); continue; }
if (arg === '--flow') { if (flow !== undefined) return undefined; flow = next; continue; }
if (arg === '--repo') { if (repo !== undefined) return undefined; repo = next; continue; }
if (arg === '--approver') { if (approver !== undefined) return undefined; approver = next; continue; }
if (name !== undefined) return undefined;
Expand All @@ -86,8 +97,9 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
if (arg.startsWith('-') || value !== undefined) return undefined;
value = arg;
}
if (value === undefined || repo === undefined || on.length === 0) return undefined;
return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, noConnect, json, plugins };
if (value === undefined) return undefined;
if (flow === undefined && (repo === undefined || on.length === 0)) return undefined;
return { command: 'cloud-deploy', value, flow, repo, on, approver, name, agents, draft, noConnect, json, plugins };
}

function describeSource(source: FlowTriggerSource): string {
Expand All @@ -96,6 +108,7 @@ function describeSource(source: FlowTriggerSource): string {
}

export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
if (args.flow !== undefined) return runCloudDeployVersionCli({ ...args, flow: args.flow }, io);
const agents = args.agents === undefined ? undefined : parseAgentHarnessesOr(args.agents);
// What the source declares, so a harness refusal names the right remedy
// even when `--agents` was not given; the loader's own failure is reported
Expand All @@ -111,7 +124,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
const deployment = await deployToCloud({
path: args.value,
repository: parseRepository(args.repo),
repository: parseRepository(args.repo!),
sources: args.on.map(parseTriggerSource),
approver: args.approver,
draft: args.draft,
Expand All @@ -124,7 +137,8 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
io.stdout(JSON.stringify({ ok: true, ...deployment }));
return 0;
}
io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`);
io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`
+ (deployment.version === undefined ? '' : ` · ${describeVersionChange(deployment.version)}`));
io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
Expand Down Expand Up @@ -181,7 +195,7 @@ export async function runCloudUndeployCli({ agentId, json }: { agentId: string;
}
}

function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
export function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
// Cloud names the missing coding-agent credential on activation; say how it is connected.
Expand Down
103 changes: 103 additions & 0 deletions packages/sdk/src/cli/cloud-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { CloudFlowError } from '../cloud-http.js';
import {
activateCloudFlowVersion, deployVersionToCloud, getCloudListener, resolveCloudFlow,
} from '../cloud-versions.js';
import { describeVersionChange } from '../cloud-versions-wire.js';
import { describeFlowRequirements } from '../flow-requirements.js';
import { cliConnectPrompt } from './cloud-connect-cli.js';
import { reportCloudFailure, type CloudDeployArgs } from './cloud-deploy.js';
import type { CliIo } from '../cli.js';

/**
* `flows deploy <flow.ts> --flow <name-or-id>`: the source becomes the next
* version of that flow. Listener settings are not part of a version, so a
* flag that would change one is refused by name instead of being dropped.
*/
export async function runCloudDeployVersionCli(
args: CloudDeployArgs & { flow: string }, io: CliIo,
): Promise<0 | 1 | 2> {
try {
const settings = [
args.repo === undefined ? undefined : '--repo',
args.on.length === 0 ? undefined : '--on',
args.approver === undefined ? undefined : '--approver',
args.name === undefined ? undefined : '--name',
args.agents === undefined ? undefined : '--agents',
args.draft ? '--draft' : undefined,
].filter((flag): flag is string => flag !== undefined);
if (settings.length > 0) {
throw new CloudFlowError('invalid_input',
`--flow deploys a new version of the flow's source only; ${settings.join(', ')} stay on the listener. `
+ 'Drop them, or change them in the Cloud dashboard.');
}
const connect = cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json });
const deployment = await deployVersionToCloud({
path: args.value,
flow: args.flow,
...(connect === undefined ? {} : { connect }),
...(args.plugins.length === 0 ? {} : { plugins: args.plugins }),
});
if (args.json) {
io.stdout(JSON.stringify({ ok: true, ...deployment }));
return 0;
}
io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`
+ ` · ${describeVersionChange(deployment.version)}`);
io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
const requires = describeFlowRequirements(deployment.requirements);
if (requires) io.stdout(` requires: ${requires}`);
for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
io.stdout('New runs launch this version; runs already started finish on theirs. History: flows versions '
+ JSON.stringify(deployment.name));
return 0;
} catch (error) {
return reportCloudFailure(error, args.json, io);
}
}

/** `flows versions <name-or-id>`: newest first, the active one marked. */
export async function runCloudVersionsCli(
{ flow, json }: { flow: string; json: boolean }, io: CliIo,
): Promise<0 | 1 | 2> {
try {
const listener = await getCloudListener(await resolveCloudFlow(flow));
if (json) {
io.stdout(JSON.stringify({
ok: true, agentId: listener.agentId, name: listener.name,
activeVersion: listener.activeVersion?.version ?? null, versions: listener.versions,
}));
return 0;
}
if (listener.versions.length === 0) {
io.stdout(`${listener.agentId} ${JSON.stringify(listener.name)} has no recorded versions.`);
return 0;
}
io.stdout(`${listener.agentId} ${JSON.stringify(listener.name)} ${listener.status}`);
for (const version of listener.versions) {
const active = version.version === listener.activeVersion?.version ? ' (active)' : '';
io.stdout(` version ${version.version}${active} ${version.createdAt} ${version.origin} sha256 ${version.sourceSha256.slice(0, 12)}`);
}
return 0;
} catch (error) {
return reportCloudFailure(error, json, io);
}
}

/** `flows rollback <name-or-id> <version>`: move the pointer; later versions stay recorded. */
export async function runCloudRollbackCli(
{ flow, version, json }: { flow: string; version: string; json: boolean }, io: CliIo,
): Promise<0 | 1 | 2> {
try {
// Cloud numbers versions up to nine digits; anything longer cannot exist.
if (!/^[1-9][0-9]{0,8}$/u.test(version)) {
throw new CloudFlowError('invalid_input', `A version is a positive whole number, got "${version}".`);
}
const result = await activateCloudFlowVersion(flow, Number(version));
io.stdout(json
? JSON.stringify({ ok: true, ...result })
: `ACTIVATED ${result.agentId} ${result.status} · ${describeVersionChange(result.version)}`);
return 0;
} catch (error) {
return reportCloudFailure(error, json, io);
}
}
Loading
Loading