authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-08 00:34:43-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 00:34:43-03:00
log9e37f0c291974d084a59d7b9f165a1cb056e6043
treed6b52b8e251eeef060bba9d2e2cf49a684e8f27a
parent15193df9fb915837f7215909a7d741789db0a273
signature Signed by PGP key B5690EEEBB952194

fix(preview): transfer recordings to the agent environment (#10572)


11 files changed, 517 insertions(+), 17 deletions(-)

apps/server/src/mcp/PreviewAutomationBroker.test.ts+43
......@@ -404,6 +404,49 @@ it.effect("classifies a remote non-editable target without collapsing it to exec
404404 );
405405});
406406
407it.effect.each([
408 "PreviewAutomationRecordingTransferError",
409 "PreviewAutomationRecordingDesktopUpdateRequiredError",
410 "PreviewAutomationRecordingTooLargeError",
411 "PreviewAutomationRecordingDeadlineExpiredError",
412] as const)("preserves recording failure %s", (tag) =>
413 Effect.scoped(
414 Effect.gen(function* () {
415 const broker = yield* makeBroker;
416 const remoteError = {
417 _tag: tag,
418 message: "remote recording details",
419 detail: { reason: "untrusted-reason", threadId: "untrusted-thread" },
420 };
421 const requests = requestsFrom(yield* broker.connect(makeHost()));
422 yield* Stream.runForEach(requests, (request) =>
423 broker.respond({
424 clientId: "client-1",
425 connectionId: request.connectionId,
426 requestId: request.requestId,
427 ok: false,
428 error: remoteError,
429 }),
430 ).pipe(Effect.forkScoped);
431 yield* Effect.yieldNow;
432 const error = yield* broker
433 .invoke<void>({
434 scope,
435 operation: "recordingStop",
436 input: {},
437 })
438 .pipe(Effect.flip);
439 expect(error).toMatchObject({
440 _tag: tag,
441 threadId: scope.threadId,
442 });
443 expect(error.cause).toBe(remoteError);
444 expect(error.message).toContain("remains on the desktop");
445 expect(error.message).not.toContain("remote recording details");
446 }),
447 ),
448);
449
407450it.effect("distinguishes malformed remote failures", () =>
408451 Effect.scoped(
409452 Effect.gen(function* () {
apps/server/src/mcp/PreviewAutomationBroker.ts+24
......@@ -7,6 +7,10 @@ import {
77 PreviewAutomationMalformedResponseError,
88 PreviewAutomationNoAvailableHostError,
99 PreviewAutomationRemoteUnavailableError,
10 PreviewAutomationRecordingTransferError,
11 PreviewAutomationRecordingDesktopUpdateRequiredError,
12 PreviewAutomationRecordingTooLargeError,
13 PreviewAutomationRecordingDeadlineExpiredError,
1014 PreviewAutomationRequestQueueClosedError,
1115 PreviewAutomationResultTooLargeError,
1216 PreviewAutomationTabNotFoundError,
......@@ -194,6 +198,26 @@ const classifyResponseError = (
194198 cause: error,
195199 };
196200 switch (error._tag) {
201 case "PreviewAutomationRecordingDesktopUpdateRequiredError":
202 return new PreviewAutomationRecordingDesktopUpdateRequiredError({
203 threadId: context.threadId,
204 cause: error,
205 });
206 case "PreviewAutomationRecordingTooLargeError":
207 return new PreviewAutomationRecordingTooLargeError({
208 threadId: context.threadId,
209 cause: error,
210 });
211 case "PreviewAutomationRecordingDeadlineExpiredError":
212 return new PreviewAutomationRecordingDeadlineExpiredError({
213 threadId: context.threadId,
214 cause: error,
215 });
216 case "PreviewAutomationRecordingTransferError":
217 return new PreviewAutomationRecordingTransferError({
218 threadId: context.threadId,
219 cause: error,
220 });
197221 case "PreviewAutomationNoAvailableHostError":
198222 return new PreviewAutomationNoAvailableHostError({
199223 ...context,
apps/server/src/mcp/toolkits/preview/handlers.test.ts+125-2
......@@ -1,6 +1,17 @@
1import { describe, expect, it } from "vite-plus/test";
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
28
3import { normalizePreviewOpenInput } from "./handlers.ts";
9import {
10 createPendingAttachmentId,
11 parseThreadSegmentFromAttachmentId,
12} from "../../../attachmentStore.ts";
13import * as ServerConfig from "../../../config.ts";
14import { claimPreviewRecording, normalizePreviewOpenInput } from "./handlers.ts";
415
516describe("normalizePreviewOpenInput", () => {
617 it("leaves an unstated visibility for the client preference to decide", () => {
......@@ -30,3 +41,115 @@ describe("normalizePreviewOpenInput", () => {
3041 });
3142 });
3243});
44
45describe("claimPreviewRecording", () => {
46 it.effect("overlapping and repeated claims return the same retained recording", () =>
47 Effect.gen(function* () {
48 const config = yield* ServerConfig.ServerConfig;
49 const fileSystem = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const uploadedAttachmentId = createPendingAttachmentId(".webm");
52 const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`);
53 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
54 yield* fileSystem.writeFileString(pendingPath, "video!");
55 const response = {
56 id: "desktop-recording",
57 tabId: "tab-1",
58 path: "/desktop/recording.webm",
59 mimeType: "video/webm",
60 sizeBytes: 6,
61 createdAt: "2026-09-07T00:00:00.000Z",
62 uploadedAttachmentId,
63 };
64 const claim = claimPreviewRecording(ThreadId.make("thread-1"), response);
65 const [first, second] = yield* Effect.all([claim, claim], { concurrency: "unbounded" });
66 expect(first).toEqual(second);
67 expect(yield* claim).toEqual(first);
68 expect(yield* fileSystem.readFileString(first.path)).toBe("video!");
69 expect(yield* fileSystem.exists(pendingPath)).toBe(false);
70 const wrongThread = yield* claimPreviewRecording(ThreadId.make("thread-2"), response).pipe(
71 Effect.result,
72 );
73 expect(wrongThread._tag).toBe("Failure");
74 const wrongPath = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
75 ...response,
76 uploadedAttachmentId: `../${uploadedAttachmentId}`,
77 }).pipe(Effect.result);
78 expect(wrongPath._tag).toBe("Failure");
79 }).pipe(
80 Effect.provide(
81 ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
82 Layer.provideMerge(NodeServices.layer),
83 ),
84 ),
85 ),
86 );
87
88 it.effect.each([6, 5])(
89 "claims only a complete uploaded recording (reported bytes: %s)",
90 (sizeBytes) =>
91 Effect.gen(function* () {
92 const config = yield* ServerConfig.ServerConfig;
93 const fileSystem = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const uploadedAttachmentId = createPendingAttachmentId(".webm");
96 const pendingPath = path.join(config.attachmentsDir, `${uploadedAttachmentId}.webm`);
97 yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
98 yield* fileSystem.writeFileString(pendingPath, "video!");
99 const response = {
100 id: "desktop-recording",
101 tabId: "tab-1",
102 path: "/desktop/recording.webm",
103 mimeType: "video/webm",
104 sizeBytes,
105 createdAt: "2026-09-07T00:00:00.000Z",
106 uploadedAttachmentId,
107 };
108 const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), response).pipe(
109 Effect.result,
110 );
111 if (sizeBytes === 6) {
112 expect(result._tag).toBe("Success");
113 if (result._tag !== "Success") return;
114 expect(result.success.path).not.toBe(response.path);
115 expect(parseThreadSegmentFromAttachmentId(result.success.id)).toBe("thread-1");
116 expect(yield* fileSystem.readFileString(result.success.path)).toBe("video!");
117 expect(yield* fileSystem.exists(pendingPath)).toBe(false);
118 } else {
119 expect(result._tag).toBe("Failure");
120 if (result._tag !== "Failure") return;
121 expect(result.failure._tag).toBe("PreviewAutomationRecordingTransferError");
122 expect(yield* fileSystem.exists(pendingPath)).toBe(true);
123 }
124 }).pipe(
125 Effect.provide(
126 ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
127 Layer.provideMerge(NodeServices.layer),
128 ),
129 ),
130 ),
131 );
132
133 it.effect("reports an older desktop without returning its inaccessible path", () =>
134 Effect.gen(function* () {
135 const result = yield* claimPreviewRecording(ThreadId.make("thread-1"), {
136 id: "desktop-recording",
137 tabId: "tab-1",
138 path: "/desktop/recording.webm",
139 mimeType: "video/webm",
140 sizeBytes: 6,
141 createdAt: "2026-09-07T00:00:00.000Z",
142 }).pipe(Effect.result);
143 expect(result._tag).toBe("Failure");
144 if (result._tag !== "Failure") return;
145 expect(result.failure._tag).toBe("PreviewAutomationRecordingDesktopUpdateRequiredError");
146 expect(result.failure.message).toContain("Update the desktop app");
147 }).pipe(
148 Effect.provide(
149 ServerConfig.layerTest(process.cwd(), { prefix: "t3-preview-recording-" }).pipe(
150 Layer.provideMerge(NodeServices.layer),
151 ),
152 ),
153 ),
154 );
155});
apps/server/src/mcp/toolkits/preview/handlers.ts+106-10
......@@ -1,16 +1,31 @@
11import * as Effect from "effect/Effect";
2import type {
3 PreviewAutomationOperation,
4 PreviewAutomationOpenInput,
2import * as FileSystem from "effect/FileSystem";
3import * as Schema from "effect/Schema";
4import {
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
7 PreviewAutomationRecordingTransferError,
8 PreviewAutomationRecordingDesktopUpdateRequiredError,
59 PreviewAutomationRecordingArtifact,
6 PreviewAutomationRecordingStatus,
7 PreviewAutomationResizeResult,
8 PreviewAutomationSetColorSchemeResult,
9 PreviewAutomationSnapshot,
10 PreviewAutomationStatus,
11 PreviewTabId,
10 type ThreadId,
11 type PreviewAutomationOperation,
12 type PreviewAutomationOpenInput,
13 type PreviewAutomationRecordingStatus,
14 type PreviewAutomationResizeResult,
15 type PreviewAutomationSetColorSchemeResult,
16 type PreviewAutomationSnapshot,
17 type PreviewAutomationStatus,
18 type PreviewTabId,
1219} from "@t3tools/contracts";
1320
21import {
22 parseAttachmentUuid,
23 parseAttachmentFileExtension,
24 PENDING_ATTACHMENT_THREAD_SEGMENT,
25 toSafeThreadAttachmentSegment,
26} from "../../../attachmentStore.ts";
27import { resolveAttachmentRelativePath } from "../../../attachmentPaths.ts";
28import * as ServerConfig from "../../../config.ts";
1429import * as McpInvocationContext from "../../McpInvocationContext.ts";
1530import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
1631import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts";
......@@ -67,6 +82,79 @@ const invokeTargeted = <A>(
6782 return invoke<A>(operation, operationInput, timeoutMs, tabId);
6883};
6984
85const UploadedRecordingArtifact = Schema.Struct({
86 ...PreviewAutomationRecordingArtifact.fields,
87 uploadedAttachmentId: Schema.optional(Schema.String),
88});
89const decodeUploadedRecordingArtifact = Schema.decodeUnknownEffect(UploadedRecordingArtifact);
90
91export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")(function* (
92 threadId: ThreadId,
93 response: unknown,
94) {
95 const artifact = yield* decodeUploadedRecordingArtifact(response).pipe(
96 Effect.mapError(
97 (cause) =>
98 new PreviewAutomationRecordingTransferError({
99 threadId,
100 cause,
101 }),
102 ),
103 );
104 if (!artifact.uploadedAttachmentId) {
105 return yield* new PreviewAutomationRecordingDesktopUpdateRequiredError({ threadId });
106 }
107 const config = yield* ServerConfig.ServerConfig;
108 const uuid = parseAttachmentUuid(artifact.uploadedAttachmentId);
109 const extension = parseAttachmentFileExtension(artifact.uploadedAttachmentId);
110 const threadSegment = toSafeThreadAttachmentSegment(threadId);
111 const pendingId = `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${uuid}-${extension}`;
112 if (!uuid || !extension || !threadSegment || artifact.uploadedAttachmentId !== pendingId) {
113 return yield* new PreviewAutomationRecordingTransferError({
114 threadId,
115 });
116 }
117 // The same completed upload can be returned to overlapping stop requests.
118 const finalId = `${threadSegment}-${uuid}-${extension}`;
119 const currentPath = resolveAttachmentRelativePath({
120 attachmentsDir: config.attachmentsDir,
121 relativePath: `${pendingId}.${extension}`,
122 });
123 const finalPath = resolveAttachmentRelativePath({
124 attachmentsDir: config.attachmentsDir,
125 relativePath: `${finalId}.${extension}`,
126 });
127 if (!currentPath || !finalPath) {
128 return yield* new PreviewAutomationRecordingTransferError({ threadId });
129 }
130 const fileSystem = yield* FileSystem.FileSystem;
131 const validateFile = (filePath: string) =>
132 fileSystem.stat(filePath).pipe(
133 Effect.filterOrFail(
134 (stat) =>
135 stat.type === "File" &&
136 Number(stat.size) === artifact.sizeBytes &&
137 artifact.sizeBytes > 0 &&
138 artifact.sizeBytes <= PROVIDER_SEND_TURN_MAX_FILE_BYTES,
139 () => new PreviewAutomationRecordingTransferError({ threadId }),
140 ),
141 );
142 yield* Effect.gen(function* () {
143 yield* validateFile(currentPath);
144 yield* fileSystem.rename(currentPath, finalPath);
145 }).pipe(
146 // Another stop may already have claimed this exact upload for this thread.
147 Effect.catch((cause) =>
148 cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound"
149 ? validateFile(finalPath)
150 : Effect.fail(cause),
151 ),
152 Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })),
153 );
154 const { uploadedAttachmentId: _uploadedAttachmentId, ...recording } = artifact;
155 return { ...recording, id: finalId, path: finalPath };
156});
157
70158const handlers = {
71159 preview_status: (input) => invokeTargeted<PreviewAutomationStatus>("status", input ?? {}),
72160 preview_open: (input) =>
......@@ -94,7 +182,15 @@ const handlers = {
94182 preview_recording_start: (input) =>
95183 invokeTargeted<PreviewAutomationRecordingStatus>("recordingStart", input ?? {}),
96184 preview_recording_stop: (input) =>
97 invokeTargeted<PreviewAutomationRecordingArtifact>("recordingStop", input ?? {}),
185 Effect.gen(function* () {
186 const scope = yield* McpInvocationContext.requireMcpCapability("preview");
187 const response = yield* invokeTargeted<unknown>(
188 "recordingStop",
189 { ...input, transferToEnvironment: true },
190 PREVIEW_RECORDING_STOP_TIMEOUT_MS,
191 );
192 return yield* claimPreviewRecording(scope.threadId, response);
193 }),
98194} satisfies Parameters<typeof PreviewToolkit.toLayer>[0];
99195
100196const { preview_snapshot, ...standardHandlers } = handlers;
apps/server/src/mcp/toolkits/preview/tools.ts+4-2
......@@ -19,10 +19,12 @@ import {
1919 PreviewAutomationWaitForInput,
2020} from "@t3tools/contracts";
2121import * as Schema from "effect/Schema";
22import * as FileSystem from "effect/FileSystem";
2223import { Tool, Toolkit } from "effect/unstable/ai";
2324
2425import * as McpInvocationContext from "../../McpInvocationContext.ts";
2526import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
27import * as ServerConfig from "../../../config.ts";
2628
2729const dependencies = [
2830 McpInvocationContext.McpInvocationContext,
......@@ -207,11 +209,11 @@ export const PreviewRecordingStartTool = safeBrowserTool(
207209export const PreviewRecordingStopTool = safeBrowserTool(
208210 Tool.make("preview_recording_stop", {
209211 description:
210 "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.",
212 "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.",
211213 parameters: PreviewAutomationTabTargetInput,
212214 success: PreviewAutomationRecordingArtifact,
213215 failure: PreviewAutomationError,
214 dependencies,
216 dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig],
215217 }).annotate(Tool.Title, "Stop browser recording"),
216218);
217219
apps/web/src/browser/browserRecording.test.ts+43
......@@ -78,6 +78,7 @@ import {
7878 readActiveBrowserRecordingTargets,
7979 startBrowserRecording,
8080 stopBrowserRecording,
81 stopBrowserRecordingForUpload,
8182} from "./browserRecording";
8283import { useBrowserSurfaceStore } from "./browserSurfaceStore";
8384import { previewRuntimeTabId } from "./previewRuntimeTabId";
......@@ -180,6 +181,48 @@ describe("browser recording", () => {
180181 await stopBrowserRecording("automation-recording-tab");
181182 });
182183
184 it("saves locally and releases capture before transferring the encoded recording once", async () => {
185 const stopTrack = vi.fn();
186 getDisplayMedia.mockResolvedValue({
187 getVideoTracks: () => [],
188 getTracks: () => [{ stop: stopTrack }],
189 });
190 await startBrowserRecording("transfer-tab");
191 let finishUpload!: () => void;
192 const uploaded = new Promise<void>((resolve) => {
193 finishUpload = resolve;
194 });
195 const transfer = vi.fn(async (artifact, blob: Blob) => {
196 expect(save).toHaveBeenCalledOnce();
197 expect(stopTrack).toHaveBeenCalled();
198 expect(artifact.path).toBe("/tmp/recording-test.webm");
199 expect(blob.type).toBe("video/webm;codecs=vp9");
200 await uploaded;
201 return "uploaded-recording";
202 });
203 const localStop = stopBrowserRecording("transfer-tab");
204 const firstStop = stopBrowserRecordingForUpload("transfer-tab", transfer);
205 const secondStop = stopBrowserRecordingForUpload("transfer-tab", transfer);
206 finishUpload();
207 expect(await firstStop).toEqual(await secondStop);
208 expect((await firstStop)?.uploadedAttachmentId).toBe("uploaded-recording");
209 expect((await localStop)?.path).toBe("/tmp/recording-test.webm");
210 expect(transfer).toHaveBeenCalledOnce();
211 });
212
213 it("keeps the saved desktop file and releases the recording when transfer fails", async () => {
214 await startBrowserRecording("failed-transfer-tab");
215 await expect(
216 stopBrowserRecordingForUpload("failed-transfer-tab", async () => {
217 throw new Error("Connection interrupted");
218 }),
219 ).rejects.toThrow("Connection interrupted");
220 expect(save).toHaveBeenCalledOnce();
221 expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false);
222 await startBrowserRecording("failed-transfer-tab");
223 await stopBrowserRecording("failed-transfer-tab");
224 });
225
183226 it("paints and holds a hidden browser surface for the recording lifetime", async () => {
184227 startScreencast.mockImplementationOnce(async (tabId: string) => {
185228 expect(animationFrameCount).toBe(2);
apps/web/src/browser/browserRecording.ts+16
......@@ -123,6 +123,8 @@ interface ActiveRecording {
123123 releaseSurfaceActivity: (() => void) | null;
124124 stream: MediaStream | null;
125125 recorder: MediaRecorder | null;
126 savedBlob?: Blob;
127 uploadPromise?: Promise<string>;
126128 lifecycle: BrowserRecordingLifecycle;
127129}
128130
......@@ -708,6 +710,7 @@ const finalizeBrowserRecording = async (
708710 mimeType,
709711 new Uint8Array(await blob.arrayBuffer()),
710712 );
713 recording.savedBlob = blob;
711714 result = { _tag: "Success", artifact };
712715 } catch (cause) {
713716 throw new BrowserRecordingOperationError({
......@@ -814,3 +817,16 @@ export function stopBrowserRecording(
814817 recording.lifecycle = { phase: "stopping", stopPromise };
815818 return stopPromise;
816819}
820
821/** Joins local stops and shares one upload among concurrent automation requests. */
822export async function stopBrowserRecordingForUpload(
823 tabId: string,
824 upload: (artifact: DesktopPreviewRecordingArtifact, blob: Blob) => Promise<string>,
825): Promise<(DesktopPreviewRecordingArtifact & { uploadedAttachmentId: string }) | null> {
826 const recording = activeRecordings.get(tabId);
827 if (!recording) return null;
828 const artifact = await stopBrowserRecording(tabId);
829 if (!artifact || !recording.savedBlob) return null;
830 recording.uploadPromise ??= upload(artifact, recording.savedBlob);
831 return { ...artifact, uploadedAttachmentId: await recording.uploadPromise };
832}
apps/web/src/browser/browserRecordingUpload.ts created+84
......@@ -0,0 +1,84 @@
1import {
2 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
3 PreviewAutomationRecordingTransferError,
4 PreviewAutomationRecordingTooLargeError,
5 PreviewAutomationRecordingDeadlineExpiredError,
6 type DesktopPreviewRecordingArtifact,
7 type ScopedThreadRef,
8} from "@t3tools/contracts";
9import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
10import {
11 deletePendingAttachmentUpload,
12 runAttachmentUploadCycle,
13} from "@t3tools/client-runtime/state/attachments";
14
15import { appAtomRegistry } from "~/rpc/atomRegistry";
16import { attachmentEnvironment } from "~/state/attachments";
17import { readPreparedConnection } from "~/state/session";
18
19/** Sends the finished encoded file once; capture frames never cross the environment connection. */
20export async function uploadBrowserRecording(
21 { environmentId, threadId }: ScopedThreadRef,
22 artifact: DesktopPreviewRecordingArtifact,
23 blob: Blob,
24 deadlineMs: number,
25): Promise<string> {
26 if (blob.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) {
27 throw new PreviewAutomationRecordingTooLargeError({ threadId });
28 }
29 const result = await runAttachmentUploadCycle({
30 registry: appAtomRegistry,
31 createUploadUrl: attachmentEnvironment.createUploadUrl,
32 remove: attachmentEnvironment.remove,
33 environmentId,
34 upload: {
35 type: "file",
36 name: artifact.path.split(/[\\/]/).at(-1) ?? artifact.id,
37 mimeType: artifact.mimeType,
38 sizeBytes: blob.size,
39 },
40 resolveUploadUrl: (relativeUrl) => {
41 const connection = readPreparedConnection(environmentId);
42 return connection ? resolveAssetUrl(connection.httpBaseUrl, relativeUrl) : null;
43 },
44 transport: (url) => {
45 const controller = new AbortController();
46 // Encoding, saving and minting consume the same request budget. Leave time to reply.
47 const remainingMs = deadlineMs - Date.now() - 1_000;
48 return {
49 abort: () => controller.abort(),
50 done:
51 remainingMs <= 0
52 ? Promise.reject(new Error("Recording transfer deadline expired."))
53 : fetch(url, {
54 method: "POST",
55 headers: { "Content-Type": artifact.mimeType },
56 body: blob,
57 signal: AbortSignal.any([controller.signal, AbortSignal.timeout(remainingMs)]),
58 }).then((response) => {
59 if (!response.ok)
60 throw new Error(`Recording upload rejected (${response.status}).`);
61 }),
62 };
63 },
64 });
65 if (result.status !== "uploaded") {
66 if (result.attachmentId) {
67 deletePendingAttachmentUpload({
68 registry: appAtomRegistry,
69 remove: attachmentEnvironment.remove,
70 environmentId,
71 attachmentId: result.attachmentId,
72 });
73 }
74 const cause = result.status === "failed" ? result.error : undefined;
75 if (Date.now() >= deadlineMs - 1_000) {
76 throw new PreviewAutomationRecordingDeadlineExpiredError({ threadId, cause });
77 }
78 throw new PreviewAutomationRecordingTransferError({
79 threadId,
80 cause,
81 });
82 }
83 return result.attachmentId;
84}
apps/web/src/components/preview/PreviewAutomationHosts.tsx+18-2
......@@ -35,8 +35,10 @@ import {
3535 readActiveBrowserRecordingTargets,
3636 startBrowserRecording,
3737 stopBrowserRecording,
38 stopBrowserRecordingForUpload,
3839} from "~/browser/browserRecording";
3940import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope";
41import { uploadBrowserRecording } from "~/browser/browserRecordingUpload";
4042import {
4143 acquireBrowserSurfaceActivity,
4244 useBrowserSurfaceStore,
......@@ -716,7 +718,18 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
716718 const stopRuntimeTabId =
717719 activeRecordings.find((recording) => recording.serverTabId === stopTabId)
718720 ?.runtimeTabId ?? null;
719 const artifact = stopRuntimeTabId ? await stopBrowserRecording(stopRuntimeTabId) : null;
721 const transferToEnvironment =
722 typeof request.input === "object" &&
723 request.input !== null &&
724 "transferToEnvironment" in request.input &&
725 request.input.transferToEnvironment === true;
726 const artifact = stopRuntimeTabId
727 ? transferToEnvironment
728 ? await stopBrowserRecordingForUpload(stopRuntimeTabId, (saved, blob) =>
729 uploadBrowserRecording(threadRef, saved, blob, hostDeadlineMs),
730 )
731 : await stopBrowserRecording(stopRuntimeTabId)
732 : null;
720733 if (!artifact || !stopTabId) {
721734 return raisePreviewAutomationHostError(
722735 new PreviewAutomationRecordingNotActiveError({
......@@ -727,7 +740,10 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
727740 }),
728741 );
729742 }
730 return { ...artifact, tabId: stopTabId };
743 return {
744 ...artifact,
745 tabId: stopTabId,
746 };
731747 }
732748 }
733749 } catch (cause) {
apps/web/src/components/preview/previewAutomationErrors.ts+9-1
......@@ -2,6 +2,10 @@ import {
22 EnvironmentId,
33 type PreviewAutomationHost,
44 PreviewAutomationOperation,
5 PreviewAutomationRecordingTransferError,
6 PreviewAutomationRecordingDesktopUpdateRequiredError,
7 PreviewAutomationRecordingTooLargeError,
8 PreviewAutomationRecordingDeadlineExpiredError,
59 type PreviewAutomationRequest,
610 type PreviewAutomationResponse,
711 PreviewTabId,
......@@ -206,6 +210,10 @@ export class PreviewAutomationOperationError extends Schema.TaggedErrorClass<Pre
206210}
207211
208212export const PreviewAutomationHostError = Schema.Union([
213 PreviewAutomationRecordingTransferError,
214 PreviewAutomationRecordingDesktopUpdateRequiredError,
215 PreviewAutomationRecordingTooLargeError,
216 PreviewAutomationRecordingDeadlineExpiredError,
209217 PreviewAutomationOverlayTimeoutError,
210218 PreviewAutomationNavigationTimeoutError,
211219 PreviewAutomationViewportTimeoutError,
......@@ -228,7 +236,7 @@ export function serializePreviewAutomationHostError(
228236 ),
229237 );
230238 return {
231 _tag: error.responseTag,
239 _tag: "responseTag" in error ? error.responseTag : error._tag,
232240 message: error.message,
233241 ...(Object.keys(detail).length === 0 ? {} : { detail }),
234242 };
packages/contracts/src/previewAutomation.ts+45
......@@ -553,6 +553,8 @@ export const PreviewAutomationRecordingStatus = Schema.Struct({
553553});
554554export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type;
555555
556export const PREVIEW_RECORDING_STOP_TIMEOUT_MS = 120_000;
557
556558export const PreviewAutomationRecordingArtifact = Schema.Struct({
557559 id: Schema.String,
558560 tabId: PreviewTabId,
......@@ -856,7 +858,50 @@ export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorC
856858 }
857859}
858860
861export class PreviewAutomationRecordingTransferError extends Schema.TaggedErrorClass<PreviewAutomationRecordingTransferError>()(
862 "PreviewAutomationRecordingTransferError",
863 {
864 threadId: ThreadId,
865 cause: Schema.optional(Schema.Defect()),
866 },
867) {
868 override get message(): string {
869 return "Preview recording could not be saved to the agent environment. The saved copy remains on the desktop.";
870 }
871}
872
873export class PreviewAutomationRecordingDesktopUpdateRequiredError extends Schema.TaggedErrorClass<PreviewAutomationRecordingDesktopUpdateRequiredError>()(
874 "PreviewAutomationRecordingDesktopUpdateRequiredError",
875 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
876) {
877 override get message(): string {
878 return "Update the desktop app to transfer recordings. The recording remains on the desktop.";
879 }
880}
881
882export class PreviewAutomationRecordingTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationRecordingTooLargeError>()(
883 "PreviewAutomationRecordingTooLargeError",
884 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
885) {
886 override get message(): string {
887 return "The recording exceeds 50 MiB. The saved copy remains on the desktop.";
888 }
889}
890
891export class PreviewAutomationRecordingDeadlineExpiredError extends Schema.TaggedErrorClass<PreviewAutomationRecordingDeadlineExpiredError>()(
892 "PreviewAutomationRecordingDeadlineExpiredError",
893 { threadId: ThreadId, cause: Schema.optional(Schema.Defect()) },
894) {
895 override get message(): string {
896 return "The recording transfer deadline expired. The saved copy remains on the desktop.";
897 }
898}
899
859900export const PreviewAutomationError = Schema.Union([
901 PreviewAutomationRecordingTransferError,
902 PreviewAutomationRecordingDesktopUpdateRequiredError,
903 PreviewAutomationRecordingTooLargeError,
904 PreviewAutomationRecordingDeadlineExpiredError,
860905 PreviewAutomationUnavailableError,
861906 PreviewAutomationNoAvailableHostError,
862907 PreviewAutomationUnsupportedClientError,