diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308e..42f849f5edf3 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -404,6 +404,49 @@ it.effect("classifies a remote non-editable target without collapsing it to exec ); }); +it.effect.each([ + "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: "untrusted-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, + }); + 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..d8f17973c218 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, @@ -194,6 +198,26 @@ 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": + return new PreviewAutomationRecordingTransferError({ + threadId: context.threadId, + 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 2c4e66746447..a2a88e14fcef 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,115 @@ 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) => + 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"); + if (result._tag !== "Failure") return; + expect(result.failure._tag).toBe("PreviewAutomationRecordingTransferError"); + 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._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError"); + 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..ac4e124ea312 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,16 +1,31 @@ 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, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingDesktopUpdateRequiredError, 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 { + 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"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -67,6 +82,79 @@ const invokeTargeted = ( return invoke(operation, operationInput, timeoutMs, tabId); }; +const UploadedRecordingArtifact = Schema.Struct({ + ...PreviewAutomationRecordingArtifact.fields, + uploadedAttachmentId: Schema.optional(Schema.String), +}); +const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact); + +export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* ( + threadId: ThreadId, + response: unknown, +) { + const artifact = yield* decodeUploadedRecordingArtifact(response).pipe( + Effect.mapError( + (cause) => + new PreviewAutomationRecordingTransferError({ + threadId, + cause, + }), + ), + ); + 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, + }); + } + // The same completed upload can be returned to overlapping stop requests. + const finalId = `${threadSegment}-${uuid}-${extension}`; + const currentPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${pendingId}.${extension}`, + }); + const finalPath = resolveAttachmentRelativePath({ + attachmentsDir: config.attachmentsDir, + relativePath: `${finalId}.${extension}`, + }); + if (!currentPath || !finalPath) { + return yield* new PreviewAutomationRecordingTransferError({ threadId }); + } + const fileSystem = yield* FileSystem.FileSystem; + 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* () { + yield* validateFile(currentPath); + yield* fileSystem.rename(currentPath, finalPath); + }).pipe( + // 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 }; +}); + const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => @@ -94,7 +182,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 }, + PREVIEW_RECORDING_STOP_TIMEOUT_MS, + ); + 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..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"; @@ -180,6 +181,48 @@ 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; + return "uploaded-recording"; + }); + 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( + stopBrowserRecordingForUpload("failed-transfer-tab", async () => { + throw new Error("Connection interrupted"); + }), + ).rejects.toThrow("Connection interrupted"); + 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..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; } @@ -708,6 +710,7 @@ const finalizeBrowserRecording = async ( mimeType, new Uint8Array(await blob.arrayBuffer()), ); + recording.savedBlob = blob; result = { _tag: "Success", artifact }; } catch (cause) { throw new BrowserRecordingOperationError({ @@ -814,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 new file mode 100644 index 000000000000..399352898388 --- /dev/null +++ b/apps/web/src/browser/browserRecordingUpload.ts @@ -0,0 +1,84 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PreviewAutomationRecordingTransferError, + PreviewAutomationRecordingTooLargeError, + PreviewAutomationRecordingDeadlineExpiredError, + type DesktopPreviewRecordingArtifact, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { + deletePendingAttachmentUpload, + 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, threadId }: ScopedThreadRef, + artifact: DesktopPreviewRecordingArtifact, + blob: Blob, + deadlineMs: number, +): Promise { + if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + throw new PreviewAutomationRecordingTooLargeError({ threadId }); + } + 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(); + // 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: + 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}).`); + }), + }; + }, + }); + if (result.status !== "uploaded") { + 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, + cause, + }); + } + return result.attachmentId; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index fd87f7e80c79..d1fc12821730 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -35,8 +35,10 @@ import { readActiveBrowserRecordingTargets, startBrowserRecording, stopBrowserRecording, + stopBrowserRecordingForUpload, } from "~/browser/browserRecording"; import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; +import { uploadBrowserRecording } from "~/browser/browserRecordingUpload"; import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, @@ -716,7 +718,18 @@ 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; + const artifact = stopRuntimeTabId + ? transferToEnvironment + ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) => + uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs), + ) + : await stopBrowserRecording(stopRuntimeTabId) + : null; if (!artifact || !stopTabId) { return raisePreviewAutomationHostError( new PreviewAutomationRecordingNotActiveError({ @@ -727,7 +740,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) }), ); } - return { ...artifact, tabId: stopTabId }; + return { + ...artifact, + tabId: stopTabId, + }; } } } catch (cause) { 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
()(
+  "PreviewAutomationRecordingTransferError",
+  {
+    threadId: ThreadId,
+    cause: Schema.optional(Schema.Defect()),
+  },
+) {
+  override get message(): string {
+    return "Preview recording could not be saved to the agent environment. The saved copy remains on the desktop.";
+  }
+}
+
+export class PreviewAutomationRecordingDesktopUpdateRequiredError 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,