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
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Verify recommended-flow release pins
run: npm run verify:recommended-flows

- name: Build OpenNext (Cloudflare)
working-directory: web
env:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Verify recommended-flow release pins
run: npm run verify:recommended-flows

# Root script fans out to every workspace (web + router), so a new
# workspace is covered automatically.
- name: Run tests
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dev": "npm --workspace web run dev",
"build": "npm --workspace web run build",
"test": "npm --workspaces run test",
"verify:recommended-flows": "node web/scripts/verify-recommended-flow-catalog.mjs",
"preview": "npm --workspace web run preview",
"dev:teams": "CLOUD_DEV_ORIGIN=http://127.0.0.1:3101 npm --workspace web run dev -- --hostname 127.0.0.1 --port 3100"
}
Expand Down
18 changes: 18 additions & 0 deletions web/app/api/v1/flows/catalog/[flowId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getRecommendedFlow } from '../../../../../../lib/recommended-flow-catalog';

const headers = {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400',
};

export async function GET(_request: Request, { params }: { params: Promise<{ flowId: string }> }) {
const { flowId } = await params;
const recommendedFlow = getRecommendedFlow(flowId);
if (!recommendedFlow) {
return Response.json({ error: {
code: 'recommended_flow_not_found',
message: `Recommended flow "${flowId}" was not found.`,
} }, { status: 404, headers });
}
return Response.json(recommendedFlow, { headers });
}
10 changes: 10 additions & 0 deletions web/app/api/v1/flows/catalog/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { getRecommendedFlowCatalog } from '../../../../../lib/recommended-flow-catalog';

export const dynamic = 'force-static';

export function GET() {
return Response.json(getRecommendedFlowCatalog(), { headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400',
} });
}
55 changes: 55 additions & 0 deletions web/content/docs/relayflows/recommended.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: 'Recommended flows'
description: 'Discover versioned, maintained flows and activate the same canonical source from Cloud or the CLI.'
---

Recommended flows are maintained starting points. The catalog is public, versioned JSON, so the Cloud dashboard and the `flows` CLI discover the same identifiers, defaults, requirements, and source instead of keeping separate copies.

## Software Garden

Software Garden is the display name of the first recommended flow. Its stable catalog and authored flow ID is `software-factory`: a GitHub issue starts implementation, deterministic repository checks, adversarial review, and a pull request for a human decision.

The released source uses Claude Code for implementation and review, so catalog version 1 allows and defaults only that harness. A future catalog version can point at a new released source with a different requirement; clients do not rewrite the authored flow.

Software Garden currently supports GitHub repositories. Each activation supplies one or more repositories, and Cloud scopes a GitHub listener to each one. The catalog deliberately does not define Babysitter behavior.

## Catalog API

The stable endpoints are:

```text
GET https://agentrelay.com/api/v1/flows/catalog
GET https://agentrelay.com/api/v1/flows/catalog/software-factory
```

The list response starts with `schemaVersion: 1` and `catalogVersion: 1`. Every flow has a stable `id`, its own numeric `version`, display copy, repository-host support, trigger defaults, required/default activation inputs, and an immutable source reference.

The catalog does not copy or generate the flow body. `source` names the canonical `AgentWorkforce/flows` owner, repository, path, release tag, full commit SHA, GitHub blob and raw URLs, media type, and SHA-256 content digest. Both URLs contain the commit SHA, never a mutable branch:

```text
https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts
```

CI and the production release workflow resolve the release tag, fetch the pinned raw file with a size bound, and verify the digest. A broken, moved, mutable, or drifted source therefore fails before the catalog can ship. Cloud fetches it through the existing trusted source loader and stores the fetched body only as that deployment's immutable snapshot and provenance.

## Activation contract

Cloud accepts the catalog ID and activation settings, then fetches the matching canonical source itself:

```json
{
"workspaceId": "workspace-id",
"flowId": "software-factory",
"label": "Platform Garden",
"repositories": [
{ "owner": "acme", "name": "api" },
{ "owner": "acme", "name": "web" }
],
"inputs": {
"approver": "github:@octocat",
"agents": ["claude"]
}
}
```

