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
22 changes: 17 additions & 5 deletions docs/CLOUD.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,10 +537,18 @@ flows deploy issue-triage.flow.ts \
--repo AgentWorkforce/flows \
--on github:labels=agent \
--approver khaliqgant
flows deploy issue-triage.flow.ts --repo gitlab:group/sub/project --on gitlab:labels=agent --approver khaliqgant
flows deployments
flows undeploy <deployment-id>
```

`--repo owner/name` (or `github:owner/name`) targets GitHub. Use
`gitlab:group/sub/project` or `https://gitlab.com/group/sub/project.git` for
GitLab, including nested namespaces. GitHub HTTP(S) URLs remain supported. The CLI applies Cloud's GitLab
shape check before deploying: the namespace is at most 20 segments and 255
characters, and each segment and the project name starts with a letter or
digit and does not end in `.`, `.git` or `.atom`.

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

Expand Down Expand Up @@ -574,7 +582,7 @@ on `f.human`, finish on the version they started with.
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;
installation (or GitLab, 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
Expand All @@ -589,9 +597,12 @@ decides which form is meant.
`team` matches the team's name or its key. A Linear `events` is `issues` (the
default — `issue.create`), `assigned` — `AppUserNotification.issueAssignedToYou`,
issues assigned to the connected app user, so assigning a ticket delegates it —
or `all` for both. A GitHub source without
`repository` is
scoped to `--repo`. `events` is `issues` (the default: `issues.opened` and
or `all` for both. A GitHub source without `repository` is scoped to a GitHub
`--repo` target;
a GitLab target requires an explicit `--on github:repository=owner/name`.
A GitLab source without `project` is scoped to a GitLab target. With a GitHub
target, a GitLab source remains unscoped unless you supply `project`.
`events` is `issues` (the default: `issues.opened` and
`issues.labeled`) or `pull_request` — `merge_request` for `gitlab` — which wakes on a pull request being
opened, receiving commits, being reopened, or being reviewed; a
pull-request run checks out the pull request's own head and receives
Expand Down Expand Up @@ -633,7 +644,8 @@ SDK; the same function reads a compiled YAML spec, where a helper step such as
`tools.relayfile` mounts in the header, `f.<helper>` use in the default body
(recognised exactly as helper preflight recognises it), provider triggers
(`.on(github.issues())`), the `--on` sources and the deploy target (every
launched run lands in `--repo`, so GitHub is always required), the `cli:` of
launched run lands in `--repo`, so the target's GitHub or GitLab integration
is required), the `cli:` of
each `f.agent`/`f.llm` call in the default body (else the nearest `flows.json`
`cli`, else `claude`), and `tools.mcp`. Handler bodies are not statically
scanned for requirements. Authored input never selects an extension handler:
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const CLI_VERBS = [
args: [{ name: 'flow', description: 'flow.ts for a hosted listener, or <flow>@sha256:<digest> for a bundle', required: true }],
options: [
{ flags: '--to <file-bucket-uri>', description: 'Destination file bucket for a sealed bundle' },
{ flags: '--repo <owner/name>', description: 'Repository the hosted listener watches' },
{ flags: '--repo <owner/name|gitlab:group/project>', description: 'Target repository: owner/name or github:owner/name for GitHub; gitlab:group/project (subgroups allowed) or a github.com/gitlab.com HTTP(S) URL' },
{ flags: '--on <provider>', description: 'Trigger source, as <provider>[:key=value,...]; repeatable' },
{ flags: '--approver <handle>', description: 'Handle delivered to every launched run as input.approver' },
{ flags: '--agents <list>', description: 'Agent harnesses to allow, as claude[,codex]' },
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const USAGE = [
'flows plugin update [--json] [--yes] [--to <ref>] [<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] [--plugin <ref>] [--no-connect] [--json]',
'flows deploy <flow.ts> --repo <owner/name|gitlab:group/project> --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>',
Expand Down
12 changes: 8 additions & 4 deletions packages/sdk/src/cli/cloud-deploy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CloudFlowError } from '../cloud-http.js';
import {
deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
type FlowTriggerSource,
type DeployRepository, type FlowTriggerSource,
} from '../cloud-deploy.js';
import { describeFlowRequirements } from '../flow-requirements.js';
import { describeVersionChange } from '../cloud-versions-wire.js';
Expand All @@ -27,7 +27,7 @@ export interface CloudDeployArgs {
}

/**
* `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
* `flows deploy <flow.ts> --repo <owner/name|gitlab:group/project> --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]`
*
Expand Down Expand Up @@ -102,6 +102,10 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
return { command: 'cloud-deploy', value, flow, repo, on, approver, name, agents, draft, noConnect, json, plugins };
}

function describeRepository(repo: DeployRepository): string {
return `${repo.host === 'gitlab' ? 'gitlab:' : ''}${repo.owner}/${repo.name}`;
}

function describeSource(source: FlowTriggerSource): string {
const settings = Object.entries(source.settings).map(([k, v]) => `${k}=${v}`).join(' ');
return settings ? `${source.provider} ${settings}` : source.provider;
Expand Down Expand Up @@ -140,7 +144,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
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}`);
io.stdout(` repository: ${describeRepository(deployment.repository)}`);
for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
const requires = describeFlowRequirements(deployment.requirements);
if (requires) io.stdout(` requires: ${requires}`);
Expand Down Expand Up @@ -175,7 +179,7 @@ export async function runCloudDeploymentsCli({ json }: { json: boolean }, io: Cl
return 0;
}
for (const d of deployments) {
const repo = d.repository ? ` ${d.repository.owner}/${d.repository.name}` : '';
const repo = d.repository ? ` ${describeRepository(d.repository)}` : '';
io.stdout(`${d.agentId} ${d.status} ${JSON.stringify(d.name)}${repo}`);
for (const source of d.sources) io.stdout(` on: ${describeSource(source)}`);
}
Expand Down
82 changes: 62 additions & 20 deletions packages/sdk/src/cloud-deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import { parseVersionChange, type CloudFlowVersionChange } from './cloud-version
* 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 on the workspace's relayfile projections. There
* is no webhook to register: the GitHub App installation (or Slack/Linear/
* Jira/Shortcut connection) is the ingress, and each matching ticket launches
* a run of the stored source with `{ approver, issue, event }` as its input,
* inside a fresh branch of the deployment's repository.
* is no webhook to register: the GitHub App installation (or the GitLab,
* Slack, Linear, Jira or Shortcut connection) is the ingress, and each
* matching ticket launches a run of the stored source with
* `{ approver, issue, event }` as its input, inside a fresh branch of the
* deployment's repository.
*/

export const FLOW_TRIGGER_PROVIDERS = ['github', 'gitlab', 'linear', 'jira', 'shortcut', 'slack'] as const;
Expand All @@ -45,6 +46,25 @@ const MAX_SOURCE_BYTES = 256_000;
const MAX_SETTING_LENGTH = 500;
const REPO_OWNER = /^[A-Za-z0-9-]{1,39}$/u;
const REPO_NAME = /^[A-Za-z0-9_.-]{1,100}$/u;
// GitLab coordinates mirror Cloud's isValidFlowRepositoryCoordinates
// (AgentWorkforce/cloud packages/web/lib/flows/flow-repository.ts): the owner
// is the namespace path, at most 255 characters and 20 segments (the root
// group counts; the project name does not), and every owner segment and the
// project name match GITLAB_SEGMENT without a trailing ., .git or .atom.
const GITLAB_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/u;
const GITLAB_OWNER_SEGMENTS = 20;
const GITLAB_OWNER_LENGTH = 255;

function validGitlabSegment(segment: string): boolean {
return GITLAB_SEGMENT.test(segment)
&& !segment.endsWith('.') && !segment.endsWith('.git') && !segment.endsWith('.atom');
}

export interface DeployRepository {
owner: string;
name: string;
host?: 'gitlab';
}

export interface FlowTriggerSource {
provider: FlowTriggerProvider;
Expand All @@ -53,7 +73,7 @@ export interface FlowTriggerSource {

export interface DeployToCloudInput {
path: string;
repository: { owner: string; name: string };
repository: DeployRepository;
sources: FlowTriggerSource[];
/** The `f.human` approver handle every launched run receives as `input.approver`. */
approver: string;
Expand Down Expand Up @@ -98,7 +118,7 @@ export interface CloudDeployment {
agentId: string;
name: string;
status: string;
repository: { owner: string; name: string };
repository: DeployRepository;
sources: FlowTriggerSource[];
sourceSha256: string;
/** What the source declared it needs; the harnesses became `inputs.agents` unless `agents` was given. */
Expand All @@ -109,12 +129,24 @@ export interface CloudDeployment {
version?: CloudFlowVersionChange;
}

export function parseRepository(value: string): { owner: string; name: string } {
const [owner, name, extra] = value.replace(/^https?:\/\/github\.com\//iu, '').replace(/\.git$/iu, '').split('/');
if (!owner || !name || extra !== undefined || !REPO_OWNER.test(owner) || !REPO_NAME.test(name)) {
throw new CloudFlowError('invalid_input', `Expected --repo <owner>/<name>, got "${value}".`);
export function parseRepository(value: string): DeployRepository {
const prefix = /^(github|gitlab):/iu.exec(value);
const path = prefix ? value.slice(prefix[0].length) : value;
const url = /^https?:\/\/(github|gitlab)\.com\//iu.exec(path);
const host = (prefix?.[1] ?? url?.[1] ?? 'github').toLowerCase();
const parts = (url ? path.slice(url[0].length) : path).replace(/\.git$/iu, '').split('/');
const name = parts.at(-1)!;
const owner = parts.slice(0, -1).join('/');
const valid = host === 'gitlab'
? parts.length >= 2 && parts.length - 1 <= GITLAB_OWNER_SEGMENTS
&& owner.length <= GITLAB_OWNER_LENGTH && parts.every(validGitlabSegment)
: parts.length === 2 && REPO_OWNER.test(owner) && REPO_NAME.test(name);
if (!valid || (url && url[1]!.toLowerCase() !== host)) {
throw new CloudFlowError('invalid_input',
`Expected --repo <owner/name> (GitHub) or gitlab:<group/project> (up to ${GITLAB_OWNER_SEGMENTS} namespace segments), `
+ `or a github.com/gitlab.com HTTP(S) project URL, got "${value}".`);
}
return { owner, name };
return { owner, name, ...(host === 'gitlab' ? { host: 'gitlab' as const } : {}) };
}

/** `github`, `github:labels=agent,contains=urgent`, `slack:channel=#eng`. */
Expand Down Expand Up @@ -233,20 +265,29 @@ export async function deployToCloud(
if (!approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.');
const name = (input.name ?? definition.name).trim();
if (!name || name.length > 100) throw new CloudFlowError('invalid_input', 'Deployment name must be 1-100 characters.');
// A GitHub source scoped to nothing would wake on every repository the
// installation covers; default it to the deployment's own repository.
const sources = input.sources.map(s => s.provider === 'github' && s.settings['repository'] === undefined
? { ...s, settings: { ...s.settings, repository: `${input.repository.owner}/${input.repository.name}` } }
: s);
// Scope same-host sources to the target rather than waking on every project.
const target = `${input.repository.owner}/${input.repository.name}`;
const sources = input.sources.map(s => {
if (s.provider === 'github' && s.settings['repository'] === undefined) {
if (input.repository.host === 'gitlab') {
throw new CloudFlowError('invalid_input',
'A GitLab target needs an explicit --on github:repository=owner/name for a GitHub source.');
}
return { ...s, settings: { ...s.settings, repository: target } };
}
if (s.provider === 'gitlab' && s.settings['project'] === undefined && input.repository.host === 'gitlab') {
return { ...s, settings: { ...s.settings, project: target } };
}
return s;
});
options.signal?.throwIfAborted();

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.');
}
// Every launched run lands in the deployment's repository, so GitHub is
// required even when no GitHub source wakes it.
// Every launched run needs the target's host, even when another provider wakes it.
const requirements = mergeFlowExtensionRequirements(
flowRequirements(definition, {
sources, repository: input.repository, ...(projectCli === undefined ? {} : { projectCli }),
Expand Down Expand Up @@ -307,7 +348,7 @@ export interface CloudDeploymentSummary {
agentId: string;
name: string;
status: string;
repository?: { owner: string; name: string };
repository?: DeployRepository;
sources: FlowTriggerSource[];
createdAt?: string;
updatedAt?: string;
Expand All @@ -323,7 +364,8 @@ export async function listCloudDeployments(options: CloudConnectionOptions = {})
throw new CloudFlowError('invalid_response', 'Cloud returned a malformed deployment row.');
}
const repository = isCloudRecord(row.repository) && typeof row.repository.owner === 'string'
&& typeof row.repository.name === 'string' ? { owner: row.repository.owner, name: row.repository.name } : undefined;
&& typeof row.repository.name === 'string' ? { owner: row.repository.owner, name: row.repository.name,
...(row.repository.host === 'gitlab' ? { host: 'gitlab' as const } : {}) } : undefined;
const sources = Array.isArray(row.sources) ? row.sources.flatMap((s): FlowTriggerSource[] =>
isCloudRecord(s) && typeof s.provider === 'string' && (FLOW_TRIGGER_PROVIDERS as readonly string[]).includes(s.provider)
? [{ provider: s.provider as FlowTriggerProvider,
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/cloud-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
} from './cloud-http.js';
import {
FLOW_AGENT_HARNESSES, listCloudDeployments, loadDeploySource, type FlowTriggerSource,
FLOW_AGENT_HARNESSES, listCloudDeployments, loadDeploySource, type DeployRepository, type FlowTriggerSource,
} from './cloud-deploy.js';
import {
parseVersion, parseVersionChange, type CloudFlowVersion, type CloudFlowVersionChange,
Expand All @@ -25,7 +25,7 @@ export interface CloudListener {
agentId: string;
name: string;
status: string;
repository: { owner: string; name: string; host?: 'gitlab' };
repository: DeployRepository;
sources: FlowTriggerSource[];
activeVersion: CloudFlowVersion | null;
versions: CloudFlowVersion[];
Expand Down
9 changes: 6 additions & 3 deletions packages/sdk/src/flow-requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export interface FlowRequirements {
export interface FlowRequirementsContext {
/** Trigger sources the deployment listens on (`--on`, or the wizard's chosen sources). */
sources?: readonly { provider: string }[];
/** Set when the deployment targets a repository: every launched run needs GitHub. */
repository?: boolean | { owner: string; name: string };
/** Set when the deployment targets a repository: every launched run needs its host (true means GitHub). */
repository?: boolean | { owner: string; name: string; host?: 'gitlab' };
/** The nearest `flows.json` `cli`, when one applies. */
projectCli?: string;
}
Expand Down Expand Up @@ -180,7 +180,10 @@ export function flowRequirements(
for (const source of context.sources ?? []) {
declare({ provider: source.provider, from: 'source', detail: `--on ${source.provider}` });
}
if (context.repository) declare({ provider: 'github', from: 'source', detail: 'deploy target' });
if (context.repository) {
const provider = typeof context.repository === 'object' && context.repository.host === 'gitlab' ? 'gitlab' : 'github';
declare({ provider, from: 'source', detail: 'deploy target' });
}

const uses = [...harnessUses.values()];
return {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export {
export { prepareCloudSubmission, cloudSubmissionBody, type CloudSubmission } from './cloud-run.js';
export {
deployToCloud, listCloudDeployments, undeployFromCloud, parseRepository, parseTriggerSource, FLOW_TRIGGER_PROVIDERS,
type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider,
type DeployRepository, type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider,
} from './cloud-deploy.js';
export {
ensureIntegrationsConnected, integrationConnected, providerLabel,
Expand Down
Loading
Loading