History

refactor(plugins): migrate openapi/graphql/google-discovery/onepassword to Captured

Four plugin migrations in parallel. For each: - Deleted the per-plugin `*InternalError` class; use the shared `InternalError` from `@executor/api` at the group level. - Deleted `sanitize*` / `toXxxInternalError` helpers from `api/handlers.ts`; handlers are now bare `yield the service; return yield* ext.foo(...)`. - Changed every extension service tag to `Captured<XxxPluginExtension>`. - Narrowed `Effect<_, Error>` blanket types on extension methods to typed domain errors + `StorageFailure`. Internal helpers (`OpenapiStore`, `GraphqlStore`, `GoogleDiscoveryStore`, `OnePasswordStore`) got matching narrowings. - Each plugin's `extension(ctx) => ({...}) satisfies XxxPluginExtension` now holds without any `as unknown as` cast. - `GoogleDiscoveryParseError`/`OAuthError`/`SourceError` converted from `Data.TaggedError` to `Schema.TaggedError` with `HttpApiSchema` status annotations so they can `.addError(...)` on the group. - OnePassword: dropped `Effect.orDie` from every handler (was masking defects); `OnePasswordStore` surfaces storage-layer failures as `StorageError` with cause preserved. Host wiring (`apps/cloud/src/api/protected.ts`, `apps/local/src/server/main.ts`, cloud test-harness) now pulls every extension from `wrapped.X` — single top-level `const wrapped = withCapture(executor)`, no per-extension wrapping, no raw/wrapped split. 34/34 typecheck, every plugin's tests + cloud's 27+23 tests green.

Rhys Sullivan committed 7 months ago

Commit 3d3d39f1 parent d9aee0e24 files · +621 −510Browse files

Unified · Split

24 files
  1. apps/cloud/src/api/protected.ts +6 −7
  2. apps/cloud/src/services/__test-harness__/api-harness.ts +2 −2
  3. apps/local/src/server/main.ts +8 −9
  4. notes/error-handling.md +20 −20
  5. packages/plugins/google-discovery/src/api/group.ts +38 −26
  6. packages/plugins/google-discovery/src/api/handlers.test.ts +68 −42
  7. packages/plugins/google-discovery/src/api/handlers.ts +43 −50
  8. packages/plugins/google-discovery/src/sdk/binding-store.ts +22 −12
  9. packages/plugins/google-discovery/src/sdk/document.ts +6 −2
  10. packages/plugins/google-discovery/src/sdk/errors.ts +21 −4
  11. packages/plugins/google-discovery/src/sdk/plugin.ts +34 −16
  12. packages/plugins/graphql/src/api/group.ts +23 −17
  13. packages/plugins/graphql/src/api/handlers.ts +26 −51
  14. packages/plugins/graphql/src/sdk/invoke.ts +2 −2
  15. packages/plugins/graphql/src/sdk/plugin.ts +24 −14
  16. packages/plugins/graphql/src/sdk/store.ts +8 −8
  17. packages/plugins/onepassword/src/api/group.ts +19 −14
  18. packages/plugins/onepassword/src/api/handlers.ts +21 −8
  19. packages/plugins/onepassword/src/sdk/errors.ts +9 −4
  20. packages/plugins/onepassword/src/sdk/plugin.ts +108 −103
  21. packages/plugins/openapi/src/api/group.ts +30 −28
  22. packages/plugins/openapi/src/api/handlers.ts +28 −52
  23. packages/plugins/openapi/src/sdk/plugin.ts +36 −8
  24. packages/plugins/openapi/src/sdk/store.ts +19 −11
