From 959bd0fca3031840cd0589e5a7880c3aea3c93cc Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 18 Jun 2026 21:23:28 +0200 Subject: [PATCH 1/2] fix(runtime): surface writeback no-receipt failures --- packages/runtime/CHANGELOG.md | 4 + packages/runtime/src/clients/index.test.ts | 73 +++++++++++++ packages/runtime/src/clients/index.ts | 120 ++++++++++++++++++++- packages/runtime/src/index.ts | 4 + 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/clients/index.test.ts diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 5d47b01f..b39dcffe 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 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..16cc416c --- /dev/null +++ b/packages/runtime/src/clients/index.test.ts @@ -0,0 +1,73 @@ +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: 0 }, + '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; + } + ); +}); + +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..19d8216d 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,113 @@ 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') { + 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, From 672f3e076f7dcb38ff300d1ac80d90ac2d488e10 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Thu, 18 Jun 2026 21:33:44 +0200 Subject: [PATCH 2/2] fix(runtime): preserve fire-and-forget writebacks --- packages/runtime/CHANGELOG.md | 2 +- packages/runtime/src/clients/index.test.ts | 21 ++++++++++++++++++++- packages/runtime/src/clients/index.ts | 3 +++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index b39dcffe..1e1fb8a1 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Treat Relayfile writebacks without receipts as first-class `WritebackError`s from the runtime client helpers. +- Treat Relayfile writebacks that time out without receipts as first-class `WritebackError`s from the runtime client helpers. ## [4.1.5] - 2026-06-18 diff --git a/packages/runtime/src/clients/index.test.ts b/packages/runtime/src/clients/index.test.ts index 16cc416c..debe63df 100644 --- a/packages/runtime/src/clients/index.test.ts +++ b/packages/runtime/src/clients/index.test.ts @@ -42,7 +42,7 @@ test('writeJsonFile treats missing receipts as first-class writeback errors', as await assert.rejects( () => writeJsonFile( - { relayfileMountRoot: root, writebackTimeoutMs: 0 }, + { relayfileMountRoot: root, writebackTimeoutMs: 1, writebackPollMs: 1 }, 'slack', 'postMessage', '/slack/channels/C123/messages/msg.json', @@ -58,6 +58,25 @@ test('writeJsonFile treats missing receipts as first-class writeback errors', as ); }); +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; diff --git a/packages/runtime/src/clients/index.ts b/packages/runtime/src/clients/index.ts index 19d8216d..ab54058f 100644 --- a/packages/runtime/src/clients/index.ts +++ b/packages/runtime/src/clients/index.ts @@ -142,6 +142,9 @@ export async function writeJsonFile( 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;