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
2 changes: 1 addition & 1 deletion catalog/plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"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.",
"description": "Live-state PR babysitter: parallel review lenses, deterministic reconciliation, and an exact-head merge gate across the full eleven-event subscription contract.",
Comment thread
cursor[bot] marked this conversation as resolved.
"source": { "owner": "AgentWorkforce", "repo": "flows", "path": "examples/babysitter" },
"ref": "05c3dff138883322e80cb793b1f5a097ad510572",
"digest": "ae6af3335eb6d4e54559327acc1465419244b47911d8ff356850b61f6228d862",
Expand Down
8 changes: 3 additions & 5 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,11 +649,9 @@ execute nowhere (#301); what composition changes today is the declared
trigger set that `flows check`, requirements, and future dispatch read.

GitHub `pull_request.ready_for_review`, `pull_request.labeled`, and
`pull_request.unlabeled` are **not** in the surface registry. The registry is
generated from the pinned relayfile adapter mappings (`scripts/generate-triggers.mjs`);
this repo cannot add those actions without an adapter-package change. A
Babysitter manifest that declares them is refused `plugin_event_unroutable`
until that upstream catalog grows.
`pull_request.unlabeled` are in the generated surface registry through the
pinned relayfile adapter catalog. Babysitter declares all three and installs
without narrowing its eleven-subscription contract.

## 4. Build: the immutable bundle

Expand Down
9 changes: 3 additions & 6 deletions examples/babysitter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,9 @@ flows add github:AgentWorkforce/flows@<sha>#examples/babysitter

The manifest's `triggers` are the eleven-subscription contract in
`subscriptions.ts`, family by family, and `tests/manifest.test.ts` pins the
two lists to each other. Three of those actions — `pull_request.ready_for_review`,
`labeled`, `unlabeled` — are not yet in the surface event registry
(`providerEventTypes`), so the flows CLI refuses this manifest with
`plugin_event_unroutable` until that registry change lands; the manifest
deliberately declares the full contract rather than the routable subset, so
that an install grants exactly the events the flow registers.
two lists to each other. The Surface registry includes all eleven events,
including `pull_request.ready_for_review`, `labeled`, and `unlabeled`, so an
install grants exactly the events the flow registers.

## Operator input

Expand Down
71 changes: 4 additions & 67 deletions packages/sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@relayfile/adapter-core": "0.5.26",
"@relayfile/adapter-core": "0.6.2",
Comment thread
miyaontherelay marked this conversation as resolved.
"@relayfile/relay-helpers": "0.4.12",
"@relayflows/surface": "2.0.25",
"@types/js-yaml": "^4.0.9",
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/tests/catalog-plugins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('catalog/plugins.json', () => {
expect(new Set(names).size).toBe(names.length);
});

it('records a fail-closed babysitter entry with a pinned sha and digest', () => {
it('records the full Babysitter contract with a pinned sha and digest', () => {
const babysitter = catalog.plugins.find(p => p.name === 'babysitter');
expect(babysitter).toMatchObject({
source: { owner: 'AgentWorkforce', repo: 'flows', path: 'examples/babysitter' },
Expand All @@ -31,7 +31,8 @@ describe('catalog/plugins.json', () => {
});
expect(babysitter!.ref).toMatch(SHA);
expect(babysitter!.digest).toMatch(HEX64);
expect(String(babysitter!.description)).toContain('plugin_event_unroutable');
expect(String(babysitter!.description)).toContain('eleven-event subscription contract');
expect(String(babysitter!.description)).not.toContain('plugin_event_unroutable');
expect(TIERS.has(String(babysitter!.tier))).toBe(true);
});
});
24 changes: 9 additions & 15 deletions packages/sdk/tests/flow-extension-compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ describe('composing flow extensions onto a base flow', () => {
const p = project();
await install(p);
const loaded = await loadAuthoredFlow(p.flow, { versions });
expect(loaded.extensions.map(e => ({ name: e.name, ref: e.ref, handlers: e.handlers.length }))).toEqual([{ name: 'babysitter', ref: REF, handlers: 8 }]);
expect(loaded.extensions.map(e => ({ name: e.name, ref: e.ref, handlers: e.handlers.length }))).toEqual([{ name: 'babysitter', ref: REF, handlers: 11 }]);
expect(subscriptions(loaded)).toEqual([
'issues.opened',
'pull_request.opened', 'pull_request.synchronize', 'pull_request.reopened', 'pull_request.closed',
'pull_request.opened', 'pull_request.synchronize', 'pull_request.reopened', 'pull_request.ready_for_review',
'pull_request.closed', 'pull_request.labeled', 'pull_request.unlabeled',
'pull_request_review.submitted', 'pull_request_review.dismissed', 'check_run.completed', 'issue_comment.created',
]);
const composed = loaded.getDefinition(loaded.handle);
Expand All @@ -89,12 +90,12 @@ describe('composing flow extensions onto a base flow', () => {
// Every composed subscription is one the surface registry can lower.
expect(preflightProviderTriggers(composed.handlers.map(h => h.trigger))).toEqual([]);
// The extension's own handle is not the root: asking for its definition goes to the surface, not the composition.
expect(loaded.getDefinition(loaded.extensions[0]!.handle).handlers).toHaveLength(8);
expect(loaded.getDefinition(loaded.extensions[0]!.handle).handlers).toHaveLength(11);
// Its graph node resolves through the accessor its own entry import returned, not the root's.
const node = loaded.graph[1]!;
expect(node.getDefinition).toBe(loaded.extensions[0]!.getDefinition);
expect(node.getDefinition(node.handle).name).toBe('babysitter');
expect(node.getDefinition(node.handle).handlers).toHaveLength(8);
expect(node.getDefinition(node.handle).handlers).toHaveLength(11);
});
it('loads the root alone with extensions: none, and helper loading ignores extension entries', async () => {
const p = project();
Expand Down Expand Up @@ -129,12 +130,12 @@ describe('composing flow extensions onto a base flow', () => {
await install(p);
const { report } = await checkAuthoredTriggers(p.flow);
expect(report.ok).toBe(true);
expect(report.extensions).toEqual([{ name: 'babysitter', version: '0.1.0', ref: REF, digest: expect.stringMatching(/^[0-9a-f]{64}$/), handlers: 8, hooks: [] }]);
expect(report.extensions).toEqual([{ name: 'babysitter', version: '0.1.0', ref: REF, digest: expect.stringMatching(/^[0-9a-f]{64}$/), handlers: 11, hooks: [] }]);
expect(report.requirements?.integrations.map(i => i.provider)).toContain('github');
expect(report.requirements?.harnessUses).toContainEqual({ harness: 'claude', detail: 'plugin "babysitter"' });
expect(await runCli(['check', p.flow], p.io)).toBe(0);
expect(p.text()).toContain(`EXTENSION babysitter@0.1.0 ${REF} sha256:`);
expect(p.text()).toContain('8 handler(s) composed after the base flow');
expect(p.text()).toContain('11 handler(s) composed after the base flow');
const loaded = await loadAuthoredFlow(p.flow, { versions });
const submissions = await collectExtensionSubmissions(loaded);
expect(submissions).toHaveLength(1);
Expand Down Expand Up @@ -271,8 +272,8 @@ describe('composition fails closed', () => {
});
it.each([
['an entry subscribing beyond its manifest', undefined,
"import { flow, github } from '@relayflows/surface';\nexport default flow('babysitter', async f => { f.done('success'); }).on(github.pull_request('opened'), async f => { f.done('success'); }).on(github.pull_request('labeled'), async f => { f.done('success'); });\n",
'plugin_manifest_invalid', 'subscribes to github pull_request.labeled'],
"import { flow, github } from '@relayflows/surface';\nexport default flow('babysitter', async f => { f.done('success'); }).on(github.pull_request('opened'), async f => { f.done('success'); }).on(github.pull_request('edited'), async f => { f.done('success'); });\n",
'plugin_manifest_invalid', 'subscribes to github pull_request.edited'],
['an entry with a schedule handler', undefined,
"import { flow, schedule } from '@relayflows/surface';\nexport default flow('babysitter', async f => { f.done('success'); }).on(schedule.every('1h'), async f => { f.done('success'); });\n",
'plugin_unsupported', 'schedule trigger'],
Expand All @@ -293,11 +294,4 @@ describe('composition fails closed', () => {
await install(p, variant(m => m, entry));
await expect(loadAuthoredFlow(p.flow, { versions })).rejects.toMatchObject({ code, message: expect.stringContaining(message) });
});
it('never composes an event the surface registry cannot lower, even if an entry asks for it', async () => {
// The manifest gate refuses ready_for_review at install; an entry alone cannot smuggle it past the manifest.
const p = project();
const entries = variant(m => m, "import { flow, github } from '@relayflows/surface';\nexport default flow('babysitter', async f => { f.done('success'); }).on(github.pull_request('ready_for_review'), async f => { f.done('success'); });\n");
await install(p, entries);
await expect(loadAuthoredFlow(p.flow, { versions })).rejects.toMatchObject({ code: 'plugin_manifest_invalid', message: expect.stringContaining('pull_request.ready_for_review') });
});
});
9 changes: 6 additions & 3 deletions packages/sdk/tests/plugin-extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('flows add <github ref>', () => {
expect(existsSync(join(store, 'babysitter.flow.ts'))).toBe(true);
expect(existsSync(join(store, 'manifest.json'))).toBe(true);
expect(p.text()).toContain(`Added babysitter@0.1.0 (flow-extension) from ${REF}`);
expect(p.text()).toContain('events: github pull_request[opened,synchronize,reopened,closed]; github pull_request_review[submitted,dismissed]; github check_run[completed]; github issue_comment[created]');
expect(p.text()).toContain('events: github pull_request[opened,synchronize,reopened,ready_for_review,closed,labeled,unlabeled]; github pull_request_review[submitted,dismissed]; github check_run[completed]; github issue_comment[created]');
expect(p.text()).toContain('writes (declared, unenforced): github:pull_request:comment');
expect(p.text()).toContain('recorded in flows.json and flows.lock.json');
expect(gh.calls.some(url => url.includes('/commits/feat%2Fbabysitter-v2'))).toBe(true);
Expand Down Expand Up @@ -209,11 +209,14 @@ describe('schema-2 manifest validation', () => {
const m = validateFlowExtensionManifest(manifestJson);
expect(m).toMatchObject({ schema: 2, kind: 'flow-extension', name: 'babysitter', entry: 'babysitter.flow.ts', extends: { handlers: true, hooks: [] } });
expect(m.triggers).toHaveLength(4);
expect(m.triggers[0]).toMatchObject({
provider: 'github', event: 'pull_request',
actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'],
});
expect(Object.isFrozen(m) && Object.isFrozen(m.permissions) && Object.isFrozen(m.triggers)).toBe(true);
});
it.each([
['an event the surface registry cannot lower', (m: Record<string, unknown>) => ({ ...m, triggers: [{ provider: 'github', event: 'pull_request', actions: ['ready_for_review'] }] }), 'plugin_event_unroutable'],
['labeled/unlabeled, which the registry lacks', (m: Record<string, unknown>) => ({ ...m, triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled', 'unlabeled'] }] }), 'plugin_event_unroutable'],
['an event the surface registry cannot lower', (m: Record<string, unknown>) => ({ ...m, triggers: [{ provider: 'github', event: 'pull_request', actions: ['future_action'] }] }), 'plugin_event_unroutable'],
['an unknown provider', (m: Record<string, unknown>) => ({ ...m, triggers: [{ provider: 'nope', event: 'x', actions: [] }] }), 'plugin_event_unroutable'],
['an unknown kind', (m: Record<string, unknown>) => ({ ...m, kind: 'banana' }), 'plugin_kind_invalid'],
['schema 1 with the extension kind', (m: Record<string, unknown>) => ({ ...m, schema: 1 }), 'plugin_manifest_invalid'],
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/tests/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ describe('preflight: CLI resolution and refusal predicates', () => {
{ ref: 'github:o/r@main#ext', fetch: repo([...files(manifest), { path: 'ext/link', data: Buffer.from('x'), mode: '120000' }]) },
{ ref: 'github:o/r@main#ext', fetch: repo([...files(manifest), { path: 'ext/big', data: Buffer.alloc(256_001) }]) },
{ ref: 'github:o/r@main#ext', fetch: repo(files({ ...manifest, kind: 'banana' })) },
{ ref: 'github:o/r@main#ext', fetch: repo(files({ ...manifest, triggers: [{ provider: 'github', event: 'pull_request', actions: ['ready_for_review'] }] })) },
{ ref: 'github:o/r@main#ext', fetch: repo(files({ ...manifest, triggers: [{ provider: 'github', event: 'pull_request', actions: ['future_action'] }] })) },
{ ref: 'github:o/r@main#ext', fetch: repo(files({ ...manifest, compat: { ...manifest.compat, surface: '^1.0.0' } })) },
{ ref: 'github:o/r@main#ext', fetch: repo(files({ ...manifest, source: { host: 'github', owner: 'someone', repo: 'else', path: 'ext' } })) },
];
Expand Down
4 changes: 2 additions & 2 deletions packages/surface/src/triggers/PROVIDERS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<!-- GENERATED by scripts/generate-triggers.mjs — do not edit. -->
# Provider trigger namespaces

47 providers, 570 events. `mapping` rows come from the adapter's `webhooks:` block (payload-aware signatures); `catalog` rows come from `KNOWN_TRIGGER_CATALOG` (`supportedEvents()`) only. Every provider also includes its catalog events, so the count is what ingress delivers.
47 providers, 573 events. `mapping` rows come from the adapter's `webhooks:` block (payload-aware signatures); `catalog` rows come from `KNOWN_TRIGGER_CATALOG` (`supportedEvents()`) only. Every provider also includes its catalog events, so the count is what ingress delivers.

| Provider | Namespace | Events | Source | Registry-only events |
|---|---|---:|---|---|
Expand All @@ -19,7 +19,7 @@
| `fathom` | `fathom` | 1 | mapping | |
| `gcp` | `gcp` | 9 | mapping | |
| `gcs` | `gcs` | 3 | mapping | |
| `github` | `github` | 26 | mapping | |
| `github` | `github` | 29 | mapping | |
| `gitlab` | `gitlab` | 53 | mapping | |
| `gmail` | `gmail` | 3 | mapping | |
| `google-calendar` | `google_calendar` | 3 | catalog | |
Expand Down
9 changes: 9 additions & 0 deletions packages/surface/src/triggers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,19 @@ export const github = Object.freeze({
pull_request_closed(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.closed", filter);
},
pull_request_labeled(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.labeled", filter);
},
pull_request_opened(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.opened", filter);
},
pull_request_ready_for_review(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.ready_for_review", filter);
},
pull_request_synchronize(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.synchronize", filter);
},
pull_request_unlabeled(filter?: WebhookFilter) {
return providerTrigger("github", "pull_request.unlabeled", filter);
},
});
Loading
Loading