diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308ee9c185aff3c322887f5310c05f98..42f849f5edf32a6709f100c5960d6a0fa5f98ba2 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 3e9bfaac26ff5a72863dfe7524f7ad3a6f38d62d..d8f17973c218a2f352a9e3ef45432beeb27e1a18 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 2c4e66746447bad7850224f1a1576d8776558de7..a2a88e14fcefa0bcfe47ca19ea71ecfb3c024d0c 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 c501ee08711d66fa2dc722624bab27ae133827ca..ac4e124ea31238968fcf1951c43987747be885ec 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 ab8d1580bb61cde104e2f20aa6b4bb24a8644f8f..cc2e572bc37373f83d01e4d4cdf131638bb1d979 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 218684c54fbc98d62d1a0beded25f206e226890b..bd38c4f9f69a5a318d8bd592101d438c67a79811 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 92050394a1c9485eeb59f119454066d08a9557da..9bf22108b5732347bc4fd6d18c1540f56efac104 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 0000000000000000000000000000000000000000..3993528983885c203efb0c5c3f35feb0dfc51bfa --- /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 fd87f7e80c7921ac580173c52816f88a7a7f0667..d1fc12821730c3f5a0ecc8dd13d48027cc3c054b 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 97a099ec72eb16ce688fa552d413af8cbdb6985f..e198befa3a4a20a71c98d2799552563aa7a7102c 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,