| author | |
| committer | |
| log | 336d176027d1079fb98554a9439b6a2412c7e2fe |
| tree | 0f7a4f9c2fd79f2e9c571372fa1f7f19ab263eca |
| parent | 204984bf8cc4c9b85ef4d100b355c6c0caf57a21 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 441 insertions(+), 70 deletions(-)
apps/server/src/mcp/PreviewAutomationBroker.test.ts+110-2| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { | 2 | import { |
| 3 | EnvironmentId, | 3 | EnvironmentId, |
| 4 | PreviewAutomationClientDisconnectedError, | ||
| 5 | PreviewAutomationInvalidSelectorError, | ||
| 6 | PreviewAutomationMalformedResponseError, | ||
| 4 | PreviewAutomationNoFocusedOwnerError, | 7 | PreviewAutomationNoFocusedOwnerError, |
| 5 | PreviewAutomationUnavailableError, | ||
| 6 | ProviderInstanceId, | 8 | ProviderInstanceId, |
| 7 | ThreadId, | 9 | ThreadId, |
| 8 | type PreviewAutomationOwner, | 10 | type PreviewAutomationOwner, |
| ... | @@ -59,6 +61,95 @@ it.effect("atomically registers a connected owner and correlates its response", | ... | @@ -59,6 +61,95 @@ it.effect("atomically registers a connected owner and correlates its response", |
| 59 | ), | 61 | ), |
| 60 | ); | 62 | ); |
| 61 | 63 | ||
| 64 | it.effect("preserves bounded request and remote selector diagnostics", () => { | ||
| 65 | const locator = "role=button[name='request-secret']"; | ||
| 66 | const remoteMessage = "Unexpected token near remote-secret."; | ||
| 67 | const remoteError = { | ||
| 68 | _tag: "PreviewAutomationInvalidSelectorError", | ||
| 69 | message: remoteMessage, | ||
| 70 | detail: { selector: "role=button[name='remote-secret']" }, | ||
| 71 | } as const; | ||
| 72 | |||
| 73 | return Effect.scoped( | ||
| 74 | Effect.gen(function* () { | ||
| 75 | const broker = yield* PreviewAutomationBroker.make; | ||
| 76 | const requests = yield* broker.connect(makeOwner({ tabId: "tab-1" })); | ||
| 77 | yield* Stream.runForEach(requests, (request) => | ||
| 78 | broker.respond({ | ||
| 79 | requestId: request.requestId, | ||
| 80 | ok: false, | ||
| 81 | error: remoteError, | ||
| 82 | }), | ||
| 83 | ).pipe(Effect.forkScoped); | ||
| 84 | yield* Effect.yieldNow; | ||
| 85 | |||
| 86 | const error = yield* broker | ||
| 87 | .invoke<void>({ | ||
| 88 | scope, | ||
| 89 | operation: "click", | ||
| 90 | input: { locator }, | ||
| 91 | timeoutMs: 1_234, | ||
| 92 | }) | ||
| 93 | .pipe(Effect.flip); | ||
| 94 | |||
| 95 | expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); | ||
| 96 | expect(error).toMatchObject({ | ||
| 97 | operation: "click", | ||
| 98 | environmentId: scope.environmentId, | ||
| 99 | threadId: scope.threadId, | ||
| 100 | providerSessionId: scope.providerSessionId, | ||
| 101 | providerInstanceId: scope.providerInstanceId, | ||
| 102 | clientId: "client-1", | ||
| 103 | requestId: "preview-0", | ||
| 104 | tabId: "tab-1", | ||
| 105 | timeoutMs: 1_234, | ||
| 106 | selectorKind: "locator", | ||
| 107 | selectorLength: locator.length, | ||
| 108 | remoteTag: "PreviewAutomationInvalidSelectorError", | ||
| 109 | remoteMessageLength: remoteMessage.length, | ||
| 110 | remoteDetailKind: "object", | ||
| 111 | }); | ||
| 112 | expect(error.message).toBe( | ||
| 113 | `Preview automation click received an invalid locator (${locator.length} characters).`, | ||
| 114 | ); | ||
| 115 | expect(error.message).not.toContain("secret"); | ||
| 116 | expect(error.cause).toBe(remoteError); | ||
| 117 | expect("selector" in error).toBe(false); | ||
| 118 | expect("remoteMessage" in error).toBe(false); | ||
| 119 | expect("remoteDetail" in error).toBe(false); | ||
| 120 | }), | ||
| 121 | ); | ||
| 122 | }); | ||
| 123 | |||
| 124 | it.effect("distinguishes malformed remote failures", () => | ||
| 125 | Effect.scoped( | ||
| 126 | Effect.gen(function* () { | ||
| 127 | const broker = yield* PreviewAutomationBroker.make; | ||
| 128 | const requests = yield* broker.connect(makeOwner()); | ||
| 129 | yield* Stream.runForEach(requests, (request) => | ||
| 130 | broker.respond({ requestId: request.requestId, ok: false }), | ||
| 131 | ).pipe(Effect.forkScoped); | ||
| 132 | yield* Effect.yieldNow; | ||
| 133 | |||
| 134 | const error = yield* broker | ||
| 135 | .invoke<void>({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) | ||
| 136 | .pipe(Effect.flip); | ||
| 137 | |||
| 138 | expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); | ||
| 139 | expect(error).toMatchObject({ | ||
| 140 | operation: "status", | ||
| 141 | environmentId: scope.environmentId, | ||
| 142 | threadId: scope.threadId, | ||
| 143 | providerSessionId: scope.providerSessionId, | ||
| 144 | providerInstanceId: scope.providerInstanceId, | ||
| 145 | clientId: "client-1", | ||
| 146 | requestId: "preview-0", | ||
| 147 | timeoutMs: 2_000, | ||
| 148 | }); | ||
| 149 | }), | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | |||
| 62 | it.effect("rejects calls when no focused owner exists", () => | 153 | it.effect("rejects calls when no focused owner exists", () => |
| 63 | Effect.gen(function* () { | 154 | Effect.gen(function* () { |
| 64 | const broker = yield* PreviewAutomationBroker.make; | 155 | const broker = yield* PreviewAutomationBroker.make; |
| ... | @@ -66,6 +157,13 @@ it.effect("rejects calls when no focused owner exists", () => | ... | @@ -66,6 +157,13 @@ it.effect("rejects calls when no focused owner exists", () => |
| 66 | .invoke<void>({ scope, operation: "status", input: {} }) | 157 | .invoke<void>({ scope, operation: "status", input: {} }) |
| 67 | .pipe(Effect.flip); | 158 | .pipe(Effect.flip); |
| 68 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | 159 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); |
| 160 | expect(error).toMatchObject({ | ||
| 161 | operation: "status", | ||
| 162 | environmentId: scope.environmentId, | ||
| 163 | threadId: scope.threadId, | ||
| 164 | providerSessionId: scope.providerSessionId, | ||
| 165 | providerInstanceId: scope.providerInstanceId, | ||
| 166 | }); | ||
| 69 | }), | 167 | }), |
| 70 | ); | 168 | ); |
| 71 | 169 | ||
| ... | @@ -162,7 +260,17 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec | ... | @@ -162,7 +260,17 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec |
| 162 | const _replacementRequests = yield* broker.connect(makeOwner()); | 260 | const _replacementRequests = yield* broker.connect(makeOwner()); |
| 163 | 261 | ||
| 164 | const error = yield* Fiber.join(pending); | 262 | const error = yield* Fiber.join(pending); |
| 165 | expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); | 263 | expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); |
| 264 | expect(error).toMatchObject({ | ||
| 265 | operation: "status", | ||
| 266 | environmentId: scope.environmentId, | ||
| 267 | threadId: scope.threadId, | ||
| 268 | providerSessionId: scope.providerSessionId, | ||
| 269 | providerInstanceId: scope.providerInstanceId, | ||
| 270 | clientId: "client-1", | ||
| 271 | requestId: "preview-0", | ||
| 272 | timeoutMs: 15_000, | ||
| 273 | }); | ||
| 166 | }), | 274 | }), |
| 167 | ), | 275 | ), |
| 168 | ); | 276 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts+149-52| ... | @@ -1,12 +1,16 @@ | ... | @@ -1,12 +1,16 @@ |
| 1 | import { | 1 | import { |
| 2 | PreviewAutomationClientDisconnectedError, | ||
| 2 | PreviewAutomationControlInterruptedError, | 3 | PreviewAutomationControlInterruptedError, |
| 3 | PreviewAutomationExecutionError, | 4 | PreviewAutomationExecutionError, |
| 5 | PreviewAutomationHostNotConnectedError, | ||
| 4 | PreviewAutomationInvalidSelectorError, | 6 | PreviewAutomationInvalidSelectorError, |
| 7 | PreviewAutomationMalformedResponseError, | ||
| 5 | PreviewAutomationNoFocusedOwnerError, | 8 | PreviewAutomationNoFocusedOwnerError, |
| 9 | PreviewAutomationRemoteUnavailableError, | ||
| 10 | PreviewAutomationRequestQueueClosedError, | ||
| 6 | PreviewAutomationResultTooLargeError, | 11 | PreviewAutomationResultTooLargeError, |
| 7 | PreviewAutomationTabNotFoundError, | 12 | PreviewAutomationTabNotFoundError, |
| 8 | PreviewAutomationTimeoutError, | 13 | PreviewAutomationTimeoutError, |
| 9 | PreviewAutomationUnavailableError, | ||
| 10 | PreviewAutomationUnsupportedClientError, | 14 | PreviewAutomationUnsupportedClientError, |
| 11 | type PreviewAutomationError, | 15 | type PreviewAutomationError, |
| 12 | type PreviewAutomationOperation, | 16 | type PreviewAutomationOperation, |
| ... | @@ -62,6 +66,21 @@ interface ClientConnection { | ... | @@ -62,6 +66,21 @@ interface ClientConnection { |
| 62 | interface PendingRequest { | 66 | interface PendingRequest { |
| 63 | readonly queue: ClientConnection["queue"]; | 67 | readonly queue: ClientConnection["queue"]; |
| 64 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; | 68 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; |
| 69 | readonly context: PreviewAutomationRequestErrorContext; | ||
| 70 | } | ||
| 71 | |||
| 72 | interface PreviewAutomationRequestErrorContext { | ||
| 73 | readonly operation: PreviewAutomationOperation; | ||
| 74 | readonly environmentId: McpInvocationContext.McpInvocationScope["environmentId"]; | ||
| 75 | readonly threadId: McpInvocationContext.McpInvocationScope["threadId"]; | ||
| 76 | readonly providerSessionId: string; | ||
| 77 | readonly providerInstanceId: McpInvocationContext.McpInvocationScope["providerInstanceId"]; | ||
| 78 | readonly clientId: string; | ||
| 79 | readonly requestId: string; | ||
| 80 | readonly tabId?: PreviewTabId; | ||
| 81 | readonly timeoutMs: number; | ||
| 82 | readonly selectorKind?: "locator" | "selector"; | ||
| 83 | readonly selectorLength?: number; | ||
| 65 | } | 84 | } |
| 66 | 85 | ||
| 67 | interface BrokerState { | 86 | interface BrokerState { |
| ... | @@ -71,48 +90,104 @@ interface BrokerState { | ... | @@ -71,48 +90,104 @@ interface BrokerState { |
| 71 | readonly requestSequence: number; | 90 | readonly requestSequence: number; |
| 72 | } | 91 | } |
| 73 | 92 | ||
| 74 | const makeResponseError = ( | 93 | const selectorDiagnosticsFromInput = ( |
| 94 | input: unknown, | ||
| 95 | ): Pick<PreviewAutomationRequestErrorContext, "selectorKind" | "selectorLength"> => { | ||
| 96 | if (typeof input !== "object" || input === null) return {}; | ||
| 97 | if ("locator" in input && typeof input.locator === "string") { | ||
| 98 | return { selectorKind: "locator", selectorLength: input.locator.length }; | ||
| 99 | } | ||
| 100 | if ("selector" in input && typeof input.selector === "string") { | ||
| 101 | return { selectorKind: "selector", selectorLength: input.selector.length }; | ||
| 102 | } | ||
| 103 | return {}; | ||
| 104 | }; | ||
| 105 | |||
| 106 | type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; | ||
| 107 | |||
| 108 | function remoteDetailKind(detail: unknown): RemoteDetailKind { | ||
| 109 | if (detail === null) return "null"; | ||
| 110 | if (Array.isArray(detail)) return "array"; | ||
| 111 | switch (typeof detail) { | ||
| 112 | case "string": | ||
| 113 | return "string"; | ||
| 114 | case "number": | ||
| 115 | return "number"; | ||
| 116 | case "boolean": | ||
| 117 | return "boolean"; | ||
| 118 | default: | ||
| 119 | return "object"; | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | const classifyResponseError = ( | ||
| 124 | context: PreviewAutomationRequestErrorContext, | ||
| 75 | error: NonNullable<PreviewAutomationResponse["error"]>, | 125 | error: NonNullable<PreviewAutomationResponse["error"]>, |
| 76 | ): PreviewAutomationError => { | 126 | ): PreviewAutomationError => { |
| 127 | const remoteDiagnostics = { | ||
| 128 | remoteTag: error._tag, | ||
| 129 | remoteMessageLength: error.message.length, | ||
| 130 | ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }), | ||
| 131 | cause: error, | ||
| 132 | }; | ||
| 77 | switch (error._tag) { | 133 | switch (error._tag) { |
| 78 | case "PreviewAutomationNoFocusedOwnerError": | 134 | case "PreviewAutomationNoFocusedOwnerError": |
| 79 | return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); | 135 | return new PreviewAutomationNoFocusedOwnerError({ |
| 136 | ...context, | ||
| 137 | ...remoteDiagnostics, | ||
| 138 | }); | ||
| 80 | case "PreviewAutomationUnsupportedClientError": | 139 | case "PreviewAutomationUnsupportedClientError": |
| 81 | return new PreviewAutomationUnsupportedClientError({ message: error.message }); | 140 | return new PreviewAutomationUnsupportedClientError({ |
| 141 | ...context, | ||
| 142 | ...remoteDiagnostics, | ||
| 143 | }); | ||
| 82 | case "PreviewAutomationTabNotFoundError": | 144 | case "PreviewAutomationTabNotFoundError": |
| 83 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | 145 | return new PreviewAutomationTabNotFoundError({ |
| 146 | ...context, | ||
| 147 | ...remoteDiagnostics, | ||
| 148 | }); | ||
| 84 | case "PreviewAutomationTimeoutError": | 149 | case "PreviewAutomationTimeoutError": |
| 85 | return new PreviewAutomationTimeoutError({ message: error.message }); | 150 | return new PreviewAutomationTimeoutError({ |
| 151 | ...context, | ||
| 152 | ...remoteDiagnostics, | ||
| 153 | }); | ||
| 86 | case "PreviewAutomationControlInterruptedError": | 154 | case "PreviewAutomationControlInterruptedError": |
| 87 | return new PreviewAutomationControlInterruptedError({ message: error.message }); | 155 | return new PreviewAutomationControlInterruptedError({ |
| 156 | ...context, | ||
| 157 | ...remoteDiagnostics, | ||
| 158 | }); | ||
| 88 | case "PreviewAutomationInvalidSelectorError": { | 159 | case "PreviewAutomationInvalidSelectorError": { |
| 89 | const detail = | ||
| 90 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | ||
| 91 | return new PreviewAutomationInvalidSelectorError({ | 160 | return new PreviewAutomationInvalidSelectorError({ |
| 92 | message: error.message, | 161 | ...context, |
| 93 | selector: | 162 | ...remoteDiagnostics, |
| 94 | detail && "selector" in detail && typeof detail.selector === "string" | ||
| 95 | ? detail.selector | ||
| 96 | : "", | ||
| 97 | }); | 163 | }); |
| 98 | } | 164 | } |
| 99 | case "PreviewAutomationResultTooLargeError": { | 165 | case "PreviewAutomationResultTooLargeError": { |
| 100 | const detail = | 166 | const detail = |
| 101 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | 167 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; |
| 168 | const maximumBytes = | ||
| 169 | detail && | ||
| 170 | "maximumBytes" in detail && | ||
| 171 | typeof detail.maximumBytes === "number" && | ||
| 172 | Number.isInteger(detail.maximumBytes) && | ||
| 173 | detail.maximumBytes > 0 | ||
| 174 | ? detail.maximumBytes | ||
| 175 | : undefined; | ||
| 102 | return new PreviewAutomationResultTooLargeError({ | 176 | return new PreviewAutomationResultTooLargeError({ |
| 103 | message: error.message, | 177 | ...context, |
| 104 | maximumBytes: | 178 | ...remoteDiagnostics, |
| 105 | detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" | 179 | ...(maximumBytes === undefined ? {} : { maximumBytes }), |
| 106 | ? detail.maximumBytes | ||
| 107 | : 64_000, | ||
| 108 | }); | 180 | }); |
| 109 | } | 181 | } |
| 110 | case "PreviewAutomationUnavailableError": | 182 | case "PreviewAutomationUnavailableError": |
| 111 | return new PreviewAutomationUnavailableError({ message: error.message }); | 183 | return new PreviewAutomationRemoteUnavailableError({ |
| 184 | ...context, | ||
| 185 | ...remoteDiagnostics, | ||
| 186 | }); | ||
| 112 | default: | 187 | default: |
| 113 | return new PreviewAutomationExecutionError({ | 188 | return new PreviewAutomationExecutionError({ |
| 114 | message: error.message, | 189 | ...context, |
| 115 | detail: error.detail, | 190 | ...remoteDiagnostics, |
| 116 | }); | 191 | }); |
| 117 | } | 192 | } |
| 118 | }; | 193 | }; |
| ... | @@ -148,13 +223,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -148,13 +223,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 148 | }); | 223 | }); |
| 149 | yield* Effect.forEach( | 224 | yield* Effect.forEach( |
| 150 | toFail, | 225 | toFail, |
| 151 | ({ deferred }) => | 226 | ({ deferred, context }) => |
| 152 | Deferred.fail( | 227 | Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), |
| 153 | deferred, | ||
| 154 | new PreviewAutomationUnavailableError({ | ||
| 155 | message: "The preview automation client disconnected.", | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | { discard: true }, | 228 | { discard: true }, |
| 159 | ); | 229 | ); |
| 160 | yield* Queue.shutdown(queue); | 230 | yield* Queue.shutdown(queue); |
| ... | @@ -228,10 +298,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -228,10 +298,8 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 228 | yield* Deferred.fail( | 298 | yield* Deferred.fail( |
| 229 | pending.deferred, | 299 | pending.deferred, |
| 230 | response.error | 300 | response.error |
| 231 | ? makeResponseError(response.error) | 301 | ? classifyResponseError(pending.context, response.error) |
| 232 | : new PreviewAutomationExecutionError({ | 302 | : new PreviewAutomationMalformedResponseError(pending.context), |
| 233 | message: "Preview automation failed without an error payload.", | ||
| 234 | }), | ||
| 235 | ); | 303 | ); |
| 236 | } | 304 | } |
| 237 | }); | 305 | }); |
| ... | @@ -250,28 +318,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -250,28 +318,60 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 250 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | 318 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); |
| 251 | const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); | 319 | const owner = candidates.find((candidate) => current.clients.has(candidate.clientId)); |
| 252 | if (!owner) { | 320 | if (!owner) { |
| 253 | if (candidates.length > 0) { | 321 | const disconnectedOwner = candidates[0]; |
| 254 | return yield* new PreviewAutomationUnavailableError({ | 322 | if (disconnectedOwner) { |
| 255 | message: "The browser host is not connected.", | 323 | return yield* new PreviewAutomationHostNotConnectedError({ |
| 324 | operation: input.operation, | ||
| 325 | environmentId: input.scope.environmentId, | ||
| 326 | threadId: input.scope.threadId, | ||
| 327 | providerSessionId: input.scope.providerSessionId, | ||
| 328 | providerInstanceId: input.scope.providerInstanceId, | ||
| 329 | clientId: disconnectedOwner.clientId, | ||
| 256 | }); | 330 | }); |
| 257 | } | 331 | } |
| 258 | return yield* new PreviewAutomationNoFocusedOwnerError({ | 332 | return yield* new PreviewAutomationNoFocusedOwnerError({ |
| 259 | message: "No desktop browser host is available for this thread.", | 333 | operation: input.operation, |
| 334 | environmentId: input.scope.environmentId, | ||
| 335 | threadId: input.scope.threadId, | ||
| 336 | providerSessionId: input.scope.providerSessionId, | ||
| 337 | providerInstanceId: input.scope.providerInstanceId, | ||
| 260 | }); | 338 | }); |
| 261 | } | 339 | } |
| 262 | const connection = current.clients.get(owner.clientId); | 340 | const connection = current.clients.get(owner.clientId); |
| 263 | if (!connection) { | 341 | if (!connection) { |
| 264 | return yield* new PreviewAutomationUnavailableError({ | 342 | return yield* new PreviewAutomationHostNotConnectedError({ |
| 265 | message: "The browser host is not connected.", | 343 | operation: input.operation, |
| 344 | environmentId: input.scope.environmentId, | ||
| 345 | threadId: input.scope.threadId, | ||
| 346 | providerSessionId: input.scope.providerSessionId, | ||
| 347 | providerInstanceId: input.scope.providerInstanceId, | ||
| 348 | clientId: owner.clientId, | ||
| 266 | }); | 349 | }); |
| 267 | } | 350 | } |
| 268 | const timeoutMs = input.timeoutMs ?? 15_000; | 351 | const timeoutMs = input.timeoutMs ?? 15_000; |
| 269 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | 352 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); |
| 270 | const requestId = yield* SynchronizedRef.modify(state, (next) => { | 353 | const [requestId, requestContext] = yield* SynchronizedRef.modify(state, (next) => { |
| 271 | const requestId = `preview-${next.requestSequence}`; | 354 | const requestId = `preview-${next.requestSequence}`; |
| 355 | const tabId = input.tabId ?? owner.tabId ?? undefined; | ||
| 356 | const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); | ||
| 357 | const context: PreviewAutomationRequestErrorContext = { | ||
| 358 | operation: input.operation, | ||
| 359 | environmentId: input.scope.environmentId, | ||
| 360 | threadId: input.scope.threadId, | ||
| 361 | providerSessionId: input.scope.providerSessionId, | ||
| 362 | providerInstanceId: input.scope.providerInstanceId, | ||
| 363 | clientId: owner.clientId, | ||
| 364 | requestId, | ||
| 365 | ...(tabId === undefined ? {} : { tabId }), | ||
| 366 | timeoutMs, | ||
| 367 | ...selectorDiagnostics, | ||
| 368 | }; | ||
| 272 | const pending = new Map(next.pending); | 369 | const pending = new Map(next.pending); |
| 273 | pending.set(requestId, { queue: connection.queue, deferred }); | 370 | pending.set(requestId, { queue: connection.queue, deferred, context }); |
| 274 | return [requestId, { ...next, pending, requestSequence: next.requestSequence + 1 }] as const; | 371 | return [ |
| 372 | [requestId, context] as const, | ||
| 373 | { ...next, pending, requestSequence: next.requestSequence + 1 }, | ||
| 374 | ] as const; | ||
| 275 | }); | 375 | }); |
| 276 | const removePending = SynchronizedRef.update(state, (next) => { | 376 | const removePending = SynchronizedRef.update(state, (next) => { |
| 277 | if (!next.pending.has(requestId)) return next; | 377 | if (!next.pending.has(requestId)) return next; |
| ... | @@ -283,24 +383,21 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { | ... | @@ -283,24 +383,21 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { |
| 283 | const offered = yield* Queue.offer(connection.queue, { | 383 | const offered = yield* Queue.offer(connection.queue, { |
| 284 | requestId, | 384 | requestId, |
| 285 | threadId: input.scope.threadId, | 385 | threadId: input.scope.threadId, |
| 286 | tabId: input.tabId ?? owner.tabId ?? undefined, | 386 | tabId: requestContext.tabId, |
| 287 | operation: input.operation, | 387 | operation: input.operation, |
| 288 | input: input.input, | 388 | input: input.input, |
| 289 | timeoutMs, | 389 | timeoutMs, |
| 290 | }); | 390 | }); |
| 291 | if (!offered) { | 391 | if (!offered) { |
| 292 | return yield* new PreviewAutomationUnavailableError({ | 392 | const completion = yield* Deferred.poll(deferred); |
| 293 | message: "The preview automation client is no longer accepting requests.", | 393 | if (Option.isSome(completion)) { |
| 294 | }); | 394 | return (yield* completion.value) as A; |
| 395 | } | ||
| 396 | return yield* new PreviewAutomationRequestQueueClosedError(requestContext); | ||
| 295 | } | 397 | } |
| 296 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | 398 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); |
| 297 | return yield* Option.match(result, { | 399 | return yield* Option.match(result, { |
| 298 | onNone: () => | 400 | onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), |
| 299 | Effect.fail( | ||
| 300 | new PreviewAutomationTimeoutError({ | ||
| 301 | message: `Preview automation timed out after ${timeoutMs}ms.`, | ||
| 302 | }), | ||
| 303 | ), | ||
| 304 | onSome: (value) => Effect.succeed(value as A), | 401 | onSome: (value) => Effect.succeed(value as A), |
| 305 | }); | 402 | }); |
| 306 | }); | 403 | }); |
packages/contracts/src/previewAutomation.ts+182-16| ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; | ... | @@ -2,6 +2,7 @@ import { Schema } from "effect"; |
| 2 | 2 | ||
| 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 4 | import { PreviewTabId } from "./preview.ts"; | 4 | import { PreviewTabId } from "./preview.ts"; |
| 5 | import { ProviderInstanceId } from "./providerInstance.ts"; | ||
| 5 | 6 | ||
| 6 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) | 7 | const BoundedUrl = Schema.String.check(Schema.isTrimmed()) |
| 7 | .check( | 8 | .check( |
| ... | @@ -455,45 +456,205 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<P | ... | @@ -455,45 +456,205 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<P |
| 455 | { message: Schema.String }, | 456 | { message: Schema.String }, |
| 456 | ) {} | 457 | ) {} |
| 457 | 458 | ||
| 459 | const PreviewAutomationScopeErrorFields = { | ||
| 460 | operation: PreviewAutomationOperation, | ||
| 461 | environmentId: EnvironmentId, | ||
| 462 | threadId: ThreadId, | ||
| 463 | providerSessionId: TrimmedNonEmptyString, | ||
| 464 | providerInstanceId: ProviderInstanceId, | ||
| 465 | }; | ||
| 466 | |||
| 467 | const PreviewAutomationRequestErrorFields = { | ||
| 468 | ...PreviewAutomationScopeErrorFields, | ||
| 469 | clientId: TrimmedNonEmptyString, | ||
| 470 | requestId: TrimmedNonEmptyString, | ||
| 471 | tabId: Schema.optional(PreviewTabId), | ||
| 472 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), | ||
| 473 | }; | ||
| 474 | |||
| 475 | const PreviewAutomationRemoteDiagnosticFields = { | ||
| 476 | remoteTag: TrimmedNonEmptyString, | ||
| 477 | remoteMessageLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), | ||
| 478 | remoteDetailKind: Schema.optional( | ||
| 479 | Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), | ||
| 480 | ), | ||
| 481 | cause: Schema.Defect(), | ||
| 482 | }; | ||
| 483 | |||
| 484 | const PreviewAutomationOptionalRemoteDiagnosticFields = { | ||
| 485 | remoteTag: Schema.optional(TrimmedNonEmptyString), | ||
| 486 | remoteMessageLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | ||
| 487 | remoteDetailKind: Schema.optional( | ||
| 488 | Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), | ||
| 489 | ), | ||
| 490 | cause: Schema.optional(Schema.Defect()), | ||
| 491 | }; | ||
| 492 | |||
| 458 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( | 493 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( |
| 459 | "PreviewAutomationNoFocusedOwnerError", | 494 | "PreviewAutomationNoFocusedOwnerError", |
| 460 | { message: Schema.String }, | 495 | { |
| 461 | ) {} | 496 | ...PreviewAutomationScopeErrorFields, |
| 497 | clientId: Schema.optional(TrimmedNonEmptyString), | ||
| 498 | requestId: Schema.optional(TrimmedNonEmptyString), | ||
| 499 | tabId: Schema.optional(PreviewTabId), | ||
| 500 | timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), | ||
| 501 | ...PreviewAutomationOptionalRemoteDiagnosticFields, | ||
| 502 | }, | ||
| 503 | ) { | ||
| 504 | override get message(): string { | ||
| 505 | const summary = `No focused preview automation owner is available for ${this.operation} in thread ${this.threadId}.`; | ||
| 506 | return summary; | ||
| 507 | } | ||
| 508 | } | ||
| 462 | 509 | ||
| 463 | export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( | 510 | export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( |
| 464 | "PreviewAutomationUnsupportedClientError", | 511 | "PreviewAutomationUnsupportedClientError", |
| 465 | { message: Schema.String }, | 512 | { |
| 466 | ) {} | 513 | ...PreviewAutomationRequestErrorFields, |
| 514 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 515 | }, | ||
| 516 | ) { | ||
| 517 | override get message(): string { | ||
| 518 | return `Preview automation client ${this.clientId} does not support ${this.operation}.`; | ||
| 519 | } | ||
| 520 | } | ||
| 467 | 521 | ||
| 468 | export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( | 522 | export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( |
| 469 | "PreviewAutomationTabNotFoundError", | 523 | "PreviewAutomationTabNotFoundError", |
| 470 | { message: Schema.String }, | 524 | { |
| 471 | ) {} | 525 | ...PreviewAutomationRequestErrorFields, |
| 526 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 527 | }, | ||
| 528 | ) { | ||
| 529 | override get message(): string { | ||
| 530 | const summary = this.tabId | ||
| 531 | ? `Preview tab ${this.tabId} was not found for ${this.operation}.` | ||
| 532 | : `No active preview tab was found for ${this.operation}.`; | ||
| 533 | return summary; | ||
| 534 | } | ||
| 535 | } | ||
| 472 | 536 | ||
| 473 | export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( | 537 | export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( |
| 474 | "PreviewAutomationTimeoutError", | 538 | "PreviewAutomationTimeoutError", |
| 475 | { message: Schema.String }, | 539 | { |
| 476 | ) {} | 540 | ...PreviewAutomationRequestErrorFields, |
| 541 | ...PreviewAutomationOptionalRemoteDiagnosticFields, | ||
| 542 | }, | ||
| 543 | ) { | ||
| 544 | override get message(): string { | ||
| 545 | const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; | ||
| 546 | return summary; | ||
| 547 | } | ||
| 548 | } | ||
| 477 | 549 | ||
| 478 | export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()( | 550 | export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()( |
| 479 | "PreviewAutomationControlInterruptedError", | 551 | "PreviewAutomationControlInterruptedError", |
| 480 | { message: Schema.String }, | 552 | { |
| 481 | ) {} | 553 | ...PreviewAutomationRequestErrorFields, |
| 554 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 555 | }, | ||
| 556 | ) { | ||
| 557 | override get message(): string { | ||
| 558 | return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; | ||
| 559 | } | ||
| 560 | } | ||
| 482 | 561 | ||
| 483 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( | 562 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( |
| 484 | "PreviewAutomationExecutionError", | 563 | "PreviewAutomationExecutionError", |
| 485 | { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, | 564 | { |
| 486 | ) {} | 565 | ...PreviewAutomationRequestErrorFields, |
| 566 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 567 | }, | ||
| 568 | ) { | ||
| 569 | override get message(): string { | ||
| 570 | return `Preview automation ${this.operation} failed on client ${this.clientId}.`; | ||
| 571 | } | ||
| 572 | } | ||
| 487 | 573 | ||
| 488 | export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( | 574 | export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( |
| 489 | "PreviewAutomationInvalidSelectorError", | 575 | "PreviewAutomationInvalidSelectorError", |
| 490 | { message: Schema.String, selector: Schema.String }, | 576 | { |
| 491 | ) {} | 577 | ...PreviewAutomationRequestErrorFields, |
| 578 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 579 | selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), | ||
| 580 | selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), | ||
| 581 | }, | ||
| 582 | ) { | ||
| 583 | override get message(): string { | ||
| 584 | if (this.selectorKind !== undefined && this.selectorLength !== undefined) { | ||
| 585 | return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; | ||
| 586 | } | ||
| 587 | return `Preview automation ${this.operation} received an invalid selector.`; | ||
| 588 | } | ||
| 589 | } | ||
| 492 | 590 | ||
| 493 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( | 591 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( |
| 494 | "PreviewAutomationResultTooLargeError", | 592 | "PreviewAutomationResultTooLargeError", |
| 495 | { message: Schema.String, maximumBytes: Schema.Int }, | 593 | { |
| 496 | ) {} | 594 | ...PreviewAutomationRequestErrorFields, |
| 595 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 596 | maximumBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), | ||
| 597 | }, | ||
| 598 | ) { | ||
| 599 | override get message(): string { | ||
| 600 | const summary = | ||
| 601 | this.maximumBytes === undefined | ||
| 602 | ? `Preview automation ${this.operation} produced a result that is too large.` | ||
| 603 | : `Preview automation ${this.operation} produced a result larger than ${this.maximumBytes} bytes.`; | ||
| 604 | return summary; | ||
| 605 | } | ||
| 606 | } | ||
| 607 | |||
| 608 | export class PreviewAutomationHostNotConnectedError extends Schema.TaggedErrorClass<PreviewAutomationHostNotConnectedError>()( | ||
| 609 | "PreviewAutomationHostNotConnectedError", | ||
| 610 | { | ||
| 611 | ...PreviewAutomationScopeErrorFields, | ||
| 612 | clientId: TrimmedNonEmptyString, | ||
| 613 | }, | ||
| 614 | ) { | ||
| 615 | override get message(): string { | ||
| 616 | return `Preview automation host ${this.clientId} is not connected for ${this.operation}.`; | ||
| 617 | } | ||
| 618 | } | ||
| 619 | |||
| 620 | export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass<PreviewAutomationClientDisconnectedError>()( | ||
| 621 | "PreviewAutomationClientDisconnectedError", | ||
| 622 | PreviewAutomationRequestErrorFields, | ||
| 623 | ) { | ||
| 624 | override get message(): string { | ||
| 625 | return `Preview automation client ${this.clientId} disconnected during ${this.operation}.`; | ||
| 626 | } | ||
| 627 | } | ||
| 628 | |||
| 629 | export class PreviewAutomationRequestQueueClosedError extends Schema.TaggedErrorClass<PreviewAutomationRequestQueueClosedError>()( | ||
| 630 | "PreviewAutomationRequestQueueClosedError", | ||
| 631 | PreviewAutomationRequestErrorFields, | ||
| 632 | ) { | ||
| 633 | override get message(): string { | ||
| 634 | return `Preview automation client ${this.clientId} stopped accepting ${this.operation} requests.`; | ||
| 635 | } | ||
| 636 | } | ||
| 637 | |||
| 638 | export class PreviewAutomationRemoteUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationRemoteUnavailableError>()( | ||
| 639 | "PreviewAutomationRemoteUnavailableError", | ||
| 640 | { | ||
| 641 | ...PreviewAutomationRequestErrorFields, | ||
| 642 | ...PreviewAutomationRemoteDiagnosticFields, | ||
| 643 | }, | ||
| 644 | ) { | ||
| 645 | override get message(): string { | ||
| 646 | return `Preview automation ${this.operation} is unavailable on client ${this.clientId}.`; | ||
| 647 | } | ||
| 648 | } | ||
| 649 | |||
| 650 | export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorClass<PreviewAutomationMalformedResponseError>()( | ||
| 651 | "PreviewAutomationMalformedResponseError", | ||
| 652 | PreviewAutomationRequestErrorFields, | ||
| 653 | ) { | ||
| 654 | override get message(): string { | ||
| 655 | return `Preview automation client ${this.clientId} returned a malformed response for ${this.operation}.`; | ||
| 656 | } | ||
| 657 | } | ||
| 497 | 658 | ||
| 498 | export const PreviewAutomationError = Schema.Union([ | 659 | export const PreviewAutomationError = Schema.Union([ |
| 499 | PreviewAutomationUnavailableError, | 660 | PreviewAutomationUnavailableError, |
| ... | @@ -505,6 +666,11 @@ export const PreviewAutomationError = Schema.Union([ | ... | @@ -505,6 +666,11 @@ export const PreviewAutomationError = Schema.Union([ |
| 505 | PreviewAutomationExecutionError, | 666 | PreviewAutomationExecutionError, |
| 506 | PreviewAutomationInvalidSelectorError, | 667 | PreviewAutomationInvalidSelectorError, |
| 507 | PreviewAutomationResultTooLargeError, | 668 | PreviewAutomationResultTooLargeError, |
| 669 | PreviewAutomationHostNotConnectedError, | ||
| 670 | PreviewAutomationClientDisconnectedError, | ||
| 671 | PreviewAutomationRequestQueueClosedError, | ||
| 672 | PreviewAutomationRemoteUnavailableError, | ||
| 673 | PreviewAutomationMalformedResponseError, | ||
| 508 | ]); | 674 | ]); |
| 509 | export type PreviewAutomationError = typeof PreviewAutomationError.Type; | 675 | export type PreviewAutomationError = typeof PreviewAutomationError.Type; |
| 510 | 676 |