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
33 import { Effect, Layer } from "effect";
44
55 import { ExecutorService, ExecutionEngineService } from "@executor/api/server";
6−import { withCapture } from "@executor/api";
76 import { createExecutionEngine } from "@executor/execution";
87 import { makeDynamicWorkerExecutor } from "@executor/runtime-dynamic-worker";
98 import { OpenApiExtensionService } from "@executor/plugin-openapi/api";
@@ -48,18 +47,15 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 38 unmodified lines
4847 makeTrackExecutionUsage(autumn),
4948 );
5049
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.
5753 const requestServices = Layer.mergeAll(
58− Layer.succeed(ExecutorService, wrapped),
54+ Layer.succeed(ExecutorService, executor),
5955 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),
6359 );
6460
6561 return yield* HttpApiBuilder.httpApp.pipe(
apps/cloud/src/services/__test-harness__/api-harness.ts+4−6
@@ -30,7 +30,6 @@ import { · 29 unmodified lines
3030 ExecutionEngineService,
3131 ExecutorService,
3232 } from "@executor/api/server";
33−import { withCapture } from "@executor/api";
3433 import { createExecutionEngine } from "@executor/execution";
3534 import {
3635 Scope,
@@ -217,13 +216,12 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 180 unmodified lines
217216 Effect.gen(function* () {
218217 const executor = yield* createTestScopedExecutor(scopeId, scopeName);
219218 const engine = createExecutionEngine({ executor });
220− const wrapped = withCapture(executor);
221219 const services = Layer.mergeAll(
222− Layer.succeed(ExecutorService, wrapped),
220+ Layer.succeed(ExecutorService, executor),
223221 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),
227225 );
228226 return yield* HttpApiBuilder.httpApp.pipe(
229227 Effect.provide(
apps/local/src/server/main.ts+9−17
@@ -7,11 +7,7 @@ import { · 6 unmodified lines
77 } from "@effect/platform";
88 import { Context, Effect, Layer, ManagedRuntime } from "effect";
99
10−import {
11− addGroup,
12− observabilityMiddleware,
13− withCapture,
14−} from "@executor/api";
10+import { addGroup, observabilityMiddleware } from "@executor/api";
1511 import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
1612 import { createExecutionEngine } from "@executor/execution";
1713 import {
@@ -94,18 +90,14 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 76 unmodified lines
9490 const executor = await getExecutor();
9591 const engine = createExecutionEngine({ executor });
9692
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.
10395 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),
109101 );
110102
111103 const api = HttpApiBuilder.toWebHandler(
@@ -113,7 +105,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 1 unmodified line
113105 Layer.provideMerge(HttpApiBuilder.middlewareOpenApi()),
114106 Layer.provideMerge(LocalApiBase),
115107 Layer.provideMerge(pluginExtensions),
116− Layer.provideMerge(Layer.succeed(ExecutorService, wrapped)),
108+ Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
117109 Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
118110 Layer.provideMerge(HttpServer.layerContext),
119111 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
9494 Optional — resolved via `Effect.serviceOption`; missing service =
9595 empty trace ids. Nothing breaks if it's not wired.
9696
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.
101103
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(...)`:
107105
108106 ```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+ })),
112112 )
113113 ```
114114
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).
126119
127120 - `observabilityMiddleware(Api)` — defect safety net. An
128121 `HttpApiBuilder.middleware` layer that wraps the HttpApp once and
@@ -172,12 +165,13 @@ its annotated status. · 43 unmodified lines
172165 - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
173166 `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
174167 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.)
181175 - **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a
182176 console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`)
183177 that prints the squashed cause + pretty cause to stderr and returns a
@@ -194,8 +188,8 @@ its annotated status. · 10 unmodified lines
194188 - Per-plugin `*InternalError` types — clients can't tell which plugin
195189 emitted a 500 anyway. Use the shared `InternalError`.
196190 - `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(...)`.
199193 - SDK code importing `Sentry.captureException` or referencing
200194 `InternalError` / `ErrorCapture` — translation lives strictly in
201195 `@executor/api`. If the SDK imports it, the layering is wrong.
packages/core/api/src/executions/api.ts+4−1
@@ -1,6 +1,8 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33
4+import { InternalError } from "../observability";
5+
46 // ---------------------------------------------------------------------------
57 // Schemas
68 // ---------------------------------------------------------------------------
@@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines
6062 .setPayload(ResumeRequest)
6163 .addSuccess(ResumeResponse)
6264 .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
44 import { ExecutorApi } from "../api";
55 import { formatExecuteResult, formatPausedExecution } from "@executor/execution";
66 import { ExecutionEngineService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>
910 handlers
1011 .handle("execute", ({ payload }) =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const engine = yield* ExecutionEngineService;
1314 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));
1415
@@ -28,10 +29,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 13 unmodified lines
2829 text: formatted.text,
2930 structured: formatted.structured,
3031 };
31− }),
32+ })),
3233 )
3334 .handle("resume", ({ path, payload }) =>
34− Effect.gen(function* () {
35+ capture(Effect.gen(function* () {
3536 const engine = yield* ExecutionEngineService;
3637 const result = yield* Effect.promise(() =>
3738 engine.resume(path.executionId, {
@@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines
6263 structured: formatted.structured,
6364 isError: false,
6465 };
65− }),
66+ })),
6667 ),
6768 );
packages/core/api/src/handlers/scope.ts+3−2
@@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines
33
44 import { ExecutorApi } from "../api";
55 import { ExecutorService } from "../services";
6+import { capture } from "@executor/api";
67
78 export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) =>
89 handlers.handle("info", () =>
9− Effect.gen(function* () {
10+ capture(Effect.gen(function* () {
1011 const executor = yield* ExecutorService;
1112 return {
1213 id: executor.scope.id,
1314 name: executor.scope.name,
1415 dir: executor.scope.name,
1516 };
16− }),
17+ })),
1718 ),
1819 );
packages/core/api/src/handlers/secrets.ts+11−10
@@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 const refToResponse = (ref: SecretRef) => ({
910 id: ref.id,
@@ -16,21 +17,21 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines
1617 export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>
1718 handlers
1819 .handle("list", () =>
19− Effect.gen(function* () {
20+ capture(Effect.gen(function* () {
2021 const executor = yield* ExecutorService;
2122 const refs = yield* executor.secrets.list();
2223 return refs.map(refToResponse);
23− }),
24+ })),
2425 )
2526 .handle("status", ({ path }) =>
26− Effect.gen(function* () {
27+ capture(Effect.gen(function* () {
2728 const executor = yield* ExecutorService;
2829 const status = yield* executor.secrets.status(path.secretId);
2930 return { secretId: path.secretId, status };
30− }),
31+ })),
3132 )
3233 .handle("set", ({ payload }) =>
33− Effect.gen(function* () {
34+ capture(Effect.gen(function* () {
3435 const executor = yield* ExecutorService;
3536 const ref = yield* executor.secrets.set({
3637 id: payload.id,
@@ -39,23 +40,23 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han · 2 unmodified lines
3940 provider: payload.provider,
4041 });
4142 return refToResponse(ref);
42− }),
43+ })),
4344 )
4445 .handle("resolve", ({ path }) =>
45− Effect.gen(function* () {
46+ capture(Effect.gen(function* () {
4647 const executor = yield* ExecutorService;
4748 const value = yield* executor.secrets.get(path.secretId);
4849 if (value === null) {
4950 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));
5051 }
5152 return { secretId: path.secretId, value };
52− }),
53+ })),
5354 )
5455 .handle("remove", ({ path }) =>
55− Effect.gen(function* () {
56+ capture(Effect.gen(function* () {
5657 const executor = yield* ExecutorService;
5758 yield* executor.secrets.remove(path.secretId);
5859 return { removed: true };
59− }),
60+ })),
6061 ),
6162 );
packages/core/api/src/handlers/sources.ts+11−10
@@ -4,11 +4,12 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>
910 handlers
1011 .handle("list", () =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const executor = yield* ExecutorService;
1314 const sources = yield* executor.sources.list();
1415 return sources.map((s) => ({
@@ -21,24 +22,24 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 6 unmodified lines
2122 canRefresh: s.canRefresh,
2223 canEdit: s.canEdit,
2324 }));
24− }),
25+ })),
2526 )
2627 .handle("remove", ({ path }) =>
27− Effect.gen(function* () {
28+ capture(Effect.gen(function* () {
2829 const executor = yield* ExecutorService;
2930 yield* executor.sources.remove(path.sourceId);
3031 return { removed: true };
31− }),
32+ })),
3233 )
3334 .handle("refresh", ({ path }) =>
34− Effect.gen(function* () {
35+ capture(Effect.gen(function* () {
3536 const executor = yield* ExecutorService;
3637 yield* executor.sources.refresh(path.sourceId);
3738 return { refreshed: true };
38− }),
39+ })),
3940 )
4041 .handle("tools", ({ path }) =>
41− Effect.gen(function* () {
42+ capture(Effect.gen(function* () {
4243 const executor = yield* ExecutorService;
4344 const tools = yield* executor.tools.list({ sourceId: path.sourceId });
4445 return tools.map((t) => ({
@@ -49,10 +50,10 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines
4950 description: t.description,
5051 mayElicit: t.annotations?.mayElicit,
5152 }));
52− }),
53+ })),
5354 )
5455 .handle("detect", ({ payload }) =>
55− Effect.gen(function* () {
56+ capture(Effect.gen(function* () {
5657 const executor = yield* ExecutorService;
5758 const results = yield* executor.sources.detect(payload.url);
5859 return results.map((r) => ({
@@ -62,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 3 unmodified lines
6263 name: r.name,
6364 namespace: r.namespace,
6465 }));
65− }),
66+ })),
6667 ),
6768 );
packages/core/api/src/handlers/tools.ts+5−4
@@ -4,11 +4,12 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines
44
55 import { ExecutorApi } from "../api";
66 import { ExecutorService } from "../services";
7+import { capture } from "@executor/api";
78
89 export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>
910 handlers
1011 .handle("list", () =>
11− Effect.gen(function* () {
12+ capture(Effect.gen(function* () {
1213 const executor = yield* ExecutorService;
1314 const tools = yield* executor.tools.list();
1415 return tools.map((t) => ({
@@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 4 unmodified lines
1920 description: t.description,
2021 mayElicit: t.annotations?.mayElicit,
2122 }));
22− }),
23+ })),
2324 )
2425 .handle("schema", ({ path }) =>
25− Effect.gen(function* () {
26+ capture(Effect.gen(function* () {
2627 const executor = yield* ExecutorService;
2728 const schema = yield* executor.tools.schema(path.toolId);
2829 if (schema === null) {
2930 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));
3031 }
3132 return schema;
32− }),
33+ })),
3334 ),
3435 );
packages/core/api/src/index.ts+0−2
@@ -9,7 +9,5 @@ export { · 8 unmodified lines
99 ErrorCapture,
1010 observabilityMiddleware,
1111 capture,
12− withCapture,
13− type Captured,
1412 type ErrorCaptureShape,
1513 } from "./observability";
packages/core/api/src/observability.test.ts+4−75
@@ -1,13 +1,7 @@
11 // ---------------------------------------------------------------------------
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.
115 // ErrorCapture is optional — absent hosts just get empty trace ids.
126 // ---------------------------------------------------------------------------
137
@@ -15,12 +9,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
159 import { Cause, Effect, Layer, Ref } from "effect";
1610 import { StorageError, UniqueViolationError } from "@executor/storage-core";
1711
18−import {
19− capture,
20− ErrorCapture,
21− InternalError,
22− withCapture,
23−} from "./observability";
12+import { capture, ErrorCapture, InternalError } from "./observability";
2413
2514 // Recording ErrorCapture — returns a fixed trace id and accumulates
2615 // every cause it sees in a Ref.
@@ -96,63 +85,3 @@ describe("capture", () => { · 69 unmodified lines
9685 );
9786 });
9887
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−});
packages/core/api/src/observability.ts+8−103
@@ -13,11 +13,14 @@ · 12 unmodified lines
1313 // the cloud Worker, console in the CLI, in-memory in tests) to
1414 // record causes and return correlation ids. Optional; absent →
1515 // 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).
2124 // 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp
2225 // once; catches any cause that slipped past the typed channel and
2326 // produces the same `InternalError({ traceId })` shape.
@@ -111,104 +114,6 @@ export const capture = <A, E, R>( · 87 unmodified lines
111114 ),
112115 ) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>;
113116
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−
212117 /**
213118 * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer
214119 * 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
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
44
5+import { InternalError } from "../observability";
6+
57 // ---------------------------------------------------------------------------
68 // Response schemas
79 // ---------------------------------------------------------------------------
@@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines
1618 // Group
1719 // ---------------------------------------------------------------------------
1820
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) {}
packages/core/api/src/services.ts+1−2
@@ -1,11 +1,10 @@
11 import { Context } from "effect";
22 import type { Executor } from "@executor/sdk";
33 import type { ExecutionEngine } from "@executor/execution";
4−import type { Captured } from "./observability";
54
65 export class ExecutorService extends Context.Tag("ExecutorService")<
76 ExecutorService,
8− Captured<Executor>
7+ Executor
98 >() {}
109
1110 export 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
1111 import { describe, expect, it } from "@effect/vitest";
1212 import { Effect, Layer } from "effect";
1313
14−import {
15− addGroup,
16− observabilityMiddleware,
17− withCapture,
18−} from "@executor/api";
14+import { addGroup, observabilityMiddleware } from "@executor/api";
1915 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
2016 import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
2117 import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
@@ -47,10 +43,7 @@ const WebHandler = Effect.acquireRelease( · 25 unmodified lines
4743 Layer.provide(Layer.succeed(ExecutorService, {} as never)),
4844 Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
4945 Layer.provide(
50− Layer.succeed(
51− GoogleDiscoveryExtensionService,
52− withCapture(failingExtension),
53− ),
46+ Layer.succeed(GoogleDiscoveryExtensionService, failingExtension),
5447 ),
5548 Layer.provideMerge(HttpServer.layerContext),
5649 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
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup, type Captured } from "@executor/api";
6+import { addGroup, capture } from "@executor/api";
77 import type {
88 GoogleDiscoveryAddSourceInput,
99 GoogleDiscoveryOAuthAuthResult,
@@ -25,7 +25,7 @@ import { GoogleDiscoveryGroup } from "./group"; · 15 unmodified lines
2525
2626 export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
2727 GoogleDiscoveryExtensionService,
28− Captured<GoogleDiscoveryPluginExtension>
28+ GoogleDiscoveryPluginExtension
2929 >() {}
3030
3131 // ---------------------------------------------------------------------------
@@ -60,19 +60,19 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 28 unmodified lines
6060 (handlers) =>
6161 handlers
6262 .handle("probeDiscovery", ({ payload }) =>
63− Effect.gen(function* () {
63+ capture(Effect.gen(function* () {
6464 const ext = yield* GoogleDiscoveryExtensionService;
6565 return yield* ext.probeDiscovery(payload.discoveryUrl);
66− }),
66+ })),
6767 )
6868 .handle("addSource", ({ payload }) =>
69− Effect.gen(function* () {
69+ capture(Effect.gen(function* () {
7070 const ext = yield* GoogleDiscoveryExtensionService;
7171 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
72− }),
72+ })),
7373 )
7474 .handle("startOAuth", ({ payload }) =>
75− Effect.gen(function* () {
75+ capture(Effect.gen(function* () {
7676 const ext = yield* GoogleDiscoveryExtensionService;
7777 return yield* ext.startOAuth({
7878 name: payload.name,
@@ -82,26 +82,26 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
8282 redirectUrl: payload.redirectUrl,
8383 scopes: payload.scopes,
8484 });
85− }),
85+ })),
8686 )
8787 .handle("completeOAuth", ({ payload }) =>
88− Effect.gen(function* () {
88+ capture(Effect.gen(function* () {
8989 const ext = yield* GoogleDiscoveryExtensionService;
9090 return yield* ext.completeOAuth({
9191 state: payload.state,
9292 code: payload.code,
9393 error: payload.error,
9494 });
95− }),
95+ })),
9696 )
9797 .handle("getSource", ({ path }) =>
98− Effect.gen(function* () {
98+ capture(Effect.gen(function* () {
9999 const ext = yield* GoogleDiscoveryExtensionService;
100100 return yield* ext.getSource(path.namespace);
101− }),
101+ })),
102102 )
103103 .handle("oauthCallback", ({ urlParams }) =>
104− Effect.gen(function* () {
104+ capture(Effect.gen(function* () {
105105 const ext = yield* GoogleDiscoveryExtensionService;
106106 const html = yield* runOAuthCallback<
107107 GoogleDiscoveryOAuthAuthResult,
@@ -119,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 11 unmodified lines
119119 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
120120 });
121121 return yield* HttpServerResponse.html(html);
122− }),
122+ })),
123123 ),
124124 );
packages/plugins/graphql/src/api/handlers.ts+8−8
@@ -1,7 +1,7 @@
11 import { HttpApiBuilder } from "@effect/platform";
22 import { Context, Effect } from "effect";
33
4−import { addGroup, type Captured } from "@executor/api";
4+import { addGroup, capture } from "@executor/api";
55 import type {
66 GraphqlPluginExtension,
77 HeaderValue,
@@ -22,7 +22,7 @@ import { GraphqlGroup } from "./group"; · 14 unmodified lines
2222
2323 export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<
2424 GraphqlExtensionService,
25− Captured<GraphqlPluginExtension>
25+ GraphqlPluginExtension
2626 >() {}
2727
2828 // ---------------------------------------------------------------------------
@@ -44,7 +44,7 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 15 unmodified lines
4444 export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>
4545 handlers
4646 .handle("addSource", ({ payload }) =>
47− Effect.gen(function* () {
47+ capture(Effect.gen(function* () {
4848 const ext = yield* GraphqlExtensionService;
4949 const result = yield* ext.addSource({
5050 endpoint: payload.endpoint,
@@ -57,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines
5757 toolCount: result.toolCount,
5858 namespace: payload.namespace ?? "graphql",
5959 };
60− }),
60+ })),
6161 )
6262 .handle("getSource", ({ path }) =>
63− Effect.gen(function* () {
63+ capture(Effect.gen(function* () {
6464 const ext = yield* GraphqlExtensionService;
6565 return yield* ext.getSource(path.namespace);
66− }),
66+ })),
6767 )
6868 .handle("updateSource", ({ path, payload }) =>
69− Effect.gen(function* () {
69+ capture(Effect.gen(function* () {
7070 const ext = yield* GraphqlExtensionService;
7171 yield* ext.updateSource(path.namespace, {
7272 name: payload.name,
@@ -74,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line
7474 headers: payload.headers as Record<string, HeaderValue> | undefined,
7575 } as GraphqlUpdateSourceInput);
7676 return { updated: true };
77− }),
77+ })),
7878 ),
7979 );
packages/plugins/mcp/src/api/handlers.test.ts+2−11
@@ -11,11 +11,7 @@ import { HttpApiBuilder, HttpServer } from "@effect/platform"; · 10 unmodified lines
1111 import { describe, expect, it } from "@effect/vitest";
1212 import { Effect, Layer } from "effect";
1313
14−import {
15− addGroup,
16− observabilityMiddleware,
17− withCapture,
18−} from "@executor/api";
14+import { addGroup, observabilityMiddleware } from "@executor/api";
1915 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
2016 import type { McpPluginExtension } from "../sdk/plugin";
2117 import { McpExtensionService, McpHandlers } from "./handlers";
@@ -50,12 +46,7 @@ const WebHandler = Effect.acquireRelease( · 28 unmodified lines
5046 Layer.provide(observabilityMiddleware(Api)),
5147 Layer.provide(Layer.succeed(ExecutorService, {} as never)),
5248 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)),
5950 Layer.provideMerge(HttpServer.layerContext),
6051 Layer.provideMerge(HttpApiBuilder.Router.Live),
6152 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
packages/plugins/mcp/src/api/handlers.ts+25−28
@@ -1,25 +1,22 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
22 import { Cause, Context, Effect } from "effect";
33
4−import { addGroup, type Captured } from "@executor/api";
4+import { addGroup, capture } from "@executor/api";
55 import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
66 import { McpOAuthError } from "../sdk/errors";
77 import { McpGroup } from "./group";
88
99 // ---------------------------------------------------------------------------
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).
1815 // ---------------------------------------------------------------------------
1916
2017 export class McpExtensionService extends Context.Tag("McpExtensionService")<
2118 McpExtensionService,
22− Captured<McpPluginExtension>
19+ McpPluginExtension
2320 >() {}
2421
2522 // ---------------------------------------------------------------------------
@@ -168,60 +165,60 @@ const toSourceConfig = ( · 142 unmodified lines
168165 export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>
169166 handlers
170167 .handle("probeEndpoint", ({ payload }) =>
171− Effect.gen(function* () {
168+ capture(Effect.gen(function* () {
172169 const ext = yield* McpExtensionService;
173170 return yield* ext.probeEndpoint(payload.endpoint);
174− }),
171+ })),
175172 )
176173 .handle("addSource", ({ payload }) =>
177− Effect.gen(function* () {
174+ capture(Effect.gen(function* () {
178175 const ext = yield* McpExtensionService;
179176 return yield* ext.addSource(
180177 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),
181178 );
182− }),
179+ })),
183180 )
184181 .handle("removeSource", ({ payload }) =>
185− Effect.gen(function* () {
182+ capture(Effect.gen(function* () {
186183 const ext = yield* McpExtensionService;
187184 yield* ext.removeSource(payload.namespace);
188185 return { removed: true };
189− }),
186+ })),
190187 )
191188 .handle("refreshSource", ({ payload }) =>
192− Effect.gen(function* () {
189+ capture(Effect.gen(function* () {
193190 const ext = yield* McpExtensionService;
194191 return yield* ext.refreshSource(payload.namespace);
195− }),
192+ })),
196193 )
197194 .handle("startOAuth", ({ payload }) =>
198− Effect.gen(function* () {
195+ capture(Effect.gen(function* () {
199196 const ext = yield* McpExtensionService;
200197 return yield* ext.startOAuth({
201198 endpoint: payload.endpoint,
202199 redirectUrl: payload.redirectUrl,
203200 queryParams: payload.queryParams,
204201 });
205− }),
202+ })),
206203 )
207204 .handle("completeOAuth", ({ payload }) =>
208− Effect.gen(function* () {
205+ capture(Effect.gen(function* () {
209206 const ext = yield* McpExtensionService;
210207 return yield* ext.completeOAuth({
211208 state: payload.state,
212209 code: payload.code,
213210 error: payload.error,
214211 });
215− }),
212+ })),
216213 )
217214 .handle("getSource", ({ path }) =>
218− Effect.gen(function* () {
215+ capture(Effect.gen(function* () {
219216 const ext = yield* McpExtensionService;
220217 return yield* ext.getSource(path.namespace);
221− }),
218+ })),
222219 )
223220 .handle("updateSource", ({ path, payload }) =>
224− Effect.gen(function* () {
221+ capture(Effect.gen(function* () {
225222 const ext = yield* McpExtensionService;
226223 yield* ext.updateSource(path.namespace, {
227224 name: payload.name,
@@ -231,13 +228,13 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
231228 auth: payload.auth as McpUpdateSourceInput["auth"],
232229 });
233230 return { updated: true };
234− }),
231+ })),
235232 )
236233 .handle("oauthCallback", ({ urlParams }) =>
237234 // OAuth popup is special: it always returns 200 HTML and renders the
238235 // failure into the popup body so the parent window's listener gets a
239236 // structured result. Catching here is intentional, not a leak.
240− Effect.gen(function* () {
237+ capture(Effect.gen(function* () {
241238 const ext = yield* McpExtensionService;
242239 const result = yield* Effect.matchCauseEffect(
243240 ext.completeOAuth({
@@ -267,6 +264,6 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 23 unmodified lines
267264 },
268265 );
269266 return yield* HttpServerResponse.html(popupDocument(result));
270− }),
267+ })),
271268 ),
272269 );
packages/plugins/onepassword/src/api/handlers.ts+12−12
@@ -1,7 +1,7 @@
11 import { HttpApiBuilder } from "@effect/platform";
22 import { Context, Effect } from "effect";
33
4−import { addGroup, type Captured } from "@executor/api";
4+import { addGroup, capture } from "@executor/api";
55 import type { OnePasswordExtension } from "../sdk/plugin";
66 import { OnePasswordGroup } from "./group";
77
@@ -18,7 +18,7 @@ import { OnePasswordGroup } from "./group"; · 10 unmodified lines
1818
1919 export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<
2020 OnePasswordExtensionService,
21− Captured<OnePasswordExtension>
21+ OnePasswordExtension
2222 >() {}
2323
2424 // ---------------------------------------------------------------------------
@@ -43,31 +43,31 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 18 unmodified lines
4343 (handlers) =>
4444 handlers
4545 .handle("getConfig", () =>
46− Effect.gen(function* () {
46+ capture(Effect.gen(function* () {
4747 const ext = yield* OnePasswordExtensionService;
4848 return yield* ext.getConfig();
49− }),
49+ })),
5050 )
5151 .handle("configure", ({ payload }) =>
52− Effect.gen(function* () {
52+ capture(Effect.gen(function* () {
5353 const ext = yield* OnePasswordExtensionService;
5454 yield* ext.configure(payload);
55− }),
55+ })),
5656 )
5757 .handle("removeConfig", () =>
58− Effect.gen(function* () {
58+ capture(Effect.gen(function* () {
5959 const ext = yield* OnePasswordExtensionService;
6060 yield* ext.removeConfig();
61− }),
61+ })),
6262 )
6363 .handle("status", () =>
64− Effect.gen(function* () {
64+ capture(Effect.gen(function* () {
6565 const ext = yield* OnePasswordExtensionService;
6666 return yield* ext.status();
67− }),
67+ })),
6868 )
6969 .handle("listVaults", ({ urlParams }) =>
70− Effect.gen(function* () {
70+ capture(Effect.gen(function* () {
7171 const ext = yield* OnePasswordExtensionService;
7272 const auth =
7373 urlParams.authKind === "desktop-app"
@@ -75,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line
7575 : { kind: "service-account" as const, tokenSecretId: urlParams.account };
7676 const vaults = yield* ext.listVaults(auth);
7777 return { vaults: [...vaults] };
78− }),
78+ })),
7979 ),
8080 );
packages/plugins/openapi/src/api/handlers.ts+16−16
@@ -3,7 +3,7 @@ import { Context, Effect } from "effect"; · 2 unmodified lines
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup, InternalError, type Captured } from "@executor/api";
6+import { addGroup, capture, InternalError } from "@executor/api";
77 import { OpenApiOAuthError } from "../sdk/errors";
88 import type {
99 OpenApiPluginExtension,
@@ -33,7 +33,7 @@ const toPopupErrorMessage = (error: unknown): string => { · 23 unmodified lines
3333
3434 export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<
3535 OpenApiExtensionService,
36− Captured<OpenApiPluginExtension>
36+ OpenApiPluginExtension
3737 >() {}
3838
3939 // ---------------------------------------------------------------------------
@@ -55,13 +55,13 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 15 unmodified lines
5555 export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>
5656 handlers
5757 .handle("previewSpec", ({ payload }) =>
58− Effect.gen(function* () {
58+ capture(Effect.gen(function* () {
5959 const ext = yield* OpenApiExtensionService;
6060 return yield* ext.previewSpec(payload.spec);
61− }),
61+ })),
6262 )
6363 .handle("addSpec", ({ payload }) =>
64− Effect.gen(function* () {
64+ capture(Effect.gen(function* () {
6565 const ext = yield* OpenApiExtensionService;
6666 const result = yield* ext.addSpec({
6767 spec: payload.spec,
@@ -75,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
7575 toolCount: result.toolCount,
7676 namespace: result.sourceId,
7777 };
78− }),
78+ })),
7979 )
8080 .handle("getSource", ({ path }) =>
81− Effect.gen(function* () {
81+ capture(Effect.gen(function* () {
8282 const ext = yield* OpenApiExtensionService;
8383 return yield* ext.getSource(path.namespace);
84− }),
84+ })),
8585 )
8686 .handle("updateSource", ({ path, payload }) =>
87− Effect.gen(function* () {
87+ capture(Effect.gen(function* () {
8888 const ext = yield* OpenApiExtensionService;
8989 yield* ext.updateSource(path.namespace, {
9090 name: payload.name,
@@ -92,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line
9292 headers: payload.headers as Record<string, HeaderValue> | undefined,
9393 } as OpenApiUpdateSourceInput);
9494 return { updated: true };
95− }),
95+ })),
9696 )
9797 .handle("startOAuth", ({ payload }) =>
98− Effect.gen(function* () {
98+ capture(Effect.gen(function* () {
9999 const ext = yield* OpenApiExtensionService;
100100 return yield* ext.startOAuth({
101101 displayName: payload.displayName,
@@ -108,23 +108,23 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines
108108 clientSecretSecretId: payload.clientSecretSecretId ?? null,
109109 scopes: [...payload.scopes],
110110 });
111− }),
111+ })),
112112 )
113113 .handle("completeOAuth", ({ payload }) =>
114− Effect.gen(function* () {
114+ capture(Effect.gen(function* () {
115115 const ext = yield* OpenApiExtensionService;
116116 return yield* ext.completeOAuth({
117117 state: payload.state,
118118 code: payload.code,
119119 error: payload.error,
120120 });
121− }),
121+ })),
122122 )
123123 .handle("oauthCallback", ({ urlParams }) =>
124124 // OAuth popup is special: it always returns 200 HTML and renders the
125125 // failure into the popup body so the parent window's listener gets a
126126 // structured result.
127− Effect.gen(function* () {
127+ capture(Effect.gen(function* () {
128128 const ext = yield* OpenApiExtensionService;
129129 const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({
130130 complete: ({ state, code, error }) =>
@@ -138,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
138138 channelName: OPENAPI_OAUTH_CHANNEL,
139139 });
140140 return yield* HttpServerResponse.html(html);
141− }),
141+ })),
142142 ),
143143 );
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+
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.`);