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
124 changes: 120 additions & 4 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,124 @@ pinned runtime must be enabled by that deployment's operator.
```sh
flows run --cloud examples/cloud-gates/cloud-gates.flow.yaml
flows run --cloud --wait --json examples/cloud-gates/cloud-gates.flow.yaml
flows run --cloud --input '{}' review.flow.ts
```

An authored `.flow.ts` takes `--input` exactly as a local direct run does (an
existing JSON file, otherwise inline JSON), and travels as one self-contained
source with its pinned Surface authority.

## Code sync

```sh
cd <your-repo>
flows run --cloud --sync-code --wait review.flow.ts --input '{"pr": 7}'
flows sync <run-id> # apply the run's changes to this checkout
```

`--sync-code` uploads the invoking directory before submission, so the hosted
run — every `f.run` and every `f.agent` — executes inside that tree, the way
v1's `agent-relay cloud run --sync-code` did. Inside a Git checkout the upload
is `git ls-files --cached --others --exclude-standard`: `.gitignore` governs,
untracked files ride along, `.git` and `node_modules` never do. A checkout
whose `git` fails for any other reason (a corrupt `.git`, a locked index, no
`git` on PATH) is refused as `sync_unsupported` rather than widened to a plain
walk, so an ignored `.env` never reaches Cloud because Git was unavailable.
Outside Git, every file except those two directories — there is no ignore
rule there, so keep secrets out of such a tree. Executable bits are preserved;
symlinks whose target resolves outside the tree are dropped and listed as
`sync_link_skipped` warnings. The archive is streamed to a temporary file, so
packing costs one file plus the compressor's window, not the tree. The limit is
256 MiB uncompressed. The flow source itself is still sent in the request
body, so it must stay self-contained; sibling imports inside the tree are not
resolved by the hosted runner.

Interrupting before the run request — during prepare, packing or upload —
reports `submission_aborted`: nothing was admitted and rerunning is safe. Only
an interrupted submission itself reports `admission_unknown`.

