History

refactor(api): replace withCapture proxy with per-handler capture(...)

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.

Rhys Sullivan committed 7 months ago

Commit 96b76e61 parent 50f256823 files · +294 −379Browse files

Unified · Split

23 files
  1. apps/cloud/src/api/protected.ts +7 −11
  2. apps/cloud/src/services/__test-harness__/api-harness.ts +4 −6
  3. apps/local/src/server/main.ts +9 −17
  4. notes/error-handling.md +25 −31
  5. packages/core/api/src/executions/api.ts +4 −1
  6. packages/core/api/src/handlers/executions.ts +5 −4
  7. packages/core/api/src/handlers/scope.ts +3 −2
  8. packages/core/api/src/handlers/secrets.ts +11 −10
  9. packages/core/api/src/handlers/sources.ts +11 −10
  10. packages/core/api/src/handlers/tools.ts +5 −4
  11. packages/core/api/src/index.ts +0 −2
  12. packages/core/api/src/observability.test.ts +4 −75
  13. packages/core/api/src/observability.ts +8 −103
  14. packages/core/api/src/scope/api.ts +5 −3
  15. packages/core/api/src/services.ts +1 −2
  16. packages/plugins/google-discovery/src/api/handlers.test.ts +2 −9
  17. packages/plugins/google-discovery/src/api/handlers.ts +14 −14
  18. packages/plugins/graphql/src/api/handlers.ts +8 −8
  19. packages/plugins/mcp/src/api/handlers.test.ts +2 −11
  20. packages/plugins/mcp/src/api/handlers.ts +25 −28
  21. packages/plugins/onepassword/src/api/handlers.ts +12 −12
  22. packages/plugins/openapi/src/api/handlers.ts +16 −16
  23. scripts/wrap-handlers-with-capture.ts +113 −0
