Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 without receipts as first-class `WritebackError`s from the runtime client helpers.

## [4.1.5] - 2026-06-18

### Dependencies
Expand Down
73 changes: 73 additions & 0 deletions packages/runtime/src/clients/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<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}`);
}
120 changes: 119 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,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<WritebackResult> {
const result = await coreWriteJsonFile(client, provider, operation, relayPath, body);
const normalized = normalizeWritebackStatus(result);
if (normalized.state !== 'succeeded') {
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.qkg1.topment/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