The transport is the Cloud API only. `POST /api/v1/workflows/prepare` must
answer with a `cloud-api` workflow-storage backend (Cloud's R2), the archive
is `PUT` to `/api/v1/workflows/runs/<run>/storage/<key>` with the run-scoped
credential that receipt carries, and the run is submitted against that
prepared run ID. A `prepare` that offers any other backend is refused as
`unsupported_storage_backend` before a byte is uploaded; this SDK carries no
AWS client and never uploads to a bucket directly. Sync happens before
submission, so a refused backend or failed upload never leaves a launched run
pointing at a tree Cloud does not hold.

`flows sync <run-id>` fetches the sandbox's post-run diff from
`/api/v1/workflows/runs/<run>/patch` and applies it with `git apply` after a
`--check` pass, so a conflicting patch leaves the tree untouched
(`patch_conflict`, exit 2). The patch lands in the working tree uncommitted
and every touched path is listed, deletions included: what the run changed —
it is your own flow's output, but it is agent output — is reviewed with
`git diff` before any of it is kept, the same contract v1's `cloud sync` had.
Runs that declared several mounted paths carry one patch per path and are
refused here (`sync_unsupported`). `--dir <path>` targets a checkout other
than the current directory.

A synced run and a Cloud repository grant are mutually exclusive on the
server: `--sync-code` is the local-driven development loop, and
webhook-triggered deployments keep cloning through the grant.

## Credentials

Every hosted verb resolves its credential the same way: the `token` option,
then `FLOWS_CLOUD_TOKEN`, then the `agent-relay cloud login` store
(`~/.agentworkforce/relay/cloud-auth.json`, or `AGENT_RELAY_HOME`). The login
store also supplies the base URL unless `FLOWS_CLOUD_URL` overrides it, so a
login against one deployment never sends its token to another. An expired
login is refused with the re-login remedy rather than sent.

Running and syncing work with either kind of token. Deploying, listing and
removing listeners need the interactive `cli:auth` credential the login
produces; a deployment (CI) token gets `session_required` and the CLI says so.

## Listener deployments

```sh
flows deploy issue-triage.flow.ts \
--repo AgentWorkforce/flows \
--on github:labels=agent \
--approver khaliqgant
flows deployments
flows undeploy <deployment-id>
```

Optional flags: `--agents claude,codex`, `--name "Issue triage"`, `--draft`,
`--json`, and further `--on` sources.

`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
ticket sources. There is no webhook to register. The workspace's GitHub App
installation (or Slack, Linear, Jira or Shortcut connection) is the ingress;
Cloud ingests events into the workspace's relayfile projection and the
listener's rules match them there. The digest form,
`flows deploy <flow>@sha256:… --to file://…`, is unchanged; the positional
decides which form is meant.

`--on <provider>[:key=value,…]` takes `github` (`repository`, `labels`,
`contains`), `slack` (`channel`, `contains`), `linear` (`team`, `contains`),
`jira` (`project`, `contains`) or `shortcut` (`workspace`, `contains`), each
at most once. A GitHub source without `repository` is scoped to `--repo`.
Today a GitHub listener wakes on `issues.opened` and `issues.labeled` only;
pull-request and comment events are filtered out before launch.

Each matching ticket launches one run of the stored source. Cloud clones
`--repo` at its default branch onto a fresh `relayflow/<name>-<id>` branch,
runs `flows run --local-agent` there, and passes the flow body
`{ approver, issue: { source, title, body, labels, repository, url, … }, event }`
as its input. The flow must therefore be the default body,
`flow<Input>(name, header, async (f, input) => …)`; `.on(github.issues(…))`
handlers are checked but are not what Cloud dispatches. `--agents` names the
coding-agent harnesses the flow uses (default `claude`); activation checks
their credentials are connected and refuses with `flow_model_not_connected`
otherwise. `--draft` saves the flow without activating it and skips those
checks. The deploy routes answer refusals as `{ code, error }`, and the CLI
names them (`flow_repository_not_connected`, `flow_name_taken`, …).

Without `--wait`, exit 0 means the server accepted the run. With `--wait`, it
means Cloud reported `completed` with a validated `success` completion reason.
Failed/cancelled runs and observation/transport failures return 1. Local input,
Expand Down Expand Up @@ -72,10 +188,10 @@ by this endpoint and is not synthesized by the SDK.

## Current limits and scope

- Cloud's v2 bootstrap explicitly rejects authored `.flow.ts` files. The SDK
refuses those before HTTP rather than uploading code that cannot run. Inputs,
local imports, CLI configuration files, and workspace files are not bundled
or uploaded by this path.
- An authored `.flow.ts` is submitted as one self-contained source. Local
imports and `use:` dependencies are refused before HTTP. With `--sync-code`
the working tree is uploaded for the run to execute in, but the runner still
loads the flow from the request body, not from the tree.
- SDK observation has no fixed execution deadline. The merged authored-agent
executor follows worker leases rather than the former 30-second limit.
Cloud's separate `relayflow-v2-executor.ts` still has a one-hour execution
Expand Down
86 changes: 74 additions & 12 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { runDirectFlow } from './cli/direct-run.js';
import { parseReplayArgs, replayJournal, type ReplayArgs } from './cli/replay.js';
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 { isAuthoredFlowPath } from './direct-input.js';
import { parseDeployArgs, runDeploy, type DeployArgs } from './cli/deploy.js';
import { parseDigestReference } from './bundle-transport.js';
Expand Down Expand Up @@ -51,7 +53,11 @@ type ParsedArgs =
| BuildArgs
| DeployArgs
| { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean }
| { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean }
| { command: 'sync'; runId: string; json: boolean; root: string }
| CloudDeployArgs
| { command: 'deployments'; json: boolean }
| { command: 'undeploy'; agentId: string; json: boolean }
| { command: 'check'; json: boolean; watch: boolean; value: string }
| { command: 'run'; bucket: string | undefined; reuseFromRunId: string | undefined; localAgent: boolean; dataDir: string; input: string | undefined; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
| { command: 'resume'; localAgent: boolean; dataDir: string; json: boolean; spawn: boolean; noObserverLink: boolean; allowHumanInfluenced: boolean; value: string }
Expand All @@ -66,12 +72,17 @@ const USAGE = [
'flows add <helper-name|@flows/helper-name>',
'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] [--json]',
'flows deployments [--json]',
'flows undeploy [--json] <deployment-id>',
'flows deploy <flow>@sha256:<digest> --to <file-bucket-uri>',
'flows run <flow>@sha256:<digest> [--bucket <file-bucket-uri>] [--data-dir <dir>] [--json]',
'flows check [--watch] [--json] <flow.ts|flow.yaml|spec.json>',
'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] [--reuse-from <run-id>] <flow.yaml|spec.json>',
'flows run --cloud [--json] [--wait] <flow.yaml|spec.json>',
'flows run --cloud [--json] [--wait] [--sync-code] <flow.yaml|spec.json>',
'flows run --cloud [--json] [--wait] [--sync-code] <flow.ts> --input <inline-json-or-file>',
'flows sync [--json] [--dir <path>] <run-id>',
'flows run [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <flow.ts> --input <inline-json-or-file>',
'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--data-dir <dir>] [--local-agent] <run-id>',
Expand Down Expand Up @@ -116,6 +127,10 @@ export async function runCli(
if (parsed.command === 'serve-webhook') return runServeWebhook(parsed, io);

if (parsed.command === 'cloud-run') return runCloudCli(parsed, io);
if (parsed.command === 'sync') return runCloudSyncCli(parsed, io);
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 === 'replay') return replayJournal(parsed, io);
if (parsed.command === 'build') return runBuild(parsed, io);
if (parsed.command === 'deploy') return runDeploy(parsed, io);
Expand Down Expand Up @@ -419,17 +434,35 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
if (command === 'add') return args.length === 2 ? { command: 'add', value: args[1]! } : undefined;
if (command === 'replay') return parseReplayArgs(args.slice(1));
if (command === 'build') return parseBuildArgs(args.slice(1));
if (command === 'deploy') return parseDeployArgs(args.slice(1));
if (command === 'deploy') {
// The positional decides the form: an authored source deploys a hosted
// listener; a digest reference copies a sealed bundle into a file bucket.
const source = args.slice(1).find(a => !a.startsWith('-') && isAuthoredFlowPath(a));
return source !== undefined ? parseCloudDeployArgs(args.slice(1)) : parseDeployArgs(args.slice(1));
}
if (command === 'undeploy') {
const rest = args.slice(1).filter(a => a !== '--json');
const json = args.length - 1 - rest.length;
if (json > 1 || rest.length !== 1 || rest[0]!.startsWith('-')) return undefined;
return { command: 'undeploy', agentId: rest[0]!, json: json === 1 };
}
if (command === 'deployments') {
const rest = args.slice(1);
if (rest.length > 1 || (rest.length === 1 && rest[0] !== '--json')) return undefined;
return { command: 'deployments', json: rest.length === 1 };
}
if (command === 'serve-webhook') return parseWebhookArgs(args.slice(1));
if (command === 'hn-monitor') return parseHnMonitorArgs(args.slice(1));
if (command === 'tick') return parseTickArgs(args.slice(1));
if (command === 'observer') return parseObserverArgs(args.slice(1));
if (command === 'sync') return parseSyncArgs(args.slice(1));
if (command !== 'check' && command !== 'run' && command !== 'resume') return undefined;

let json = false;
let watch = false;
let cloud = false;
let wait = false;
let syncCode = false;
let localAgent = false;
let allowHumanInfluenced = false;
let dataDir = DEFAULT_DATA_DIR;
Expand All @@ -443,10 +476,11 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
const positionals: string[] = [];
for (let index = 1; index < args.length; index += 1) {
const argument = args[index]!;
if (argument === '--cloud' || argument === '--wait') {
if (command !== 'run' || (argument === '--cloud' ? cloud : wait)) return undefined;
if (argument === '--cloud' || argument === '--wait' || argument === '--sync-code') {
if (command !== 'run' || (argument === '--cloud' ? cloud : argument === '--wait' ? wait : syncCode)) return undefined;
if (argument === '--cloud') cloud = true;
else wait = true;
else if (argument === '--wait') wait = true;
else syncCode = true;
continue;
}
if (argument === '--allow-human-influenced') {
Expand Down Expand Up @@ -520,13 +554,15 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
if (bucket !== undefined && (cloud || !parseDigestReference(positionals[0]!))) return undefined;
if (cloud) {
// `--cloud` submits the spec to Cloud, so every flag that only describes a
// local run -- an inline input, a data dir, a suppressed daemon, a local
// agent, a local observer-link opt-out -- describes nothing there and is
// refused rather than ignored.
if (allowHumanInfluenced || sawInput || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
return { command: 'cloud-run', value: positionals[0]!, json, wait };
// local run -- a data dir, a suppressed daemon, a local agent, a local
// observer-link opt-out -- describes nothing there and is refused rather
// than ignored. `--input` is the authored body's argument and travels with
// the source, so it is accepted exactly where a local run accepts it.
if (allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || reuseFromRunId !== undefined) return undefined;
if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode };
}
if (wait) return undefined;
if (wait || syncCode) return undefined;
if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;

if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
Expand Down Expand Up @@ -579,6 +615,32 @@ function parseHnMonitorArgs(rest: readonly string[]): ParsedArgs | undefined {
* directory at all -- the mint is a pure Relaycast API round-trip. No
* positional argument, no other flags.
*/
/** `flows sync [--json] [--dir <path>] <run-id>`: apply a hosted run's patch to a local tree. */
function parseSyncArgs(args: readonly string[]): ParsedArgs | undefined {
let json = false;
let root: string | undefined;
const positionals: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]!;
if (argument === '--json') {
if (json) return undefined;
json = true;
continue;
}
if (argument === '--dir') {
const value = args[index + 1];
if (root !== undefined || value === undefined || value.startsWith('-')) return undefined;
root = value;
index += 1;
continue;
}
if (argument.startsWith('-')) return undefined;
positionals.push(argument);
}
if (positionals.length !== 1) return undefined;
return { command: 'sync', runId: positionals[0]!, json, root: root ?? '.' };
}

function parseObserverArgs(rest: readonly string[]): ParsedArgs | undefined {
let dataDir = DEFAULT_DATA_DIR;
let sawDataDir = false;
Expand Down
7 changes: 6 additions & 1 deletion packages/sdk/src/cli/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,12 @@ function systemProbes(flowDirectory: string, config: ProjectConfig): PreflightPr
helper: helperReady,
cli: (cli, source, model) => probeCli(cli, source === 'project' ? config.directory : flowDirectory, model),
executor: (trigger) => config.executors.includes(trigger.executor),
command: (binary) => executableExists(binary, flowDirectory),
// A deterministic step runs in the daemon's working directory — the
// directory `flows run` was invoked from, or Cloud's code mount — not in
// the flow file's. Probing `./x` against the flow's directory answered a
// question the kernel never asks, and refused a Cloud run whose synced
// tree held the script while its source sat in the state directory.
command: (binary) => executableExists(binary, process.cwd()),
};
}

Expand Down
Loading
Loading