From 88db450daa6ec5c6b04517fcf8bcf8813107deca Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 12:16:13 +0200 Subject: [PATCH 1/2] refactor(runtime): source the VFS transport from @relayfile/adapter-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes moving integration handling out of workforce. The VFS draft-write transport (writeJsonFile/readJsonFile/draftFile/resolveMountRoot/…) now lives in @relayfile/adapter-core/vfs-client (the generic Relayfile write protocol, not anything workforce-specific). runtime/clients re-exports it so the single source of truth is the relayfile layer and there's no duplicated transport. - Deleted packages/runtime/src/clients/request.ts (the local copy). - clients/index.ts re-exports the transport + RelayfileWritebackError from @relayfile/adapter-core/vfs-client. - errors.ts keeps `WorkforceIntegrationError` as an ALIAS of the relocated `RelayfileWritebackError` (same class, not a subclass) so handlers that `catch (e) { e instanceof WorkforceIntegrationError }` still match what the transport throws. Verified: alias identity holds + instanceof works. - Added @relayfile/adapter-core@^0.3.29 as a direct runtime dep (it has the /vfs-client subpath; persona-kit only used /triggers + /scope-keys). Existing agents importing @agentworkforce/runtime/clients are unchanged. The ergonomic per-provider clients live in @relayfile/relay-helpers. runtime 57/57; deploy/mcp-workforce/cli typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/runtime/package.json | 3 +- packages/runtime/src/clients/index.ts | 16 +- packages/runtime/src/clients/request.ts | 262 ------------------------ packages/runtime/src/errors.ts | 52 ++--- packages/runtime/src/index.ts | 1 + pnpm-lock.yaml | 18 ++ 6 files changed, 45 insertions(+), 307 deletions(-) delete mode 100644 packages/runtime/src/clients/request.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 72e8d0ba..7adcb4ab 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -50,6 +50,7 @@ }, "dependencies": { "@agent-assistant/proactive": "^0.4.32", - "@agentworkforce/persona-kit": "workspace:*" + "@agentworkforce/persona-kit": "workspace:*", + "@relayfile/adapter-core": "^0.3.29" } } diff --git a/packages/runtime/src/clients/index.ts b/packages/runtime/src/clients/index.ts index c849d50d..b1801455 100644 --- a/packages/runtime/src/clients/index.ts +++ b/packages/runtime/src/clients/index.ts @@ -1,7 +1,12 @@ // Shared VFS-backed transport surface. All provider interactions go through -// these helpers — no per-provider client code lives in the runtime. Handlers -// and custom clients import these directly instead of recreating the -// path-validation + receipt-polling logic. +// these helpers — no per-provider client code lives in the runtime. +// +// The transport itself now lives in the relayfile layer +// (`@relayfile/adapter-core/vfs-client`) — it's the generic Relayfile +// draft-write protocol, not anything workforce-specific. The runtime re-exports +// it so existing handlers that `import … from '@agentworkforce/runtime/clients'` +// keep working unchanged. The ergonomic per-provider clients live in +// `@relayfile/relay-helpers`. export { draftFile, encodeSegment, @@ -11,9 +16,10 @@ export { readTextFile, resolveMountRoot, writeJsonFile, + RelayfileWritebackError, type IntegrationClientOptions, type WritebackReceipt, type WritebackResult -} from './request.js'; +} from '@relayfile/adapter-core/vfs-client'; -export { WorkforceIntegrationError, SandboxNotAvailableError } from '../errors.js'; \ No newline at end of file +export { WorkforceIntegrationError, SandboxNotAvailableError } from '../errors.js'; diff --git a/packages/runtime/src/clients/request.ts b/packages/runtime/src/clients/request.ts deleted file mode 100644 index 9dfc205b..00000000 --- a/packages/runtime/src/clients/request.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { WorkforceIntegrationError } from '../errors.js'; - -/** - * Shared VFS-backed transport for every workforce integration client. - * - * Workforce integration clients do not call provider REST APIs - * directly. Instead they read and write JSON files at canonical paths - * inside a Relayfile mount (`///.json`). A - * Relayfile writeback worker picks up the draft files and turns them - * into real provider calls, then writes a receipt back to the same - * path. This matches the rest of the workforce/cloud stack (sage, - * workflows) and gets writeback durability + retry semantics for free. - * - * The handler-side ergonomics stay identical to the direct-REST shape - * — `await writeJsonFile(client, 'github', 'comment', commentPath, { body })` - * returns when the write lands and, by default, waits briefly for the - * provider receipt. Setting `writebackTimeoutMs` to `0` keeps - * fire-and-forget behavior. - */ -export interface IntegrationClientOptions { - /** Absolute path to the Relayfile mount the handler is running in. */ - relayfileMountRoot?: string; - /** @deprecated alias for {@link relayfileMountRoot}. */ - relayfileRoot?: string; - /** @deprecated alias for {@link relayfileMountRoot}. */ - mountRoot?: string; - /** Working directory fallback when no mount root is configured. */ - workspaceCwd?: string; - /** - * Max wait, in ms, for the Relayfile writeback worker to emit a - * receipt onto the just-written draft. Defaults to 3000ms. `0` means - * fire-and-forget — the client returns immediately without a receipt. - */ - writebackTimeoutMs?: number; - /** Poll interval while waiting for a receipt. Default 250ms. */ - writebackPollMs?: number; - /** Relayfile connection id, if the writeback needs one. */ - connectionId?: string; - /** Direct Relayfile API base URL, when the client talks to it out-of-band. */ - relayfileBaseUrl?: string; - /** API token for the Relayfile API, when applicable. */ - relayfileApiToken?: string; - /** Workforce cloud API token, for cross-service auth (slack, jira). */ - cloudApiToken?: string; - /** Workspace id the handler is bound to. */ - workspaceId?: string; - /** Slack team id, when the client targets a specific workspace. */ - slackTeamId?: string; -} - -/** - * Shape of the JSON the Relayfile writeback worker writes back into a - * draft file once the remote write completes. Clients read this back - * to populate their return values (issue numbers, comment ids, etc.). - */ -export interface WritebackReceipt { - created?: string; - path?: string; - url?: string; - id?: string; - identifier?: string; - externalId?: string; - merged?: boolean | string; - sha?: string; - [key: string]: unknown; -} - -export interface WritebackResult { - path: string; - absolutePath: string; - receipt?: WritebackReceipt; -} - -const DEFAULT_WRITEBACK_TIMEOUT_MS = 3_000; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** Percent-encode a path segment so identifiers safely round-trip. */ -export function encodeSegment(value: string | number): string { - return encodeURIComponent(String(value)); -} - -/** - * Allocate a unique draft filename for a new resource. The Relayfile - * writeback worker renames the file to the canonical id on receipt. - */ -export function draftFile(prefix: string): string { - return `${prefix} ${randomUUID()}.json`; -} - -/** - * Resolve the absolute Relayfile mount root, honoring (in order) the - * client-supplied option, the `RELAYFILE_MOUNT_ROOT` / `RELAYFILE_ROOT` - * env vars, and finally `workspaceCwd` / `process.cwd()`. - */ -export function resolveMountRoot(client: IntegrationClientOptions): string { - return path.resolve( - client.relayfileMountRoot ?? - client.relayfileRoot ?? - client.mountRoot ?? - process.env.RELAYFILE_MOUNT_ROOT ?? - process.env.RELAYFILE_ROOT ?? - client.workspaceCwd ?? - process.cwd() - ); -} - -function toAbsolutePath(client: IntegrationClientOptions, relayPath: string): string { - const root = resolveMountRoot(client); - const normalized = relayPath.startsWith('/') ? relayPath.slice(1) : relayPath; - const absolute = path.resolve(root, normalized); - const relative = path.relative(root, absolute); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`Relayfile path escapes mount root: ${relayPath}`); - } - return absolute; -} - -export async function readJsonFile( - client: IntegrationClientOptions, - provider: string, - operation: string, - relayPath: string -): Promise { - try { - const absolutePath = toAbsolutePath(client, relayPath); - return JSON.parse(await readFile(absolutePath, 'utf8')) as T; - } catch (cause) { - throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); - } -} - -export async function readTextFile( - client: IntegrationClientOptions, - provider: string, - operation: string, - relayPath: string -): Promise { - try { - return await readFile(toAbsolutePath(client, relayPath), 'utf8'); - } catch (cause) { - throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); - } -} - -export async function listJsonFiles( - client: IntegrationClientOptions, - provider: string, - operation: string, - relayDir: string -): Promise> { - try { - const absoluteDir = toAbsolutePath(client, relayDir); - const entries = await readdirIfPresent(absoluteDir); - const out: Array<{ path: string; value: T }> = []; - for (const entry of entries) { - if (!entry.endsWith('.json')) continue; - const relayPath = `${relayDir.replace(/\/+$/, '')}/${entry}`; - const value = JSON.parse(await readFile(path.join(absoluteDir, entry), 'utf8')) as T; - out.push({ path: relayPath, value }); - } - return out; - } catch (cause) { - throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); - } -} - -export async function listDirectoryEntries( - client: IntegrationClientOptions, - provider: string, - operation: string, - relayDir: string -): Promise { - try { - return await readdirIfPresent(toAbsolutePath(client, relayDir)); - } catch (cause) { - throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); - } -} - -async function readdirIfPresent(absoluteDir: string): Promise { - try { - return await readdir(absoluteDir); - } catch (error) { - if (isNoEntryError(error)) { - return []; - } - throw error; - } -} - -function isNoEntryError(error: unknown): boolean { - return isRecord(error) && error.code === 'ENOENT'; -} - -/** - * Write a draft JSON payload atomically (write-then-rename) so the - * writeback worker never sees a partial file. Waits for a receipt - * by default; pass `writebackTimeoutMs: 0` to return immediately. - */ -export async function writeJsonFile( - client: IntegrationClientOptions, - provider: string, - operation: string, - relayPath: string, - body: unknown -): Promise { - try { - const absolutePath = toAbsolutePath(client, relayPath); - await mkdir(path.dirname(absolutePath), { recursive: true }); - const tempPath = `${absolutePath}.tmp-${randomUUID()}`; - await writeFile(tempPath, `${JSON.stringify(body, null, 2)}\n`, 'utf8'); - await rename(tempPath, absolutePath); - const receipt = await waitForReceipt(absolutePath, client); - return { path: relayPath, absolutePath, ...(receipt ? { receipt } : {}) }; - } catch (cause) { - throw new WorkforceIntegrationError({ provider, operation, cause, retryable: false }); - } -} - -async function waitForReceipt( - absolutePath: string, - client: IntegrationClientOptions -): Promise { - const timeoutMs = client.writebackTimeoutMs ?? DEFAULT_WRITEBACK_TIMEOUT_MS; - // Fire-and-forget: never reinterpret the just-written draft as a - // receipt. The draft payload may legitimately carry top-level `id` / - // `path` / `created` fields (e.g. an upsert update writing back the - // canonical issue), and treating that as a receipt would surface a - // bogus identifier to callers. - if (timeoutMs <= 0) return undefined; - const deadline = Date.now() + timeoutMs; - do { - const parsed = await readCurrentJson(absolutePath); - if ( - isRecord(parsed) && - (typeof parsed.created === 'string' || - typeof parsed.path === 'string' || - typeof parsed.id === 'string' || - typeof parsed.externalId === 'string' || - typeof parsed.merged === 'boolean' || - typeof parsed.merged === 'string') - ) { - return parsed as WritebackReceipt; - } - await new Promise((resolve) => setTimeout(resolve, client.writebackPollMs ?? 250)); - } while (Date.now() < deadline); - return undefined; -} - -async function readCurrentJson(absolutePath: string): Promise { - try { - return JSON.parse(await readFile(absolutePath, 'utf8')) as unknown; - } catch { - return undefined; - } -} diff --git a/packages/runtime/src/errors.ts b/packages/runtime/src/errors.ts index 22884794..32a076b5 100644 --- a/packages/runtime/src/errors.ts +++ b/packages/runtime/src/errors.ts @@ -1,44 +1,18 @@ /** - * Error type every integration client throws when a remote write or read - * fails. Lives at the package root rather than inside `clients/` so the - * persona's handler can `import { WorkforceIntegrationError }` without - * reaching into a subpath that may evolve. - * - * The error carries enough metadata for the runtime's retry layer to - * make decisions without parsing message strings: - * - * - `provider` — `github` / `linear` / `slack` / … - * - `operation` — the client method that failed (`comment`, - * `upsertIssue.create`, …) - * - `retryable` — `true` for transient failures (the runtime resends - * the originating event); `false` for permanent shape/validation - * failures that won't change on retry - * - `cause` — the original thrown value, preserved for logs + * Error every integration read/write throws on failure. The class now lives + * with the transport in `@relayfile/adapter-core/vfs-client` as + * `RelayfileWritebackError`; `WorkforceIntegrationError` is kept as an **alias + * of the same class** (not a subclass) so existing handlers that + * `catch (e) { if (e instanceof WorkforceIntegrationError) … }` still match + * what the relocated transport throws. Same `{ provider, operation, cause, + * retryable }` shape. */ -export interface WorkforceIntegrationErrorOptions { - provider: string; - operation: string; - cause?: unknown; - retryable?: boolean; -} - -export class WorkforceIntegrationError extends Error { - readonly provider: string; - readonly operation: string; - override readonly cause?: unknown; - readonly retryable: boolean; - - constructor(options: WorkforceIntegrationErrorOptions) { - super(`${options.provider}.${options.operation} failed${ - options.cause instanceof Error ? `: ${options.cause.message}` : '' - }`); - this.name = 'WorkforceIntegrationError'; - this.provider = options.provider; - this.operation = options.operation; - if (options.cause !== undefined) this.cause = options.cause; - this.retryable = options.retryable ?? false; - } -} +export { + RelayfileWritebackError, + RelayfileWritebackError as WorkforceIntegrationError, + type RelayfileWritebackErrorOptions, + type RelayfileWritebackErrorOptions as WorkforceIntegrationErrorOptions +} from '@relayfile/adapter-core/vfs-client'; /** * Thrown when handler code calls `ctx.sandbox.exec()` on a persona that diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index f62679f4..fd8e7d02 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -60,6 +60,7 @@ export { type IntegrationClientOptions, type WritebackReceipt, type WritebackResult, + RelayfileWritebackError, WorkforceIntegrationError, SandboxNotAvailableError } from './clients/index.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0d04cb0..9d145752 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit + '@relayfile/adapter-core': + specifier: ^0.3.29 + version: 0.3.29(@relayfile/sdk@0.7.40) packages/workload-router: dependencies: @@ -850,6 +853,13 @@ packages: peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' + '@relayfile/adapter-core@0.3.29': + resolution: {integrity: sha512-rOTKgTofH5NUB9Q47GKJHv54dRJNS8rQS1FO0qON4MQpdKJ9RK69uJlQ52YJ/O73AGuj0qfnfZP1xcnA+3X4yw==} + 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 +3109,14 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 + '@relayfile/adapter-core@0.3.29(@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 b87ed6abc1f90a6f4364ce4ea5593811ccd7ee62 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 2 Jun 2026 12:22:25 +0200 Subject: [PATCH 2/2] refactor(runtime): export error option types for API completeness (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini review: export RelayfileWritebackErrorOptions + WorkforceIntegrationErrorOptions from /clients and the main entry so consumers can type-annotate error options. (Declined the suggested @relayfile/sdk peer dep — vfs-client doesn't import sdk, persona-kit doesn't declare it, and it isn't in the tree; adding it would impose an unused dep.) runtime 57/57, typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/runtime/src/clients/index.ts | 7 ++++++- packages/runtime/src/index.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/clients/index.ts b/packages/runtime/src/clients/index.ts index b1801455..08c56826 100644 --- a/packages/runtime/src/clients/index.ts +++ b/packages/runtime/src/clients/index.ts @@ -17,9 +17,14 @@ export { resolveMountRoot, writeJsonFile, RelayfileWritebackError, + type RelayfileWritebackErrorOptions, type IntegrationClientOptions, type WritebackReceipt, type WritebackResult } from '@relayfile/adapter-core/vfs-client'; -export { WorkforceIntegrationError, SandboxNotAvailableError } from '../errors.js'; +export { + WorkforceIntegrationError, + type WorkforceIntegrationErrorOptions, + SandboxNotAvailableError +} from '../errors.js'; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index fd8e7d02..56973229 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -61,7 +61,9 @@ export { type WritebackReceipt, type WritebackResult, RelayfileWritebackError, + type RelayfileWritebackErrorOptions, WorkforceIntegrationError, + type WorkforceIntegrationErrorOptions, SandboxNotAvailableError } from './clients/index.js';