import { PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationNoFocusedOwnerError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, PreviewAutomationUnavailableError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, type PreviewAutomationOwner, type PreviewAutomationOwnerIdentity, type PreviewAutomationRequest, type PreviewAutomationResponse, type PreviewTabId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as McpInvocationContext from "./McpInvocationContext.ts"; export interface PreviewAutomationInvokeInput { readonly scope: McpInvocationContext.McpInvocationScope; readonly operation: PreviewAutomationOperation; readonly input: unknown; readonly tabId?: PreviewTabId; readonly timeoutMs?: number; } export class PreviewAutomationBroker extends Context.Service< PreviewAutomationBroker, { readonly connect: ( owner: PreviewAutomationOwner, ) => Effect.Effect>; readonly reportOwner: ( owner: PreviewAutomationOwner, ) => Effect.Effect; readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect; readonly respond: ( response: PreviewAutomationResponse, ) => Effect.Effect; readonly invoke: ( request: PreviewAutomationInvokeInput, ) => Effect.Effect; } >()("t3/mcp/PreviewAutomationBroker") {} interface ClientConnection { readonly clientId: string; readonly queue: Queue.Queue; } interface PendingRequest { readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; } interface BrokerState { readonly clients: ReadonlyMap; readonly owners: ReadonlyMap; readonly pending: ReadonlyMap; readonly requestSequence: number; } const makeResponseError = ( error: NonNullable, ): PreviewAutomationError => { switch (error._tag) { case "PreviewAutomationNoFocusedOwnerError": return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); case "PreviewAutomationUnsupportedClientError": return new PreviewAutomationUnsupportedClientError({ message: error.message }); case "PreviewAutomationTabNotFoundError": return new PreviewAutomationTabNotFoundError({ message: error.message }); case "PreviewAutomationTimeoutError": return new PreviewAutomationTimeoutError({ message: error.message }); case "PreviewAutomationControlInterruptedError": return new PreviewAutomationControlInterruptedError({ message: error.message }); case "PreviewAutomationInvalidSelectorError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; return new PreviewAutomationInvalidSelectorError({ message: error.message, selector: detail && "selector" in detail && typeof detail.selector === "string" ? detail.selector : "", }); } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; return new PreviewAutomationResultTooLargeError({ message: error.message, maximumBytes: detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" ? detail.maximumBytes : 64_000, }); } case "PreviewAutomationUnavailableError": return new PreviewAutomationUnavailableError({ message: error.message }); default: return new PreviewAutomationExecutionError({ message: error.message, detail: error.detail, }); } }; export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const state = yield* SynchronizedRef.make({ clients: new Map(), owners: new Map(), pending: new Map(), requestSequence: 0, }); const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( clientId: string, queue: ClientConnection["queue"], ) { const toFail = yield* SynchronizedRef.modify(state, (current) => { const clients = new Map(current.clients); const owners = new Map(current.owners); const pending = new Map(current.pending); const disconnected: PendingRequest[] = []; if (current.clients.get(clientId)?.queue === queue) { clients.delete(clientId); owners.delete(clientId); } for (const [requestId, entry] of pending) { if (entry.queue === queue) { pending.delete(requestId); disconnected.push(entry); } } return [disconnected, { ...current, clients, owners, pending }] as const; }); yield* Effect.forEach( toFail, ({ deferred }) => Deferred.fail( deferred, new PreviewAutomationUnavailableError({ message: "The preview automation client disconnected.", }), ), { discard: true }, ); yield* Queue.shutdown(queue); }); const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( "PreviewAutomationBroker.connect", )(function* (owner) { const clientId = owner.clientId; const queue = yield* Queue.unbounded(); const previous = yield* SynchronizedRef.modify(state, (current) => { const clients = new Map(current.clients); const owners = new Map(current.owners); const existingOwner = current.owners.get(clientId); clients.set(clientId, { clientId, queue }); owners.set( clientId, existingOwner?.environmentId === owner.environmentId && existingOwner.threadId === owner.threadId ? { ...existingOwner, supportsAutomation: owner.supportsAutomation } : owner, ); return [current.clients.get(clientId), { ...current, clients, owners }] as const; }); if (previous) yield* disconnect(clientId, previous.queue); return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); }); const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn( "PreviewAutomationBroker.reportOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { const owners = new Map(current.owners); owners.set(owner.clientId, owner); return { ...current, owners }; }); }); const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn( "PreviewAutomationBroker.clearOwner", )(function* (owner) { yield* SynchronizedRef.update(state, (current) => { const currentOwner = current.owners.get(owner.clientId); if ( !currentOwner || currentOwner.environmentId !== owner.environmentId || currentOwner.threadId !== owner.threadId ) { return current; } const owners = new Map(current.owners); owners.delete(owner.clientId); return { ...current, owners }; }); }); const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( "PreviewAutomationBroker.respond", )(function* (response) { const pending = yield* SynchronizedRef.modify(state, (current) => { const entry = current.pending.get(response.requestId); if (!entry) return [undefined, current] as const; const next = new Map(current.pending); next.delete(response.requestId); return [entry, { ...current, pending: next }] as const; }); if (!pending) return; if (response.ok) { yield* Deferred.succeed(pending.deferred, response.result); } else { yield* Deferred.fail( pending.deferred, response.error ? makeResponseError(response.error) : new PreviewAutomationExecutionError({ message: "Preview automation failed without an error payload.", }), ); } }); const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( input: Parameters[0], ): Effect.fn.Return { const current = yield* SynchronizedRef.get(state); const candidates = Array.from(current.owners.values()) .filter( (owner) => owner.environmentId === input.scope.environmentId && owner.threadId === input.scope.threadId && owner.supportsAutomation, ) .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); if (!owner) { if (candidates.length > 0) { return yield* new PreviewAutomationUnavailableError({ message: "The browser host is not connected.", }); } return yield* new PreviewAutomationNoFocusedOwnerError({ message: "No desktop browser host is available for this thread.", }); } const connection = current.clients.get(owner.clientId); if (!connection) { return yield* new PreviewAutomationUnavailableError({ message: "The browser host is not connected.", }); } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); const requestId = yield* SynchronizedRef.modify(state, (next) => { const requestId = `preview-${next.requestSequence}`; const pending = new Map(next.pending); pending.set(requestId, { queue: connection.queue, deferred }); return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; }); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); pending.delete(requestId); return { ...next, pending }; }); const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { const offered = yield* Queue.offer(connection.queue, { requestId, threadId: input.scope.threadId, tabId: input.tabId ?? owner.tabId ?? undefined, operation: input.operation, input: input.input, timeoutMs, }); if (!offered) { return yield* new PreviewAutomationUnavailableError({ message: "The preview automation client is no longer accepting requests.", }); } const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); return yield* Option.match(result, { onNone: () => Effect.fail( new PreviewAutomationTimeoutError({ message: `Preview automation timed out after ${timeoutMs}ms.`, }), ), onSome: (value) => Effect.succeed(value as A), }); }); return yield* awaitResponse().pipe(Effect.ensuring(removePending)); }); return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); }).pipe(Effect.withSpan("PreviewAutomationBroker.make")); export const layer = Layer.effect(PreviewAutomationBroker, make);