apps/cloud/src/api/protected.ts+7−11
@@ -3,7 +3,6 @@ import { HttpApiBuilder, HttpApiSwagger, HttpServerRequest } from "@effect/platf · 2 unmodified lines
3import { Effect, Layer } from "effect";3import { Effect, Layer } from "effect";
4 4
5import { ExecutorService, ExecutionEngineService } from "@executor/api/server";5import { ExecutorService, ExecutionEngineService } from "@executor/api/server";
6import { withCapture } from "@executor/api";
7import { createExecutionEngine } from "@executor/execution";6import { createExecutionEngine } from "@executor/execution";
8import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";7import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
9import { OpenApiExtensionService } from "@executor/plugin-openapi/api";8import { 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-returning50 // 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(
apps/cloud/src/services/__test-harness__/api-harness.ts+4−6
@@ -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";
33import { withCapture } from "@executor/api";
34import { createExecutionEngine } from "@executor/execution";33import { createExecutionEngine } from "@executor/execution";
35import {34import {
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(
apps/local/src/server/main.ts+9−17
@@ -7,11 +7,7 @@ import { · 6 unmodified lines
7} from "@effect/platform";7} from "@effect/platform";
8import { Context, Effect, Layer, ManagedRuntime } from "effect";8import { Context, Effect, Layer, ManagedRuntime } from "effect";
9 9
10import {10import { addGroup, observabilityMiddleware } from "@executor/api";
11 addGroup,
12 observabilityMiddleware,
13 withCapture,
14} from "@executor/api";
15import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";11import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
16import { createExecutionEngine } from "@executor/execution";12import { createExecutionEngine } from "@executor/execution";
17import {13import {
@@ -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-returning93 // 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 })),
notes/error-handling.md+25−31
@@ -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`, fails98 the typed channel, captures the cause via `ErrorCapture`, fails with
99 with `InternalError({ traceId })`. Every other typed failure99 `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 extension104 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 ```ts106 ```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 of115 One line at the top of each handler. No service-level proxy, no
116 `withCapture`. Service tags declare this post-wrap shape so116 `Captured<T>` type gymnastics — the translation is visible right
117 handlers see `InternalError` in the method's error union (matching117 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. An120- `observabilityMiddleware(Api)` — defect safety net. An
128 `HttpApiBuilder.middleware` layer that wraps the HttpApp once and121 `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`) — provides165- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
173 `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API166 `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
174 layer in `protected-layers.ts` so it's available to both167 layer in `protected-layers.ts` so it's available to both
175 `observabilityMiddleware` (defect catchall) AND to `withCapture`'s168 `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 service170 `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 tracing172 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 a175- **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 a177 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 plugin188- 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 a190- `sanitize*` helpers in handler files that `catchAllCause` + map to a
197 generic 500 — same swallowing problem in disguise. Prefer191 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 referencing193- SDK code importing `Sentry.captureException` or referencing
200 `InternalError` / `ErrorCapture` — translation lives strictly in194 `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.
packages/core/api/src/executions/api.ts+4−1
@@ -1,6 +1,8 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3 3
4import { InternalError } from "../observability";
5
4// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
5// Schemas7// 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) {}
packages/core/api/src/handlers/executions.ts+5−4
@@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines
4import { ExecutorApi } from "../api";4import { ExecutorApi } from "../api";
5import { formatExecuteResult, formatPausedExecution } from "@executor/execution";5import { formatExecuteResult, formatPausedExecution } from "@executor/execution";
6import { ExecutionEngineService } from "../services";6import { ExecutionEngineService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>9export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>
9 handlers10 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);
packages/core/api/src/handlers/scope.ts+3−2
@@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { ExecutorApi } from "../api";4import { ExecutorApi } from "../api";
5import { ExecutorService } from "../services";5import { ExecutorService } from "../services";
6import { capture } from "@executor/api";
6 7
7export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) =>8export 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);
packages/core/api/src/handlers/secrets.ts+11−10
@@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8const refToResponse = (ref: SecretRef) => ({9const refToResponse = (ref: SecretRef) => ({
9 id: ref.id,10 id: ref.id,
@@ -16,21 +17,21 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines
16export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>17export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>
17 handlers18 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);
packages/core/api/src/handlers/sources.ts+11−10
@@ -4,11 +4,12 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>9export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>
9 handlers10 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);
packages/core/api/src/handlers/tools.ts+5−4
@@ -4,11 +4,12 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>9export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>
9 handlers10 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);
packages/core/api/src/index.ts+0−2
@@ -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";
packages/core/api/src/observability.test.ts+4−75
@@ -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 typed4// `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
15import { Cause, Effect, Layer, Ref } from "effect";9import { Cause, Effect, Layer, Ref } from "effect";
16import { StorageError, UniqueViolationError } from "@executor/storage-core";10import { StorageError, UniqueViolationError } from "@executor/storage-core";
17 11
18import {12import { 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 accumulates14// 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
99describe("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});
packages/core/api/src/observability.ts+8−103
@@ -13,11 +13,14 @@ · 12 unmodified lines
13// the cloud Worker, console in the CLI, in-memory in tests) to13// 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 helpers16// 3. `capture(eff)` — the one translator. Catches `StorageError` and
17// that translate `StorageError` to `InternalError({ traceId })` at17// `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 and19// 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 HttpApp24// 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp
22// once; catches any cause that slipped past the typed channel and25// 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
131const 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.
163type 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
176export 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
190export 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` layer118 * 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,
packages/core/api/src/scope/api.ts+5−3
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Response schemas8// Response schemas
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines
16// Group18// Group
17// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
18 20
19export class ScopeApi extends HttpApiGroup.make("scope").add(21export 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) {}
packages/core/api/src/services.ts+1−2
@@ -1,11 +1,10 @@
1import { Context } from "effect";1import { Context } from "effect";
2import type { Executor } from "@executor/sdk";2import type { Executor } from "@executor/sdk";
3import type { ExecutionEngine } from "@executor/execution";3import type { ExecutionEngine } from "@executor/execution";
4import type { Captured } from "./observability";
5 4
6export class ExecutorService extends Context.Tag("ExecutorService")<5export class ExecutorService extends Context.Tag("ExecutorService")<
7 ExecutorService,6 ExecutorService,
8 Captured<Executor>7 Executor
9>() {}8>() {}
10 9
11export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<10export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
packages/plugins/google-discovery/src/api/handlers.test.ts+2−9
@@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines
11import { describe, expect, it } from "@effect/vitest";11import { describe, expect, it } from "@effect/vitest";
12import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
13 13
14import {14import { addGroup, observabilityMiddleware } from "@executor/api";
15 addGroup,
16 observabilityMiddleware,
17 withCapture,
18} from "@executor/api";
19import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";15import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
20import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";16import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
21import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";17import { 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),
packages/plugins/google-discovery/src/api/handlers.ts+14−14
@@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup, type Captured } from "@executor/api";6import { addGroup, capture } from "@executor/api";
7import type {7import type {
8 GoogleDiscoveryAddSourceInput,8 GoogleDiscoveryAddSourceInput,
9 GoogleDiscoveryOAuthAuthResult,9 GoogleDiscoveryOAuthAuthResult,
@@ -25,7 +25,7 @@ import { GoogleDiscoveryGroup } from "./group"; · 15 unmodified lines
25 25
26export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<26export 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 handlers61 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);
packages/plugins/graphql/src/api/handlers.ts+8−8
@@ -1,7 +1,7 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup, type Captured } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import type {5import type {
6 GraphqlPluginExtension,6 GraphqlPluginExtension,
7 HeaderValue,7 HeaderValue,
@@ -22,7 +22,7 @@ import { GraphqlGroup } from "./group"; · 14 unmodified lines
22 22
23export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<23export 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
44export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>44export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>
45 handlers45 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);
packages/plugins/mcp/src/api/handlers.test.ts+2−11
@@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines
11import { describe, expect, it } from "@effect/vitest";11import { describe, expect, it } from "@effect/vitest";
12import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
13 13
14import {14import { addGroup, observabilityMiddleware } from "@executor/api";
15 addGroup,
16 observabilityMiddleware,
17 withCapture,
18} from "@executor/api";
19import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";15import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
20import type { McpPluginExtension } from "../sdk/plugin";16import type { McpPluginExtension } from "../sdk/plugin";
21import { McpExtensionService, McpHandlers } from "./handlers";17import { 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),
packages/plugins/mcp/src/api/handlers.ts+25−28
@@ -1,25 +1,22 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Cause, Context, Effect } from "effect";
3 3
4import { addGroup, type Captured } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
6import { McpOAuthError } from "../sdk/errors";6import { McpOAuthError } from "../sdk/errors";
7import { McpGroup } from "./group";7import { McpGroup } from "./group";
8 8
9// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
10// Service tag10// 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 cloud13// at the edge; that's why the tag type matches the SDK shape directly
14// app provides an already-wrapped extension via14// (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
20export class McpExtensionService extends Context.Tag("McpExtensionService")<17export 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
168export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>165export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>
169 handlers166 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 the234 // 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 a235 // 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);
packages/plugins/onepassword/src/api/handlers.ts+12−12
@@ -1,7 +1,7 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup, type Captured } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import type { OnePasswordExtension } from "../sdk/plugin";5import type { OnePasswordExtension } from "../sdk/plugin";
6import { OnePasswordGroup } from "./group";6import { OnePasswordGroup } from "./group";
7 7
@@ -18,7 +18,7 @@ import { OnePasswordGroup } from "./group"; · 10 unmodified lines
18 18
19export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<19export 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 handlers44 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);
packages/plugins/openapi/src/api/handlers.ts+16−16
@@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup, InternalError, type Captured } from "@executor/api";6import { addGroup, capture, InternalError } from "@executor/api";
7import { OpenApiOAuthError } from "../sdk/errors";7import { OpenApiOAuthError } from "../sdk/errors";
8import type {8import type {
9 OpenApiPluginExtension,9 OpenApiPluginExtension,
@@ -33,7 +33,7 @@ const toPopupErrorMessage = (error: unknown): string => { · 23 unmodified lines
33 33
34export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<34export 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
55export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>55export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>
56 handlers56 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 the124 // 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 a125 // 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);
scripts/wrap-handlers-with-capture.ts+113−0
@@ -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
12import { readFile, writeFile } from "node:fs/promises";
13
14const 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)
36const HANDLER_BODY = /(=>)\s*\n((?:[ \t]*\/\/[^\n]*\n)*)([ \t]+)Effect\.gen\(function\* \(\) \{([\s\S]*?)\n\3\}\)/g;
37
38function 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
52function 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
91async 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
107const results = await Promise.all(FILES.map(processFile));
108for (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}
112const total = results.reduce((n, r) => n + Math.max(0, r.handlers), 0);
113console.log(`\n${total} handler bodies wrapped.`);