diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 5d47b01f..1e1fb8a1 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Treat Relayfile writebacks that time out without receipts as first-class `WritebackError`s from the runtime client helpers. + ## [4.1.5] - 2026-06-18 ### Dependencies diff --git a/packages/runtime/src/clients/index.test.ts b/packages/runtime/src/clients/index.test.ts new file mode 100644 index 00000000..debe63df --- /dev/null +++ b/packages/runtime/src/clients/index.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + WorkforceIntegrationError, + WritebackError, + normalizeWritebackStatus, + writeJsonFile +} from './index.js'; + +test('writeJsonFile returns successful writes with receipts', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'workforce-writeback-ok-')); + const relayPath = '/slack/channels/C123/messages/msg.json'; + const absolutePath = path.join(root, relayPath.slice(1)); + + const pending = writeJsonFile( + { relayfileMountRoot: root, writebackTimeoutMs: 1_000, writebackPollMs: 10 }, + 'slack', + 'postMessage', + relayPath, + { text: 'hello' } + ); + + await waitForDraft(absolutePath); + await writeFile( + absolutePath, + `${JSON.stringify({ created: '2026-06-18T18:00:00.000Z', id: 'msg-1', path: relayPath })}\n`, + 'utf8' + ); + + const result = await pending; + assert.equal(result.path, relayPath); + assert.equal(result.receipt?.id, 'msg-1'); + assert.equal(normalizeWritebackStatus(result).state, 'succeeded'); +}); + +test('writeJsonFile treats missing receipts as first-class writeback errors', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'workforce-writeback-no-receipt-')); + + await assert.rejects( + () => + writeJsonFile( + { relayfileMountRoot: root, writebackTimeoutMs: 1, writebackPollMs: 1 }, + 'slack', + 'postMessage', + '/slack/channels/C123/messages/msg.json', + { text: 'hello' } + ), + (error: unknown) => { + assert(error instanceof WritebackError); + assert(error instanceof WorkforceIntegrationError); + assert.equal((error as { state?: unknown }).state, 'no_receipt'); + assert.equal((error as { path?: unknown }).path, '/slack/channels/C123/messages/msg.json'); + return true; + } + ); +}); + +test('writeJsonFile preserves explicit fire-and-forget writebacks', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'workforce-writeback-fire-and-forget-')); + const relayPath = '/slack/channels/C123/messages/msg.json'; + + const result = await writeJsonFile( + { relayfileMountRoot: root, writebackTimeoutMs: 0 }, + 'slack', + 'postMessage', + relayPath, + { text: 'hello' } + ); + + assert.equal(result.path, relayPath); + assert.equal(result.receipt, undefined); + assert.deepEqual(JSON.parse(await readFile(path.join(root, relayPath.slice(1)), 'utf8')), { + text: 'hello' + }); +}); + +async function waitForDraft(filePath: string): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + const deadline = Date.now() + 1_000; + do { + try { + await readFile(filePath, 'utf8'); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } while (Date.now() < deadline); + throw new Error(`draft was not written: ${filePath}`); +} diff --git a/packages/runtime/src/clients/index.ts b/packages/runtime/src/clients/index.ts index 08c56826..ab54058f 100644 --- a/packages/runtime/src/clients/index.ts +++ b/packages/runtime/src/clients/index.ts @@ -1,3 +1,12 @@ +import * as adapterVfsClient from '@relayfile/adapter-core/vfs-client'; +import { + writeJsonFile as coreWriteJsonFile, + RelayfileWritebackError, + type IntegrationClientOptions, + type WritebackReceipt, + type WritebackResult +} from '@relayfile/adapter-core/vfs-client'; + // Shared VFS-backed transport surface. All provider interactions go through // these helpers — no per-provider client code lives in the runtime. // @@ -15,7 +24,6 @@ export { readJsonFile, readTextFile, resolveMountRoot, - writeJsonFile, RelayfileWritebackError, type RelayfileWritebackErrorOptions, type IntegrationClientOptions, @@ -28,3 +36,116 @@ export { type WorkforceIntegrationErrorOptions, SandboxNotAvailableError } from '../errors.js'; + +export type NormalizedWritebackState = + | 'succeeded' + | 'no_receipt' + | 'validation_failed' + | 'readonly_rejected' + | 'adapter_error' + | 'ok'; + +export interface NormalizedWritebackStatus { + state: NormalizedWritebackState; + path: string; + op?: 'create' | 'patch' | 'delete'; + id?: string; + error?: string; + field?: string; + receipt?: WritebackReceipt; + timestamp?: string; + entry?: unknown; +} + +type AdapterVfsClientExtensions = { + normalizeWritebackStatus?: ( + result?: WritebackResult, + entry?: unknown + ) => NormalizedWritebackStatus; + WritebackError?: new (normalized: NormalizedWritebackStatus) => RelayfileWritebackError & + NormalizedWritebackStatus; +}; + +class FallbackWritebackError extends RelayfileWritebackError { + readonly state: NormalizedWritebackState; + readonly path: string; + readonly op?: 'create' | 'patch' | 'delete'; + readonly id?: string; + readonly receipt?: WritebackReceipt; + readonly error?: string; + readonly field?: string; + readonly timestamp?: string; + + constructor(normalized: NormalizedWritebackStatus) { + super({ + provider: 'writeback', + operation: normalized.state, + cause: normalized.error ? new Error(normalized.error) : undefined, + retryable: false + }); + this.name = 'WritebackError'; + this.message = `writeback ${normalized.state} ${normalized.path}${ + normalized.error ? `: ${normalized.error}` : '' + }`; + this.state = normalized.state; + this.path = normalized.path; + this.op = normalized.op; + this.id = normalized.id; + this.receipt = normalized.receipt; + this.error = normalized.error; + this.field = normalized.field; + this.timestamp = normalized.timestamp; + } +} + +const adapterExtensions = adapterVfsClient as AdapterVfsClientExtensions; + +export const WritebackError = adapterExtensions.WritebackError ?? FallbackWritebackError; + +export function normalizeWritebackStatus( + result?: WritebackResult, + entry?: unknown +): NormalizedWritebackStatus { + const normalize = adapterExtensions.normalizeWritebackStatus; + if (normalize) return normalize(result, entry); + + if (!result?.receipt) { + return { + state: 'no_receipt', + path: result?.path ?? '', + error: 'writeback produced no receipt' + }; + } + + const receipt = result.receipt; + const id = + receipt.id !== undefined + ? String(receipt.id) + : receipt.created !== undefined + ? String(receipt.created) + : undefined; + return { + state: 'succeeded', + path: result.path, + ...(id ? { id } : {}), + receipt + }; +} + +export async function writeJsonFile( + client: IntegrationClientOptions, + provider: string, + operation: string, + relayPath: string, + body: unknown +): Promise { + const result = await coreWriteJsonFile(client, provider, operation, relayPath, body); + const normalized = normalizeWritebackStatus(result); + if (normalized.state !== 'succeeded') { + if (client.writebackTimeoutMs === 0 && normalized.state === 'no_receipt') { + return result; + } + throw new WritebackError(normalized); + } + return result; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 1fdf2fa0..a0a11aba 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -110,9 +110,13 @@ export { readTextFile, resolveMountRoot, writeJsonFile, + normalizeWritebackStatus, type IntegrationClientOptions, + type NormalizedWritebackState, + type NormalizedWritebackStatus, type WritebackReceipt, type WritebackResult, + WritebackError, RelayfileWritebackError, type RelayfileWritebackErrorOptions, WorkforceIntegrationError,