diff --git a/docs/SURFACE.md b/docs/SURFACE.md index 184a2e3f7..b5870ac67 100644 --- a/docs/SURFACE.md +++ b/docs/SURFACE.md @@ -78,6 +78,14 @@ No process runs between events: the handler wakes, executes to its next await, p failed calls retain their MCP diagnostic in `trajectory_tail` and complete with `worker_error`. + The first typed codegen slice covers Slack's four existing dispatcher methods. + `Ctx` composes the generated helper namespace map; argument shapes come from + the pinned relayfile ergonomic client and results retain journal-backed `Step` + semantics. Run `npm run gen --prefix packages/surface` to regenerate; the + regression typecheck checks byte-for-byte drift. Mapping/discovery generation + and the other provider namespaces remain follow-up work; see + [the generator notes](../packages/surface/src/helpers/README.md). + 4. **`{{prev}}` / return-value chaining.** Output flows downward implicitly; naming steps is for reaching back, not bookkeeping. 5. **Headers are optional escalation.** identity, memory, budget, tools appear only when used. The empty header is the common case. 6. **Agent definitions escalate by composition** — and a reusable agent *is* a flow: diff --git a/docs/spec-N-minimal-validation.md b/docs/spec-N-minimal-validation.md new file mode 100644 index 000000000..92a0ea47c --- /dev/null +++ b/docs/spec-N-minimal-validation.md @@ -0,0 +1,74 @@ +# Slice N minimal proof: validation + +Scope: generated Slack argument types, namespace composition, and drift detection. +The full five-provider slice remains open; see +[follow-up notes](../packages/surface/src/helpers/README.md). + +Surface command (from `packages/surface`, with Bun on PATH): + +```sh +bun install --frozen-lockfile --ignore-scripts && bun run build && bun run test && bun run typecheck && bun run typecheck:regressions +``` + +Captured output: + +```text +bun install v1.4.2 (744846f84) + +Checked 91 installs across 142 packages (no changes) [39.00ms] +$ tsc +$ bun run build && tsc -p tsconfig.test.json && vitest run +$ tsc + + RUN v2.1.9 /Users/khaliqgant/flows-spec-N-helpers/packages/surface + + ✓ tests/flow.test.ts (7 tests) 3ms + ✓ tests/helpers.snapshot.test.ts (1 test) 190ms + + Test Files 2 passed (2) + Tests 8 passed (8) + Start at 13:35:43 + Duration 413ms (transform 32ms, setup 0ms, collect 34ms, tests 193ms, environment 0ms, prepare 68ms) + +$ tsc --noEmit +$ tsc -p ../../regressions/tsconfig.json && tsc -p tsconfig.test.json && node scripts/check-generated-helpers.mjs +HELPERS_GENERATED_OK index.ts, slack.ts +``` + +SDK used the local surface tarball via: + +```sh +# From packages/surface +npm pack --ignore-scripts --pack-destination /tmp/spec-N-surface-pack +npm ci --prefix ../sdk --ignore-scripts +npm install /tmp/spec-N-surface-pack/relayflows-surface-2.0.8.tgz --prefix ../sdk --no-save --ignore-scripts +``` + +SDK command (from `packages/sdk`): + +```sh +npm run typecheck && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/authored-flow.test.ts +``` + +Captured output: + +```text + +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.8 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /Users/khaliqgant/flows-spec-N-helpers/packages/sdk + + ✓ tests/authored-flow.test.ts (25 tests) 639ms + + Test Files 1 passed (1) + Tests 25 passed (25) + Start at 13:35:23 + Duration 1.07s (transform 110ms, setup 0ms, collect 217ms, tests 639ms, environment 0ms, prepare 34ms) + +``` diff --git a/packages/surface/bun.lock b/packages/surface/bun.lock index 743cefb02..74fd21213 100644 --- a/packages/surface/bun.lock +++ b/packages/surface/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@relayflows/surface", "devDependencies": { + "@types/node": "^22.20.2", "typescript": "^5.6.0", "vitest": "^2.1.0", }, @@ -144,6 +145,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], + "@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="], "@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="], @@ -272,6 +275,8 @@ "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], diff --git a/packages/surface/package-lock.json b/packages/surface/package-lock.json index d699ec4a9..9d67cfe48 100644 --- a/packages/surface/package-lock.json +++ b/packages/surface/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.8", "license": "Apache-2.0", "devDependencies": { + "@types/node": "^22.20.2", "typescript": "^5.6.0", "vitest": "^2.1.0" }, @@ -1033,6 +1034,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1928,6 +1939,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/packages/surface/package.json b/packages/surface/package.json index 9dd8c0854..9ba99dd58 100644 --- a/packages/surface/package.json +++ b/packages/surface/package.json @@ -22,10 +22,11 @@ "src" ], "scripts": { + "gen": "node ../../scripts/generate-helpers.mjs", "build": "tsc", "prepare": "bun run build", "typecheck": "tsc --noEmit", - "typecheck:regressions": "tsc -p ../../regressions/tsconfig.json", + "typecheck:regressions": "tsc -p ../../regressions/tsconfig.json && tsc -p tsconfig.test.json && node scripts/check-generated-helpers.mjs", "typecheck:examples": "tsc -p ../../examples/tsconfig.json", "test": "bun run build && tsc -p tsconfig.test.json && vitest run" }, @@ -34,6 +35,7 @@ "node": ">=20.19.0 || >=22.12.0" }, "devDependencies": { + "@types/node": "^22.20.2", "typescript": "^5.6.0", "vitest": "^2.1.0" }, diff --git a/packages/surface/scripts/check-generated-helpers.mjs b/packages/surface/scripts/check-generated-helpers.mjs new file mode 100644 index 000000000..071617845 --- /dev/null +++ b/packages/surface/scripts/check-generated-helpers.mjs @@ -0,0 +1,21 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const generated = fileURLToPath(new URL('../src/helpers/', import.meta.url)); +const generator = fileURLToPath(new URL('../../../scripts/generate-helpers.mjs', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'surface-helpers-')); +try { + execFileSync(process.execPath, [generator, '--out-dir', temporary], { stdio: 'pipe' }); + const expected = readdirSync(temporary).sort(); + const actual = readdirSync(generated).filter(name => name.endsWith('.ts')).sort(); + const mismatch = new Set([...expected, ...actual].filter(name => + !expected.includes(name) || !actual.includes(name) + || !readFileSync(join(temporary, name)).equals(readFileSync(join(generated, name))))); + if (mismatch.size) throw new Error(`Generated helpers drifted: ${[...mismatch].join(', ')}. Run npm run gen --prefix packages/surface`); + console.log(`HELPERS_GENERATED_OK ${expected.join(', ')}`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index 0d4d0f7a3..c97b84387 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -1,4 +1,4 @@ -import type { SlackHelper } from "./slack.js"; +import type { Helpers } from "./helpers/index.js"; import type { CloudHelper } from "./cloud.js"; import type { RunCompletionReason } from "./completion.js"; import type { Step } from "./step.js"; @@ -28,7 +28,7 @@ export interface LlmOptions { * This package declares the authoring contract only. It cannot construct a * context or execute a step, so all effects remain behind the journal client. */ -export interface Ctx { +export interface Ctx extends Helpers { readonly mcp: Readonly Step>>>>; run(command: string): Step; llm(strings: TemplateStringsArray, ...values: unknown[]): Step; @@ -39,5 +39,4 @@ export interface Ctx { dispatch(flow: string, input: unknown): Promise; done(reason: RunCompletionReason): void; cloud: CloudHelper; - slack: SlackHelper; } diff --git a/packages/surface/src/helpers/README.md b/packages/surface/src/helpers/README.md new file mode 100644 index 000000000..6ee0ceb9d --- /dev/null +++ b/packages/surface/src/helpers/README.md @@ -0,0 +1,26 @@ +Generated TypeScript helpers; do not edit the `.ts` files. Run +`npm run gen --prefix packages/surface` from the repository root (or `npm run gen` +inside this package). This repository has no root npm workspace, so +`npm run gen -w @relayflows/surface` is not available. + +This minimal slice ships Slack's four existing journal-backed methods. Argument +shapes come from the pinned `@relayfile/relay-helpers` declaration; return types +come from the existing surface dispatcher contract, including `reply`'s `ref`. +`Ctx` extends the generated `Helpers` namespace map. A union of namespace maps +would make only their common properties accessible, so composition uses an +interface instead. + +To consume a read-only adapter checkout before release: +`node scripts/generate-helpers.mjs --adapters-dir /path/to/relayfile-adapters`. +Use `--out-dir /tmp/generated-helpers` to inspect output without changing source. +CI regenerates from the installed pinned package and compares every generated +TypeScript file byte-for-byte, including the namespace index. + +Follow-up for the full slice N: add GitHub, Notion, Linear, and Stripe once their +methods have runtime dispatch support; consume mapping/discovery resources and +generate the remaining providers. The current runtime implements only Slack. +The uniform upstream clients expose resource `read`/`list`/`write` methods, +not `stripe.createInvoice` or `notion.appendBlock`; those aliases need an agreed +runtime contract before this types-only generator can expose them. GitHub's +bespoke `createIssue` also requires `owner` in addition to `repo`, `title`, and +`body`. No new provider methods or resource methods are advertised in this proof. diff --git a/packages/surface/src/helpers/index.ts b/packages/surface/src/helpers/index.ts new file mode 100644 index 000000000..c62a5cba8 --- /dev/null +++ b/packages/surface/src/helpers/index.ts @@ -0,0 +1,11 @@ +// GENERATED by scripts/generate-helpers.mjs — do not edit. +// Run `npm run gen --prefix packages/surface` from the repository root. + +import type { SlackHelper } from "./slack.js"; + +export type { SlackHelper } from "./slack.js"; + +/** Supported helper namespaces composed into Ctx. */ +export interface Helpers { + slack: SlackHelper; +} diff --git a/packages/surface/src/helpers/slack.ts b/packages/surface/src/helpers/slack.ts new file mode 100644 index 000000000..dc0318a9a --- /dev/null +++ b/packages/surface/src/helpers/slack.ts @@ -0,0 +1,14 @@ +// GENERATED by scripts/generate-helpers.mjs — do not edit. +// Run `npm run gen --prefix packages/surface` from the repository root. + +import type { SlackHelper as RuntimeSlackHelper } from "../slack.js"; + +/** Adapter argument shapes over the journal-backed Slack dispatcher. */ +export interface SlackHelper { + post(channel: string, text: string, opts?: { + replyTo?: string; + }): ReturnType; + dm(user: string, text: string): ReturnType; + reply(channel: string, threadTs: string, text: string): ReturnType; + react(channel: string, messageTs: string, emoji: string): ReturnType; +} diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts index a8409d411..47b3395d9 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -21,3 +21,4 @@ export { type FlowHeader, } from "./flow.js"; export { flowRunWritebackIdempotency, type SlackHelper, type SlackReceipt } from "./slack.js"; +export type { Helpers } from "./helpers/index.js"; diff --git a/packages/surface/tests/helpers-typecheck-fail.test-d.ts b/packages/surface/tests/helpers-typecheck-fail.test-d.ts new file mode 100644 index 000000000..6096b97ed --- /dev/null +++ b/packages/surface/tests/helpers-typecheck-fail.test-d.ts @@ -0,0 +1,18 @@ +import type { Ctx } from '../src/index.js'; + +export function rejectedHelpers(f: Ctx): void { + // @ts-expect-error channels must be strings. + f.slack.post(42, 'hi'); + // @ts-expect-error preserve the bespoke option key. + f.slack.post('#test', 'hi', { replyToo: '123' }); + // @ts-expect-error user IDs must be strings. + f.slack.dm(42, 'hi'); + // @ts-expect-error replies require a text argument. + f.slack.reply('#test', '123.456'); + // @ts-expect-error emojis must be strings. + f.slack.react('#test', '123.456', 42); + // @ts-expect-error unsupported provider namespaces stay closed. + f.notarealprovider.anything(); + // @ts-expect-error resource clients do not have runtime dispatch yet. + f.slack.messages.list(); +} diff --git a/packages/surface/tests/helpers-typecheck-pass.test-d.ts b/packages/surface/tests/helpers-typecheck-pass.test-d.ts new file mode 100644 index 000000000..ae1d225b9 --- /dev/null +++ b/packages/surface/tests/helpers-typecheck-pass.test-d.ts @@ -0,0 +1,12 @@ +import type { Ctx, Helpers, SlackHelper, SlackReceipt, Step } from '../src/index.js'; + +export function supportedHelpers(f: Ctx): void { + const helpers: Helpers = f; + const compatible: SlackHelper = helpers.slack; + const post: Step = f.slack.post('#test', 'hi'); + const reply: Step = f.slack.reply('#test', '123.456', 'hi'); + const dm: Step<{ user: string; ts: string }> = f.slack.dm('U123', 'hi'); + const react: Step = f.slack.react('#test', '123.456', 'eyes'); + f.slack.post('#test', 'hi', { replyTo: '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/slack/draft' }).gate(receipt => Boolean(receipt.ts)); + void [compatible, post, reply, dm, react]; +} diff --git a/packages/surface/tests/helpers.snapshot.test.ts b/packages/surface/tests/helpers.snapshot.test.ts new file mode 100644 index 000000000..5e871fd4e --- /dev/null +++ b/packages/surface/tests/helpers.snapshot.test.ts @@ -0,0 +1,9 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; + +it('regenerates helpers byte-identically from the pinned adapter', () => { + const guard = fileURLToPath(new URL('../scripts/check-generated-helpers.mjs', import.meta.url)); + expect(execFileSync(process.execPath, [guard], { encoding: 'utf8' })) + .toContain('HELPERS_GENERATED_OK index.ts, slack.ts'); +}); diff --git a/packages/surface/tsconfig.test.json b/packages/surface/tsconfig.test.json index 9a8f6e8e6..13710b2c9 100644 --- a/packages/surface/tsconfig.test.json +++ b/packages/surface/tsconfig.test.json @@ -6,7 +6,7 @@ "sourceMap": false, "noEmit": true, "rootDir": ".", - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"] }, "include": ["tests/**/*.ts"], "exclude": [] diff --git a/scripts/generate-helpers.mjs b/scripts/generate-helpers.mjs new file mode 100644 index 000000000..dd44e10d7 --- /dev/null +++ b/scripts/generate-helpers.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const surface = fileURLToPath(new URL('../packages/surface/', import.meta.url)); +const require = createRequire(join(surface, 'package.json')); +const ts = require('typescript'); +const { values } = parseArgs({ options: { + 'out-dir': { type: 'string', default: join(surface, 'src/helpers') }, + 'adapters-dir': { type: 'string' }, +} }); +// The pinned published declaration makes regeneration reproducible in CI. +// An explicit checkout lets adapter authors check source changes before release. +const input = values['adapters-dir'] + ? join(resolve(values['adapters-dir']), 'packages/relay-helpers/src/slack.ts') + : join(dirname(require.resolve('@relayfile/relay-helpers/package.json')), 'dist/slack.d.ts'); +const source = ts.createSourceFile(input, readFileSync(input, 'utf8'), ts.ScriptTarget.Latest, true); +assert.equal(source.parseDiagnostics.length, 0, 'Invalid Slack adapter source'); +const client = source.statements.find(node => ts.isInterfaceDeclaration(node) && node.name.text === 'SlackClient'); +assert(client, 'Missing upstream SlackClient interface'); +const verbs = ['post', 'dm', 'reply', 'react']; +assert.deepEqual(client.members.map(member => member.name?.getText(source)), verbs, + 'Slack adapter verbs changed; review dispatcher support before regenerating'); +const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: true }); +const methods = client.members.map(member => { + assert(ts.isMethodSignature(member) && !member.typeParameters?.length, + 'Expected a non-generic Slack method'); + // Copy the parameter AST unchanged: no hand-maintained approximation of args. + const parameters = member.parameters.map(parameter => + printer.printNode(ts.EmitHint.Unspecified, parameter, source)).join(', '); + const name = member.name.getText(source); + return ` ${name}(${parameters}): ReturnType;`.replaceAll('\n', '\n '); +}); +const header = '// GENERATED by scripts/generate-helpers.mjs — do not edit.\n' + + '// Run `npm run gen --prefix packages/surface` from the repository root.\n'; +const files = { + 'slack.ts': `${header}\nimport type { SlackHelper as RuntimeSlackHelper } from "../slack.js";\n\n` + + '/** Adapter argument shapes over the journal-backed Slack dispatcher. */\n' + + `export interface SlackHelper {\n${methods.join('\n')}\n}\n`, + 'index.ts': `${header}\nimport type { SlackHelper } from "./slack.js";\n\n` + + 'export type { SlackHelper } from "./slack.js";\n\n' + + '/** Supported helper namespaces composed into Ctx. */\n' + + 'export interface Helpers {\n slack: SlackHelper;\n}\n', +}; +const destination = resolve(values['out-dir']); +mkdirSync(destination, { recursive: true }); +for (const [name, content] of Object.entries(files)) writeFileSync(join(destination, name), content); +console.log(`Generated ${Object.keys(files).join(', ')}`);