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 | 3 | import { Effect, Layer } from "effect"; |
| 4 | 4 | |
| 5 | 5 | import { ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 6 | −import { withCapture } from "@executor/api"; | |
| 7 | 6 | import { createExecutionEngine } from "@executor/execution"; |
| 8 | 7 | import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker"; |
| 9 | 8 | import { OpenApiExtensionService } from "@executor/plugin-openapi/api"; |
| @@ -48,18 +47,15 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 38 unmodified lines | ||
| 48 | 47 | makeTrackExecutionUsage(autumn), |
| 49 | 48 | ); |
| 50 | 49 | |
| 51 | − // `withCapture` wraps the executor once — every Effect-returning | |
| 52 | − // method on core + every plugin extension translates `StorageError` | |
| 53 | − // → `InternalError({ traceId })` via `ErrorCapture`; | |
| 54 | − // `UniqueViolationError` becomes a defect. Handlers see the | |
| 55 | − // already-captured shape. See notes/error-handling.md. | |
| 56 | − const wrapped = withCapture(executor); | |
| 50 | + // Handlers wrap their own bodies with `capture(...)` — the edge | |
| 51 | + // translation lives per-handler, not at service construction. See | |
| 52 | + // notes/error-handling.md. | |
| 57 | 53 | const requestServices = Layer.mergeAll( |
| 58 | − Layer.succeed(ExecutorService, wrapped), | |
| 54 | + Layer.succeed(ExecutorService, executor), | |
| 59 | 55 | Layer.succeed(ExecutionEngineService, engine), |
| 60 | − Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 61 | − Layer.succeed(McpExtensionService, wrapped.mcp), | |
| 62 | − Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 56 | + Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 57 | + Layer.succeed(McpExtensionService, executor.mcp), | |
| 58 | + Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 63 | 59 | ); |
| 64 | 60 | |
| 65 | 61 | return yield* HttpApiBuilder.httpApp.pipe( |
| @@ -30,7 +30,6 @@ import { · 29 unmodified lines | ||
| 30 | 30 | ExecutionEngineService, |
| 31 | 31 | ExecutorService, |
| 32 | 32 | } from "@executor/api/server"; |
| 33 | −import { withCapture } from "@executor/api"; | |
| 34 | 33 | import { createExecutionEngine } from "@executor/execution"; |
| 35 | 34 | import { |
| 36 | 35 | Scope, |
| @@ -217,13 +216,12 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 180 unmodified lines | ||
| 217 | 216 | Effect.gen(function* () { |
| 218 | 217 | const executor = yield* createTestScopedExecutor(scopeId, scopeName); |
| 219 | 218 | const engine = createExecutionEngine({ executor }); |
| 220 | − const wrapped = withCapture(executor); | |
| 221 | 219 | const services = Layer.mergeAll( |
| 222 | − Layer.succeed(ExecutorService, wrapped), | |
| 220 | + Layer.succeed(ExecutorService, executor), | |
| 223 | 221 | Layer.succeed(ExecutionEngineService, engine), |
| 224 | − Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 225 | − Layer.succeed(McpExtensionService, wrapped.mcp), | |
| 226 | − Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 222 | + Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 223 | + Layer.succeed(McpExtensionService, executor.mcp), | |
| 224 | + Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 227 | 225 | ); |
| 228 | 226 | return yield* HttpApiBuilder.httpApp.pipe( |
| 229 | 227 | Effect.provide( |
| @@ -7,11 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@effect/platform"; |
| 8 | 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; |
| 9 | 9 | |
| 10 | −import { | |
| 11 | − addGroup, | |
| 12 | − observabilityMiddleware, | |
| 13 | − withCapture, | |
| 14 | −} from "@executor/api"; | |
| 10 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 15 | 11 | import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 16 | 12 | import { createExecutionEngine } from "@executor/execution"; |
| 17 | 13 | import { |
| @@ -94,18 +90,14 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 76 unmodified lines | ||
| 94 | 90 | const executor = await getExecutor(); |
| 95 | 91 | const engine = createExecutionEngine({ executor }); |
| 96 | 92 | |
| 97 | − // `withCapture` wraps the executor once — every Effect-returning | |
| 98 | − // method on core + every plugin extension translates `StorageError` | |
| 99 | − // to `InternalError({ traceId })` via `ErrorCapture`; | |
| 100 | − // `UniqueViolationError` becomes a defect. Handlers see the | |
| 101 | − // already-captured shape. | |
| 102 | − const wrapped = withCapture(executor); | |
| 93 | + // Handlers wrap their own bodies with `capture(...)` — the edge | |
| 94 | + // translation lives per-handler, not at service construction. | |
| 103 | 95 | const pluginExtensions = Layer.mergeAll( |
| 104 | − Layer.succeed(OpenApiExtensionService, wrapped.openapi), | |
| 105 | − Layer.succeed(McpExtensionService, wrapped.mcp), | |
| 106 | − Layer.succeed(GoogleDiscoveryExtensionService, wrapped.googleDiscovery), | |
| 107 | − Layer.succeed(OnePasswordExtensionService, wrapped.onepassword), | |
| 108 | − Layer.succeed(GraphqlExtensionService, wrapped.graphql), | |
| 96 | + Layer.succeed(OpenApiExtensionService, executor.openapi), | |
| 97 | + Layer.succeed(McpExtensionService, executor.mcp), | |
| 98 | + Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery), | |
| 99 | + Layer.succeed(OnePasswordExtensionService, executor.onepassword), | |
| 100 | + Layer.succeed(GraphqlExtensionService, executor.graphql), | |
| 109 | 101 | ); |
| 110 | 102 | |
| 111 | 103 | const api = HttpApiBuilder.toWebHandler( |
| @@ -113,7 +105,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 1 unmodified line | ||
| 113 | 105 | Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()), |
| 114 | 106 | Layer.provideMerge(LocalApiBase), |
| 115 | 107 | Layer.provideMerge(pluginExtensions), |
| 116 | − Layer.provideMerge(Layer.succeed(ExecutorService, wrapped)), | |
| 108 | + Layer.provideMerge(Layer.succeed(ExecutorService, executor)), | |
| 117 | 109 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), |
| 118 | 110 | Layer.provideMerge(HttpServer.layerContext), |
| 119 | 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 | 94 | Optional — resolved via `Effect.serviceOption`; missing service = |
| 95 | 95 | empty trace ids. Nothing breaks if it's not wired. |
| 96 | 96 | |
| 97 | −- `capture(eff)` — single-Effect wrapper. Catches `StorageError` | |
| 98 | − on the typed channel, captures the cause via `ErrorCapture`, fails | |
| 99 | − with `InternalError({ traceId })`. Every other typed failure | |
| 100 | − (`UniqueViolationError`, plugin-domain errors) passes through. | |
| 97 | +- `capture(eff)` — the single translator. Catches `StorageError` on | |
| 98 | + the typed channel, captures the cause via `ErrorCapture`, fails with | |
| 99 | + `InternalError({ traceId })`. Catches `UniqueViolationError` and | |
| 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 | |
| 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. | |
| 104 | + Every handler wraps its generator body with `capture(...)`: | |
| 107 | 105 | |
| 108 | 106 | ```ts |
| 109 | − Layer.succeed( | |
| 110 | − McpExtensionService, | |
| 111 | − withCapture(executor.mcp), | |
| 107 | + .handle("probeEndpoint", ({ payload }) => | |
| 108 | + capture(Effect.gen(function* () { | |
| 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 | |
| 116 | − `withCapture`. Service tags declare this post-wrap shape so | |
| 117 | − handlers see `InternalError` in the method's error union (matching | |
| 118 | − the group's `.addError(InternalError)`). Example: | |
| 119 | − | |
| 120 | − ```ts | |
| 121 | − class McpExtensionService extends Context.Tag("McpExtensionService")< | |
| 122 | − McpExtensionService, | |
| 123 | − Captured<McpPluginExtension> | |
| 124 | − >() {} | |
| 125 | − ``` | |
| 115 | + One line at the top of each handler. No service-level proxy, no | |
| 116 | + `Captured<T>` type gymnastics — the translation is visible right | |
| 117 | + where it happens, and TypeScript rejects handlers that forget | |
| 118 | + (because `StorageError` isn't in the group's `.addError` list). | |
| 126 | 119 | |
| 127 | 120 | - `observabilityMiddleware(Api)` — defect safety net. An |
| 128 | 121 | `HttpApiBuilder.middleware` layer that wraps the HttpApp once and |
| @@ -172,12 +165,13 @@ its annotated status. · 43 unmodified lines | ||
| 172 | 165 | - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides |
| 173 | 166 | `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API |
| 174 | 167 | layer in `protected-layers.ts` so it's available to both |
| 175 | − `observabilityMiddleware` (defect catchall) AND to `withCapture`'s | |
| 176 | − typed-channel translation. `protected.ts` wraps once per-request: | |
| 177 | − `const wrapped = withCapture(executor)` → every extension service | |
| 178 | − pulls from `wrapped.X`. (Distinct from `apps/cloud/src/services/telemetry.ts`, | |
| 179 | − which is the OTEL→Axiom span bridge — "telemetry" in the tracing | |
| 180 | − sense.) | |
| 168 | + `observabilityMiddleware` (defect catchall) AND the per-handler | |
| 169 | + `capture(...)` translation. Service tags (`ExecutorService`, | |
| 170 | + `McpExtensionService`, etc.) hold the raw SDK shapes; the cloud app | |
| 171 | + just does `Layer.succeed(McpExtensionService, executor.mcp)` — | |
| 172 | + handlers do the translation themselves. (Distinct from | |
| 173 | + `apps/cloud/src/services/telemetry.ts`, which is the OTEL→Axiom span | |
| 174 | + bridge — "telemetry" in the tracing sense.) | |
| 181 | 175 | - **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a |
| 182 | 176 | console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`) |
| 183 | 177 | that prints the squashed cause + pretty cause to stderr and returns a |
| @@ -194,8 +188,8 @@ its annotated status. · 10 unmodified lines | ||
| 194 | 188 | - Per-plugin `*InternalError` types — clients can't tell which plugin |
| 195 | 189 | emitted a 500 anyway. Use the shared `InternalError`. |
| 196 | 190 | - `sanitize*` helpers in handler files that `catchAllCause` + map to a |
| 197 | − generic 500 — same swallowing problem in disguise. Prefer | |
| 198 | − `withCapture` at Layer composition. | |
| 191 | + generic 500 — same swallowing problem in disguise. Prefer wrapping | |
| 192 | + the handler's `Effect.gen` body with `capture(...)`. | |
| 199 | 193 | - SDK code importing `Sentry.captureException` or referencing |
| 200 | 194 | `InternalError` / `ErrorCapture` — translation lives strictly in |
| 201 | 195 | `@executor/api`. If the SDK imports it, the layering is wrong. |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | +import { InternalError } from "../observability"; | |
| 5 | + | |
| 4 | 6 | // --------------------------------------------------------------------------- |
| 5 | 7 | // Schemas |
| 6 | 8 | // --------------------------------------------------------------------------- |
| @@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines | ||
| 60 | 62 | .setPayload(ResumeRequest) |
| 61 | 63 | .addSuccess(ResumeResponse) |
| 62 | 64 | .addError(ExecutionNotFoundError), |
| 63 | − ) {} | |
| 65 | + ) | |
| 66 | + .addError(InternalError) {} | |
| @@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { ExecutorApi } from "../api"; |
| 5 | 5 | import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; |
| 6 | 6 | import { ExecutionEngineService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("execute", ({ payload }) => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const engine = yield* ExecutionEngineService; |
| 13 | 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 | 29 | text: formatted.text, |
| 29 | 30 | structured: formatted.structured, |
| 30 | 31 | }; |
| 31 | − }), | |
| 32 | + })), | |
| 32 | 33 | ) |
| 33 | 34 | .handle("resume", ({ path, payload }) => |
| 34 | − Effect.gen(function* () { | |
| 35 | + capture(Effect.gen(function* () { | |
| 35 | 36 | const engine = yield* ExecutionEngineService; |
| 36 | 37 | const result = yield* Effect.promise(() => |
| 37 | 38 | engine.resume(path.executionId, { |
| @@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines | ||
| 62 | 63 | structured: formatted.structured, |
| 63 | 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 | 4 | import { ExecutorApi } from "../api"; |
| 5 | 5 | import { ExecutorService } from "../services"; |
| 6 | +import { capture } from "@executor/api"; | |
| 6 | 7 | |
| 7 | 8 | export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => |
| 8 | 9 | handlers.handle("info", () => |
| 9 | − Effect.gen(function* () { | |
| 10 | + capture(Effect.gen(function* () { | |
| 10 | 11 | const executor = yield* ExecutorService; |
| 11 | 12 | return { |
| 12 | 13 | id: executor.scope.id, |
| 13 | 14 | name: executor.scope.name, |
| 14 | 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 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | const refToResponse = (ref: SecretRef) => ({ |
| 9 | 10 | id: ref.id, |
| @@ -16,21 +17,21 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines | ||
| 16 | 17 | export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) => |
| 17 | 18 | handlers |
| 18 | 19 | .handle("list", () => |
| 19 | − Effect.gen(function* () { | |
| 20 | + capture(Effect.gen(function* () { | |
| 20 | 21 | const executor = yield* ExecutorService; |
| 21 | 22 | const refs = yield* executor.secrets.list(); |
| 22 | 23 | return refs.map(refToResponse); |
| 23 | − }), | |
| 24 | + })), | |
| 24 | 25 | ) |
| 25 | 26 | .handle("status", ({ path }) => |
| 26 | − Effect.gen(function* () { | |
| 27 | + capture(Effect.gen(function* () { | |
| 27 | 28 | const executor = yield* ExecutorService; |
| 28 | 29 | const status = yield* executor.secrets.status(path.secretId); |
| 29 | 30 | return { secretId: path.secretId, status }; |
| 30 | − }), | |
| 31 | + })), | |
| 31 | 32 | ) |
| 32 | 33 | .handle("set", ({ payload }) => |
| 33 | − Effect.gen(function* () { | |
| 34 | + capture(Effect.gen(function* () { | |
| 34 | 35 | const executor = yield* ExecutorService; |
| 35 | 36 | const ref = yield* executor.secrets.set({ |
| 36 | 37 | id: payload.id, |
| @@ -39,23 +40,23 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 2 unmodified lines | ||
| 39 | 40 | provider: payload.provider, |
| 40 | 41 | }); |
| 41 | 42 | return refToResponse(ref); |
| 42 | − }), | |
| 43 | + })), | |
| 43 | 44 | ) |
| 44 | 45 | .handle("resolve", ({ path }) => |
| 45 | − Effect.gen(function* () { | |
| 46 | + capture(Effect.gen(function* () { | |
| 46 | 47 | const executor = yield* ExecutorService; |
| 47 | 48 | const value = yield* executor.secrets.get(path.secretId); |
| 48 | 49 | if (value === null) { |
| 49 | 50 | return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); |
| 50 | 51 | } |
| 51 | 52 | return { secretId: path.secretId, value }; |
| 52 | − }), | |
| 53 | + })), | |
| 53 | 54 | ) |
| 54 | 55 | .handle("remove", ({ path }) => |
| 55 | − Effect.gen(function* () { | |
| 56 | + capture(Effect.gen(function* () { | |
| 56 | 57 | const executor = yield* ExecutorService; |
| 57 | 58 | yield* executor.secrets.remove(path.secretId); |
| 58 | 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 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("list", () => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const executor = yield* ExecutorService; |
| 13 | 14 | const sources = yield* executor.sources.list(); |
| 14 | 15 | return sources.map((s) => ({ |
| @@ -21,24 +22,24 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 6 unmodified lines | ||
| 21 | 22 | canRefresh: s.canRefresh, |
| 22 | 23 | canEdit: s.canEdit, |
| 23 | 24 | })); |
| 24 | − }), | |
| 25 | + })), | |
| 25 | 26 | ) |
| 26 | 27 | .handle("remove", ({ path }) => |
| 27 | − Effect.gen(function* () { | |
| 28 | + capture(Effect.gen(function* () { | |
| 28 | 29 | const executor = yield* ExecutorService; |
| 29 | 30 | yield* executor.sources.remove(path.sourceId); |
| 30 | 31 | return { removed: true }; |
| 31 | − }), | |
| 32 | + })), | |
| 32 | 33 | ) |
| 33 | 34 | .handle("refresh", ({ path }) => |
| 34 | − Effect.gen(function* () { | |
| 35 | + capture(Effect.gen(function* () { | |
| 35 | 36 | const executor = yield* ExecutorService; |
| 36 | 37 | yield* executor.sources.refresh(path.sourceId); |
| 37 | 38 | return { refreshed: true }; |
| 38 | − }), | |
| 39 | + })), | |
| 39 | 40 | ) |
| 40 | 41 | .handle("tools", ({ path }) => |
| 41 | − Effect.gen(function* () { | |
| 42 | + capture(Effect.gen(function* () { | |
| 42 | 43 | const executor = yield* ExecutorService; |
| 43 | 44 | const tools = yield* executor.tools.list({ sourceId: path.sourceId }); |
| 44 | 45 | return tools.map((t) => ({ |
| @@ -49,10 +50,10 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines | ||
| 49 | 50 | description: t.description, |
| 50 | 51 | mayElicit: t.annotations?.mayElicit, |
| 51 | 52 | })); |
| 52 | − }), | |
| 53 | + })), | |
| 53 | 54 | ) |
| 54 | 55 | .handle("detect", ({ payload }) => |
| 55 | − Effect.gen(function* () { | |
| 56 | + capture(Effect.gen(function* () { | |
| 56 | 57 | const executor = yield* ExecutorService; |
| 57 | 58 | const results = yield* executor.sources.detect(payload.url); |
| 58 | 59 | return results.map((r) => ({ |
| @@ -62,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 3 unmodified lines | ||
| 62 | 63 | name: r.name, |
| 63 | 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 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("list", () => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const executor = yield* ExecutorService; |
| 13 | 14 | const tools = yield* executor.tools.list(); |
| 14 | 15 | return tools.map((t) => ({ |
| @@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 4 unmodified lines | ||
| 19 | 20 | description: t.description, |
| 20 | 21 | mayElicit: t.annotations?.mayElicit, |
| 21 | 22 | })); |
| 22 | − }), | |
| 23 | + })), | |
| 23 | 24 | ) |
| 24 | 25 | .handle("schema", ({ path }) => |
| 25 | − Effect.gen(function* () { | |
| 26 | + capture(Effect.gen(function* () { | |
| 26 | 27 | const executor = yield* ExecutorService; |
| 27 | 28 | const schema = yield* executor.tools.schema(path.toolId); |
| 28 | 29 | if (schema === null) { |
| 29 | 30 | return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); |
| 30 | 31 | } |
| 31 | 32 | return schema; |
| 32 | − }), | |
| 33 | + })), | |
| 33 | 34 | ), |
| 34 | 35 | ); |
| @@ -9,7 +9,5 @@ export { · 8 unmodified lines | ||
| 9 | 9 | ErrorCapture, |
| 10 | 10 | observabilityMiddleware, |
| 11 | 11 | capture, |
| 12 | − withCapture, | |
| 13 | − type Captured, | |
| 14 | 12 | type ErrorCaptureShape, |
| 15 | 13 | } from "./observability"; |
| @@ -1,13 +1,7 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | −// Edge translation — `StorageError → InternalError(traceId)` behaviour. | |
| 3 | −// | |
| 4 | −// SDK code (@executor/sdk) emits raw `StorageError` in its typed | |
| 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. | |
| 2 | +// Edge translation — `StorageError → InternalError(traceId)` behaviour | |
| 3 | +// via `capture(eff)`. Handlers wrap their generator bodies with | |
| 4 | +// `capture(...)`; this file exercises the translator in isolation. | |
| 11 | 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 | 9 | import { Cause, Effect, Layer, Ref } from "effect"; |
| 16 | 10 | import { StorageError, UniqueViolationError } from "@executor/storage-core"; |
| 17 | 11 | |
| 18 | −import { | |
| 19 | − capture, | |
| 20 | − ErrorCapture, | |
| 21 | − InternalError, | |
| 22 | − withCapture, | |
| 23 | −} from "./observability"; | |
| 12 | +import { capture, ErrorCapture, InternalError } from "./observability"; | |
| 24 | 13 | |
| 25 | 14 | // Recording ErrorCapture — returns a fixed trace id and accumulates |
| 26 | 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 | 13 | // the cloud Worker, console in the CLI, in-memory in tests) to |
| 14 | 14 | // record causes and return correlation ids. Optional; absent → |
| 15 | 15 | // empty trace ids, nothing breaks. |
| 16 | −// 3. `capture` / `withCapture` — one-Effect and whole-surface helpers | |
| 17 | −// that translate `StorageError` to `InternalError({ traceId })` at | |
| 18 | −// Layer composition. Applied ONCE at the top of service wiring — | |
| 19 | −// `withCapture(executor)` covers the entire executor surface and | |
| 20 | −// every plugin extension on it, in one call. | |
| 16 | +// 3. `capture(eff)` — the one translator. Catches `StorageError` and | |
| 17 | +// `UniqueViolationError` in the typed channel: the former is | |
| 18 | +// captured via `ErrorCapture` and re-failed as `InternalError({ | |
| 19 | +// traceId })`; the latter dies as a defect (plugins that want to | |
| 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 | 24 | // 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp |
| 22 | 25 | // once; catches any cause that slipped past the typed channel and |
| 23 | 26 | // produces the same `InternalError({ traceId })` shape. |
| @@ -111,104 +114,6 @@ export const capture = <A, E, R>( · 87 unmodified lines | ||
| 111 | 114 | ), |
| 112 | 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 | 118 | * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer |
| 214 | 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 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Response schemas |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines | ||
| 16 | 18 | // Group |
| 17 | 19 | // --------------------------------------------------------------------------- |
| 18 | 20 | |
| 19 | −export class ScopeApi extends HttpApiGroup.make("scope").add( | |
| 20 | − HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse), | |
| 21 | −) {} | |
| 21 | +export class ScopeApi extends HttpApiGroup.make("scope") | |
| 22 | + .add(HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse)) | |
| 23 | + .addError(InternalError) {} | |
| @@ -1,11 +1,10 @@ | ||
| 1 | 1 | import { Context } from "effect"; |
| 2 | 2 | import type { Executor } from "@executor/sdk"; |
| 3 | 3 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | −import type { Captured } from "./observability"; | |
| 5 | 4 | |
| 6 | 5 | export class ExecutorService extends Context.Tag("ExecutorService")< |
| 7 | 6 | ExecutorService, |
| 8 | − Captured<Executor> | |
| 7 | + Executor | |
| 9 | 8 | >() {} |
| 10 | 9 | |
| 11 | 10 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| @@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines | ||
| 11 | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | 12 | import { Effect, Layer } from "effect"; |
| 13 | 13 | |
| 14 | −import { | |
| 15 | − addGroup, | |
| 16 | − observabilityMiddleware, | |
| 17 | − withCapture, | |
| 18 | −} from "@executor/api"; | |
| 14 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 19 | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 20 | 16 | import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; |
| 21 | 17 | import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; |
| @@ -47,10 +43,7 @@ const WebHandler = Effect.acquireRelease( · 25 unmodified lines | ||
| 47 | 43 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), |
| 48 | 44 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), |
| 49 | 45 | Layer.provide( |
| 50 | − Layer.succeed( | |
| 51 | − GoogleDiscoveryExtensionService, | |
| 52 | − withCapture(failingExtension), | |
| 53 | − ), | |
| 46 | + Layer.succeed(GoogleDiscoveryExtensionService, failingExtension), | |
| 54 | 47 | ), |
| 55 | 48 | Layer.provideMerge(HttpServer.layerContext), |
| 56 | 49 | Layer.provideMerge(HttpApiBuilder.Router.Live), |
| @@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 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 | 7 | import type { |
| 8 | 8 | GoogleDiscoveryAddSourceInput, |
| 9 | 9 | GoogleDiscoveryOAuthAuthResult, |
| @@ -25,7 +25,7 @@ import { GoogleDiscoveryGroup } from "./group"; · 15 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 27 | 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 | 60 | (handlers) => |
| 61 | 61 | handlers |
| 62 | 62 | .handle("probeDiscovery", ({ payload }) => |
| 63 | − Effect.gen(function* () { | |
| 63 | + capture(Effect.gen(function* () { | |
| 64 | 64 | const ext = yield* GoogleDiscoveryExtensionService; |
| 65 | 65 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 66 | − }), | |
| 66 | + })), | |
| 67 | 67 | ) |
| 68 | 68 | .handle("addSource", ({ payload }) => |
| 69 | − Effect.gen(function* () { | |
| 69 | + capture(Effect.gen(function* () { | |
| 70 | 70 | const ext = yield* GoogleDiscoveryExtensionService; |
| 71 | 71 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 72 | − }), | |
| 72 | + })), | |
| 73 | 73 | ) |
| 74 | 74 | .handle("startOAuth", ({ payload }) => |
| 75 | − Effect.gen(function* () { | |
| 75 | + capture(Effect.gen(function* () { | |
| 76 | 76 | const ext = yield* GoogleDiscoveryExtensionService; |
| 77 | 77 | return yield* ext.startOAuth({ |
| 78 | 78 | name: payload.name, |
| @@ -82,26 +82,26 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 82 | 82 | redirectUrl: payload.redirectUrl, |
| 83 | 83 | scopes: payload.scopes, |
| 84 | 84 | }); |
| 85 | − }), | |
| 85 | + })), | |
| 86 | 86 | ) |
| 87 | 87 | .handle("completeOAuth", ({ payload }) => |
| 88 | − Effect.gen(function* () { | |
| 88 | + capture(Effect.gen(function* () { | |
| 89 | 89 | const ext = yield* GoogleDiscoveryExtensionService; |
| 90 | 90 | return yield* ext.completeOAuth({ |
| 91 | 91 | state: payload.state, |
| 92 | 92 | code: payload.code, |
| 93 | 93 | error: payload.error, |
| 94 | 94 | }); |
| 95 | − }), | |
| 95 | + })), | |
| 96 | 96 | ) |
| 97 | 97 | .handle("getSource", ({ path }) => |
| 98 | − Effect.gen(function* () { | |
| 98 | + capture(Effect.gen(function* () { | |
| 99 | 99 | const ext = yield* GoogleDiscoveryExtensionService; |
| 100 | 100 | return yield* ext.getSource(path.namespace); |
| 101 | − }), | |
| 101 | + })), | |
| 102 | 102 | ) |
| 103 | 103 | .handle("oauthCallback", ({ urlParams }) => |
| 104 | − Effect.gen(function* () { | |
| 104 | + capture(Effect.gen(function* () { | |
| 105 | 105 | const ext = yield* GoogleDiscoveryExtensionService; |
| 106 | 106 | const html = yield* runOAuthCallback< |
| 107 | 107 | GoogleDiscoveryOAuthAuthResult, |
| @@ -119,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 11 unmodified lines | ||
| 119 | 119 | channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL, |
| 120 | 120 | }); |
| 121 | 121 | return yield* HttpServerResponse.html(html); |
| 122 | − }), | |
| 122 | + })), | |
| 123 | 123 | ), |
| 124 | 124 | ); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | −import { addGroup, type Captured } from "@executor/api"; | |
| 4 | +import { addGroup, capture } from "@executor/api"; | |
| 5 | 5 | import type { |
| 6 | 6 | GraphqlPluginExtension, |
| 7 | 7 | HeaderValue, |
| @@ -22,7 +22,7 @@ import { GraphqlGroup } from "./group"; · 14 unmodified lines | ||
| 22 | 22 | |
| 23 | 23 | export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")< |
| 24 | 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 | 44 | export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => |
| 45 | 45 | handlers |
| 46 | 46 | .handle("addSource", ({ payload }) => |
| 47 | − Effect.gen(function* () { | |
| 47 | + capture(Effect.gen(function* () { | |
| 48 | 48 | const ext = yield* GraphqlExtensionService; |
| 49 | 49 | const result = yield* ext.addSource({ |
| 50 | 50 | endpoint: payload.endpoint, |
| @@ -57,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines | ||
| 57 | 57 | toolCount: result.toolCount, |
| 58 | 58 | namespace: payload.namespace ?? "graphql", |
| 59 | 59 | }; |
| 60 | − }), | |
| 60 | + })), | |
| 61 | 61 | ) |
| 62 | 62 | .handle("getSource", ({ path }) => |
| 63 | − Effect.gen(function* () { | |
| 63 | + capture(Effect.gen(function* () { | |
| 64 | 64 | const ext = yield* GraphqlExtensionService; |
| 65 | 65 | return yield* ext.getSource(path.namespace); |
| 66 | − }), | |
| 66 | + })), | |
| 67 | 67 | ) |
| 68 | 68 | .handle("updateSource", ({ path, payload }) => |
| 69 | − Effect.gen(function* () { | |
| 69 | + capture(Effect.gen(function* () { | |
| 70 | 70 | const ext = yield* GraphqlExtensionService; |
| 71 | 71 | yield* ext.updateSource(path.namespace, { |
| 72 | 72 | name: payload.name, |
| @@ -74,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line | ||
| 74 | 74 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 75 | 75 | } as GraphqlUpdateSourceInput); |
| 76 | 76 | return { updated: true }; |
| 77 | − }), | |
| 77 | + })), | |
| 78 | 78 | ), |
| 79 | 79 | ); |
| @@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines | ||
| 11 | 11 | import { describe, expect, it } from "@effect/vitest"; |
| 12 | 12 | import { Effect, Layer } from "effect"; |
| 13 | 13 | |
| 14 | −import { | |
| 15 | − addGroup, | |
| 16 | − observabilityMiddleware, | |
| 17 | − withCapture, | |
| 18 | −} from "@executor/api"; | |
| 14 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 19 | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 20 | 16 | import type { McpPluginExtension } from "../sdk/plugin"; |
| 21 | 17 | import { McpExtensionService, McpHandlers } from "./handlers"; |
| @@ -50,12 +46,7 @@ const WebHandler = Effect.acquireRelease( · 28 unmodified lines | ||
| 50 | 46 | Layer.provide(observabilityMiddleware(Api)), |
| 51 | 47 | Layer.provide(Layer.succeed(ExecutorService, {} as never)), |
| 52 | 48 | Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), |
| 53 | − Layer.provide( | |
| 54 | − Layer.succeed( | |
| 55 | − McpExtensionService, | |
| 56 | − withCapture(failingExtension), | |
| 57 | − ), | |
| 58 | − ), | |
| 49 | + Layer.provide(Layer.succeed(McpExtensionService, failingExtension)), | |
| 59 | 50 | Layer.provideMerge(HttpServer.layerContext), |
| 60 | 51 | Layer.provideMerge(HttpApiBuilder.Router.Live), |
| 61 | 52 | Layer.provideMerge(HttpApiBuilder.Middleware.layer), |
| @@ -1,25 +1,22 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | 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 | 5 | import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin"; |
| 6 | 6 | import { McpOAuthError } from "../sdk/errors"; |
| 7 | 7 | import { McpGroup } from "./group"; |
| 8 | 8 | |
| 9 | 9 | // --------------------------------------------------------------------------- |
| 10 | −// Service tag | |
| 11 | −// | |
| 12 | −// Holds the `Captured` shape — every method's `StorageError` | |
| 13 | −// channel has been swapped for `InternalError({ traceId })`. The cloud | |
| 14 | −// app provides an already-wrapped extension via | |
| 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. | |
| 10 | +// Service tag — holds the raw extension shape the executor produces. | |
| 11 | +// Handlers wrap their generator bodies with `capture(...)` from | |
| 12 | +// `@executor/api`, which translates `StorageError` to `InternalError` | |
| 13 | +// at the edge; that's why the tag type matches the SDK shape directly | |
| 14 | +// (no `Captured<>` inversion). | |
| 18 | 15 | // --------------------------------------------------------------------------- |
| 19 | 16 | |
| 20 | 17 | export class McpExtensionService extends Context.Tag("McpExtensionService")< |
| 21 | 18 | McpExtensionService, |
| 22 | − Captured<McpPluginExtension> | |
| 19 | + McpPluginExtension | |
| 23 | 20 | >() {} |
| 24 | 21 | |
| 25 | 22 | // --------------------------------------------------------------------------- |
| @@ -168,60 +165,60 @@ const toSourceConfig = ( · 142 unmodified lines | ||
| 168 | 165 | export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) => |
| 169 | 166 | handlers |
| 170 | 167 | .handle("probeEndpoint", ({ payload }) => |
| 171 | − Effect.gen(function* () { | |
| 168 | + capture(Effect.gen(function* () { | |
| 172 | 169 | const ext = yield* McpExtensionService; |
| 173 | 170 | return yield* ext.probeEndpoint(payload.endpoint); |
| 174 | − }), | |
| 171 | + })), | |
| 175 | 172 | ) |
| 176 | 173 | .handle("addSource", ({ payload }) => |
| 177 | − Effect.gen(function* () { | |
| 174 | + capture(Effect.gen(function* () { | |
| 178 | 175 | const ext = yield* McpExtensionService; |
| 179 | 176 | return yield* ext.addSource( |
| 180 | 177 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), |
| 181 | 178 | ); |
| 182 | − }), | |
| 179 | + })), | |
| 183 | 180 | ) |
| 184 | 181 | .handle("removeSource", ({ payload }) => |
| 185 | − Effect.gen(function* () { | |
| 182 | + capture(Effect.gen(function* () { | |
| 186 | 183 | const ext = yield* McpExtensionService; |
| 187 | 184 | yield* ext.removeSource(payload.namespace); |
| 188 | 185 | return { removed: true }; |
| 189 | − }), | |
| 186 | + })), | |
| 190 | 187 | ) |
| 191 | 188 | .handle("refreshSource", ({ payload }) => |
| 192 | − Effect.gen(function* () { | |
| 189 | + capture(Effect.gen(function* () { | |
| 193 | 190 | const ext = yield* McpExtensionService; |
| 194 | 191 | return yield* ext.refreshSource(payload.namespace); |
| 195 | − }), | |
| 192 | + })), | |
| 196 | 193 | ) |
| 197 | 194 | .handle("startOAuth", ({ payload }) => |
| 198 | − Effect.gen(function* () { | |
| 195 | + capture(Effect.gen(function* () { | |
| 199 | 196 | const ext = yield* McpExtensionService; |
| 200 | 197 | return yield* ext.startOAuth({ |
| 201 | 198 | endpoint: payload.endpoint, |
| 202 | 199 | redirectUrl: payload.redirectUrl, |
| 203 | 200 | queryParams: payload.queryParams, |
| 204 | 201 | }); |
| 205 | − }), | |
| 202 | + })), | |
| 206 | 203 | ) |
| 207 | 204 | .handle("completeOAuth", ({ payload }) => |
| 208 | − Effect.gen(function* () { | |
| 205 | + capture(Effect.gen(function* () { | |
| 209 | 206 | const ext = yield* McpExtensionService; |
| 210 | 207 | return yield* ext.completeOAuth({ |
| 211 | 208 | state: payload.state, |
| 212 | 209 | code: payload.code, |
| 213 | 210 | error: payload.error, |
| 214 | 211 | }); |
| 215 | − }), | |
| 212 | + })), | |
| 216 | 213 | ) |
| 217 | 214 | .handle("getSource", ({ path }) => |
| 218 | − Effect.gen(function* () { | |
| 215 | + capture(Effect.gen(function* () { | |
| 219 | 216 | const ext = yield* McpExtensionService; |
| 220 | 217 | return yield* ext.getSource(path.namespace); |
| 221 | − }), | |
| 218 | + })), | |
| 222 | 219 | ) |
| 223 | 220 | .handle("updateSource", ({ path, payload }) => |
| 224 | − Effect.gen(function* () { | |
| 221 | + capture(Effect.gen(function* () { | |
| 225 | 222 | const ext = yield* McpExtensionService; |
| 226 | 223 | yield* ext.updateSource(path.namespace, { |
| 227 | 224 | name: payload.name, |
| @@ -231,13 +228,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | ||
| 231 | 228 | auth: payload.auth as McpUpdateSourceInput["auth"], |
| 232 | 229 | }); |
| 233 | 230 | return { updated: true }; |
| 234 | − }), | |
| 231 | + })), | |
| 235 | 232 | ) |
| 236 | 233 | .handle("oauthCallback", ({ urlParams }) => |
| 237 | 234 | // OAuth popup is special: it always returns 200 HTML and renders the |
| 238 | 235 | // failure into the popup body so the parent window's listener gets a |
| 239 | 236 | // structured result. Catching here is intentional, not a leak. |
| 240 | − Effect.gen(function* () { | |
| 237 | + capture(Effect.gen(function* () { | |
| 241 | 238 | const ext = yield* McpExtensionService; |
| 242 | 239 | const result = yield* Effect.matchCauseEffect( |
| 243 | 240 | ext.completeOAuth({ |
| @@ -267,6 +264,6 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 23 unmodified lines | ||
| 267 | 264 | }, |
| 268 | 265 | ); |
| 269 | 266 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 270 | − }), | |
| 267 | + })), | |
| 271 | 268 | ), |
| 272 | 269 | ); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | −import { addGroup, type Captured } from "@executor/api"; | |
| 4 | +import { addGroup, capture } from "@executor/api"; | |
| 5 | 5 | import type { OnePasswordExtension } from "../sdk/plugin"; |
| 6 | 6 | import { OnePasswordGroup } from "./group"; |
| 7 | 7 | |
| @@ -18,7 +18,7 @@ import { OnePasswordGroup } from "./group"; · 10 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")< |
| 20 | 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 | 43 | (handlers) => |
| 44 | 44 | handlers |
| 45 | 45 | .handle("getConfig", () => |
| 46 | − Effect.gen(function* () { | |
| 46 | + capture(Effect.gen(function* () { | |
| 47 | 47 | const ext = yield* OnePasswordExtensionService; |
| 48 | 48 | return yield* ext.getConfig(); |
| 49 | − }), | |
| 49 | + })), | |
| 50 | 50 | ) |
| 51 | 51 | .handle("configure", ({ payload }) => |
| 52 | − Effect.gen(function* () { | |
| 52 | + capture(Effect.gen(function* () { | |
| 53 | 53 | const ext = yield* OnePasswordExtensionService; |
| 54 | 54 | yield* ext.configure(payload); |
| 55 | − }), | |
| 55 | + })), | |
| 56 | 56 | ) |
| 57 | 57 | .handle("removeConfig", () => |
| 58 | − Effect.gen(function* () { | |
| 58 | + capture(Effect.gen(function* () { | |
| 59 | 59 | const ext = yield* OnePasswordExtensionService; |
| 60 | 60 | yield* ext.removeConfig(); |
| 61 | − }), | |
| 61 | + })), | |
| 62 | 62 | ) |
| 63 | 63 | .handle("status", () => |
| 64 | − Effect.gen(function* () { | |
| 64 | + capture(Effect.gen(function* () { | |
| 65 | 65 | const ext = yield* OnePasswordExtensionService; |
| 66 | 66 | return yield* ext.status(); |
| 67 | − }), | |
| 67 | + })), | |
| 68 | 68 | ) |
| 69 | 69 | .handle("listVaults", ({ urlParams }) => |
| 70 | − Effect.gen(function* () { | |
| 70 | + capture(Effect.gen(function* () { | |
| 71 | 71 | const ext = yield* OnePasswordExtensionService; |
| 72 | 72 | const auth = |
| 73 | 73 | urlParams.authKind === "desktop-app" |
| @@ -75,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line | ||
| 75 | 75 | : { kind: "service-account" as const, tokenSecretId: urlParams.account }; |
| 76 | 76 | const vaults = yield* ext.listVaults(auth); |
| 77 | 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 | 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 | 7 | import { OpenApiOAuthError } from "../sdk/errors"; |
| 8 | 8 | import type { |
| 9 | 9 | OpenApiPluginExtension, |
| @@ -33,7 +33,7 @@ const toPopupErrorMessage = (error: unknown): string => { · 23 unmodified lines | ||
| 33 | 33 | |
| 34 | 34 | export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")< |
| 35 | 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 | 55 | export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) => |
| 56 | 56 | handlers |
| 57 | 57 | .handle("previewSpec", ({ payload }) => |
| 58 | − Effect.gen(function* () { | |
| 58 | + capture(Effect.gen(function* () { | |
| 59 | 59 | const ext = yield* OpenApiExtensionService; |
| 60 | 60 | return yield* ext.previewSpec(payload.spec); |
| 61 | − }), | |
| 61 | + })), | |
| 62 | 62 | ) |
| 63 | 63 | .handle("addSpec", ({ payload }) => |
| 64 | − Effect.gen(function* () { | |
| 64 | + capture(Effect.gen(function* () { | |
| 65 | 65 | const ext = yield* OpenApiExtensionService; |
| 66 | 66 | const result = yield* ext.addSpec({ |
| 67 | 67 | spec: payload.spec, |
| @@ -75,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | ||
| 75 | 75 | toolCount: result.toolCount, |
| 76 | 76 | namespace: result.sourceId, |
| 77 | 77 | }; |
| 78 | − }), | |
| 78 | + })), | |
| 79 | 79 | ) |
| 80 | 80 | .handle("getSource", ({ path }) => |
| 81 | − Effect.gen(function* () { | |
| 81 | + capture(Effect.gen(function* () { | |
| 82 | 82 | const ext = yield* OpenApiExtensionService; |
| 83 | 83 | return yield* ext.getSource(path.namespace); |
| 84 | − }), | |
| 84 | + })), | |
| 85 | 85 | ) |
| 86 | 86 | .handle("updateSource", ({ path, payload }) => |
| 87 | − Effect.gen(function* () { | |
| 87 | + capture(Effect.gen(function* () { | |
| 88 | 88 | const ext = yield* OpenApiExtensionService; |
| 89 | 89 | yield* ext.updateSource(path.namespace, { |
| 90 | 90 | name: payload.name, |
| @@ -92,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line | ||
| 92 | 92 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 93 | 93 | } as OpenApiUpdateSourceInput); |
| 94 | 94 | return { updated: true }; |
| 95 | − }), | |
| 95 | + })), | |
| 96 | 96 | ) |
| 97 | 97 | .handle("startOAuth", ({ payload }) => |
| 98 | − Effect.gen(function* () { | |
| 98 | + capture(Effect.gen(function* () { | |
| 99 | 99 | const ext = yield* OpenApiExtensionService; |
| 100 | 100 | return yield* ext.startOAuth({ |
| 101 | 101 | displayName: payload.displayName, |
| @@ -108,23 +108,23 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines | ||
| 108 | 108 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 109 | 109 | scopes: [...payload.scopes], |
| 110 | 110 | }); |
| 111 | − }), | |
| 111 | + })), | |
| 112 | 112 | ) |
| 113 | 113 | .handle("completeOAuth", ({ payload }) => |
| 114 | − Effect.gen(function* () { | |
| 114 | + capture(Effect.gen(function* () { | |
| 115 | 115 | const ext = yield* OpenApiExtensionService; |
| 116 | 116 | return yield* ext.completeOAuth({ |
| 117 | 117 | state: payload.state, |
| 118 | 118 | code: payload.code, |
| 119 | 119 | error: payload.error, |
| 120 | 120 | }); |
| 121 | − }), | |
| 121 | + })), | |
| 122 | 122 | ) |
| 123 | 123 | .handle("oauthCallback", ({ urlParams }) => |
| 124 | 124 | // OAuth popup is special: it always returns 200 HTML and renders the |
| 125 | 125 | // failure into the popup body so the parent window's listener gets a |
| 126 | 126 | // structured result. |
| 127 | − Effect.gen(function* () { | |
| 127 | + capture(Effect.gen(function* () { | |
| 128 | 128 | const ext = yield* OpenApiExtensionService; |
| 129 | 129 | const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({ |
| 130 | 130 | complete: ({ state, code, error }) => |
| @@ -138,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | ||
| 138 | 138 | channelName: OPENAPI_OAUTH_CHANNEL, |
| 139 | 139 | }); |
| 140 | 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.`); | |