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
8 changes: 8 additions & 0 deletions docs/SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions docs/spec-N-minimal-validation.md
Original file line number Diff line number Diff line change
@@ -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)

```
5 changes: 5 additions & 0 deletions packages/surface/bun.lock

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

18 changes: 18 additions & 0 deletions packages/surface/package-lock.json

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

4 changes: 3 additions & 1 deletion packages/surface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
},
Expand Down
21 changes: 21 additions & 0 deletions packages/surface/scripts/check-generated-helpers.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
5 changes: 2 additions & 3 deletions packages/surface/src/context.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Record<string, Readonly<Record<string, (args: unknown) => Step<unknown>>>>>;
run(command: string): Step<string>;
llm(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
Expand All @@ -39,5 +39,4 @@ export interface Ctx {
dispatch<T>(flow: string, input: unknown): Promise<T>;
done(reason: RunCompletionReason): void;
cloud: CloudHelper;
slack: SlackHelper;
}
26 changes: 26 additions & 0 deletions packages/surface/src/helpers/README.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/surface/src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions packages/surface/src/helpers/slack.ts
Original file line number Diff line number Diff line change
@@ -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<RuntimeSlackHelper["post"]>;
dm(user: string, text: string): ReturnType<RuntimeSlackHelper["dm"]>;
reply(channel: string, threadTs: string, text: string): ReturnType<RuntimeSlackHelper["reply"]>;
react(channel: string, messageTs: string, emoji: string): ReturnType<RuntimeSlackHelper["react"]>;
}
1 change: 1 addition & 0 deletions packages/surface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
18 changes: 18 additions & 0 deletions packages/surface/tests/helpers-typecheck-fail.test-d.ts
Original file line number Diff line number Diff line change
@@ -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();
}
12 changes: 12 additions & 0 deletions packages/surface/tests/helpers-typecheck-pass.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<SlackReceipt> = f.slack.post('#test', 'hi');
const reply: Step<SlackReceipt> = f.slack.reply('#test', '123.456', 'hi');
const dm: Step<{ user: string; ts: string }> = f.slack.dm('U123', 'hi');
const react: Step<void> = f.slack.react('#test', '123.456', 'eyes');
f.slack.post('#test', 'hi', { replyTo: '/slack/draft' }).gate(receipt => Boolean(receipt.ts));
void [compatible, post, reply, dm, react];
}
9 changes: 9 additions & 0 deletions packages/surface/tests/helpers.snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
2 changes: 1 addition & 1 deletion packages/surface/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"sourceMap": false,
"noEmit": true,
"rootDir": ".",
"types": ["vitest/globals"]
"types": ["vitest/globals", "node"]
},
"include": ["tests/**/*.ts"],
"exclude": []
Expand Down
51 changes: 51 additions & 0 deletions scripts/generate-helpers.mjs
Original file line number Diff line number Diff line change
@@ -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<RuntimeSlackHelper["${name}"]>;`.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(', ')}`);
Loading