From 9c29146cba84bb61973b0eea6ed3186a8ef862ef Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 10:57:38 +0200 Subject: [PATCH 1/4] feat(relay-helpers): catalog-backed provider clients (@agentworkforce/relay-helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers the ctx.linear.comment()-style ergonomics removed from the runtime (9020e5d) as an opt-in factory package, with every path sourced from @relayfile/adapter-core/writeback-paths instead of hardcoded — so handler paths can't drift from the adapter that materializes the draft. - `relayClient(provider, opts?)` — generic, covers all 29 catalog providers: `path()` (resolve), `write()` (collection draft OR direct .json item write), `read()` / `list()`. Binds the mount root once (defaults to the RELAYFILE_MOUNT_ROOT env). - `linearClient` / `githubClient` / `slackClient` — ergonomic named methods (comment / createIssue / getIssue / merge / review / post / dm / reply / …) ported from the deleted typed clients, now over the catalog. - 6 tests (collection vs item writes, read/list, the three bespoke clients, and an all-29-provider path-resolution smoke). typecheck + build clean. Wires the package into publish.yml (topological position after runtime) at the lockstep version 3.0.39. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/publish.yml | 4 +- packages/relay-helpers/README.md | 45 +++++++++ packages/relay-helpers/package.json | 39 ++++++++ packages/relay-helpers/src/generic.ts | 92 +++++++++++++++++ packages/relay-helpers/src/github.ts | 99 +++++++++++++++++++ packages/relay-helpers/src/index.ts | 25 +++++ packages/relay-helpers/src/linear.ts | 63 ++++++++++++ packages/relay-helpers/src/receipt.ts | 13 +++ .../relay-helpers/src/relay-helpers.test.ts | 90 +++++++++++++++++ packages/relay-helpers/src/slack.ts | 47 +++++++++ packages/relay-helpers/tsconfig.json | 8 ++ pnpm-lock.yaml | 24 +++++ 12 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 packages/relay-helpers/README.md create mode 100644 packages/relay-helpers/package.json create mode 100644 packages/relay-helpers/src/generic.ts create mode 100644 packages/relay-helpers/src/github.ts create mode 100644 packages/relay-helpers/src/index.ts create mode 100644 packages/relay-helpers/src/linear.ts create mode 100644 packages/relay-helpers/src/receipt.ts create mode 100644 packages/relay-helpers/src/relay-helpers.test.ts create mode 100644 packages/relay-helpers/src/slack.ts create mode 100644 packages/relay-helpers/tsconfig.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c7d3521e..59799dd4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,6 +88,7 @@ jobs: # Dependency order (topological): # persona-kit (leaf — consumed by everyone) # runtime (→ persona-kit; consumed by deploy + mcp-workforce) + # relay-helpers (→ runtime; consumed by agents, not other workspace pkgs) # workload-router (→ persona-kit; consumed by cli) # deploy (→ persona-kit + runtime; consumed by cli) # mcp-workforce (→ persona-kit + runtime) @@ -95,7 +96,7 @@ jobs: # cli (→ persona-kit + workload-router + deploy) # agentworkforce (→ cli — umbrella wrapper, must publish last) # personas-core publishes via the separate publish-personas.yml workflow. - echo "packages=persona-kit runtime workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=persona-kit runtime relay-helpers workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT" # Lockstep baseline heal. The workspace publishes every package at the # same version, so if any package's local version lags either its own @@ -701,6 +702,7 @@ jobs: const packageOrder = [ 'persona-kit', 'runtime', + 'relay-helpers', 'workload-router', 'deploy', 'mcp-workforce', diff --git a/packages/relay-helpers/README.md b/packages/relay-helpers/README.md new file mode 100644 index 00000000..66e07b3d --- /dev/null +++ b/packages/relay-helpers/README.md @@ -0,0 +1,45 @@ +# @agentworkforce/relay-helpers + +Ergonomic, catalog-backed provider clients for Workforce agent handlers. + +The runtime exposes only generic VFS helpers (`writeJsonFile`, `readJsonFile`, …); +the per-provider typed clients (`ctx.linear.comment(...)`) were removed. This +package recovers that ergonomics as an **opt-in factory**, with every path +sourced from [`@relayfile/adapter-core/writeback-paths`](https://www.npmjs.com/package/@relayfile/adapter-core) +(the adapter-owned source of truth) instead of hardcoded — so paths never drift +from the adapter that materializes the draft. + +```ts +import { linearClient, githubClient, slackClient } from '@agentworkforce/relay-helpers'; + +const linear = linearClient(); // binds the mount root once (RELAYFILE_MOUNT_ROOT) +const issue = await linear.getIssue(issueId); +await linear.comment(issueId, ':rocket: done'); + +await githubClient().merge({ owner, repo, number }); +await slackClient().post('#eng', 'shipped'); +``` + +## Generic client (all providers) + +Every provider in the catalog (29+) is reachable through `relayClient`, with +generic methods over the catalog paths: + +```ts +import { relayClient } from '@agentworkforce/relay-helpers'; + +const notion = relayClient('notion'); +notion.path('pages', { databaseId }); // resolve a path (no IO) +await notion.write('pages', { databaseId }, { /* … */ }); // collection create or item write +await notion.list('pages', { databaseId }); // list a collection +``` + +- `write(resource, params, body)` drops a uniquely-named draft for a collection + resource, or writes directly to an item resource (a path ending in `.json`). + The Relayfile writeback worker turns the draft into the real provider call. +- `read` / `list` operate over the catalog paths. +- Unknown providers/resources or missing path params throw loudly — never a + guessed path. + +The `linearClient` / `githubClient` / `slackClient` factories wrap `relayClient` +with named, ergonomic methods for the most common operations. diff --git a/packages/relay-helpers/package.json b/packages/relay-helpers/package.json new file mode 100644 index 00000000..84c9b8c0 --- /dev/null +++ b/packages/relay-helpers/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agentworkforce/relay-helpers", + "version": "3.0.39", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/relay-helpers" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/**/*.test.js dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@agentworkforce/runtime": "workspace:*", + "@relayfile/adapter-core": "^0.3.27" + } +} diff --git a/packages/relay-helpers/src/generic.ts b/packages/relay-helpers/src/generic.ts new file mode 100644 index 00000000..e215c32d --- /dev/null +++ b/packages/relay-helpers/src/generic.ts @@ -0,0 +1,92 @@ +import { + draftFile, + encodeSegment, + listJsonFiles, + readJsonFile, + writeJsonFile, + type IntegrationClientOptions, + type WritebackResult +} from '@agentworkforce/runtime/clients'; +import { + WRITEBACK_PATH_CATALOG, + writebackPath, + type WritebackProvider, + type WritebackResource +} from '@relayfile/adapter-core/writeback-paths'; + +export type RelayParams = Record; + +/** + * A catalog-backed client for one provider. Every path comes from + * `@relayfile/adapter-core/writeback-paths` (the adapter-owned source of + * truth), so handlers never hardcode `/linear/issues/...` strings that drift + * from the adapter. Works for any provider in the catalog; the + * `linearClient` / `githubClient` / `slackClient` factories wrap this with + * named, ergonomic methods. + */ +export interface RelayClient

{ + readonly provider: P; + /** Resolve a resource's canonical mount path (no IO). */ + path(resource: WritebackResource

& string, params?: RelayParams): string; + /** + * Write `body`. For a collection resource (e.g. `/linear/issues/{id}/comments`) + * this drops a uniquely-named draft the Relayfile writeback worker turns into + * the create call. For an item resource (a path ending in `.json`, e.g. + * `…/pulls/{n}/merge.json`) this writes the body to that exact path. + */ + write(resource: WritebackResource

& string, params: RelayParams, body: unknown): Promise; + /** Read a single item resource (a `.json` path). */ + read(resource: WritebackResource

& string, params?: RelayParams): Promise; + /** List the records of a collection resource. */ + list(resource: WritebackResource

& string, params?: RelayParams): Promise; +} + +function isItemPath(path: string): boolean { + return path.endsWith('.json'); +} + +/** + * Build a {@link RelayClient} for `provider`. `opts` (mount root, writeback + * timeout, …) is bound once and reused by every method; it defaults to the + * ambient `RELAYFILE_MOUNT_ROOT` env, so `relayClient('linear')` is enough + * inside a sandbox handler. + */ +export function relayClient

( + provider: P, + opts: IntegrationClientOptions = {} +): RelayClient

{ + const knownResources = (): string => Object.keys(WRITEBACK_PATH_CATALOG[provider] ?? {}).join(', '); + return { + provider, + path(resource, params = {}) { + return writebackPath(provider, resource, params); + }, + async write(resource, params, body) { + const base = writebackPath(provider, resource, params); + const target = isItemPath(base) ? base : `${base}/${draftFile(String(resource))}`; + return writeJsonFile(opts, provider, `write.${String(resource)}`, target, body); + }, + read(resource: WritebackResource

& string, params: RelayParams = {}): Promise { + const path = writebackPath(provider, resource, params); + if (!isItemPath(path)) { + throw new Error( + `read("${String(resource)}") resolves to collection "${path}"; read a specific item path or use list(). Known resources for ${provider}: ${knownResources()}` + ); + } + return readJsonFile(opts, provider, `read.${String(resource)}`, path); + }, + async list(resource: WritebackResource

& string, params: RelayParams = {}): Promise { + const path = writebackPath(provider, resource, params); + if (isItemPath(path)) { + throw new Error( + `list("${String(resource)}") resolves to item "${path}"; use read() instead. Known resources for ${provider}: ${knownResources()}` + ); + } + const files = await listJsonFiles(opts, provider, `list.${String(resource)}`, path); + return files.map((file) => file.value); + } + }; +} + +/** Re-exported so callers can build item-read paths (`${collection}/${id}.json`). */ +export { encodeSegment, type IntegrationClientOptions, type WritebackResult }; diff --git a/packages/relay-helpers/src/github.ts b/packages/relay-helpers/src/github.ts new file mode 100644 index 00000000..b1269ece --- /dev/null +++ b/packages/relay-helpers/src/github.ts @@ -0,0 +1,99 @@ +import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; +import { relayClient } from './generic.js'; +import { created } from './receipt.js'; + +export interface GithubTarget { + owner: string; + repo: string; + number: number; +} + +export interface GithubClient { + /** Comment on an issue or pull request. */ + comment(target: GithubTarget, body: string): Promise<{ id: string; url: string }>; + /** Create an issue. */ + createIssue(args: { + owner: string; + repo: string; + title: string; + body: string; + labels?: string[]; + }): Promise<{ id: string; url: string }>; + /** Merge a pull request. */ + merge(args: { + owner: string; + repo: string; + number: number; + method?: 'merge' | 'squash' | 'rebase'; + commitTitle?: string; + commitMessage?: string; + sha?: string; + }): Promise<{ merged: boolean; sha?: string }>; + /** Post a review on a pull request. */ + review( + target: GithubTarget, + args: { + body: string; + event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'; + comments?: Array<{ path: string; line: number; body: string }>; + } + ): Promise; +} + +/** + * Ergonomic GitHub client over the writeback-path catalog. Recovers the + * `ctx.github.comment(...)` shape removed from the runtime. + */ +export function githubClient(opts: IntegrationClientOptions = {}): GithubClient { + const relay = relayClient('github', opts); + return { + async comment(target, body) { + return created( + await relay.write( + 'issue-comments', + { owner: target.owner, repo: target.repo, issueNumber: target.number }, + { body } + ) + ); + }, + async createIssue(args) { + return created( + await relay.write( + 'issues', + { owner: args.owner, repo: args.repo }, + { title: args.title, body: args.body, ...(args.labels ? { labels: args.labels } : {}) } + ) + ); + }, + async merge(args) { + const result = await relay.write( + 'merge', + { owner: args.owner, repo: args.repo, pullNumber: args.number }, + { + ...(args.method !== undefined ? { merge_method: args.method } : {}), + ...(args.commitTitle !== undefined ? { commit_title: args.commitTitle } : {}), + ...(args.commitMessage !== undefined ? { commit_message: args.commitMessage } : {}), + ...(args.sha !== undefined ? { sha: args.sha } : {}) + } + ); + const sha = + typeof result.receipt?.sha === 'string' + ? result.receipt.sha + : typeof result.receipt?.id === 'string' + ? result.receipt.id + : undefined; + const merged = result.receipt?.merged; + return { + merged: merged === true || merged === 'true' || (merged === undefined && Boolean(sha)), + ...(sha ? { sha } : {}) + }; + }, + async review(target, args) { + await relay.write( + 'reviews', + { owner: target.owner, repo: target.repo, pullNumber: target.number }, + { ...args, comments: args.comments ?? [] } + ); + } + }; +} diff --git a/packages/relay-helpers/src/index.ts b/packages/relay-helpers/src/index.ts new file mode 100644 index 00000000..ec498db9 --- /dev/null +++ b/packages/relay-helpers/src/index.ts @@ -0,0 +1,25 @@ +/** + * `@agentworkforce/relay-helpers` — ergonomic, catalog-backed provider clients + * for Workforce agent handlers. + * + * The runtime exposes only generic VFS helpers (`writeJsonFile`, `readJsonFile`, + * …); the per-provider typed clients (`ctx.linear.comment(...)`) were removed. + * This package recovers that ergonomics as an opt-in factory, with every path + * sourced from `@relayfile/adapter-core/writeback-paths` (the adapter-owned + * source of truth) instead of hardcoded — so paths never drift from the + * adapter that materializes the draft. + * + * import { linearClient } from '@agentworkforce/relay-helpers'; + * const linear = linearClient(); // binds the mount root once + * const issue = await linear.getIssue(issueId); + * await linear.comment(issueId, ':rocket: done'); + * + * `relayClient(provider)` gives the same transport for any of the 29 providers + * in the catalog, with generic `write` / `read` / `list` methods. + */ +export { relayClient, encodeSegment, type RelayClient, type RelayParams } from './generic.js'; +export { linearClient, type LinearClient } from './linear.js'; +export { githubClient, type GithubClient, type GithubTarget } from './github.js'; +export { slackClient, type SlackClient } from './slack.js'; +export { created } from './receipt.js'; +export type { IntegrationClientOptions, WritebackResult } from '@agentworkforce/runtime/clients'; diff --git a/packages/relay-helpers/src/linear.ts b/packages/relay-helpers/src/linear.ts new file mode 100644 index 00000000..f072e8e7 --- /dev/null +++ b/packages/relay-helpers/src/linear.ts @@ -0,0 +1,63 @@ +import { + readJsonFile, + writeJsonFile, + type IntegrationClientOptions +} from '@agentworkforce/runtime/clients'; +import { encodeSegment, relayClient } from './generic.js'; +import { created } from './receipt.js'; + +export interface LinearClient { + /** Comment on an issue. */ + comment(issueId: string, body: string): Promise<{ id: string; url: string }>; + /** Create an issue. */ + createIssue(args: { + teamId: string; + title: string; + description?: string; + assigneeId?: string; + labelIds?: string[]; + projectId?: string; + stateId?: string; + }): Promise<{ id: string; url: string }>; + /** Patch an existing issue. */ + updateIssue( + issueId: string, + args: { title?: string; description?: string; assigneeId?: string; stateId?: string } + ): Promise; + /** Read one issue by id. */ + getIssue>(issueId: string): Promise; + /** List issues. */ + listIssues>(): Promise; +} + +/** + * Ergonomic Linear client over the writeback-path catalog. Recovers the + * `ctx.linear.comment(...)` shape removed from the runtime, with paths sourced + * from `@relayfile/adapter-core` rather than hardcoded. + */ +export function linearClient(opts: IntegrationClientOptions = {}): LinearClient { + const relay = relayClient('linear', opts); + const issuePath = (issueId: string) => `${relay.path('issues')}/${encodeSegment(issueId)}.json`; + return { + async comment(issueId, body) { + return created(await relay.write('comments', { issueId }, { body })); + }, + async createIssue(args) { + const result = await relay.write('issues', {}, args); + const id = result.receipt?.created ?? result.receipt?.id ?? result.path; + return { + id, + url: result.receipt?.url ?? result.path + }; + }, + async updateIssue(issueId, args) { + await writeJsonFile(opts, 'linear', 'updateIssue', issuePath(issueId), args); + }, + getIssue>(issueId: string): Promise { + return readJsonFile(opts, 'linear', 'getIssue', issuePath(issueId)); + }, + listIssues>(): Promise { + return relay.list('issues'); + } + }; +} diff --git a/packages/relay-helpers/src/receipt.ts b/packages/relay-helpers/src/receipt.ts new file mode 100644 index 00000000..b4213303 --- /dev/null +++ b/packages/relay-helpers/src/receipt.ts @@ -0,0 +1,13 @@ +import type { WritebackResult } from '@agentworkforce/runtime/clients'; + +/** + * Normalize the writeback receipt into the `{ id, url }` shape the old typed + * clients returned. Falls back to the draft path when the worker hasn't + * written a receipt yet (fire-and-forget / timeout). + */ +export function created(result: WritebackResult): { id: string; url: string } { + return { + id: result.receipt?.created ?? result.receipt?.id ?? result.path, + url: result.receipt?.url ?? result.path + }; +} diff --git a/packages/relay-helpers/src/relay-helpers.test.ts b/packages/relay-helpers/src/relay-helpers.test.ts new file mode 100644 index 00000000..afc63a2e --- /dev/null +++ b/packages/relay-helpers/src/relay-helpers.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { WRITEBACK_PATH_CATALOG } from '@relayfile/adapter-core/writeback-paths'; +import { githubClient, linearClient, relayClient, slackClient } from './index.js'; + +/** Fire-and-forget client bound to a throwaway mount; no writeback worker runs. */ +async function mount(): Promise<{ root: string; opts: { relayfileMountRoot: string; writebackTimeoutMs: number } }> { + const root = await mkdtemp(path.join(tmpdir(), 'relay-helpers-')); + return { root, opts: { relayfileMountRoot: root, writebackTimeoutMs: 0 } }; +} + +async function onlyJsonIn(dir: string): Promise<{ name: string; body: unknown }> { + const entries = (await readdir(dir)).filter((entry) => entry.endsWith('.json')); + assert.equal(entries.length, 1, `expected one draft in ${dir}, saw ${entries.join(', ') || 'none'}`); + return { name: entries[0], body: JSON.parse(await readFile(path.join(dir, entries[0]), 'utf8')) }; +} + +test('relayClient.path resolves catalog paths and write drops a collection draft', async () => { + const { root, opts } = await mount(); + const linear = relayClient('linear', opts); + assert.equal(linear.path('comments', { issueId: 'ISS-1' }), '/linear/issues/ISS-1/comments'); + + await linear.write('comments', { issueId: 'ISS-1' }, { body: 'hi' }); + const draft = await onlyJsonIn(path.join(root, 'linear/issues/ISS-1/comments')); + assert.deepEqual(draft.body, { body: 'hi' }); +}); + +test('relayClient.write writes item (.json) resources to the exact path', async () => { + const { root, opts } = await mount(); + const gh = relayClient('github', opts); + // `merge` resolves to `…/merge.json` — an item path, written directly (no draft). + await gh.write('merge', { owner: 'o', repo: 'r', pullNumber: 7 }, { merge_method: 'squash' }); + const body = JSON.parse(await readFile(path.join(root, 'github/repos/o/r/pulls/7/merge.json'), 'utf8')); + assert.deepEqual(body, { merge_method: 'squash' }); +}); + +test('relayClient.read / list operate over the catalog paths', async () => { + const { root, opts } = await mount(); + await mkdir(path.join(root, 'linear/issues'), { recursive: true }); + await writeFile(path.join(root, 'linear/issues/ISS-9.json'), JSON.stringify({ id: 'ISS-9', title: 't' })); + const linear = relayClient('linear', opts); + const listed = await linear.list<{ id: string }>('issues'); + assert.deepEqual(listed.map((i) => i.id), ['ISS-9']); +}); + +test('linearClient recovers comment / createIssue / getIssue ergonomics', async () => { + const { root, opts } = await mount(); + await mkdir(path.join(root, 'linear/issues'), { recursive: true }); + await writeFile(path.join(root, 'linear/issues/ISS-1.json'), JSON.stringify({ id: 'ISS-1', title: 'Fix' })); + + const linear = linearClient(opts); + const issue = await linear.getIssue<{ title: string }>('ISS-1'); + assert.equal(issue.title, 'Fix'); + + await linear.comment('ISS-1', ':rocket: done'); + const comment = await onlyJsonIn(path.join(root, 'linear/issues/ISS-1/comments')); + assert.deepEqual(comment.body, { body: ':rocket: done' }); + + // Fresh mount so the create draft is the only file in /linear/issues. + const fresh = await mount(); + await linearClient(fresh.opts).createIssue({ teamId: 'T', title: 'New' }); + const created = await onlyJsonIn(path.join(fresh.root, 'linear/issues')); + assert.deepEqual(created.body, { teamId: 'T', title: 'New' }); +}); + +test('githubClient.comment and slackClient.post target the canonical paths', async () => { + const { root, opts } = await mount(); + await githubClient(opts).comment({ owner: 'AgentWorkforce', repo: 'cloud', number: 1643 }, 'hello'); + const ghComment = await onlyJsonIn(path.join(root, 'github/repos/AgentWorkforce/cloud/issues/1643/comments')); + assert.deepEqual(ghComment.body, { body: 'hello' }); + + await slackClient(opts).post('C123', 'shipped'); + const msg = await onlyJsonIn(path.join(root, 'slack/channels/C123/messages')); + assert.deepEqual(msg.body, { text: 'shipped' }); +}); + +test('relayClient covers every provider in the catalog', () => { + const providers = Object.keys(WRITEBACK_PATH_CATALOG); + assert.ok(providers.length >= 29, `expected >=29 providers, saw ${providers.length}`); + for (const provider of providers) { + const client = relayClient(provider as keyof typeof WRITEBACK_PATH_CATALOG); + const [resource, variants] = Object.entries(WRITEBACK_PATH_CATALOG[provider as keyof typeof WRITEBACK_PATH_CATALOG])[0]; + // Build params from the first variant's placeholders so path() resolves. + const params = Object.fromEntries((variants[0].params as readonly string[]).map((name) => [name, 'x'])); + assert.ok(client.path(resource as never, params).startsWith(`/${provider}`)); + } +}); diff --git a/packages/relay-helpers/src/slack.ts b/packages/relay-helpers/src/slack.ts new file mode 100644 index 00000000..8b367c7e --- /dev/null +++ b/packages/relay-helpers/src/slack.ts @@ -0,0 +1,47 @@ +import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; +import { relayClient } from './generic.js'; + +/** Slack message timestamps contain `.`; the mount path encodes it as `_`. */ +function tsParam(ts: string): string { + return ts.replace(/\./g, '_'); +} + +export interface SlackClient { + /** Post a message to a channel. */ + post(channel: string, text: string): Promise<{ channel: string; ts: string }>; + /** Direct-message a user. */ + dm(user: string, text: string): Promise<{ user: string; ts: string }>; + /** Reply in a thread. */ + reply(channel: string, threadTs: string, text: string): Promise<{ channel: string; ts: string }>; + /** React to a message. */ + react(channel: string, messageTs: string, emoji: string): Promise; +} + +/** + * Ergonomic Slack client over the writeback-path catalog. Recovers the + * `ctx.slack.post(...)` shape removed from the runtime. + */ +export function slackClient(opts: IntegrationClientOptions = {}): SlackClient { + const relay = relayClient('slack', opts); + return { + async post(channel, text) { + const result = await relay.write('messages', { channelId: channel }, { text }); + return { channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + }, + async dm(user, text) { + const result = await relay.write('direct-messages', { userId: user }, { text }); + return { user, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + }, + async reply(channel, threadTs, text) { + const result = await relay.write( + 'replies', + { channelId: channel, messageTs: tsParam(threadTs) }, + { text } + ); + return { channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; + }, + async react(channel, messageTs, emoji) { + await relay.write('reactions', { channelId: channel, messageTs: tsParam(messageTs) }, { emoji }); + } + }; +} diff --git a/packages/relay-helpers/tsconfig.json b/packages/relay-helpers/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/packages/relay-helpers/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d04cb0..217f9559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,15 @@ importers: packages/personas-core: {} + packages/relay-helpers: + dependencies: + '@agentworkforce/runtime': + specifier: workspace:* + version: link:../runtime + '@relayfile/adapter-core': + specifier: ^0.3.27 + version: 0.3.27(@relayfile/sdk@0.7.40) + packages/runtime: dependencies: '@agent-assistant/proactive': @@ -850,6 +859,13 @@ packages: peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' + '@relayfile/adapter-core@0.3.27': + resolution: {integrity: sha512-4fpNWc7+8jDkOs6vUQ3VHi4cltW1I9uoCbngCs2maWLXnHvJ/he0Zrw69FoLVYG9ujzX5Hwx3jhj3g09Grpx9w==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@relayfile/sdk': '>=0.6.0 <1' + '@relayfile/core@0.7.40': resolution: {integrity: sha512-vY48SxZgahnvE0CHDyy/iny17ypnfbX5myVPtocZVNpMiz4dS1iabO70WR+uYVgSiIZR7MpKTPFSs3SxEjQWag==} engines: {node: '>=18'} @@ -3099,6 +3115,14 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 + '@relayfile/adapter-core@0.3.27(@relayfile/sdk@0.7.40)': + dependencies: + '@relayfile/sdk': 0.7.40 + '@scalar/postman-to-openapi': 0.6.3 + cheerio: 1.2.0 + minimatch: 10.2.5 + yaml: 2.9.0 + '@relayfile/core@0.7.40': {} '@relayfile/local-mount@0.7.24': From 63af74484dcd7cd51f4a6f521bb8e49679c940b6 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 11:07:34 +0200 Subject: [PATCH 2/4] feat(relay-helpers): named client for every catalog provider (all 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a named, typed client for every provider in the writeback-path catalog (asanaClient, notionClient, jiraClient, … — 29 total), not just the bespoke trio. Each exposes its catalog resources uniformly as `.{resource}.{path,write,read,list}` via the new `providerClient(provider)` engine. linear/github/slack additionally carry the named ergonomic methods (comment / post / mergePullRequest / …) on top of that resource-keyed base. - `providerClient(provider, opts?)` + `ProviderClient

` / `ResourceClient`. - 26 generated named exports in clients.ts; linear/github/slack enriched. - github's bespoke merge renamed `mergePullRequest` (the catalog resource key `merge` owns `.merge`) — also matches the old deleted client's name. - Test asserts every catalog provider has a named export, so a new adapter surfaces as a failure until its client is added. 8/8 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/relay-helpers/README.md | 29 +++++----- packages/relay-helpers/src/clients.ts | 37 ++++++++++++ packages/relay-helpers/src/github.ts | 56 +++++++++++------- packages/relay-helpers/src/index.ts | 45 ++++++++++++-- packages/relay-helpers/src/linear.ts | 54 +++++++++-------- packages/relay-helpers/src/provider-client.ts | 58 +++++++++++++++++++ .../relay-helpers/src/relay-helpers.test.ts | 27 +++++++-- packages/relay-helpers/src/slack.ts | 34 +++++------ 8 files changed, 251 insertions(+), 89 deletions(-) create mode 100644 packages/relay-helpers/src/clients.ts create mode 100644 packages/relay-helpers/src/provider-client.ts diff --git a/packages/relay-helpers/README.md b/packages/relay-helpers/README.md index 66e07b3d..5776a757 100644 --- a/packages/relay-helpers/README.md +++ b/packages/relay-helpers/README.md @@ -20,26 +20,29 @@ await githubClient().merge({ owner, repo, number }); await slackClient().post('#eng', 'shipped'); ``` -## Generic client (all providers) +## A named client for every provider -Every provider in the catalog (29+) is reachable through `relayClient`, with -generic methods over the catalog paths: +Every provider in the catalog has a named client (`asanaClient`, `notionClient`, +`jiraClient`, … — all 29), exposing its resources as +`.{resource}.{path,write,read,list}`: ```ts -import { relayClient } from '@agentworkforce/relay-helpers'; +import { notionClient } from '@agentworkforce/relay-helpers'; -const notion = relayClient('notion'); -notion.path('pages', { databaseId }); // resolve a path (no IO) -await notion.write('pages', { databaseId }, { /* … */ }); // collection create or item write -await notion.list('pages', { databaseId }); // list a collection +const notion = notionClient(); +notion.pages.path({ databaseId }); // resolve a path (no IO) +await notion.pages.write({ databaseId }, { /* … */ }); // collection create or item write +await notion.pages.list({ databaseId }); // list a collection ``` -- `write(resource, params, body)` drops a uniquely-named draft for a collection - resource, or writes directly to an item resource (a path ending in `.json`). - The Relayfile writeback worker turns the draft into the real provider call. +- `write(params, body)` drops a uniquely-named draft for a collection resource, + or writes directly to an item resource (a path ending in `.json`). The + Relayfile writeback worker turns the draft into the real provider call. - `read` / `list` operate over the catalog paths. - Unknown providers/resources or missing path params throw loudly — never a guessed path. -The `linearClient` / `githubClient` / `slackClient` factories wrap `relayClient` -with named, ergonomic methods for the most common operations. +`linearClient` / `githubClient` / `slackClient` are the same resource-keyed +clients **plus** named ergonomic methods (`comment`, `post`, `mergePullRequest`, +…). `relayClient(provider)` is the dynamic, string-keyed escape hatch when the +provider isn't known at author time. diff --git a/packages/relay-helpers/src/clients.ts b/packages/relay-helpers/src/clients.ts new file mode 100644 index 00000000..20848a99 --- /dev/null +++ b/packages/relay-helpers/src/clients.ts @@ -0,0 +1,37 @@ +import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; +import { providerClient, type ProviderClient } from './provider-client.js'; + +/** + * Named, resource-keyed clients for every provider in the writeback-path + * catalog. `linear` / `github` / `slack` get richer ergonomic clients in their + * own modules; the rest are uniform resource-keyed clients here. + * + * Each exposes its catalog resources as `.{resource}.{path,write,read,list}`, + * e.g. `notionClient().pages.write({ databaseId }, { ... })`. + */ +export const asanaClient = (opts?: IntegrationClientOptions): ProviderClient<'asana'> => providerClient('asana', opts); +export const azureBlobClient = (opts?: IntegrationClientOptions): ProviderClient<'azure-blob'> => providerClient('azure-blob', opts); +export const boxClient = (opts?: IntegrationClientOptions): ProviderClient<'box'> => providerClient('box', opts); +export const clickupClient = (opts?: IntegrationClientOptions): ProviderClient<'clickup'> => providerClient('clickup', opts); +export const confluenceClient = (opts?: IntegrationClientOptions): ProviderClient<'confluence'> => providerClient('confluence', opts); +export const dropboxClient = (opts?: IntegrationClientOptions): ProviderClient<'dropbox'> => providerClient('dropbox', opts); +export const gcsClient = (opts?: IntegrationClientOptions): ProviderClient<'gcs'> => providerClient('gcs', opts); +export const gitlabClient = (opts?: IntegrationClientOptions): ProviderClient<'gitlab'> => providerClient('gitlab', opts); +export const gmailClient = (opts?: IntegrationClientOptions): ProviderClient<'gmail'> => providerClient('gmail', opts); +export const googleCalendarClient = (opts?: IntegrationClientOptions): ProviderClient<'google-calendar'> => providerClient('google-calendar', opts); +export const googleDriveClient = (opts?: IntegrationClientOptions): ProviderClient<'google-drive'> => providerClient('google-drive', opts); +export const granolaClient = (opts?: IntegrationClientOptions): ProviderClient<'granola'> => providerClient('granola', opts); +export const hubspotClient = (opts?: IntegrationClientOptions): ProviderClient<'hubspot'> => providerClient('hubspot', opts); +export const intercomClient = (opts?: IntegrationClientOptions): ProviderClient<'intercom'> => providerClient('intercom', opts); +export const jiraClient = (opts?: IntegrationClientOptions): ProviderClient<'jira'> => providerClient('jira', opts); +export const notionClient = (opts?: IntegrationClientOptions): ProviderClient<'notion'> => providerClient('notion', opts); +export const onedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'onedrive'> => providerClient('onedrive', opts); +export const pipedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'pipedrive'> => providerClient('pipedrive', opts); +export const postgresClient = (opts?: IntegrationClientOptions): ProviderClient<'postgres'> => providerClient('postgres', opts); +export const redditClient = (opts?: IntegrationClientOptions): ProviderClient<'reddit'> => providerClient('reddit', opts); +export const redisClient = (opts?: IntegrationClientOptions): ProviderClient<'redis'> => providerClient('redis', opts); +export const s3Client = (opts?: IntegrationClientOptions): ProviderClient<'s3'> => providerClient('s3', opts); +export const salesforceClient = (opts?: IntegrationClientOptions): ProviderClient<'salesforce'> => providerClient('salesforce', opts); +export const sharepointClient = (opts?: IntegrationClientOptions): ProviderClient<'sharepoint'> => providerClient('sharepoint', opts); +export const teamsClient = (opts?: IntegrationClientOptions): ProviderClient<'teams'> => providerClient('teams', opts); +export const zendeskClient = (opts?: IntegrationClientOptions): ProviderClient<'zendesk'> => providerClient('zendesk', opts); diff --git a/packages/relay-helpers/src/github.ts b/packages/relay-helpers/src/github.ts index b1269ece..4990b9d3 100644 --- a/packages/relay-helpers/src/github.ts +++ b/packages/relay-helpers/src/github.ts @@ -1,5 +1,5 @@ import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; -import { relayClient } from './generic.js'; +import { providerClient, type ProviderClient } from './provider-client.js'; import { created } from './receipt.js'; export interface GithubTarget { @@ -8,7 +8,7 @@ export interface GithubTarget { number: number; } -export interface GithubClient { +export interface GithubClient extends ProviderClient<'github'> { /** Comment on an issue or pull request. */ comment(target: GithubTarget, body: string): Promise<{ id: string; url: string }>; /** Create an issue. */ @@ -19,8 +19,11 @@ export interface GithubClient { body: string; labels?: string[]; }): Promise<{ id: string; url: string }>; - /** Merge a pull request. */ - merge(args: { + /** + * Merge a pull request. (Named `mergePullRequest`, not `merge`, because + * `merge` is the catalog resource key exposed as `.merge`.) + */ + mergePullRequest(args: { owner: string; repo: string; number: number; @@ -41,33 +44,38 @@ export interface GithubClient { } /** - * Ergonomic GitHub client over the writeback-path catalog. Recovers the - * `ctx.github.comment(...)` shape removed from the runtime. + * Ergonomic GitHub client over the writeback-path catalog, plus the uniform + * resource-keyed access (`.issues`, `.["issue-comments"]`, `.merge`, `.reviews`). */ export function githubClient(opts: IntegrationClientOptions = {}): GithubClient { - const relay = relayClient('github', opts); - return { - async comment(target, body) { + const base = providerClient('github', opts); + return Object.assign(base, { + async comment(target: GithubTarget, body: string) { return created( - await relay.write( - 'issue-comments', + await base['issue-comments'].write( { owner: target.owner, repo: target.repo, issueNumber: target.number }, { body } ) ); }, - async createIssue(args) { + async createIssue(args: { owner: string; repo: string; title: string; body: string; labels?: string[] }) { return created( - await relay.write( - 'issues', + await base.issues.write( { owner: args.owner, repo: args.repo }, { title: args.title, body: args.body, ...(args.labels ? { labels: args.labels } : {}) } ) ); }, - async merge(args) { - const result = await relay.write( - 'merge', + async mergePullRequest(args: { + owner: string; + repo: string; + number: number; + method?: 'merge' | 'squash' | 'rebase'; + commitTitle?: string; + commitMessage?: string; + sha?: string; + }) { + const result = await base.merge.write( { owner: args.owner, repo: args.repo, pullNumber: args.number }, { ...(args.method !== undefined ? { merge_method: args.method } : {}), @@ -88,12 +96,18 @@ export function githubClient(opts: IntegrationClientOptions = {}): GithubClient ...(sha ? { sha } : {}) }; }, - async review(target, args) { - await relay.write( - 'reviews', + async review( + target: GithubTarget, + args: { + body: string; + event: 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES'; + comments?: Array<{ path: string; line: number; body: string }>; + } + ) { + await base.reviews.write( { owner: target.owner, repo: target.repo, pullNumber: target.number }, { ...args, comments: args.comments ?? [] } ); } - }; + }) as GithubClient; } diff --git a/packages/relay-helpers/src/index.ts b/packages/relay-helpers/src/index.ts index ec498db9..4448f423 100644 --- a/packages/relay-helpers/src/index.ts +++ b/packages/relay-helpers/src/index.ts @@ -14,12 +14,49 @@ * const issue = await linear.getIssue(issueId); * await linear.comment(issueId, ':rocket: done'); * - * `relayClient(provider)` gives the same transport for any of the 29 providers - * in the catalog, with generic `write` / `read` / `list` methods. + * Every provider in the catalog has a named client (`asanaClient`, + * `notionClient`, … through all 29), exposing its resources as + * `.{resource}.{path,write,read,list}`. `linear` / `github` / `slack` add + * named ergonomic methods on top. `relayClient(provider)` is the dynamic, + * string-keyed escape hatch when the provider isn't known at author time. */ export { relayClient, encodeSegment, type RelayClient, type RelayParams } from './generic.js'; -export { linearClient, type LinearClient } from './linear.js'; +export { providerClient, type ProviderClient, type ResourceClient } from './provider-client.js'; +export { created } from './receipt.js'; + +// Ergonomic clients (resource-keyed access + named methods). +export { linearClient, type LinearClient, type LinearCreateIssueArgs } from './linear.js'; export { githubClient, type GithubClient, type GithubTarget } from './github.js'; export { slackClient, type SlackClient } from './slack.js'; -export { created } from './receipt.js'; + +// Named resource-keyed clients for the remaining catalog providers. +export { + asanaClient, + azureBlobClient, + boxClient, + clickupClient, + confluenceClient, + dropboxClient, + gcsClient, + gitlabClient, + gmailClient, + googleCalendarClient, + googleDriveClient, + granolaClient, + hubspotClient, + intercomClient, + jiraClient, + notionClient, + onedriveClient, + pipedriveClient, + postgresClient, + redditClient, + redisClient, + s3Client, + salesforceClient, + sharepointClient, + teamsClient, + zendeskClient +} from './clients.js'; + export type { IntegrationClientOptions, WritebackResult } from '@agentworkforce/runtime/clients'; diff --git a/packages/relay-helpers/src/linear.ts b/packages/relay-helpers/src/linear.ts index f072e8e7..40aba4f5 100644 --- a/packages/relay-helpers/src/linear.ts +++ b/packages/relay-helpers/src/linear.ts @@ -3,22 +3,25 @@ import { writeJsonFile, type IntegrationClientOptions } from '@agentworkforce/runtime/clients'; -import { encodeSegment, relayClient } from './generic.js'; +import { encodeSegment } from './generic.js'; +import { providerClient, type ProviderClient } from './provider-client.js'; import { created } from './receipt.js'; -export interface LinearClient { +export interface LinearCreateIssueArgs { + teamId: string; + title: string; + description?: string; + assigneeId?: string; + labelIds?: string[]; + projectId?: string; + stateId?: string; +} + +export interface LinearClient extends ProviderClient<'linear'> { /** Comment on an issue. */ comment(issueId: string, body: string): Promise<{ id: string; url: string }>; /** Create an issue. */ - createIssue(args: { - teamId: string; - title: string; - description?: string; - assigneeId?: string; - labelIds?: string[]; - projectId?: string; - stateId?: string; - }): Promise<{ id: string; url: string }>; + createIssue(args: LinearCreateIssueArgs): Promise<{ id: string; url: string }>; /** Patch an existing issue. */ updateIssue( issueId: string, @@ -32,32 +35,27 @@ export interface LinearClient { /** * Ergonomic Linear client over the writeback-path catalog. Recovers the - * `ctx.linear.comment(...)` shape removed from the runtime, with paths sourced - * from `@relayfile/adapter-core` rather than hardcoded. + * `ctx.linear.comment(...)` shape removed from the runtime, plus the uniform + * resource-keyed access (`.issues`, `.comments`) every provider client has. */ export function linearClient(opts: IntegrationClientOptions = {}): LinearClient { - const relay = relayClient('linear', opts); - const issuePath = (issueId: string) => `${relay.path('issues')}/${encodeSegment(issueId)}.json`; - return { - async comment(issueId, body) { - return created(await relay.write('comments', { issueId }, { body })); + const base = providerClient('linear', opts); + const issuePath = (issueId: string) => `${base.issues.path()}/${encodeSegment(issueId)}.json`; + return Object.assign(base, { + async comment(issueId: string, body: string) { + return created(await base.comments.write({ issueId }, { body })); }, - async createIssue(args) { - const result = await relay.write('issues', {}, args); - const id = result.receipt?.created ?? result.receipt?.id ?? result.path; - return { - id, - url: result.receipt?.url ?? result.path - }; + async createIssue(args: LinearCreateIssueArgs) { + return created(await base.issues.write({}, args)); }, - async updateIssue(issueId, args) { + async updateIssue(issueId: string, args: Record) { await writeJsonFile(opts, 'linear', 'updateIssue', issuePath(issueId), args); }, getIssue>(issueId: string): Promise { return readJsonFile(opts, 'linear', 'getIssue', issuePath(issueId)); }, listIssues>(): Promise { - return relay.list('issues'); + return base.issues.list(); } - }; + }) as LinearClient; } diff --git a/packages/relay-helpers/src/provider-client.ts b/packages/relay-helpers/src/provider-client.ts new file mode 100644 index 00000000..2d76d663 --- /dev/null +++ b/packages/relay-helpers/src/provider-client.ts @@ -0,0 +1,58 @@ +import type { IntegrationClientOptions, WritebackResult } from '@agentworkforce/runtime/clients'; +import { + WRITEBACK_PATH_CATALOG, + type WritebackProvider, + type WritebackResource +} from '@relayfile/adapter-core/writeback-paths'; +import { relayClient, type RelayParams } from './generic.js'; + +/** + * One resource of a provider, bound to a client. Every path is resolved from + * the writeback-path catalog. + */ +export interface ResourceClient { + /** Resolve this resource's canonical mount path (no IO). */ + path(params?: RelayParams): string; + /** + * Write `body`: a uniquely-named draft for a collection resource, or a direct + * write for an item (`.json`) resource. The writeback worker materializes the + * draft into the real provider call. + */ + write(params: RelayParams, body: unknown): Promise; + /** Read a single item resource (a `.json` path). */ + read(params?: RelayParams): Promise; + /** List the records of a collection resource. */ + list(params?: RelayParams): Promise; +} + +/** A provider client: one {@link ResourceClient} per catalog resource. */ +export type ProviderClient

= { + readonly [R in WritebackResource

& string]: ResourceClient; +}; + +/** + * Build a resource-keyed client for any provider in the catalog. The mount + * root / writeback options are bound once and shared by every resource. + * + * @example + * const notion = providerClient('notion'); + * await notion.pages.write({ databaseId }, { ... }); + * await notion.comments.path({ databaseId, pageId }); + */ +export function providerClient

( + provider: P, + opts: IntegrationClientOptions = {} +): ProviderClient

{ + const relay = relayClient(provider, opts); + const out: Record = {}; + for (const resource of Object.keys(WRITEBACK_PATH_CATALOG[provider])) { + const r = resource as WritebackResource

& string; + out[resource] = { + path: (params) => relay.path(r, params), + write: (params, body) => relay.write(r, params, body), + read: (params?: RelayParams) => relay.read(r, params), + list: (params?: RelayParams) => relay.list(r, params) + }; + } + return out as ProviderClient

; +} diff --git a/packages/relay-helpers/src/relay-helpers.test.ts b/packages/relay-helpers/src/relay-helpers.test.ts index afc63a2e..a189125e 100644 --- a/packages/relay-helpers/src/relay-helpers.test.ts +++ b/packages/relay-helpers/src/relay-helpers.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { WRITEBACK_PATH_CATALOG } from '@relayfile/adapter-core/writeback-paths'; -import { githubClient, linearClient, relayClient, slackClient } from './index.js'; +import * as helpers from './index.js'; +import { githubClient, linearClient, notionClient, relayClient, slackClient } from './index.js'; + +const clientExportName = (provider: string): string => + `${provider.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase())}Client`; /** Fire-and-forget client bound to a throwaway mount; no writeback worker runs. */ async function mount(): Promise<{ root: string; opts: { relayfileMountRoot: string; writebackTimeoutMs: number } }> { @@ -77,13 +81,28 @@ test('githubClient.comment and slackClient.post target the canonical paths', asy assert.deepEqual(msg.body, { text: 'shipped' }); }); -test('relayClient covers every provider in the catalog', () => { +test('every catalog provider has a named client export', () => { const providers = Object.keys(WRITEBACK_PATH_CATALOG); assert.ok(providers.length >= 29, `expected >=29 providers, saw ${providers.length}`); - for (const provider of providers) { + const missing = providers.filter( + (provider) => typeof (helpers as Record)[clientExportName(provider)] !== 'function' + ); + assert.deepEqual(missing, [], `providers without a named client export: ${missing.join(', ')}`); +}); + +test('a named resource-keyed client resolves and writes catalog paths', async () => { + const { root, opts } = await mount(); + const notion = notionClient(opts); + assert.equal(notion.pages.path({ databaseId: 'db1' }), '/notion/databases/db1/pages'); + await notion.pages.write({ databaseId: 'db1' }, { title: 'P' }); + const draft = await onlyJsonIn(path.join(root, 'notion/databases/db1/pages')); + assert.deepEqual(draft.body, { title: 'P' }); +}); + +test('relayClient (dynamic) still resolves paths for every provider', () => { + for (const provider of Object.keys(WRITEBACK_PATH_CATALOG)) { const client = relayClient(provider as keyof typeof WRITEBACK_PATH_CATALOG); const [resource, variants] = Object.entries(WRITEBACK_PATH_CATALOG[provider as keyof typeof WRITEBACK_PATH_CATALOG])[0]; - // Build params from the first variant's placeholders so path() resolves. const params = Object.fromEntries((variants[0].params as readonly string[]).map((name) => [name, 'x'])); assert.ok(client.path(resource as never, params).startsWith(`/${provider}`)); } diff --git a/packages/relay-helpers/src/slack.ts b/packages/relay-helpers/src/slack.ts index 8b367c7e..d0090133 100644 --- a/packages/relay-helpers/src/slack.ts +++ b/packages/relay-helpers/src/slack.ts @@ -1,12 +1,12 @@ import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; -import { relayClient } from './generic.js'; +import { providerClient, type ProviderClient } from './provider-client.js'; /** Slack message timestamps contain `.`; the mount path encodes it as `_`. */ function tsParam(ts: string): string { return ts.replace(/\./g, '_'); } -export interface SlackClient { +export interface SlackClient extends ProviderClient<'slack'> { /** Post a message to a channel. */ post(channel: string, text: string): Promise<{ channel: string; ts: string }>; /** Direct-message a user. */ @@ -18,30 +18,26 @@ export interface SlackClient { } /** - * Ergonomic Slack client over the writeback-path catalog. Recovers the - * `ctx.slack.post(...)` shape removed from the runtime. + * Ergonomic Slack client over the writeback-path catalog, plus the uniform + * resource-keyed access (`.messages`, `.["direct-messages"]`, `.replies`, `.reactions`). */ export function slackClient(opts: IntegrationClientOptions = {}): SlackClient { - const relay = relayClient('slack', opts); - return { - async post(channel, text) { - const result = await relay.write('messages', { channelId: channel }, { text }); + const base = providerClient('slack', opts); + return Object.assign(base, { + async post(channel: string, text: string) { + const result = await base.messages.write({ channelId: channel }, { text }); return { channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; }, - async dm(user, text) { - const result = await relay.write('direct-messages', { userId: user }, { text }); + async dm(user: string, text: string) { + const result = await base['direct-messages'].write({ userId: user }, { text }); return { user, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; }, - async reply(channel, threadTs, text) { - const result = await relay.write( - 'replies', - { channelId: channel, messageTs: tsParam(threadTs) }, - { text } - ); + async reply(channel: string, threadTs: string, text: string) { + const result = await base.replies.write({ channelId: channel, messageTs: tsParam(threadTs) }, { text }); return { channel, ts: result.receipt?.created ?? result.receipt?.id ?? '' }; }, - async react(channel, messageTs, emoji) { - await relay.write('reactions', { channelId: channel, messageTs: tsParam(messageTs) }, { emoji }); + async react(channel: string, messageTs: string, emoji: string) { + await base.reactions.write({ channelId: channel, messageTs: tsParam(messageTs) }, { emoji }); } - }; + }) as SlackClient; } From d3f509aa23da1b13bcdf95b8936f53b22dfb06dd Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 11:11:07 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs(relay-helpers):=20AGENTS.md=20+=20CLAU?= =?UTF-8?q?DE.md=20=E2=80=94=20named-client-per-provider=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the rule that every provider in @relayfile/adapter-core/writeback-paths must have a named Client here, how to add one when adapter-core is bumped, the resource-name collision gotcha (github mergePullRequest), and the no-hardcoded-paths invariant. The CI test is the hard guard; these are the reminder for whoever bumps the adapter dep. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/relay-helpers/AGENTS.md | 37 ++++++++++++++++++++++++++++++++ packages/relay-helpers/CLAUDE.md | 10 +++++++++ 2 files changed, 47 insertions(+) create mode 100644 packages/relay-helpers/AGENTS.md create mode 100644 packages/relay-helpers/CLAUDE.md diff --git a/packages/relay-helpers/AGENTS.md b/packages/relay-helpers/AGENTS.md new file mode 100644 index 00000000..648cf54c --- /dev/null +++ b/packages/relay-helpers/AGENTS.md @@ -0,0 +1,37 @@ +# relay-helpers — agent notes + +## Invariant: a named client for every catalog provider + +This package MUST expose a named `Client` for **every** provider in +`@relayfile/adapter-core/writeback-paths` (`WRITEBACK_PATH_CATALOG`). The test +**"every catalog provider has a named client export"** (`src/relay-helpers.test.ts`) +fails CI until it does — so this can't silently drift. + +When `@relayfile/adapter-core` is bumped and a new provider appears (a new +writeback-capable adapter shipped upstream in `relayfile-adapters`), the build +goes red. To fix it: + +1. Add a named export to `src/clients.ts`: + ```ts + export const fooClient = (opts?: IntegrationClientOptions): ProviderClient<'foo'> => + providerClient('foo', opts); + ``` + camelCase any hyphens in the provider slug (`azure-blob` → `azureBlobClient`). +2. Re-export it from `src/index.ts`. +3. If the provider deserves ergonomic named methods (like `linear`/`github`/`slack` + with `comment`/`post`/`mergePullRequest`), add a bespoke module that + *enriches* `providerClient(...)` via `Object.assign` instead of the plain + one-liner — but watch for a method name that collides with a catalog resource + key (that's why github's merge helper is `mergePullRequest`, not `merge`). +4. `pnpm --filter @agentworkforce/relay-helpers test` must pass. + +## Paths are never hardcoded here + +Every path comes from the catalog via `writebackPath` / `relayClient` / +`providerClient`. Do not write `/linear/issues/...` literals — that reintroduces +the drift this package exists to prevent. If a path is wrong, fix it in the +adapter's `resources.ts` upstream (relayfile-adapters), regenerate the catalog, +and bump the dep. + +The upstream obligation (an adapter author noticing a new provider lands here) +is documented in `relayfile-adapters/AGENTS.md` → "Declared catalogs". diff --git a/packages/relay-helpers/CLAUDE.md b/packages/relay-helpers/CLAUDE.md new file mode 100644 index 00000000..5eccaf53 --- /dev/null +++ b/packages/relay-helpers/CLAUDE.md @@ -0,0 +1,10 @@ +# CLAUDE.md — @agentworkforce/relay-helpers + +See [AGENTS.md](./AGENTS.md) for the full notes. + +**Key rule:** this package must export a named `Client` for every +provider in `@relayfile/adapter-core/writeback-paths`. The test "every catalog +provider has a named client export" enforces it. When you bump +`@relayfile/adapter-core` and a new provider appears, add it to `src/clients.ts` +(and re-export from `src/index.ts`). Never hardcode provider paths here — resolve +them through `writebackPath` / `relayClient` / `providerClient`. From a43db7b6493a18ccf6bcb714ccf6ee827f883b74 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 11:17:32 +0200 Subject: [PATCH 4/4] refactor(relay-helpers): generate the per-provider named clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-maintained 26-export clients.ts with src/generated/clients.ts, emitted from the catalog by scripts/generate-clients.mjs (mirrors the workforce src/generated codegen convention). index.ts `export *`s it, so adding a provider is friction-free: bump @relayfile/adapter-core, run `pnpm --filter @agentworkforce/relay-helpers gen`, done. A new in-sync test ("src/generated/clients.ts is in sync with the catalog") imports the generator's renderClients() and diffs the committed file, so a stale generated file fails CI — same guard pattern as adapter-core's catalogs. Bespoke providers (linear/github/slack) stay in their own enriched modules and are excluded via BESPOKE_PROVIDERS in the generator. Docs (AGENTS.md/CLAUDE.md) updated: regenerate, don't hand-edit. 9/9 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/relay-helpers/AGENTS.md | 33 +++---- packages/relay-helpers/CLAUDE.md | 12 +-- packages/relay-helpers/package.json | 1 + .../scripts/generate-clients.mjs | 57 ++++++++++++ packages/relay-helpers/src/clients.ts | 37 -------- .../relay-helpers/src/generated/clients.ts | 87 +++++++++++++++++++ packages/relay-helpers/src/index.ts | 33 +------ .../relay-helpers/src/relay-helpers.test.ts | 15 ++++ 8 files changed, 189 insertions(+), 86 deletions(-) create mode 100644 packages/relay-helpers/scripts/generate-clients.mjs delete mode 100644 packages/relay-helpers/src/clients.ts create mode 100644 packages/relay-helpers/src/generated/clients.ts diff --git a/packages/relay-helpers/AGENTS.md b/packages/relay-helpers/AGENTS.md index 648cf54c..c6a099e6 100644 --- a/packages/relay-helpers/AGENTS.md +++ b/packages/relay-helpers/AGENTS.md @@ -7,23 +7,26 @@ This package MUST expose a named `Client` for **every** provider in **"every catalog provider has a named client export"** (`src/relay-helpers.test.ts`) fails CI until it does — so this can't silently drift. -When `@relayfile/adapter-core` is bumped and a new provider appears (a new -writeback-capable adapter shipped upstream in `relayfile-adapters`), the build -goes red. To fix it: +The named clients for the plain providers are **generated** — `src/generated/clients.ts` +is emitted by `scripts/generate-clients.mjs` from the catalog. Do not hand-edit +it. When `@relayfile/adapter-core` is bumped and a new provider appears (a new +writeback-capable adapter shipped upstream in `relayfile-adapters`), the in-sync +test goes red. To fix it: -1. Add a named export to `src/clients.ts`: - ```ts - export const fooClient = (opts?: IntegrationClientOptions): ProviderClient<'foo'> => - providerClient('foo', opts); +1. Regenerate: + ```bash + pnpm --filter @agentworkforce/relay-helpers gen ``` - camelCase any hyphens in the provider slug (`azure-blob` → `azureBlobClient`). -2. Re-export it from `src/index.ts`. -3. If the provider deserves ergonomic named methods (like `linear`/`github`/`slack` - with `comment`/`post`/`mergePullRequest`), add a bespoke module that - *enriches* `providerClient(...)` via `Object.assign` instead of the plain - one-liner — but watch for a method name that collides with a catalog resource - key (that's why github's merge helper is `mergePullRequest`, not `merge`). -4. `pnpm --filter @agentworkforce/relay-helpers test` must pass. + This adds the new `Client` (camelCased slug — `azure-blob` → + `azureBlobClient`) to `src/generated/clients.ts`. `index.ts` does + `export *` from it, so no re-export edit is needed. +2. If the provider deserves ergonomic named methods (like `linear`/`github`/`slack` + with `comment`/`post`/`mergePullRequest`), add it to `BESPOKE_PROVIDERS` in + the generator and write a bespoke module that *enriches* `providerClient(...)` + via `Object.assign` — but watch for a method name that collides with a catalog + resource key (that's why github's merge helper is `mergePullRequest`, not + `merge`). +3. `pnpm --filter @agentworkforce/relay-helpers test` must pass. ## Paths are never hardcoded here diff --git a/packages/relay-helpers/CLAUDE.md b/packages/relay-helpers/CLAUDE.md index 5eccaf53..9db2ba2a 100644 --- a/packages/relay-helpers/CLAUDE.md +++ b/packages/relay-helpers/CLAUDE.md @@ -3,8 +3,10 @@ See [AGENTS.md](./AGENTS.md) for the full notes. **Key rule:** this package must export a named `Client` for every -provider in `@relayfile/adapter-core/writeback-paths`. The test "every catalog -provider has a named client export" enforces it. When you bump -`@relayfile/adapter-core` and a new provider appears, add it to `src/clients.ts` -(and re-export from `src/index.ts`). Never hardcode provider paths here — resolve -them through `writebackPath` / `relayClient` / `providerClient`. +provider in `@relayfile/adapter-core/writeback-paths`. The non-bespoke clients +are **generated** into `src/generated/clients.ts` (do not hand-edit). When you +bump `@relayfile/adapter-core` and a new provider appears, the in-sync test goes +red — run `pnpm --filter @agentworkforce/relay-helpers gen` to regenerate; +`index.ts` `export *`s it, so nothing else to edit. Never hardcode provider +paths here — resolve them through `writebackPath` / `relayClient` / +`providerClient`. diff --git a/packages/relay-helpers/package.json b/packages/relay-helpers/package.json index 84c9b8c0..248c3095 100644 --- a/packages/relay-helpers/package.json +++ b/packages/relay-helpers/package.json @@ -26,6 +26,7 @@ "access": "public" }, "scripts": { + "gen": "node scripts/generate-clients.mjs", "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/packages/relay-helpers/scripts/generate-clients.mjs b/packages/relay-helpers/scripts/generate-clients.mjs new file mode 100644 index 00000000..46967e0e --- /dev/null +++ b/packages/relay-helpers/scripts/generate-clients.mjs @@ -0,0 +1,57 @@ +// Generates `src/generated/clients.ts` — a named resource-keyed client for +// every provider in the writeback-path catalog. Run after bumping +// `@relayfile/adapter-core`: +// +// pnpm --filter @agentworkforce/relay-helpers gen +// +// `renderClients()` is also imported by the in-sync test, so a stale committed +// file fails CI. +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { WRITEBACK_PATH_CATALOG } from '@relayfile/adapter-core/writeback-paths'; + +/** Providers with a bespoke, hand-written client module (enriched with named methods). */ +export const BESPOKE_PROVIDERS = new Set(['linear', 'github', 'slack']); + +const camel = (slug) => slug.replace(/-([a-z])/g, (_m, c) => c.toUpperCase()); +export const clientName = (provider) => `${camel(provider)}Client`; + +/** Catalog providers that get a generated (non-bespoke) client, sorted. */ +export function generatedProviders() { + return Object.keys(WRITEBACK_PATH_CATALOG) + .filter((provider) => !BESPOKE_PROVIDERS.has(provider)) + .sort(); +} + +export function renderClients() { + const header = + `// GENERATED by scripts/generate-clients.mjs from\n` + + `// @relayfile/adapter-core/writeback-paths — do not edit by hand.\n` + + `// Run \`pnpm --filter @agentworkforce/relay-helpers gen\` after bumping adapter-core.\n` + + `//\n` + + `// linear / github / slack have bespoke ergonomic clients in their own modules;\n` + + `// every other catalog provider gets a uniform resource-keyed client here.\n`; + const imports = + `import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients';\n` + + `import { providerClient, type ProviderClient } from '../provider-client.js';\n`; + const body = generatedProviders() + .map( + (provider) => + `export const ${clientName(provider)} = (opts?: IntegrationClientOptions): ProviderClient<'${provider}'> =>\n` + + ` providerClient('${provider}', opts);` + ) + .join('\n\n'); + return `${header}\n${imports}\n${body}\n`; +} + +const outputFile = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../src/generated/clients.ts' +); + +// Write when executed directly (`node scripts/generate-clients.mjs`). +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await writeFile(outputFile, renderClients()); + process.stdout.write(`wrote ${path.relative(process.cwd(), outputFile)} (${generatedProviders().length} clients)\n`); +} diff --git a/packages/relay-helpers/src/clients.ts b/packages/relay-helpers/src/clients.ts deleted file mode 100644 index 20848a99..00000000 --- a/packages/relay-helpers/src/clients.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; -import { providerClient, type ProviderClient } from './provider-client.js'; - -/** - * Named, resource-keyed clients for every provider in the writeback-path - * catalog. `linear` / `github` / `slack` get richer ergonomic clients in their - * own modules; the rest are uniform resource-keyed clients here. - * - * Each exposes its catalog resources as `.{resource}.{path,write,read,list}`, - * e.g. `notionClient().pages.write({ databaseId }, { ... })`. - */ -export const asanaClient = (opts?: IntegrationClientOptions): ProviderClient<'asana'> => providerClient('asana', opts); -export const azureBlobClient = (opts?: IntegrationClientOptions): ProviderClient<'azure-blob'> => providerClient('azure-blob', opts); -export const boxClient = (opts?: IntegrationClientOptions): ProviderClient<'box'> => providerClient('box', opts); -export const clickupClient = (opts?: IntegrationClientOptions): ProviderClient<'clickup'> => providerClient('clickup', opts); -export const confluenceClient = (opts?: IntegrationClientOptions): ProviderClient<'confluence'> => providerClient('confluence', opts); -export const dropboxClient = (opts?: IntegrationClientOptions): ProviderClient<'dropbox'> => providerClient('dropbox', opts); -export const gcsClient = (opts?: IntegrationClientOptions): ProviderClient<'gcs'> => providerClient('gcs', opts); -export const gitlabClient = (opts?: IntegrationClientOptions): ProviderClient<'gitlab'> => providerClient('gitlab', opts); -export const gmailClient = (opts?: IntegrationClientOptions): ProviderClient<'gmail'> => providerClient('gmail', opts); -export const googleCalendarClient = (opts?: IntegrationClientOptions): ProviderClient<'google-calendar'> => providerClient('google-calendar', opts); -export const googleDriveClient = (opts?: IntegrationClientOptions): ProviderClient<'google-drive'> => providerClient('google-drive', opts); -export const granolaClient = (opts?: IntegrationClientOptions): ProviderClient<'granola'> => providerClient('granola', opts); -export const hubspotClient = (opts?: IntegrationClientOptions): ProviderClient<'hubspot'> => providerClient('hubspot', opts); -export const intercomClient = (opts?: IntegrationClientOptions): ProviderClient<'intercom'> => providerClient('intercom', opts); -export const jiraClient = (opts?: IntegrationClientOptions): ProviderClient<'jira'> => providerClient('jira', opts); -export const notionClient = (opts?: IntegrationClientOptions): ProviderClient<'notion'> => providerClient('notion', opts); -export const onedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'onedrive'> => providerClient('onedrive', opts); -export const pipedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'pipedrive'> => providerClient('pipedrive', opts); -export const postgresClient = (opts?: IntegrationClientOptions): ProviderClient<'postgres'> => providerClient('postgres', opts); -export const redditClient = (opts?: IntegrationClientOptions): ProviderClient<'reddit'> => providerClient('reddit', opts); -export const redisClient = (opts?: IntegrationClientOptions): ProviderClient<'redis'> => providerClient('redis', opts); -export const s3Client = (opts?: IntegrationClientOptions): ProviderClient<'s3'> => providerClient('s3', opts); -export const salesforceClient = (opts?: IntegrationClientOptions): ProviderClient<'salesforce'> => providerClient('salesforce', opts); -export const sharepointClient = (opts?: IntegrationClientOptions): ProviderClient<'sharepoint'> => providerClient('sharepoint', opts); -export const teamsClient = (opts?: IntegrationClientOptions): ProviderClient<'teams'> => providerClient('teams', opts); -export const zendeskClient = (opts?: IntegrationClientOptions): ProviderClient<'zendesk'> => providerClient('zendesk', opts); diff --git a/packages/relay-helpers/src/generated/clients.ts b/packages/relay-helpers/src/generated/clients.ts new file mode 100644 index 00000000..de15ec14 --- /dev/null +++ b/packages/relay-helpers/src/generated/clients.ts @@ -0,0 +1,87 @@ +// GENERATED by scripts/generate-clients.mjs from +// @relayfile/adapter-core/writeback-paths — do not edit by hand. +// Run `pnpm --filter @agentworkforce/relay-helpers gen` after bumping adapter-core. +// +// linear / github / slack have bespoke ergonomic clients in their own modules; +// every other catalog provider gets a uniform resource-keyed client here. + +import type { IntegrationClientOptions } from '@agentworkforce/runtime/clients'; +import { providerClient, type ProviderClient } from '../provider-client.js'; + +export const asanaClient = (opts?: IntegrationClientOptions): ProviderClient<'asana'> => + providerClient('asana', opts); + +export const azureBlobClient = (opts?: IntegrationClientOptions): ProviderClient<'azure-blob'> => + providerClient('azure-blob', opts); + +export const boxClient = (opts?: IntegrationClientOptions): ProviderClient<'box'> => + providerClient('box', opts); + +export const clickupClient = (opts?: IntegrationClientOptions): ProviderClient<'clickup'> => + providerClient('clickup', opts); + +export const confluenceClient = (opts?: IntegrationClientOptions): ProviderClient<'confluence'> => + providerClient('confluence', opts); + +export const dropboxClient = (opts?: IntegrationClientOptions): ProviderClient<'dropbox'> => + providerClient('dropbox', opts); + +export const gcsClient = (opts?: IntegrationClientOptions): ProviderClient<'gcs'> => + providerClient('gcs', opts); + +export const gitlabClient = (opts?: IntegrationClientOptions): ProviderClient<'gitlab'> => + providerClient('gitlab', opts); + +export const gmailClient = (opts?: IntegrationClientOptions): ProviderClient<'gmail'> => + providerClient('gmail', opts); + +export const googleCalendarClient = (opts?: IntegrationClientOptions): ProviderClient<'google-calendar'> => + providerClient('google-calendar', opts); + +export const googleDriveClient = (opts?: IntegrationClientOptions): ProviderClient<'google-drive'> => + providerClient('google-drive', opts); + +export const granolaClient = (opts?: IntegrationClientOptions): ProviderClient<'granola'> => + providerClient('granola', opts); + +export const hubspotClient = (opts?: IntegrationClientOptions): ProviderClient<'hubspot'> => + providerClient('hubspot', opts); + +export const intercomClient = (opts?: IntegrationClientOptions): ProviderClient<'intercom'> => + providerClient('intercom', opts); + +export const jiraClient = (opts?: IntegrationClientOptions): ProviderClient<'jira'> => + providerClient('jira', opts); + +export const notionClient = (opts?: IntegrationClientOptions): ProviderClient<'notion'> => + providerClient('notion', opts); + +export const onedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'onedrive'> => + providerClient('onedrive', opts); + +export const pipedriveClient = (opts?: IntegrationClientOptions): ProviderClient<'pipedrive'> => + providerClient('pipedrive', opts); + +export const postgresClient = (opts?: IntegrationClientOptions): ProviderClient<'postgres'> => + providerClient('postgres', opts); + +export const redditClient = (opts?: IntegrationClientOptions): ProviderClient<'reddit'> => + providerClient('reddit', opts); + +export const redisClient = (opts?: IntegrationClientOptions): ProviderClient<'redis'> => + providerClient('redis', opts); + +export const s3Client = (opts?: IntegrationClientOptions): ProviderClient<'s3'> => + providerClient('s3', opts); + +export const salesforceClient = (opts?: IntegrationClientOptions): ProviderClient<'salesforce'> => + providerClient('salesforce', opts); + +export const sharepointClient = (opts?: IntegrationClientOptions): ProviderClient<'sharepoint'> => + providerClient('sharepoint', opts); + +export const teamsClient = (opts?: IntegrationClientOptions): ProviderClient<'teams'> => + providerClient('teams', opts); + +export const zendeskClient = (opts?: IntegrationClientOptions): ProviderClient<'zendesk'> => + providerClient('zendesk', opts); diff --git a/packages/relay-helpers/src/index.ts b/packages/relay-helpers/src/index.ts index 4448f423..9af323bb 100644 --- a/packages/relay-helpers/src/index.ts +++ b/packages/relay-helpers/src/index.ts @@ -29,34 +29,9 @@ export { linearClient, type LinearClient, type LinearCreateIssueArgs } from './l export { githubClient, type GithubClient, type GithubTarget } from './github.js'; export { slackClient, type SlackClient } from './slack.js'; -// Named resource-keyed clients for the remaining catalog providers. -export { - asanaClient, - azureBlobClient, - boxClient, - clickupClient, - confluenceClient, - dropboxClient, - gcsClient, - gitlabClient, - gmailClient, - googleCalendarClient, - googleDriveClient, - granolaClient, - hubspotClient, - intercomClient, - jiraClient, - notionClient, - onedriveClient, - pipedriveClient, - postgresClient, - redditClient, - redisClient, - s3Client, - salesforceClient, - sharepointClient, - teamsClient, - zendeskClient -} from './clients.js'; +// Named resource-keyed clients for the remaining catalog providers +// (generated from the catalog — see scripts/generate-clients.mjs). +// `export *` so a newly-added provider needs only a re-`gen`, no edit here. +export * from './generated/clients.js'; export type { IntegrationClientOptions, WritebackResult } from '@agentworkforce/runtime/clients'; diff --git a/packages/relay-helpers/src/relay-helpers.test.ts b/packages/relay-helpers/src/relay-helpers.test.ts index a189125e..de3d1c1f 100644 --- a/packages/relay-helpers/src/relay-helpers.test.ts +++ b/packages/relay-helpers/src/relay-helpers.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { WRITEBACK_PATH_CATALOG } from '@relayfile/adapter-core/writeback-paths'; import * as helpers from './index.js'; import { githubClient, linearClient, notionClient, relayClient, slackClient } from './index.js'; @@ -90,6 +91,20 @@ test('every catalog provider has a named client export', () => { assert.deepEqual(missing, [], `providers without a named client export: ${missing.join(', ')}`); }); +test('src/generated/clients.ts is in sync with the catalog', async () => { + // dist/.test.js → package root is one level up. + const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const { renderClients } = await import( + pathToFileURL(path.join(pkgRoot, 'scripts/generate-clients.mjs')).href + ); + const committed = await readFile(path.join(pkgRoot, 'src/generated/clients.ts'), 'utf8'); + assert.equal( + committed, + renderClients(), + 'generated clients are stale — run `pnpm --filter @agentworkforce/relay-helpers gen`' + ); +}); + test('a named resource-keyed client resolves and writes catalog paths', async () => { const { root, opts } = await mount(); const notion = notionClient(opts);