diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 5631b3bef57..9f7ef2113d7 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -1,8 +1,10 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + PreviewAutomationClientDisconnectedError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, - PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, type PreviewAutomationOwner, @@ -59,6 +61,95 @@ it.effect("atomically registers a connected owner and correlates its response", ), ); +it.effect("preserves bounded request and remote selector diagnostics", () => { + const locator = "role=button[name='request-secret']"; + const remoteMessage = "Unexpected token near remote-secret."; + const remoteError = { + _tag: "PreviewAutomationInvalidSelectorError", + message: remoteMessage, + detail: { selector: "role=button[name='remote-secret']" }, + } as const; + + return Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + requestId: request.requestId, + ok: false, + error: remoteError, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ + scope, + operation: "click", + input: { locator }, + timeoutMs: 1_234, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); + expect(error).toMatchObject({ + operation: "click", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + tabId: "tab-1", + timeoutMs: 1_234, + selectorKind: "locator", + selectorLength: locator.length, + remoteTag: "PreviewAutomationInvalidSelectorError", + remoteMessageLength: remoteMessage.length, + remoteDetailKind: "object", + }); + expect(error.message).toBe( + `Preview automation click received an invalid locator (${locator.length} characters).`, + ); + expect(error.message).not.toContain("secret"); + expect(error.cause).toBe(remoteError); + expect("selector" in error).toBe(false); + expect("remoteMessage" in error).toBe(false); + expect("remoteDetail" in error).toBe(false); + }), + ); +}); + +it.effect("distinguishes malformed remote failures", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.make; + const requests = yield* broker.connect(makeOwner()); + yield* Stream.runForEach(requests, (request) => + broker.respond({ requestId: request.requestId, ok: false }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const error = yield* broker + .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 2_000, + }); + }), + ), +); + it.effect("rejects calls when no focused owner exists", () => Effect.gen(function* () { const broker = yield* PreviewAutomationBroker.make; @@ -66,6 +157,13 @@ it.effect("rejects calls when no focused owner exists", () => .invoke({ scope, operation: "status", input: {} }) .pipe(Effect.flip); expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + }); }), ); @@ -162,7 +260,17 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec const _replacementRequests = yield* broker.connect(makeOwner()); const error = yield* Fiber.join(pending); - expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); + expect(error).toMatchObject({ + operation: "status", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + clientId: "client-1", + requestId: "preview-0", + timeoutMs: 15_000, + }); }), ), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index ee9d5bdbd0d..a2bdb95f061 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -1,12 +1,16 @@ import { + PreviewAutomationClientDisconnectedError, PreviewAutomationControlInterruptedError, PreviewAutomationExecutionError, + PreviewAutomationHostNotConnectedError, PreviewAutomationInvalidSelectorError, + PreviewAutomationMalformedResponseError, PreviewAutomationNoFocusedOwnerError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationRequestQueueClosedError, PreviewAutomationResultTooLargeError, PreviewAutomationTabNotFoundError, PreviewAutomationTimeoutError, - PreviewAutomationUnavailableError, PreviewAutomationUnsupportedClientError, type PreviewAutomationError, type PreviewAutomationOperation, @@ -62,6 +66,21 @@ interface ClientConnection { interface PendingRequest { readonly queue: ClientConnection["queue"]; readonly deferred: Deferred.Deferred; + readonly context: PreviewAutomationRequestErrorContext; +} + +interface PreviewAutomationRequestErrorContext { + readonly operation: PreviewAutomationOperation; + readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; + readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; + readonly providerSessionId: string; + readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; + readonly clientId: string; + readonly requestId: string; + readonly tabId?: PreviewTabId; + readonly timeoutMs: number; + readonly selectorKind?: "locator" | "selector"; + readonly selectorLength?: number; } interface BrokerState { @@ -71,48 +90,104 @@ interface BrokerState { readonly requestSequence: number; } -const makeResponseError = ( +const selectorDiagnosticsFromInput = ( + input: unknown, +): Pick => { + if (typeof input !== "object" || input === null) return {}; + if ("locator" in input && typeof input.locator === "string") { + return { selectorKind: "locator", selectorLength: input.locator.length }; + } + if ("selector" in input && typeof input.selector === "string") { + return { selectorKind: "selector", selectorLength: input.selector.length }; + } + return {}; +}; + +type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; + +function remoteDetailKind(detail: unknown): RemoteDetailKind { + if (detail === null) return "null"; + if (Array.isArray(detail)) return "array"; + switch (typeof detail) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + default: + return "object"; + } +} + +const classifyResponseError = ( + context: PreviewAutomationRequestErrorContext, error: NonNullable, ): PreviewAutomationError => { + const remoteDiagnostics = { + remoteTag: error._tag, + remoteMessageLength: error.message.length, + ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }), + cause: error, + }; switch (error._tag) { case "PreviewAutomationNoFocusedOwnerError": - return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); + return new PreviewAutomationNoFocusedOwnerError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationUnsupportedClientError": - return new PreviewAutomationUnsupportedClientError({ message: error.message }); + return new PreviewAutomationUnsupportedClientError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTabNotFoundError": - return new PreviewAutomationTabNotFoundError({ message: error.message }); + return new PreviewAutomationTabNotFoundError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationTimeoutError": - return new PreviewAutomationTimeoutError({ message: error.message }); + return new PreviewAutomationTimeoutError({ + ...context, + ...remoteDiagnostics, + }); case "PreviewAutomationControlInterruptedError": - return new PreviewAutomationControlInterruptedError({ message: error.message }); + return new PreviewAutomationControlInterruptedError({ + ...context, + ...remoteDiagnostics, + }); 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 - : "", + ...context, + ...remoteDiagnostics, }); } case "PreviewAutomationResultTooLargeError": { const detail = typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; + const maximumBytes = + detail && + "maximumBytes" in detail && + typeof detail.maximumBytes === "number" && + Number.isInteger(detail.maximumBytes) && + detail.maximumBytes > 0 + ? detail.maximumBytes + : undefined; return new PreviewAutomationResultTooLargeError({ - message: error.message, - maximumBytes: - detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" - ? detail.maximumBytes - : 64_000, + ...context, + ...remoteDiagnostics, + ...(maximumBytes === undefined ? {} : { maximumBytes }), }); } case "PreviewAutomationUnavailableError": - return new PreviewAutomationUnavailableError({ message: error.message }); + return new PreviewAutomationRemoteUnavailableError({ + ...context, + ...remoteDiagnostics, + }); default: return new PreviewAutomationExecutionError({ - message: error.message, - detail: error.detail, + ...context, + ...remoteDiagnostics, }); } }; @@ -148,13 +223,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); yield* Effect.forEach( toFail, - ({ deferred }) => - Deferred.fail( - deferred, - new PreviewAutomationUnavailableError({ - message: "The preview automation client disconnected.", - }), - ), + ({ deferred, context }) => + Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), { discard: true }, ); yield* Queue.shutdown(queue); @@ -228,10 +298,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { yield* Deferred.fail( pending.deferred, response.error - ? makeResponseError(response.error) - : new PreviewAutomationExecutionError({ - message: "Preview automation failed without an error payload.", - }), + ? classifyResponseError(pending.context, response.error) + : new PreviewAutomationMalformedResponseError(pending.context), ); } }); @@ -250,28 +318,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { .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.", + const disconnectedOwner = candidates[0]; + if (disconnectedOwner) { + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: disconnectedOwner.clientId, }); } return yield* new PreviewAutomationNoFocusedOwnerError({ - message: "No desktop browser host is available for this thread.", + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, }); } const connection = current.clients.get(owner.clientId); if (!connection) { - return yield* new PreviewAutomationUnavailableError({ - message: "The browser host is not connected.", + return yield* new PreviewAutomationHostNotConnectedError({ + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, }); } const timeoutMs = input.timeoutMs ?? 15_000; const deferred = yield* Deferred.make(); - const requestId = yield* SynchronizedRef.modify(state, (next) => { + const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { const requestId = `preview-${next.requestSequence}`; + const tabId = input.tabId ?? owner.tabId ?? undefined; + const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); + const context: PreviewAutomationRequestErrorContext = { + operation: input.operation, + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + clientId: owner.clientId, + requestId, + ...(tabId === undefined ? {} : { tabId }), + timeoutMs, + ...selectorDiagnostics, + }; const pending = new Map(next.pending); - pending.set(requestId, { queue: connection.queue, deferred }); - return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; + pending.set(requestId, { queue: connection.queue, deferred, context }); + return [ + [requestId, context] as const, + { ...next, pending, requestSequence: next.requestSequence + 1 }, + ] as const; }); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; @@ -283,24 +383,21 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { const offered = yield* Queue.offer(connection.queue, { requestId, threadId: input.scope.threadId, - tabId: input.tabId ?? owner.tabId ?? undefined, + tabId: requestContext.tabId, operation: input.operation, input: input.input, timeoutMs, }); if (!offered) { - return yield* new PreviewAutomationUnavailableError({ - message: "The preview automation client is no longer accepting requests.", - }); + const completion = yield* Deferred.poll(deferred); + if (Option.isSome(completion)) { + return (yield* completion.value) as A; + } + return yield* new PreviewAutomationRequestQueueClosedError(requestContext); } 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.`, - }), - ), + onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), onSome: (value) => Effect.succeed(value as A), }); }); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 110fc2415ad..d6b9f59ae8d 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PreviewTabId } from "./preview.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; const BoundedUrl = Schema.String.check(Schema.isTrimmed()) .check( @@ -455,45 +456,205 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass

()( "PreviewAutomationNoFocusedOwnerError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationScopeErrorFields, + clientId: Schema.optional(TrimmedNonEmptyString), + requestId: Schema.optional(TrimmedNonEmptyString), + tabId: Schema.optional(PreviewTabId), + timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`; + return summary; + } +} export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass()( "PreviewAutomationUnsupportedClientError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation client ${this.clientId} does not support ${this.operation}.`; + } +} export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass()( "PreviewAutomationTabNotFoundError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = this.tabId + ? `Preview tab ${this.tabId} was not found for ${this.operation}.` + : `No active preview tab was found for ${this.operation}.`; + return summary; + } +} export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( "PreviewAutomationTimeoutError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationOptionalRemoteDiagnosticFields, + }, +) { + override get message(): string { + const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; + return summary; + } +} export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( "PreviewAutomationControlInterruptedError", - { message: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; + } +} export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass()( "PreviewAutomationExecutionError", - { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} failed on client ${this.clientId}.`; + } +} export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( "PreviewAutomationInvalidSelectorError", - { message: Schema.String, selector: Schema.String }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), + selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + }, +) { + override get message(): string { + if (this.selectorKind !== undefined && this.selectorLength !== undefined) { + return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; + } + return `Preview automation ${this.operation} received an invalid selector.`; + } +} export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( "PreviewAutomationResultTooLargeError", - { message: Schema.String, maximumBytes: Schema.Int }, -) {} + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + maximumBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), + }, +) { + override get message(): string { + const summary = + this.maximumBytes === undefined + ? `Preview automation ${this.operation} produced a result that is too large.` + : `Preview automation ${this.operation} produced a result larger than ${this.maximumBytes} bytes.`; + return summary; + } +} + +export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationHostNotConnectedError", + { + ...PreviewAutomationScopeErrorFields, + clientId: TrimmedNonEmptyString, + }, +) { + override get message(): string { + return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`; + } +} + +export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()( + "PreviewAutomationClientDisconnectedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} disconnected during ${this.operation}.`; + } +} + +export class PreviewAutomationRequestQueueClosedError extends Schema.TaggedErrorClass()( + "PreviewAutomationRequestQueueClosedError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} stopped accepting ${this.operation} requests.`; + } +} + +export class PreviewAutomationRemoteUnavailableError extends Schema.TaggedErrorClass()( + "PreviewAutomationRemoteUnavailableError", + { + ...PreviewAutomationRequestErrorFields, + ...PreviewAutomationRemoteDiagnosticFields, + }, +) { + override get message(): string { + return `Preview automation ${this.operation} is unavailable on client ${this.clientId}.`; + } +} + +export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorClass()( + "PreviewAutomationMalformedResponseError", + PreviewAutomationRequestErrorFields, +) { + override get message(): string { + return `Preview automation client ${this.clientId} returned a malformed response for ${this.operation}.`; + } +} export const PreviewAutomationError = Schema.Union([ PreviewAutomationUnavailableError, @@ -505,6 +666,11 @@ export const PreviewAutomationError = Schema.Union([ PreviewAutomationExecutionError, PreviewAutomationInvalidSelectorError, PreviewAutomationResultTooLargeError, + PreviewAutomationHostNotConnectedError, + PreviewAutomationClientDisconnectedError, + PreviewAutomationRequestQueueClosedError, + PreviewAutomationRemoteUnavailableError, + PreviewAutomationMalformedResponseError, ]); export type PreviewAutomationError = typeof PreviewAutomationError.Type;