apps/cloud/src/api/protected.ts+6−7
@@ -49,18 +49,17 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 48 unmodified lines
4949 );
5050
5151 // `withCapture` wraps the executor once — every Effect-returning
52− // method translates `StorageError` → `InternalError({ traceId })`
53− // via `ErrorCapture`. Service tags that declare `Captured<...>`
54− // (MCP today) pull from `wrapped.*`; tags still on the pre-capture
55− // shape (openapi / graphql) pull from the raw executor until they
56− // migrate. See notes/error-handling.md.
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.
5756 const wrapped = withCapture(executor);
5857 const requestServices = Layer.mergeAll(
5958 Layer.succeed(ExecutorService, executor),
6059 Layer.succeed(ExecutionEngineService, engine),
61− Layer.succeed(OpenApiExtensionService, executor.openapi),
60+ Layer.succeed(OpenApiExtensionService, wrapped.openapi),
6261 Layer.succeed(McpExtensionService, wrapped.mcp),
63− Layer.succeed(GraphqlExtensionService, executor.graphql),
62+ Layer.succeed(GraphqlExtensionService, wrapped.graphql),
6463 );
6564
6665 return yield* HttpApiBuilder.httpApp.pipe(
apps/cloud/src/services/__test-harness__/api-harness.ts+2−2
@@ -221,9 +221,9 @@ const buildAppForScope = (scopeId: string, scopeName: string) => · 220 unmodified lines
221221 const services = Layer.mergeAll(
222222 Layer.succeed(ExecutorService, executor),
223223 Layer.succeed(ExecutionEngineService, engine),
224− Layer.succeed(OpenApiExtensionService, executor.openapi),
224+ Layer.succeed(OpenApiExtensionService, wrapped.openapi),
225225 Layer.succeed(McpExtensionService, wrapped.mcp),
226− Layer.succeed(GraphqlExtensionService, executor.graphql),
226+ Layer.succeed(GraphqlExtensionService, wrapped.graphql),
227227 );
228228 return yield* HttpApiBuilder.httpApp.pipe(
229229 Effect.provide(
apps/local/src/server/main.ts+8−9
@@ -95,18 +95,17 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 94 unmodified lines
9595 const engine = createExecutionEngine({ executor });
9696
9797 // `withCapture` wraps the executor once — every Effect-returning
98− // method translates `StorageError` to `InternalError({ traceId })` via
99− // `ErrorCapture`. Service tags that declare `Captured<...>` (MCP
100− // today) pull from `wrapped.*`; tags still on the pre-capture shape
101− // (openapi / graphql / google-discovery / onepassword) pull from the
102− // raw executor until they migrate.
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.
103102 const wrapped = withCapture(executor);
104103 const pluginExtensions = Layer.mergeAll(
105− Layer.succeed(OpenApiExtensionService, executor.openapi),
104+ Layer.succeed(OpenApiExtensionService, wrapped.openapi),
106105 Layer.succeed(McpExtensionService, wrapped.mcp),
107− Layer.succeed(GoogleDiscoveryExtensionService, executor.googleDiscovery),
108− Layer.succeed(OnePasswordExtensionService, executor.onepassword),
109− Layer.succeed(GraphqlExtensionService, executor.graphql),
106+ Layer.succeed(GoogleDiscoveryExtensionService, wrapped.googleDiscovery),
107+ Layer.succeed(OnePasswordExtensionService, wrapped.onepassword),
108+ Layer.succeed(GraphqlExtensionService, wrapped.graphql),
110109 );
111110
112111 const api = HttpApiBuilder.toWebHandler(
notes/error-handling.md+20−20
@@ -94,33 +94,33 @@ 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−- `captureStorage(eff)` — single-Effect wrapper. Catches `StorageError`
97+- `capture(eff)` — single-Effect wrapper. Catches `StorageError`
9898 on the typed channel, captures the cause via `ErrorCapture`, fails
9999 with `InternalError({ traceId })`. Every other typed failure
100100 (`UniqueViolationError`, plugin-domain errors) passes through.
101101
102−- `withStorageCapture(obj)` — proxy wrapper for a whole extension
102+- `withCapture(obj)` — proxy wrapper for a whole extension
103103 surface. Walks methods (recursing into nested plain-object shapes)
104− and applies `captureStorage` to each Effect-returning method. Lets
104+ and applies `capture` to each Effect-returning method. Lets
105105 the cloud app wire translation once at Layer composition instead of
106106 per handler.
107107
108108 ```ts
109109 Layer.succeed(
110110 McpExtensionService,
111− withStorageCapture(executor.mcp),
111+ withCapture(executor.mcp),
112112 )
113113 ```
114114
115−- `StorageCaptured<T>` — the type-level mirror of
116− `withStorageCapture`. Service tags declare this post-wrap shape so
115+- `Captured<T>` — the type-level mirror of
116+ `withCapture`. Service tags declare this post-wrap shape so
117117 handlers see `InternalError` in the method's error union (matching
118118 the group's `.addError(InternalError)`). Example:
119119
120120 ```ts
121121 class McpExtensionService extends Context.Tag("McpExtensionService")<
122122 McpExtensionService,
123− StorageCaptured<McpPluginExtension>
123+ Captured<McpPluginExtension>
124124 >() {}
125125 ```
126126
@@ -170,18 +170,18 @@ its annotated status. · 43 unmodified lines
170170 ### Hosts
171171
172172 - **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
173− `ErrorCaptureLive`, a Sentry-backed implementation. Wired into the
174− protected API layer composition (`protected-layers.ts`) where it's
175− available both to `observabilityMiddleware` and to the method
176− wrappers produced by `withStorageCapture`. `protected.ts` wraps
177− per-request: `Layer.succeed(McpExtensionService, withStorageCapture(executor.mcp))`.
178− (Distinct from `apps/cloud/src/services/telemetry.ts`, which is the
179− OTEL→Axiom span bridge — "telemetry" in the tracing sense.)
180−- **CLI** (`apps/local/src/server/main.ts`) — also wraps via
181− `withStorageCapture` at Layer composition. No `ErrorCaptureLive`
182− provided, so trace ids come back empty; storage failures still
183− surface as `InternalError(traceId="")`. Drop-in an `ErrorCapture`
184− layer when we want a console or file sink.
173+ `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
174+ 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.)
181+- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a
182+ console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`)
183+ that prints the squashed cause + pretty cause to stderr and returns a
184+ short correlation id.
185185 - **Tests / Promise SDK / examples** — non-HTTP consumers see raw
186186 `StorageError` / `StorageFailure` in the SDK's typed channel and
187187 can match on it directly.
@@ -195,7 +195,7 @@ its annotated status. · 7 unmodified lines
195195 emitted a 500 anyway. Use the shared `InternalError`.
196196 - `sanitize*` helpers in handler files that `catchAllCause` + map to a
197197 generic 500 — same swallowing problem in disguise. Prefer
198− `withStorageCapture` at Layer composition.
198+ `withCapture` at Layer composition.
199199 - SDK code importing `Sentry.captureException` or referencing
200200 `InternalError` / `ErrorCapture` — translation lives strictly in
201201 `@executor/api`. If the SDK imports it, the layering is wrong.
packages/plugins/google-discovery/src/api/group.ts+38−26
@@ -1,7 +1,13 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
6+import {
7+ GoogleDiscoveryOAuthError,
8+ GoogleDiscoveryParseError,
9+ GoogleDiscoverySourceError,
10+} from "../sdk/errors";
511 import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
612
713 export { HttpApiSchema };
@@ -109,55 +115,61 @@ export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryA · 101 unmodified lines
109115 HttpApiSchema.annotations({ status: 400 }),
110116 ) {}
111117
112−export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()(
113− "GoogleDiscoveryInternalError",
114− {
115− message: Schema.String,
116− },
117− HttpApiSchema.annotations({ status: 500 }),
118−) {}
118+// ---------------------------------------------------------------------------
119+// Group
120+//
121+// Domain errors + the shared opaque 500 (`InternalError`) are declared
122+// once at the group level via `.addError(...)` — every endpoint
123+// inherits them. The domain error carries its HTTP status via
124+// `HttpApiSchema.annotations`; `InternalError` is the public 5xx
125+// surface, translated from `StorageError` at the HTTP edge by
126+// `withCapture`. No per-endpoint `.addError(...)`, no per-handler
127+// InternalError — handlers just `return yield* ext.foo(...)`.
128+// ---------------------------------------------------------------------------
119129
120130 export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
121131 .add(
122132 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
123133 .setPayload(ProbePayload)
124− .addSuccess(ProbeResponse)
125− .addError(GoogleDiscoveryApiError)
126− .addError(GoogleDiscoveryInternalError),
134+ .addSuccess(ProbeResponse),
127135 )
128136 .add(
129137 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
130138 .setPayload(AddSourcePayload)
131− .addSuccess(AddSourceResponse)
132− .addError(GoogleDiscoveryApiError)
133− .addError(GoogleDiscoveryInternalError),
139+ .addSuccess(AddSourceResponse),
134140 )
135141 .add(
136142 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
137143 .setPayload(StartOAuthPayload)
138− .addSuccess(StartOAuthResponse)
139− .addError(GoogleDiscoveryApiError)
140− .addError(GoogleDiscoveryInternalError),
144+ .addSuccess(StartOAuthResponse),
141145 )
142146 .add(
143147 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
144148 .setPayload(CompleteOAuthPayload)
145− .addSuccess(CompleteOAuthResponse)
146− .addError(GoogleDiscoveryApiError)
147− .addError(GoogleDiscoveryInternalError),
149+ .addSuccess(CompleteOAuthResponse),
148150 )
149151 .add(
150152 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
151153 .setUrlParams(OAuthCallbackParams)
152− .addSuccess(HtmlResponse)
153− .addError(GoogleDiscoveryApiError)
154− .addError(GoogleDiscoveryInternalError),
154+ .addSuccess(HtmlResponse),
155155 )
156156 .add(
157157 HttpApiEndpoint.get(
158158 "getSource",
159159 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
160− .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))
161− .addError(GoogleDiscoveryApiError)
162− .addError(GoogleDiscoveryInternalError),
163− ) {}
160+ .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)),
161+ )
162+ // Errors declared once at the group level — every endpoint inherits.
163+ // `InternalError` is the shared opaque 500 translated at the HTTP edge
164+ // by `withCapture`. The others are 4xx domain errors carrying their
165+ // status via `HttpApiSchema.annotations`; handlers return them through
166+ // the typed channel and HttpApi encodes them directly. We only list
167+ // errors a Google Discovery *group* endpoint can surface —
168+ // `GoogleDiscoveryInvocationError` is thrown inside `invokeTool` which
169+ // is reached via the core `tools.invoke` endpoint, not any Google
170+ // Discovery-group endpoint, so it doesn't belong here.
171+ .addError(InternalError)
172+ .addError(GoogleDiscoveryApiError)
173+ .addError(GoogleDiscoveryOAuthError)
174+ .addError(GoogleDiscoveryParseError)
175+ .addError(GoogleDiscoverySourceError) {}
packages/plugins/google-discovery/src/api/handlers.test.ts+68−42
@@ -1,8 +1,21 @@
1+// ---------------------------------------------------------------------------
2+// Handler-level integration test for the Google Discovery group.
3+//
4+// Verifies the layer wiring stays coherent end-to-end: the handlers
5+// pull the wrapped extension from the service, and any un-caught cause
6+// lands in the observability middleware — producing a 500 whose body is
7+// the opaque `InternalError` schema (no internal leakage).
8+// ---------------------------------------------------------------------------
9+
110 import { HttpApiBuilder, HttpServer } from "@effect/platform";
2−import { describe, expect, it } from "vitest";
11+import { describe, expect, it } from "@effect/vitest";
312 import { Effect, Layer } from "effect";
413
5−import { addGroup } from "@executor/api";
14+import {
15+ addGroup,
16+ observabilityMiddleware,
17+ withCapture,
18+} from "@executor/api";
619 import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
720 import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
821 import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
@@ -10,57 +23,70 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line
1023
1124 const unused = Effect.dieMessage("unused");
1225
13−const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({
26+const failingExtension: GoogleDiscoveryPluginExtension = {
1427 probeDiscovery: () => Effect.die(new Error("Not implemented")),
1528 addSource: () => unused,
1629 removeSource: () => unused,
1730 startOAuth: () => unused,
1831 completeOAuth: () => Effect.die(new Error("Not implemented")),
1932 getSource: () => Effect.succeed(null),
20−});
33+};
2134
2235 const Api = addGroup(GoogleDiscoveryGroup);
2336
24−const fakeExecutor = {} as any;
25−const fakeExecutionEngine = {} as any;
26−
27−const createHandler = () =>
28− HttpApiBuilder.toWebHandler(
29− HttpApiBuilder.api(Api).pipe(
30− Layer.provide(CoreHandlers),
31− Layer.provide(GoogleDiscoveryHandlers),
32− Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),
33− Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),
34− Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())),
35− Layer.provideMerge(HttpServer.layerContext),
36− Layer.provideMerge(HttpApiBuilder.Router.Live),
37− Layer.provideMerge(HttpApiBuilder.Middleware.layer),
37+// `acquireRelease` keeps disposal inside the Effect scope — no
38+// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
39+// scope for us.
40+const WebHandler = Effect.acquireRelease(
41+ Effect.sync(() =>
42+ HttpApiBuilder.toWebHandler(
43+ HttpApiBuilder.api(Api).pipe(
44+ Layer.provide(CoreHandlers),
45+ Layer.provide(GoogleDiscoveryHandlers),
46+ Layer.provide(observabilityMiddleware(Api)),
47+ Layer.provide(Layer.succeed(ExecutorService, {} as never)),
48+ Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
49+ Layer.provide(
50+ Layer.succeed(
51+ GoogleDiscoveryExtensionService,
52+ withCapture(failingExtension),
53+ ),
54+ ),
55+ Layer.provideMerge(HttpServer.layerContext),
56+ Layer.provideMerge(HttpApiBuilder.Router.Live),
57+ Layer.provideMerge(HttpApiBuilder.Middleware.layer),
58+ ),
3859 ),
39− );
60+ ),
61+ (web) => Effect.promise(() => web.dispose()),
62+);
4063
4164 describe("GoogleDiscoveryHandlers", () => {
42− it("sanitizes unknown endpoint failures", async () => {
43− const web = createHandler();
44− try {
45− const response = await web.handler(
46− new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
47− method: "POST",
48− headers: { "content-type": "application/json" },
49− body: JSON.stringify({
50− discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
51− }),
52− }),
53− );
65+ it.scoped(
66+ "defect-returning methods produce an opaque InternalError, no leakage",
67+ () =>
68+ Effect.gen(function* () {
69+ const web = yield* WebHandler;
70+ const response = yield* Effect.promise(() =>
71+ web.handler(
72+ new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
73+ method: "POST",
74+ headers: { "content-type": "application/json" },
75+ body: JSON.stringify({
76+ discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
77+ }),
78+ }),
79+ ),
80+ );
5481
55− expect(response.status).toBe(500);
56− const body = await response.json();
57− expect(body).toEqual({
58− _tag: "GoogleDiscoveryInternalError",
59− message: "Internal server error",
60− });
61− expect(JSON.stringify(body)).not.toContain("Not implemented");
62− } finally {
63− await web.dispose();
64− }
65− });
82+ expect(response.status).toBe(500);
83+ const body = (yield* Effect.promise(() => response.json())) as {
84+ _tag?: string;
85+ traceId?: string;
86+ };
87+ expect(body._tag).toBe("InternalError");
88+ expect(typeof body.traceId).toBe("string");
89+ expect(JSON.stringify(body)).not.toContain("Not implemented");
90+ }),
91+ );
6692 });
packages/plugins/google-discovery/src/api/handlers.ts+43−50
@@ -1,65 +1,58 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup } from "@executor/api";
6+import { addGroup, type Captured } from "@executor/api";
77 import type {
88 GoogleDiscoveryAddSourceInput,
99 GoogleDiscoveryOAuthAuthResult,
1010 GoogleDiscoveryPluginExtension,
1111 } from "../sdk/plugin";
12−import {
13− GoogleDiscoveryInvocationError,
14− GoogleDiscoveryOAuthError,
15− GoogleDiscoveryParseError,
16− GoogleDiscoverySourceError,
17−} from "../sdk/errors";
18−import {
19− GoogleDiscoveryApiError,
20− GoogleDiscoveryGroup,
21− GoogleDiscoveryInternalError,
22−} from "./group";
12+import { GoogleDiscoveryOAuthError } from "../sdk/errors";
13+import { GoogleDiscoveryGroup } from "./group";
14+
15+// ---------------------------------------------------------------------------
16+// Service tag
17+//
18+// Holds the `Captured` shape — every method's `StorageFailure` channel
19+// has been swapped for `InternalError({ traceId })`. The host app
20+// provides an already-wrapped extension via
21+// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`.
22+// Handlers see `InternalError` in the error union, which matches
23+// `.addError(InternalError)` on the group — no per-handler translation.
24+// ---------------------------------------------------------------------------
2325
2426 export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
2527 GoogleDiscoveryExtensionService,
26− GoogleDiscoveryPluginExtension
28+ Captured<GoogleDiscoveryPluginExtension>
2729 >() {}
2830
31+// ---------------------------------------------------------------------------
32+// Composed API
33+// ---------------------------------------------------------------------------
34+
2935 const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
3036
3137 const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
3238
33−const toPopupErrorMessage = (error: unknown): string => {
34− if (error instanceof GoogleDiscoveryOAuthError) {
35− return error.message;
36− }
37− return "Authentication failed";
38−};
39−
40−const toGoogleDiscoveryApiError = (
41− error: unknown,
42−): GoogleDiscoveryApiError | GoogleDiscoveryInternalError => {
43− if (error instanceof GoogleDiscoveryApiError || error instanceof GoogleDiscoveryInternalError) {
44− return error;
45− }
46− if (
47− error instanceof GoogleDiscoveryParseError ||
48− error instanceof GoogleDiscoverySourceError ||
49− error instanceof GoogleDiscoveryOAuthError ||
50− error instanceof GoogleDiscoveryInvocationError
51− ) {
52− return new GoogleDiscoveryApiError({ message: error.message });
53− }
54− return new GoogleDiscoveryInternalError({ message: "Internal server error" });
55−};
39+const toPopupErrorMessage = (error: unknown): string =>
40+ error instanceof GoogleDiscoveryOAuthError ? error.message : "Authentication failed";
5641
57−const sanitizeGoogleDiscoveryFailure = <A, R>(
58− effect: Effect.Effect<A, unknown, R>,
59−): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> =>
60− Effect.catchAllCause(effect, (cause) =>
61− Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))),
62− );
42+// ---------------------------------------------------------------------------
43+// Handlers
44+//
45+// Each handler is exactly: yield the extension service, call the method,
46+// return. Plugin SDK errors flow through the typed channel and are
47+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
48+// `StorageFailure` has already been translated to `InternalError` by
49+// `withCapture` on the service instance; defects bubble up and are
50+// captured + downgraded to `InternalError(traceId)` by the API-level
51+// observability middleware.
52+//
53+// No `sanitize*`, no `liftDomainErrors`, no per-handler error mapping.
54+// If you find yourself adding error-handling here you're in the wrong layer.
55+// ---------------------------------------------------------------------------
6356
6457 export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
6558 ExecutorApiWithGoogleDiscovery,
@@ -70,13 +63,13 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 4 unmodified lines
7063 Effect.gen(function* () {
7164 const ext = yield* GoogleDiscoveryExtensionService;
7265 return yield* ext.probeDiscovery(payload.discoveryUrl);
73− }).pipe(sanitizeGoogleDiscoveryFailure),
66+ }),
7467 )
7568 .handle("addSource", ({ payload }) =>
7669 Effect.gen(function* () {
7770 const ext = yield* GoogleDiscoveryExtensionService;
7871 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
79− }).pipe(sanitizeGoogleDiscoveryFailure),
72+ }),
8073 )
8174 .handle("startOAuth", ({ payload }) =>
8275 Effect.gen(function* () {
@@ -89,7 +82,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 6 unmodified lines
8982 redirectUrl: payload.redirectUrl,
9083 scopes: payload.scopes,
9184 });
92− }).pipe(sanitizeGoogleDiscoveryFailure),
85+ }),
9386 )
9487 .handle("completeOAuth", ({ payload }) =>
9588 Effect.gen(function* () {
@@ -99,20 +92,20 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
9992 code: payload.code,
10093 error: payload.error,
10194 });
102− }).pipe(sanitizeGoogleDiscoveryFailure),
95+ }),
10396 )
10497 .handle("getSource", ({ path }) =>
10598 Effect.gen(function* () {
10699 const ext = yield* GoogleDiscoveryExtensionService;
107100 return yield* ext.getSource(path.namespace);
108− }).pipe(sanitizeGoogleDiscoveryFailure),
101+ }),
109102 )
110103 .handle("oauthCallback", ({ urlParams }) =>
111104 Effect.gen(function* () {
112105 const ext = yield* GoogleDiscoveryExtensionService;
113106 const html = yield* runOAuthCallback<
114107 GoogleDiscoveryOAuthAuthResult,
115− GoogleDiscoveryOAuthError,
108+ unknown,
116109 never
117110 >({
118111 complete: ({ state, code, error }) =>
@@ -126,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 7 unmodified lines
126119 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
127120 });
128121 return yield* HttpServerResponse.html(html);
129− }).pipe(sanitizeGoogleDiscoveryFailure),
122+ }),
130123 ),
131124 );
packages/plugins/google-discovery/src/sdk/binding-store.ts+22−12
@@ -14,7 +14,11 @@ · 13 unmodified lines
1414
1515 import { Effect, Schema } from "effect";
1616
17−import { defineSchema, type StorageDeps } from "@executor/sdk";
17+import {
18+ defineSchema,
19+ type StorageDeps,
20+ type StorageFailure,
21+} from "@executor/sdk";
1822
1923 import {
2024 GoogleDiscoveryMethodBinding,
@@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines
101105 // Store interface
102106 // ---------------------------------------------------------------------------
103107
108+// Every method routes through the typed adapter (`ctx.storage.adapter`)
109+// so the typed error channel is `StorageFailure`. Schema-decode failures
110+// inside `Effect.gen` land as defects, not typed errors, and are caught
111+// by the HTTP edge's observability middleware.
104112 export interface GoogleDiscoveryStore {
105113 readonly getBinding: (toolId: string) => Effect.Effect<
106114 { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null,
107− Error
115+ StorageFailure
108116 >;
109117 readonly putBinding: (
110118 toolId: string,
111119 sourceId: string,
112120 binding: GoogleDiscoveryMethodBinding,
113− ) => Effect.Effect<void, Error>;
121+ ) => Effect.Effect<void, StorageFailure>;
114122 readonly removeBindingsBySource: (
115123 sourceId: string,
116− ) => Effect.Effect<readonly string[], Error>;
124+ ) => Effect.Effect<readonly string[], StorageFailure>;
117125 readonly getBindingsForSource: (
118126 sourceId: string,
119127 ) => Effect.Effect<
120128 ReadonlyMap<string, GoogleDiscoveryMethodBinding>,
121− Error
129+ StorageFailure
122130 >;
123131
124− readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect<void, Error>;
125− readonly removeSource: (sourceId: string) => Effect.Effect<void, Error>;
132+ readonly putSource: (
133+ source: GoogleDiscoveryStoredSource,
134+ ) => Effect.Effect<void, StorageFailure>;
135+ readonly removeSource: (sourceId: string) => Effect.Effect<void, StorageFailure>;
126136 readonly getSource: (
127137 sourceId: string,
128− ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;
138+ ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
129139 readonly getSourceConfig: (
130140 sourceId: string,
131− ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error>;
141+ ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>;
132142
133143 readonly putOAuthSession: (
134144 sessionId: string,
135145 session: GoogleDiscoveryOAuthSession,
136− ) => Effect.Effect<void, Error>;
146+ ) => Effect.Effect<void, StorageFailure>;
137147 readonly getOAuthSession: (
138148 sessionId: string,
139− ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error>;
140− readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;
149+ ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, StorageFailure>;
150+ readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
141151 }
142152
143153 // ---------------------------------------------------------------------------
packages/plugins/google-discovery/src/sdk/document.ts+6−2
@@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines
130130 });
131131 type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
132132
133−const toParseError = (message: string, cause: unknown) =>
134− new GoogleDiscoveryParseError({ message, cause });
133+// The Schema.TaggedError version of GoogleDiscoveryParseError no longer
134+// carries a `cause` field — it would leak raw decoder internals over the
135+// wire. The decoder failure still shows up on the Effect cause chain for
136+// server-side logging; the client only sees the user-facing `message`.
137+const toParseError = (message: string, _cause: unknown) =>
138+ new GoogleDiscoveryParseError({ message });
135139
136140 const decodeUnknownWith =
137141 <A>(
packages/plugins/google-discovery/src/sdk/errors.ts+21−4
@@ -1,10 +1,25 @@
1+// Google Discovery plugin tagged errors. The three errors that cross
2+// the HTTP edge carry an `HttpApiSchema` annotation so they can be
3+// `.addError(...)` directly on the API group — handlers return them
4+// and HttpApi encodes each as a 4xx response with a typed body, no
5+// per-handler sanitisation step.
6+//
7+// `GoogleDiscoveryInvocationError` stays a `Data.TaggedError` because
8+// it only surfaces through `invokeTool`, which runs under the core
9+// `tools.invoke` endpoint — not any endpoint on the Google Discovery
10+// group — so it doesn't need an HTTP annotation.
11+
112 import { Data, Schema } from "effect";
13+import { HttpApiSchema } from "@effect/platform";
214 import type { Option } from "effect";
315
4−export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{
5− readonly message: string;
6− readonly cause?: unknown;
7−}> {}
16+export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
17+ "GoogleDiscoveryParseError",
18+ {
19+ message: Schema.String,
20+ },
21+ HttpApiSchema.annotations({ status: 400 }),
22+) {}
823
924 export class GoogleDiscoveryInvocationError extends Data.TaggedError(
1025 "GoogleDiscoveryInvocationError",
@@ -19,6 +34,7 @@ export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscover · 8 unmodified lines
1934 {
2035 message: Schema.String,
2136 },
37+ HttpApiSchema.annotations({ status: 400 }),
2238 ) {}
2339
2440 export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(
@@ -26,4 +42,5 @@ export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscove · 1 unmodified line
2642 {
2743 message: Schema.String,
2844 },
45+ HttpApiSchema.annotations({ status: 400 }),
2946 ) {}
packages/plugins/google-discovery/src/sdk/plugin.ts+34−16
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 SetSecretInput,
1010 SourceDetectionResult,
1111 type PluginCtx,
12+ type StorageFailure,
1213 type ToolAnnotations,
1314 } from "@executor/sdk";
1415
@@ -99,6 +100,23 @@ export interface GoogleDiscoveryOAuthAuthResult { · 84 unmodified lines
99100 readonly scopes: readonly string[];
100101 }
101102
103+/**
104+ * Errors any Google Discovery extension method may surface. The first
105+ * three are plugin-domain tagged errors that flow directly to clients
106+ * (4xx, each carrying its own `HttpApiSchema` status once wrapped at the
107+ * API edge). `StorageFailure` covers raw backend failures (`StorageError`)
108+ * plus `UniqueViolationError`; the HTTP edge (`@executor/api`'s
109+ * `withCapture`) translates `StorageError` to the opaque
110+ * `InternalError({ traceId })` at Layer composition. `UniqueViolationError`
111+ * passes through — plugins can `Effect.catchTag` it if they want a
112+ * friendlier user-facing error.
113+ */
114+export type GoogleDiscoveryExtensionFailure =
115+ | GoogleDiscoveryParseError
116+ | GoogleDiscoverySourceError
117+ | GoogleDiscoveryOAuthError
118+ | StorageFailure;
119+
102120 export interface GoogleDiscoveryPluginExtension {
103121 readonly probeDiscovery: (
104122 discoveryUrl: string,
@@ -110,21 +128,29 @@ export interface GoogleDiscoveryPluginExtension { · 5 unmodified lines
110128 input: GoogleDiscoveryAddSourceInput,
111129 ) => Effect.Effect<
112130 { readonly toolCount: number; readonly namespace: string },
113− GoogleDiscoveryParseError | GoogleDiscoverySourceError | Error
131+ GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
114132 >;
115− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
133+ readonly removeSource: (
134+ namespace: string,
135+ ) => Effect.Effect<void, StorageFailure>;
116136 readonly startOAuth: (
117137 input: GoogleDiscoveryOAuthStartInput,
118138 ) => Effect.Effect<
119139 GoogleDiscoveryOAuthStartResponse,
120− GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | Error
140+ | GoogleDiscoveryParseError
141+ | GoogleDiscoverySourceError
142+ | GoogleDiscoveryOAuthError
143+ | StorageFailure
121144 >;
122145 readonly completeOAuth: (
123146 input: GoogleDiscoveryOAuthCompleteInput,
124− ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>;
147+ ) => Effect.Effect<
148+ GoogleDiscoveryOAuthAuthResult,
149+ GoogleDiscoveryOAuthError | StorageFailure
150+ >;
125151 readonly getSource: (
126152 namespace: string,
127− ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;
153+ ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
128154 }
129155
130156 // ---------------------------------------------------------------------------
@@ -286,7 +312,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 155 unmodified lines
286312 schema: googleDiscoverySchema,
287313 storage: (deps) => makeGoogleDiscoveryStore(deps),
288314
289− extension: (ctx): GoogleDiscoveryPluginExtension => ({
315+ extension: (ctx) => ({
290316 probeDiscovery: (discoveryUrl) =>
291317 Effect.gen(function* () {
292318 const text = yield* fetchDiscoveryDocument(discoveryUrl);
@@ -465,18 +491,10 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 172 unmodified lines
465491 scope: stored.scope,
466492 scopes: [...session.scopes],
467493 };
468− }).pipe(
469− Effect.mapError((err) =>
470− err instanceof GoogleDiscoveryOAuthError
471− ? err
472− : new GoogleDiscoveryOAuthError({
473− message: err instanceof Error ? err.message : String(err),
474− }),
475− ),
476− ),
494+ }),
477495
478496 getSource: (namespace) => ctx.storage.getSource(namespace),
479− }),
497+ } satisfies GoogleDiscoveryPluginExtension),
480498
481499 invokeTool: ({ ctx, toolRow, args }) =>
482500 invokeGoogleDiscoveryTool({
packages/plugins/graphql/src/api/group.ts+23−17
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
67 import { HeaderValue } from "../sdk/types";
@@ -63,35 +64,40 @@ const ExtractionError = GraphqlExtractionError.annotations( · 56 unmodified lines
6364 HttpApiSchema.annotations({ status: 400 }),
6465 );
6566
66−export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()(
67− "GraphqlInternalError",
68− {
69− message: Schema.String,
70− },
71− HttpApiSchema.annotations({ status: 500 }),
72−) {}
73−
7467 // ---------------------------------------------------------------------------
7568 // Group
69+//
70+// Plugin SDK errors (GraphqlIntrospectionError etc.) are declared once at
71+// the group level via `.addError(...)` — every endpoint inherits them. The
72+// errors themselves carry their HTTP status via `HttpApiSchema.annotations`
73+// above, so handlers just `return yield* ext.foo(...)` and the schema
74+// encodes whatever it gets.
75+//
76+// 5xx is handled at the API level: `.addError(InternalError)` adds a
77+// single shared opaque-by-schema 500 surface translated from `StorageError`
78+// by `withCapture` at the HTTP edge. No per-handler wrapping, no
79+// per-plugin InternalError.
7680 // ---------------------------------------------------------------------------
7781
7882 export class GraphqlGroup extends HttpApiGroup.make("graphql")
7983 .add(
8084 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources`
8185 .setPayload(AddSourcePayload)
82− .addSuccess(AddSourceResponse)
83− .addError(IntrospectionError)
84− .addError(ExtractionError)
85− .addError(GraphqlInternalError),
86+ .addSuccess(AddSourceResponse),
8687 )
8788 .add(
8889 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
89− .addSuccess(Schema.NullOr(StoredSourceSchema))
90− .addError(GraphqlInternalError),
90+ .addSuccess(Schema.NullOr(StoredSourceSchema)),
9191 )
9292 .add(
9393 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
9494 .setPayload(UpdateSourcePayload)
95− .addSuccess(UpdateSourceResponse)
96− .addError(GraphqlInternalError),
97− ) {}
95+ .addSuccess(UpdateSourceResponse),
96+ )
97+ // Errors declared once at the group level — every endpoint inherits.
98+ // Plugin domain errors carry their own HttpApiSchema status (4xx);
99+ // `InternalError` is the shared opaque 500 translated at the HTTP
100+ // edge by `withCapture`.
101+ .addError(InternalError)
102+ .addError(IntrospectionError)
103+ .addError(ExtractionError) {}
packages/plugins/graphql/src/api/handlers.ts+26−51
@@ -1,61 +1,30 @@
11 import { HttpApiBuilder } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
4−import { addGroup } from "@executor/api";
5−import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors";
6−import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin";
7−import { GraphqlGroup, GraphqlInternalError } from "./group";
4+import { addGroup, type Captured } from "@executor/api";
5+import type {
6+ GraphqlPluginExtension,
7+ HeaderValue,
8+ GraphqlUpdateSourceInput,
9+} from "../sdk/plugin";
10+import { GraphqlGroup } from "./group";
811
912 // ---------------------------------------------------------------------------
10−// Service tag — the server provides the GraphQL extension
13+// Service tag
14+//
15+// Holds the `Captured` shape — every method's `StorageError` channel has
16+// been swapped for `InternalError({ traceId })`. The host app provides an
17+// already-wrapped extension via
18+// `Layer.succeed(GraphqlExtensionService, withCapture(executor.graphql))`.
19+// Handlers see `InternalError` in the error union, which matches
20+// `.addError(InternalError)` on the group — no per-handler translation.
1121 // ---------------------------------------------------------------------------
1222
1323 export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<
1424 GraphqlExtensionService,
15− GraphqlPluginExtension
25+ Captured<GraphqlPluginExtension>
1626 >() {}
1727
18−// ---------------------------------------------------------------------------
19−// Failure mapping
20−// ---------------------------------------------------------------------------
21−
22−type GraphqlAddSourceFailure =
23− | GraphqlIntrospectionError
24− | GraphqlExtractionError
25− | GraphqlInternalError;
26−
27−const toGraphqlAddSourceFailure = (error: unknown): GraphqlAddSourceFailure => {
28− if (
29− error instanceof GraphqlIntrospectionError ||
30− error instanceof GraphqlExtractionError ||
31− error instanceof GraphqlInternalError
32− ) {
33− return error;
34− }
35− const message = error instanceof Error ? error.message : String(error);
36− return new GraphqlInternalError({ message });
37−};
38−
39−const sanitizeAddSourceFailure = <A, R>(
40− effect: Effect.Effect<A, unknown, R>,
41−): Effect.Effect<A, GraphqlAddSourceFailure, R> =>
42− Effect.catchAllCause(effect, (cause) =>
43− Effect.fail(toGraphqlAddSourceFailure(Cause.squash(cause))),
44− );
45−
46−const toGraphqlInternalError = (error: unknown): GraphqlInternalError => {
47− if (error instanceof GraphqlInternalError) return error;
48− const message = error instanceof Error ? error.message : String(error);
49− return new GraphqlInternalError({ message });
50−};
51−
52−const sanitizeInternalFailure = <A, R>(
53− effect: Effect.Effect<A, unknown, R>,
54−): Effect.Effect<A, GraphqlInternalError, R> =>
55− Effect.catchAllCause(effect, (cause) =>
56− Effect.fail(toGraphqlInternalError(Cause.squash(cause))),
57− );
58−
5928 // ---------------------------------------------------------------------------
6029 // Composed API — core + graphql group
6130 // ---------------------------------------------------------------------------
@@ -64,6 +33,12 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines
6433
6534 // ---------------------------------------------------------------------------
6635 // Handlers
36+//
37+// Each handler is exactly: yield the extension service, call the method,
38+// return. Plugin SDK errors flow through the typed channel and are
39+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
40+// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
41+// by the API-level observability middleware.
6742 // ---------------------------------------------------------------------------
6843
6944 export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>
@@ -82,13 +57,13 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 12 unmodified lines
8257 toolCount: result.toolCount,
8358 namespace: payload.namespace ?? "graphql",
8459 };
85− }).pipe(sanitizeAddSourceFailure),
60+ }),
8661 )
8762 .handle("getSource", ({ path }) =>
8863 Effect.gen(function* () {
8964 const ext = yield* GraphqlExtensionService;
9065 return yield* ext.getSource(path.namespace);
91− }).pipe(sanitizeInternalFailure),
66+ }),
9267 )
9368 .handle("updateSource", ({ path, payload }) =>
9469 Effect.gen(function* () {
@@ -99,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 4 unmodified lines
9974 headers: payload.headers as Record<string, HeaderValue> | undefined,
10075 } as GraphqlUpdateSourceInput);
10176 return { updated: true };
102− }).pipe(sanitizeInternalFailure),
77+ }),
10378 ),
10479 );
packages/plugins/graphql/src/sdk/invoke.ts+2−2
@@ -14,8 +14,8 @@ import { · 13 unmodified lines
1414
1515 export const resolveHeaders = (
1616 headers: Record<string, HeaderValue>,
17− secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },
18−): Effect.Effect<Record<string, string>, Error> =>
17+ secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> },
18+): Effect.Effect<Record<string, string>> =>
1919 Effect.gen(function* () {
2020 const entries = Object.entries(headers);
2121 // Resolve secret-backed headers in parallel. Missing / failing
packages/plugins/graphql/src/sdk/plugin.ts+24−14
@@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines
55 import {
66 definePlugin,
77 SourceDetectionResult,
8+ type StorageFailure,
89 type ToolAnnotations,
910 type ToolRow,
1011 } from "@executor/sdk";
@@ -24,7 +25,7 @@ import { · 13 unmodified lines
2425 type IntrospectionTypeRef,
2526 } from "./introspect";
2627 import { extract } from "./extract";
27−import { GraphqlExtractionError } from "./errors";
28+import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors";
2829 import { invokeWithLayer, resolveHeaders } from "./invoke";
2930 import {
3031 graphqlSchema,
@@ -69,19 +70,35 @@ export interface GraphqlUpdateSourceInput { · 38 unmodified lines
6970 readonly headers?: Record<string, HeaderValue>;
7071 }
7172
73+/**
74+ * Errors any GraphQL extension method may surface. `GraphqlIntrospectionError`
75+ * and `GraphqlExtractionError` are plugin-domain tagged errors that flow
76+ * directly to clients (4xx, each carrying its own `HttpApiSchema` status).
77+ * `StorageFailure` covers raw backend failures (`StorageError` plus
78+ * `UniqueViolationError`); the HTTP edge (`@executor/api`'s `withCapture`)
79+ * translates `StorageError` to the opaque `InternalError({ traceId })` at
80+ * Layer composition.
81+ */
82+export type GraphqlExtensionFailure =
83+ | GraphqlIntrospectionError
84+ | GraphqlExtractionError
85+ | StorageFailure;
86+
7287 export interface GraphqlPluginExtension {
7388 /** Add a GraphQL endpoint and register its operations as tools */
7489 readonly addSource: (
7590 config: GraphqlSourceConfig,
76− ) => Effect.Effect<{ readonly toolCount: number }, Error>;
91+ ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>;
7792
7893 /** Remove all tools from a previously added GraphQL source by namespace */
79− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
94+ readonly removeSource: (
95+ namespace: string,
96+ ) => Effect.Effect<void, StorageFailure>;
8097
8198 /** Fetch the full stored source by namespace (or null if missing) */
8299 readonly getSource: (
83100 namespace: string,
84− ) => Effect.Effect<StoredGraphqlSource | null, Error>;
101+ ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
85102
86103 /** Update config (endpoint, headers) for an existing GraphQL source.
87104 * Does NOT re-introspect or re-register tools — just patches the
@@ -89,7 +106,7 @@ export interface GraphqlPluginExtension { · 1 unmodified line
89106 readonly updateSource: (
90107 namespace: string,
91108 input: GraphqlUpdateSourceInput,
92− ) => Effect.Effect<void, Error>;
109+ ) => Effect.Effect<void, StorageFailure>;
93110 }
94111
95112 // ---------------------------------------------------------------------------
@@ -282,7 +299,7 @@ export const graphqlPlugin = definePlugin( · 186 unmodified lines
282299 schema: graphqlSchema,
283300 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),
284301
285− extension: (ctx): GraphqlPluginExtension => {
302+ extension: (ctx) => {
286303 const resolveConfigHeaders = (
287304 headers: Record<string, HeaderValue> | undefined,
288305 ) =>
@@ -368,13 +385,6 @@ export const graphqlPlugin = definePlugin( · 79 unmodified lines
368385 return {
369386 addSource: (config) =>
370387 addSourceInternal(config).pipe(
371− Effect.mapError((err) =>
372− err instanceof Error
373− ? err
374− : new GraphqlExtractionError({
375− message: String(err),
376− }),
377− ),
378388 Effect.tap((result) =>
379389 configFile
380390 ? configFile.upsertSource(
@@ -406,7 +416,7 @@ export const graphqlPlugin = definePlugin( · 25 unmodified lines
406416 endpoint: input.endpoint,
407417 headers: input.headers,
408418 }),
409− };
419+ } satisfies GraphqlPluginExtension;
410420 },
411421
412422 staticSources: (self) => [
packages/plugins/graphql/src/sdk/store.ts+8−8
@@ -1,6 +1,6 @@
11 import { Effect } from "effect";
22
3−import { defineSchema, type StorageDeps } from "@executor/sdk";
3+import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk";
44
55 import { OperationBinding, type HeaderValue } from "./types";
66
@@ -89,28 +89,28 @@ export interface GraphqlStore { · 82 unmodified lines
8989 readonly upsertSource: (
9090 input: StoredGraphqlSource,
9191 operations: readonly StoredOperation[],
92− ) => Effect.Effect<void, Error>;
92+ ) => Effect.Effect<void, StorageFailure>;
9393
9494 readonly updateSourceMeta: (
9595 namespace: string,
9696 patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue> },
97− ) => Effect.Effect<void, Error>;
97+ ) => Effect.Effect<void, StorageFailure>;
9898
9999 readonly getSource: (
100100 namespace: string,
101− ) => Effect.Effect<StoredGraphqlSource | null, Error>;
101+ ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
102102
103− readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], Error>;
103+ readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], StorageFailure>;
104104
105105 readonly getOperationByToolId: (
106106 toolId: string,
107− ) => Effect.Effect<StoredOperation | null, Error>;
107+ ) => Effect.Effect<StoredOperation | null, StorageFailure>;
108108
109109 readonly listOperationsBySource: (
110110 sourceId: string,
111− ) => Effect.Effect<readonly StoredOperation[], Error>;
111+ ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
112112
113− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
113+ readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
114114 }
115115
116116 // ---------------------------------------------------------------------------
packages/plugins/onepassword/src/api/group.ts+19−14
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import { OnePasswordError } from "../sdk/errors";
67 import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
@@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines
3233
3334 const GetConfigResponse = Schema.NullOr(OnePasswordConfig);
3435
35−// ---------------------------------------------------------------------------
36−// Errors with HTTP status
37−// ---------------------------------------------------------------------------
38−
39−const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 }));
40−
4136 // ---------------------------------------------------------------------------
4237 // Group
38+//
39+// Plugin SDK errors (OnePasswordError) are declared once at the group level
40+// via `.addError(...)` — every endpoint inherits. The error carries its own
41+// 502 status via `HttpApiSchema.annotations` in errors.ts.
42+//
43+// `InternalError` is the shared opaque 500 schema translated at the HTTP
44+// edge by `withCapture` (see observability.ts). Storage failures on
45+// `ctx.storage`/`ctx.secrets` flow through as `StorageFailure` in the
46+// typed channel and are captured + downgraded to `InternalError({ traceId })`
47+// at Layer composition. No per-handler translation.
4348 // ---------------------------------------------------------------------------
4449
4550 export class OnePasswordGroup extends HttpApiGroup.make("onepassword")
@@ -51,8 +56,7 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 5 unmodified lines
5156 .add(
5257 HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config`
5358 .setPayload(ConfigurePayload)
54− .addSuccess(Schema.Void)
55− .addError(OpError),
59+ .addSuccess(Schema.Void),
5660 )
5761 .add(
5862 HttpApiEndpoint.del("removeConfig")`/scopes/${scopeIdParam}/onepassword/config`.addSuccess(
@@ -60,13 +64,14 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 1 unmodified line
6064 ),
6165 )
6266 .add(
63− HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`
64− .addSuccess(ConnectionStatus)
65− .addError(OpError),
67+ HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`.addSuccess(
68+ ConnectionStatus,
69+ ),
6670 )
6771 .add(
6872 HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults`
6973 .setUrlParams(ListVaultsParams)
70− .addSuccess(ListVaultsResponse)
71− .addError(OpError),
72− ) {}
74+ .addSuccess(ListVaultsResponse),
75+ )
76+ .addError(InternalError)
77+ .addError(OnePasswordError) {}
packages/plugins/onepassword/src/api/handlers.ts+21−8
@@ -1,17 +1,24 @@
11 import { HttpApiBuilder } from "@effect/platform";
22 import { Context, Effect } from "effect";
33
4−import { addGroup } from "@executor/api";
4+import { addGroup, type Captured } from "@executor/api";
55 import type { OnePasswordExtension } from "../sdk/plugin";
66 import { OnePasswordGroup } from "./group";
77
88 // ---------------------------------------------------------------------------
9−// Service tag — the server provides the 1Password extension
9+// Service tag
10+//
11+// Holds the `Captured` shape — every method's `StorageFailure` channel has
12+// been swapped for `InternalError({ traceId })`. The host provides an
13+// already-wrapped extension via
14+// `Layer.succeed(OnePasswordExtensionService, withCapture(executor).onepassword)`.
15+// Handlers see `InternalError` in the error union, which matches
16+// `.addError(InternalError)` on the group — no per-handler translation.
1017 // ---------------------------------------------------------------------------
1118
1219 export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<
1320 OnePasswordExtensionService,
14− OnePasswordExtension
21+ Captured<OnePasswordExtension>
1522 >() {}
1623
1724 // ---------------------------------------------------------------------------
@@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 4 unmodified lines
2229
2330 // ---------------------------------------------------------------------------
2431 // Handlers
32+//
33+// Each handler is exactly: yield the extension service, call the method,
34+// return. Plugin SDK errors flow through the typed channel and are
35+// schema-encoded (OnePasswordError -> 502) by HttpApi. Defects bubble up
36+// and are captured + downgraded to `InternalError(traceId)` by the
37+// observability middleware.
2538 // ---------------------------------------------------------------------------
2639
2740 export const OnePasswordHandlers = HttpApiBuilder.group(
@@ -33,25 +46,25 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 5 unmodified lines
3346 Effect.gen(function* () {
3447 const ext = yield* OnePasswordExtensionService;
3548 return yield* ext.getConfig();
36− }).pipe(Effect.orDie),
49+ }),
3750 )
3851 .handle("configure", ({ payload }) =>
3952 Effect.gen(function* () {
4053 const ext = yield* OnePasswordExtensionService;
4154 yield* ext.configure(payload);
42− }).pipe(Effect.orDie),
55+ }),
4356 )
4457 .handle("removeConfig", () =>
4558 Effect.gen(function* () {
4659 const ext = yield* OnePasswordExtensionService;
4760 yield* ext.removeConfig();
48− }).pipe(Effect.orDie),
61+ }),
4962 )
5063 .handle("status", () =>
5164 Effect.gen(function* () {
5265 const ext = yield* OnePasswordExtensionService;
5366 return yield* ext.status();
54− }).pipe(Effect.orDie),
67+ }),
5568 )
5669 .handle("listVaults", ({ urlParams }) =>
5770 Effect.gen(function* () {
@@ -62,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 4 unmodified lines
6275 : { kind: "service-account" as const, tokenSecretId: urlParams.account };
6376 const vaults = yield* ext.listVaults(auth);
6477 return { vaults: [...vaults] };
65− }).pipe(Effect.orDie),
78+ }),
6679 ),
6780 );
packages/plugins/onepassword/src/sdk/errors.ts+9−4
@@ -1,6 +1,11 @@
11 import { Schema } from "effect";
2+import { HttpApiSchema } from "@effect/platform";
23
3−export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", {
4− operation: Schema.String,
5− message: Schema.String,
6−}) {}
4+export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()(
5+ "OnePasswordError",
6+ {
7+ operation: Schema.String,
8+ message: Schema.String,
9+ },
10+ HttpApiSchema.annotations({ status: 502 }),
11+) {}
packages/plugins/onepassword/src/sdk/plugin.ts+108−103
@@ -2,9 +2,11 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
22
33 import {
44 definePlugin,
5+ StorageError,
56 type PluginCtx,
67 type ScopedBlobStore,
78 type SecretProvider,
9+ type StorageFailure,
810 } from "@executor/sdk";
911
1012 import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";
@@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines
2426 const DEFAULT_TIMEOUT_MS = 15_000;
2527 const CONFIG_KEY = "config";
2628
29+// ---------------------------------------------------------------------------
30+// Shared failure alias.
31+//
32+// Every extension method either touches storage (`ctx.storage` blobs or
33+// `ctx.secrets`) or reaches the 1Password backend. Storage I/O surfaces
34+// as `StorageFailure`; the HTTP edge (`withCapture`) translates
35+// `StorageError` to `InternalError({ traceId })`. Domain problems (not
36+// configured, service-account token missing, backend RPC failure) stay
37+// as `OnePasswordError` and encode to 502 via the schema annotation on
38+// the class.
39+// ---------------------------------------------------------------------------
40+
41+export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure;
42+
2743 // ---------------------------------------------------------------------------
2844 // Plugin extension — public API on executor.onepassword
2945 // ---------------------------------------------------------------------------
@@ -32,59 +48,69 @@ export interface OnePasswordExtension { · 2 unmodified lines
3248 /** Configure the 1Password connection */
3349 readonly configure: (
3450 config: OnePasswordConfig,
35− ) => Effect.Effect<void, OnePasswordError>;
51+ ) => Effect.Effect<void, StorageFailure>;
3652
3753 /** Get current configuration (if any) */
3854 readonly getConfig: () => Effect.Effect<
3955 OnePasswordConfig | null,
40− OnePasswordError
56+ OnePasswordExtensionFailure
4157 >;
4258
4359 /** Remove the 1Password configuration */
44− readonly removeConfig: () => Effect.Effect<void, OnePasswordError>;
60+ readonly removeConfig: () => Effect.Effect<void, StorageFailure>;
4561
4662 /** Check connection status */
47− readonly status: () => Effect.Effect<ConnectionStatus, OnePasswordError>;
63+ readonly status: () => Effect.Effect<
64+ ConnectionStatus,
65+ OnePasswordExtensionFailure
66+ >;
4867
4968 /** List accessible vaults (requires auth) */
5069 readonly listVaults: (
5170 auth: OnePasswordAuth,
52− ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordError>;
71+ ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordExtensionFailure>;
5372
5473 /** Resolve a secret directly by op:// URI */
55− readonly resolve: (uri: string) => Effect.Effect<string, OnePasswordError>;
74+ readonly resolve: (
75+ uri: string,
76+ ) => Effect.Effect<string, OnePasswordExtensionFailure>;
5677 }
5778
5879 // ---------------------------------------------------------------------------
59−// Typed config store — single blob, JSON encoded. Declared inline because
60−// there's only one key and one document; a schema table would be overkill.
80+// Typed config store — single blob, JSON encoded. Blob I/O failures surface
81+// as `StorageError` (HTTP edge translates to `InternalError`); decode
82+// failures stay `OnePasswordError` — the blob's contents are a plugin
83+// concern, not an infrastructure one.
6184 // ---------------------------------------------------------------------------
6285
6386 export interface OnePasswordStore {
6487 readonly getConfig: () => Effect.Effect<
6588 OnePasswordConfig | null,
66− OnePasswordError
89+ StorageError | OnePasswordError
6790 >;
6891 readonly saveConfig: (
6992 config: OnePasswordConfig,
70− ) => Effect.Effect<void, OnePasswordError>;
71− readonly deleteConfig: () => Effect.Effect<void, OnePasswordError>;
93+ ) => Effect.Effect<void, StorageError>;
94+ readonly deleteConfig: () => Effect.Effect<void, StorageError>;
7295 }
7396
7497 const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);
7598
99+const blobStorageError = (operation: string) =>
100+ (cause: unknown): StorageError =>
101+ new StorageError({
102+ message: `onepassword blob ${operation}: ${
103+ cause instanceof Error ? cause.message : String(cause)
104+ }`,
105+ cause,
106+ });
107+
76108 export const makeOnePasswordStore = (
77109 blobs: ScopedBlobStore,
78110 ): OnePasswordStore => ({
79111 getConfig: () =>
80112 blobs.get(CONFIG_KEY).pipe(
81− Effect.mapError(
82− (cause) =>
83− new OnePasswordError({
84− operation: "config load",
85− message: cause instanceof Error ? cause.message : String(cause),
86− }),
87− ),
113+ Effect.mapError(blobStorageError("read")),
88114 Effect.flatMap((raw) => {
89115 if (raw === null) return Effect.succeed(null);
90116 return Effect.try({
@@ -108,26 +134,10 @@ export const makeOnePasswordStore = ( · 17 unmodified lines
108134 name: config.name,
109135 }),
110136 )
111− .pipe(
112− Effect.mapError(
113− (cause) =>
114− new OnePasswordError({
115− operation: "config save",
116− message: cause instanceof Error ? cause.message : String(cause),
117− }),
118− ),
119− ),
137+ .pipe(Effect.mapError(blobStorageError("write"))),
120138
121139 deleteConfig: () =>
122− blobs.delete(CONFIG_KEY).pipe(
123− Effect.mapError(
124− (cause) =>
125− new OnePasswordError({
126− operation: "config delete",
127− message: cause instanceof Error ? cause.message : String(cause),
128− }),
129− ),
130− ),
140+ blobs.delete(CONFIG_KEY).pipe(Effect.mapError(blobStorageError("delete"))),
131141 });
132142
133143 // ---------------------------------------------------------------------------
@@ -137,7 +147,7 @@ export const makeOnePasswordStore = ( · 3 unmodified lines
137147 const resolveAuth = (
138148 auth: OnePasswordAuth,
139149 ctx: PluginCtx<OnePasswordStore>,
140−): Effect.Effect<ResolvedAuth, OnePasswordError> => {
150+): Effect.Effect<ResolvedAuth, OnePasswordError | StorageFailure> => {
141151 if (auth.kind === "desktop-app") {
142152 return Effect.succeed({
143153 kind: "desktop-app" as const,
@@ -145,13 +155,6 @@ const resolveAuth = ( · 1 unmodified line
145155 });
146156 }
147157 return ctx.secrets.get(auth.tokenSecretId).pipe(
148− Effect.mapError(
149− (cause) =>
150− new OnePasswordError({
151− operation: "auth resolution",
152− message: `Failed to resolve service account token secret "${auth.tokenSecretId}": ${cause instanceof Error ? cause.message : String(cause)}`,
153− }),
154− ),
155158 Effect.flatMap((token) => {
156159 if (token === null) {
157160 return Effect.fail(
@@ -174,7 +177,7 @@ const getServiceFromConfig = ( · 16 unmodified lines
174177 ctx: PluginCtx<OnePasswordStore>,
175178 timeoutMs: number,
176179 preferSdk: boolean | undefined,
177−): Effect.Effect<OnePasswordService, OnePasswordError> =>
180+): Effect.Effect<OnePasswordService, OnePasswordError | StorageFailure> =>
178181 resolveAuth(config.auth, ctx).pipe(
179182 Effect.flatMap((resolved) =>
180183 makeOnePasswordService(resolved, { timeoutMs, preferSdk }),
@@ -252,69 +255,71 @@ export const onepasswordPlugin = definePlugin( · 71 unmodified lines
252255 id: "onepassword" as const,
253256 storage: ({ blobs }) => makeOnePasswordStore(blobs),
254257
255− extension: (ctx): OnePasswordExtension => ({
256− configure: (config) => ctx.storage.saveConfig(config),
257−
258− getConfig: () => ctx.storage.getConfig(),
259−
260− removeConfig: () => ctx.storage.deleteConfig(),
261−
262− status: () =>
263− Effect.gen(function* () {
264− const config = yield* ctx.storage.getConfig();
265− if (!config) {
258+ extension: (ctx) => {
259+ return {
260+ configure: (config) => ctx.storage.saveConfig(config),
261+
262+ getConfig: () => ctx.storage.getConfig(),
263+
264+ removeConfig: () => ctx.storage.deleteConfig(),
265+
266+ status: () =>
267+ Effect.gen(function* () {
268+ const config = yield* ctx.storage.getConfig();
269+ if (!config) {
270+ return new ConnectionStatus({
271+ connected: false,
272+ error: "Not configured",
273+ });
274+ }
275+ const svc = yield* getServiceFromConfig(
276+ config,
277+ ctx,
278+ timeoutMs,
279+ preferSdk,
280+ );
281+ const vaults = yield* svc.listVaults();
282+ const vault = vaults.find((v) => v.id === config.vaultId);
266283 return new ConnectionStatus({
267− connected: false,
268− error: "Not configured",
284+ connected: true,
285+ vaultName: vault?.title,
269286 });
270− }
271− const svc = yield* getServiceFromConfig(
272− config,
273− ctx,
274− timeoutMs,
275− preferSdk,
276− );
277− const vaults = yield* svc.listVaults();
278− const vault = vaults.find((v) => v.id === config.vaultId);
279− return new ConnectionStatus({
280− connected: true,
281− vaultName: vault?.title,
282− });
283− }),
287+ }),
284288
285− listVaults: (auth) =>
286− Effect.gen(function* () {
287− const resolved = yield* resolveAuth(auth, ctx);
288− const svc = yield* makeOnePasswordService(resolved, {
289− timeoutMs,
290− preferSdk,
291− });
292− const vaults = yield* svc.listVaults();
293− return vaults
294− .map((v) => new Vault({ id: v.id, name: v.title }))
295− .sort((a, b) => a.name.localeCompare(b.name));
296− }),
289+ listVaults: (auth) =>
290+ Effect.gen(function* () {
291+ const resolved = yield* resolveAuth(auth, ctx);
292+ const svc = yield* makeOnePasswordService(resolved, {
293+ timeoutMs,
294+ preferSdk,
295+ });
296+ const vaults = yield* svc.listVaults();
297+ return vaults
298+ .map((v) => new Vault({ id: v.id, name: v.title }))
299+ .sort((a, b) => a.name.localeCompare(b.name));
300+ }),
297301
298− resolve: (uri) =>
299− Effect.gen(function* () {
300− const config = yield* ctx.storage.getConfig();
301− if (!config) {
302− return yield* Effect.fail(
303− new OnePasswordError({
304− operation: "resolve",
305− message: "1Password is not configured",
306− }),
302+ resolve: (uri) =>
303+ Effect.gen(function* () {
304+ const config = yield* ctx.storage.getConfig();
305+ if (!config) {
306+ return yield* Effect.fail(
307+ new OnePasswordError({
308+ operation: "resolve",
309+ message: "1Password is not configured",
310+ }),
311+ );
312+ }
313+ const svc = yield* getServiceFromConfig(
314+ config,
315+ ctx,
316+ timeoutMs,
317+ preferSdk,
307318 );
308− }
309− const svc = yield* getServiceFromConfig(
310− config,
311− ctx,
312− timeoutMs,
313− preferSdk,
314− );
315− return yield* svc.resolveSecret(uri);
316− }),
317− }),
319+ return yield* svc.resolveSecret(uri);
320+ }),
321+ } satisfies OnePasswordExtension;
322+ },
318323
319324 secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)],
320325 };
packages/plugins/openapi/src/api/group.ts+30−28
@@ -1,6 +1,7 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import { Schema } from "effect";
33 import { ScopeId } from "@executor/sdk";
4+import { InternalError } from "@executor/api";
45
56 import {
67 OpenApiParseError,
@@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines
99100 );
100101 const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));
101102
102−export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()(
103− "OpenApiInternalError",
104− {
105− message: Schema.String,
106− },
107− HttpApiSchema.annotations({ status: 500 }),
108−) {}
109−
110103 // ---------------------------------------------------------------------------
111104 // Group
105+//
106+// Plugin SDK errors (OpenApiParseError, OpenApiExtractionError,
107+// OpenApiOAuthError) are declared once at the group level via
108+// `.addError(...)` — every endpoint inherits them. The errors themselves
109+// carry their HTTP status via `HttpApiSchema.annotations` above, so
110+// handlers just `return yield* ext.foo(...)` and the schema encodes
111+// whatever comes out.
112+//
113+// 5xx is handled at the API level: `.addError(InternalError)` adds the
114+// shared opaque 500 surface. Defects are captured + downgraded to it by
115+// an HttpApiBuilder middleware (see apps/cloud/src/observability.ts).
116+// StorageError → InternalError translation happens at service wiring
117+// time via `withCapture(executor)`.
112118 // ---------------------------------------------------------------------------
113119
114120 export class OpenApiGroup extends HttpApiGroup.make("openapi")
115121 .add(
116122 HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
117123 .setPayload(PreviewSpecPayload)
118− .addSuccess(SpecPreview)
119− .addError(ParseError)
120− .addError(ExtractionError)
121− .addError(OpenApiInternalError),
124+ .addSuccess(SpecPreview),
122125 )
123126 .add(
124127 HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`
125128 .setPayload(AddSpecPayload)
126− .addSuccess(AddSpecResponse)
127− .addError(ParseError)
128− .addError(ExtractionError)
129− .addError(OpenApiInternalError),
129+ .addSuccess(AddSpecResponse),
130130 )
131131 .add(
132132 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
133− .addSuccess(Schema.NullOr(StoredSourceSchema))
134− .addError(OpenApiInternalError),
133+ .addSuccess(Schema.NullOr(StoredSourceSchema)),
135134 )
136135 .add(
137136 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
138137 .setPayload(UpdateSourcePayload)
139− .addSuccess(UpdateSourceResponse)
140− .addError(OpenApiInternalError),
138+ .addSuccess(UpdateSourceResponse),
141139 )
142140 .add(
143141 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start`
144142 .setPayload(StartOAuthPayload)
145− .addSuccess(StartOAuthResponse)
146− .addError(OAuthError),
143+ .addSuccess(StartOAuthResponse),
147144 )
148145 .add(
149146 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete`
150147 .setPayload(CompleteOAuthPayload)
151− .addSuccess(OAuth2Auth)
152− .addError(OAuthError),
148+ .addSuccess(OAuth2Auth),
153149 )
154150 .add(
155151 HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback")
@@ -158,7 +154,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 2 unmodified lines
158154 Schema.Unknown.annotations(
159155 HttpApiSchema.annotations({ contentType: "text/html" }),
160156 ),
161− )
162− .addError(OAuthError)
163− .addError(OpenApiInternalError),
164− ) {}
157+ ),
158+ )
159+ // Errors declared once at the group level — every endpoint inherits.
160+ // Plugin domain errors carry their own HttpApiSchema status (4xx);
161+ // `InternalError` is the shared opaque 500 translated at the HTTP
162+ // edge by `withCapture`.
163+ .addError(InternalError)
164+ .addError(ParseError)
165+ .addError(ExtractionError)
166+ .addError(OAuthError) {}
packages/plugins/openapi/src/api/handlers.ts+28−52
@@ -1,21 +1,17 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2−import { Cause, Context, Effect } from "effect";
2+import { Context, Effect } from "effect";
33
44 import { runOAuthCallback } from "@executor/plugin-oauth2/http";
55
6−import { addGroup } from "@executor/api";
7−import {
8− OpenApiExtractionError,
9− OpenApiOAuthError,
10− OpenApiParseError,
11−} from "../sdk/errors";
6+import { addGroup, InternalError, type Captured } from "@executor/api";
7+import { OpenApiOAuthError } from "../sdk/errors";
128 import type {
139 OpenApiPluginExtension,
1410 HeaderValue,
1511 OpenApiUpdateSourceInput,
1612 } from "../sdk/plugin";
1713 import { OAuth2Auth } from "../sdk/types";
18−import { OpenApiGroup, OpenApiInternalError } from "./group";
14+import { OpenApiGroup } from "./group";
1915
2016 const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result";
2117
@@ -25,50 +21,21 @@ const toPopupErrorMessage = (error: unknown): string => { · 3 unmodified lines
2521 };
2622
2723 // ---------------------------------------------------------------------------
28−// Service tag — the server provides the OpenAPI extension
24+// Service tag
25+//
26+// Holds the `Captured` shape — every method's `StorageFailure`
27+// channel has been swapped for `InternalError({ traceId })`. The cloud
28+// app provides an already-wrapped extension via
29+// `Layer.succeed(OpenApiExtensionService, withCapture(executor.openapi))`.
30+// Handlers see `InternalError` in the error union, which matches
31+// `.addError(InternalError)` on the group — no per-handler translation.
2932 // ---------------------------------------------------------------------------
3033
3134 export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<
3235 OpenApiExtensionService,
33− OpenApiPluginExtension
36+ Captured<OpenApiPluginExtension>
3437 >() {}
3538
36−// ---------------------------------------------------------------------------
37−// Failure mapping
38−// ---------------------------------------------------------------------------
39−
40−type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError;
41−
42−const toOpenApiSpecFailure = (error: unknown): OpenApiSpecFailure => {
43− if (
44− error instanceof OpenApiParseError ||
45− error instanceof OpenApiExtractionError ||
46− error instanceof OpenApiInternalError
47− ) {
48− return error;
49− }
50− const message = error instanceof Error ? error.message : String(error);
51− return new OpenApiInternalError({ message });
52−};
53−
54−const sanitizeSpecFailure = <A, R>(
55− effect: Effect.Effect<A, unknown, R>,
56−): Effect.Effect<A, OpenApiSpecFailure, R> =>
57− Effect.catchAllCause(effect, (cause) => Effect.fail(toOpenApiSpecFailure(Cause.squash(cause))));
58−
59−const toOpenApiInternalError = (error: unknown): OpenApiInternalError => {
60− if (error instanceof OpenApiInternalError) return error;
61− const message = error instanceof Error ? error.message : String(error);
62− return new OpenApiInternalError({ message });
63−};
64−
65−const sanitizeInternalFailure = <A, R>(
66− effect: Effect.Effect<A, unknown, R>,
67−): Effect.Effect<A, OpenApiInternalError, R> =>
68− Effect.catchAllCause(effect, (cause) =>
69− Effect.fail(toOpenApiInternalError(Cause.squash(cause))),
70− );
71−
7239 // ---------------------------------------------------------------------------
7340 // Composed API — core + openapi group
7441 // ---------------------------------------------------------------------------
@@ -77,6 +44,12 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines
7744
7845 // ---------------------------------------------------------------------------
7946 // Handlers
47+//
48+// Each handler is exactly: yield the extension service, call the method,
49+// return. Plugin SDK errors flow through the typed channel and are
50+// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
51+// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
52+// by the API-level observability middleware.
8053 // ---------------------------------------------------------------------------
8154
8255 export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>
@@ -85,7 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 2 unmodified lines
8558 Effect.gen(function* () {
8659 const ext = yield* OpenApiExtensionService;
8760 return yield* ext.previewSpec(payload.spec);
88− }).pipe(sanitizeSpecFailure),
61+ }),
8962 )
9063 .handle("addSpec", ({ payload }) =>
9164 Effect.gen(function* () {
@@ -102,13 +75,13 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 10 unmodified lines
10275 toolCount: result.toolCount,
10376 namespace: result.sourceId,
10477 };
105− }).pipe(sanitizeSpecFailure),
78+ }),
10679 )
10780 .handle("getSource", ({ path }) =>
10881 Effect.gen(function* () {
10982 const ext = yield* OpenApiExtensionService;
11083 return yield* ext.getSource(path.namespace);
111− }).pipe(sanitizeInternalFailure),
84+ }),
11285 )
11386 .handle("updateSource", ({ path, payload }) =>
11487 Effect.gen(function* () {
@@ -119,7 +92,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 4 unmodified lines
11992 headers: payload.headers as Record<string, HeaderValue> | undefined,
12093 } as OpenApiUpdateSourceInput);
12194 return { updated: true };
122− }).pipe(sanitizeInternalFailure),
95+ }),
12396 )
12497 .handle("startOAuth", ({ payload }) =>
12598 Effect.gen(function* () {
@@ -148,9 +121,12 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 22 unmodified lines
148121 }),
149122 )
150123 .handle("oauthCallback", ({ urlParams }) =>
124+ // OAuth popup is special: it always returns 200 HTML and renders the
125+ // failure into the popup body so the parent window's listener gets a
126+ // structured result.
151127 Effect.gen(function* () {
152128 const ext = yield* OpenApiExtensionService;
153− const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError, never>({
129+ const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({
154130 complete: ({ state, code, error }) =>
155131 ext.completeOAuth({
156132 state,
@@ -162,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 5 unmodified lines
162138 channelName: OPENAPI_OAUTH_CHANNEL,
163139 });
164140 return yield* HttpServerResponse.html(html);
165− }).pipe(sanitizeInternalFailure),
141+ }),
166142 ),
167143 );
packages/plugins/openapi/src/sdk/plugin.ts+36−8
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
1818 SetSecretInput,
1919 SourceDetectionResult,
2020 definePlugin,
21+ type StorageFailure,
2122 type ToolAnnotations,
2223 type ToolRow,
2324 } from "@executor/sdk";
@@ -28,7 +29,11 @@ import { · 4 unmodified lines
2829 type OpenApiSourceConfig,
2930 } from "@executor/config";
3031
31−import { OpenApiOAuthError } from "./errors";
32+import {
33+ OpenApiExtractionError,
34+ OpenApiOAuthError,
35+ OpenApiParseError,
36+} from "./errors";
3237 import { parse, resolveSpecText } from "./parse";
3338 import { extract } from "./extract";
3439 import { compileToolDefinitions, type ToolDefinition } from "./definitions";
@@ -105,17 +110,40 @@ export interface OpenApiCompleteOAuthInput { · 70 unmodified lines
105110 readonly error?: string;
106111 }
107112
113+/**
114+ * Errors any OpenAPI extension method may surface. The first three are
115+ * plugin-domain tagged errors that flow directly to clients (4xx, each
116+ * carrying its own `HttpApiSchema` status). `StorageFailure` covers
117+ * raw backend failures (`StorageError`) plus `UniqueViolationError`;
118+ * the HTTP edge (`@executor/api`'s `withCapture`) translates
119+ * `StorageError` to the opaque `InternalError({ traceId })` at Layer
120+ * composition. `UniqueViolationError` passes through — plugins can
121+ * `Effect.catchTag` it if they want a friendlier user-facing error.
122+ */
123+export type OpenApiExtensionFailure =
124+ | OpenApiParseError
125+ | OpenApiExtractionError
126+ | OpenApiOAuthError
127+ | StorageFailure;
128+
108129 export interface OpenApiPluginExtension {
109− readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;
130+ readonly previewSpec: (
131+ specText: string,
132+ ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>;
110133 readonly addSpec: (
111134 config: OpenApiSpecConfig,
112− ) => Effect.Effect<{ readonly sourceId: string; readonly toolCount: number }, Error>;
113− readonly removeSpec: (namespace: string) => Effect.Effect<void, Error>;
114− readonly getSource: (namespace: string) => Effect.Effect<StoredSource | null, Error>;
135+ ) => Effect.Effect<
136+ { readonly sourceId: string; readonly toolCount: number },
137+ OpenApiParseError | OpenApiExtractionError | StorageFailure
138+ >;
139+ readonly removeSpec: (namespace: string) => Effect.Effect<void, StorageFailure>;
140+ readonly getSource: (
141+ namespace: string,
142+ ) => Effect.Effect<StoredSource | null, StorageFailure>;
115143 readonly updateSource: (
116144 namespace: string,
117145 input: OpenApiUpdateSourceInput,
118− ) => Effect.Effect<void, Error>;
146+ ) => Effect.Effect<void, StorageFailure>;
119147 readonly startOAuth: (
120148 input: OpenApiStartOAuthInput,
121149 ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>;
@@ -222,7 +250,7 @@ export const openApiPlugin = definePlugin( · 100 unmodified lines
222250 schema: openapiSchema,
223251 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
224252
225− extension: (ctx): OpenApiPluginExtension => {
253+ extension: (ctx) => {
226254 // Wraps ctx.secrets.set for the oauth2 helper so createSecret
227255 // returns the freshly-minted id.
228256 const createOAuthSecret = (args: {
@@ -523,7 +551,7 @@ export const openApiPlugin = definePlugin( · 294 unmodified lines
523551 : new OpenApiOAuthError({ message: err.message }),
524552 ),
525553 ),
526− };
554+ } satisfies OpenApiPluginExtension;
527555 },
528556
529557 staticSources: (self) => [
packages/plugins/openapi/src/sdk/store.ts+19−11
@@ -1,6 +1,10 @@
11 import { Effect, Option, Schema } from "effect";
22
3−import { defineSchema, type StorageDeps } from "@executor/sdk";
3+import {
4+ defineSchema,
5+ type StorageDeps,
6+ type StorageFailure,
7+} from "@executor/sdk";
48
59 import {
610 HeaderValue,
@@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines
134138 // Store interface
135139 // ---------------------------------------------------------------------------
136140
141+// Every method routes through the typed adapter (`ctx.storage.adapter`)
142+// so the typed error channel is `StorageFailure`. Schema-decode failures
143+// inside `Effect.gen` land as defects, not typed errors, and are caught
144+// by the HTTP edge's observability middleware.
137145 export interface OpenapiStore {
138146 readonly upsertSource: (
139147 input: StoredSource,
140148 operations: readonly StoredOperation[],
141− ) => Effect.Effect<void, Error>;
149+ ) => Effect.Effect<void, StorageFailure>;
142150
143151 readonly updateSourceMeta: (
144152 namespace: string,
@@ -148,34 +156,34 @@ export interface OpenapiStore { · 3 unmodified lines
148156 readonly headers?: Record<string, HeaderValue>;
149157 readonly oauth2?: OAuth2Auth;
150158 },
151− ) => Effect.Effect<void, Error>;
159+ ) => Effect.Effect<void, StorageFailure>;
152160
153161 readonly getSource: (
154162 namespace: string,
155− ) => Effect.Effect<StoredSource | null, Error>;
163+ ) => Effect.Effect<StoredSource | null, StorageFailure>;
156164
157− readonly listSources: () => Effect.Effect<readonly StoredSource[], Error>;
165+ readonly listSources: () => Effect.Effect<readonly StoredSource[], StorageFailure>;
158166
159167 readonly getOperationByToolId: (
160168 toolId: string,
161− ) => Effect.Effect<StoredOperation | null, Error>;
169+ ) => Effect.Effect<StoredOperation | null, StorageFailure>;
162170
163171 readonly listOperationsBySource: (
164172 sourceId: string,
165− ) => Effect.Effect<readonly StoredOperation[], Error>;
173+ ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
166174
167− readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;
175+ readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
168176
169177 readonly putOAuthSession: (
170178 sessionId: string,
171179 session: OpenApiOAuthSession,
172− ) => Effect.Effect<void, Error>;
180+ ) => Effect.Effect<void, StorageFailure>;
173181
174182 readonly getOAuthSession: (
175183 sessionId: string,
176− ) => Effect.Effect<OpenApiOAuthSession | null, Error>;
184+ ) => Effect.Effect<OpenApiOAuthSession | null, StorageFailure>;
177185
178− readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;
186+ readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
179187 }
180188
181189 // ---------------------------------------------------------------------------

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t