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
106 changes: 106 additions & 0 deletions docs/evidence/spec-Y/README.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 11 additions & 4 deletions packages/sdk/src/slack-writeback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlackHelper['post']>[1]; opts?: Parameters<SlackHelper['post']>[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 } };
Expand Down Expand Up @@ -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<string, unknown> = { ...request.body as Record<string, unknown>, 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<string, unknown>;
const body: Record<string, unknown> = { ...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') {
Expand All @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions packages/sdk/tests/authored-flow-slack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
35 changes: 35 additions & 0 deletions packages/sdk/tests/slack-block-kit.test.ts
Original file line number Diff line number Diff line change
@@ -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<SlackCall, { verb: 'post' }>['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' });
});
16 changes: 16 additions & 0 deletions packages/surface/src/helpers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 5 additions & 3 deletions packages/surface/src/helpers/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeSlackHelper["post"]>;
post(...args: Parameters<RuntimeSlackHelper["post"]>): 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"]>;
Expand Down
10 changes: 9 additions & 1 deletion packages/surface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/surface/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export {
type TriggeredFlowHandle,
type ReadonlyFlowHeader,
} from "./flow.js";
export { slackPostBody } from "./slack.js";
29 changes: 28 additions & 1 deletion packages/surface/src/slack.ts
Original file line number Diff line number Diff line change
@@ -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<SlackPostMessage, "text"> {
replyTo?: string;
}

export interface SlackReceipt {
channel: string;
Expand All @@ -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<SlackReceipt>;
post(channel: string, text: string | SlackPostMessage, opts?: SlackPostOptions): Step<SlackReceipt>;
dm(user: string, text: string): Step<{ user: string; ts: string }>;
reply(channel: string, threadTs: string, text: string): Step<SlackReceipt>;
react(channel: string, messageTs: string, emoji: string): Step<void>;
}

/** 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}`;
Expand Down
65 changes: 65 additions & 0 deletions packages/surface/tests/slack-block-kit.test.ts
Original file line number Diff line number Diff line change
@@ -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<Step<SlackReceipt>>();
expectTypeOf(f.slack.post('C1', { text: 'Release', blocks, attachments }))
.toEqualTypeOf<Step<SlackReceipt>>();
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: [] });
});
});
Loading
Loading