diff --git a/docs/evidence/spec-Y/README.md b/docs/evidence/spec-Y/README.md new file mode 100644 index 000000000..dc37fe98f --- /dev/null +++ b/docs/evidence/spec-Y/README.md @@ -0,0 +1,106 @@ +# Slice Y local verification + +Structured `f.slack.post` messages and options preserve blocks, attachments, +fallback text, and `replyTo` through the SDK writeback transport. Plain-text +calls retain their existing body and receipt shapes. No interaction handlers +or callback registration are introduced. + +## Upstream type availability + +The installed, pinned `@relayfile/relay-helpers` 0.4.11 declaration and the local +adapter checkout expose a text-only `SlackClient.post`; neither exports the +OpenAPI types described in the brief. The implementation therefore generates +the outer message shapes from checked-in Slack OpenAPI fragments instead. +See `scripts/slack-message-schema.json` for source URL, retrieval date, and JSON +pointers, and `packages/surface/src/helpers/README.md` for the resulting typing +limits. Nested block layouts and attachment fields remain open in that schema. + +## Passing checks + +Working directory: `packages/surface`. + +```sh +PATH=/Users/khaliqgant/.bun/bin:$PATH npm run typecheck +PATH=/Users/khaliqgant/.bun/bin:$PATH npm test +npm run typecheck:regressions +npm run typecheck:examples +``` + +Captured output excerpts (all commands exited 0): + +```text +> @relayflows/surface@2.0.8 typecheck +> tsc --noEmit + + Test Files 4 passed (4) + Tests 30 passed (30) + +HELPERS_GENERATED_OK index.ts, slack.ts + +> @relayflows/surface@2.0.8 typecheck:examples +> tsc -p ../../examples/tsconfig.json +``` + +Working directory: `packages/sdk`. + +```sh +npm run typecheck +npm run build +npm run typecheck:tests +./node_modules/.bin/vitest run tests/slack-block-kit.test.ts tests/slack-writeback.test.ts +``` + +Captured output excerpts (all commands exited 0): + +```text +> @relayflows/sdk@2.0.8 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +> @relayflows/sdk@2.0.8 build +> tsc && node scripts/make-cli-executable.mjs + +> @relayflows/sdk@2.0.8 typecheck:tests +> tsc -p tsconfig.tests.json + + Test Files 2 passed (2) + Tests 6 passed (6) +``` + +## Checks blocked by the environment + +Working directory: `packages/sdk`. + +```sh +RELAYFLOWD_BIN=/Users/khaliqgant/.relayflows-toolchain/target/3923540030/debug/relayflowd ./node_modules/.bin/vitest run tests/authored-flow-slack.test.ts +``` + +Captured output excerpts (exit 1): + +```text +Error: Error: bind socket /var/folders/_z/f_fpl8j533g_r63706k2xvp00000gn/T/relayflowd-3a67e6a9f2c0.sock + +Caused by: + Operation not permitted (os error 1) + + Test Files 1 failed (1) + Tests 5 failed | 2 passed (7) +``` + +The sandbox refused socket binding for the added journal snapshot test and +four existing Slack tests. This does not establish a passing daemon integration +run; the new test still needs execution in an environment that permits sockets. + +```sh +PATH=/Users/khaliqgant/.cargo/bin:/Users/khaliqgant/.bun/bin:$PATH npm test +``` + +Captured output excerpt (exit 101 during the prerequisite Rust build): + +```text +error: failed to build archive at `/Users/khaliqgant/.relayflows-toolchain/target/2234480737/debug/deps/librelayflowd-b546e1c22a21bac5.rlib`: No space left on device (os error 28) + +error: could not compile `relayflowd` (lib) due to 1 previous error +``` + +The build artifacts created by this failed attempt were removed. The full SDK +suite has not passed locally; no tests were disabled to bypass these failures. diff --git a/packages/sdk/src/slack-writeback.ts b/packages/sdk/src/slack-writeback.ts index 63983a14c..daa2d2162 100644 --- a/packages/sdk/src/slack-writeback.ts +++ b/packages/sdk/src/slack-writeback.ts @@ -2,13 +2,14 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdir, open, readFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { slackClient } from '@relayfile/relay-helpers'; -import { flowRunWritebackIdempotency } from '@relayflows/surface'; +import { flowRunWritebackIdempotency, type SlackHelper } from '@relayflows/surface'; +import { slackPostBody } from '@relayflows/surface/runtime'; import type { RelayTransport } from '@relayfile/relay-helpers/transport'; import { writeJsonFile, type WritebackResult } from '@relayfile/adapter-core/vfs-client'; import { slackMount } from './slack-preflight.js'; export type SlackCall = - | { type: 'effect'; provider: 'slack'; verb: 'post'; params: { channel: string; text: string; opts?: { replyTo?: string } } } + | { type: 'effect'; provider: 'slack'; verb: 'post'; params: { channel: string; text: Parameters[1]; opts?: Parameters[2] } } | { type: 'effect'; provider: 'slack'; verb: 'dm'; params: { user: string; text: string } } | { type: 'effect'; provider: 'slack'; verb: 'reply'; params: { channel: string; threadTs: string; text: string } } | { type: 'effect'; provider: 'slack'; verb: 'react'; params: { channel: string; messageTs: string; emoji: string } }; @@ -39,7 +40,12 @@ export async function slackWriteback( async list() { throw new Error('Slack effect transport is write-only'); }, async write(request) { signal.throwIfAborted(); - const body: Record = { ...request.body as Record, idempotencyKey }; + // The pinned adapter's ergonomic post accepts text only. Preserve structured + // content at its transport boundary, retaining its paths and receipt handling. + const content = call.verb === 'post' + ? slackPostBody(call.params.text, call.params.opts) + : request.body as Record; + const body: Record = { ...content, idempotencyKey }; const stamped = { ...request, body }; const draft = `${request.path}/draft-${createHash('sha256').update(idempotencyKey).digest('hex')}.json`; if (process.env.RELAYFLOWS_SLACK_MOCK === '1') { @@ -66,7 +72,8 @@ export async function slackWriteback( }; const client = slackClient({ transport }); switch (call.verb) { - case 'post': return client.post(call.params.channel, call.params.text, call.params.opts); + case 'post': return client.post(call.params.channel, + typeof call.params.text === 'string' ? call.params.text : call.params.text.text ?? '', call.params.opts); case 'dm': return client.dm(call.params.user, call.params.text); case 'reply': return { ...await client.reply(call.params.channel, call.params.threadTs, call.params.text), ref: deliveredRef }; case 'react': await client.react(call.params.channel, call.params.messageTs, call.params.emoji); return null; diff --git a/packages/sdk/tests/authored-flow-slack.test.ts b/packages/sdk/tests/authored-flow-slack.test.ts index 1330bbe5b..9a2e33853 100644 --- a/packages/sdk/tests/authored-flow-slack.test.ts +++ b/packages/sdk/tests/authored-flow-slack.test.ts @@ -67,6 +67,36 @@ function mockFiles(dataDir: string): string[] { } describe('authored Slack helper effects', () => { + it('snapshots structured posts into the journal and delivers the same Block Kit body', async () => { + vi.stubEnv('RELAYFLOWS_SLACK_MOCK', '1'); + const dataDir = temporary(); + const { client } = await start(dataDir); + const blocks = [{ type: 'section', text: { type: 'mrkdwn', text: '*Release*' } }]; + const attachments = [{ color: '#36a64f', fallback: 'Release', blocks }]; + const message = { text: 'Release', blocks, attachments }; + const expected = structuredClone(message); + const result = await executeAuthoredFlow(flow('slack-block-kit', async f => { + const post = f.slack.post('C1', message, { replyTo: 'parent-ref' }); + blocks[0]!.text.text = 'mutated after scheduling'; + attachments.push({ color: '#ff0000', fallback: 'later', blocks: [] }); + // Postfix .gate(callback) is spec-deferred (SURFACE §6); await the + // step directly and assert the receipt shape from the resolved value. + const receipt = await post; + expect(receipt.channel).toBe('C1'); + f.done('success'); + }), client, undefined, { dataDir }); + const step = result.journalSteps[0]!; + const entries = (await client.journalRead(step.runId, 1)).entries as any[]; + const completed = entries.filter(entry => entry.entry_type === 'step.completed'); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ payload: { completionReason: 'success', output: { + params: { channel: 'C1', text: expected, opts: { replyTo: 'parent-ref' } }, + } } }); + expect(entries.filter(entry => entry.entry_type === 'effect.confirmed')).toHaveLength(1); + const written = JSON.parse(readFileSync(join(dataDir, 'mock-writeback/slack', `${step.id}.json`), 'utf8')); + expect(written.request.body).toEqual({ ...expected, parentRef: 'parent-ref', idempotencyKey: `${step.runId}:${step.id}` }); + }); + it('journals exactly one effect with the authored params and typed receipt, without network', async () => { vi.stubEnv('RELAYFLOWS_SLACK_MOCK', '1'); const network = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network forbidden')); diff --git a/packages/sdk/tests/slack-block-kit.test.ts b/packages/sdk/tests/slack-block-kit.test.ts new file mode 100644 index 000000000..2d89e3525 --- /dev/null +++ b/packages/sdk/tests/slack-block-kit.test.ts @@ -0,0 +1,35 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { slackWriteback, type SlackCall } from '../src/slack-writeback.js'; + +const directories: string[] = []; +afterEach(() => { + for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +const blocks = [{ type: 'section', text: { type: 'mrkdwn', text: '*Release*' } }]; +const attachments = [{ color: '#36a64f', fallback: 'Release', blocks }]; + +it.each<{ params: Extract['params']; body: object }>([ + { params: { channel: 'C1', text: 'hello' }, body: { text: 'hello' } }, + { params: { channel: 'C1', text: { text: 'Release', blocks, attachments }, opts: { replyTo: 'parent' } }, + body: { text: 'Release', blocks, attachments, parentRef: 'parent' } }, + { params: { channel: 'C1', text: { blocks } }, body: { blocks } }, + { params: { channel: 'C1', text: { attachments } }, body: { attachments } }, + { params: { channel: 'C1', text: 'Release', opts: { blocks, attachments } }, + body: { text: 'Release', blocks, attachments } }, +])('delivers the structured writeback body: $body', async ({ params, body }) => { + vi.stubEnv('RELAYFLOWS_SLACK_MOCK', '1'); + const dataDir = mkdtempSync(join(tmpdir(), 'slack-block-kit-')); + directories.push(dataDir); + // Model the journal serialization boundary used by first dispatch and resume. + const call: SlackCall = JSON.parse(JSON.stringify({ type: 'effect', provider: 'slack', verb: 'post', params })); + const receipt = await slackWriteback(call, dataDir, 'run', 'step', new AbortController().signal); + expect(receipt).toEqual({ channel: 'C1', ts: 'mock-step', ref: 'mock-ref-step' }); + const written = JSON.parse(readFileSync(join(dataDir, 'mock-writeback/slack/step.json'), 'utf8')); + expect(written.request).toMatchObject({ provider: 'slack', resource: 'messages', parameters: { channelId: 'C1' } }); + expect(written.request.body).toEqual({ ...body, idempotencyKey: 'run:step' }); +}); diff --git a/packages/surface/src/helpers/README.md b/packages/surface/src/helpers/README.md index 6ee0ceb9d..05d64f644 100644 --- a/packages/surface/src/helpers/README.md +++ b/packages/surface/src/helpers/README.md @@ -6,6 +6,22 @@ inside this package). This repository has no root npm workspace, so 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`. +`post` extends that contract with a structured second argument (`text`, `blocks`, +`attachments`) or `blocks`/`attachments` in its third options argument. Options +take precedence when both forms provide the same field. `replyTo` still maps to +the adapter's `parentRef`; no interaction callback registration is added. + +The pinned relay-helpers 0.4.11 package does **not** export Block Kit/OpenAPI +types. Until it does, `scripts/slack-message-schema.json` vendors the small +fragments used by the generator, with their upstream URL and JSON pointers. +Slack's published OpenAPI schema requires a string `type` on blocks but leaves +their other fields open. Attachment objects are likewise open (the generic +chat.update message schema is used; chat.postMessage's response attachment +schema requires a server-assigned `id`, unsuitable for authoring). +These types do not validate individual block layouts or interaction handlers. +The SDK preserves structured content at the adapter transport boundary because +the pinned adapter's ergonomic `post` still accepts only text. + `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. diff --git a/packages/surface/src/helpers/slack.ts b/packages/surface/src/helpers/slack.ts index dc0318a9a..9b79bf8bb 100644 --- a/packages/surface/src/helpers/slack.ts +++ b/packages/surface/src/helpers/slack.ts @@ -3,11 +3,13 @@ import type { SlackHelper as RuntimeSlackHelper } from "../slack.js"; +/** Generated from the pinned Slack OpenAPI fragments in scripts/slack-message-schema.json. */ +export type SlackBlock = { type: string; [key: string]: unknown; }; +export type SlackAttachment = { [key: string]: unknown; }; + /** Adapter argument shapes over the journal-backed Slack dispatcher. */ export interface SlackHelper { - post(channel: string, text: string, opts?: { - replyTo?: string; - }): ReturnType; + post(...args: Parameters): 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 27dc4fd26..bae5bb074 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -21,7 +21,15 @@ export { type TriggeredFlowHandle, type FlowHeader, } from "./flow.js"; -export { flowRunWritebackIdempotency, type SlackHelper, type SlackReceipt } from "./slack.js"; +export { + flowRunWritebackIdempotency, + type SlackHelper, + type SlackReceipt, + type SlackBlock, + type SlackAttachment, + type SlackPostMessage, + type SlackPostOptions, +} from "./slack.js"; export type { Helpers } from "./helpers/index.js"; export type { MemoryHelper, MemoryFinding, MemoryRecallOptions, HistoryEntry, TrajectoryEntry } from "./memory.js"; export { webhook, type TriggerSource, type WebhookFilter, type WebhookValue } from "./triggers.js"; diff --git a/packages/surface/src/runtime.ts b/packages/surface/src/runtime.ts index 08663667f..dec331e46 100644 --- a/packages/surface/src/runtime.ts +++ b/packages/surface/src/runtime.ts @@ -7,3 +7,4 @@ export { type TriggeredFlowHandle, type ReadonlyFlowHeader, } from "./flow.js"; +export { slackPostBody } from "./slack.js"; diff --git a/packages/surface/src/slack.ts b/packages/surface/src/slack.ts index 1654d1735..4470abb5d 100644 --- a/packages/surface/src/slack.ts +++ b/packages/surface/src/slack.ts @@ -1,4 +1,18 @@ import type { Step } from "./step.js"; +import type { SlackAttachment, SlackBlock } from "./helpers/slack.js"; + +export type { SlackAttachment, SlackBlock } from "./helpers/slack.js"; + +/** Structured message content. Text supplies the notification/accessibility fallback. */ +export interface SlackPostMessage { + text?: string; + blocks?: SlackBlock[]; + attachments?: SlackAttachment[]; +} + +export interface SlackPostOptions extends Omit { + replyTo?: string; +} export interface SlackReceipt { channel: string; @@ -8,12 +22,25 @@ export interface SlackReceipt { /** Slack writeback helpers; every call is a journal-backed effect. */ export interface SlackHelper { - post(channel: string, text: string, opts?: { replyTo?: string }): Step; + post(channel: string, text: string | SlackPostMessage, opts?: SlackPostOptions): Step; dm(user: string, text: string): Step<{ user: string; ts: string }>; reply(channel: string, threadTs: string, text: string): Step; react(channel: string, messageTs: string, emoji: string): Step; } +/** Normalize both authoring forms to the adapter's JSON writeback body. */ +export function slackPostBody(text: string | SlackPostMessage, opts?: SlackPostOptions): SlackPostMessage & { parentRef?: string } { + const message = typeof text === "string" ? { text } : text; + const blocks = opts?.blocks ?? message.blocks; + const attachments = opts?.attachments ?? message.attachments; + return { + ...(message.text === undefined ? {} : { text: message.text }), + ...(blocks === undefined ? {} : { blocks }), + ...(attachments === undefined ? {} : { attachments }), + ...(opts?.replyTo ? { parentRef: opts.replyTo } : {}), + }; +} + /** Stable across attempts; deliberately independent of process-tick ordinals. */ export function flowRunWritebackIdempotency(runId: string, stepId: string): string { return `${runId}:${stepId}`; diff --git a/packages/surface/tests/slack-block-kit.test.ts b/packages/surface/tests/slack-block-kit.test.ts new file mode 100644 index 000000000..ea19cbcf4 --- /dev/null +++ b/packages/surface/tests/slack-block-kit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { flow, type SlackBlock, type SlackPostMessage, type SlackReceipt, type Step } from '@relayflows/surface'; +import { slackPostBody } from '@relayflows/surface/runtime'; + +const blocks: SlackBlock[] = [ + { type: 'header', text: { type: 'plain_text', text: 'Release shipped' } }, + { type: 'section', text: { type: 'mrkdwn', text: '*All checks passed*' } }, + { type: 'divider' }, + { type: 'context', elements: [{ type: 'mrkdwn', text: 'Build 42' }] }, + { type: 'image', image_url: 'https://example.com/release.png', alt_text: 'Release' }, +]; +const attachments = [{ color: '#36a64f', fallback: 'Release shipped', blocks }]; + +describe('Slack Block Kit posts', () => { + it('accepts structured messages and options while preserving Step receipts', () => { + flow('typed-slack', async f => { + expectTypeOf(f.slack.post('C1', 'hello')).toEqualTypeOf>(); + expectTypeOf(f.slack.post('C1', { text: 'Release', blocks, attachments })) + .toEqualTypeOf>(); + f.slack.post('C1', { blocks }).gate(receipt => receipt.ts.length > 0); + f.slack.post('C1', { attachments }, { replyTo: 'draft-parent' }); + f.slack.post('C1', 'Release', { blocks, attachments, replyTo: 'draft-parent' }); + // @ts-expect-error Channel must be a string. + f.slack.post(42, { blocks }); + // @ts-expect-error Fallback text must be a string. + f.slack.post('C1', { text: 42 }); + // @ts-expect-error Blocks are structured arrays, not JSON strings. + f.slack.post('C1', { blocks: '[]' }); + // @ts-expect-error The OpenAPI schema requires each block's type. + f.slack.post('C1', { blocks: [{ text: 'missing type' }] }); + // @ts-expect-error Block type must be a string. + f.slack.post('C1', 'Release', { blocks: [{ type: 42 }] }); + // @ts-expect-error Attachments must be objects. + f.slack.post('C1', { attachments: ['invalid'] }); + // @ts-expect-error No callback registration in the posting API. + f.slack.post('C1', { blocks }, { callbackUrl: 'https://example.com' }); + }); + }); + + it('preserves the plain-text body and replyTo mapping', () => { + expect(slackPostBody('hello')).toEqual({ text: 'hello' }); + expect(slackPostBody('hello', { replyTo: 'draft-parent' })) + .toEqual({ text: 'hello', parentRef: 'draft-parent' }); + }); + + it('preserves all structured content without mutating the authored message', () => { + const message: SlackPostMessage = { text: 'Release', blocks, attachments }; + const before = structuredClone(message); + expect(slackPostBody(message, { replyTo: 'draft-parent' })) + .toEqual({ ...before, parentRef: 'draft-parent' }); + expect(message).toEqual(before); + }); + + it('supports blocks or attachments without fallback text', () => { + expect(slackPostBody({ blocks })).toEqual({ blocks }); + expect(slackPostBody({ attachments })).toEqual({ attachments }); + }); + + it('supports structured options and explicit empty arrays', () => { + expect(slackPostBody('Release', { blocks, attachments })) + .toEqual({ text: 'Release', blocks, attachments }); + expect(slackPostBody({ blocks, attachments }, { blocks: [], attachments: [] })) + .toEqual({ blocks: [], attachments: [] }); + }); +}); diff --git a/scripts/generate-helpers.mjs b/scripts/generate-helpers.mjs index dd44e10d7..0b5173114 100644 --- a/scripts/generate-helpers.mjs +++ b/scripts/generate-helpers.mjs @@ -32,12 +32,29 @@ const methods = client.members.map(member => { const parameters = member.parameters.map(parameter => printer.printNode(ts.EmitHint.Unspecified, parameter, source)).join(', '); const name = member.name.getText(source); + // The surface adds structured posts while the pinned adapter accepts text only. + if (name === 'post') return ' post(...args: Parameters): ReturnType;'; return ` ${name}(${parameters}): ReturnType;`.replaceAll('\n', '\n '); }); +// Pinned OpenAPI fragments fill the gap until relay-helpers exports message types. +// Slack's schema deliberately leaves block contents and attachment fields open. +const messageSchema = JSON.parse(readFileSync(new URL('./slack-message-schema.json', import.meta.url), 'utf8')); +function schemaType(schema) { + if (schema.type === 'string') return 'string'; + assert.equal(schema.type, 'object', 'Unsupported Slack schema type'); + const fields = Object.entries(schema.properties ?? {}).map(([name, value]) => + `${name}${schema.required?.includes(name) ? '' : '?'}: ${schemaType(value)};`); + if (schema.additionalProperties !== false) fields.push('[key: string]: unknown;'); + return `{ ${fields.join(' ')} }`; +} +const messageTypes = Object.entries(messageSchema.schemas).map(([name, { schema }]) => + `export type ${name} = ${schemaType(schema)};`).join('\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` + + '/** Generated from the pinned Slack OpenAPI fragments in scripts/slack-message-schema.json. */\n' + + `${messageTypes}\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` diff --git a/scripts/slack-message-schema.json b/scripts/slack-message-schema.json new file mode 100644 index 000000000..70c26112f --- /dev/null +++ b/scripts/slack-message-schema.json @@ -0,0 +1,19 @@ +{ + "source": "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2_without_examples.json", + "retrieved": "2026-09-11", + "schemas": { + "SlackBlock": { + "pointer": "/definitions/blocks/items", + "schema": { + "additionalProperties": true, + "properties": { "type": { "type": "string" } }, + "required": ["type"], + "type": "object" + } + }, + "SlackAttachment": { + "pointer": "/paths/~1chat.update/post/responses/200/schema/properties/message/properties/attachments/items", + "schema": { "type": "object" } + } + } +}