`approver` is required. `agents` may be omitted to use the catalog default. The custom label names the visible activation group; each repository still gets its own scoped listener and run workspace.
38 changes: 38 additions & 0 deletions web/data/recommended-flow-catalog.v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"catalogVersion": 1,
"flows": [
{
"id": "software-factory",
"version": 1,
"name": "Software Garden",
"summary": "Turn GitHub issues into reviewed, tested pull requests.",
"description": "Implement a ticket, run the repository checks outside the agent, put the change through adversarial review, and open a pull request for a human decision.",
"defaultLabel": "Software Garden",
"supportedRepositoryHosts": ["github"],
"defaultTrigger": {
"provider": "github",
"settings": {}
},
"inputs": {
"required": ["approver"],
"defaults": {
"agents": ["claude"]
},
"allowedAgents": ["claude"]
},
"source": {
"kind": "github",
"owner": "AgentWorkforce",
"repo": "flows",
"path": "examples/software-factory/software-factory.flow.ts",
"release": "v2.0.22",
"ref": "b4dd665eb433bd7f52d1045543aef5f14fb7891e",
"url": "https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts",
"rawUrl": "https://raw.githubusercontent.com/AgentWorkforce/flows/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts",
"mediaType": "text/typescript",
"sha256": "41c2137179455881a0fcf568006d2b41e24386ba1cf7dc2c6051871b6f744368"
}
}
]
}
1 change: 1 addition & 0 deletions web/lib/product-docs-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const relayflowsSection: ProductDocSection = {
items: [
{ title: 'Multi-agent flows', slug: 'multi-agent' },
{ title: 'Cloud', slug: 'cloud' },
{ title: 'Recommended flows', slug: 'recommended' },
{ title: 'Memory & integrations', slug: 'memory-and-integrations' },
{ title: 'Reliability', slug: 'reliability' },
],
Expand Down
12 changes: 12 additions & 0 deletions web/lib/recommended-flow-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import catalog from '../data/recommended-flow-catalog.v1.json';

export type RecommendedFlow = typeof catalog.flows[number];

/** The checked-in JSON is the one catalog consumed by the API, Cloud and CLI. */
export function getRecommendedFlowCatalog() {
return catalog;
}

export function getRecommendedFlow(id: string): RecommendedFlow | null {
return catalog.flows.find(flow => flow.id === id) ?? null;
}
91 changes: 91 additions & 0 deletions web/lib/test/recommended-flow-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import {
getRecommendedFlow,
getRecommendedFlowCatalog,
} from '../recommended-flow-catalog';
import { GET as getCatalog } from '../../app/api/v1/flows/catalog/route';
import { GET as getCatalogItem } from '../../app/api/v1/flows/catalog/[flowId]/route';

describe('recommended flow catalog', () => {
it('publishes Software Garden metadata under its canonical flow id', () => {
const catalog = getRecommendedFlowCatalog();
expect(catalog).toEqual({
schemaVersion: 1,
catalogVersion: 1,
flows: [{
id: 'software-factory',
version: 1,
name: 'Software Garden',
summary: expect.any(String),
description: expect.any(String),
defaultLabel: 'Software Garden',
supportedRepositoryHosts: ['github'],
defaultTrigger: { provider: 'github', settings: {} },
inputs: {
required: ['approver'],
defaults: { agents: ['claude'] },
allowedAgents: ['claude'],
},
source: {
kind: 'github',
owner: 'AgentWorkforce',
repo: 'flows',
path: 'examples/software-factory/software-factory.flow.ts',
release: 'v2.0.22',
ref: 'b4dd665eb433bd7f52d1045543aef5f14fb7891e',
url: 'https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts',
rawUrl: 'https://raw.githubusercontent.com/AgentWorkforce/flows/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts',
mediaType: 'text/typescript',
sha256: '41c2137179455881a0fcf568006d2b41e24386ba1cf7dc2c6051871b6f744368',
},
}],
});
expect(JSON.parse(JSON.stringify(catalog))).toEqual(catalog);
});

it('keeps display branding separate from the canonical activation id', () => {
expect(getRecommendedFlow('software-factory')?.name).toBe('Software Garden');
expect(getRecommendedFlow('software-garden')).toBeNull();
expect(getRecommendedFlow('babysitter')).toBeNull();
});

it('references an immutable released source instead of carrying authored source', () => {
const flow = getRecommendedFlow('software-factory')!;
expect(flow.source.ref).toMatch(/^[0-9a-f]{40}$/);
expect(flow.source.sha256).toMatch(/^[0-9a-f]{64}$/);
expect(flow.source.url).toContain(`/blob/${flow.source.ref}/${flow.source.path}`);
expect(flow.source.rawUrl).toContain(`/${flow.source.ref}/${flow.source.path}`);
expect(Object.keys(flow.source).sort()).toEqual([
'kind', 'mediaType', 'owner', 'path', 'rawUrl', 'ref', 'release', 'repo', 'sha256', 'url',
]);
});
});

describe('recommended flow catalog HTTP surface', () => {
it('serves list and detail JSON with cache and cross-origin metadata', async () => {
const list = getCatalog();
expect(list.status).toBe(200);
expect(list.headers.get('access-control-allow-origin')).toBe('*');
await expect(list.json()).resolves.toMatchObject({ schemaVersion: 1, flows: [{ id: 'software-factory' }] });

const detail = await getCatalogItem(new Request('https://agentrelay.com/api/v1/flows/catalog/software-factory'), {
params: Promise.resolve({ flowId: 'software-factory' }),
});
expect(detail.status).toBe(200);
await expect(detail.json()).resolves.toMatchObject({
id: 'software-factory',
name: 'Software Garden',
source: { ref: 'b4dd665eb433bd7f52d1045543aef5f14fb7891e' },
});
});

it('does not treat display names or future concepts as catalog ids', async () => {
for (const flowId of ['software-garden', 'babysitter', 'unknown']) {
const response = await getCatalogItem(new Request(`https://agentrelay.com/api/v1/flows/catalog/${flowId}`), {
params: Promise.resolve({ flowId }),
});
expect(response.status, flowId).toBe(404);
await expect(response.json()).resolves.toMatchObject({ error: { code: 'recommended_flow_not_found' } });
}
});
});
67 changes: 67 additions & 0 deletions web/scripts/verify-recommended-flow-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
const catalogUrl = new URL('../data/recommended-flow-catalog.v1.json', import.meta.url);
const catalog = JSON.parse(await readFile(catalogUrl, 'utf8'));
const MAX_SOURCE_BYTES = 1024 * 1024;

if (catalog.schemaVersion !== 1 || catalog.catalogVersion !== 1 || !Array.isArray(catalog.flows)) {
throw new Error('recommended-flow catalog must be schemaVersion 1, catalogVersion 1, with a flows array');
}

for (const flow of catalog.flows) {
const { source } = flow;
if (!source || source.kind !== 'github') throw new Error(`${flow.id}: source.kind must be github`);
if (!/^[A-Za-z0-9_.-]+$/.test(source.owner) || !/^[A-Za-z0-9_.-]+$/.test(source.repo)) {
throw new Error(`${flow.id}: invalid GitHub owner or repository`);
}
if (!/^[0-9a-f]{40}$/.test(source.ref)) throw new Error(`${flow.id}: source.ref must be a full commit SHA`);
if (!/^[0-9a-f]{64}$/.test(source.sha256)) throw new Error(`${flow.id}: source.sha256 must be lowercase hex`);
if (!/^v\d+\.\d+\.\d+$/.test(source.release)) throw new Error(`${flow.id}: source.release must be a version tag`);
if (!source.path || source.path.startsWith('/') || source.path.includes('..')) throw new Error(`${flow.id}: invalid source.path`);

const blobUrl = `https://github.com/${source.owner}/${source.repo}/blob/${source.ref}/${source.path}`;
const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${source.ref}/${source.path}`;
if (source.url !== blobUrl || source.rawUrl !== rawUrl) {
throw new Error(`${flow.id}: source URLs do not match owner/repo/ref/path`);
}

const tagRef = `refs/tags/${source.release}`;
const { stdout } = await execFileAsync('git', [
'ls-remote', `https://github.com/${source.owner}/${source.repo}.git`, tagRef, `${tagRef}^{}`,
], { maxBuffer: 1024 * 1024, timeout: 30_000 });
const remoteRefs = new Map(stdout.trim().split('\n').filter(Boolean).map(line => line.split(/\s+/, 2).reverse()));
const releasedCommit = remoteRefs.get(`${tagRef}^{}`) ?? remoteRefs.get(tagRef);
if (releasedCommit !== source.ref) {
throw new Error(`${flow.id}: ${source.release} resolves to ${releasedCommit ?? 'nothing'}, not ${source.ref}`);
}

const response = await fetch(source.rawUrl, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
if (!response.ok) throw new Error(`${flow.id}: source fetch returned HTTP ${response.status}`);
const declaredLength = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > MAX_SOURCE_BYTES) {
throw new Error(`${flow.id}: source exceeds ${MAX_SOURCE_BYTES} bytes`);
}
if (!response.body) throw new Error(`${flow.id}: source response has no body`);
const hash = createHash('sha256');
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAX_SOURCE_BYTES) {
await reader.cancel();
throw new Error(`${flow.id}: source exceeds ${MAX_SOURCE_BYTES} bytes`);
}
hash.update(value);
}
const actualHash = hash.digest('hex');
if (actualHash !== source.sha256) {
throw new Error(`${flow.id}: source hash is ${actualHash}, expected ${source.sha256}`);
}
console.log(`${flow.id}: verified ${source.release} (${source.ref}) sha256:${actualHash}`);
}
Loading