From 42880de938e9027eeca12508029e5e156a0ff8d9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 20:23:05 +0000 Subject: [PATCH 1/4] fix(preview): transfer recordings to the agent environment --- .../src/mcp/toolkits/preview/handlers.test.ts | 82 +++++++++++++- .../src/mcp/toolkits/preview/handlers.ts | 102 ++++++++++++++++-- apps/server/src/mcp/toolkits/preview/tools.ts | 6 +- apps/web/src/browser/browserRecording.test.ts | 38 +++++++ apps/web/src/browser/browserRecording.ts | 5 +- .../web/src/browser/browserRecordingUpload.ts | 58 ++++++++++ .../preview/PreviewAutomationHosts.tsx | 28 ++++- packages/contracts/src/previewAutomation.ts | 14 +++ 8 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/browser/browserRecordingUpload.ts diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index 2c4e66746447..560ec2a9b295 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -1,6 +1,17 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; -import { normalizePreviewOpenInput } from "./handlers.ts"; +import { + createPendingAttachmentId, + parseThreadSegmentFromAttachmentId, +} from "../../../attachmentStore.ts"; +import * as ServerConfig from "../../../config.ts"; +import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts"; describe("normalizePreviewOpenInput", () => { it("leaves an unstated visibility for the client preference to decide", () => { @@ -30,3 +41,70 @@ describe("normalizePreviewOpenInput", () => { }); }); }); + +describe("claimPreviewRecording", () => { + it.effect.each([6, 5])( + "claims only a complete uploaded recording (reported bytes: %s)", + (sizeBytes) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uploadedAttachmentId = createPendingAttachmentId(".webm"); + const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(pendingPath, "video!"); + const response = { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes, + createdAt: "2026-09-07T00:00:00.000Z", + uploadedAttachmentId, + }; + const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), response).pipe( + Effect.result, + ); + if (sizeBytes === 6) { + expect(result._tag).toBe("Success"); + if (result._tag !== "Success") return; + expect(result.success.path).not.toBe(response.path); + expect(parseThreadSegmentFromAttachmentId(result.success.id)).toBe("thread-1"); + expect(yield* fileSystem.readFileString(result.success.path)).toBe("video!"); + expect(yield* fileSystem.exists(pendingPath)).toBe(false); + } else { + expect(result._tag).toBe("Failure"); + expect(yield* fileSystem.exists(pendingPath)).toBe(true); + } + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect("reports an older desktop without returning its inaccessible path", () => + Effect.gen(function* () { + const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes: 6, + createdAt: "2026-09-07T00:00:00.000Z", + }).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure.message).toContain("Update the desktop app"); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); +}); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index c501ee08711d..8cfd4cbb352a 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,16 +1,23 @@ import * as Effect from "effect/Effect"; -import type { - PreviewAutomationOperation, - PreviewAutomationOpenInput, +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PreviewAutomationRecordingTransferError, PreviewAutomationRecordingArtifact, - PreviewAutomationRecordingStatus, - PreviewAutomationResizeResult, - PreviewAutomationSetColorSchemeResult, - PreviewAutomationSnapshot, - PreviewAutomationStatus, - PreviewTabId, + type ThreadId, + type PreviewAutomationOperation, + type PreviewAutomationOpenInput, + type PreviewAutomationRecordingStatus, + type PreviewAutomationResizeResult, + type PreviewAutomationSetColorSchemeResult, + type PreviewAutomationSnapshot, + type PreviewAutomationStatus, + type PreviewTabId, } from "@t3tools/contracts"; +import { planAttachmentClaim } from "../../../attachmentStore.ts"; +import * as ServerConfig from "../../../config.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -67,6 +74,73 @@ const invokeTargeted = ( return invoke(operation, operationInput, timeoutMs, tabId); }; +const UploadedRecordingArtifact = Schema.Struct({ + ...PreviewAutomationRecordingArtifact.fields, + uploadedAttachmentId: Schema.optional(Schema.String), +}); +const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact); +const isRecordingTransferError = Schema.is(PreviewAutomationRecordingTransferError); + +export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* ( + threadId: ThreadId, + response: unknown, +) { + const artifact = yield* decodeUploadedRecordingArtifact(response).pipe( + Effect.mapError( + (cause) => + new PreviewAutomationRecordingTransferError({ + threadId, + detail: "The desktop returned invalid recording metadata.", + cause, + }), + ), + ); + if (!artifact.uploadedAttachmentId) { + return yield* new PreviewAutomationRecordingTransferError({ + threadId, + detail: + "Update the desktop app to transfer recordings. The recording remains on the desktop.", + }); + } + const config = yield* ServerConfig.ServerConfig; + const claim = planAttachmentClaim({ + attachmentsDir: config.attachmentsDir, + threadId, + attachmentId: artifact.uploadedAttachmentId, + }); + if (!claim.ok) { + return yield* new PreviewAutomationRecordingTransferError({ threadId, detail: claim.reason }); + } + const fileSystem = yield* FileSystem.FileSystem; + yield* Effect.gen(function* () { + const stat = yield* fileSystem.stat(claim.currentPath); + if ( + stat.type !== "File" || + Number(stat.size) !== artifact.sizeBytes || + artifact.sizeBytes <= 0 || + artifact.sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES + ) { + return yield* new PreviewAutomationRecordingTransferError({ + threadId, + detail: "The uploaded recording size does not match its metadata or exceeds 50 MiB.", + }); + } + yield* fileSystem.rename(claim.currentPath, claim.finalPath); + }).pipe( + Effect.mapError((cause) => + isRecordingTransferError(cause) + ? cause + : new PreviewAutomationRecordingTransferError({ + threadId, + detail: "The uploaded recording could not be retained.", + cause, + }), + ), + ); + const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact; + return { ...recording, id: claim.finalId, path: claim.finalPath }; +}); + const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => @@ -94,7 +168,15 @@ const handlers = { preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => - invokeTargeted("recordingStop", input ?? {}), + Effect.gen(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const response = yield* invokeTargeted( + "recordingStop", + { ...input, transferToEnvironment: true }, + 120_000, + ); + return yield* claimPreviewRecording(scope.threadId, response); + }), } satisfies Parameters[0]; const { preview_snapshot, ...standardHandlers } = handlers; diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index ab8d1580bb61..cc2e572bc373 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -19,10 +19,12 @@ import { PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import * as FileSystem from "effect/FileSystem"; import { Tool, Toolkit } from "effect/unstable/ai"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; +import * as ServerConfig from "../../../config.ts"; const dependencies = [ McpInvocationContext.McpInvocationContext, @@ -207,11 +209,11 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, - dependencies, + dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), ); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 218684c54fbc..2d6683b28ead 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -180,6 +180,44 @@ describe("browser recording", () => { await stopBrowserRecording("automation-recording-tab"); }); + it("saves locally and releases capture before transferring the encoded recording once", async () => { + const stopTrack = vi.fn(); + getDisplayMedia.mockResolvedValue({ + getVideoTracks: () => [], + getTracks: () => [{ stop: stopTrack }], + }); + await startBrowserRecording("transfer-tab"); + let finishUpload!: () => void; + const uploaded = new Promise((resolve) => { + finishUpload = resolve; + }); + const transfer = vi.fn(async (artifact, blob: Blob) => { + expect(save).toHaveBeenCalledOnce(); + expect(stopTrack).toHaveBeenCalled(); + expect(artifact.path).toBe("/tmp/recording-test.webm"); + expect(blob.type).toBe("video/webm;codecs=vp9"); + await uploaded; + }); + const firstStop = stopBrowserRecording("transfer-tab", transfer); + const secondStop = stopBrowserRecording("transfer-tab", transfer); + finishUpload(); + expect(await firstStop).toEqual(await secondStop); + expect(transfer).toHaveBeenCalledOnce(); + }); + + it("keeps the saved desktop file and releases the recording when transfer fails", async () => { + await startBrowserRecording("failed-transfer-tab"); + await expect( + stopBrowserRecording("failed-transfer-tab", async () => { + throw new Error("Connection interrupted"); + }), + ).rejects.toMatchObject({ _tag: "BrowserRecordingOperationError" }); + expect(save).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false); + await startBrowserRecording("failed-transfer-tab"); + await stopBrowserRecording("failed-transfer-tab"); + }); + it("paints and holds a hidden browser surface for the recording lifetime", async () => { startScreencast.mockImplementationOnce(async (tabId: string) => { expect(animationFrameCount).toBe(2); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 92050394a1c9..fd9e7090210f 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -661,6 +661,7 @@ export async function startBrowserRecording( const finalizeBrowserRecording = async ( bridge: NonNullable, recording: ActiveRecording, + onSaved?: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, ): Promise => { const { tabId } = recording; let result: @@ -708,6 +709,7 @@ const finalizeBrowserRecording = async ( mimeType, new Uint8Array(await blob.arrayBuffer()), ); + await onSaved?.(artifact, blob); result = { _tag: "Success", artifact }; } catch (cause) { throw new BrowserRecordingOperationError({ @@ -792,6 +794,7 @@ const discardBrowserRecording = async ( export function stopBrowserRecording( tabId: string, + onSaved?: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, ): Promise { const bridge = previewBridge; const recording = activeRecordings.get(tabId); @@ -800,7 +803,7 @@ export function stopBrowserRecording( if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() - .then(() => finalizeBrowserRecording(bridge, recording)) + .then(() => finalizeBrowserRecording(bridge, recording, onSaved)) .catch((error) => { if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { const cleanupAfterStartup = recording.startupSettled.then(() => diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts new file mode 100644 index 000000000000..a88884091997 --- /dev/null +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -0,0 +1,58 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + type DesktopPreviewRecordingArtifact, + type EnvironmentId, +} from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { attachmentEnvironment } from "~/state/attachments"; +import { readPreparedConnection } from "~/state/session"; + +/** Sends the finished encoded file once; capture frames never cross the environment connection. */ +export async function uploadBrowserRecording( + environmentId: EnvironmentId, + artifact: DesktopPreviewRecordingArtifact, + blob: Blob, +): Promise { + if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new Error(`Recording exceeds the 50 MiB transfer limit. Desktop copy: ${artifact.path}`); + } + const result = await runAttachmentUploadCycle({ + registry: appAtomRegistry, + createUploadUrl: attachmentEnvironment.createUploadUrl, + remove: attachmentEnvironment.remove, + environmentId, + upload: { + type: "file", + name: artifact.path.split(/[\\/]/).at(-1) ?? artifact.id, + mimeType: artifact.mimeType, + sizeBytes: blob.size, + }, + resolveUploadUrl: (relativeUrl) => { + const connection = readPreparedConnection(environmentId); + return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null; + }, + transport: (url) => { + const controller = new AbortController(); + return { + abort: () => controller.abort(), + done: fetch(url, { + method: "POST", + headers: { "Content-Type": artifact.mimeType }, + body: blob, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(110_000)]), + }).then((response) => { + if (!response.ok) throw new Error(`Recording upload rejected (${response.status}).`); + }), + }; + }, + }); + if (result.status !== "uploaded") { + throw new Error(`Recording transfer failed. Desktop copy: ${artifact.path}`, { + cause: result.status === "failed" ? result.error : undefined, + }); + } + return result.attachmentId; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index fd87f7e80c79..ce852aa63d71 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -37,6 +37,7 @@ import { stopBrowserRecording, } from "~/browser/browserRecording"; import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; +import { uploadBrowserRecording } from "~/browser/browserRecordingUpload"; import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, @@ -716,7 +717,26 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const stopRuntimeTabId = activeRecordings.find((recording) => recording.serverTabId === stopTabId) ?.runtimeTabId ?? null; - const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null; + const transferToEnvironment = + typeof request.input === "object" && + request.input !== null && + "transferToEnvironment" in request.input && + request.input.transferToEnvironment === true; + let uploadedAttachmentId: string | undefined; + const artifact = stopRuntimeTabId + ? await stopBrowserRecording( + stopRuntimeTabId, + transferToEnvironment + ? async (saved, blob) => { + uploadedAttachmentId = await uploadBrowserRecording( + environmentId, + saved, + blob, + ); + } + : undefined, + ) + : null; if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( new PreviewAutomationRecordingNotActiveError({ @@ -727,7 +747,11 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }), ); } - return { ...artifact, tabId: stopTabId }; + return { + ...artifact, + tabId: stopTabId, + ...(uploadedAttachmentId ? { uploadedAttachmentId } : {}), + }; } } } catch (cause) { diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index e33615fa4c05..e69ff7404252 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -856,7 +856,21 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC } } +export class PreviewAutomationRecordingTransferError extends Schema.TaggedErrorClass()( + "PreviewAutomationRecordingTransferError", + { + threadId: ThreadId, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Preview recording could not be saved to the agent environment: ${this.detail}`; + } +} + export const PreviewAutomationError = Schema.Union([ + PreviewAutomationRecordingTransferError, PreviewAutomationUnavailableError, PreviewAutomationNoAvailableHostError, PreviewAutomationUnsupportedClientError, From 181d8ab08caf9844c56cef82701d2a43bfa6bed9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 20:31:12 +0000 Subject: [PATCH 2/4] fix(preview): share recording uploads and honor the stop deadline --- .../src/mcp/toolkits/preview/handlers.test.ts | 3 +++ .../src/mcp/toolkits/preview/handlers.ts | 17 ++++++++------ apps/web/src/browser/browserRecording.test.ts | 13 +++++++---- apps/web/src/browser/browserRecording.ts | 21 +++++++++++++---- .../web/src/browser/browserRecordingUpload.ts | 23 ++++++++++++------- .../preview/PreviewAutomationHosts.tsx | 20 +++++----------- packages/contracts/src/previewAutomation.ts | 20 ++++++++++++++-- 7 files changed, 78 insertions(+), 39 deletions(-) diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index 560ec2a9b295..c25a1120d5df 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -75,6 +75,8 @@ describe("claimPreviewRecording", () => { expect(yield* fileSystem.exists(pendingPath)).toBe(false); } else { expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") return; + expect(result.failure.reason).toBe("size-mismatch"); expect(yield* fileSystem.exists(pendingPath)).toBe(true); } }).pipe( @@ -98,6 +100,7 @@ describe("claimPreviewRecording", () => { }).pipe(Effect.result); expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") return; + expect(result.failure.reason).toBe("desktop-update-required"); expect(result.failure.message).toContain("Update the desktop app"); }).pipe( Effect.provide( diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 8cfd4cbb352a..9dc75bb8b1fb 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -3,6 +3,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, PreviewAutomationRecordingTransferError, PreviewAutomationRecordingArtifact, type ThreadId, @@ -90,7 +91,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( (cause) => new PreviewAutomationRecordingTransferError({ threadId, - detail: "The desktop returned invalid recording metadata.", + reason: "invalid-metadata", cause, }), ), @@ -98,8 +99,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( if (!artifact.uploadedAttachmentId) { return yield* new PreviewAutomationRecordingTransferError({ threadId, - detail: - "Update the desktop app to transfer recordings. The recording remains on the desktop.", + reason: "desktop-update-required", }); } const config = yield* ServerConfig.ServerConfig; @@ -109,7 +109,10 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( attachmentId: artifact.uploadedAttachmentId, }); if (!claim.ok) { - return yield* new PreviewAutomationRecordingTransferError({ threadId, detail: claim.reason }); + return yield* new PreviewAutomationRecordingTransferError({ + threadId, + reason: "invalid-upload", + }); } const fileSystem = yield* FileSystem.FileSystem; yield* Effect.gen(function* () { @@ -122,7 +125,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( ) { return yield* new PreviewAutomationRecordingTransferError({ threadId, - detail: "The uploaded recording size does not match its metadata or exceeds 50 MiB.", + reason: "size-mismatch", }); } yield* fileSystem.rename(claim.currentPath, claim.finalPath); @@ -132,7 +135,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( ? cause : new PreviewAutomationRecordingTransferError({ threadId, - detail: "The uploaded recording could not be retained.", + reason: "retain-failed", cause, }), ), @@ -173,7 +176,7 @@ const handlers = { const response = yield* invokeTargeted( "recordingStop", { ...input, transferToEnvironment: true }, - 120_000, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, ); return yield* claimPreviewRecording(scope.threadId, response); }), diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 2d6683b28ead..bd38c4f9f69a 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -78,6 +78,7 @@ import { readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, + stopBrowserRecordingForUpload, } from "./browserRecording"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; @@ -197,21 +198,25 @@ describe("browser recording", () => { expect(artifact.path).toBe("/tmp/recording-test.webm"); expect(blob.type).toBe("video/webm;codecs=vp9"); await uploaded; + return "uploaded-recording"; }); - const firstStop = stopBrowserRecording("transfer-tab", transfer); - const secondStop = stopBrowserRecording("transfer-tab", transfer); + const localStop = stopBrowserRecording("transfer-tab"); + const firstStop = stopBrowserRecordingForUpload("transfer-tab", transfer); + const secondStop = stopBrowserRecordingForUpload("transfer-tab", transfer); finishUpload(); expect(await firstStop).toEqual(await secondStop); + expect((await firstStop)?.uploadedAttachmentId).toBe("uploaded-recording"); + expect((await localStop)?.path).toBe("/tmp/recording-test.webm"); expect(transfer).toHaveBeenCalledOnce(); }); it("keeps the saved desktop file and releases the recording when transfer fails", async () => { await startBrowserRecording("failed-transfer-tab"); await expect( - stopBrowserRecording("failed-transfer-tab", async () => { + stopBrowserRecordingForUpload("failed-transfer-tab", async () => { throw new Error("Connection interrupted"); }), - ).rejects.toMatchObject({ _tag: "BrowserRecordingOperationError" }); + ).rejects.toThrow("Connection interrupted"); expect(save).toHaveBeenCalledOnce(); expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false); await startBrowserRecording("failed-transfer-tab"); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index fd9e7090210f..9bf22108b573 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -123,6 +123,8 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; + savedBlob?: Blob; + uploadPromise?: Promise; lifecycle: BrowserRecordingLifecycle; } @@ -661,7 +663,6 @@ export async function startBrowserRecording( const finalizeBrowserRecording = async ( bridge: NonNullable, recording: ActiveRecording, - onSaved?: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, ): Promise => { const { tabId } = recording; let result: @@ -709,7 +710,7 @@ const finalizeBrowserRecording = async ( mimeType, new Uint8Array(await blob.arrayBuffer()), ); - await onSaved?.(artifact, blob); + recording.savedBlob = blob; result = { _tag: "Success", artifact }; } catch (cause) { throw new BrowserRecordingOperationError({ @@ -794,7 +795,6 @@ const discardBrowserRecording = async ( export function stopBrowserRecording( tabId: string, - onSaved?: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, ): Promise { const bridge = previewBridge; const recording = activeRecordings.get(tabId); @@ -803,7 +803,7 @@ export function stopBrowserRecording( if (recording.lifecycle.phase === "starting") recording.lifecycle.cancelBeforeGrant(); const stopPromise = Promise.resolve() - .then(() => finalizeBrowserRecording(bridge, recording, onSaved)) + .then(() => finalizeBrowserRecording(bridge, recording)) .catch((error) => { if (isStartupWaitTimeout(error) && activeRecordings.get(recording.tabId) === recording) { const cleanupAfterStartup = recording.startupSettled.then(() => @@ -817,3 +817,16 @@ export function stopBrowserRecording( recording.lifecycle = { phase: "stopping", stopPromise }; return stopPromise; } + +/** Joins local stops and shares one upload among concurrent automation requests. */ +export async function stopBrowserRecordingForUpload( + tabId: string, + upload: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise, +): Promise<(DesktopPreviewRecordingArtifact & { uploadedAttachmentId: string }) | null> { + const recording = activeRecordings.get(tabId); + if (!recording) return null; + const artifact = await stopBrowserRecording(tabId); + if (!artifact || !recording.savedBlob) return null; + recording.uploadPromise ??= upload(artifact, recording.savedBlob); + return { ...artifact, uploadedAttachmentId: await recording.uploadPromise }; +} diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts index a88884091997..00617f3930d1 100644 --- a/apps/web/src/browser/browserRecordingUpload.ts +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -15,6 +15,7 @@ export async function uploadBrowserRecording( environmentId: EnvironmentId, artifact: DesktopPreviewRecordingArtifact, blob: Blob, + deadlineMs: number, ): Promise { if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { throw new Error(`Recording exceeds the 50 MiB transfer limit. Desktop copy: ${artifact.path}`); @@ -36,16 +37,22 @@ export async function uploadBrowserRecording( }, transport: (url) => { const controller = new AbortController(); + // Encoding, saving and minting consume the same request budget. Leave time to reply. + const remainingMs = deadlineMs - Date.now() - 1_000; return { abort: () => controller.abort(), - done: fetch(url, { - method: "POST", - headers: { "Content-Type": artifact.mimeType }, - body: blob, - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(110_000)]), - }).then((response) => { - if (!response.ok) throw new Error(`Recording upload rejected (${response.status}).`); - }), + done: + remainingMs <= 0 + ? Promise.reject(new Error("Recording transfer deadline expired.")) + : fetch(url, { + method: "POST", + headers: { "Content-Type": artifact.mimeType }, + body: blob, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]), + }).then((response) => { + if (!response.ok) + throw new Error(`Recording upload rejected (${response.status}).`); + }), }; }, }); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index ce852aa63d71..eba72d40b680 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -35,6 +35,7 @@ import { readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, + stopBrowserRecordingForUpload, } from "~/browser/browserRecording"; import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; import { uploadBrowserRecording } from "~/browser/browserRecordingUpload"; @@ -722,20 +723,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) request.input !== null && "transferToEnvironment" in request.input && request.input.transferToEnvironment === true; - let uploadedAttachmentId: string | undefined; const artifact = stopRuntimeTabId - ? await stopBrowserRecording( - stopRuntimeTabId, - transferToEnvironment - ? async (saved, blob) => { - uploadedAttachmentId = await uploadBrowserRecording( - environmentId, - saved, - blob, - ); - } - : undefined, - ) + ? transferToEnvironment + ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => + uploadBrowserRecording(environmentId, saved, blob, hostDeadlineMs), + ) + : await stopBrowserRecording(stopRuntimeTabId) : null; if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( @@ -750,7 +743,6 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return { ...artifact, tabId: stopTabId, - ...(uploadedAttachmentId ? { uploadedAttachmentId } : {}), }; } } diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index e69ff7404252..af85066d1014 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -553,6 +553,8 @@ export const PreviewAutomationRecordingStatus = Schema.Struct({ }); export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type; +export const PREVIEW_RECORDING_STOP_TIMEOUT_MS = 120_000; + export const PreviewAutomationRecordingArtifact = Schema.Struct({ id: Schema.String, tabId: PreviewTabId, @@ -860,12 +862,26 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedErrorC "PreviewAutomationRecordingTransferError", { threadId: ThreadId, - detail: Schema.String, + reason: Schema.Literals([ + "invalid-metadata", + "desktop-update-required", + "invalid-upload", + "size-mismatch", + "retain-failed", + ]), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Preview recording could not be saved to the agent environment: ${this.detail}`; + const detail = { + "invalid-metadata": "The desktop returned invalid recording metadata.", + "desktop-update-required": + "Update the desktop app to transfer recordings. The recording remains on the desktop.", + "invalid-upload": "The uploaded recording is missing, expired, or already claimed.", + "size-mismatch": "The uploaded recording size does not match its metadata or exceeds 50 MiB.", + "retain-failed": "The uploaded recording could not be retained.", + }[this.reason]; + return `Preview recording could not be saved to the agent environment: ${detail}`; } } From b76b06714ab1742c49d64893722c504e60e3bb61 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 7 Sep 2026 20:50:11 +0000 Subject: [PATCH 3/4] fix(preview): retain concurrent transfers and preserve recording errors --- .../src/mcp/PreviewAutomationBroker.test.ts | 51 +++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 32 +++++++++ .../src/mcp/toolkits/preview/handlers.test.ts | 46 +++++++++++- .../src/mcp/toolkits/preview/handlers.ts | 72 ++++++++++++++----- .../web/src/browser/browserRecordingUpload.ts | 32 +++++++-- .../preview/PreviewAutomationHosts.tsx | 2 +- .../preview/previewAutomationErrors.ts | 10 ++- packages/contracts/src/previewAutomation.ts | 42 ++++++++--- 8 files changed, 251 insertions(+), 36 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308e..7a38fc52f737 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -404,6 +404,57 @@ it.effect("classifies a remote non-editable target without collapsing it to exec ); }); +it.effect.each([ + ["PreviewAutomationRecordingTransferError", "invalid-metadata"], + ["PreviewAutomationRecordingTransferError", "invalid-upload"], + ["PreviewAutomationRecordingTransferError", "size-mismatch"], + ["PreviewAutomationRecordingTransferError", "retain-failed"], + ["PreviewAutomationRecordingTransferError", "upload-failed"], + ["PreviewAutomationRecordingTransferError", "unknown-reason"], + ["PreviewAutomationRecordingDesktopUpdateRequiredError", undefined], + ["PreviewAutomationRecordingTooLargeError", undefined], + ["PreviewAutomationRecordingDeadlineExpiredError", undefined], +] as const)("preserves recording failure %s", ([tag, reason]) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const remoteError = { + _tag: tag, + message: "remote recording details", + detail: { reason, threadId: "untrusted-thread" }, + }; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const error = yield* broker + .invoke({ + scope, + operation: "recordingStop", + input: {}, + }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: tag, + threadId: scope.threadId, + ...(reason === undefined + ? {} + : { reason: reason === "unknown-reason" ? "upload-failed" : reason }), + }); + expect(error.cause).toBe(remoteError); + expect(error.message).toContain("remains on the desktop"); + expect(error.message).not.toContain("remote recording details"); + }), + ), +); + it.effect("distinguishes malformed remote failures", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 3e9bfaac26ff..5f0a165fb60b 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -7,6 +7,10 @@ import { PreviewAutomationMalformedResponseError, PreviewAutomationNoAvailableHostError, PreviewAutomationRemoteUnavailableError, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, @@ -183,6 +187,8 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } } +const isRecordingTransferReason = Schema.is(PreviewAutomationRecordingTransferError.fields.reason); + const classifyResponseError = ( context: PreviewAutomationRequestErrorContext, error: NonNullable, @@ -194,6 +200,32 @@ const classifyResponseError = ( cause: error, }; switch (error._tag) { + case "PreviewAutomationRecordingDesktopUpdateRequiredError": + return new PreviewAutomationRecordingDesktopUpdateRequiredError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingTooLargeError": + return new PreviewAutomationRecordingTooLargeError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingDeadlineExpiredError": + return new PreviewAutomationRecordingDeadlineExpiredError({ + threadId: context.threadId, + cause: error, + }); + case "PreviewAutomationRecordingTransferError": { + const reason = + typeof error.detail === "object" && error.detail !== null && "reason" in error.detail + ? error.detail.reason + : undefined; + return new PreviewAutomationRecordingTransferError({ + threadId: context.threadId, + reason: isRecordingTransferReason(reason) ? reason : "upload-failed", + cause: error, + }); + } case "PreviewAutomationNoAvailableHostError": return new PreviewAutomationNoAvailableHostError({ ...context, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts index c25a1120d5df..1cbb7ddf4025 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts @@ -43,6 +43,48 @@ describe("normalizePreviewOpenInput", () => { }); describe("claimPreviewRecording", () => { + it.effect("overlapping and repeated claims return the same retained recording", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const uploadedAttachmentId = createPendingAttachmentId(".webm"); + const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(pendingPath, "video!"); + const response = { + id: "desktop-recording", + tabId: "tab-1", + path: "/desktop/recording.webm", + mimeType: "video/webm", + sizeBytes: 6, + createdAt: "2026-09-07T00:00:00.000Z", + uploadedAttachmentId, + }; + const claim = claimPreviewRecording(ThreadId.make("thread-1"), response); + const [first, second] = yield* Effect.all([claim, claim], { concurrency: "unbounded" }); + expect(first).toEqual(second); + expect(yield* claim).toEqual(first); + expect(yield* fileSystem.readFileString(first.path)).toBe("video!"); + expect(yield* fileSystem.exists(pendingPath)).toBe(false); + const wrongThread = yield* claimPreviewRecording(ThreadId.make("thread-2"), response).pipe( + Effect.result, + ); + expect(wrongThread._tag).toBe("Failure"); + const wrongPath = yield* claimPreviewRecording(ThreadId.make("thread-1"), { + ...response, + uploadedAttachmentId: `../${uploadedAttachmentId}`, + }).pipe(Effect.result); + expect(wrongPath._tag).toBe("Failure"); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + it.effect.each([6, 5])( "claims only a complete uploaded recording (reported bytes: %s)", (sizeBytes) => @@ -76,7 +118,7 @@ describe("claimPreviewRecording", () => { } else { expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") return; - expect(result.failure.reason).toBe("size-mismatch"); + expect(result.failure).toMatchObject({ reason: "size-mismatch" }); expect(yield* fileSystem.exists(pendingPath)).toBe(true); } }).pipe( @@ -100,7 +142,7 @@ describe("claimPreviewRecording", () => { }).pipe(Effect.result); expect(result._tag).toBe("Failure"); if (result._tag !== "Failure") return; - expect(result.failure.reason).toBe("desktop-update-required"); + expect(result.failure._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError"); expect(result.failure.message).toContain("Update the desktop app"); }).pipe( Effect.provide( diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 9dc75bb8b1fb..763997de8149 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -5,6 +5,7 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, PREVIEW_RECORDING_STOP_TIMEOUT_MS, PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, PreviewAutomationRecordingArtifact, type ThreadId, type PreviewAutomationOperation, @@ -17,7 +18,13 @@ import { type PreviewTabId, } from "@t3tools/contracts"; -import { planAttachmentClaim } from "../../../attachmentStore.ts"; +import { + parseAttachmentUuid, + parseAttachmentFileExtension, + PENDING_ATTACHMENT_THREAD_SEGMENT, + toSafeThreadAttachmentSegment, +} from "../../../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts"; import * as ServerConfig from "../../../config.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -97,38 +104,71 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( ), ); if (!artifact.uploadedAttachmentId) { + return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId }); + } + const config = yield* ServerConfig.ServerConfig; + const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId); + const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId); + const threadSegment = toSafeThreadAttachmentSegment(threadId); + const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`; + if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) { return yield* new PreviewAutomationRecordingTransferError({ threadId, - reason: "desktop-update-required", + reason: "invalid-upload", }); } - const config = yield* ServerConfig.ServerConfig; - const claim = planAttachmentClaim({ + // The same completed upload can be returned to overlapping stop requests. + const finalId = `${threadSegment}-${uuid}-${extension}`; + const currentPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, - threadId, - attachmentId: artifact.uploadedAttachmentId, + relativePath: `${pendingId}.${extension}`, }); - if (!claim.ok) { + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${finalId}.${extension}`, + }); + if (!currentPath || !finalPath) { return yield* new PreviewAutomationRecordingTransferError({ threadId, reason: "invalid-upload", }); } const fileSystem = yield* FileSystem.FileSystem; + const matchesRecording = (stat: FileSystem.File.Info) => + stat.type === "File" && + Number(stat.size) === artifact.sizeBytes && + artifact.sizeBytes > 0 && + artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES; yield* Effect.gen(function* () { - const stat = yield* fileSystem.stat(claim.currentPath); - if ( - stat.type !== "File" || - Number(stat.size) !== artifact.sizeBytes || - artifact.sizeBytes <= 0 || - artifact.sizeBytes > PROVIDER_SEND_TURN_MAX_FILE_BYTES - ) { + const pendingStat = yield* fileSystem + .stat(currentPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(cause), + ), + ); + const stat = pendingStat ?? (yield* fileSystem.stat(finalPath)); + if (!matchesRecording(stat)) { return yield* new PreviewAutomationRecordingTransferError({ threadId, reason: "size-mismatch", }); } - yield* fileSystem.rename(claim.currentPath, claim.finalPath); + if (pendingStat) { + yield* fileSystem + .rename(currentPath, finalPath) + .pipe( + Effect.catch((cause) => + cause.reason._tag === "NotFound" ? Effect.void : Effect.fail(cause), + ), + ); + if (!matchesRecording(yield* fileSystem.stat(finalPath))) { + return yield* new PreviewAutomationRecordingTransferError({ + threadId, + reason: "size-mismatch", + }); + } + } }).pipe( Effect.mapError((cause) => isRecordingTransferError(cause) @@ -141,7 +181,7 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( ), ); const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact; - return { ...recording, id: claim.finalId, path: claim.finalPath }; + return { ...recording, id: finalId, path: finalPath }; }); const handlers = { diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts index 00617f3930d1..69406f4d8d55 100644 --- a/apps/web/src/browser/browserRecordingUpload.ts +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -1,10 +1,16 @@ import { PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, type DesktopPreviewRecordingArtifact, - type EnvironmentId, + type ScopedThreadRef, } from "@t3tools/contracts"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; -import { runAttachmentUploadCycle } from "@t3tools/client-runtime/state/attachments"; +import { + deletePendingAttachmentUpload, + runAttachmentUploadCycle, +} from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { attachmentEnvironment } from "~/state/attachments"; @@ -12,13 +18,13 @@ import { readPreparedConnection } from "~/state/session"; /** Sends the finished encoded file once; capture frames never cross the environment connection. */ export async function uploadBrowserRecording( - environmentId: EnvironmentId, + { environmentId, threadId }: ScopedThreadRef, artifact: DesktopPreviewRecordingArtifact, blob: Blob, deadlineMs: number, ): Promise { if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { - throw new Error(`Recording exceeds the 50 MiB transfer limit. Desktop copy: ${artifact.path}`); + throw new PreviewAutomationRecordingTooLargeError({ threadId }); } const result = await runAttachmentUploadCycle({ registry: appAtomRegistry, @@ -57,8 +63,22 @@ export async function uploadBrowserRecording( }, }); if (result.status !== "uploaded") { - throw new Error(`Recording transfer failed. Desktop copy: ${artifact.path}`, { - cause: result.status === "failed" ? result.error : undefined, + if (result.attachmentId) { + deletePendingAttachmentUpload({ + registry: appAtomRegistry, + remove: attachmentEnvironment.remove, + environmentId, + attachmentId: result.attachmentId, + }); + } + const cause = result.status === "failed" ? result.error : undefined; + if (Date.now() >= deadlineMs - 1_000) { + throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause }); + } + throw new PreviewAutomationRecordingTransferError({ + threadId, + reason: "upload-failed", + cause, }); } return result.attachmentId; diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index eba72d40b680..d1fc12821730 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -726,7 +726,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const artifact = stopRuntimeTabId ? transferToEnvironment ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => - uploadBrowserRecording(environmentId, saved, blob, hostDeadlineMs), + uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs), ) : await stopBrowserRecording(stopRuntimeTabId) : null; diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index 97a099ec72eb..e198befa3a4a 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -2,6 +2,10 @@ import { EnvironmentId, type PreviewAutomationHost, PreviewAutomationOperation, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, type PreviewAutomationRequest, type PreviewAutomationResponse, PreviewTabId, @@ -206,6 +210,10 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass
()(
+  "PreviewAutomationRecordingDesktopUpdateRequiredError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "Update the desktop app to transfer recordings. The recording remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationRecordingTooLargeError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "The recording exceeds 50 MiB. The saved copy remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.TaggedErrorClass()(
+  "PreviewAutomationRecordingDeadlineExpiredError",
+  { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
+) {
+  override get message(): string {
+    return "The recording transfer deadline expired. The saved copy remains on the desktop.";
   }
 }
 
 export const PreviewAutomationError = Schema.Union([
   PreviewAutomationRecordingTransferError,
+  PreviewAutomationRecordingDesktopUpdateRequiredError,
+  PreviewAutomationRecordingTooLargeError,
+  PreviewAutomationRecordingDeadlineExpiredError,
   PreviewAutomationUnavailableError,
   PreviewAutomationNoAvailableHostError,
   PreviewAutomationUnsupportedClientError,

From af49c30cb96887693bf27f774978a486098e9b28 Mon Sep 17 00:00:00 2001
From: maria-rcks 
Date: Tue, 8 Sep 2026 03:06:40 +0000
Subject: [PATCH 4/4] refactor(preview): simplify recording transfer validation

---
 .../src/mcp/PreviewAutomationBroker.test.ts   | 20 ++----
 .../server/src/mcp/PreviewAutomationBroker.ts | 10 +--
 .../src/mcp/toolkits/preview/handlers.test.ts |  2 +-
 .../src/mcp/toolkits/preview/handlers.ts      | 69 ++++++-------------
 .../web/src/browser/browserRecordingUpload.ts |  1 -
 packages/contracts/src/previewAutomation.ts   |  7 --
 6 files changed, 28 insertions(+), 81 deletions(-)

diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
index 7a38fc52f737..42f849f5edf3 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts
@@ -405,23 +405,18 @@ it.effect("classifies a remote non-editable target without collapsing it to exec
 });
 
 it.effect.each([
-  ["PreviewAutomationRecordingTransferError", "invalid-metadata"],
-  ["PreviewAutomationRecordingTransferError", "invalid-upload"],
-  ["PreviewAutomationRecordingTransferError", "size-mismatch"],
-  ["PreviewAutomationRecordingTransferError", "retain-failed"],
-  ["PreviewAutomationRecordingTransferError", "upload-failed"],
-  ["PreviewAutomationRecordingTransferError", "unknown-reason"],
-  ["PreviewAutomationRecordingDesktopUpdateRequiredError", undefined],
-  ["PreviewAutomationRecordingTooLargeError", undefined],
-  ["PreviewAutomationRecordingDeadlineExpiredError", undefined],
-] as const)("preserves recording failure %s", ([tag, reason]) =>
+  "PreviewAutomationRecordingTransferError",
+  "PreviewAutomationRecordingDesktopUpdateRequiredError",
+  "PreviewAutomationRecordingTooLargeError",
+  "PreviewAutomationRecordingDeadlineExpiredError",
+] as const)("preserves recording failure %s", (tag) =>
   Effect.scoped(
     Effect.gen(function* () {
       const broker = yield* makeBroker;
       const remoteError = {
         _tag: tag,
         message: "remote recording details",
-        detail: { reason, threadId: "untrusted-thread" },
+        detail: { reason: "untrusted-reason", threadId: "untrusted-thread" },
       };
       const requests = requestsFrom(yield* broker.connect(makeHost()));
       yield* Stream.runForEach(requests, (request) =>
@@ -444,9 +439,6 @@ it.effect.each([
       expect(error).toMatchObject({
         _tag: tag,
         threadId: scope.threadId,
-        ...(reason === undefined
-          ? {}
-          : { reason: reason === "unknown-reason" ? "upload-failed" : reason }),
       });
       expect(error.cause).toBe(remoteError);
       expect(error.message).toContain("remains on the desktop");
diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts
index 5f0a165fb60b..d8f17973c218 100644
--- a/apps/server/src/mcp/PreviewAutomationBroker.ts
+++ b/apps/server/src/mcp/PreviewAutomationBroker.ts
@@ -187,8 +187,6 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind {
   }
 }
 
-const isRecordingTransferReason = Schema.is(PreviewAutomationRecordingTransferError.fields.reason);
-
 const classifyResponseError = (
   context: PreviewAutomationRequestErrorContext,
   error: NonNullable,
@@ -215,17 +213,11 @@ const classifyResponseError = (
         threadId: context.threadId,
         cause: error,
       });
-    case "PreviewAutomationRecordingTransferError": {
-      const reason =
-        typeof error.detail === "object" && error.detail !== null && "reason" in error.detail
-          ? error.detail.reason
-          : undefined;
+    case "PreviewAutomationRecordingTransferError":
       return new PreviewAutomationRecordingTransferError({
         threadId: context.threadId,
-        reason: isRecordingTransferReason(reason) ? reason : "upload-failed",
         cause: error,
       });
-    }
     case "PreviewAutomationNoAvailableHostError":
       return new PreviewAutomationNoAvailableHostError({
         ...context,
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.test.ts b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
index 1cbb7ddf4025..a2a88e14fcef 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.test.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.test.ts
@@ -118,7 +118,7 @@ describe("claimPreviewRecording", () => {
         } else {
           expect(result._tag).toBe("Failure");
           if (result._tag !== "Failure") return;
-          expect(result.failure).toMatchObject({ reason: "size-mismatch" });
+          expect(result.failure._tag).toBe("PreviewAutomationRecordingTransferError");
           expect(yield* fileSystem.exists(pendingPath)).toBe(true);
         }
       }).pipe(
diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts
index 763997de8149..ac4e124ea312 100644
--- a/apps/server/src/mcp/toolkits/preview/handlers.ts
+++ b/apps/server/src/mcp/toolkits/preview/handlers.ts
@@ -87,7 +87,6 @@ const UploadedRecordingArtifact = Schema.Struct({
   uploadedAttachmentId: Schema.optional(Schema.String),
 });
 const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact);
-const isRecordingTransferError = Schema.is(PreviewAutomationRecordingTransferError);
 
 export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* (
   threadId: ThreadId,
@@ -98,7 +97,6 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
       (cause) =>
         new PreviewAutomationRecordingTransferError({
           threadId,
-          reason: "invalid-metadata",
           cause,
         }),
     ),
@@ -114,7 +112,6 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
   if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) {
     return yield* new PreviewAutomationRecordingTransferError({
       threadId,
-      reason: "invalid-upload",
     });
   }
   // The same completed upload can be returned to overlapping stop requests.
@@ -128,57 +125,31 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(
     relativePath: `${finalId}.${extension}`,
   });
   if (!currentPath || !finalPath) {
-    return yield* new PreviewAutomationRecordingTransferError({
-      threadId,
-      reason: "invalid-upload",
-    });
+    return yield* new PreviewAutomationRecordingTransferError({ threadId });
   }
   const fileSystem = yield* FileSystem.FileSystem;
-  const matchesRecording = (stat: FileSystem.File.Info) =>
-    stat.type === "File" &&
-    Number(stat.size) === artifact.sizeBytes &&
-    artifact.sizeBytes > 0 &&
-    artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES;
+  const validateFile = (filePath: string) =>
+    fileSystem.stat(filePath).pipe(
+      Effect.filterOrFail(
+        (stat) =>
+          stat.type === "File" &&
+          Number(stat.size) === artifact.sizeBytes &&
+          artifact.sizeBytes > 0 &&
+          artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES,
+        () => new PreviewAutomationRecordingTransferError({ threadId }),
+      ),
+    );
   yield* Effect.gen(function* () {
-    const pendingStat = yield* fileSystem
-      .stat(currentPath)
-      .pipe(
-        Effect.catch((cause) =>
-          cause.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(cause),
-        ),
-      );
-    const stat = pendingStat ?? (yield* fileSystem.stat(finalPath));
-    if (!matchesRecording(stat)) {
-      return yield* new PreviewAutomationRecordingTransferError({
-        threadId,
-        reason: "size-mismatch",
-      });
-    }
-    if (pendingStat) {
-      yield* fileSystem
-        .rename(currentPath, finalPath)
-        .pipe(
-          Effect.catch((cause) =>
-            cause.reason._tag === "NotFound" ? Effect.void : Effect.fail(cause),
-          ),
-        );
-      if (!matchesRecording(yield* fileSystem.stat(finalPath))) {
-        return yield* new PreviewAutomationRecordingTransferError({
-          threadId,
-          reason: "size-mismatch",
-        });
-      }
-    }
+    yield* validateFile(currentPath);
+    yield* fileSystem.rename(currentPath, finalPath);
   }).pipe(
-    Effect.mapError((cause) =>
-      isRecordingTransferError(cause)
-        ? cause
-        : new PreviewAutomationRecordingTransferError({
-            threadId,
-            reason: "retain-failed",
-            cause,
-          }),
+    // Another stop may already have claimed this exact upload for this thread.
+    Effect.catch((cause) =>
+      cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound"
+        ? validateFile(finalPath)
+        : Effect.fail(cause),
     ),
+    Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })),
   );
   const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact;
   return { ...recording, id: finalId, path: finalPath };
diff --git a/apps/web/src/browser/browserRecordingUpload.ts b/apps/web/src/browser/browserRecordingUpload.ts
index 69406f4d8d55..399352898388 100644
--- a/apps/web/src/browser/browserRecordingUpload.ts
+++ b/apps/web/src/browser/browserRecordingUpload.ts
@@ -77,7 +77,6 @@ export async function uploadBrowserRecording(
     }
     throw new PreviewAutomationRecordingTransferError({
       threadId,
-      reason: "upload-failed",
       cause,
     });
   }
diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts
index fa6a060279f2..61ff80095679 100644
--- a/packages/contracts/src/previewAutomation.ts
+++ b/packages/contracts/src/previewAutomation.ts
@@ -862,13 +862,6 @@ export class PreviewAutomationRecordingTransferError extends Schema.TaggedErrorC
   "PreviewAutomationRecordingTransferError",
   {
     threadId: ThreadId,
-    reason: Schema.Literals([
-      "invalid-metadata",
-      "invalid-upload",
-      "size-mismatch",
-      "retain-failed",
-      "upload-failed",
-    ]),
     cause: Schema.optional(Schema.Defect()),
   },
 ) {