diff --git a/README.md b/README.md index ba59bab97..d8aa64c2d 100644 --- a/README.md +++ b/README.md @@ -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 ` stops it. Sign in once with `agent-relay cloud login`. +To ship a change to a deployed flow, run `flows deploy --flow `: 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? diff --git a/docs/CLOUD.md b/docs/CLOUD.md index 6479ffd1e..20f1a8473 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -544,6 +544,32 @@ flows undeploy 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 +# DEPLOYED 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 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 ` 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 diff --git a/packages/sdk/src/cli-commands.ts b/packages/sdk/src/cli-commands.ts index 7b2025be0..001c51d96 100644 --- a/packages/sdk/src/cli-commands.ts +++ b/packages/sdk/src/cli-commands.ts @@ -163,11 +163,29 @@ export const CLI_VERBS = [ { flags: '--name ', description: 'Name for the hosted listener' }, { flags: '--draft', description: 'Create the listener without activating it' }, { flags: '--plugin ', description: 'Send-only GitHub flow-extension ref; repeatable. Does not write flows.json' }, + { flags: '--flow ', 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', diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 46ca42ab5..b1cf680b2 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -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'; @@ -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 } @@ -123,7 +126,10 @@ const USAGE = [ 'flows build [--out ] ', 'flows build --verify ', 'flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--plugin ] [--no-connect] [--json]', + 'flows deploy --flow [--plugin ] [--no-connect] [--json]', 'flows deployments [--json]', + 'flows versions [--json] ', + 'flows rollback [--json] ', 'flows undeploy [--json] ', 'flows schedule [--cron "" | --every ] [--tz ] [--input ] [--name ] [--no-connect] [--json]', 'flows schedules [--json]', @@ -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); @@ -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; diff --git a/packages/sdk/src/cli/cloud-deploy.ts b/packages/sdk/src/cli/cloud-deploy.ts index 53bc522b7..60971ee52 100644 --- a/packages/sdk/src/cli/cloud-deploy.ts +++ b/packages/sdk/src/cli/cloud-deploy.ts @@ -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; @@ -25,6 +29,11 @@ export interface CloudDeployArgs { /** * `flows deploy --repo --on [:k=v,…] [--on …] * --approver [--name ] [--agents ] [--draft] [--no-connect] [--json]` + * `flows deploy --flow [--plugin ] [--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 @@ -32,6 +41,7 @@ export interface CloudDeployArgs { */ 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; @@ -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; @@ -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 { @@ -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 @@ -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, @@ -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)}`); @@ -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. diff --git a/packages/sdk/src/cli/cloud-versions.ts b/packages/sdk/src/cli/cloud-versions.ts new file mode 100644 index 000000000..1d7ce4229 --- /dev/null +++ b/packages/sdk/src/cli/cloud-versions.ts @@ -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 `: 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 `: 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 `: 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); + } +} diff --git a/packages/sdk/src/cloud-deploy.ts b/packages/sdk/src/cloud-deploy.ts index d132039cb..31bfb38f7 100644 --- a/packages/sdk/src/cloud-deploy.ts +++ b/packages/sdk/src/cloud-deploy.ts @@ -11,6 +11,7 @@ import { } from './flow-requirements.js'; import { readProjectConfig } from './cli/check.js'; import { assertNoUseDependencies, collectExtensionSubmissions } from './flow-extension-submit.js'; +import { parseVersionChange, type CloudFlowVersionChange } from './cloud-versions-wire.js'; /** * Hosted listener deployment: the CLI form of the agentrelay.com onboarding's @@ -104,6 +105,8 @@ export interface CloudDeployment { requirements: FlowRequirements; /** Integrations connected through the prompt during this deploy. */ connected: string[]; + /** The listener's active version after this deploy; absent from a Cloud without versions. */ + version?: CloudFlowVersionChange; } export function parseRepository(value: string): { owner: string; name: string } { @@ -165,9 +168,18 @@ export function parseTriggerSource(value: string): FlowTriggerSource { return { provider: provider as FlowTriggerProvider, settings }; } -export async function deployToCloud( - input: DeployToCloudInput, options: CloudConnectionOptions = {}, -): Promise { +/** One authored source, read and loaded the way every hosted deploy sends it. */ +export interface DeploySource { + bytes: Buffer; + source: string; + definition: ReturnType>['getDefinition']>; + extensions: Awaited>; + projectCli: string | undefined; +} + +export async function loadDeploySource( + input: { path: string; plugins?: readonly string[] }, +): Promise { if (!/\.flow\.ts$/iu.test(input.path)) { throw new CloudFlowError('unsupported_source', 'flows deploy takes one authored .flow.ts source.'); } @@ -204,6 +216,13 @@ export async function deployToCloud( } catch { projectCli = undefined; } + return { bytes, source, definition, extensions, projectCli }; +} + +export async function deployToCloud( + input: DeployToCloudInput, options: CloudConnectionOptions = {}, +): Promise { + const { bytes, source, definition, extensions, projectCli } = await loadDeploySource(input); if (input.sources.length === 0 || input.sources.length > 10) { throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.'); } @@ -274,11 +293,13 @@ export async function deployToCloud( if (!isCloudRecord(result) || typeof result.agentId !== 'string' || typeof result.status !== 'string') { throw new CloudFlowError('invalid_response', 'Cloud did not return a deployment.'); } + const version = parseVersionChange(result.version); return { agentId: result.agentId, name, status: result.status, repository: input.repository, sources, sourceSha256: createHash('sha256').update(bytes).digest('hex'), requirements, connected, + ...(version === undefined ? {} : { version }), }; } diff --git a/packages/sdk/src/cloud-versions-wire.ts b/packages/sdk/src/cloud-versions-wire.ts new file mode 100644 index 000000000..b499e8973 --- /dev/null +++ b/packages/sdk/src/cloud-versions-wire.ts @@ -0,0 +1,58 @@ +import { isCloudRecord } from './cloud-http.js'; + +/** + * What a Cloud write did to a listener's active version (cloud#4115). + * `reactivated`: the bytes match an earlier version, so the pointer moved to + * it without a new row, and `version` can be LOWER than `previousVersion`. + */ +export interface CloudFlowVersionChange { + version: number; + previousVersion: number | null; + change: 'created' | 'reactivated' | 'unchanged'; +} + +export interface CloudFlowVersion { + version: number; + sourceSha256: string; + origin: string; + createdAt: string; +} + +const CHANGES = ['created', 'reactivated', 'unchanged'] as const; + +function isVersionNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1; +} + +/** Undefined for a Cloud that predates versions or a malformed field. */ +export function parseVersionChange(value: unknown): CloudFlowVersionChange | undefined { + if (!isCloudRecord(value) || !isVersionNumber(value.version) + || !(value.previousVersion === null || isVersionNumber(value.previousVersion)) + || !(CHANGES as readonly unknown[]).includes(value.change)) return undefined; + return { + version: value.version, + previousVersion: value.previousVersion as number | null, + change: value.change as CloudFlowVersionChange['change'], + }; +} + +export function parseVersion(value: unknown): CloudFlowVersion | undefined { + if (!isCloudRecord(value) || !isVersionNumber(value.version) || typeof value.sourceSha256 !== 'string' + || typeof value.origin !== 'string' || typeof value.createdAt !== 'string') return undefined; + return { version: value.version, sourceSha256: value.sourceSha256, origin: value.origin, createdAt: value.createdAt }; +} + +/** + * `version 4 (was 3)`; a rollback by content says the number went down, so + * nobody reads v2-after-v3 as a failed deploy. + */ +export function describeVersionChange(change: CloudFlowVersionChange): string { + if (change.change === 'unchanged') return `version ${change.version} (unchanged)`; + const was = change.previousVersion === null ? '' : ` (was ${change.previousVersion}`; + if (change.change === 'reactivated') { + const down = change.previousVersion !== null && change.version < change.previousVersion + ? '; active version went down' : ''; + return `re-activated version ${change.version}${was}${down}${was ? ')' : ''}`; + } + return `version ${change.version}${was}${was ? ')' : ''}`; +} diff --git a/packages/sdk/src/cloud-versions.ts b/packages/sdk/src/cloud-versions.ts new file mode 100644 index 000000000..4af98098b --- /dev/null +++ b/packages/sdk/src/cloud-versions.ts @@ -0,0 +1,179 @@ +import { createHash } from 'node:crypto'; +import { ensureIntegrationsConnected, type ConnectPrompt } from './cloud-connect.js'; +import { + CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions, +} from './cloud-http.js'; +import { + FLOW_AGENT_HARNESSES, listCloudDeployments, loadDeploySource, type FlowTriggerSource, +} from './cloud-deploy.js'; +import { + parseVersion, parseVersionChange, type CloudFlowVersion, type CloudFlowVersionChange, +} from './cloud-versions-wire.js'; +import { flowRequirements, mergeFlowExtensionRequirements, type FlowRequirements } from './flow-requirements.js'; + +/** + * Versions of a hosted listener (AgentWorkforce/cloud#4115). A deployed + * flow's source is immutable: `flows deploy --flow ` makes + * the next version and moves the listener's pointer to it, leaving the + * listener's repository, triggers, approver, agents and run budget untouched; + * `flows rollback` moves the pointer to any recorded version. + */ + +const LISTENER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +export interface CloudListener { + agentId: string; + name: string; + status: string; + repository: { owner: string; name: string; host?: 'gitlab' }; + sources: FlowTriggerSource[]; + activeVersion: CloudFlowVersion | null; + versions: CloudFlowVersion[]; +} + +/** A listener id as given, else the one deployment in the workspace with that name. */ +export async function resolveCloudFlow(flow: string, options: CloudConnectionOptions = {}): Promise { + const wanted = flow.trim(); + if (!wanted) throw new CloudFlowError('invalid_input', '--flow needs a flow name or listener id.'); + if (LISTENER_UUID.test(wanted)) return wanted.toLowerCase(); + const deployments = await listCloudDeployments(options); + // Any listener id the workspace lists, before names: ids are not all uuids. + const byId = deployments.find(deployment => deployment.agentId === wanted); + if (byId) return byId.agentId; + const matches = deployments.filter(deployment => deployment.name.toLowerCase() === wanted.toLowerCase()); + if (matches.length === 1) return matches[0]!.agentId; + if (matches.length === 0) { + throw new CloudFlowError('invalid_input', `No flow named "${wanted}" in this workspace; list them with: flows deployments`); + } + throw new CloudFlowError('invalid_input', + `More than one flow is named "${wanted}" (${matches.map(match => match.agentId).join(', ')}); pass the listener id.`); +} + +export async function getCloudListener(agentId: string, options: CloudConnectionOptions = {}): Promise { + const payload = await cloudRequest(`/api/v1/flows/listeners/${encodeURIComponent(agentId)}`, options); + const listener = isCloudRecord(payload) && isCloudRecord(payload.listener) ? payload.listener : undefined; + if (!listener || typeof listener.name !== 'string' || typeof listener.status !== 'string' + || !isCloudRecord(listener.repository) || typeof listener.repository.owner !== 'string' + || typeof listener.repository.name !== 'string' || !Array.isArray(listener.sources)) { + throw new CloudFlowError('invalid_response', 'Cloud did not return the flow listener.'); + } + const versions = isCloudRecord(payload) && Array.isArray(payload.versions) + ? payload.versions.flatMap(entry => parseVersion(entry) ?? []) : []; + return { + agentId, + name: listener.name, + status: listener.status, + repository: { + owner: listener.repository.owner, name: listener.repository.name, + ...(listener.repository.host === 'gitlab' ? { host: 'gitlab' as const } : {}), + }, + sources: listener.sources.flatMap((entry): FlowTriggerSource[] => isCloudRecord(entry) && typeof entry.provider === 'string' + ? [{ provider: entry.provider as FlowTriggerSource['provider'], + settings: Object.fromEntries(Object.entries(isCloudRecord(entry.settings) ? entry.settings : {}) + .flatMap(([key, value]) => typeof value === 'string' ? [[key, value]] : typeof value === 'boolean' ? [[key, String(value)]] : [])) }] + : []), + activeVersion: isCloudRecord(payload) ? parseVersion(payload.activeVersion) ?? null : null, + versions, + }; +} + +export interface DeployVersionInput { + path: string; + /** The flow's deployed name or its listener id. */ + flow: string; + plugins?: readonly string[]; + connect?: ConnectPrompt; + checkConnections?: boolean; +} + +export interface CloudVersionDeployment { + agentId: string; + name: string; + status: string; + sourceSha256: string; + requirements: FlowRequirements; + connected: string[]; + version: CloudFlowVersionChange; +} + +/** `POST /api/v1/flows/listeners//versions`: the source alone becomes the next version. */ +export async function deployVersionToCloud( + input: DeployVersionInput, options: CloudConnectionOptions = {}, +): Promise { + const { bytes, source, definition, extensions, projectCli } = await loadDeploySource(input); + const agentId = await resolveCloudFlow(input.flow, options); + const listener = await getCloudListener(agentId, options); + options.signal?.throwIfAborted(); + // Requirements derive from the source, read against the listener's own + // repository and triggers, exactly as the create form reads its flags. + const requirements = mergeFlowExtensionRequirements( + flowRequirements(definition, { + sources: listener.sources, + // A GitHub repository means every run needs GitHub; a GitLab project does not. + ...(listener.repository.host === 'gitlab' ? {} : { repository: listener.repository }), + ...(projectCli === undefined ? {} : { projectCli }), + }), + extensions.map(extension => ({ name: extension.name, permissions: extension.manifest.permissions })), + ); + // As on create: a harness Cloud cannot run is refused here, not deployed. + // There is no --agents override beside --flow; the listener's agents stay. + const unsupported = requirements.harnessUses + .filter(use => !(FLOW_AGENT_HARNESSES as readonly string[]).includes(use.harness)); + if (unsupported.length > 0) { + throw new CloudFlowError('unsupported_source', + `This flow declares ${unsupported.map(use => `${use.harness} (${use.detail})`).join(', ')}, which Cloud deployments cannot run yet; ` + + `Cloud runs ${FLOW_AGENT_HARNESSES.join(' and ')}. Change the declaration before deploying a new version.`); + } + const whoami = await cloudRequest('/api/v1/auth/whoami', options); + const workspace = isCloudRecord(whoami) && isCloudRecord(whoami.currentWorkspace) ? whoami.currentWorkspace : undefined; + if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) { + throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.'); + } + // A draft activates nothing, so Cloud checks nothing; match it here. + const connected = listener.status !== 'listening' || input.checkConnections === false + ? [] + : (await ensureIntegrationsConnected(requirements, { + ...options, workspaceId: workspace.id, ...(input.connect === undefined ? {} : { prompt: input.connect }), + })).connected; + options.signal?.throwIfAborted(); + const result = await cloudFetch(`/api/v1/flows/listeners/${encodeURIComponent(agentId)}/versions`, options, { + method: 'POST', detail: true, body: JSON.stringify({ + workspaceId: workspace.id, + source, + requirements: { + integrations: requirements.integrations.map(integration => integration.provider), + harnesses: requirements.harnesses, + mcp: requirements.mcp, + }, + ...(extensions.length === 0 ? {} : { extensions }), + }), + }); + const version = isCloudRecord(result) ? parseVersionChange(result.version) : undefined; + if (!isCloudRecord(result) || typeof result.status !== 'string' || version === undefined) { + throw new CloudFlowError('invalid_response', 'Cloud did not return the new flow version.'); + } + return { + agentId, name: listener.name, status: result.status, + sourceSha256: createHash('sha256').update(bytes).digest('hex'), + requirements, connected, version, + }; +} + +/** `POST /api/v1/flows/listeners//versions//activate`: history is kept; only the pointer moves. */ +export async function activateCloudFlowVersion( + flow: string, version: number, options: CloudConnectionOptions = {}, +): Promise<{ agentId: string; status: string; version: CloudFlowVersionChange }> { + if (!Number.isSafeInteger(version) || version < 1) { + throw new CloudFlowError('invalid_input', `A version is a positive whole number, got "${version}".`); + } + const agentId = await resolveCloudFlow(flow, options); + const result = await cloudFetch( + `/api/v1/flows/listeners/${encodeURIComponent(agentId)}/versions/${version}/activate`, + options, { method: 'POST', detail: true }, + ); + const change = isCloudRecord(result) ? parseVersionChange(result.version) : undefined; + if (!isCloudRecord(result) || typeof result.status !== 'string' || change === undefined) { + throw new CloudFlowError('invalid_response', 'Cloud did not confirm the active version.'); + } + return { agentId, status: result.status, version: change }; +} diff --git a/packages/sdk/tests/cloud-deploy.test.ts b/packages/sdk/tests/cloud-deploy.test.ts index badce1dea..74e0ccbcb 100644 --- a/packages/sdk/tests/cloud-deploy.test.ts +++ b/packages/sdk/tests/cloud-deploy.test.ts @@ -358,3 +358,148 @@ describe('flows undeploy', () => { expect(fetch).not.toHaveBeenCalled(); }); }); + +describe('flow versions (cloud#4115)', () => { + const LISTENER = '11111111-1111-4111-8111-111111111111'; + const LISTENER_DETAIL = { + listener: { + listenerId: LISTENER, name: 'Cloud Software Garden', status: 'listening', + repository: { owner: 'acme', name: 'web' }, + sources: [{ provider: 'github', settings: { repository: 'acme/web', labels: 'agent' } }], + }, + activeVersion: { id: 'v3', version: 3, sourceSha256: 'c'.repeat(64), origin: 'cli', createdAt: '2026-10-02T09:00:00.000Z' }, + versions: [ + { id: 'v3', version: 3, sourceSha256: 'c'.repeat(64), origin: 'cli', createdAt: '2026-10-02T09:00:00.000Z' }, + { id: 'v2', version: 2, sourceSha256: 'b'.repeat(64), origin: 'dashboard', createdAt: '2026-10-01T09:00:00.000Z' }, + ], + }; + const DEPLOYMENTS = { deployments: [ + { agentId: LISTENER, name: 'Cloud Software Garden', status: 'listening', sources: [] }, + { agentId: '22222222-2222-4222-8222-222222222222', name: 'Other', status: 'listening', sources: [] }, + ] }; + function io() { + const out: string[] = []; + return { out, io: { stdout: (line: string) => out.push(line), stderr: (line: string) => out.push(`ERR ${line}`) } }; + } + + it('deploys the next version by flow name without --repo, --on or --approver', async () => { + const path = await authoredFlow('garden'); + const calls = cloud({ + '/api/v1/agents/flow-deployments': () => DEPLOYMENTS, + [`/api/v1/flows/listeners/${LISTENER}`]: () => LISTENER_DETAIL, + '/api/v1/auth/whoami': () => WHOAMI, + [`/api/v1/flows/listeners/${LISTENER}/versions`]: () => ({ + listenerId: LISTENER, status: 'listening', version: { versionId: 'v4', version: 4, previousVersion: 3, change: 'created' }, + }), + }); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--flow', 'cloud software garden'], cliIo), out.join('\n')).toBe(0); + expect(out[0]).toBe(`DEPLOYED ${LISTENER} listening · version 4 (was 3)`); + const post = calls.find(call => call.method === 'POST' && call.path.endsWith('/versions'))!; + // Only the source and what derives from it: no listener settings. + expect(Object.keys(post.body as object).sort()).toEqual(['requirements', 'source', 'workspaceId']); + expect(calls.some(call => call.path === '/api/v1/flows/deploy')).toBe(false); + }); + + it('takes a listener id directly and says when the active version went down', async () => { + const path = await authoredFlow('garden'); + const calls = cloud({ + [`/api/v1/flows/listeners/${LISTENER}`]: () => LISTENER_DETAIL, + '/api/v1/auth/whoami': () => WHOAMI, + [`/api/v1/flows/listeners/${LISTENER}/versions`]: () => ({ + listenerId: LISTENER, status: 'listening', version: { versionId: 'v2', version: 2, previousVersion: 4, change: 'reactivated' }, + }), + }); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--flow', LISTENER], cliIo), out.join('\n')).toBe(0); + expect(out[0]).toBe(`DEPLOYED ${LISTENER} listening · re-activated version 2 (was 4; active version went down)`); + expect(calls.some(call => call.path === '/api/v1/agents/flow-deployments')).toBe(false); + }); + + it('refuses listener settings beside --flow by name, before any request', async () => { + const path = await authoredFlow('garden'); + const calls = cloud({}); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--flow', 'x', '--repo', 'o/r', '--on', 'github', '--approver', 'k'], cliIo)).toBe(2); + expect(out[0]).toContain('--repo, --on, --approver stay on the listener'); + expect(calls).toHaveLength(0); + }); + + it('refuses a source declaring a harness Cloud cannot run, before posting a version', async () => { + const dir = await tempDir('cloud-deploy-gemini-'); + await symlink(join(process.cwd(), 'node_modules'), join(dir, 'node_modules'), 'dir'); + const path = join(dir, 'gemini.flow.ts'); + await writeFile(path, "import { flow } from '@relayflows/surface';\n" + + "export default flow('gemini', { budget: '$5/run' }, async (f) => {\n" + + " await f.agent('review', { cli: 'gemini', task: 'review' });\n f.done('success');\n});\n"); + const calls = cloud({ + [`/api/v1/flows/listeners/${LISTENER}`]: () => LISTENER_DETAIL, + '/api/v1/auth/whoami': () => WHOAMI, + }); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--flow', LISTENER], cliIo)).toBe(2); + expect(out[0]).toContain('declares gemini'); + expect(out[0]).toContain('Cloud deployments cannot run'); + expect(calls.some(call => call.method === 'POST')).toBe(false); + }); + + it('addresses a listener by a non-uuid id the workspace lists', async () => { + cloud({ + '/api/v1/agents/flow-deployments': () => ({ deployments: [{ agentId: 'agent-9', name: 'Garden', status: 'listening', sources: [] }] }), + '/api/v1/flows/listeners/agent-9/versions/2/activate': () => ({ + listenerId: 'agent-9', status: 'listening', version: { versionId: 'v2', version: 2, previousVersion: 3, change: 'reactivated' }, + }), + }); + const { out, io: cliIo } = io(); + expect(await runCli(['rollback', 'agent-9', '2'], cliIo), out.join('\n')).toBe(0); + expect(out[0]).toContain('ACTIVATED agent-9 listening'); + }); + + it('names an unknown or ambiguous flow', async () => { + const path = await authoredFlow('garden'); + cloud({ '/api/v1/agents/flow-deployments': () => ({ deployments: [ + ...DEPLOYMENTS.deployments, { agentId: '33333333-3333-4333-8333-333333333333', name: 'Other', status: 'draft', sources: [] }, + ] }) }); + const missing = io(); + expect(await runCli(['deploy', path, '--flow', 'Nope'], missing.io)).toBe(2); + expect(missing.out[0]).toContain('No flow named "Nope"'); + const ambiguous = io(); + expect(await runCli(['deploy', path, '--flow', 'other'], ambiguous.io)).toBe(2); + expect(ambiguous.out[0]).toContain('pass the listener id'); + }); + + it('reports the version a create-form deploy made', async () => { + const path = await authoredFlow('triage'); + cloud({ + '/api/v1/auth/whoami': () => WHOAMI, + '/api/v1/flows/deploy': () => ({ status: 201, body: { + agentId: 'agent-9', status: 'listening', version: { versionId: 'v1', version: 1, previousVersion: null, change: 'created' }, + } }), + }); + const { out, io: cliIo } = io(); + expect(await runCli(['deploy', path, '--repo', 'o/r', '--on', 'github', '--approver', 'k'], cliIo)).toBe(0); + expect(out[0]).toBe('DEPLOYED agent-9 listening · version 1'); + }); + + it('lists versions with the active one marked, and rolls back', async () => { + const calls = cloud({ + '/api/v1/agents/flow-deployments': () => DEPLOYMENTS, + [`/api/v1/flows/listeners/${LISTENER}`]: () => LISTENER_DETAIL, + [`/api/v1/flows/listeners/${LISTENER}/versions/2/activate`]: () => ({ + listenerId: LISTENER, status: 'listening', version: { versionId: 'v2', version: 2, previousVersion: 3, change: 'reactivated' }, + }), + }); + const listed = io(); + expect(await runCli(['versions', 'Cloud Software Garden'], listed.io)).toBe(0); + expect(listed.out[1]).toMatch(/^ {2}version 3 \(active\) /u); + expect(listed.out[2]).toMatch(/^ {2}version 2 {2}2026-10-01/u); + const rolled = io(); + expect(await runCli(['rollback', 'Cloud Software Garden', '2'], rolled.io)).toBe(0); + expect(rolled.out[0]).toBe(`ACTIVATED ${LISTENER} listening · re-activated version 2 (was 3; active version went down)`); + expect(calls.at(-1)).toMatchObject({ method: 'POST', path: `/api/v1/flows/listeners/${LISTENER}/versions/2/activate` }); + for (const version of ['two', '0', '9007199254740993']) { + const bad = io(); + expect(await runCli(['rollback', 'Cloud Software Garden', version], bad.io)).toBe(2); + } + }); +}); diff --git a/packages/sdk/tests/relay-cli-surface.test.ts b/packages/sdk/tests/relay-cli-surface.test.ts index 7b74dfc1a..95f12139b 100644 --- a/packages/sdk/tests/relay-cli-surface.test.ts +++ b/packages/sdk/tests/relay-cli-surface.test.ts @@ -93,7 +93,18 @@ const INVOCATIONS: readonly { verb: string; argv: readonly string[]; variant: Pa '--plugin', 'github:o/r@main#path', '--no-connect', '--json'], variant: 'cloud-deploy', }, + // The update form: the source becomes the next version of an existing flow. + { verb: 'deploy', argv: ['deploy', 'review.flow.ts', '--flow', 'review-listener'], variant: 'cloud-deploy' }, + { + verb: 'deploy', + argv: ['deploy', 'review.flow.ts', '--flow', 'review-listener', '--plugin', 'github:o/r@main#path', '--no-connect', '--json'], + variant: 'cloud-deploy', + }, { verb: 'deployments', argv: ['deployments', '--json'], variant: 'deployments' }, + { verb: 'versions', argv: ['versions', 'review-listener'], variant: 'versions' }, + { verb: 'versions', argv: ['versions', '--json', 'review-listener'], variant: 'versions' }, + { verb: 'rollback', argv: ['rollback', 'review-listener', '2'], variant: 'rollback' }, + { verb: 'rollback', argv: ['rollback', '--json', 'review-listener', '2'], variant: 'rollback' }, { verb: 'hn-monitor', argv: ['hn-monitor', 'start', 'spec.json'], variant: 'hn-monitor' }, { verb: 'hn-monitor',