From 5d6738a43c9140e25b4da16c7c7e94f169a6321b Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 16:30:56 -0700 Subject: [PATCH 1/2] feat(flows): add a vendored plugin catalog and gallery Vendored the D1 plugins.json catalog, render it at /flows/plugins with Install plugin badges, add flowPluginBadgeMarkdown(), and document schema-2 flows-plugin.json, flows add, flows plugin, and trust tiers. Co-Authored-By: Claude Opus 5 (1M context) --- web/app/flows/plugins/PluginsGallery.tsx | 179 +++++++++++ web/app/flows/plugins/page.tsx | 35 ++ web/app/flows/plugins/plugins.module.css | 340 ++++++++++++++++++++ web/app/sitemap.ts | 6 + web/content/docs/relayflows/plugins.mdx | 137 ++++++++ web/content/docs/relayflows/recommended.mdx | 2 +- web/data/flow-plugin-catalog.v1.json | 15 + web/lib/flow-plugin-catalog.ts | 130 ++++++++ web/lib/product-docs-nav.ts | 1 + web/lib/test/flow-plugin-catalog.test.ts | 133 ++++++++ web/lib/test/product-docs.test.ts | 10 + 11 files changed, 987 insertions(+), 1 deletion(-) create mode 100644 web/app/flows/plugins/PluginsGallery.tsx create mode 100644 web/app/flows/plugins/page.tsx create mode 100644 web/app/flows/plugins/plugins.module.css create mode 100644 web/content/docs/relayflows/plugins.mdx create mode 100644 web/data/flow-plugin-catalog.v1.json create mode 100644 web/lib/flow-plugin-catalog.ts create mode 100644 web/lib/test/flow-plugin-catalog.test.ts diff --git a/web/app/flows/plugins/PluginsGallery.tsx b/web/app/flows/plugins/PluginsGallery.tsx new file mode 100644 index 0000000..265e262 --- /dev/null +++ b/web/app/flows/plugins/PluginsGallery.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { Lock, ShieldCheck, Unplug } from 'lucide-react'; +import Link from 'next/link'; + +import { FadeIn } from '../../../components/FadeIn'; +import { + FLOW_PLUGIN_BADGE_IMAGE_PATH, + FLOW_PLUGIN_TRUST_TIER_COPY, + FLOW_PLUGIN_TRUST_TIER_DISCLAIMER, + flowPluginBadgeMarkdown, + flowPluginInstallHref, + flowPluginSourceUrl, + getFlowPluginCatalog, + pluginHasUnroutableTriggers, + pluginInstallFlowUrl, +} from '../../../lib/flow-plugin-catalog'; +import s from './plugins.module.css'; + +const CAPABILITIES = [ + { + Icon: Lock, + title: 'Pinned source', + text: 'Every card is a sha and a content digest. Branches are never persisted; upgrades re-resolve.', + }, + { + Icon: ShieldCheck, + title: 'Trust tiers, displayed only', + text: 'First-party, verified, or community. The label never skips digest, compat, or event checks.', + }, + { + Icon: Unplug, + title: 'Fail closed', + text: 'A trigger the surface registry cannot route is plugin_event_unroutable, not a silent drop.', + }, +]; + +export function PluginsGallery() { + const catalog = getFlowPluginCatalog(); + + return ( +
+
+
+ +
+ + FLOW PLUGINS +
+
+ + +

+ Install a plugin +
+ onto a base flow +

+
+ + +

+ A curated catalog of schema-2 flow extensions. Each card pins a public GitHub + directory and an install badge that opens Cloud with the base flow and the plugin + source. +

+
+ + + + +
+
+ +
+ {CAPABILITIES.map(({ Icon, ...cap }, i) => ( + +
+ + +
+

{cap.title}

+

{cap.text}

+
+
+
+ ))} +
+ +
+ {catalog.plugins.map((plugin, i) => { + const installHref = flowPluginInstallHref(plugin); + const flowUrl = pluginInstallFlowUrl(plugin); + const pluginUrl = flowPluginSourceUrl(plugin); + const tier = FLOW_PLUGIN_TRUST_TIER_COPY[plugin.tier]; + const badgeMarkdown = + flowUrl && + flowPluginBadgeMarkdown({ + flowUrl, + plugins: [pluginUrl], + }); + + return ( + +
+
+

{plugin.name}

+ + {tier.label} + +
+

{plugin.description}

+
+ {plugin.base.map((base) => ( + + base: {base} + + ))} + sha {plugin.ref.slice(0, 12)} + sha256:{plugin.digest.slice(0, 12)} +
+ {pluginHasUnroutableTriggers(plugin) ? ( +

+ Fail-closed: GitHub pull_request.ready_for_review,{' '} + labeled, and unlabeled are not in the surface + registry yet, so some Babysitter triggers remain{' '} + plugin_event_unroutable. +

+ ) : null} +
+ {installHref ? ( + + Install plugin + + ) : null} + + View source + +
+ {badgeMarkdown ?
{badgeMarkdown}
: null} +
+
+ ); + })} +
+ +

+ {FLOW_PLUGIN_TRUST_TIER_DISCLAIMER} permissions.writes is declared, + UNENFORCED until gate 8. +

+
+ ); +} diff --git a/web/app/flows/plugins/page.tsx b/web/app/flows/plugins/page.tsx new file mode 100644 index 0000000..c58d9f0 --- /dev/null +++ b/web/app/flows/plugins/page.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from 'next'; + +import { GitHubStarsBadge } from '../../../components/GitHubStars'; +import { SiteFooter } from '../../../components/SiteFooter'; +import { SiteNav } from '../../../components/SiteNav'; +import { absoluteUrl, SITE_NAME } from '../../../lib/site'; +import { PluginsGallery } from './PluginsGallery'; + +const title = 'Flow plugins — Install onto a base flow'; +const description = + 'A curated catalog of schema-2 flow extensions. Install Babysitter onto Software Garden with a pinned sha, digest, and trust tier.'; + +export const metadata: Metadata = { + title, + description, + alternates: { + canonical: absoluteUrl('/flows/plugins'), + }, + openGraph: { + siteName: SITE_NAME, + title, + description, + url: absoluteUrl('/flows/plugins'), + }, +}; + +export default function FlowPluginsPage() { + return ( + <> + } /> + + + + ); +} diff --git a/web/app/flows/plugins/plugins.module.css b/web/app/flows/plugins/plugins.module.css new file mode 100644 index 0000000..2aad8f8 --- /dev/null +++ b/web/app/flows/plugins/plugins.module.css @@ -0,0 +1,340 @@ +/* Plugin gallery. Same token language as /agents (Sora headings, radial + hero, pill CTAs) with cards that carry the install badge. */ + +.page { + min-height: 100vh; + background: + radial-gradient( + ellipse 36% 28% at 10% 8%, + color-mix(in srgb, var(--primary) 16%, white 22%), + transparent 72% + ), + radial-gradient( + ellipse 34% 26% at 90% 10%, + color-mix(in srgb, var(--primary) 14%, white 20%), + transparent 70% + ), + var(--landing-hero-bg); + color: var(--fg); + font-family: var(--font-geist-sans), sans-serif; + -webkit-font-smoothing: antialiased; +} + +:global(html[data-theme='dark']) .page { + background: + radial-gradient(circle at 14% 0%, color-mix(in srgb, var(--primary) 18%, transparent), transparent 36%), + radial-gradient(circle at 78% 14%, color-mix(in srgb, var(--primary) 14%, transparent), transparent 30%), + radial-gradient(circle at 50% 22%, color-mix(in srgb, var(--primary) 8%, transparent), transparent 44%), + var(--landing-hero-bg); +} + +.heroSection { + position: relative; + margin-top: -60px; + padding-top: 60px; +} + +.hero { + max-width: 900px; + margin: 0 auto; + padding: 72px 40px 64px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 26px; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg-elevated); + border: 1px solid var(--line); + padding: 6px 16px; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--primary); +} + +.badgeDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--primary); + box-shadow: 0 0 6px color-mix(in srgb, var(--primary) 50%, transparent); +} + +.headline { + font-family: var(--font-heading), sans-serif; + font-size: clamp(2.6rem, 5.6vw, 4.1rem); + font-weight: 500; + line-height: 1.06; + letter-spacing: -0.03em; + color: var(--fg); + opacity: 0.85; + margin: 0; +} + +:global(html[data-theme='dark']) .headline { + opacity: 1; +} + +.subtitle { + font-size: 1.12rem; + line-height: 1.65; + color: var(--fg-muted); + max-width: 62ch; + margin: 0; +} + +.pillNav { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 6px; +} + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 22px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card-bg); + color: var(--fg); + font-size: 0.92rem; + font-weight: 600; + text-decoration: none; + transition: + border-color 0.2s, + color 0.2s, + transform 0.15s, + box-shadow 0.2s; +} + +.pill:hover { + border-color: var(--primary); + color: var(--primary); + transform: translateY(-1px); + box-shadow: 0 4px 16px color-mix(in srgb, var(--primary) 10%, transparent); +} + +.capabilities { + max-width: 1100px; + margin: 0 auto; + padding: 8px 40px 4px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; +} + +.capCard { + display: flex; + align-items: flex-start; + gap: 14px; + height: 100%; + padding: 18px 20px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--card-bg); +} + +.capIcon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 10px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); +} + +.capIcon svg { + width: 19px; + height: 19px; +} + +.capTitle { + font-family: var(--font-heading), sans-serif; + font-size: 0.98rem; + font-weight: 600; + color: var(--fg); + margin: 0 0 3px; +} + +.capText { + font-size: 0.86rem; + line-height: 1.5; + color: var(--fg-muted); + margin: 0; +} + +.gallery { + max-width: 1100px; + margin: 0 auto; + padding: 36px 40px 96px; + display: grid; + grid-template-columns: 1fr; + gap: 20px; +} + +.card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 26px; + border-radius: 18px; + border: 1px solid var(--line); + background: var(--card-bg); + box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 4%, transparent); +} + +.cardHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.cardName { + font-family: var(--font-heading), sans-serif; + font-size: 1.35rem; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--fg); + margin: 0; +} + +.cardDescription { + font-size: 0.98rem; + line-height: 1.6; + color: var(--fg-muted); + margin: 0; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip, +.tier { + display: inline-flex; + align-items: center; + padding: 3px 11px; + border-radius: 100px; + font-size: 0.74rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.chip { + border: 1px solid var(--line); + background: var(--section-bg, var(--card-bg)); + color: var(--fg-muted); + font-family: var(--font-geist-mono), monospace; +} + +.tier { + border: 1px solid color-mix(in srgb, var(--primary) 16%, transparent); + background: color-mix(in srgb, var(--primary) 7%, transparent); + color: var(--primary); +} + +.note { + margin: 0; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--secondary-500, #c2410c) 28%, var(--line)); + background: color-mix(in srgb, var(--secondary-500, #c2410c) 8%, transparent); + color: var(--fg); + font-size: 0.86rem; + line-height: 1.55; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; + margin-top: 4px; +} + +.install { + display: inline-flex; + align-items: center; + line-height: 0; +} + +.install img { + display: block; + height: 32px; + width: auto; +} + +.source { + font-size: 0.88rem; + font-weight: 600; + color: var(--fg-muted); + text-decoration: none; +} + +.source:hover { + color: var(--primary); +} + +.snippet { + margin: 0; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--line); + background: var(--section-bg, var(--bg)); + color: var(--fg-muted); + font-family: var(--font-geist-mono), monospace; + font-size: 0.72rem; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.disclaimer { + max-width: 72ch; + margin: 0 auto; + padding: 0 40px 80px; + font-size: 0.88rem; + line-height: 1.6; + color: var(--fg-muted); + text-align: center; +} + +.note code, +.disclaimer code { + font-family: var(--font-geist-mono), monospace; + font-size: 0.9em; +} + +@media (max-width: 860px) { + .hero { + padding: 56px 24px 48px; + } + + .capabilities, + .gallery, + .disclaimer { + padding-left: 24px; + padding-right: 24px; + } + + .capabilities { + grid-template-columns: 1fr; + } +} diff --git a/web/app/sitemap.ts b/web/app/sitemap.ts index 2273d83..fe56cf1 100644 --- a/web/app/sitemap.ts +++ b/web/app/sitemap.ts @@ -92,6 +92,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly', priority: 0.9, }, + { + url: absoluteUrl('/flows/plugins'), + lastModified: now, + changeFrequency: 'weekly', + priority: 0.8, + }, { url: absoluteUrl('/agents/use-cases'), lastModified: now, diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx new file mode 100644 index 0000000..eaced42 --- /dev/null +++ b/web/content/docs/relayflows/plugins.mdx @@ -0,0 +1,137 @@ +--- +title: 'Plugins' +description: 'Install a schema-2 flow extension onto a base flow — flows-plugin.json, flows add, flows plugin, and the trust tiers the gallery displays.' +--- + +A **flow plugin** is a directory in a public GitHub repository whose `flows-plugin.json` says what it contributes to a base flow. Helper plugins (`kind` absent or `"helper"`) still install from npm as `@flows/helper-*` and extend `Ctx` with verbs; that v1 path is unchanged. Schema 2 adds a second kind on the same file: + +```json +{ "schema": 2, "kind": "flow-extension" } +``` + +The entry default-exports `flow()` and declares handlers, hooks, triggers, permissions, compat, and the same mandatory `preflight`. Browse the vendored catalog on the [plugin gallery](/flows/plugins). Each card's **Install plugin** badge opens Cloud with the base flow and the plugin source: + +```text +/cloud/flows/deploy?flow=&plugin= +``` + +`plugin` may repeat. The badge helper appends each one so a second plugin does not overwrite the first. + + + See the plugin gallery — Babysitter on Software Garden, with the install badge. + + +## `flows-plugin.json` schema 2 + +`kind` absent or `"helper"` keeps today's v1 semantics byte-for-byte. `"schema": 2, "kind": "flow-extension"` is additive: it requires `entry`, `compat`, and `source` (filled by `flows add`); it forbids `verbs` (a flow extension that also wants verbs ships a helper plugin beside it). Unknown top-level keys are refused (`plugin_manifest_invalid`). `preflight` is mandatory. + +Worked example — Babysitter on Software Garden: + +```json +{ + "schema": 2, + "kind": "flow-extension", + "name": "babysitter", + "version": "0.1.0", + "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate.", + "compat": { + "surface": "^2.0.22", + "sdk": "^2.0.22", + "base": [{ "name": "software-factory", "version": "^2.0.0" }] + }, + "entry": "babysitter.flow.ts", + "extends": { "handlers": true, "hooks": ["merge-gate"], "verbs": [], "gates": [] }, + "triggers": [ + { "provider": "github", "event": "pull_request", "actions": ["opened", "synchronize", "reopened", "ready_for_review"] }, + { "provider": "github", "event": "pull_request_review", "actions": ["submitted"] }, + { "provider": "github", "event": "check_run", "actions": ["completed"] }, + { "provider": "github", "event": "issue_comment", "actions": ["created"] } + ], + "permissions": { + "integrations": ["github"], + "harnesses": ["claude"], + "mcp": [], + "writes": ["github:pull_request:comment"], + "budget": { "dollars": 8, "wallclock": "45m" } + }, + "preflight": { "credentials": [], "servers": ["https://api.github.com"] } +} +``` + +Rules that matter at review time: + +- `name` is kebab-case (`^[a-z0-9]+(-[a-z0-9]+)*$`). +- `compat.surface` / `compat.sdk` are semver ranges checked against the pinned runtime. Mismatch is `plugin_incompatible`, never a warning. +- `compat.base[]` names the base `flow()` and a version range. A base without `version` matches only `"*"`. +- `permissions.writes` is a **declaration for review**, labelled UNENFORCED until gate 8 (#442) turns it into scope enforcement. The honest statement until then: the plugin can do anything the deployment can. +- Manifest triggers are validated against the surface registry `providerEventTypes`. An event the registry cannot route is `plugin_event_unroutable`. + + + GitHub `pull_request.ready_for_review`, `pull_request.labeled`, and `pull_request.unlabeled` are not in the surface registry yet. A Babysitter manifest that declares them is refused `plugin_event_unroutable` until the relayfile adapter catalog grows. The gallery states this fail-closed; it does not paper over it. + + +## `flows add` + +Input forms accepted (ref may be a tag, branch, or sha **at input only**): + +```text +flows add github:/@# +flows add https://github.com///tree// +flows add /@# +flows add +``` + +The GitHub path is public, unauthenticated, https only. Private repositories are out of scope and answer 404 (`plugin_source_unresolved`). Resolution: + +1. Ref → 40-hex sha. +2. Tree at that commit, refusing symlinks, submodules, traversal, files over 256 KB, or a plugin over 2 MB. +3. Content digest = sha256 of the canonical `[{bytes,path,sha256}]` payload (the same routine sealed bundles use). +4. Bytes land at `.flows/plugins/@sha256:/`, never in `node_modules`. +5. `flows.json.plugins` records the canonical `github:/@#` — a branch or tag is never persisted. +6. `flows.lock.json` (version 2) records name, version, source, digest, manifest hash, and declaration order. That order is composition order. + +A later re-fetch that yields a different digest for the same sha is `plugin_source_drift`. + +## `flows plugin` + +```text +flows plugin list [--json] +flows plugin verify [--json] [--offline] +flows plugin remove [--json] +flows plugin update [--json] [--yes] [--to ] [] +``` + +- **list** reads `flows.lock.json`. +- **verify** re-hashes the store against the lock and, unless `--offline`, re-fetches the pinned commit. Drift is exit 2. +- **remove** drops the name from `flows.json.plugins` and the lock (order is rebuilt) and deletes the store directory only if nothing else references it. +- **update** re-resolves, shows the permissions / events / budget diff, and requires `--yes` (refuse otherwise, exit 2). + +`flows check` composes base + plugins, runs plugin preflight, compat, and event routability, and prints one `EXTENSION` line per composed extension. `flows deploy … [--plugin ]…` is send-only: it does not rewrite the working tree's `flows.json`. + +## Trust tiers + +The gallery and the deploy wizard display a tier. **The label is never used to skip a check.** + +| Tier | How it is assigned | +| --- | --- | +| **first-party** | `AgentWorkforce/*` at a sha reachable from `main` | +| **verified** | bundle `identity.json` keyid matches a publisher key registered in Cloud | +| **community** | anything else | + +Babysitter in catalog v1 is **community**: the owner is AgentWorkforce, but the pinned sha is not reachable from `main` until the babysitter branch merges. That is the honest label, not a downgrade of the code. + + + `permissions.writes` stays a reviewed declaration labelled UNENFORCED until gate 8. Displaying a first-party or verified badge does not enforce write scope, skip the digest, or route an event the registry does not carry. + + +## Install badge + +`flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive: + +```markdown +[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F05c3dff138883322e80cb793b1f5a097ad510572%2Fexamples%2Fbabysitter) +``` + +The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge. + +The catalog itself is versioned JSON, `{version:1, plugins:[{name, description, source:{owner,repo,path}, ref, digest, compat, tier, base}]}`. Any public repo with a `flows-plugin.json` is installable by URL; the catalog is a curated index, not a gate. diff --git a/web/content/docs/relayflows/recommended.mdx b/web/content/docs/relayflows/recommended.mdx index bf1a0fb..01e813d 100644 --- a/web/content/docs/relayflows/recommended.mdx +++ b/web/content/docs/relayflows/recommended.mdx @@ -11,7 +11,7 @@ Software Garden is the display name of the first recommended flow. Its stable ca 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. +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. Babysitter is a [flow plugin](/docs/relayflows/plugins) installed onto Software Garden, not a recommended flow of its own. ## Catalog API diff --git a/web/data/flow-plugin-catalog.v1.json b/web/data/flow-plugin-catalog.v1.json new file mode 100644 index 0000000..d7323b9 --- /dev/null +++ b/web/data/flow-plugin-catalog.v1.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "plugins": [ + { + "name": "babysitter", + "description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, exact-head merge gate. Fail-closed: GitHub pull_request.ready_for_review, labeled, and unlabeled are not in the surface registry, so a manifest that declares them is refused plugin_event_unroutable until the relayfile adapter catalog grows.", + "source": { "owner": "AgentWorkforce", "repo": "flows", "path": "examples/babysitter" }, + "ref": "05c3dff138883322e80cb793b1f5a097ad510572", + "digest": "ae6af3335eb6d4e54559327acc1465419244b47911d8ff356850b61f6228d862", + "compat": { "surface": "^2.0.22", "sdk": "^2.0.22", "base": ["software-factory"] }, + "tier": "community", + "base": ["software-factory"] + } + ] +} diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts new file mode 100644 index 0000000..d20bc3a --- /dev/null +++ b/web/lib/flow-plugin-catalog.ts @@ -0,0 +1,130 @@ +import catalogJson from '../data/flow-plugin-catalog.v1.json'; +import { SITE_URL } from './site'; + +export const FLOW_PLUGIN_TRUST_TIERS = ['first-party', 'verified', 'community'] as const; +export type FlowPluginTrustTier = (typeof FLOW_PLUGIN_TRUST_TIERS)[number]; + +export type FlowPluginCatalogEntry = { + name: string; + description: string; + source: { owner: string; repo: string; path: string }; + ref: string; + digest: string; + compat: { surface: string; sdk: string; base: string[] }; + tier: FlowPluginTrustTier; + base: string[]; +}; + +export type FlowPluginCatalog = { + version: 1; + plugins: FlowPluginCatalogEntry[]; +}; + +export const FLOW_PLUGIN_TRUST_TIER_COPY: Record< + FlowPluginTrustTier, + { label: string; summary: string } +> = { + 'first-party': { + label: 'First-party', + summary: 'AgentWorkforce/* at a sha reachable from main.', + }, + verified: { + label: 'Verified', + summary: 'Publisher keyid registered in Cloud.', + }, + community: { + label: 'Community', + summary: 'Any other public source.', + }, +}; + +/** Display-only. Trust tiers never skip digest, compat, or event-routability checks. */ +export const FLOW_PLUGIN_TRUST_TIER_DISCLAIMER = + 'Tiers are displayed only. They never skip digest, compat, or event-routability checks.'; + +export const SOFTWARE_FACTORY_FLOW_URL = + 'https://github.com/AgentWorkforce/flows/blob/main/examples/software-factory/software-factory.flow.ts'; + +export const BASE_FLOW_URLS: Record = { + 'software-factory': SOFTWARE_FACTORY_FLOW_URL, +}; + +export const FLOW_PLUGIN_BADGE_IMAGE_PATH = '/deploy-flow_small.svg'; + +const catalog = catalogJson as FlowPluginCatalog; + +/** Vendored copy of AgentWorkforce/flows catalog/plugins.json (D1). */ +export function getFlowPluginCatalog(): FlowPluginCatalog { + return catalog; +} + +export function getFlowPlugin(name: string): FlowPluginCatalogEntry | null { + return catalog.plugins.find((plugin) => plugin.name === name) ?? null; +} + +export function flowPluginSourceUrl(plugin: FlowPluginCatalogEntry): string { + const { owner, repo, path } = plugin.source; + return `https://github.com/${owner}/${repo}/tree/${plugin.ref}/${path}`; +} + +export function flowPluginGithubRef(plugin: FlowPluginCatalogEntry): string { + const { owner, repo, path } = plugin.source; + return `github:${owner}/${repo}@${plugin.ref}#${path}`; +} + +export function baseFlowUrl(name: string): string | null { + return BASE_FLOW_URLS[name] ?? null; +} + +export function pluginInstallFlowUrl(plugin: FlowPluginCatalogEntry): string | null { + for (const name of plugin.base) { + const url = baseFlowUrl(name); + if (url) return url; + } + return null; +} + +export function pluginHasUnroutableTriggers(plugin: FlowPluginCatalogEntry): boolean { + return plugin.description.includes('plugin_event_unroutable'); +} + +function originFrom(appOrigin: string): string { + return appOrigin.replace(/\/+$/, ''); +} + +/** + * Query string for `/cloud/flows/deploy`. `plugin` is appended so a second + * plugin does not overwrite the first (`URLSearchParams.set` would). + */ +export function flowPluginDeploySearch(input: { flowUrl: string; plugins: string[] }): string { + const params = new URLSearchParams(); + params.set('flow', input.flowUrl); + for (const plugin of input.plugins) { + params.append('plugin', plugin); + } + return params.toString(); +} + +export function flowPluginInstallPath(input: { flowUrl: string; plugins: string[] }): string { + return `/cloud/flows/deploy?${flowPluginDeploySearch(input)}`; +} + +export function flowPluginInstallHref(plugin: FlowPluginCatalogEntry): string | null { + const flowUrl = pluginInstallFlowUrl(plugin); + if (!flowUrl) return null; + return flowPluginInstallPath({ flowUrl, plugins: [flowPluginSourceUrl(plugin)] }); +} + +/** + * README badge for installing a plugin onto a base flow. Counterpart of + * Cloud's `flowDeployBadgeMarkdown()`, with `plugin` repeated via append. + */ +export function flowPluginBadgeMarkdown(input: { + appOrigin?: string; + flowUrl: string; + plugins: string[]; +}): string { + const origin = originFrom(input.appOrigin ?? SITE_URL); + const query = flowPluginDeploySearch(input); + return `[![Install plugin](${origin}${FLOW_PLUGIN_BADGE_IMAGE_PATH})](${origin}/cloud/flows/deploy?${query})`; +} diff --git a/web/lib/product-docs-nav.ts b/web/lib/product-docs-nav.ts index 0f683cc..b11d50e 100644 --- a/web/lib/product-docs-nav.ts +++ b/web/lib/product-docs-nav.ts @@ -238,6 +238,7 @@ export const relayflowsSection: ProductDocSection = { { title: 'Multi-agent flows', slug: 'multi-agent' }, { title: 'Cloud', slug: 'cloud' }, { title: 'Recommended flows', slug: 'recommended' }, + { title: 'Plugins', slug: 'plugins' }, { title: 'Memory & integrations', slug: 'memory-and-integrations' }, { title: 'Reliability', slug: 'reliability' }, ], diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts new file mode 100644 index 0000000..9e4c16f --- /dev/null +++ b/web/lib/test/flow-plugin-catalog.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; + +import { + FLOW_PLUGIN_TRUST_TIERS, + SOFTWARE_FACTORY_FLOW_URL, + flowPluginBadgeMarkdown, + flowPluginGithubRef, + flowPluginInstallHref, + flowPluginInstallPath, + flowPluginSourceUrl, + getFlowPlugin, + getFlowPluginCatalog, + pluginHasUnroutableTriggers, +} from '../flow-plugin-catalog'; +import { relayflowsSection, getProductSearchIndex } from '../product-docs'; + +const SHA = /^[0-9a-f]{40}$/; +const HEX64 = /^[0-9a-f]{64}$/; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +describe('flow plugin catalog', () => { + const catalog = getFlowPluginCatalog(); + + it('is version 1 with unique kebab-case plugin names', () => { + expect(catalog.version).toBe(1); + expect(Array.isArray(catalog.plugins)).toBe(true); + expect(catalog.plugins.length).toBeGreaterThan(0); + const names = catalog.plugins.map((plugin) => plugin.name); + expect(names.every((name) => NAME.test(name))).toBe(true); + expect(new Set(names).size).toBe(names.length); + }); + + it('records a fail-closed babysitter entry with a pinned sha and digest', () => { + const babysitter = getFlowPlugin('babysitter'); + expect(babysitter).toMatchObject({ + source: { owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter' }, + tier: 'community', + base: ['software-factory'], + }); + expect(babysitter!.ref).toMatch(SHA); + expect(babysitter!.digest).toMatch(HEX64); + expect(babysitter!.description).toContain('plugin_event_unroutable'); + expect(FLOW_PLUGIN_TRUST_TIERS.includes(babysitter!.tier)).toBe(true); + expect(pluginHasUnroutableTriggers(babysitter!)).toBe(true); + }); + + it('builds a GitHub tree URL at the pinned sha, not a branch', () => { + const babysitter = getFlowPlugin('babysitter')!; + expect(flowPluginSourceUrl(babysitter)).toBe( + `https://github.com/AgentWorkforce/flows/tree/${babysitter.ref}/examples/babysitter`, + ); + expect(flowPluginGithubRef(babysitter)).toBe( + `github:AgentWorkforce/flows@${babysitter.ref}#examples/babysitter`, + ); + }); +}); + +describe('flowPluginBadgeMarkdown', () => { + const pluginA = + 'https://github.com/AgentWorkforce/flows/tree/05c3dff138883322e80cb793b1f5a097ad510572/examples/babysitter'; + const pluginB = 'github:acme/plugins@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa#extra'; + + it('points the Install plugin badge at /cloud/flows/deploy with flow and plugin', () => { + const markdown = flowPluginBadgeMarkdown({ + appOrigin: 'https://agentrelay.com/', + flowUrl: SOFTWARE_FACTORY_FLOW_URL, + plugins: [pluginA], + }); + const href = markdown.match(/\((https:\/\/agentrelay\.com\/cloud\/flows\/deploy\?[^)]+)\)/)?.[1]; + expect(markdown.startsWith('[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)]')).toBe( + true, + ); + expect(href).toBeDefined(); + const url = new URL(href!); + expect(url.pathname).toBe('/cloud/flows/deploy'); + expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); + expect(url.searchParams.getAll('plugin')).toEqual([pluginA]); + }); + + it('appends plugin so repeats survive', () => { + const markdown = flowPluginBadgeMarkdown({ + appOrigin: 'https://agentrelay.com', + flowUrl: SOFTWARE_FACTORY_FLOW_URL, + plugins: [pluginA, pluginB], + }); + const href = markdown.match(/\((https:\/\/agentrelay\.com\/cloud\/flows\/deploy\?[^)]+)\)/)?.[1]; + const url = new URL(href!); + expect(url.searchParams.getAll('plugin')).toEqual([pluginA, pluginB]); + expect(url.searchParams.get('plugin')).toBe(pluginA); + + const path = flowPluginInstallPath({ + flowUrl: SOFTWARE_FACTORY_FLOW_URL, + plugins: [pluginA, pluginB], + }); + expect(new URL(path, 'https://agentrelay.com').searchParams.getAll('plugin')).toEqual([ + pluginA, + pluginB, + ]); + }); + + it('wires the babysitter gallery card to Software Garden plus the pinned plugin tree', () => { + const babysitter = getFlowPlugin('babysitter')!; + const href = flowPluginInstallHref(babysitter); + expect(href).toBeTruthy(); + const url = new URL(href!, 'https://agentrelay.com'); + expect(url.pathname).toBe('/cloud/flows/deploy'); + expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); + expect(url.searchParams.getAll('plugin')).toEqual([flowPluginSourceUrl(babysitter)]); + }); +}); + +describe('Flows plugin docs', () => { + it('publishes plugins in navigation and scoped search', () => { + const navItems = relayflowsSection.nav.flatMap((group) => group.items); + + expect(navItems).toContainEqual({ title: 'Plugins', slug: 'plugins' }); + + const searchEntry = getProductSearchIndex(relayflowsSection).find( + (entry) => entry.slug === 'plugins', + ); + + expect(searchEntry).toMatchObject({ title: 'Plugins' }); + expect(searchEntry?.headings).toEqual([ + 'flows-plugin.json schema 2', + 'flows add', + 'flows plugin', + 'Trust tiers', + 'Install badge', + ]); + expect(searchEntry?.body).toContain('Schema 2'); + expect(searchEntry?.body).toContain('flow-extension'); + }); +}); diff --git a/web/lib/test/product-docs.test.ts b/web/lib/test/product-docs.test.ts index ccaa63f..b8bc1bd 100644 --- a/web/lib/test/product-docs.test.ts +++ b/web/lib/test/product-docs.test.ts @@ -5,6 +5,7 @@ import { fileSection, getProductDocSlugs, getProductSearchIndex, + relayflowsSection, } from '../product-docs'; describe('Factory product docs', () => { @@ -68,3 +69,12 @@ describe('Relayfile product docs', () => { expect(searchEntry?.headings).toContain('Publish the review'); }); }); + +describe('Flows product docs', () => { + it('lists plugins next to recommended flows', () => { + const goingFurther = relayflowsSection.nav.find((group) => group.title === 'Going further'); + + expect(goingFurther?.items).toContainEqual({ title: 'Plugins', slug: 'plugins' }); + expect(getProductDocSlugs(relayflowsSection)).toContain('plugins'); + }); +}); From 6af11bec1dca68a592021a4441d560deb0d4057a Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Sun, 20 Sep 2026 19:53:46 -0700 Subject: [PATCH 2/2] fix(flows): pin Software Garden install badges to v2.0.22 Gallery and docs badges used blob/main, which moves. Pin to the recommended-catalog commit b4dd665 / release v2.0.22. Co-Authored-By: Claude Opus 5 (1M context) --- web/content/docs/relayflows/plugins.mdx | 2 +- web/lib/flow-plugin-catalog.ts | 2 +- web/lib/test/flow-plugin-catalog.test.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/content/docs/relayflows/plugins.mdx b/web/content/docs/relayflows/plugins.mdx index eaced42..a352c8c 100644 --- a/web/content/docs/relayflows/plugins.mdx +++ b/web/content/docs/relayflows/plugins.mdx @@ -129,7 +129,7 @@ Babysitter in catalog v1 is **community**: the owner is AgentWorkforce, but the `flowPluginBadgeMarkdown()` renders the README form. `plugin` is appended so repeats survive: ```markdown -[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fmain%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F05c3dff138883322e80cb793b1f5a097ad510572%2Fexamples%2Fbabysitter) +[![Install plugin](https://agentrelay.com/deploy-flow_small.svg)](https://agentrelay.com/cloud/flows/deploy?flow=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Fblob%2Fb4dd665eb433bd7f52d1045543aef5f14fb7891e%2Fexamples%2Fsoftware-factory%2Fsoftware-factory.flow.ts&plugin=https%3A%2F%2Fgithub.com%2FAgentWorkforce%2Fflows%2Ftree%2F05c3dff138883322e80cb793b1f5a097ad510572%2Fexamples%2Fbabysitter) ``` The first plugin in the vendored catalog is Babysitter. Its base flow URL is Software Garden (`software-factory`); its plugin URL is the tree at the pinned sha. The [plugin gallery](/flows/plugins) shows the live badge. diff --git a/web/lib/flow-plugin-catalog.ts b/web/lib/flow-plugin-catalog.ts index d20bc3a..f60f0e5 100644 --- a/web/lib/flow-plugin-catalog.ts +++ b/web/lib/flow-plugin-catalog.ts @@ -43,7 +43,7 @@ export const FLOW_PLUGIN_TRUST_TIER_DISCLAIMER = 'Tiers are displayed only. They never skip digest, compat, or event-routability checks.'; export const SOFTWARE_FACTORY_FLOW_URL = - 'https://github.com/AgentWorkforce/flows/blob/main/examples/software-factory/software-factory.flow.ts'; + 'https://github.com/AgentWorkforce/flows/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/examples/software-factory/software-factory.flow.ts'; export const BASE_FLOW_URLS: Record = { 'software-factory': SOFTWARE_FACTORY_FLOW_URL, diff --git a/web/lib/test/flow-plugin-catalog.test.ts b/web/lib/test/flow-plugin-catalog.test.ts index 9e4c16f..46ef017 100644 --- a/web/lib/test/flow-plugin-catalog.test.ts +++ b/web/lib/test/flow-plugin-catalog.test.ts @@ -74,6 +74,8 @@ describe('flowPluginBadgeMarkdown', () => { const url = new URL(href!); expect(url.pathname).toBe('/cloud/flows/deploy'); expect(url.searchParams.get('flow')).toBe(SOFTWARE_FACTORY_FLOW_URL); + expect(SOFTWARE_FACTORY_FLOW_URL).toContain('/blob/b4dd665eb433bd7f52d1045543aef5f14fb7891e/'); + expect(SOFTWARE_FACTORY_FLOW_URL).not.toContain('/blob/main/'); expect(url.searchParams.getAll('plugin')).toEqual([pluginA]); });