Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions packages/runtime/src/clients/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
123 changes: 122 additions & 1 deletion packages/runtime/src/clients/index.ts
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -15,7 +24,6 @@ export {
readJsonFile,
readTextFile,
resolveMountRoot,
writeJsonFile,
RelayfileWritebackError,
type RelayfileWritebackErrorOptions,
type IntegrationClientOptions,
Expand All @@ -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<WritebackResult> {
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);
Comment on lines +144 to +148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't throw for fire-and-forget writebacks

When a client intentionally sets writebackTimeoutMs: 0, adapter-core returns immediately with no receipt; mcp-workforce still relies on that mode in its GitHub tool test (packages/mcp-workforce/src/tools/integrations.test.ts:65-67) and calls this runtime helper from integrations.ts:84. This new branch normalizes that expected fire-and-forget result to no_receipt and throws, so integration.github.comment/createIssue/etc. can no longer write drafts in offline/fire-and-forget mode and the existing mcp-workforce test would fail. Preserve timeout-0 as a successful draft write or update the callers/config to stop using it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 672f3e0. The wrapper now preserves explicit writebackTimeoutMs: 0 fire-and-forget writes by returning the draft result when normalization reports no_receipt; timed waiters still throw WritebackError. Added regression coverage for both paths and verified @agentworkforce/runtime (95/95) plus @agentworkforce/mcp-workforce (25/25). The aggregate check got through lint/typecheck and later hit an unrelated timing flake in a deploy OAuth polling test; rerunning that deploy test passed.

}
return result;
}
4 changes: 4 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,13 @@ export {
readTextFile,
resolveMountRoot,
writeJsonFile,
normalizeWritebackStatus,
type IntegrationClientOptions,
type NormalizedWritebackState,
type NormalizedWritebackStatus,
type WritebackReceipt,
type WritebackResult,
WritebackError,
RelayfileWritebackError,
type RelayfileWritebackErrorOptions,
WorkforceIntegrationError,
Expand Down
Loading