The proxy version buys ~one character per handler at the cost of: - a recursive `Captured<T>` type with a hand-curated `CapturedOpaque` leaf list (branded IDs already bit us once); - a runtime Proxy that walks arbitrary object shapes and has to distinguish plain nested objects from class instances, with new stdlib types as potential edge cases; - a type-level lie where the declared method signature says `Effect<_, InternalError>` but the underlying method actually returns `Effect<_, StorageError>` and the proxy rewrites it. Replace with: every handler wraps its `Effect.gen(function*(){...})` body with `capture(...)`. One line at the top, explicit, honest, and self-enforcing — TypeScript rejects handlers that forget because `StorageError` isn't in the group's `.addError` list. `scripts/wrap-handlers-with-capture.ts` handled the mechanical part (45 handler bodies across core + every plugin). The rest: - Deleted `withCapture`, `Captured<T>`, `CapturedOpaque`, `isPlainObject` from `packages/core/api/src/observability.ts` and its exports. `capture` stays. - Service tags reverted from `Captured<XxxPluginExtension>` to `XxxPluginExtension` / raw `Executor`. - Host wiring (`apps/cloud/src/api/protected.ts`, `apps/local/src/server/main.ts`, cloud test harness) dropped the `const wrapped = withCapture(executor)` intro line; plain `executor.X` everywhere. - `.addError(InternalError)` added to the `executions` + `scope` API groups (they didn't previously declare it). - Handler tests (mcp + google-discovery) dropped `withCapture` import + wrap. - `notes/error-handling.md` rewritten around the per-handler `capture(...)` model. 34/34 typecheck. Unit tests + handler tests + cloud workers tests all green on clean runs. The remaining cloud node-test flakiness (sometimes-failing `sources.remove` / `openapi.updateSource`) is pre-existing PGlite test-harness setup racing, unrelated to this refactor.
Unified · Split
| @@ -3,7 +3,6 @@ import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platf · 2 unmodified lines | |||
| 3 | import { Effect, Layer } from "effect"; | 3 | import { Effect, Layer } from "effect"; |
| 4 | 4 | ||
| 5 | import { ExecutorService, ExecutionEngineService } from "@executor/api/server"; | 5 | import { ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 6 | import { withCapture } from "@executor/api"; | ||
| 7 | import { createExecutionEngine } from "@executor/execution"; | 6 | import { createExecutionEngine } from "@executor/execution"; |
| 8 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; | 7 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; |
| 9 | import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; | 8 | import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; |
| @@ -48,18 +47,15 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 38 unmodified lines | |||
| 48 | makeTrackExecutionUsage(autumn), | 47 | makeTrackExecutionUsage(autumn), |
| 49 | ); | 48 | ); |
| 50 | 49 | ||
| 51 | // `withCapture` wraps the executor once — every Effect-returning | 50 | // Handlers wrap their own bodies with `capture(...)` — the edge |
| 52 | // method on core + every plugin extension translates `StorageError` | 51 | // translation lives per-handler, not at service construction. See |
| 53 | // → `InternalError({ traceId })` via `ErrorCapture`; | 52 | // notes/error-handling.md. |
| 54 | // `UniqueViolationError` becomes a defect. Handlers see the | ||
| 55 | // already-captured shape. See notes/error-handling.md. | ||
| 56 | const wrapped = withCapture(executor); | ||
| 57 | const requestServices = Layer.mergeAll( | 53 | const requestServices = Layer.mergeAll( |
| 58 | Layer.succeed(ExecutorService, wrapped), | 54 | Layer.succeed(ExecutorService, executor), |
| 59 | Layer.succeed(ExecutionEngineService, engine), | 55 | Layer.succeed(ExecutionEngineService, engine), |
| 60 | Layer.succeed(OpenApiExtensionService, wrapped.openapi), | 56 | Layer.succeed(OpenApiExtensionService, executor.openapi), |
| 61 | Layer.succeed(McpExtensionService, wrapped.mcp), | 57 | Layer.succeed(McpExtensionService, executor.mcp), |
| 62 | Layer.succeed(GraphqlExtensionService, wrapped.graphql), | 58 | Layer.succeed(GraphqlExtensionService, executor.graphql), |
| 63 | ); | 59 | ); |
| 64 | 60 | ||
| 65 | return yield* HttpApiBuilder.httpApp.pipe( | 61 | return yield* HttpApiBuilder.httpApp.pipe( |
| @@ -30,7 +30,6 @@ import { · 29 unmodified lines | |||
| 30 | ExecutionEngineService, | 30 | ExecutionEngineService, |
| 31 | ExecutorService, | 31 | ExecutorService, |
| 32 | } from "@executor/api/server"; | 32 | } from "@executor/api/server"; |
| 33 | import { withCapture } from "@executor/api"; | ||
| 34 | import { createExecutionEngine } from "@executor/execution"; | 33 | import { createExecutionEngine } from "@executor/execution"; |
| 35 | import { | 34 | import { |
| 36 | Scope, | 35 | Scope, |
| @@ -217,13 +216,12 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 180 unmodified lines | |||
| 217 | Effect.gen(function* () { | 216 | Effect.gen(function* () { |
| 218 | const executor = yield* createTestScopedExecutor(scopeId, scopeName); | 217 | const executor = yield* createTestScopedExecutor(scopeId, scopeName); |
| 219 | const engine = createExecutionEngine({ executor }); | 218 | const engine = createExecutionEngine({ executor }); |
| 220 | const wrapped = withCapture(executor); | ||
| 221 | const services = Layer.mergeAll( | 219 | const services = Layer.mergeAll( |
| 222 | Layer.succeed(ExecutorService, wrapped), | 220 | Layer.succeed(ExecutorService, executor), |
| 223 | Layer.succeed(ExecutionEngineService, engine), | 221 | Layer.succeed(ExecutionEngineService, engine), |
| 224 | Layer.succeed(OpenApiExtensionService, wrapped.openapi), | 222 | Layer.succeed(OpenApiExtensionService, executor.openapi), |
| 225 | Layer.succeed(McpExtensionService, wrapped.mcp), | 223 | Layer.succeed(McpExtensionService, executor.mcp), |
| 226 | Layer.succeed(GraphqlExtensionService, wrapped.graphql), | 224 | Layer.succeed(GraphqlExtensionService, executor.graphql), |
| 227 | ); | 225 | ); |
| 228 | return yield* HttpApiBuilder.httpApp.pipe( | 226 | return yield* HttpApiBuilder.httpApp.pipe( |
| 229 | Effect.provide( | 227 | Effect.provide( |
| @@ -7,11 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | } from "@effect/platform"; | 7 | } from "@effect/platform"; |
| 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; | 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { addGroup, observabilityMiddleware } from "@executor/api"; |
| 11 | addGroup, | ||
| 12 | observabilityMiddleware, | ||
| 13 | withCapture, | ||
| 14 | } from "@executor/api"; | ||
| 15 | import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; | 11 | import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 16 | import { createExecutionEngine } from "@executor/execution"; | 12 | import { createExecutionEngine } from "@executor/execution"; |
| 17 | import { | 13 | import { |
| @@ -94,18 +90,14 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 76 unmodified lines | |||
| 94 | const executor = await getExecutor(); | 90 | const executor = await getExecutor(); |
| 95 | const engine = createExecutionEngine({ executor }); | 91 | const engine = createExecutionEngine({ executor }); |
| 96 | 92 | ||
| 97 | // `withCapture` wraps the executor once — every Effect-returning | 93 | // Handlers wrap their own bodies with `capture(...)` — the edge |
| 98 | // method on core + every plugin extension translates `StorageError` | 94 | // translation lives per-handler, not at service construction. |
| 99 | // to `InternalError({ traceId })` via `ErrorCapture`; | ||
| 100 | // `UniqueViolationError` becomes a defect. Handlers see the | ||
| 101 | // already-captured shape. | ||
| 102 | const wrapped = withCapture(executor); | ||
| 103 | const pluginExtensions = Layer.mergeAll( | 95 | const pluginExtensions = Layer.mergeAll( |
| 104 | Layer.succeed(OpenApiExtensionService, wrapped.openapi), | 96 | Layer.succeed(OpenApiExtensionService, executor.openapi), |
| 105 | Layer.succeed(McpExtensionService, wrapped.mcp), | 97 | Layer.succeed(McpExtensionService, executor.mcp), |
| 106 | Layer.succeed(GoogleDiscoveryExtensionService, wrapped.googleDiscovery), | 98 | Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery), |
| 107 | Layer.succeed(OnePasswordExtensionService, wrapped.onepassword), | 99 | Layer.succeed(OnePasswordExtensionService, executor.onepassword), |
| 108 | Layer.succeed(GraphqlExtensionService, wrapped.graphql), | 100 | Layer.succeed(GraphqlExtensionService, executor.graphql), |
| 109 | ); | 101 | ); |
| 110 | 102 | ||
| 111 | const api = HttpApiBuilder.toWebHandler( | 103 | const api = HttpApiBuilder.toWebHandler( |
| @@ -113,7 +105,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 1 unmodified line | |||
| 113 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), | 105 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 114 | Layer.provideMerge(LocalApiBase), | 106 | Layer.provideMerge(LocalApiBase), |
| 115 | Layer.provideMerge(pluginExtensions), | 107 | Layer.provideMerge(pluginExtensions), |
| 116 | Layer.provideMerge(Layer.succeed(ExecutorService, wrapped)), | 108 | Layer.provideMerge(Layer.succeed(ExecutorService, executor)), |
| 117 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), | 109 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), |
| 118 | Layer.provideMerge(HttpServer.layerContext), | 110 | Layer.provideMerge(HttpServer.layerContext), |
| 119 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | 111 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), |
| @@ -94,35 +94,28 @@ Owns the translation, the opaque wire schema, and the capture service. · 93 unmodified lines | |||
| 94 | Optional — resolved via `Effect.serviceOption`; missing service = | 94 | Optional — resolved via `Effect.serviceOption`; missing service = |
| 95 | empty trace ids. Nothing breaks if it's not wired. | 95 | empty trace ids. Nothing breaks if it's not wired. |
| 96 | 96 | ||
| 97 | - `capture(eff)` — single-Effect wrapper. Catches `StorageError` | 97 | - `capture(eff)` — the single translator. Catches `StorageError` on |
| 98 | on the typed channel, captures the cause via `ErrorCapture`, fails | 98 | the typed channel, captures the cause via `ErrorCapture`, fails with |
| 99 | with `InternalError({ traceId })`. Every other typed failure | 99 | `InternalError({ traceId })`. Catches `UniqueViolationError` and |
| 100 | (`UniqueViolationError`, plugin-domain errors) passes through. | 100 | dies (plugins that want to surface it as a typed domain error should |
| 101 | `Effect.catchTag` inside their own method first). Every other typed | ||
| 102 | failure passes through. | ||
| 101 | 103 | ||
| 102 | - `withCapture(obj)` — proxy wrapper for a whole extension | 104 | Every handler wraps its generator body with `capture(...)`: |
| 103 | surface. Walks methods (recursing into nested plain-object shapes) | ||
| 104 | and applies `capture` to each Effect-returning method. Lets | ||
| 105 | the cloud app wire translation once at Layer composition instead of | ||
| 106 | per handler. | ||
| 107 | 105 | ||
| 108 | ```ts | 106 | ```ts |
| 109 | Layer.succeed( | 107 | .handle("probeEndpoint", ({ payload }) => |
| 110 | McpExtensionService, | 108 | capture(Effect.gen(function* () { |
| 111 | withCapture(executor.mcp), | 109 | const ext = yield* McpExtensionService; |
| 110 | return yield* ext.probeEndpoint(payload.endpoint); | ||
| 111 | })), | ||
| 112 | ) | 112 | ) |
| 113 | ``` | 113 | ``` |
| 114 | 114 | ||
| 115 | - `Captured<T>` — the type-level mirror of | 115 | One line at the top of each handler. No service-level proxy, no |
| 116 | `withCapture`. Service tags declare this post-wrap shape so | 116 | `Captured<T>` type gymnastics — the translation is visible right |
| 117 | handlers see `InternalError` in the method's error union (matching | 117 | where it happens, and TypeScript rejects handlers that forget |
| 118 | the group's `.addError(InternalError)`). Example: | 118 | (because `StorageError` isn't in the group's `.addError` list). |
| 119 | |||
| 120 | ```ts | ||
| 121 | class McpExtensionService extends Context.Tag("McpExtensionService")< | ||
| 122 | McpExtensionService, | ||
| 123 | Captured<McpPluginExtension> | ||
| 124 | >() {} | ||
| 125 | ``` | ||
| 126 | 119 | ||
| 127 | - `observabilityMiddleware(Api)` — defect safety net. An | 120 | - `observabilityMiddleware(Api)` — defect safety net. An |
| 128 | `HttpApiBuilder.middleware` layer that wraps the HttpApp once and | 121 | `HttpApiBuilder.middleware` layer that wraps the HttpApp once and |
| @@ -172,12 +165,13 @@ its annotated status. · 43 unmodified lines | |||
| 172 | - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides | 165 | - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides |
| 173 | `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API | 166 | `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API |
| 174 | layer in `protected-layers.ts` so it's available to both | 167 | layer in `protected-layers.ts` so it's available to both |
| 175 | `observabilityMiddleware` (defect catchall) AND to `withCapture`'s | 168 | `observabilityMiddleware` (defect catchall) AND the per-handler |
| 176 | typed-channel translation. `protected.ts` wraps once per-request: | 169 | `capture(...)` translation. Service tags (`ExecutorService`, |
| 177 | `const wrapped = withCapture(executor)` → every extension service | 170 | `McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app |
| 178 | pulls from `wrapped.X`. (Distinct from `apps/cloud/src/services/telemetry.ts`, | 171 | just does `Layer.succeed(McpExtensionService, executor.mcp)` — |
| 179 | which is the OTEL→Axiom span bridge — "telemetry" in the tracing | 172 | handlers do the translation themselves. (Distinct from |
| 180 | sense.) | 173 | `apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span |
| 174 | bridge — "telemetry" in the tracing sense.) | ||
| 181 | - **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a | 175 | - **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a |
| 182 | console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) | 176 | console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) |
| 183 | that prints the squashed cause + pretty cause to stderr and returns a | 177 | that prints the squashed cause + pretty cause to stderr and returns a |
| @@ -194,8 +188,8 @@ its annotated status. · 10 unmodified lines | |||
| 194 | - Per-plugin `*InternalError` types — clients can't tell which plugin | 188 | - Per-plugin `*InternalError` types — clients can't tell which plugin |
| 195 | emitted a 500 anyway. Use the shared `InternalError`. | 189 | emitted a 500 anyway. Use the shared `InternalError`. |
| 196 | - `sanitize*` helpers in handler files that `catchAllCause` + map to a | 190 | - `sanitize*` helpers in handler files that `catchAllCause` + map to a |
| 197 | generic 500 — same swallowing problem in disguise. Prefer | 191 | generic 500 — same swallowing problem in disguise. Prefer wrapping |
| 198 | `withCapture` at Layer composition. | 192 | the handler's `Effect.gen` body with `capture(...)`. |
| 199 | - SDK code importing `Sentry.captureException` or referencing | 193 | - SDK code importing `Sentry.captureException` or referencing |
| 200 | `InternalError` / `ErrorCapture` — translation lives strictly in | 194 | `InternalError` / `ErrorCapture` — translation lives strictly in |
| 201 | `@executor/api`. If the SDK imports it, the layering is wrong. | 195 | `@executor/api`. If the SDK imports it, the layering is wrong. |
| @@ -1,6 +1,8 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { InternalError } from "../observability"; | ||
| 5 | |||
| 4 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 5 | // Schemas | 7 | // Schemas |
| 6 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| @@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines | |||
| 60 | .setPayload(ResumeRequest) | 62 | .setPayload(ResumeRequest) |
| 61 | .addSuccess(ResumeResponse) | 63 | .addSuccess(ResumeResponse) |
| 62 | .addError(ExecutionNotFoundError), | 64 | .addError(ExecutionNotFoundError), |
| 63 | ) {} | 65 | ) |
| 66 | .addError(InternalError) {} | ||
| @@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines | |||
| 4 | import { ExecutorApi } from "../api"; | 4 | import { ExecutorApi } from "../api"; |
| 5 | import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; | 5 | import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; |
| 6 | import { ExecutionEngineService } from "../services"; | 6 | import { ExecutionEngineService } from "../services"; |
| 7 | import { capture } from "@executor/api"; | ||
| 7 | 8 | ||
| 8 | export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => | 9 | export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => |
| 9 | handlers | 10 | handlers |
| 10 | .handle("execute", ({ payload }) => | 11 | .handle("execute", ({ payload }) => |
| 11 | Effect.gen(function* () { | 12 | capture(Effect.gen(function* () { |
| 12 | const engine = yield* ExecutionEngineService; | 13 | const engine = yield* ExecutionEngineService; |
| 13 | const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code)); | 14 | const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code)); |
| 14 | 15 | ||
| @@ -28,10 +29,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 13 unmodified lines | |||
| 28 | text: formatted.text, | 29 | text: formatted.text, |
| 29 | structured: formatted.structured, | 30 | structured: formatted.structured, |
| 30 | }; | 31 | }; |
| 31 | }), | 32 | })), |
| 32 | ) | 33 | ) |
| 33 | .handle("resume", ({ path, payload }) => | 34 | .handle("resume", ({ path, payload }) => |
| 34 | Effect.gen(function* () { | 35 | capture(Effect.gen(function* () { |
| 35 | const engine = yield* ExecutionEngineService; | 36 | const engine = yield* ExecutionEngineService; |
| 36 | const result = yield* Effect.promise(() => | 37 | const result = yield* Effect.promise(() => |
| 37 | engine.resume(path.executionId, { | 38 | engine.resume(path.executionId, { |
| @@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines | |||
| 62 | structured: formatted.structured, | 63 | structured: formatted.structured, |
| 63 | isError: false, | 64 | isError: false, |
| 64 | }; | 65 | }; |
| 65 | }), | 66 | })), |
| 66 | ), | 67 | ), |
| 67 | ); | 68 | ); |
| @@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { ExecutorApi } from "../api"; | 4 | import { ExecutorApi } from "../api"; |
| 5 | import { ExecutorService } from "../services"; | 5 | import { ExecutorService } from "../services"; |
| 6 | import { capture } from "@executor/api"; | ||
| 6 | 7 | ||
| 7 | export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => | 8 | export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => |
| 8 | handlers.handle("info", () => | 9 | handlers.handle("info", () => |
| 9 | Effect.gen(function* () { | 10 | capture(Effect.gen(function* () { |
| 10 | const executor = yield* ExecutorService; | 11 | const executor = yield* ExecutorService; |
| 11 | return { | 12 | return { |
| 12 | id: executor.scope.id, | 13 | id: executor.scope.id, |
| 13 | name: executor.scope.name, | 14 | name: executor.scope.name, |
| 14 | dir: executor.scope.name, | 15 | dir: executor.scope.name, |
| 15 | }; | 16 | }; |
| 16 | }), | 17 | })), |
| 17 | ), | 18 | ), |
| 18 | ); | 19 | ); |
| @@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { ExecutorApi } from "../api"; | 5 | import { ExecutorApi } from "../api"; |
| 6 | import { ExecutorService } from "../services"; | 6 | import { ExecutorService } from "../services"; |
| 7 | import { capture } from "@executor/api"; | ||
| 7 | 8 | ||
| 8 | const refToResponse = (ref: SecretRef) => ({ | 9 | const refToResponse = (ref: SecretRef) => ({ |
| 9 | id: ref.id, | 10 | id: ref.id, |
| @@ -16,21 +17,21 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines | |||
| 16 | export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) => | 17 | export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) => |
| 17 | handlers | 18 | handlers |
| 18 | .handle("list", () => | 19 | .handle("list", () => |
| 19 | Effect.gen(function* () { | 20 | capture(Effect.gen(function* () { |
| 20 | const executor = yield* ExecutorService; | 21 | const executor = yield* ExecutorService; |
| 21 | const refs = yield* executor.secrets.list(); | 22 | const refs = yield* executor.secrets.list(); |
| 22 | return refs.map(refToResponse); | 23 | return refs.map(refToResponse); |
| 23 | }), | 24 | })), |
| 24 | ) | 25 | ) |
| 25 | .handle("status", ({ path }) => | 26 | .handle("status", ({ path }) => |
| 26 | Effect.gen(function* () { | 27 | capture(Effect.gen(function* () { |
| 27 | const executor = yield* ExecutorService; | 28 | const executor = yield* ExecutorService; |
| 28 | const status = yield* executor.secrets.status(path.secretId); | 29 | const status = yield* executor.secrets.status(path.secretId); |
| 29 | return { secretId: path.secretId, status }; | 30 | return { secretId: path.secretId, status }; |
| 30 | }), | 31 | })), |
| 31 | ) | 32 | ) |
| 32 | .handle("set", ({ payload }) => | 33 | .handle("set", ({ payload }) => |
| 33 | Effect.gen(function* () { | 34 | capture(Effect.gen(function* () { |
| 34 | const executor = yield* ExecutorService; | 35 | const executor = yield* ExecutorService; |
| 35 | const ref = yield* executor.secrets.set({ | 36 | const ref = yield* executor.secrets.set({ |
| 36 | id: payload.id, | 37 | id: payload.id, |
| @@ -39,23 +40,23 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 2 unmodified lines | |||
| 39 | provider: payload.provider, | 40 | provider: payload.provider, |
| 40 | }); | 41 | }); |
| 41 | return refToResponse(ref); | 42 | return refToResponse(ref); |
| 42 | }), | 43 | })), |
| 43 | ) | 44 | ) |
| 44 | .handle("resolve", ({ path }) => | 45 | .handle("resolve", ({ path }) => |
| 45 | Effect.gen(function* () { | 46 | capture(Effect.gen(function* () { |
| 46 | const executor = yield* ExecutorService; | 47 | const executor = yield* ExecutorService; |
| 47 | const value = yield* executor.secrets.get(path.secretId); | 48 | const value = yield* executor.secrets.get(path.secretId); |
| 48 | if (value === null) { | 49 | if (value === null) { |
| 49 | return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); | 50 | return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); |
| 50 | } | 51 | } |
| 51 | return { secretId: path.secretId, value }; | 52 | return { secretId: path.secretId, value }; |
| 52 | }), | 53 | })), |
| 53 | ) | 54 | ) |
| 54 | .handle("remove", ({ path }) => | 55 | .handle("remove", ({ path }) => |
| 55 | Effect.gen(function* () { | 56 | capture(Effect.gen(function* () { |
| 56 | const executor = yield* ExecutorService; | 57 | const executor = yield* ExecutorService; |
| 57 | yield* executor.secrets.remove(path.secretId); | 58 | yield* executor.secrets.remove(path.secretId); |
| 58 | return { removed: true }; | 59 | return { removed: true }; |
| 59 | }), | 60 | })), |
| 60 | ), | 61 | ), |
| 61 | ); | 62 | ); |
| @@ -4,11 +4,12 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { ExecutorApi } from "../api"; | 5 | import { ExecutorApi } from "../api"; |
| 6 | import { ExecutorService } from "../services"; | 6 | import { ExecutorService } from "../services"; |
| 7 | import { capture } from "@executor/api"; | ||
| 7 | 8 | ||
| 8 | export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) => | 9 | export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) => |
| 9 | handlers | 10 | handlers |
| 10 | .handle("list", () => | 11 | .handle("list", () => |
| 11 | Effect.gen(function* () { | 12 | capture(Effect.gen(function* () { |
| 12 | const executor = yield* ExecutorService; | 13 | const executor = yield* ExecutorService; |
| 13 | const sources = yield* executor.sources.list(); | 14 | const sources = yield* executor.sources.list(); |
| 14 | return sources.map((s) => ({ | 15 | return sources.map((s) => ({ |
| @@ -21,24 +22,24 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 6 unmodified lines | |||
| 21 | canRefresh: s.canRefresh, | 22 | canRefresh: s.canRefresh, |
| 22 | canEdit: s.canEdit, | 23 | canEdit: s.canEdit, |
| 23 | })); | 24 | })); |
| 24 | }), | 25 | })), |
| 25 | ) | 26 | ) |
| 26 | .handle("remove", ({ path }) => | 27 | .handle("remove", ({ path }) => |
| 27 | Effect.gen(function* () { | 28 | capture(Effect.gen(function* () { |
| 28 | const executor = yield* ExecutorService; | 29 | const executor = yield* ExecutorService; |
| 29 | yield* executor.sources.remove(path.sourceId); | 30 | yield* executor.sources.remove(path.sourceId); |
| 30 | return { removed: true }; | 31 | return { removed: true }; |
| 31 | }), | 32 | })), |
| 32 | ) | 33 | ) |
| 33 | .handle("refresh", ({ path }) => | 34 | .handle("refresh", ({ path }) => |
| 34 | Effect.gen(function* () { | 35 | capture(Effect.gen(function* () { |
| 35 | const executor = yield* ExecutorService; | 36 | const executor = yield* ExecutorService; |
| 36 | yield* executor.sources.refresh(path.sourceId); | 37 | yield* executor.sources.refresh(path.sourceId); |
| 37 | return { refreshed: true }; | 38 | return { refreshed: true }; |
| 38 | }), | 39 | })), |
| 39 | ) | 40 | ) |
| 40 | .handle("tools", ({ path }) => | 41 | .handle("tools", ({ path }) => |
| 41 | Effect.gen(function* () { | 42 | capture(Effect.gen(function* () { |
| 42 | const executor = yield* ExecutorService; | 43 | const executor = yield* ExecutorService; |
| 43 | const tools = yield* executor.tools.list({ sourceId: path.sourceId }); | 44 | const tools = yield* executor.tools.list({ sourceId: path.sourceId }); |
| 44 | return tools.map((t) => ({ | 45 | return tools.map((t) => ({ |
| @@ -49,10 +50,10 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines | |||
| 49 | description: t.description, | 50 | description: t.description, |
| 50 | mayElicit: t.annotations?.mayElicit, | 51 | mayElicit: t.annotations?.mayElicit, |
| 51 | })); | 52 | })); |
| 52 | }), | 53 | })), |
| 53 | ) | 54 | ) |
| 54 | .handle("detect", ({ payload }) => | 55 | .handle("detect", ({ payload }) => |
| 55 | Effect.gen(function* () { | 56 | capture(Effect.gen(function* () { |
| 56 | const executor = yield* ExecutorService; | 57 | const executor = yield* ExecutorService; |
| 57 | const results = yield* executor.sources.detect(payload.url); | 58 | const results = yield* executor.sources.detect(payload.url); |
| 58 | return results.map((r) => ({ | 59 | return results.map((r) => ({ |
| @@ -62,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 3 unmodified lines | |||
| 62 | name: r.name, | 63 | name: r.name, |
| 63 | namespace: r.namespace, | 64 | namespace: r.namespace, |
| 64 | })); | 65 | })); |
| 65 | }), | 66 | })), |
| 66 | ), | 67 | ), |
| 67 | ); | 68 | ); |
| @@ -4,11 +4,12 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines | |||
| 4 | 4 | ||
| 5 | import { ExecutorApi } from "../api"; | 5 | import { ExecutorApi } from "../api"; |
| 6 | import { ExecutorService } from "../services"; | 6 | import { ExecutorService } from "../services"; |
| 7 | import { capture } from "@executor/api"; | ||
| 7 | 8 | ||
| 8 | export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) => | 9 | export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) => |
| 9 | handlers | 10 | handlers |
| 10 | .handle("list", () => | 11 | .handle("list", () => |
| 11 | Effect.gen(function* () { | 12 | capture(Effect.gen(function* () { |
| 12 | const executor = yield* ExecutorService; | 13 | const executor = yield* ExecutorService; |
| 13 | const tools = yield* executor.tools.list(); | 14 | const tools = yield* executor.tools.list(); |
| 14 | return tools.map((t) => ({ | 15 | return tools.map((t) => ({ |
| @@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 4 unmodified lines | |||
| 19 | description: t.description, | 20 | description: t.description, |
| 20 | mayElicit: t.annotations?.mayElicit, | 21 | mayElicit: t.annotations?.mayElicit, |
| 21 | })); | 22 | })); |
| 22 | }), | 23 | })), |
| 23 | ) | 24 | ) |
| 24 | .handle("schema", ({ path }) => | 25 | .handle("schema", ({ path }) => |
| 25 | Effect.gen(function* () { | 26 | capture(Effect.gen(function* () { |
| 26 | const executor = yield* ExecutorService; | 27 | const executor = yield* ExecutorService; |
| 27 | const schema = yield* executor.tools.schema(path.toolId); | 28 | const schema = yield* executor.tools.schema(path.toolId); |
| 28 | if (schema === null) { | 29 | if (schema === null) { |
| 29 | return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); | 30 | return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); |
| 30 | } | 31 | } |
| 31 | return schema; | 32 | return schema; |
| 32 | }), | 33 | })), |
| 33 | ), | 34 | ), |
| 34 | ); | 35 | ); |
| @@ -9,7 +9,5 @@ export { · 8 unmodified lines | |||
| 9 | ErrorCapture, | 9 | ErrorCapture, |
| 10 | observabilityMiddleware, | 10 | observabilityMiddleware, |
| 11 | capture, | 11 | capture, |
| 12 | withCapture, | ||
| 13 | type Captured, | ||
| 14 | type ErrorCaptureShape, | 12 | type ErrorCaptureShape, |
| 15 | } from "./observability"; | 13 | } from "./observability"; |
| @@ -1,13 +1,7 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Edge translation — `StorageError → InternalError(traceId)` behaviour. | 2 | // Edge translation — `StorageError → InternalError(traceId)` behaviour |
| 3 | // | 3 | // via `capture(eff)`. Handlers wrap their generator bodies with |
| 4 | // SDK code (@executor/sdk) emits raw `StorageError` in its typed | 4 | // `capture(...)`; this file exercises the translator in isolation. |
| 5 | // channel. The HTTP edge has two primitives that translate it: | ||
| 6 | // | ||
| 7 | // - `capture(eff)` — single-Effect wrapper | ||
| 8 | // - `withCapture(obj)` — proxy wrapper for whole extensions | ||
| 9 | // | ||
| 10 | // Both route through the `ErrorCapture` tag to generate a trace id. | ||
| 11 | // ErrorCapture is optional — absent hosts just get empty trace ids. | 5 | // ErrorCapture is optional — absent hosts just get empty trace ids. |
| 12 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 13 | 7 | ||
| @@ -15,12 +9,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 15 | import { Cause, Effect, Layer, Ref } from "effect"; | 9 | import { Cause, Effect, Layer, Ref } from "effect"; |
| 16 | import { StorageError, UniqueViolationError } from "@executor/storage-core"; | 10 | import { StorageError, UniqueViolationError } from "@executor/storage-core"; |
| 17 | 11 | ||
| 18 | import { | 12 | import { capture, ErrorCapture, InternalError } from "./observability"; |
| 19 | capture, | ||
| 20 | ErrorCapture, | ||
| 21 | InternalError, | ||
| 22 | withCapture, | ||
| 23 | } from "./observability"; | ||
| 24 | 13 | ||
| 25 | // Recording ErrorCapture — returns a fixed trace id and accumulates | 14 | // Recording ErrorCapture — returns a fixed trace id and accumulates |
| 26 | // every cause it sees in a Ref. | 15 | // every cause it sees in a Ref. |
| @@ -96,63 +85,3 @@ describe("capture", () => { · 69 unmodified lines | |||
| 96 | ); | 85 | ); |
| 97 | }); | 86 | }); |
| 98 | 87 | ||
| 99 | describe("withCapture", () => { | ||
| 100 | it.effect("wraps every Effect-returning method on the extension", () => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const { layer } = yield* makeRecorder("trace-ext"); | ||
| 103 | |||
| 104 | // Stand-in extension with one failing method and one succeeding | ||
| 105 | // method. Walk the proxy to prove translation is structural, not | ||
| 106 | // per-method. | ||
| 107 | const ext = { | ||
| 108 | boom: () => | ||
| 109 | Effect.fail( | ||
| 110 | new StorageError({ message: "boom", cause: undefined }), | ||
| 111 | ), | ||
| 112 | ok: () => Effect.succeed("fine"), | ||
| 113 | }; | ||
| 114 | const wrapped = withCapture(ext); | ||
| 115 | |||
| 116 | const boomResult = yield* Effect.flip(wrapped.boom()).pipe( | ||
| 117 | Effect.provide(layer), | ||
| 118 | ); | ||
| 119 | expect(boomResult).toBeInstanceOf(InternalError); | ||
| 120 | expect(boomResult.traceId).toBe("trace-ext"); | ||
| 121 | |||
| 122 | const okResult = yield* wrapped.ok(); | ||
| 123 | expect(okResult).toBe("fine"); | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | |||
| 127 | it.effect("recurses into nested plain-object surfaces", () => | ||
| 128 | Effect.gen(function* () { | ||
| 129 | const ext = { | ||
| 130 | nested: { | ||
| 131 | boom: () => | ||
| 132 | Effect.fail( | ||
| 133 | new StorageError({ message: "boom", cause: undefined }), | ||
| 134 | ), | ||
| 135 | }, | ||
| 136 | }; | ||
| 137 | const wrapped = withCapture(ext); | ||
| 138 | const result = yield* Effect.flip(wrapped.nested.boom()); | ||
| 139 | expect(result).toBeInstanceOf(InternalError); | ||
| 140 | }), | ||
| 141 | ); | ||
| 142 | |||
| 143 | it.effect("UniqueViolationError becomes a defect through the wrapper", () => | ||
| 144 | Effect.gen(function* () { | ||
| 145 | const ext = { | ||
| 146 | conflict: () => | ||
| 147 | Effect.fail(new UniqueViolationError({ model: "thing" })), | ||
| 148 | }; | ||
| 149 | const wrapped = withCapture(ext); | ||
| 150 | const exit = yield* Effect.exit(wrapped.conflict()); | ||
| 151 | expect(exit._tag).toBe("Failure"); | ||
| 152 | if (exit._tag === "Failure") { | ||
| 153 | const defects = Cause.defects(exit.cause); | ||
| 154 | expect(Array.from(defects)[0]).toBeInstanceOf(UniqueViolationError); | ||
| 155 | } | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | }); | ||
| @@ -13,11 +13,14 @@ · 12 unmodified lines | |||
| 13 | // the cloud Worker, console in the CLI, in-memory in tests) to | 13 | // the cloud Worker, console in the CLI, in-memory in tests) to |
| 14 | // record causes and return correlation ids. Optional; absent → | 14 | // record causes and return correlation ids. Optional; absent → |
| 15 | // empty trace ids, nothing breaks. | 15 | // empty trace ids, nothing breaks. |
| 16 | // 3. `capture` / `withCapture` — one-Effect and whole-surface helpers | 16 | // 3. `capture(eff)` — the one translator. Catches `StorageError` and |
| 17 | // that translate `StorageError` to `InternalError({ traceId })` at | 17 | // `UniqueViolationError` in the typed channel: the former is |
| 18 | // Layer composition. Applied ONCE at the top of service wiring — | 18 | // captured via `ErrorCapture` and re-failed as `InternalError({ |
| 19 | // `withCapture(executor)` covers the entire executor surface and | 19 | // traceId })`; the latter dies as a defect (plugins that want to |
| 20 | // every plugin extension on it, in one call. | 20 | // surface it as a typed domain error should `Effect.catchTag` |
| 21 | // inside their own method first). Every handler wraps its | ||
| 22 | // generator body with `capture(...)` — one line, explicit, | ||
| 23 | // self-enforcing (TypeScript rejects the handler if it forgets). | ||
| 21 | // 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp | 24 | // 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp |
| 22 | // once; catches any cause that slipped past the typed channel and | 25 | // once; catches any cause that slipped past the typed channel and |
| 23 | // produces the same `InternalError({ traceId })` shape. | 26 | // produces the same `InternalError({ traceId })` shape. |
| @@ -111,104 +114,6 @@ export const capture = <A, E, R>( · 87 unmodified lines | |||
| 111 | ), | 114 | ), |
| 112 | ) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>; | 115 | ) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>; |
| 113 | 116 | ||
| 114 | // --------------------------------------------------------------------------- | ||
| 115 | // withCapture — walk an object's methods and wrap each Effect-returning | ||
| 116 | // one with `capture`. Applied once, at the top of the service-wiring | ||
| 117 | // Layer, to the whole executor: | ||
| 118 | // | ||
| 119 | // const wrapped = withCapture(executor); | ||
| 120 | // Layer.succeed(ExecutorService, wrapped); | ||
| 121 | // Layer.succeed(McpExtensionService, wrapped.mcp); | ||
| 122 | // ... | ||
| 123 | // | ||
| 124 | // Nested plain objects (e.g. `wrapped.tools.list`) are walked | ||
| 125 | // recursively so the full surface — core + every plugin extension — | ||
| 126 | // ends up wrapped in one shot. Non-plain values (Date, Array, tagged | ||
| 127 | // errors, class instances with a non-Object prototype) pass through | ||
| 128 | // untouched so `wrapped.scope` etc. stay identity-equal. | ||
| 129 | // --------------------------------------------------------------------------- | ||
| 130 | |||
| 131 | const isPlainObject = (v: unknown): v is Record<string | symbol, unknown> => { | ||
| 132 | if (v === null || typeof v !== "object") return false; | ||
| 133 | if (Array.isArray(v)) return false; | ||
| 134 | if (v instanceof Date || v instanceof Promise) return false; | ||
| 135 | const proto = Object.getPrototypeOf(v); | ||
| 136 | return proto === Object.prototype || proto === null; | ||
| 137 | }; | ||
| 138 | |||
| 139 | /** | ||
| 140 | * Type-level mirror of `withCapture` — every Effect-returning method | ||
| 141 | * has its `StorageFailure` variant replaced with `InternalError`. Use | ||
| 142 | * to declare service tags that hold the already-captured shape: | ||
| 143 | * | ||
| 144 | * class McpExtensionService extends Context.Tag("...")< | ||
| 145 | * McpExtensionService, | ||
| 146 | * Captured<McpPluginExtension> | ||
| 147 | * >() {} | ||
| 148 | */ | ||
| 149 | // Opaque leaves that we DO NOT want `Captured<T>` to descend into at the | ||
| 150 | // type level — the runtime Proxy also leaves these alone (via | ||
| 151 | // `isPlainObject` returning false for class instances). Includes: | ||
| 152 | // | ||
| 153 | // - primitives (branded strings/numbers show up as intersections with | ||
| 154 | // `{ [BrandTypeId]: ... }`, which TS reads as `extends object` — we | ||
| 155 | // have to exclude them explicitly), | ||
| 156 | // - common stdlib boxed types (Date, Promise, Error, Array), | ||
| 157 | // - Schema-class instances and tagged errors. These all carry a | ||
| 158 | // non-Object prototype at runtime so the Proxy short-circuits; the | ||
| 159 | // type mirror stops with the same shape. | ||
| 160 | // | ||
| 161 | // Anything else that's a plain object (the executor surface, plugin | ||
| 162 | // extensions, their namespaced sub-objects) walks recursively. | ||
| 163 | type CapturedOpaque = | ||
| 164 | | string | ||
| 165 | | number | ||
| 166 | | boolean | ||
| 167 | | bigint | ||
| 168 | | symbol | ||
| 169 | | null | ||
| 170 | | undefined | ||
| 171 | | Date | ||
| 172 | | Promise<unknown> | ||
| 173 | | Error | ||
| 174 | | readonly unknown[]; | ||
| 175 | |||
| 176 | export type Captured<T> = T extends ( | ||
| 177 | ...args: infer A | ||
| 178 | ) => Effect.Effect<infer X, infer E, infer R> | ||
| 179 | ? ( | ||
| 180 | ...args: A | ||
| 181 | ) => Effect.Effect<X, Exclude<E, StorageFailure> | InternalError, R> | ||
| 182 | : T extends (...args: infer A) => infer U | ||
| 183 | ? (...args: A) => U | ||
| 184 | : T extends CapturedOpaque | ||
| 185 | ? T | ||
| 186 | : T extends object | ||
| 187 | ? { readonly [K in keyof T]: Captured<T[K]> } | ||
| 188 | : T; | ||
| 189 | |||
| 190 | export const withCapture = <T extends object>(value: T): Captured<T> => { | ||
| 191 | return new Proxy(value, { | ||
| 192 | get(target, prop, receiver) { | ||
| 193 | const v = Reflect.get(target, prop, receiver); | ||
| 194 | if (typeof v === "function") { | ||
| 195 | return (...args: unknown[]) => { | ||
| 196 | const result = (v as (...a: unknown[]) => unknown).apply( | ||
| 197 | target, | ||
| 198 | args, | ||
| 199 | ); | ||
| 200 | if (Effect.isEffect(result)) { | ||
| 201 | return capture(result as Effect.Effect<unknown, unknown, unknown>); | ||
| 202 | } | ||
| 203 | return result; | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | if (isPlainObject(v)) return withCapture(v); | ||
| 207 | return v; | ||
| 208 | }, | ||
| 209 | }) as Captured<T>; | ||
| 210 | }; | ||
| 211 | |||
| 212 | /** | 117 | /** |
| 213 | * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer | 118 | * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer |
| 214 | * that wraps the HttpApp once. Captures any cause (defects, interrupts, | 119 | * that wraps the HttpApp once. Captures any cause (defects, interrupts, |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; · 1 unmodified line | |||
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ScopeId } from "@executor/sdk"; | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | 4 | ||
| 5 | import { InternalError } from "../observability"; | ||
| 6 | |||
| 5 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 6 | // Response schemas | 8 | // Response schemas |
| 7 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| @@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines | |||
| 16 | // Group | 18 | // Group |
| 17 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
| 18 | 20 | ||
| 19 | export class ScopeApi extends HttpApiGroup.make("scope").add( | 21 | export class ScopeApi extends HttpApiGroup.make("scope") |
| 20 | HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse), | 22 | .add(HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse)) |
| 21 | ) {} | 23 | .addError(InternalError) {} |
| @@ -1,11 +1,10 @@ | |||
| 1 | import { Context } from "effect"; | 1 | import { Context } from "effect"; |
| 2 | import type { Executor } from "@executor/sdk"; | 2 | import type { Executor } from "@executor/sdk"; |
| 3 | import type { ExecutionEngine } from "@executor/execution"; | 3 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | import type { Captured } from "./observability"; | ||
| 5 | 4 | ||
| 6 | export class ExecutorService extends Context.Tag("ExecutorService")< | 5 | export class ExecutorService extends Context.Tag("ExecutorService")< |
| 7 | ExecutorService, | 6 | ExecutorService, |
| 8 | Captured<Executor> | 7 | Executor |
| 9 | >() {} | 8 | >() {} |
| 10 | 9 | ||
| 11 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< | 10 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| @@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines | |||
| 11 | import { describe, expect, it } from "@effect/vitest"; | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | import { Effect, Layer } from "effect"; | 12 | import { Effect, Layer } from "effect"; |
| 13 | 13 | ||
| 14 | import { | 14 | import { addGroup, observabilityMiddleware } from "@executor/api"; |
| 15 | addGroup, | ||
| 16 | observabilityMiddleware, | ||
| 17 | withCapture, | ||
| 18 | } from "@executor/api"; | ||
| 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 20 | import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; | 16 | import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; |
| 21 | import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; | 17 | import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; |
| @@ -47,10 +43,7 @@ const WebHandler = Effect.acquireRelease( · 25 unmodified lines | |||
| 47 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), | 43 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), |
| 48 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), | 44 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), |
| 49 | Layer.provide( | 45 | Layer.provide( |
| 50 | Layer.succeed( | 46 | Layer.succeed(GoogleDiscoveryExtensionService, failingExtension), |
| 51 | GoogleDiscoveryExtensionService, | ||
| 52 | withCapture(failingExtension), | ||
| 53 | ), | ||
| 54 | ), | 47 | ), |
| 55 | Layer.provideMerge(HttpServer.layerContext), | 48 | Layer.provideMerge(HttpServer.layerContext), |
| 56 | Layer.provideMerge(HttpApiBuilder.Router.Live), | 49 | Layer.provideMerge(HttpApiBuilder.Router.Live), |
| @@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | ||
| 6 | import { addGroup, type Captured } from "@executor/api"; | 6 | import { addGroup, capture } from "@executor/api"; |
| 7 | import type { | 7 | import type { |
| 8 | GoogleDiscoveryAddSourceInput, | 8 | GoogleDiscoveryAddSourceInput, |
| 9 | GoogleDiscoveryOAuthAuthResult, | 9 | GoogleDiscoveryOAuthAuthResult, |
| @@ -25,7 +25,7 @@ import { GoogleDiscoveryGroup } from "./group"; · 15 unmodified lines | |||
| 25 | 25 | ||
| 26 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< | 26 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 27 | GoogleDiscoveryExtensionService, | 27 | GoogleDiscoveryExtensionService, |
| 28 | Captured<GoogleDiscoveryPluginExtension> | 28 | GoogleDiscoveryPluginExtension |
| 29 | >() {} | 29 | >() {} |
| 30 | 30 | ||
| 31 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| @@ -60,19 +60,19 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 28 unmodified lines | |||
| 60 | (handlers) => | 60 | (handlers) => |
| 61 | handlers | 61 | handlers |
| 62 | .handle("probeDiscovery", ({ payload }) => | 62 | .handle("probeDiscovery", ({ payload }) => |
| 63 | Effect.gen(function* () { | 63 | capture(Effect.gen(function* () { |
| 64 | const ext = yield* GoogleDiscoveryExtensionService; | 64 | const ext = yield* GoogleDiscoveryExtensionService; |
| 65 | return yield* ext.probeDiscovery(payload.discoveryUrl); | 65 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 66 | }), | 66 | })), |
| 67 | ) | 67 | ) |
| 68 | .handle("addSource", ({ payload }) => | 68 | .handle("addSource", ({ payload }) => |
| 69 | Effect.gen(function* () { | 69 | capture(Effect.gen(function* () { |
| 70 | const ext = yield* GoogleDiscoveryExtensionService; | 70 | const ext = yield* GoogleDiscoveryExtensionService; |
| 71 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); | 71 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 72 | }), | 72 | })), |
| 73 | ) | 73 | ) |
| 74 | .handle("startOAuth", ({ payload }) => | 74 | .handle("startOAuth", ({ payload }) => |
| 75 | Effect.gen(function* () { | 75 | capture(Effect.gen(function* () { |
| 76 | const ext = yield* GoogleDiscoveryExtensionService; | 76 | const ext = yield* GoogleDiscoveryExtensionService; |
| 77 | return yield* ext.startOAuth({ | 77 | return yield* ext.startOAuth({ |
| 78 | name: payload.name, | 78 | name: payload.name, |
| @@ -82,26 +82,26 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | |||
| 82 | redirectUrl: payload.redirectUrl, | 82 | redirectUrl: payload.redirectUrl, |
| 83 | scopes: payload.scopes, | 83 | scopes: payload.scopes, |
| 84 | }); | 84 | }); |
| 85 | }), | 85 | })), |
| 86 | ) | 86 | ) |
| 87 | .handle("completeOAuth", ({ payload }) => | 87 | .handle("completeOAuth", ({ payload }) => |
| 88 | Effect.gen(function* () { | 88 | capture(Effect.gen(function* () { |
| 89 | const ext = yield* GoogleDiscoveryExtensionService; | 89 | const ext = yield* GoogleDiscoveryExtensionService; |
| 90 | return yield* ext.completeOAuth({ | 90 | return yield* ext.completeOAuth({ |
| 91 | state: payload.state, | 91 | state: payload.state, |
| 92 | code: payload.code, | 92 | code: payload.code, |
| 93 | error: payload.error, | 93 | error: payload.error, |
| 94 | }); | 94 | }); |
| 95 | }), | 95 | })), |
| 96 | ) | 96 | ) |
| 97 | .handle("getSource", ({ path }) => | 97 | .handle("getSource", ({ path }) => |
| 98 | Effect.gen(function* () { | 98 | capture(Effect.gen(function* () { |
| 99 | const ext = yield* GoogleDiscoveryExtensionService; | 99 | const ext = yield* GoogleDiscoveryExtensionService; |
| 100 | return yield* ext.getSource(path.namespace); | 100 | return yield* ext.getSource(path.namespace); |
| 101 | }), | 101 | })), |
| 102 | ) | 102 | ) |
| 103 | .handle("oauthCallback", ({ urlParams }) => | 103 | .handle("oauthCallback", ({ urlParams }) => |
| 104 | Effect.gen(function* () { | 104 | capture(Effect.gen(function* () { |
| 105 | const ext = yield* GoogleDiscoveryExtensionService; | 105 | const ext = yield* GoogleDiscoveryExtensionService; |
| 106 | const html = yield* runOAuthCallback< | 106 | const html = yield* runOAuthCallback< |
| 107 | GoogleDiscoveryOAuthAuthResult, | 107 | GoogleDiscoveryOAuthAuthResult, |
| @@ -119,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 11 unmodified lines | |||
| 119 | channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL, | 119 | channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL, |
| 120 | }); | 120 | }); |
| 121 | return yield* HttpServerResponse.html(html); | 121 | return yield* HttpServerResponse.html(html); |
| 122 | }), | 122 | })), |
| 123 | ), | 123 | ), |
| 124 | ); | 124 | ); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import { Context, Effect } from "effect"; | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup, type Captured } from "@executor/api"; | 4 | import { addGroup, capture } from "@executor/api"; |
| 5 | import type { | 5 | import type { |
| 6 | GraphqlPluginExtension, | 6 | GraphqlPluginExtension, |
| 7 | HeaderValue, | 7 | HeaderValue, |
| @@ -22,7 +22,7 @@ import { GraphqlGroup } from "./group"; · 14 unmodified lines | |||
| 22 | 22 | ||
| 23 | export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")< | 23 | export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")< |
| 24 | GraphqlExtensionService, | 24 | GraphqlExtensionService, |
| 25 | Captured<GraphqlPluginExtension> | 25 | GraphqlPluginExtension |
| 26 | >() {} | 26 | >() {} |
| 27 | 27 | ||
| 28 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| @@ -44,7 +44,7 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 15 unmodified lines | |||
| 44 | export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => | 44 | export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => |
| 45 | handlers | 45 | handlers |
| 46 | .handle("addSource", ({ payload }) => | 46 | .handle("addSource", ({ payload }) => |
| 47 | Effect.gen(function* () { | 47 | capture(Effect.gen(function* () { |
| 48 | const ext = yield* GraphqlExtensionService; | 48 | const ext = yield* GraphqlExtensionService; |
| 49 | const result = yield* ext.addSource({ | 49 | const result = yield* ext.addSource({ |
| 50 | endpoint: payload.endpoint, | 50 | endpoint: payload.endpoint, |
| @@ -57,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines | |||
| 57 | toolCount: result.toolCount, | 57 | toolCount: result.toolCount, |
| 58 | namespace: payload.namespace ?? "graphql", | 58 | namespace: payload.namespace ?? "graphql", |
| 59 | }; | 59 | }; |
| 60 | }), | 60 | })), |
| 61 | ) | 61 | ) |
| 62 | .handle("getSource", ({ path }) => | 62 | .handle("getSource", ({ path }) => |
| 63 | Effect.gen(function* () { | 63 | capture(Effect.gen(function* () { |
| 64 | const ext = yield* GraphqlExtensionService; | 64 | const ext = yield* GraphqlExtensionService; |
| 65 | return yield* ext.getSource(path.namespace); | 65 | return yield* ext.getSource(path.namespace); |
| 66 | }), | 66 | })), |
| 67 | ) | 67 | ) |
| 68 | .handle("updateSource", ({ path, payload }) => | 68 | .handle("updateSource", ({ path, payload }) => |
| 69 | Effect.gen(function* () { | 69 | capture(Effect.gen(function* () { |
| 70 | const ext = yield* GraphqlExtensionService; | 70 | const ext = yield* GraphqlExtensionService; |
| 71 | yield* ext.updateSource(path.namespace, { | 71 | yield* ext.updateSource(path.namespace, { |
| 72 | name: payload.name, | 72 | name: payload.name, |
| @@ -74,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line | |||
| 74 | headers: payload.headers as Record<string, HeaderValue> | undefined, | 74 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 75 | } as GraphqlUpdateSourceInput); | 75 | } as GraphqlUpdateSourceInput); |
| 76 | return { updated: true }; | 76 | return { updated: true }; |
| 77 | }), | 77 | })), |
| 78 | ), | 78 | ), |
| 79 | ); | 79 | ); |
| @@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines | |||
| 11 | import { describe, expect, it } from "@effect/vitest"; | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | import { Effect, Layer } from "effect"; | 12 | import { Effect, Layer } from "effect"; |
| 13 | 13 | ||
| 14 | import { | 14 | import { addGroup, observabilityMiddleware } from "@executor/api"; |
| 15 | addGroup, | ||
| 16 | observabilityMiddleware, | ||
| 17 | withCapture, | ||
| 18 | } from "@executor/api"; | ||
| 19 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 20 | import type { McpPluginExtension } from "../sdk/plugin"; | 16 | import type { McpPluginExtension } from "../sdk/plugin"; |
| 21 | import { McpExtensionService, McpHandlers } from "./handlers"; | 17 | import { McpExtensionService, McpHandlers } from "./handlers"; |
| @@ -50,12 +46,7 @@ const WebHandler = Effect.acquireRelease( · 28 unmodified lines | |||
| 50 | Layer.provide(observabilityMiddleware(Api)), | 46 | Layer.provide(observabilityMiddleware(Api)), |
| 51 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), | 47 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), |
| 52 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), | 48 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), |
| 53 | Layer.provide( | 49 | Layer.provide(Layer.succeed(McpExtensionService, failingExtension)), |
| 54 | Layer.succeed( | ||
| 55 | McpExtensionService, | ||
| 56 | withCapture(failingExtension), | ||
| 57 | ), | ||
| 58 | ), | ||
| 59 | Layer.provideMerge(HttpServer.layerContext), | 50 | Layer.provideMerge(HttpServer.layerContext), |
| 60 | Layer.provideMerge(HttpApiBuilder.Router.Live), | 51 | Layer.provideMerge(HttpApiBuilder.Router.Live), |
| 61 | Layer.provideMerge(HttpApiBuilder.Middleware.layer), | 52 | Layer.provideMerge(HttpApiBuilder.Middleware.layer), |
| @@ -1,25 +1,22 @@ | |||
| 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Cause, Context, Effect } from "effect"; | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup, type Captured } from "@executor/api"; | 4 | import { addGroup, capture } from "@executor/api"; |
| 5 | import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin"; | 5 | import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin"; |
| 6 | import { McpOAuthError } from "../sdk/errors"; | 6 | import { McpOAuthError } from "../sdk/errors"; |
| 7 | import { McpGroup } from "./group"; | 7 | import { McpGroup } from "./group"; |
| 8 | 8 | ||
| 9 | // --------------------------------------------------------------------------- | 9 | // --------------------------------------------------------------------------- |
| 10 | // Service tag | 10 | // Service tag — holds the raw extension shape the executor produces. |
| 11 | // | 11 | // Handlers wrap their generator bodies with `capture(...)` from |
| 12 | // Holds the `Captured` shape — every method's `StorageError` | 12 | // `@executor/api`, which translates `StorageError` to `InternalError` |
| 13 | // channel has been swapped for `InternalError({ traceId })`. The cloud | 13 | // at the edge; that's why the tag type matches the SDK shape directly |
| 14 | // app provides an already-wrapped extension via | 14 | // (no `Captured<>` inversion). |
| 15 | // `Layer.succeed(McpExtensionService, withCapture(executor.mcp))`. | ||
| 16 | // Handlers see `InternalError` in the error union, which matches | ||
| 17 | // `.addError(InternalError)` on the group — no per-handler translation. | ||
| 18 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 19 | 16 | ||
| 20 | export class McpExtensionService extends Context.Tag("McpExtensionService")< | 17 | export class McpExtensionService extends Context.Tag("McpExtensionService")< |
| 21 | McpExtensionService, | 18 | McpExtensionService, |
| 22 | Captured<McpPluginExtension> | 19 | McpPluginExtension |
| 23 | >() {} | 20 | >() {} |
| 24 | 21 | ||
| 25 | // --------------------------------------------------------------------------- | 22 | // --------------------------------------------------------------------------- |
| @@ -168,60 +165,60 @@ const toSourceConfig = ( · 142 unmodified lines | |||
| 168 | export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) => | 165 | export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) => |
| 169 | handlers | 166 | handlers |
| 170 | .handle("probeEndpoint", ({ payload }) => | 167 | .handle("probeEndpoint", ({ payload }) => |
| 171 | Effect.gen(function* () { | 168 | capture(Effect.gen(function* () { |
| 172 | const ext = yield* McpExtensionService; | 169 | const ext = yield* McpExtensionService; |
| 173 | return yield* ext.probeEndpoint(payload.endpoint); | 170 | return yield* ext.probeEndpoint(payload.endpoint); |
| 174 | }), | 171 | })), |
| 175 | ) | 172 | ) |
| 176 | .handle("addSource", ({ payload }) => | 173 | .handle("addSource", ({ payload }) => |
| 177 | Effect.gen(function* () { | 174 | capture(Effect.gen(function* () { |
| 178 | const ext = yield* McpExtensionService; | 175 | const ext = yield* McpExtensionService; |
| 179 | return yield* ext.addSource( | 176 | return yield* ext.addSource( |
| 180 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), | 177 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), |
| 181 | ); | 178 | ); |
| 182 | }), | 179 | })), |
| 183 | ) | 180 | ) |
| 184 | .handle("removeSource", ({ payload }) => | 181 | .handle("removeSource", ({ payload }) => |
| 185 | Effect.gen(function* () { | 182 | capture(Effect.gen(function* () { |
| 186 | const ext = yield* McpExtensionService; | 183 | const ext = yield* McpExtensionService; |
| 187 | yield* ext.removeSource(payload.namespace); | 184 | yield* ext.removeSource(payload.namespace); |
| 188 | return { removed: true }; | 185 | return { removed: true }; |
| 189 | }), | 186 | })), |
| 190 | ) | 187 | ) |
| 191 | .handle("refreshSource", ({ payload }) => | 188 | .handle("refreshSource", ({ payload }) => |
| 192 | Effect.gen(function* () { | 189 | capture(Effect.gen(function* () { |
| 193 | const ext = yield* McpExtensionService; | 190 | const ext = yield* McpExtensionService; |
| 194 | return yield* ext.refreshSource(payload.namespace); | 191 | return yield* ext.refreshSource(payload.namespace); |
| 195 | }), | 192 | })), |
| 196 | ) | 193 | ) |
| 197 | .handle("startOAuth", ({ payload }) => | 194 | .handle("startOAuth", ({ payload }) => |
| 198 | Effect.gen(function* () { | 195 | capture(Effect.gen(function* () { |
| 199 | const ext = yield* McpExtensionService; | 196 | const ext = yield* McpExtensionService; |
| 200 | return yield* ext.startOAuth({ | 197 | return yield* ext.startOAuth({ |
| 201 | endpoint: payload.endpoint, | 198 | endpoint: payload.endpoint, |
| 202 | redirectUrl: payload.redirectUrl, | 199 | redirectUrl: payload.redirectUrl, |
| 203 | queryParams: payload.queryParams, | 200 | queryParams: payload.queryParams, |
| 204 | }); | 201 | }); |
| 205 | }), | 202 | })), |
| 206 | ) | 203 | ) |
| 207 | .handle("completeOAuth", ({ payload }) => | 204 | .handle("completeOAuth", ({ payload }) => |
| 208 | Effect.gen(function* () { | 205 | capture(Effect.gen(function* () { |
| 209 | const ext = yield* McpExtensionService; | 206 | const ext = yield* McpExtensionService; |
| 210 | return yield* ext.completeOAuth({ | 207 | return yield* ext.completeOAuth({ |
| 211 | state: payload.state, | 208 | state: payload.state, |
| 212 | code: payload.code, | 209 | code: payload.code, |
| 213 | error: payload.error, | 210 | error: payload.error, |
| 214 | }); | 211 | }); |
| 215 | }), | 212 | })), |
| 216 | ) | 213 | ) |
| 217 | .handle("getSource", ({ path }) => | 214 | .handle("getSource", ({ path }) => |
| 218 | Effect.gen(function* () { | 215 | capture(Effect.gen(function* () { |
| 219 | const ext = yield* McpExtensionService; | 216 | const ext = yield* McpExtensionService; |
| 220 | return yield* ext.getSource(path.namespace); | 217 | return yield* ext.getSource(path.namespace); |
| 221 | }), | 218 | })), |
| 222 | ) | 219 | ) |
| 223 | .handle("updateSource", ({ path, payload }) => | 220 | .handle("updateSource", ({ path, payload }) => |
| 224 | Effect.gen(function* () { | 221 | capture(Effect.gen(function* () { |
| 225 | const ext = yield* McpExtensionService; | 222 | const ext = yield* McpExtensionService; |
| 226 | yield* ext.updateSource(path.namespace, { | 223 | yield* ext.updateSource(path.namespace, { |
| 227 | name: payload.name, | 224 | name: payload.name, |
| @@ -231,13 +228,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | |||
| 231 | auth: payload.auth as McpUpdateSourceInput["auth"], | 228 | auth: payload.auth as McpUpdateSourceInput["auth"], |
| 232 | }); | 229 | }); |
| 233 | return { updated: true }; | 230 | return { updated: true }; |
| 234 | }), | 231 | })), |
| 235 | ) | 232 | ) |
| 236 | .handle("oauthCallback", ({ urlParams }) => | 233 | .handle("oauthCallback", ({ urlParams }) => |
| 237 | // OAuth popup is special: it always returns 200 HTML and renders the | 234 | // OAuth popup is special: it always returns 200 HTML and renders the |
| 238 | // failure into the popup body so the parent window's listener gets a | 235 | // failure into the popup body so the parent window's listener gets a |
| 239 | // structured result. Catching here is intentional, not a leak. | 236 | // structured result. Catching here is intentional, not a leak. |
| 240 | Effect.gen(function* () { | 237 | capture(Effect.gen(function* () { |
| 241 | const ext = yield* McpExtensionService; | 238 | const ext = yield* McpExtensionService; |
| 242 | const result = yield* Effect.matchCauseEffect( | 239 | const result = yield* Effect.matchCauseEffect( |
| 243 | ext.completeOAuth({ | 240 | ext.completeOAuth({ |
| @@ -267,6 +264,6 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 23 unmodified lines | |||
| 267 | }, | 264 | }, |
| 268 | ); | 265 | ); |
| 269 | return yield* HttpServerResponse.html(popupDocument(result)); | 266 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 270 | }), | 267 | })), |
| 271 | ), | 268 | ), |
| 272 | ); | 269 | ); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { HttpApiBuilder } from "@effect/platform"; | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | import { Context, Effect } from "effect"; | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { addGroup, type Captured } from "@executor/api"; | 4 | import { addGroup, capture } from "@executor/api"; |
| 5 | import type { OnePasswordExtension } from "../sdk/plugin"; | 5 | import type { OnePasswordExtension } from "../sdk/plugin"; |
| 6 | import { OnePasswordGroup } from "./group"; | 6 | import { OnePasswordGroup } from "./group"; |
| 7 | 7 | ||
| @@ -18,7 +18,7 @@ import { OnePasswordGroup } from "./group"; · 10 unmodified lines | |||
| 18 | 18 | ||
| 19 | export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")< | 19 | export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")< |
| 20 | OnePasswordExtensionService, | 20 | OnePasswordExtensionService, |
| 21 | Captured<OnePasswordExtension> | 21 | OnePasswordExtension |
| 22 | >() {} | 22 | >() {} |
| 23 | 23 | ||
| 24 | // --------------------------------------------------------------------------- | 24 | // --------------------------------------------------------------------------- |
| @@ -43,31 +43,31 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 18 unmodified lines | |||
| 43 | (handlers) => | 43 | (handlers) => |
| 44 | handlers | 44 | handlers |
| 45 | .handle("getConfig", () => | 45 | .handle("getConfig", () => |
| 46 | Effect.gen(function* () { | 46 | capture(Effect.gen(function* () { |
| 47 | const ext = yield* OnePasswordExtensionService; | 47 | const ext = yield* OnePasswordExtensionService; |
| 48 | return yield* ext.getConfig(); | 48 | return yield* ext.getConfig(); |
| 49 | }), | 49 | })), |
| 50 | ) | 50 | ) |
| 51 | .handle("configure", ({ payload }) => | 51 | .handle("configure", ({ payload }) => |
| 52 | Effect.gen(function* () { | 52 | capture(Effect.gen(function* () { |
| 53 | const ext = yield* OnePasswordExtensionService; | 53 | const ext = yield* OnePasswordExtensionService; |
| 54 | yield* ext.configure(payload); | 54 | yield* ext.configure(payload); |
| 55 | }), | 55 | })), |
| 56 | ) | 56 | ) |
| 57 | .handle("removeConfig", () => | 57 | .handle("removeConfig", () => |
| 58 | Effect.gen(function* () { | 58 | capture(Effect.gen(function* () { |
| 59 | const ext = yield* OnePasswordExtensionService; | 59 | const ext = yield* OnePasswordExtensionService; |
| 60 | yield* ext.removeConfig(); | 60 | yield* ext.removeConfig(); |
| 61 | }), | 61 | })), |
| 62 | ) | 62 | ) |
| 63 | .handle("status", () => | 63 | .handle("status", () => |
| 64 | Effect.gen(function* () { | 64 | capture(Effect.gen(function* () { |
| 65 | const ext = yield* OnePasswordExtensionService; | 65 | const ext = yield* OnePasswordExtensionService; |
| 66 | return yield* ext.status(); | 66 | return yield* ext.status(); |
| 67 | }), | 67 | })), |
| 68 | ) | 68 | ) |
| 69 | .handle("listVaults", ({ urlParams }) => | 69 | .handle("listVaults", ({ urlParams }) => |
| 70 | Effect.gen(function* () { | 70 | capture(Effect.gen(function* () { |
| 71 | const ext = yield* OnePasswordExtensionService; | 71 | const ext = yield* OnePasswordExtensionService; |
| 72 | const auth = | 72 | const auth = |
| 73 | urlParams.authKind === "desktop-app" | 73 | urlParams.authKind === "desktop-app" |
| @@ -75,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line | |||
| 75 | : { kind: "service-account" as const, tokenSecretId: urlParams.account }; | 75 | : { kind: "service-account" as const, tokenSecretId: urlParams.account }; |
| 76 | const vaults = yield* ext.listVaults(auth); | 76 | const vaults = yield* ext.listVaults(auth); |
| 77 | return { vaults: [...vaults] }; | 77 | return { vaults: [...vaults] }; |
| 78 | }), | 78 | })), |
| 79 | ), | 79 | ), |
| 80 | ); | 80 | ); |
| @@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | ||
| 6 | import { addGroup, InternalError, type Captured } from "@executor/api"; | 6 | import { addGroup, capture, InternalError } from "@executor/api"; |
| 7 | import { OpenApiOAuthError } from "../sdk/errors"; | 7 | import { OpenApiOAuthError } from "../sdk/errors"; |
| 8 | import type { | 8 | import type { |
| 9 | OpenApiPluginExtension, | 9 | OpenApiPluginExtension, |
| @@ -33,7 +33,7 @@ const toPopupErrorMessage = (error: unknown): string => { · 23 unmodified lines | |||
| 33 | 33 | ||
| 34 | export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")< | 34 | export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")< |
| 35 | OpenApiExtensionService, | 35 | OpenApiExtensionService, |
| 36 | Captured<OpenApiPluginExtension> | 36 | OpenApiPluginExtension |
| 37 | >() {} | 37 | >() {} |
| 38 | 38 | ||
| 39 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| @@ -55,13 +55,13 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 15 unmodified lines | |||
| 55 | export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) => | 55 | export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) => |
| 56 | handlers | 56 | handlers |
| 57 | .handle("previewSpec", ({ payload }) => | 57 | .handle("previewSpec", ({ payload }) => |
| 58 | Effect.gen(function* () { | 58 | capture(Effect.gen(function* () { |
| 59 | const ext = yield* OpenApiExtensionService; | 59 | const ext = yield* OpenApiExtensionService; |
| 60 | return yield* ext.previewSpec(payload.spec); | 60 | return yield* ext.previewSpec(payload.spec); |
| 61 | }), | 61 | })), |
| 62 | ) | 62 | ) |
| 63 | .handle("addSpec", ({ payload }) => | 63 | .handle("addSpec", ({ payload }) => |
| 64 | Effect.gen(function* () { | 64 | capture(Effect.gen(function* () { |
| 65 | const ext = yield* OpenApiExtensionService; | 65 | const ext = yield* OpenApiExtensionService; |
| 66 | const result = yield* ext.addSpec({ | 66 | const result = yield* ext.addSpec({ |
| 67 | spec: payload.spec, | 67 | spec: payload.spec, |
| @@ -75,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | |||
| 75 | toolCount: result.toolCount, | 75 | toolCount: result.toolCount, |
| 76 | namespace: result.sourceId, | 76 | namespace: result.sourceId, |
| 77 | }; | 77 | }; |
| 78 | }), | 78 | })), |
| 79 | ) | 79 | ) |
| 80 | .handle("getSource", ({ path }) => | 80 | .handle("getSource", ({ path }) => |
| 81 | Effect.gen(function* () { | 81 | capture(Effect.gen(function* () { |
| 82 | const ext = yield* OpenApiExtensionService; | 82 | const ext = yield* OpenApiExtensionService; |
| 83 | return yield* ext.getSource(path.namespace); | 83 | return yield* ext.getSource(path.namespace); |
| 84 | }), | 84 | })), |
| 85 | ) | 85 | ) |
| 86 | .handle("updateSource", ({ path, payload }) => | 86 | .handle("updateSource", ({ path, payload }) => |
| 87 | Effect.gen(function* () { | 87 | capture(Effect.gen(function* () { |
| 88 | const ext = yield* OpenApiExtensionService; | 88 | const ext = yield* OpenApiExtensionService; |
| 89 | yield* ext.updateSource(path.namespace, { | 89 | yield* ext.updateSource(path.namespace, { |
| 90 | name: payload.name, | 90 | name: payload.name, |
| @@ -92,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line | |||
| 92 | headers: payload.headers as Record<string, HeaderValue> | undefined, | 92 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 93 | } as OpenApiUpdateSourceInput); | 93 | } as OpenApiUpdateSourceInput); |
| 94 | return { updated: true }; | 94 | return { updated: true }; |
| 95 | }), | 95 | })), |
| 96 | ) | 96 | ) |
| 97 | .handle("startOAuth", ({ payload }) => | 97 | .handle("startOAuth", ({ payload }) => |
| 98 | Effect.gen(function* () { | 98 | capture(Effect.gen(function* () { |
| 99 | const ext = yield* OpenApiExtensionService; | 99 | const ext = yield* OpenApiExtensionService; |
| 100 | return yield* ext.startOAuth({ | 100 | return yield* ext.startOAuth({ |
| 101 | displayName: payload.displayName, | 101 | displayName: payload.displayName, |
| @@ -108,23 +108,23 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines | |||
| 108 | clientSecretSecretId: payload.clientSecretSecretId ?? null, | 108 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 109 | scopes: [...payload.scopes], | 109 | scopes: [...payload.scopes], |
| 110 | }); | 110 | }); |
| 111 | }), | 111 | })), |
| 112 | ) | 112 | ) |
| 113 | .handle("completeOAuth", ({ payload }) => | 113 | .handle("completeOAuth", ({ payload }) => |
| 114 | Effect.gen(function* () { | 114 | capture(Effect.gen(function* () { |
| 115 | const ext = yield* OpenApiExtensionService; | 115 | const ext = yield* OpenApiExtensionService; |
| 116 | return yield* ext.completeOAuth({ | 116 | return yield* ext.completeOAuth({ |
| 117 | state: payload.state, | 117 | state: payload.state, |
| 118 | code: payload.code, | 118 | code: payload.code, |
| 119 | error: payload.error, | 119 | error: payload.error, |
| 120 | }); | 120 | }); |
| 121 | }), | 121 | })), |
| 122 | ) | 122 | ) |
| 123 | .handle("oauthCallback", ({ urlParams }) => | 123 | .handle("oauthCallback", ({ urlParams }) => |
| 124 | // OAuth popup is special: it always returns 200 HTML and renders the | 124 | // OAuth popup is special: it always returns 200 HTML and renders the |
| 125 | // failure into the popup body so the parent window's listener gets a | 125 | // failure into the popup body so the parent window's listener gets a |
| 126 | // structured result. | 126 | // structured result. |
| 127 | Effect.gen(function* () { | 127 | capture(Effect.gen(function* () { |
| 128 | const ext = yield* OpenApiExtensionService; | 128 | const ext = yield* OpenApiExtensionService; |
| 129 | const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({ | 129 | const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({ |
| 130 | complete: ({ state, code, error }) => | 130 | complete: ({ state, code, error }) => |
| @@ -138,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | |||
| 138 | channelName: OPENAPI_OAUTH_CHANNEL, | 138 | channelName: OPENAPI_OAUTH_CHANNEL, |
| 139 | }); | 139 | }); |
| 140 | return yield* HttpServerResponse.html(html); | 140 | return yield* HttpServerResponse.html(html); |
| 141 | }), | 141 | })), |
| 142 | ), | 142 | ), |
| 143 | ); | 143 | ); |
| @@ -0,0 +1,113 @@ | |||
| 1 | #!/usr/bin/env bun | ||
| 2 | // --------------------------------------------------------------------------- | ||
| 3 | // One-shot script that wraps each `HttpApiBuilder.group(...).handle(name, fn)` | ||
| 4 | // body with `capture(...)` from `@executor/api`. Replaces the Proxy-based | ||
| 5 | // `withCapture` service wrapping with per-handler explicit translation. | ||
| 6 | // | ||
| 7 | // Run once after removing `withCapture` / `Captured<T>` and flipping service | ||
| 8 | // tags back to raw shapes. This script is idempotent — it skips handlers | ||
| 9 | // that already have `capture(...)` around their body. | ||
| 10 | // --------------------------------------------------------------------------- | ||
| 11 | |||
| 12 | import { readFile, writeFile } from "node:fs/promises"; | ||
| 13 | |||
| 14 | const FILES = [ | ||
| 15 | "packages/core/api/src/handlers/tools.ts", | ||
| 16 | "packages/core/api/src/handlers/sources.ts", | ||
| 17 | "packages/core/api/src/handlers/secrets.ts", | ||
| 18 | "packages/core/api/src/handlers/executions.ts", | ||
| 19 | "packages/core/api/src/handlers/scope.ts", | ||
| 20 | "packages/plugins/mcp/src/api/handlers.ts", | ||
| 21 | "packages/plugins/openapi/src/api/handlers.ts", | ||
| 22 | "packages/plugins/graphql/src/api/handlers.ts", | ||
| 23 | "packages/plugins/google-discovery/src/api/handlers.ts", | ||
| 24 | "packages/plugins/onepassword/src/api/handlers.ts", | ||
| 25 | ]; | ||
| 26 | |||
| 27 | // Matches `.handle("name", (arg) =>\n [optional comment lines] Effect.gen(function* () { | ||
| 28 | // ...body... | ||
| 29 | // })` — where the closing `}` sits at the same indent as `Effect.gen`. | ||
| 30 | // | ||
| 31 | // Groups: | ||
| 32 | // 1. the `=>` + trailing whitespace / newline | ||
| 33 | // 2. optional leading comments + whitespace before Effect.gen | ||
| 34 | // 3. the indent of the Effect.gen line (used to find the matching close) | ||
| 35 | // 4. the body of Effect.gen (non-greedy, up to matching close) | ||
| 36 | const HANDLER_BODY = /(=>)\s*\n((?:[ \t]*\/\/[^\n]*\n)*)([ \t]+)Effect\.gen\(function\* \(\) \{([\s\S]*?)\n\3\}\)/g; | ||
| 37 | |||
| 38 | function wrapHandlerBodies(src: string): { next: string; count: number } { | ||
| 39 | let count = 0; | ||
| 40 | const next = src.replace(HANDLER_BODY, (match, arrow, leading, indent, body) => { | ||
| 41 | // Skip if already wrapped — a previous `capture(` sits immediately before | ||
| 42 | // this Effect.gen token on the same line. | ||
| 43 | const idx = src.indexOf(match); | ||
| 44 | const preceding = src.slice(Math.max(0, idx - 20), idx + arrow.length); | ||
| 45 | if (preceding.includes("capture(")) return match; | ||
| 46 | count++; | ||
| 47 | return `${arrow}\n${leading}${indent}capture(Effect.gen(function* () {${body}\n${indent}}))`; | ||
| 48 | }); | ||
| 49 | return { next, count }; | ||
| 50 | } | ||
| 51 | |||
| 52 | function ensureCaptureImport(src: string): string { | ||
| 53 | // Already imports `capture`? | ||
| 54 | if (/from\s+["']@executor\/api["'][^;]*\bcapture\b/.test(src)) return src; | ||
| 55 | |||
| 56 | // Has an existing `@executor/api` import — extend it. | ||
| 57 | const named = /import\s*\{([^}]*)\}\s*from\s*["']@executor\/api["'];?/; | ||
| 58 | if (named.test(src)) { | ||
| 59 | return src.replace(named, (_, inner) => { | ||
| 60 | const names = inner | ||
| 61 | .split(",") | ||
| 62 | .map((s: string) => s.trim()) | ||
| 63 | .filter(Boolean); | ||
| 64 | if (names.includes("capture")) return _; | ||
| 65 | names.push("capture"); | ||
| 66 | names.sort((a: string, b: string) => { | ||
| 67 | // `type ` imports sort after value imports | ||
| 68 | const at = a.startsWith("type "); | ||
| 69 | const bt = b.startsWith("type "); | ||
| 70 | if (at !== bt) return at ? 1 : -1; | ||
| 71 | return a.localeCompare(b); | ||
| 72 | }); | ||
| 73 | return `import { ${names.join(", ")} } from "@executor/api";`; | ||
| 74 | }); | ||
| 75 | } | ||
| 76 | |||
| 77 | // No existing import — add a fresh one after the last import line. | ||
| 78 | const importLines = [...src.matchAll(/^import [^\n]+\n/gm)]; | ||
| 79 | if (importLines.length === 0) { | ||
| 80 | return `import { capture } from "@executor/api";\n\n${src}`; | ||
| 81 | } | ||
| 82 | const last = importLines[importLines.length - 1]; | ||
| 83 | const insertAt = last.index! + last[0].length; | ||
| 84 | return ( | ||
| 85 | src.slice(0, insertAt) + | ||
| 86 | `import { capture } from "@executor/api";\n` + | ||
| 87 | src.slice(insertAt) | ||
| 88 | ); | ||
| 89 | } | ||
| 90 | |||
| 91 | async function processFile(path: string): Promise<{ path: string; handlers: number }> { | ||
| 92 | let src: string; | ||
| 93 | try { | ||
| 94 | src = await readFile(path, "utf8"); | ||
| 95 | } catch { | ||
| 96 | return { path, handlers: -1 }; // file doesn't exist; skip | ||
| 97 | } | ||
| 98 | |||
| 99 | const { next: wrapped, count } = wrapHandlerBodies(src); | ||
| 100 | if (count === 0) return { path, handlers: 0 }; | ||
| 101 | |||
| 102 | const final = ensureCaptureImport(wrapped); | ||
| 103 | await writeFile(path, final, "utf8"); | ||
| 104 | return { path, handlers: count }; | ||
| 105 | } | ||
| 106 | |||
| 107 | const results = await Promise.all(FILES.map(processFile)); | ||
| 108 | for (const { path, handlers } of results) { | ||
| 109 | if (handlers < 0) console.log(`skipped (not found): ${path}`); | ||
| 110 | else console.log(`${handlers.toString().padStart(3)} handlers: ${path}`); | ||
| 111 | } | ||
| 112 | const total = results.reduce((n, r) => n + Math.max(0, r.handlers), 0); | ||
| 113 | console.log(`\n${total} handler bodies wrapped.`); | ||