From dcdc6bd2b5954745e66967c53656bb94cb4e7f3f Mon Sep 17 00:00:00 2001 From: agentrelaybot Date: Tue, 22 Sep 2026 03:20:04 -0700 Subject: [PATCH] feat(extensions): native Babysitter turn handler Add extensions/babysitter, a schema-2 extension on software-factory whose eleven GitHub handlers turn Cloud's normalized delivery descriptor into one cloud:babysitter-turn queue call: f.capabilities.cloud.babysitterTurn.queue({ delivery }). It sends no findings, head, label, session, lineage, or config. Unknown input, receipts other than queued|duplicate, rejections, and a missing capability fail the run. The manifest matches the #550 exporter permission shape. #549's refusal is unchanged: hosted execution still refuses the handler with plugin_unsupported. compat requires the surface release after 2.0.25. Co-Authored-By: Claude Opus 5 Session-Id: 7d116c0d-6756-4905-ace7-1235559066ed --- docs/BABYSITTER-CATALOG-HANDOFF.md | 7 + .../babysitter-native-handler/full-suite.txt | 62 ++++++ .../babysitter-native-handler/mutation.txt | 35 +++ evidence/babysitter-native-handler/tests.txt | 24 +++ .../babysitter-native-handler/typecheck.txt | 13 ++ extensions/babysitter/README.md | 41 ++++ extensions/babysitter/babysitter.flow.ts | 57 +++++ extensions/babysitter/flows-plugin.json | 28 +++ extensions/babysitter/turn.ts | 96 +++++++++ .../tests/babysitter-native-extension.test.ts | 199 ++++++++++++++++++ 10 files changed, 562 insertions(+) create mode 100644 evidence/babysitter-native-handler/full-suite.txt create mode 100644 evidence/babysitter-native-handler/mutation.txt create mode 100644 evidence/babysitter-native-handler/tests.txt create mode 100644 evidence/babysitter-native-handler/typecheck.txt create mode 100644 extensions/babysitter/README.md create mode 100644 extensions/babysitter/babysitter.flow.ts create mode 100644 extensions/babysitter/flows-plugin.json create mode 100644 extensions/babysitter/turn.ts create mode 100644 packages/sdk/tests/babysitter-native-extension.test.ts diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index af79b42ca..d074e7691 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -18,6 +18,13 @@ repository/PR event, and call the Cloud lineage path. Cloud must recheck the exact live `babysit` label and the bound session/head. Permission declarations are not enforcement. Export success is byte verification, not execution approval. +The native package source is `extensions/babysitter` (see its README for the +turn contract). It is unreleased and cannot execute: #549 still refuses it, +the SDK context has no `capabilities.cloud.babysitterTurn`, and its +`compat` requires a surface release after 2.0.25 that routes `labeled`, +`unlabeled`, and `ready_for_review`. Export it only from a reviewed, merged +commit. + ## Export reviewed bytes After the native package is reviewed and committed, build the SDK and run: diff --git a/evidence/babysitter-native-handler/full-suite.txt b/evidence/babysitter-native-handler/full-suite.txt new file mode 100644 index 000000000..80e9dbf3e --- /dev/null +++ b/evidence/babysitter-native-handler/full-suite.txt @@ -0,0 +1,62 @@ +# Full SDK suite on this branch's final bytes (local macOS, not CI) +$ (cd packages/sdk && npm test) + Test Files 23 failed | 140 passed | 3 skipped (166) + Tests 51 failed | 2556 passed | 25 skipped (2632) +exit=1 +Failing files (count of failing tests): + 1 tests/authored-flow-operation.test.ts + 1 tests/authored-node-runtime.test.ts + 2 tests/authored-root.test.ts + 1 tests/budget-unmetered-live.test.ts + 1 tests/build-gate.test.ts + 4 tests/bundle-preflight.test.ts + 3 tests/bundle.test.ts + 1 tests/cli-status.test.ts + 8 tests/cli-watch.test.ts + 1 tests/cloud-schedule.test.ts + 1 tests/communication-mixed-resume.test.ts + 3 tests/deploy.test.ts + 2 tests/direct-input.test.ts + 3 tests/flow-executor-chain.test.ts + 1 tests/flow-extension-compose.test.ts + 6 tests/live-kernel.test.ts + 1 tests/mcp.test.ts + 3 tests/provider-trigger-executor.test.ts + 5 tests/run-from-digest.test.ts + 1 tests/stop-process-group.test.ts + 6 tests/webhook-live.test.ts + 1 tests/worker-cli.test.ts + 1 tests/yaml-helper-live.test.ts +tests/babysitter-native-extension.test.ts is not among them. + +# Baseline: 21 files that failed in an earlier full run, rerun at origin/main 607ac671 with this branch's changes stashed (git stash push -u) +$ (cd packages/sdk && npm run build && npx vitest run ) + Test Files 17 failed | 4 passed (21) + Tests 38 failed | 236 passed | 18 skipped (292) +exit=1 + 1 tests/authored-flow-operation.test.ts + 1 tests/authored-node-runtime.test.ts + 3 tests/authored-root.test.ts + 2 tests/bundle.test.ts + 1 tests/cli-status.test.ts + 8 tests/cli-watch.test.ts + 1 tests/cloud-schedule.test.ts + 1 tests/communication-mixed-resume.test.ts + 1 tests/direct-input.test.ts + 4 tests/flow-executor-chain.test.ts + 1 tests/flow-extension-compose.test.ts + 3 tests/live-kernel.test.ts + 1 tests/mcp.test.ts + 3 tests/provider-trigger-executor.test.ts + 3 tests/run-from-digest.test.ts + 1 tests/stop-process-group.test.ts + 6 tests/webhook-live.test.ts + +# Files failing in the final full run but not in that baseline, rerun in isolation on this branch +$ (cd packages/sdk && npx vitest run tests/build-gate.test.ts tests/budget-unmetered-live.test.ts tests/deploy.test.ts tests/worker-cli.test.ts tests/yaml-helper-live.test.ts tests/bundle-preflight.test.ts) + Test Files 6 passed (6) + Tests 40 passed (40) +exit=0 + +Observed failure causes under full-suite load include 5000ms test timeouts, relayflowd lease expiry, ENOTEMPTY temp cleanup, and timing assertions (e.g. expected 5001 to be less than 5000). +Claim limited to: the new suite passes; the listed failures also occur at origin/main in this environment or pass in isolation. CI is the authority. diff --git a/evidence/babysitter-native-handler/mutation.txt b/evidence/babysitter-native-handler/mutation.txt new file mode 100644 index 000000000..ff8d11e5a --- /dev/null +++ b/evidence/babysitter-native-handler/mutation.txt @@ -0,0 +1,35 @@ +# Mutation 1: delete the event/subscription binding check in extensions/babysitter/turn.ts +$ diff original mutated +75d74 +< if (event.eventType !== subscription) refuse(`event ${JSON.stringify(event.eventType)} was delivered to the ${subscription} handler.`); +$ npx vitest run tests/babysitter-native-extension.test.ts -t 'another subscription' + × native Babysitter extension > refuses an event for another subscription before requesting a turn 17ms + → promise resolved "undefined" instead of rejecting + Tests 1 failed | 30 skipped (31) +$ shasum -a 256 original restored +a5189b3b4db9cb7f4812e480f29dc33dfb5b392ff284952cefac9b5f8a18f781 +a5189b3b4db9cb7f4812e480f29dc33dfb5b392ff284952cefac9b5f8a18f781 +$ npx vitest run tests/babysitter-native-extension.test.ts -t 'another subscription' + ✓ tests/babysitter-native-extension.test.ts (31 tests | 30 skipped) 323ms + Tests 1 passed | 30 skipped (31) + +# Mutation 2: delete the fail-closed capability guard in extensions/babysitter/babysitter.flow.ts +$ diff original mutated +22,24d21 +< if (typeof port !== 'object' || port === null || typeof (port as Partial).queue !== 'function') { +< throw new Error('babysitter: this runtime does not provide the cloud:babysitter-turn write.'); +< } +$ npx vitest run tests/babysitter-native-extension.test.ts -t 'fails closed on' + × native Babysitter extension > fails closed on no capabilities (today's SDK context) 23ms + × native Babysitter extension > fails closed on no cloud capability 3ms + × native Babysitter extension > fails closed on a queue that is not a function 2ms +Received: "Cannot read properties of undefined (reading 'queue')" +Received: "turnQueue(...).queue is not a function" + Tests 3 failed | 28 skipped (31) +$ shasum -a 256 original restored +e1e8e9690334360612a6f6e749746e6a322eefea922f61342479c3bac18490b6 +e1e8e9690334360612a6f6e749746e6a322eefea922f61342479c3bac18490b6 +$ npx vitest run tests/babysitter-native-extension.test.ts -t 'fails closed on' + ✓ tests/babysitter-native-extension.test.ts (31 tests | 28 skipped) 308ms + Tests 3 passed | 28 skipped (31) +Note: without the guard the run still fails, as a raw TypeError; the guard pins a clear, specific refusal. diff --git a/evidence/babysitter-native-handler/tests.txt b/evidence/babysitter-native-handler/tests.txt new file mode 100644 index 000000000..00ab3c95a --- /dev/null +++ b/evidence/babysitter-native-handler/tests.txt @@ -0,0 +1,24 @@ +$ (cd packages/sdk && npx vitest run tests/babysitter-native-extension.test.ts) + + RUN v2.1.9 /Users/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-native-handler/packages/sdk + + ✓ tests/babysitter-native-extension.test.ts (31 tests) 366ms + + Test Files 1 passed (1) + Tests 31 passed (31) + Start at 03:11:40 + Duration 5.09s (transform 1.69s, setup 0ms, collect 2.94s, tests 366ms, environment 0ms, prepare 335ms) + +Exit: 0 + +$ (cd packages/sdk && npx vitest run tests/babysitter-catalog-export.test.ts tests/flow-extension-compose.test.ts tests/plugin-extension.test.ts tests/authored-flow.test.ts tests/preflight.test.ts) # passing lines elided + + RUN v2.1.9 /Users/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-native-handler/packages/sdk + + + Test Files 5 passed (5) + Tests 219 passed (219) + Start at 03:11:47 + Duration 14.25s (transform 5.37s, setup 0ms, collect 19.35s, tests 15.13s, environment 3ms, prepare 2.28s) + +Exit: 0 diff --git a/evidence/babysitter-native-handler/typecheck.txt b/evidence/babysitter-native-handler/typecheck.txt new file mode 100644 index 000000000..7f6d596f7 --- /dev/null +++ b/evidence/babysitter-native-handler/typecheck.txt @@ -0,0 +1,13 @@ +$ (cd packages/sdk && npm run typecheck) + +> @relayflows/sdk@2.0.25 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +Exit: 0 + +$ (cd packages/sdk && npm run typecheck:tests) + +> @relayflows/sdk@2.0.25 typecheck:tests +> tsc -p tsconfig.tests.json + +Exit: 0 diff --git a/extensions/babysitter/README.md b/extensions/babysitter/README.md new file mode 100644 index 000000000..bf7762bb2 --- /dev/null +++ b/extensions/babysitter/README.md @@ -0,0 +1,41 @@ +# Native Babysitter (flow extension) + +A schema-2 extension on `software-factory`. Each of its eleven GitHub +subscriptions takes Cloud's normalized delivery descriptor and makes one +`cloud:babysitter-turn` queue request: + +```jsonc +// in: Cloud's normalized descriptor, never a raw webhook +{ "event": { "provider": "github", "eventType": "pull_request.labeled", "deliveryId": "…" }, + "pullRequest": { "host": "github", "owner": "…", "repo": "…", "number": 1, "headSha": "<40 hex, optional>" } } +// out: f.capabilities.cloud.babysitterTurn.queue(request) +{ "delivery": { "deliveryId": "…", "provider": "github", "eventType": "pull_request.labeled", + "pullRequest": { "owner": "…", "repository": "…", "number": 1 } } } +``` + +The delivery is an envelope, not authority. The handler never sends findings, +prose, a head, label, session, lineage, relay agent, config, merge flag, or +route; Cloud builds the prompt from the live head and the trusted binding. Cloud's +capability adapter checks the envelope against the host-verified dispatch, +loads the persisted activation, rereads the live PR (open, exact `babysit` +label, head), and resolves the one bound Codex session. It answers +`{ receiptId, status: queued | duplicate }` and the run completes `success`; +Cloud dedupes by lineage and live head, so `deliveryId` is provenance, not the +idempotency key. Every policy refusal and in-doubt transport failure rejects, +and the run fails once with no retry or fallback. Any other input or receipt, +or a runtime without the capability, fails the run. + +## What this does not do + +- Hosted execution is still refused. The SDK rejects every matched extension + handler with `plugin_unsupported` until manifest permissions are enforced + (#549, gate 8 / #442). This package does not change that. +- The SDK context has no `capabilities.cloud.babysitterTurn`, and Cloud's + adapter is not merged. Both must land before a turn can happen. +- It is not the legacy `examples/babysitter` (Claude review lenses, GitHub + comments, merge-gate hook). It holds no GitHub write authority. +- `compat` needs a surface release after 2.0.25: the published 2.0.25 event + registry cannot route `labeled`, `unlabeled`, or `ready_for_review`. + +Catalog export follows `docs/BABYSITTER-CATALOG-HANDOFF.md`, with +`#extensions/babysitter` as the path, after human review and merge. diff --git a/extensions/babysitter/babysitter.flow.ts b/extensions/babysitter/babysitter.flow.ts new file mode 100644 index 000000000..35f21d45a --- /dev/null +++ b/extensions/babysitter/babysitter.flow.ts @@ -0,0 +1,57 @@ +// Native Babysitter: an extension on Software Factory whose only effect is a +// `cloud:babysitter-turn` request for the PR a verified delivery names. The +// review itself happens in the original Codex session Cloud has bound to that +// PR; this entry holds no GitHub credentials, runs no agent, and writes nothing +// to GitHub. Hosted execution stays refused by the SDK (#549, gate 8 / #442). +import { flow, github, type Ctx, type TriggerSource } from '@relayflows/surface'; +import { turnDelivery, turnReceipt, type BabysitterTurnDelivery, type Subscription } from './turn.ts'; + +interface TurnQueue { + queue(request: { readonly delivery: BabysitterTurnDelivery }): PromiseLike; +} + +/** + * The declared write, as Cloud's capability adapter spells it. The SDK `Ctx` + * has no `capabilities` field yet; a runtime without it must fail the run, + * never skip the turn silently. The adapter, not this entry, holds workspace, + * activation, and the verified delivery authority. + */ +function turnQueue(f: Ctx): TurnQueue { + const capabilities = (f as unknown as { readonly capabilities?: { readonly cloud?: { readonly babysitterTurn?: unknown } } }).capabilities; + const port = capabilities?.cloud?.babysitterTurn; + if (typeof port !== 'object' || port === null || typeof (port as Partial).queue !== 'function') { + throw new Error('babysitter: this runtime does not provide the cloud:babysitter-turn write.'); + } + return port as TurnQueue; +} + +function handler(subscription: Subscription) { + return async (f: Ctx, input: unknown): Promise => { + const delivery = turnDelivery(subscription, input); + // A delivery-only wake: Cloud builds the prompt from the live head and the + // trusted binding, so this entry sends no findings or prose. Refusals reject. + turnReceipt(await turnQueue(f).queue({ delivery })); + f.done('success'); + }; +} + +const handlers: readonly (readonly [TriggerSource, Subscription])[] = [ + [github.pull_request('opened'), 'pull_request.opened'], + [github.pull_request('synchronize'), 'pull_request.synchronize'], + [github.pull_request('reopened'), 'pull_request.reopened'], + [github.pull_request('ready_for_review'), 'pull_request.ready_for_review'], + [github.pull_request('closed'), 'pull_request.closed'], + [github.pull_request('labeled'), 'pull_request.labeled'], + [github.pull_request('unlabeled'), 'pull_request.unlabeled'], + [github.pull_request_review({ action: 'submitted' }), 'pull_request_review.submitted'], + [github.pull_request_review({ action: 'dismissed' }), 'pull_request_review.dismissed'], + [github.check_run('completed'), 'check_run.completed'], + [github.issue_comment('created'), 'issue_comment.created'], +]; + +// The default body is reachable only by a direct run, whose input is +// caller-controlled JSON. It never requests a turn. +export default handlers.reduce>( + (handle, [trigger, subscription]) => handle.on(trigger, handler(subscription)), + flow('babysitter', { budget: { dollars: 1, wallclock: '5m' } }, async f => { f.done('declined'); }), +); diff --git a/extensions/babysitter/flows-plugin.json b/extensions/babysitter/flows-plugin.json new file mode 100644 index 000000000..81282f1e2 --- /dev/null +++ b/extensions/babysitter/flows-plugin.json @@ -0,0 +1,28 @@ +{ + "schema": 2, + "kind": "flow-extension", + "name": "babysitter", + "version": "0.2.0", + "description": "Native Babysitter for Software Factory: turns a verified PR delivery into one cloud:babysitter-turn request. Cloud rechecks the live babysit label, head, and bound session; this extension holds no GitHub write authority.", + "compat": { + "surface": "^2.0.26", + "sdk": "^2.0.26", + "base": [{ "name": "software-factory", "version": "*" }] + }, + "entry": "babysitter.flow.ts", + "extends": { "handlers": true, "hooks": [] }, + "triggers": [ + { "provider": "github", "event": "pull_request", "actions": ["opened", "synchronize", "reopened", "ready_for_review", "closed", "labeled", "unlabeled"] }, + { "provider": "github", "event": "pull_request_review", "actions": ["submitted", "dismissed"] }, + { "provider": "github", "event": "check_run", "actions": ["completed"] }, + { "provider": "github", "event": "issue_comment", "actions": ["created"] } + ], + "permissions": { + "integrations": ["github"], + "harnesses": ["codex"], + "mcp": [], + "writes": ["cloud:babysitter-turn"], + "budget": { "dollars": 1, "wallclock": "5m" } + }, + "preflight": { "credentials": [], "servers": [] } +} diff --git a/extensions/babysitter/turn.ts b/extensions/babysitter/turn.ts new file mode 100644 index 000000000..58d944bac --- /dev/null +++ b/extensions/babysitter/turn.ts @@ -0,0 +1,96 @@ +/** + * The native Babysitter turn contract: Cloud's normalized delivery descriptor + * in, one `cloud:babysitter-turn` queue request out. + * + * Nothing here is authority. The delivery is an envelope: Cloud's capability + * adapter checks it against the host-verified dispatch, loads the persisted + * activation, rereads the live PR (open, exact `babysit` label, head), and + * resolves the bound session and lineage. The handler never names a session, + * lineage, head, label, route, or config, so a forged or stale descriptor can + * at most ask Cloud to look again. + */ + +/** The subscriptions flows-plugin.json declares, as `event.action` event types. */ +export const SUBSCRIPTIONS = [ + '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', +] as const; +export type Subscription = (typeof SUBSCRIPTIONS)[number]; + +export interface BabysitterTurnDelivery { + readonly deliveryId: string; + readonly provider: 'github'; + readonly eventType: Subscription; + readonly pullRequest: { readonly owner: string; readonly repository: string; readonly number: number }; +} + +/** + * `queued`: Cloud wrote its head-bound receipt and Relay accepted the turn. + * `duplicate`: that lineage and live head were already queued. Every refusal + * and every in-doubt transport failure rejects instead of resolving. + */ +export interface BabysitterTurnReceipt { + readonly receiptId: string; + readonly status: 'queued' | 'duplicate'; +} + +const DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/; +const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const REPO = /^[A-Za-z0-9._-]{1,100}$/; +const SHA = /^[0-9a-f]{40}$/; +const RECEIPT = /^[A-Za-z0-9_.:-]{1,200}$/; + +function refuse(message: string): never { + throw new Error(`babysitter: ${message}`); +} + +function record(value: unknown, what: string, keys: readonly string[], required: readonly string[]): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) refuse(`${what} must be an object.`); + const object = value as Record; + const extra = Object.keys(object).filter(key => !keys.includes(key)); + if (extra.length > 0) refuse(`${what} has unexpected fields ${extra.join(', ')}.`); + const missing = required.filter(key => !Object.hasOwn(object, key)); + if (missing.length > 0) refuse(`${what} is missing ${missing.join(', ')}.`); + return object; +} + +function matches(value: unknown, pattern: RegExp, what: string): string { + if (typeof value !== 'string' || !pattern.test(value)) refuse(`${what} is malformed.`); + return value; +} + +/** + * Validate the descriptor a handler received against the subscription that + * handler was registered for. Any drift, unknown field, or raw webhook shape + * is refused rather than guessed at. Cloud's `headSha` enrichment is checked + * but not forwarded: Cloud binds to the head it rereads, never to a hint. + */ +export function turnDelivery(subscription: Subscription, input: unknown): BabysitterTurnDelivery { + const top = record(input, 'input', ['event', 'pullRequest'], ['event', 'pullRequest']); + const event = record(top.event, 'event', ['provider', 'eventType', 'deliveryId'], ['provider', 'eventType', 'deliveryId']); + const pr = record(top.pullRequest, 'pullRequest', ['host', 'owner', 'repo', 'number', 'headSha'], ['owner', 'repo', 'number']); + if (event.provider !== 'github') refuse('event.provider must be github.'); + if (event.eventType !== subscription) refuse(`event ${JSON.stringify(event.eventType)} was delivered to the ${subscription} handler.`); + if (pr.host !== undefined && pr.host !== 'github') refuse('pullRequest.host must be github.'); + const owner = matches(pr.owner, OWNER, 'pullRequest.owner'); + const repository = matches(pr.repo, REPO, 'pullRequest.repo'); + if (repository === '.' || repository === '..') refuse('pullRequest.repo is malformed.'); + if (typeof pr.number !== 'number' || !Number.isSafeInteger(pr.number) || pr.number <= 0) refuse('pullRequest.number must be a positive integer.'); + if (pr.headSha !== undefined) matches(pr.headSha, SHA, 'pullRequest.headSha'); + return Object.freeze({ + deliveryId: matches(event.deliveryId, DELIVERY, 'event.deliveryId'), + provider: 'github', + eventType: subscription, + pullRequest: Object.freeze({ owner, repository, number: pr.number }), + }); +} + +/** Cloud's answer is data from across a boundary; an unknown shape fails the run. */ +export function turnReceipt(value: unknown): BabysitterTurnReceipt { + const receipt = record(value, 'turn receipt', ['receiptId', 'status'], ['receiptId', 'status']); + const receiptId = matches(receipt.receiptId, RECEIPT, 'turn receipt receiptId'); + if (receipt.status !== 'queued' && receipt.status !== 'duplicate') refuse('turn receipt status is unknown.'); + return Object.freeze({ receiptId, status: receipt.status }); +} diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts new file mode 100644 index 000000000..46df1773d --- /dev/null +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -0,0 +1,199 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Ctx } from '@relayflows/surface'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { executeAuthoredFlow } from '../src/authored-flow-executor.js'; +import { loadAuthoredFlow } from '../src/authored-flow-loader.js'; +import { exportBabysitterCatalogBundle } from '../src/babysitter-catalog-export.js'; +import { addExtensionPlugin } from '../src/cli/add-extension.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; +import { resolveExtensionSubmission } from '../src/flow-extension-submit.js'; +import { JournalClient } from '../src/journal-client.js'; +import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; +import { SHA_A, entriesFromDirectory, fakeGithub } from './fake-github.js'; + +const PATH = 'extensions/babysitter'; +const REF = `github:AgentWorkforce/flows@${SHA_A}#${PATH}`; +// pull_request.labeled, .unlabeled and .ready_for_review route only in the surface after 2.0.25. +const versions = { sdk: '2.0.26', surface: '2.0.26' }; +const now = () => new Date('2026-09-22T12:00:00Z'); +const entries = entriesFromDirectory(resolve('../..', PATH), PATH); +const SUBSCRIPTIONS = [ + '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 HEAD = 'a'.repeat(40); +const dirs: string[] = []; +afterAll(() => dirs.splice(0).forEach(p => rmSync(p, { recursive: true, force: true }))); + +function github() { + return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [SHA_A]: { entries } } } }); +} + +/** Install the committed bytes the way an operator would, then load the composition. */ +async function composed() { + const cwd = mkdtempSync(join(tmpdir(), 'babysitter-native-')); dirs.push(cwd); + mkdirSync(join(cwd, 'node_modules/@relayflows'), { recursive: true }); + symlinkSync(resolve('node_modules/@relayflows/surface'), join(cwd, 'node_modules/@relayflows/surface')); + writeFileSync(join(cwd, 'package.json'), '{"type":"module"}'); + writeFileSync(join(cwd, 'flows.json'), JSON.stringify({ cli: 'codex', executors: ['github'] })); + writeFileSync(join(cwd, 'software-factory.flow.ts'), ` + import { flow } from '@relayflows/surface'; + export default flow('software-factory', { budget: { dollars: 10, wallclock: '1h' } }, async f => { f.done('success'); }); + `); + const io = { stdout: () => {}, stderr: (s: string) => { throw new Error(s); } }; + expect(await addExtensionPlugin(REF, io, { cwd, fetch: github().fetch, now, versions })).toBe(0); + const loaded = await loadAuthoredFlow(join(cwd, 'software-factory.flow.ts'), { versions }); + return { loaded, extension: loaded.extensions[0]! }; +} + +function subscriptionOf(trigger: unknown): string { + const filter = (trigger as { filter: { type: string; payload?: { action?: string } } }).filter; + return `${filter.type}.${filter.payload?.action}`; +} + +function descriptor(eventType: string, pullRequest: Record = {}) { + return { + event: { provider: 'github', eventType, deliveryId: 'gh-delivery-7' }, + pullRequest: { host: 'github', owner: 'AgentWorkforce', repo: 'flows', number: 551, headSha: HEAD, ...pullRequest }, + }; +} + +/** A context exposing only the declared write; any other capability is absent. */ +function context(receipt: unknown = { receiptId: 'receipt-1', status: 'queued' }) { + const requests: unknown[] = []; + const done: string[] = []; + const f = { + capabilities: { cloud: { babysitterTurn: { queue: async (request: unknown) => { requests.push(request); return receipt; } } } }, + done: (reason: string) => { done.push(reason); }, + } as unknown as Ctx; + return { f, requests, done }; +} + +// One install per file: every test reads the same composition, as one deployment would. +let installed: Awaited>; +beforeAll(async () => { installed = await composed(); }); + +async function handle(eventType: string, input: unknown, f: Ctx) { + const { extension } = installed; + const handler = extension.handlers.find(h => subscriptionOf(h.trigger) === eventType)!; + await handler.body(f, input); +} + +describe('native Babysitter extension', () => { + it('composes onto Software Factory with exactly the declared, deliverable subscriptions', async () => { + const { loaded, extension } = installed; + expect(extension.manifest.permissions).toEqual({ + integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'], + budget: { dollars: 1, wallclock: '5m' }, + }); + expect(extension.manifest.extends).toEqual({ handlers: true, hooks: [] }); + expect(extension.handlers.map(h => subscriptionOf(h.trigger))).toEqual(SUBSCRIPTIONS); + expect(preflightProviderTriggers(loaded.getDefinition(loaded.handle).handlers.map(h => h.trigger))).toEqual([]); + }); + + it('stays refused by hosted dispatch (#549) before either body runs', async () => { + const { loaded } = installed; + await expect(executeAuthoredFlow(loaded.handle, new JournalClient('/unused'), undefined, { + getDefinition: loaded.getDefinition, + extensions: loaded.extensions, + extensionDispatch: hostedExtensionDispatchFromVerifiedDelivery({ provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'd-1' }), + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + }); + + it('satisfies the #550 catalog exporter from the committed bytes, manifest unmodified', async () => { + const options = { fetch: github().fetch, versions }; + const bundle = await resolveExtensionSubmission(REF, options); + const exported = await exportBabysitterCatalogBundle({ ref: REF, digest: bundle.digest, manifestSha256: bundle.manifestSha256 }, options); + expect(exported.manifest).toEqual(JSON.parse(readFileSync(resolve('../..', PATH, 'flows-plugin.json'), 'utf8'))); + expect(exported.files.map(f => f.path).sort()).toEqual(['README.md', 'babysitter.flow.ts', 'flows-plugin.json', 'turn.ts']); + }); + + it('queues every subscription as exactly the delivery envelope: no findings, head, label, session, lineage, or config', async () => { + for (const eventType of SUBSCRIPTIONS) { + const c = context(); + await handle(eventType, descriptor(eventType), c.f); + expect(c.requests).toEqual([{ + delivery: { deliveryId: 'gh-delivery-7', provider: 'github', eventType, pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 } }, + }]); + expect(c.done).toEqual(['success']); + } + }); + + it('accepts a descriptor without Cloud head enrichment or host', async () => { + const c = context(); + await handle('pull_request.labeled', descriptor('pull_request.labeled', { headSha: undefined, host: undefined }), c.f); + expect(c.requests).toEqual([{ + delivery: { deliveryId: 'gh-delivery-7', provider: 'github', eventType: 'pull_request.labeled', pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 } }, + }]); + }); + + it.each([ + ['a raw webhook', { action: 'labeled', pull_request: { number: 1 }, repository: { full_name: 'a/b' } }], + ['an event for another subscription', descriptor('pull_request.opened')], + ['a non-GitHub provider', { ...descriptor('pull_request.labeled'), event: { provider: 'gitlab', eventType: 'pull_request.labeled', deliveryId: 'd' } }], + ['a caller-supplied session', { ...descriptor('pull_request.labeled'), sessionId: 's-1' }], + ['a caller-supplied lineage on the PR', descriptor('pull_request.labeled', { lineageId: 'l-1' })], + ['a missing delivery id', { ...descriptor('pull_request.labeled'), event: { provider: 'github', eventType: 'pull_request.labeled' } }], + ['a malformed delivery id', { ...descriptor('pull_request.labeled'), event: { provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'a b' } }], + ['a short head hint', descriptor('pull_request.labeled', { headSha: 'abc123' })], + ['a non-integer PR number', descriptor('pull_request.labeled', { number: 1.5 })], + ['a traversal repo name', descriptor('pull_request.labeled', { repo: '..' })], + ['another host', descriptor('pull_request.labeled', { host: 'gitlab' })], + ['no input', undefined], + ])('refuses %s before requesting a turn', async (_case, input) => { + const c = context(); + await expect(handle('pull_request.labeled', input, c.f)).rejects.toThrow(/^babysitter: /); + expect(c.requests).toEqual([]); + expect(c.done).toEqual([]); + }); + + it.each(['queued', 'duplicate'])('completes a %s receipt as success', async status => { + const c = context({ receiptId: 'r-1', status }); + await handle('pull_request.synchronize', descriptor('pull_request.synchronize'), c.f); + expect(c.done).toEqual(['success']); + }); + + it.each([ + null, + 'accepted', + { status: 'queued' }, + { receiptId: 'r-1', status: 'refused' }, + { receiptId: 'r-1', status: 'queued', reason: 'label absent' }, + { receiptId: 'r-1', status: 'queued', sessionId: 's-1' }, + ])('fails the run on an unrecognized receipt %j', async receipt => { + const c = context(receipt); + await expect(handle('pull_request.labeled', descriptor('pull_request.labeled'), c.f)).rejects.toThrow(/^babysitter: /); + expect(c.done).toEqual([]); + }); + + it.each([ + ['no capabilities (today\'s SDK context)', {}], + ['no cloud capability', { capabilities: {} }], + ['a queue that is not a function', { capabilities: { cloud: { babysitterTurn: { queue: 'x' } } } }], + ])('fails closed on %s', async (_case, runtime) => { + const f = { ...runtime, done: () => { throw new Error('must not complete'); } } as unknown as Ctx; + await expect(handle('pull_request.labeled', descriptor('pull_request.labeled'), f)) + .rejects.toThrow('does not provide the cloud:babysitter-turn write'); + }); + + it.each(['babysit label absent', 'relay native-turn 503 (in doubt)'])('fails the run once, without retry, when Cloud rejects: %s', async message => { + let calls = 0; + const f = { + capabilities: { cloud: { babysitterTurn: { queue: async () => { calls += 1; throw new Error(message); } } } }, + done: () => { throw new Error('must not complete'); }, + } as unknown as Ctx; + await expect(handle('pull_request.labeled', descriptor('pull_request.labeled'), f)).rejects.toThrow(message); + expect(calls).toBe(1); + }); + + it('declines a direct run without requesting a turn', async () => { + const { extension } = installed; + const c = context(); + await extension.getDefinition(extension.handle).body(c.f, descriptor('pull_request.labeled')); + expect(c.requests).toEqual([]); + expect(c.done).toEqual(['declined']); + }); +});