1import {
2 PREVIEW_AUTOMATION_V1_OPERATIONS,
3 PreviewAutomationClientDisconnectedError,
4 PreviewAutomationControlInterruptedError,
5 PreviewAutomationControlReason,
6 PreviewAutomationExecutionError,
7 SERVER_BROWSER_AUTOMATION_CLIENT_ID,
8 PreviewAutomationInvalidSelectorError,
9 PreviewAutomationMalformedResponseError,
10 PreviewAutomationNoAvailableHostError,
11 PreviewAutomationRemoteUnavailableError,
12 PreviewAutomationRecordingTransferError,
13 PreviewAutomationRecordingDesktopUpdateRequiredError,
14 PreviewAutomationRecordingTooLargeError,
15 PreviewAutomationRecordingDeadlineExpiredError,
16 PreviewAutomationRequestQueueClosedError,
17 PreviewAutomationResultTooLargeError,
18 PreviewAutomationTabNotFoundError,
19 PreviewAutomationTargetNotEditableError,
20 PreviewAutomationTimeoutError,
21 PreviewAutomationUnsupportedClientError,
22 PreviewTabId,
23 type PreviewAutomationError,
24 type PreviewAutomationOperation,
25 type PreviewAutomationHost,
26 type PreviewAutomationHostFocus,
27 type PreviewAutomationResponse,
28 type PreviewAutomationStreamEvent,
29} from "@t3tools/contracts";
30import * as Context from "effect/Context";
31import type * as Cause from "effect/Cause";
32import * as SecureRandom from "effect/crypto/SecureRandom";
33import * as Deferred from "effect/Deferred";
34import * as Effect from "effect/Effect";
35import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
37import * as Queue from "effect/Queue";
38import * as Schema from "effect/Schema";
39import * as Stream from "effect/Stream";
40import * as SynchronizedRef from "effect/SynchronizedRef";
41
42import * as McpInvocationContext from "./McpInvocationContext.ts";
43
44const HOST_RESPONSE_GRACE_MS = 1_000;
45
46export interface PreviewAutomationInvokeInput {
47 readonly scope: McpInvocationContext.McpThreadInvocationScope;
48 readonly operation: PreviewAutomationOperation;
49 readonly input: unknown;
50 readonly tabId?: PreviewTabId;
51 readonly timeoutMs?: number;
52 /** Background metadata reads must not change the agent's current tab. */
53 readonly updateCurrentTab?: boolean;
54 readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void;
55}
56
57export interface PreviewAutomationConnectOptions {
58 readonly preferred?: boolean;
59}
60
61export class PreviewAutomationBroker extends Context.Service<
62 PreviewAutomationBroker,
63 {
64 readonly connect: (
65 host: PreviewAutomationHost,
66 options?: PreviewAutomationConnectOptions,
67 ) => Effect.Effect<Stream.Stream<PreviewAutomationStreamEvent>>;
68 readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect<void>;
69 readonly respond: (
70 response: PreviewAutomationResponse,
71 ) => Effect.Effect<void, PreviewAutomationError>;
72 readonly invoke: <A = unknown>(
73 request: PreviewAutomationInvokeInput,
74 ) => Effect.Effect<A, PreviewAutomationError>;
75 }
76>()("t3/mcp/PreviewAutomationBroker") {}
77
78interface ClientConnection {
79 readonly clientId: string;
80 readonly connectionId: string;
81 readonly environmentId: PreviewAutomationHost["environmentId"];
82 readonly supportedOperations: ReadonlySet<PreviewAutomationOperation>;
83 readonly focused: boolean;
84 readonly preferred: boolean;
85 readonly liveTabs: NonNullable<PreviewAutomationHostFocus["liveTabs"]>;
86 readonly focusOrder: number;
87 readonly queue: Queue.Queue<PreviewAutomationStreamEvent, Cause.Done>;
88}
89
90interface PendingRequest {
91 readonly queue: ClientConnection["queue"];
92 readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>;
93 readonly context: PreviewAutomationRequestErrorContext;
94}
95
96/** A lease belongs to this exact connectionId/queue; replacing the host invalidates it. */
97interface HostAssignment {
98 readonly clientId: ClientConnection["clientId"];
99 readonly connectionId: ClientConnection["connectionId"];
100 readonly queue: ClientConnection["queue"];
101 readonly tabId?: PreviewTabId;
102 readonly tabSequence?: number;
103}
104
105interface PreviewAutomationRequestErrorContext {
106 readonly operation: PreviewAutomationOperation;
107 readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"];
108 readonly threadId: McpInvocationContext.McpThreadCaller["threadId"];
109 readonly providerSessionId: string;
110 readonly providerInstanceId: McpInvocationContext.McpThreadCaller["providerInstanceId"];
111 readonly clientId: string;
112 readonly connectionId: ClientConnection["connectionId"];
113 readonly requestId: string;
114 readonly tabId?: PreviewTabId;
115 readonly timeoutMs: number;
116 readonly selectorKind?: "locator" | "selector";
117 readonly selectorLength?: number;
118}
119
120interface BrokerState {
121 readonly clients: ReadonlyMap<string, ClientConnection>;
122 readonly assignments: ReadonlyMap<string, HostAssignment>;
123 readonly pending: ReadonlyMap<string, PendingRequest>;
124 readonly requestSequence: number;
125 readonly focusSequence: number;
126}
127
128const removeConnectionFromState = (
129 current: BrokerState,
130 clientId: string,
131 queue: ClientConnection["queue"],
132): { readonly state: BrokerState; readonly disconnected: ReadonlyArray<PendingRequest> } => {
133 const clients = new Map(current.clients);
134 const assignments = new Map(current.assignments);
135 const pending = new Map(current.pending);
136 const disconnected: PendingRequest[] = [];
137 if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId);
138 for (const [assignmentKey, assignment] of assignments) {
139 if (assignment.queue === queue) assignments.delete(assignmentKey);
140 }
141 for (const [requestId, entry] of pending) {
142 if (entry.queue !== queue) continue;
143 pending.delete(requestId);
144 disconnected.push(entry);
145 }
146 return {
147 state: { ...current, clients, assignments, pending },
148 disconnected,
149 };
150};
151
152const selectorDiagnosticsFromInput = (
153 input: unknown,
154): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => {
155 if (typeof input !== "object" || input === null) return {};
156 if ("locator" in input && typeof input.locator === "string") {
157 return { selectorKind: "locator", selectorLength: input.locator.length };
158 }
159 if ("selector" in input && typeof input.selector === "string") {
160 return { selectorKind: "selector", selectorLength: input.selector.length };
161 }
162 return {};
163};
164
165/** Codex/OpenCode share sessions across threads; include thread id to isolate tabs and ownership. */
166const hostAssignmentKey = (scope: McpInvocationContext.McpThreadInvocationScope): string =>
167 `${scope.environmentId}\u0000${scope.thread.providerSessionId}\u0000${scope.thread.threadId}`;
168
169const isPreviewTabId = Schema.is(PreviewTabId);
170const decodeControlReason = Schema.decodeUnknownOption(PreviewAutomationControlReason);
171
172const readResultTabId = (result: unknown): PreviewTabId | null | undefined => {
173 if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined;
174 const tabId = result.tabId;
175 return tabId === null || isPreviewTabId(tabId) ? tabId : undefined;
176};
177
178const supportsOperation = (
179 connection: ClientConnection,
180 operation: PreviewAutomationOperation,
181): boolean => connection.supportedOperations.has(operation);
182
183type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean";
184
185function remoteDetailKind(detail: unknown): RemoteDetailKind {
186 if (detail === null) return "null";
187 if (Array.isArray(detail)) return "array";
188 switch (typeof detail) {
189 case "string":
190 return "string";
191 case "number":
192 return "number";
193 case "boolean":
194 return "boolean";
195 default:
196 return "object";
197 }
198}
199
200const readRemoteTimeoutMs = (detail: unknown): number | undefined => {
201 if (typeof detail !== "object" || detail === null || !("timeoutMs" in detail)) return undefined;
202 const timeoutMs = detail.timeoutMs;
203 return typeof timeoutMs === "number" && Number.isInteger(timeoutMs) && timeoutMs > 0
204 ? timeoutMs
205 : undefined;
206};
207
208const readRemoteReason = (detail: unknown): string | undefined =>
209 typeof detail === "object" &&
210 detail !== null &&
211 "reason" in detail &&
212 typeof detail.reason === "string"
213 ? detail.reason
214 : undefined;
215
216const MAX_REASON_CHARS = 500;
217
218const classifyResponseError = (
219 context: PreviewAutomationRequestErrorContext,
220 error: NonNullable<PreviewAutomationResponse["error"]>,
221): PreviewAutomationError => {
222 const remoteDiagnostics = {
223 remoteTag: error._tag,
224 remoteMessageLength: error.message.length,
225 ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }),
226 cause: error,
227 };
228 switch (error._tag) {
229 case "PreviewAutomationRecordingDesktopUpdateRequiredError":
230 return new PreviewAutomationRecordingDesktopUpdateRequiredError({
231 threadId: context.threadId,
232 cause: error,
233 });
234 case "PreviewAutomationRecordingTooLargeError":
235 return new PreviewAutomationRecordingTooLargeError({
236 threadId: context.threadId,
237 cause: error,
238 });
239 case "PreviewAutomationRecordingDeadlineExpiredError":
240 return new PreviewAutomationRecordingDeadlineExpiredError({
241 threadId: context.threadId,
242 cause: error,
243 });
244 case "PreviewAutomationRecordingTransferError":
245 return new PreviewAutomationRecordingTransferError({
246 threadId: context.threadId,
247 cause: error,
248 });
249 case "PreviewAutomationNoAvailableHostError":
250 return new PreviewAutomationNoAvailableHostError({
251 ...context,
252 ...remoteDiagnostics,
253 });
254 case "PreviewAutomationUnsupportedClientError":
255 return new PreviewAutomationUnsupportedClientError({
256 ...context,
257 ...remoteDiagnostics,
258 });
259 case "PreviewAutomationTabNotFoundError":
260 return new PreviewAutomationTabNotFoundError({
261 ...context,
262 ...remoteDiagnostics,
263 });
264 case "PreviewAutomationTimeoutError": {
265 const remoteTimeoutMs = readRemoteTimeoutMs(error.detail);
266 const remoteReason = readRemoteReason(error.detail);
267 return new PreviewAutomationTimeoutError({
268 ...context,
269 ...remoteDiagnostics,
270 ...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID && remoteReason !== undefined
271 ? { reason: remoteReason.slice(0, MAX_REASON_CHARS) }
272 : {}),
273 ...(remoteTimeoutMs === undefined
274 ? {}
275 : { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }),
276 });
277 }
278 case "PreviewAutomationControlInterruptedError": {
279 const reason = decodeControlReason(error.detail);
280 return new PreviewAutomationControlInterruptedError({
281 ...context,
282 ...remoteDiagnostics,
283 ...(Option.isSome(reason) ? { reason: reason.value } : {}),
284 });
285 }
286 case "PreviewAutomationInvalidSelectorError": {
287 const staleRef =
288 typeof error.detail === "object" &&
289 error.detail !== null &&
290 "staleRef" in error.detail &&
291 error.detail.staleRef === true;
292 return new PreviewAutomationInvalidSelectorError({
293 ...context,
294 ...remoteDiagnostics,
295 ...(staleRef ? { staleRef } : {}),
296 });
297 }
298 case "PreviewAutomationTargetNotEditableError": {
299 const detail =
300 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
301 const remoteSelectorKind =
302 detail &&
303 "selectorKind" in detail &&
304 (detail.selectorKind === "focused-element" ||
305 detail.selectorKind === "locator" ||
306 detail.selectorKind === "selector")
307 ? detail.selectorKind
308 : undefined;
309 const remoteSelectorLength =
310 detail &&
311 "selectorLength" in detail &&
312 typeof detail.selectorLength === "number" &&
313 Number.isInteger(detail.selectorLength) &&
314 detail.selectorLength >= 0
315 ? detail.selectorLength
316 : undefined;
317 return new PreviewAutomationTargetNotEditableError({
318 ...context,
319 ...remoteDiagnostics,
320 ...(remoteSelectorKind === undefined && context.selectorKind === undefined
321 ? {}
322 : { selectorKind: remoteSelectorKind ?? context.selectorKind }),
323 ...(remoteSelectorLength === undefined && context.selectorLength === undefined
324 ? {}
325 : { selectorLength: remoteSelectorLength ?? context.selectorLength }),
326 });
327 }
328 case "PreviewAutomationResultTooLargeError": {
329 const detail =
330 typeof error.detail === "object" && error.detail !== null ? error.detail : undefined;
331 const maximumBytes =
332 detail &&
333 "maximumBytes" in detail &&
334 typeof detail.maximumBytes === "number" &&
335 Number.isInteger(detail.maximumBytes) &&
336 detail.maximumBytes > 0
337 ? detail.maximumBytes
338 : undefined;
339 return new PreviewAutomationResultTooLargeError({
340 ...context,
341 ...remoteDiagnostics,
342 ...(maximumBytes === undefined ? {} : { maximumBytes }),
343 });
344 }
345 case "PreviewAutomationUnavailableError":
346 return new PreviewAutomationRemoteUnavailableError({
347 ...context,
348 ...remoteDiagnostics,
349 });
350 default:
351 return new PreviewAutomationExecutionError({
352 ...context,
353 ...remoteDiagnostics,
354 ...(context.clientId === SERVER_BROWSER_AUTOMATION_CLIENT_ID
355 ? { reason: error.message.slice(0, MAX_REASON_CHARS) }
356 : {}),
357 });
358 }
359};
360
361export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
362 const random = yield* SecureRandom.SecureRandom;
363 const state = yield* SynchronizedRef.make<BrokerState>({
364 clients: new Map(),
365 assignments: new Map(),
366 pending: new Map(),
367 requestSequence: 0,
368 focusSequence: 0,
369 });
370
371 const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* (
372 queue: ClientConnection["queue"],
373 disconnected: ReadonlyArray<PendingRequest>,
374 completeStream = false,
375 ) {
376 if (completeStream) {
377 // Close the evicted generation's RPC stream so a responsive desktop can re-register.
378 yield* Queue.clear(queue);
379 yield* Queue.end(queue);
380 } else {
381 // Replaced registrations must not reconnect and displace their successor.
382 yield* Queue.shutdown(queue);
383 }
384 yield* Effect.forEach(
385 disconnected,
386 ({ deferred, context }) =>
387 Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)),
388 { discard: true },
389 );
390 });
391
392 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
393 clientId: string,
394 queue: ClientConnection["queue"],
395 completeStream = false,
396 ) {
397 yield* SynchronizedRef.modifyEffect(state, (current) => {
398 if (current.clients.get(clientId)?.queue !== queue) {
399 return Effect.succeed([undefined, current] as const);
400 }
401 const removed = removeConnectionFromState(current, clientId, queue);
402 return closeConnection(queue, removed.disconnected, completeStream).pipe(
403 Effect.as([undefined, removed.state] as const),
404 );
405 });
406 });
407
408 const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* (
409 host: PreviewAutomationHost,
410 options: PreviewAutomationConnectOptions | undefined,
411 ) {
412 const clientId = host.clientId;
413 const queue = yield* Queue.unbounded<PreviewAutomationStreamEvent, Cause.Done>();
414 const connectionId = yield* random.uuidV4();
415 yield* Queue.offer(queue, { type: "connected", connectionId });
416 const connection: ClientConnection = {
417 clientId,
418 connectionId,
419 environmentId: host.environmentId,
420 supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS),
421 focused: false,
422 preferred: options?.preferred ?? false,
423 liveTabs: [],
424 focusOrder: 0,
425 queue,
426 };
427 const registration = yield* SynchronizedRef.modify(state, (current) => {
428 const previousConnection = current.clients.get(clientId);
429 const removed = previousConnection
430 ? removeConnectionFromState(current, clientId, previousConnection.queue)
431 : { state: current, disconnected: [] };
432 const clients = new Map(removed.state.clients);
433 const focusSequence = removed.state.focusSequence + 1;
434 const registeredConnection = { ...connection, focusOrder: focusSequence };
435 clients.set(clientId, registeredConnection);
436 return [
437 {
438 previousConnection,
439 disconnected: removed.disconnected,
440 registeredConnection,
441 },
442 { ...removed.state, clients, focusSequence },
443 ] as const;
444 });
445 if (registration.previousConnection) {
446 yield* closeConnection(registration.previousConnection.queue, registration.disconnected);
447 }
448 return registration.registeredConnection;
449 });
450
451 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
452 "PreviewAutomationBroker.connect",
453 )((host, options) =>
454 Effect.succeed(
455 Stream.unwrap(
456 Effect.acquireRelease(acquireConnection(host, options), (connection) =>
457 disconnect(connection.clientId, connection.queue),
458 ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))),
459 ),
460 ),
461 );
462
463 const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn(
464 "PreviewAutomationBroker.focusHost",
465 )(function* (host) {
466 yield* SynchronizedRef.update(state, (current) => {
467 const currentHost = current.clients.get(host.clientId);
468 if (
469 !currentHost ||
470 currentHost.environmentId !== host.environmentId ||
471 currentHost.connectionId !== host.connectionId
472 ) {
473 return current;
474 }
475 const clients = new Map(current.clients);
476 const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence;
477 clients.set(host.clientId, {
478 ...currentHost,
479 focused: host.focused,
480 liveTabs: host.liveTabs ?? currentHost.liveTabs,
481 focusOrder: host.focused ? focusSequence : currentHost.focusOrder,
482 });
483 return { ...current, clients, focusSequence };
484 });
485 });
486
487 const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn(
488 "PreviewAutomationBroker.respond",
489 )(function* (response) {
490 const pending = yield* SynchronizedRef.modify(state, (current) => {
491 const entry = current.pending.get(response.requestId);
492 if (
493 !entry ||
494 entry.context.clientId !== response.clientId ||
495 entry.context.connectionId !== response.connectionId
496 ) {
497 return [undefined, current] as const;
498 }
499 const next = new Map(current.pending);
500 next.delete(response.requestId);
501 return [entry, { ...current, pending: next }] as const;
502 });
503 if (!pending) return;
504 if (response.ok) {
505 yield* Deferred.succeed(pending.deferred, response.result);
506 } else {
507 yield* Deferred.fail(
508 pending.deferred,
509 response.error
510 ? classifyResponseError(pending.context, response.error)
511 : new PreviewAutomationMalformedResponseError(pending.context),
512 );
513 }
514 });
515
516 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
517 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
518 ): Effect.fn.Return<A, PreviewAutomationError> {
519 const deferred = yield* Deferred.make<unknown, PreviewAutomationError>();
520 const route = yield* SynchronizedRef.modify(state, (current) => {
521 const assignments = new Map(
522 Array.from(current.assignments).filter(([, assignment]) => {
523 const connection = current.clients.get(assignment.clientId);
524 return (
525 connection?.connectionId === assignment.connectionId &&
526 connection.queue === assignment.queue
527 );
528 }),
529 );
530 const assignmentKey = hostAssignmentKey(input.scope);
531 const assigned = assignments.get(assignmentKey);
532 const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined;
533 const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId;
534 // Pin interactions to one desktop runtime so cookie/DOM state cannot jump between hosts.
535 const ownsTargetTab = (host: ClientConnection, visibleOnly = false) =>
536 host.liveTabs.some(
537 (tab) =>
538 tab.threadId === input.scope.thread.threadId &&
539 (!visibleOnly || tab.visible === true) &&
540 (input.tabId === undefined || tab.tabId === input.tabId),
541 );
542 const connection =
543 hasLiveAssignment && supportsOperation(assignedConnection, input.operation)
544 ? assignedConnection
545 : hasLiveAssignment
546 ? undefined
547 : Array.from(current.clients.values())
548 .filter(
549 (host) =>
550 host.environmentId === input.scope.environmentId &&
551 supportsOperation(host, input.operation),
552 )
553 .sort(
554 (left, right) =>
555 Number(input.tabId !== undefined && ownsTargetTab(right)) -
556 Number(input.tabId !== undefined && ownsTargetTab(left)) ||
557 Number(right.preferred) - Number(left.preferred) ||
558 Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) ||
559 Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) ||
560 Number(right.focused) - Number(left.focused) ||
561 right.focusOrder - left.focusOrder,
562 )[0];
563 if (!connection) {
564 if (!hasLiveAssignment) assignments.delete(assignmentKey);
565 return [undefined, { ...current, assignments }] as const;
566 }
567 // First open may install Chromium for ten minutes; keep it alive without replaying effects.
568 const timeoutMs =
569 input.timeoutMs ?? (input.operation === "open" && connection.preferred ? 660_000 : 15_000);
570 const canReuseAssignedTab =
571 assigned !== undefined &&
572 assigned.connectionId === connection.connectionId &&
573 assigned.queue === connection.queue;
574 assignments.set(assignmentKey, {
575 clientId: connection.clientId,
576 connectionId: connection.connectionId,
577 queue: connection.queue,
578 ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}),
579 ...(canReuseAssignedTab && assigned.tabSequence !== undefined
580 ? { tabSequence: assigned.tabSequence }
581 : {}),
582 });
583
584 const requestSequence = current.requestSequence;
585 const requestId = `preview-${requestSequence}`;
586 const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined);
587 const selectorDiagnostics = selectorDiagnosticsFromInput(input.input);
588 const context: PreviewAutomationRequestErrorContext = {
589 operation: input.operation,
590 environmentId: input.scope.environmentId,
591 threadId: input.scope.thread.threadId,
592 providerSessionId: input.scope.thread.providerSessionId,
593 providerInstanceId: input.scope.thread.providerInstanceId,
594 clientId: connection.clientId,
595 connectionId: connection.connectionId,
596 requestId,
597 ...(tabId === undefined ? {} : { tabId }),
598 timeoutMs,
599 ...selectorDiagnostics,
600 };
601 const pending = new Map(current.pending);
602 pending.set(requestId, { queue: connection.queue, deferred, context });
603 return [
604 { connection, requestId, requestContext: context, requestSequence },
605 { ...current, assignments, pending, requestSequence: current.requestSequence + 1 },
606 ] as const;
607 });
608 if (!route) {
609 return yield* new PreviewAutomationNoAvailableHostError({
610 operation: input.operation,
611 environmentId: input.scope.environmentId,
612 threadId: input.scope.thread.threadId,
613 providerSessionId: input.scope.thread.providerSessionId,
614 providerInstanceId: input.scope.thread.providerInstanceId,
615 });
616 }
617 const { connection, requestId, requestContext, requestSequence } = route;
618 const { timeoutMs } = requestContext;
619 input.onTargetTab?.(requestContext.tabId);
620 const removePending = SynchronizedRef.update(state, (next) => {
621 if (!next.pending.has(requestId)) return next;
622 const pending = new Map(next.pending);
623 pending.delete(requestId);
624 return { ...next, pending };
625 });
626 const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () {
627 const offered = yield* SynchronizedRef.modifyEffect(state, (current) => {
628 // Serialize generation checks and offers with queue closure; routes can outlive eviction.
629 if (
630 current.clients.get(connection.clientId)?.queue !== connection.queue ||
631 !current.pending.has(requestId)
632 ) {
633 return Effect.succeed([false, current] as const);
634 }
635 return Queue.offer(connection.queue, {
636 type: "request",
637 connectionId: connection.connectionId,
638 request: {
639 requestId,
640 threadId: input.scope.thread.threadId,
641 tabId: requestContext.tabId,
642 tabIdExplicit: input.tabId !== undefined,
643 agentSessionId: hostAssignmentKey(input.scope),
644 operation: input.operation,
645 input: input.input,
646 timeoutMs,
647 },
648 }).pipe(Effect.map((offered) => [offered, current] as const));
649 });
650 if (!offered) {
651 const completion = yield* Deferred.poll(deferred);
652 if (Option.isSome(completion)) {
653 return (yield* completion.value) as A;
654 }
655 return yield* new PreviewAutomationRequestQueueClosedError(requestContext);
656 }
657 // The browser timer starts on delivery; allow its timeout response before evicting the host.
658 const responseTimeoutMs =
659 input.updateCurrentTab === false ? timeoutMs : timeoutMs + HOST_RESPONSE_GRACE_MS;
660 const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(responseTimeoutMs));
661 return yield* Option.match(result, {
662 // The enclosing operation owns the span for this inline handler.
663 onNone: Effect.fnUntraced(function* () {
664 // Never replay possibly applied actions; slow metadata reads must not evict the host.
665 // The in-process server browser is shared by every thread and cannot hang its stream.
666 if (
667 input.updateCurrentTab !== false &&
668 connection.clientId !== SERVER_BROWSER_AUTOMATION_CLIENT_ID
669 ) {
670 yield* disconnect(connection.clientId, connection.queue, true);
671 }
672 return yield* new PreviewAutomationTimeoutError(requestContext);
673 }),
674 onSome: (value) => Effect.succeed(value as A),
675 });
676 });
677 const result = yield* awaitResponse().pipe(Effect.ensuring(removePending));
678 if (input.updateCurrentTab === false) return result;
679 const responseTabId = readResultTabId(result);
680 const resultTabId = responseTabId === undefined ? input.tabId : responseTabId;
681 if (resultTabId === undefined) return result;
682 const assignmentKey = hostAssignmentKey(input.scope);
683 yield* SynchronizedRef.update(state, (current) => {
684 const assignment = current.assignments.get(assignmentKey);
685 if (
686 !assignment ||
687 assignment.connectionId !== connection.connectionId ||
688 assignment.queue !== connection.queue ||
689 (assignment.tabSequence ?? -1) > requestSequence
690 ) {
691 return current;
692 }
693 const assignments = new Map(current.assignments);
694 if (resultTabId === null) {
695 const { tabId: _tabId, ...withoutTabId } = assignment;
696 assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence });
697 } else {
698 assignments.set(assignmentKey, {
699 ...assignment,
700 ...(resultTabId === undefined ? {} : { tabId: resultTabId }),
701 tabSequence: requestSequence,
702 });
703 }
704 return { ...current, assignments };
705 });
706 return result;
707 });
708
709 return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke });
710}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
711
712export const layer = Layer.effect(PreviewAutomationBroker, make);