History

Merge pull request #282 from RhysSullivan/rs/typed-error-edge-model

feat(sdk): typed errors propagate to one edge mapping (StorageError, InternalError, Telemetry)

Rhys Sullivan committed 6 months ago

Commit c28e7102 parents c64a159 4d95b1375 files · +2357 −1017Browse files

Unified · Split

75 files
  1. apps/cloud/src/api/protected-layers.ts +17 −2
  2. apps/cloud/src/api/protected.ts +3 −0
  3. apps/cloud/src/observability.ts +31 −0
  4. apps/cloud/src/services/executor.ts +4 −0
  5. apps/local/src/server/main.ts +13 −1
  6. apps/local/src/server/observability.ts +32 −0
  7. bun.lock +4 −0
  8. notes/error-handling.md +195 −0
  9. notes/promise-sdk-typed-errors.md +78 −0
  10. packages/core/api/package.json +6 −2
  11. packages/core/api/src/executions/api.ts +4 −1
  12. packages/core/api/src/handlers/executions.ts +5 −4
  13. packages/core/api/src/handlers/scope.ts +3 −2
  14. packages/core/api/src/handlers/secrets.ts +21 −22
  15. packages/core/api/src/handlers/sources.ts +16 −17
  16. packages/core/api/src/handlers/tools.ts +7 −6
  17. packages/core/api/src/index.ts +7 −0
  18. packages/core/api/src/observability.test.ts +87 −0
  19. packages/core/api/src/observability.ts +155 −0
  20. packages/core/api/src/scope/api.ts +5 −3
  21. packages/core/api/src/secrets/api.ts +4 −1
  22. packages/core/api/src/services.ts +4 −1
  23. packages/core/api/src/sources/api.ts +16 −5
  24. packages/core/api/src/tools/api.ts +4 −1
  25. packages/core/api/vitest.config.ts +7 −0
  26. packages/core/sdk/package.json +1 −0
  27. packages/core/sdk/src/blob.ts +21 −8
  28. packages/core/sdk/src/error-handling.test.ts +179 −0
  29. packages/core/sdk/src/executor.test.ts +6 −1
  30. packages/core/sdk/src/executor.ts +85 −47
  31. packages/core/sdk/src/index.ts +5 −0
  32. packages/core/sdk/src/plugin.ts +35 −16
  33. packages/core/sdk/src/promise-executor.ts +6 −0
  34. packages/core/sdk/src/secrets.ts +14 −6
  35. packages/core/storage-core/src/adapter.ts +28 −24
  36. packages/core/storage-core/src/errors.ts +42 −0
  37. packages/core/storage-core/src/factory.ts +25 −16
  38. packages/core/storage-core/src/index.ts +3 −0
  39. packages/core/storage-core/src/testing/memory.ts +2 −1
  40. packages/core/storage-core/src/typed.ts +19 −11
  41. packages/core/storage-drizzle/src/adapter.ts +162 −61
  42. packages/core/storage-file/src/blob-store.ts +7 −3
  43. packages/core/storage-postgres/src/blob-store.ts +7 −3
  44. packages/plugins/file-secrets/src/index.ts +57 −22
  45. packages/plugins/google-discovery/src/api/group.ts +38 −26
  46. packages/plugins/google-discovery/src/api/handlers.test.ts +61 −42
  47. packages/plugins/google-discovery/src/api/handlers.ts +48 −55
  48. packages/plugins/google-discovery/src/sdk/binding-store.ts +22 −12
  49. packages/plugins/google-discovery/src/sdk/document.ts +6 −2
  50. packages/plugins/google-discovery/src/sdk/errors.ts +21 −4
  51. packages/plugins/google-discovery/src/sdk/invoke.ts +37 −13
  52. packages/plugins/google-discovery/src/sdk/plugin.ts +34 −16
  53. packages/plugins/graphql/src/api/group.ts +23 −17
  54. packages/plugins/graphql/src/api/handlers.ts +28 −53
  55. packages/plugins/graphql/src/sdk/invoke.ts +5 −5
  56. packages/plugins/graphql/src/sdk/plugin.ts +24 −14
  57. packages/plugins/graphql/src/sdk/store.ts +8 −8
  58. packages/plugins/keychain/src/provider.ts +18 −4
  59. packages/plugins/mcp/src/api/group.ts +39 −48
  60. packages/plugins/mcp/src/api/handlers.test.ts +59 −40
  61. packages/plugins/mcp/src/api/handlers.ts +51 −64
  62. packages/plugins/mcp/src/sdk/binding-store.ts +24 −12
  63. packages/plugins/mcp/src/sdk/errors.ts +16 −3
  64. packages/plugins/mcp/src/sdk/invoke.ts +9 −26
  65. packages/plugins/mcp/src/sdk/plugin.ts +34 −12
  66. packages/plugins/onepassword/src/api/group.ts +19 −14
  67. packages/plugins/onepassword/src/api/handlers.ts +25 −12
  68. packages/plugins/onepassword/src/sdk/errors.ts +9 −4
  69. packages/plugins/onepassword/src/sdk/plugin.ts +108 −103
  70. packages/plugins/openapi/src/api/group.ts +30 −28
  71. packages/plugins/openapi/src/api/handlers.ts +36 −60
  72. packages/plugins/openapi/src/sdk/invoke.ts +29 −8
  73. packages/plugins/openapi/src/sdk/plugin.ts +36 −8
  74. packages/plugins/openapi/src/sdk/store.ts +19 −11
  75. packages/plugins/workos-vault/src/sdk/secret-store.ts +9 −6
apps/cloud/src/api/protected-layers.ts+17−2
@@ -6,7 +6,11 @@ · 5 unmodified lines
6import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform";6import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform";
7import { Layer } from "effect";7import { Layer } from "effect";
8 8
9import { CoreExecutorApi } from "@executor/api";9import {
10 CoreExecutorApi,
11 InternalError,
12 observabilityMiddleware,
13} from "@executor/api";
10import { CoreHandlers } from "@executor/api/server";14import { CoreHandlers } from "@executor/api/server";
11import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";15import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
12import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";16import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
@@ -18,12 +22,16 @@ import { UserStoreService } from "../auth/context"; · 5 unmodified lines
18import { WorkOSAuth } from "../auth/workos";22import { WorkOSAuth } from "../auth/workos";
19import { AutumnService } from "../services/autumn";23import { AutumnService } from "../services/autumn";
20import { DbService } from "../services/db";24import { DbService } from "../services/db";
25import { ErrorCaptureLive } from "../observability";
21 26
22export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)27export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
23 .add(McpGroup)28 .add(McpGroup)
24 .add(GraphqlGroup)29 .add(GraphqlGroup)
30 .addError(InternalError)
25 .middleware(OrgAuth);31 .middleware(OrgAuth);
26 32
33const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi);
34
27const DbLive = DbService.Live;35const DbLive = DbService.Live;
28const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));36const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive));
29 37
@@ -47,6 +55,13 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 17 unmodified lines
47 GraphqlHandlers,55 GraphqlHandlers,
48);56);
49 57
58// `ErrorCaptureLive` is provided above the handler + middleware layers
59// so the `withCapture` translation path (typed-channel `StorageError →
60// InternalError(traceId)`) AND the observability middleware's defect
61// catchall both see the same Sentry-backed implementation.
50export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(62export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
51 Layer.provide(Layer.merge(ProtectedCloudApiHandlers, OrgAuthLive)),63 Layer.provide(
64 Layer.mergeAll(ProtectedCloudApiHandlers, OrgAuthLive, ObservabilityLive),
65 ),
66 Layer.provide(ErrorCaptureLive),
52);67);
apps/cloud/src/api/protected.ts+3−0
@@ -34,6 +34,9 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines
34 Effect.gen(function* () {34 Effect.gen(function* () {
35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);35 const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName);
36 36
37 // Handlers wrap their own bodies with `capture(...)` — the edge
38 // translation lives per-handler, not at service construction. See
39 // notes/error-handling.md.
37 const requestServices = Layer.mergeAll(40 const requestServices = Layer.mergeAll(
38 Layer.succeed(ExecutorService, executor),41 Layer.succeed(ExecutorService, executor),
39 Layer.succeed(ExecutionEngineService, engine),42 Layer.succeed(ExecutionEngineService, engine),
apps/cloud/src/observability.ts+31−0
@@ -0,0 +1,31 @@
1// ---------------------------------------------------------------------------
2// Cloud-app implementation of the shared `ErrorCapture` service. This is the
3// only file in the cloud-app that imports `@sentry/cloudflare` for error
4// capture — handlers, plugin SDKs, and storage code all stay
5// Sentry-agnostic and request the `ErrorCapture` tag instead.
6//
7// `withObservability` (in @executor/api) wraps every handler effect; when
8// it sees an unmapped cause it asks `ErrorCapture.captureException` for a
9// trace id and fails with `InternalError({ traceId })`. The client gets
10// the opaque id, we get the full cause + stack in Sentry.
11// ---------------------------------------------------------------------------
12
13import * as Sentry from "@sentry/cloudflare";
14import { Cause, Effect, Layer } from "effect";
15
16import { ErrorCapture } from "@executor/api";
17
18export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
19 ErrorCapture,
20 ErrorCapture.of({
21 captureException: (cause) =>
22 Effect.sync(() => {
23 const squashed = Cause.squash(cause);
24 const eventId = Sentry.captureException(squashed, (scope) => {
25 scope.setExtra("cause", Cause.pretty(cause));
26 return scope;
27 });
28 return eventId ?? "";
29 }),
30 }),
31);
apps/cloud/src/services/executor.ts+4−0
@@ -78,5 +78,9 @@ export const createScopedExecutor = ( · 77 unmodified lines
78 createdAt: new Date(),78 createdAt: new Date(),
79 });79 });
80 80
81 // The executor surface returns raw `StorageFailure`; translation to
82 // the opaque `InternalError({ traceId })` happens at the HTTP edge
83 // via `withCapture` (see `api/protected-layers.ts`). That's
84 // where `ErrorCaptureLive` (Sentry) gets wired in.
81 return yield* createExecutor({ scope, adapter, blobs, plugins });85 return yield* createExecutor({ scope, adapter, blobs, plugins });
82 });86 });
apps/local/src/server/main.ts+13−1
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7} from "@effect/platform";7} from "@effect/platform";
8import { Context, Effect, Layer, ManagedRuntime } from "effect";8import { Context, Effect, Layer, ManagedRuntime } from "effect";
9 9
10import { addGroup } from "@executor/api";10import { addGroup, observabilityMiddleware } from "@executor/api";
11import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";11import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server";
12import { createExecutionEngine } from "@executor/execution";12import { createExecutionEngine } from "@executor/execution";
13import { makeQuickJsExecutor } from "@executor/runtime-quickjs";13import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
@@ -34,6 +34,7 @@ import { · 20 unmodified lines
34} from "@executor/plugin-graphql/api";34} from "@executor/plugin-graphql/api";
35import { getExecutor } from "./executor";35import { getExecutor } from "./executor";
36import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";36import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
37import { ErrorCaptureLive } from "./observability";
37 38
38// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
39// Local server API — core + all plugin groups40// Local server API — core + all plugin groups
@@ -45,6 +46,13 @@ const LocalApi = addGroup(OpenApiGroup) · 5 unmodified lines
45 .add(OnePasswordGroup)46 .add(OnePasswordGroup)
46 .add(GraphqlGroup);47 .add(GraphqlGroup);
47 48
49// `ErrorCaptureLive` logs causes to the console and returns a short
50// correlation id. Provided above the handler + middleware layers so
51// both the `withCapture` typed-channel translation AND the
52// `observabilityMiddleware` defect catchall see the same
53// implementation.
54const LocalObservability = observabilityMiddleware(LocalApi);
55
48const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe(56const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe(
49 Layer.provide(CoreHandlers),57 Layer.provide(CoreHandlers),
50 Layer.provide(58 Layer.provide(
@@ -56,6 +64,8 @@ const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( · 5 unmodified lines
56 GraphqlHandlers,64 GraphqlHandlers,
57 ),65 ),
58 ),66 ),
67 Layer.provide(LocalObservability),
68 Layer.provide(ErrorCaptureLive),
59);69);
60 70
61// ---------------------------------------------------------------------------71// ---------------------------------------------------------------------------
@@ -81,6 +91,8 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 19 unmodified lines
81 const executor = await getExecutor();91 const executor = await getExecutor();
82 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });92 const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
83 93
94 // Handlers wrap their own bodies with `capture(...)` — the edge
95 // translation lives per-handler, not at service construction.
84 const pluginExtensions = Layer.mergeAll(96 const pluginExtensions = Layer.mergeAll(
85 Layer.succeed(OpenApiExtensionService, executor.openapi),97 Layer.succeed(OpenApiExtensionService, executor.openapi),
86 Layer.succeed(McpExtensionService, executor.mcp),98 Layer.succeed(McpExtensionService, executor.mcp),
apps/local/src/server/observability.ts+32−0
@@ -0,0 +1,32 @@
1// ---------------------------------------------------------------------------
2// Local-app `ErrorCapture` — console implementation.
3//
4// Unlike the cloud app (Sentry-backed), the CLI just prints the squashed
5// cause + pretty-printed structured cause to stderr and returns a short
6// correlation id. Operators can grep for the id in their terminal
7// scrollback when a user reports an opaque 500 traceId.
8// ---------------------------------------------------------------------------
9
10import { Cause, Effect, Layer } from "effect";
11
12import { ErrorCapture } from "@executor/api";
13
14const nextTraceId = () =>
15 `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
16
17export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed(
18 ErrorCapture,
19 ErrorCapture.of({
20 captureException: (cause) =>
21 Effect.sync(() => {
22 const traceId = nextTraceId();
23 const squashed = Cause.squash(cause);
24 console.error(
25 `[executor ${traceId}]`,
26 squashed instanceof Error ? squashed.stack ?? squashed : squashed,
27 );
28 console.error(`[executor ${traceId}] cause:`, Cause.pretty(cause));
29 return traceId;
30 }),
31 }),
32);
bun.lock+4−0
@@ -229,12 +229,15 @@ · 228 unmodified lines
229 "@effect/platform": "catalog:",229 "@effect/platform": "catalog:",
230 "@executor/execution": "workspace:*",230 "@executor/execution": "workspace:*",
231 "@executor/sdk": "workspace:*",231 "@executor/sdk": "workspace:*",
232 "@executor/storage-core": "workspace:*",
232 "effect": "catalog:",233 "effect": "catalog:",
233 },234 },
234 "devDependencies": {235 "devDependencies": {
236 "@effect/vitest": "catalog:",
235 "@types/node": "catalog:",237 "@types/node": "catalog:",
236 "bun-types": "catalog:",238 "bun-types": "catalog:",
237 "typescript": "catalog:",239 "typescript": "catalog:",
240 "vitest": "catalog:",
238 },241 },
239 },242 },
240 "packages/core/cli": {243 "packages/core/cli": {
@@ -307,6 +310,7 @@ · 66 unmodified lines
307 "name": "@executor/sdk",310 "name": "@executor/sdk",
308 "version": "0.0.1",311 "version": "0.0.1",
309 "dependencies": {312 "dependencies": {
313 "@effect/platform": "catalog:",
310 "@executor/storage-core": "workspace:*",314 "@executor/storage-core": "workspace:*",
311 "effect": "catalog:",315 "effect": "catalog:",
312 },316 },
notes/error-handling.md+195−0
@@ -0,0 +1,195 @@
1# Error handling — model + plumbing
2
3## Principle
4
5Errors are typed values that propagate through the call graph carrying
6their full causal structure. **Exactly one layer at the edge** consumes
7them and maps them to a public response. Plugin code never imports
8Sentry, never calls `captureException`, never wraps handlers with
9`sanitize*` helpers.
10
11Two error categories:
12
13- **Surfaceable**: typed Schema errors with a user-actionable message
14 (`McpOAuthError`, `OpenApiParseError`, …). Carry through to the
15 response with their own status (4xx) and message body. Not captured
16 to any external sink — they're normal business outcomes.
17- **Internal**: truly unexpected (storage failure, third-party returned
18 garbage, sync throw inside a handler). Captured via `ErrorCapture`
19 **at the HTTP edge only**, then propagated as the shared
20 `InternalError({ traceId })` schema — opaque to clients, fully
21 detailed in Sentry. One shape, one trace id, one place to look up
22 the cause.
23
24## Layering
25
26```
27 storage-core ──▶ sdk ──▶ api (HTTP edge) ──▶ host
28 StorageError StorageError InternalError ErrorCaptureLive
29 (raw) (raw) (captured) (Sentry)
30```
31
32The SDK stays storage-typed. The HTTP edge (`@executor/api`) is the
33**only** layer that translates `StorageError → InternalError` and
34captures the cause to telemetry. Non-HTTP consumers (CLI, Promise
35SDK, tests) see raw `StorageError` in the typed channel and can react
36however they want.
37
38## Plumbing
39
40### Storage layer (`@executor/storage-core`)
41
42Emits two `Data.TaggedError` classes and nothing else observability-related:
43
44- `StorageError({ message, cause })` — the catch-all for non-recoverable
45 backend failures. The `cause` travels as runtime data so the HTTP
46 edge can capture it, but it's never serialised to the wire.
47- `UniqueViolationError({ model? })` — typed 4xx-shaped failure plugins
48 want to react to (e.g. "source already exists").
49
50Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically
51can't `addError(...)` them on an HttpApi group, which enforces "these
52are internal types, not wire shapes".
53
54`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare
55`Effect<X, StorageFailure>` where `StorageFailure = StorageError |
56UniqueViolationError`. No `Error`, no telemetry service in R.
57
58Storage-core has zero observability awareness — it just emits typed
59values and lets consumers decide what to do with them.
60
61### SDK (`@executor/sdk`)
62
63The SDK is entirely observability-free.
64
65- `createExecutor` requires no observability service. `R = never`.
66- `PluginCtx.storage`, `ctx.core.*`, `ctx.secrets.*`, `ctx.transaction`
67 all surface raw `StorageFailure` in the typed error channel. Plugins
68 can `Effect.catchTag("UniqueViolationError", …)` and translate to
69 their own user-facing errors.
70- Executor public methods (`executor.tools.list()`,
71 `executor.sources.refresh()`, etc.) also surface raw `StorageFailure`.
72
73No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag
74inside the SDK. The value proposition: an SDK consumer can write a CLI,
75a script, a promise-based wrapper, whatever — and the typed channel
76shows them exactly what can go wrong.
77
78### HTTP edge (`@executor/api/observability`)
79
80Owns the translation, the opaque wire schema, and the capture service.
81
82- `InternalError({ traceId })` — the public opaque 500 schema, with an
83 `HttpApiSchema.annotations({ status: 500 })` annotation so the
84 framework encodes it correctly.
85- `ErrorCapture` — tagged Effect service for recording unexpected
86 causes. Shape:
87
88 ```ts
89 interface ErrorCaptureShape {
90 readonly captureException: (cause: Cause<unknown>) => Effect<string>
91 }
92 ```
93
94 Optional — resolved via `Effect.serviceOption`; missing service =
95 empty trace ids. Nothing breaks if it's not wired.
96
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.
103
104 Every handler wraps its generator body with `capture(...)`:
105
106 ```ts
107 .handle("probeEndpoint", ({ payload }) =>
108 capture(Effect.gen(function* () {
109 const ext = yield* McpExtensionService;
110 return yield* ext.probeEndpoint(payload.endpoint);
111 })),
112 )
113 ```
114
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).
119
120- `observabilityMiddleware(Api)` — defect safety net. An
121 `HttpApiBuilder.middleware` layer that wraps the HttpApp once and
122 catches any cause that escaped the typed channel (defects,
123 interrupts, framework bugs) via `ErrorCapture`, returning a typed
124 `InternalError({ traceId })`. Should rarely fire when the rest of
125 the pipeline is well-typed.
126
127### Plugin SDK
128
129Plugin authors write normal Effect code. Their extension method error
130unions look like:
131
132```ts
133Effect.Effect<X, MyPluginTypedError | StorageError, never>
134```
135
136Where `MyPluginTypedError` is the union of their own
137`Schema.TaggedError` classes (with `HttpApiSchema.annotations({ status: 4xx })`).
138`StorageError` is the raw storage tag — it bubbles up, and the HTTP
139edge translates it.
140
141Plugins never provide `ErrorCapture`, never import Sentry, never see
142`InternalError` in their typed channel.
143
144### API groups
145
146Each group declares its typed errors once at the group level:
147
148```ts
149class McpGroup extends HttpApiGroup.make("mcp")
150 .add(endpoint1)
151 .add(endpoint2)
152 // …
153 .addError(InternalError)
154 .addError(McpOAuthError)
155 .addError(McpConnectionError)
156 // …
157{}
158```
159
160No per-endpoint `addError`. The framework encodes each tagged error by
161its annotated status.
162
163### Hosts
164
165- **Cloud Worker** (`apps/cloud/src/observability.ts`) — provides
166 `ErrorCaptureLive`, a Sentry-backed implementation. Wired at the API
167 layer in `protected-layers.ts` so it's available to both
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.)
175- **CLI** (`apps/local/src/server/main.ts`) — same pattern. Provides a
176 console-based `ErrorCaptureLive` (`apps/local/src/server/observability.ts`)
177 that prints the squashed cause + pretty cause to stderr and returns a
178 short correlation id.
179- **Tests / Promise SDK / examples** — non-HTTP consumers see raw
180 `StorageError` / `StorageFailure` in the SDK's typed channel and
181 can match on it directly.
182
183### Anti-patterns
184
185- `Effect.orDie` at handler boundaries — silently turns recoverable
186 failures into 500s with no telemetry (defects bypass typed-channel
187 encoding).
188- Per-plugin `*InternalError` types — clients can't tell which plugin
189 emitted a 500 anyway. Use the shared `InternalError`.
190- `sanitize*` helpers in handler files that `catchAllCause` + map to a
191 generic 500 — same swallowing problem in disguise. Prefer wrapping
192 the handler's `Effect.gen` body with `capture(...)`.
193- SDK code importing `Sentry.captureException` or referencing
194 `InternalError` / `ErrorCapture` — translation lives strictly in
195 `@executor/api`. If the SDK imports it, the layering is wrong.
notes/promise-sdk-typed-errors.md+78−0
@@ -0,0 +1,78 @@
1# Promise SDK — typed errors revisit
2
3## Today
4
5`@executor/sdk/promise` wraps every Effect-returning method in
6`Effect.runPromise(...)`. That works but it's lossy at the Promise
7boundary:
8
9- The promise's *rejection type* is `unknown` (or `any` after
10 `try/catch`). Consumers have to `instanceof InternalError` /
11 `err._tag === "..."` to discriminate, even though the underlying
12 Effect had a fully typed error union.
13- Defects (uncaught throws, fiber interrupts) become rejections with
14 whatever value the Effect ran with — sometimes useful, sometimes a
15 string, depending on the cause.
16
17In other words: the Effect-side surface knows your method can fail with
18`McpOAuthError | InternalError | UniqueViolationError`; the promise
19consumer sees `Promise<X>` and has to take it on faith.
20
21## What we want (revisit)
22
23Switch the wrapper to `Effect.runPromiseExit(...)`. The promise always
24resolves with an `Exit<A, E>`:
25
26```ts
27const exit = await executor.openapi.addSpec({ ... })
28if (Exit.isSuccess(exit)) {
29 exit.value // properly typed `A`
30} else {
31 // exit.cause is Cause<McpOAuthError | InternalError | ...>
32 // narrow with Cause.failureOption / Cause.match etc.
33}
34```
35
36The error union survives the Promise boundary. Defects are visible in
37the cause. Consumers can write totality-checked `match` on the typed
38union without any runtime guessing.
39
40## Open question for the revisit
41
42Do we expose Effect's `Exit` / `Cause` directly to promise consumers,
43or wrap them in a promise-native `Result<A, E>`?
44
45**Expose `Exit`/`Cause`:**
46- Pros: one fewer abstraction, no translation layer to maintain, cause
47 preserves parallel/sequential composition.
48- Cons: consumers depend on `effect`'s API even though they're using a
49 "Promise" SDK — partly defeats the abstraction.
50
51**Wrap in a `Result<A, E>`:**
52- Pros: `@executor/sdk/promise` consumers don't import `effect` at all,
53 surface stays small.
54- Cons: another type to learn / document; loses some Cause structure
55 (parallel/interrupt) unless we replicate it.
56
57Lean toward exposing `Exit`/`Cause`, but worth thinking through what
58the `runPromiseExit` consumers actually want to do at the call site
59before deciding.
60
61## Why punted
62
63Current refactor is about getting Effect-side typed errors right
64end-to-end (storage → SDK → API). The Promise façade is downstream of
65that — once the typed unions are stable on the Effect side, the
66`runPromiseExit` rewrite is a small, well-defined change.
67
68Also: it's a breaking change for promise SDK consumers (return type
69goes from `Promise<A>` to `Promise<Exit<A, E>>`). Better as its own
70focused PR with a migration note.
71
72## Files affected when we do it
73
74- `packages/core/sdk/src/promise-executor.ts` (`promisifyDeep`,
75 `createExecutor`)
76- `packages/core/sdk/src/promise.ts` if it re-exports surface
77- Any Promise-SDK consumer in `examples/` or downstream
78- Doc updates in `notes/error-handling.md`
packages/core/api/package.json+6−2
@@ -9,17 +9,21 @@ · 8 unmodified lines
9 },9 },
10 "scripts": {10 "scripts": {
11 "typecheck": "tsgo --noEmit",11 "typecheck": "tsgo --noEmit",
12 "typecheck:slow": "tsc --noEmit"12 "typecheck:slow": "tsc --noEmit",
13 "test": "vitest run"
13 },14 },
14 "dependencies": {15 "dependencies": {
15 "@effect/platform": "catalog:",16 "@effect/platform": "catalog:",
16 "@executor/execution": "workspace:*",17 "@executor/execution": "workspace:*",
17 "@executor/sdk": "workspace:*",18 "@executor/sdk": "workspace:*",
19 "@executor/storage-core": "workspace:*",
18 "effect": "catalog:"20 "effect": "catalog:"
19 },21 },
20 "devDependencies": {22 "devDependencies": {
23 "@effect/vitest": "catalog:",
21 "@types/node": "catalog:",24 "@types/node": "catalog:",
22 "bun-types": "catalog:",25 "bun-types": "catalog:",
23 "typescript": "catalog:"26 "typescript": "catalog:",
27 "vitest": "catalog:"
24 }28 }
25}29}
packages/core/api/src/executions/api.ts+4−1
@@ -1,6 +1,8 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3 3
4import { InternalError } from "../observability";
5
4// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
5// Schemas7// Schemas
6// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
@@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines
60 .setPayload(ResumeRequest)62 .setPayload(ResumeRequest)
61 .addSuccess(ResumeResponse)63 .addSuccess(ResumeResponse)
62 .addError(ExecutionNotFoundError),64 .addError(ExecutionNotFoundError),
63 ) {}65 )
66 .addError(InternalError) {}
packages/core/api/src/handlers/executions.ts+5−4
@@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines
4import { ExecutorApi } from "../api";4import { ExecutorApi } from "../api";
5import { formatExecuteResult, formatPausedExecution } from "@executor/execution";5import { formatExecuteResult, formatPausedExecution } from "@executor/execution";
6import { ExecutionEngineService } from "../services";6import { ExecutionEngineService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>9export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) =>
9 handlers10 handlers
10 .handle("execute", ({ payload }) =>11 .handle("execute", ({ payload }) =>
11 Effect.gen(function* () {12 capture(Effect.gen(function* () {
12 const engine = yield* ExecutionEngineService;13 const engine = yield* ExecutionEngineService;
13 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));14 const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code));
14 15
@@ -28,10 +29,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 13 unmodified lines
28 text: formatted.text,29 text: formatted.text,
29 structured: formatted.structured,30 structured: formatted.structured,
30 };31 };
31 }),32 })),
32 )33 )
33 .handle("resume", ({ path, payload }) =>34 .handle("resume", ({ path, payload }) =>
34 Effect.gen(function* () {35 capture(Effect.gen(function* () {
35 const engine = yield* ExecutionEngineService;36 const engine = yield* ExecutionEngineService;
36 const result = yield* Effect.promise(() =>37 const result = yield* Effect.promise(() =>
37 engine.resume(path.executionId, {38 engine.resume(path.executionId, {
@@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines
62 structured: formatted.structured,63 structured: formatted.structured,
63 isError: false,64 isError: false,
64 };65 };
65 }),66 })),
66 ),67 ),
67);68);
packages/core/api/src/handlers/scope.ts+3−2
@@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines
3 3
4import { ExecutorApi } from "../api";4import { ExecutorApi } from "../api";
5import { ExecutorService } from "../services";5import { ExecutorService } from "../services";
6import { capture } from "@executor/api";
6 7
7export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) =>8export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) =>
8 handlers.handle("info", () =>9 handlers.handle("info", () =>
9 Effect.gen(function* () {10 capture(Effect.gen(function* () {
10 const executor = yield* ExecutorService;11 const executor = yield* ExecutorService;
11 return {12 return {
12 id: executor.scope.id,13 id: executor.scope.id,
13 name: executor.scope.name,14 name: executor.scope.name,
14 dir: executor.scope.name,15 dir: executor.scope.name,
15 };16 };
16 }),17 })),
17 ),18 ),
18);19);
packages/core/api/src/handlers/secrets.ts+21−22
@@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8const refToResponse = (ref: SecretRef) => ({9const refToResponse = (ref: SecretRef) => ({
9 id: ref.id,10 id: ref.id,
@@ -16,48 +17,46 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines
16export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>17export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) =>
17 handlers18 handlers
18 .handle("list", () =>19 .handle("list", () =>
19 Effect.gen(function* () {20 capture(Effect.gen(function* () {
20 const executor = yield* ExecutorService;21 const executor = yield* ExecutorService;
21 const refs = yield* executor.secrets.list().pipe(Effect.orDie);22 const refs = yield* executor.secrets.list();
22 return refs.map(refToResponse);23 return refs.map(refToResponse);
23 }),24 })),
24 )25 )
25 .handle("status", ({ path }) =>26 .handle("status", ({ path }) =>
26 Effect.gen(function* () {27 capture(Effect.gen(function* () {
27 const executor = yield* ExecutorService;28 const executor = yield* ExecutorService;
28 const status = yield* executor.secrets.status(path.secretId).pipe(Effect.orDie);29 const status = yield* executor.secrets.status(path.secretId);
29 return { secretId: path.secretId, status };30 return { secretId: path.secretId, status };
30 }),31 })),
31 )32 )
32 .handle("set", ({ payload }) =>33 .handle("set", ({ payload }) =>
33 Effect.gen(function* () {34 capture(Effect.gen(function* () {
34 const executor = yield* ExecutorService;35 const executor = yield* ExecutorService;
35 const ref = yield* executor.secrets36 const ref = yield* executor.secrets.set({
36 .set({37 id: payload.id,
37 id: payload.id,38 name: payload.name,
38 name: payload.name,39 value: payload.value,
39 value: payload.value,40 provider: payload.provider,
40 provider: payload.provider,41 });
41 })
42 .pipe(Effect.orDie);
43 return refToResponse(ref);42 return refToResponse(ref);
44 }),43 })),
45 )44 )
46 .handle("resolve", ({ path }) =>45 .handle("resolve", ({ path }) =>
47 Effect.gen(function* () {46 capture(Effect.gen(function* () {
48 const executor = yield* ExecutorService;47 const executor = yield* ExecutorService;
49 const value = yield* executor.secrets.get(path.secretId).pipe(Effect.orDie);48 const value = yield* executor.secrets.get(path.secretId);
50 if (value === null) {49 if (value === null) {
51 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));50 return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId }));
52 }51 }
53 return { secretId: path.secretId, value };52 return { secretId: path.secretId, value };
54 }),53 })),
55 )54 )
56 .handle("remove", ({ path }) =>55 .handle("remove", ({ path }) =>
57 Effect.gen(function* () {56 capture(Effect.gen(function* () {
58 const executor = yield* ExecutorService;57 const executor = yield* ExecutorService;
59 yield* executor.secrets.remove(path.secretId).pipe(Effect.orDie);58 yield* executor.secrets.remove(path.secretId);
60 return { removed: true };59 return { removed: true };
61 }),60 })),
62 ),61 ),
63);62);
packages/core/api/src/handlers/sources.ts+16−17
@@ -4,13 +4,14 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>9export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) =>
9 handlers10 handlers
10 .handle("list", () =>11 .handle("list", () =>
11 Effect.gen(function* () {12 capture(Effect.gen(function* () {
12 const executor = yield* ExecutorService;13 const executor = yield* ExecutorService;
13 const sources = yield* executor.sources.list().pipe(Effect.orDie);14 const sources = yield* executor.sources.list();
14 return sources.map((s) => ({15 return sources.map((s) => ({
15 id: s.id,16 id: s.id,
16 name: s.name,17 name: s.name,
@@ -21,28 +22,26 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines
21 canRefresh: s.canRefresh,22 canRefresh: s.canRefresh,
22 canEdit: s.canEdit,23 canEdit: s.canEdit,
23 }));24 }));
24 }),25 })),
25 )26 )
26 .handle("remove", ({ path }) =>27 .handle("remove", ({ path }) =>
27 Effect.gen(function* () {28 capture(Effect.gen(function* () {
28 const executor = yield* ExecutorService;29 const executor = yield* ExecutorService;
29 yield* executor.sources.remove(path.sourceId).pipe(Effect.orDie);30 yield* executor.sources.remove(path.sourceId);
30 return { removed: true };31 return { removed: true };
31 }),32 })),
32 )33 )
33 .handle("refresh", ({ path }) =>34 .handle("refresh", ({ path }) =>
34 Effect.gen(function* () {35 capture(Effect.gen(function* () {
35 const executor = yield* ExecutorService;36 const executor = yield* ExecutorService;
36 yield* executor.sources.refresh(path.sourceId).pipe(Effect.orDie);37 yield* executor.sources.refresh(path.sourceId);
37 return { refreshed: true };38 return { refreshed: true };
38 }),39 })),
39 )40 )
40 .handle("tools", ({ path }) =>41 .handle("tools", ({ path }) =>
41 Effect.gen(function* () {42 capture(Effect.gen(function* () {
42 const executor = yield* ExecutorService;43 const executor = yield* ExecutorService;
43 const tools = yield* executor.tools44 const tools = yield* executor.tools.list({ sourceId: path.sourceId });
44 .list({ sourceId: path.sourceId })
45 .pipe(Effect.orDie);
46 return tools.map((t) => ({45 return tools.map((t) => ({
47 id: ToolId.make(t.id),46 id: ToolId.make(t.id),
48 pluginId: t.pluginId,47 pluginId: t.pluginId,
@@ -51,12 +50,12 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 2 unmodified lines
51 description: t.description,50 description: t.description,
52 mayElicit: t.annotations?.mayElicit,51 mayElicit: t.annotations?.mayElicit,
53 }));52 }));
54 }),53 })),
55 )54 )
56 .handle("detect", ({ payload }) =>55 .handle("detect", ({ payload }) =>
57 Effect.gen(function* () {56 capture(Effect.gen(function* () {
58 const executor = yield* ExecutorService;57 const executor = yield* ExecutorService;
59 const results = yield* executor.sources.detect(payload.url).pipe(Effect.orDie);58 const results = yield* executor.sources.detect(payload.url);
60 return results.map((r) => ({59 return results.map((r) => ({
61 kind: r.kind,60 kind: r.kind,
62 confidence: r.confidence,61 confidence: r.confidence,
@@ -64,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 1 unmodified line
64 name: r.name,63 name: r.name,
65 namespace: r.namespace,64 namespace: r.namespace,
66 }));65 }));
67 }),66 })),
68 ),67 ),
69);68);
packages/core/api/src/handlers/tools.ts+7−6
@@ -4,13 +4,14 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines
4 4
5import { ExecutorApi } from "../api";5import { ExecutorApi } from "../api";
6import { ExecutorService } from "../services";6import { ExecutorService } from "../services";
7import { capture } from "@executor/api";
7 8
8export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>9export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) =>
9 handlers10 handlers
10 .handle("list", () =>11 .handle("list", () =>
11 Effect.gen(function* () {12 capture(Effect.gen(function* () {
12 const executor = yield* ExecutorService;13 const executor = yield* ExecutorService;
13 const tools = yield* executor.tools.list().pipe(Effect.orDie);14 const tools = yield* executor.tools.list();
14 return tools.map((t) => ({15 return tools.map((t) => ({
15 id: ToolId.make(t.id),16 id: ToolId.make(t.id),
16 pluginId: t.pluginId,17 pluginId: t.pluginId,
@@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 2 unmodified lines
19 description: t.description,20 description: t.description,
20 mayElicit: t.annotations?.mayElicit,21 mayElicit: t.annotations?.mayElicit,
21 }));22 }));
22 }),23 })),
23 )24 )
24 .handle("schema", ({ path }) =>25 .handle("schema", ({ path }) =>
25 Effect.gen(function* () {26 capture(Effect.gen(function* () {
26 const executor = yield* ExecutorService;27 const executor = yield* ExecutorService;
27 const schema = yield* executor.tools.schema(path.toolId).pipe(Effect.orDie);28 const schema = yield* executor.tools.schema(path.toolId);
28 if (schema === null) {29 if (schema === null) {
29 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));30 return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId }));
30 }31 }
31 return schema;32 return schema;
32 }),33 })),
33 ),34 ),
34);35);
packages/core/api/src/index.ts+7−0
@@ -4,3 +4,10 @@ export { SourcesApi } from "./sources/api"; · 3 unmodified lines
4export { SecretsApi } from "./secrets/api";4export { SecretsApi } from "./secrets/api";
5export { ExecutionsApi } from "./executions/api";5export { ExecutionsApi } from "./executions/api";
6export { ScopeApi } from "./scope/api";6export { ScopeApi } from "./scope/api";
7export {
8 InternalError,
9 ErrorCapture,
10 observabilityMiddleware,
11 capture,
12 type ErrorCaptureShape,
13} from "./observability";
packages/core/api/src/observability.test.ts+87−0
@@ -0,0 +1,87 @@
1// ---------------------------------------------------------------------------
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.
5// ErrorCapture is optional — absent hosts just get empty trace ids.
6// ---------------------------------------------------------------------------
7
8import { describe, expect, it } from "@effect/vitest";
9import { Cause, Effect, Layer, Ref } from "effect";
10import { StorageError, UniqueViolationError } from "@executor/storage-core";
11
12import { capture, ErrorCapture, InternalError } from "./observability";
13
14// Recording ErrorCapture — returns a fixed trace id and accumulates
15// every cause it sees in a Ref.
16const makeRecorder = (traceId = "trace-xyz") =>
17 Effect.gen(function* () {
18 const seen = yield* Ref.make<ReadonlyArray<Cause.Cause<unknown>>>([]);
19 const layer = Layer.succeed(
20 ErrorCapture,
21 ErrorCapture.of({
22 captureException: (cause) =>
23 Ref.update(seen, (prev) => [...prev, cause]).pipe(
24 Effect.as(traceId),
25 ),
26 }),
27 );
28 return { layer, seen };
29 });
30
31describe("capture", () => {
32 it.effect("translates StorageError to InternalError with ErrorCapture trace id", () =>
33 Effect.gen(function* () {
34 const { layer, seen } = yield* makeRecorder("trace-abc");
35 const err = new StorageError({ message: "db down", cause: new Error("x") });
36
37 const eff = capture(Effect.fail(err));
38 const result = yield* Effect.flip(eff).pipe(Effect.provide(layer));
39
40 expect(result).toBeInstanceOf(InternalError);
41 expect(result.traceId).toBe("trace-abc");
42
43 // The recorder saw exactly one cause carrying the original StorageError.
44 const causes = yield* Ref.get(seen);
45 expect(causes.length).toBe(1);
46 const squashed = Cause.squash(causes[0]!) as StorageError;
47 expect(squashed).toBeInstanceOf(StorageError);
48 expect(squashed.message).toBe("db down");
49 }),
50 );
51
52 it.effect("empty traceId when no ErrorCapture is wired", () =>
53 Effect.gen(function* () {
54 const err = new StorageError({ message: "nope", cause: undefined });
55 const result = yield* Effect.flip(capture(Effect.fail(err)));
56 expect(result).toBeInstanceOf(InternalError);
57 expect(result.traceId).toBe("");
58 }),
59 );
60
61 it.effect("UniqueViolationError dies (becomes a defect — plugins should catchTag before returning)", () =>
62 Effect.gen(function* () {
63 const err = new UniqueViolationError({ model: "thing" });
64 const exit = yield* Effect.exit(capture(Effect.fail(err)));
65 expect(exit._tag).toBe("Failure");
66 if (exit._tag === "Failure") {
67 const defects = Cause.defects(exit.cause);
68 expect(Array.from(defects)[0]).toBeInstanceOf(UniqueViolationError);
69 }
70 }),
71 );
72
73 it.effect("non-storage typed failures pass through unchanged", () =>
74 Effect.gen(function* () {
75 class DomainError {
76 readonly _tag = "DomainError" as const;
77 }
78 const eff = Effect.fail(new DomainError()) as Effect.Effect<
79 never,
80 DomainError
81 >;
82 const result = yield* Effect.flip(capture(eff));
83 expect(result._tag).toBe("DomainError");
84 }),
85 );
86});
87
packages/core/api/src/observability.ts+155−0
@@ -0,0 +1,155 @@
1// ---------------------------------------------------------------------------
2// HTTP-edge observability — singular translation + capture layer.
3//
4// The SDK (`@executor/sdk`) stays storage-typed: plugin code and
5// executor surface methods return `StorageError` in their typed error
6// channel. Non-HTTP consumers (CLI, Promise SDK, tests) see those raw
7// and can decide what to do. Here, at the HTTP edge, we define:
8//
9// 1. `InternalError` — public opaque 500 schema, narrow by design
10// (only `traceId`), so no internal cause/message/stack ever
11// crosses the wire.
12// 2. `ErrorCapture` — pluggable service the host wires up (Sentry in
13// the cloud Worker, console in the CLI, in-memory in tests) to
14// record causes and return correlation ids. Optional; absent →
15// empty trace ids, nothing breaks.
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).
24// 4. `observabilityMiddleware` — defect safety net. Wraps the HttpApp
25// once; catches any cause that slipped past the typed channel and
26// produces the same `InternalError({ traceId })` shape.
27//
28// Distinct from `apps/cloud/src/services/telemetry.ts` — that's the
29// OTEL bridge wiring spans to Axiom; this is exception capture in the
30// Sentry sense.
31// ---------------------------------------------------------------------------
32
33import { Cause, Context, Effect, Layer, Option, Schema } from "effect";
34import {
35 HttpApiBuilder,
36 HttpApiSchema,
37 HttpServerResponse,
38 type HttpApi,
39 type HttpApiGroup,
40} from "@effect/platform";
41import type { StorageFailure } from "@executor/storage-core";
42
43/** Public 500 surface. Opaque by schema. */
44export class InternalError extends Schema.TaggedError<InternalError>()(
45 "InternalError",
46 {
47 /** Opaque correlation id for backend lookup (Sentry event id, log line, etc.). */
48 traceId: Schema.String,
49 },
50 HttpApiSchema.annotations({ status: 500 }),
51) {}
52
53export interface ErrorCaptureShape {
54 /**
55 * Record an unexpected cause and return a correlation id the operator
56 * can later look up. Implementations (Sentry, console, etc.) decide
57 * how to persist it.
58 */
59 readonly captureException: (
60 cause: Cause.Cause<unknown>,
61 ) => Effect.Effect<string>;
62}
63
64export class ErrorCapture extends Context.Tag("@executor/api/ErrorCapture")<
65 ErrorCapture,
66 ErrorCaptureShape
67>() {
68 /** No-op — used where capture isn't wired. Traces back as empty string. */
69 static readonly NoOp: Layer.Layer<ErrorCapture> = Layer.succeed(
70 ErrorCapture,
71 ErrorCapture.of({ captureException: () => Effect.succeed("") }),
72 );
73}
74
75// Resolve ErrorCapture with a no-op fallback. Keeps the caller's R channel
76// unencumbered: no host has to provide ErrorCapture for the wrapper to
77// typecheck; if it's there, we use it; if not, trace ids are empty.
78const resolveCapture = Effect.serviceOption(ErrorCapture).pipe(
79 Effect.map((opt) =>
80 Option.isSome(opt)
81 ? opt.value
82 : ({ captureException: () => Effect.succeed("") } as const),
83 ),
84);
85
86/**
87 * HTTP-edge translator for `StorageFailure` on a single Effect. Two
88 * cases:
89 *
90 * - `StorageError` — known backend failure. Capture the cause via
91 * `ErrorCapture`, fail with `InternalError({ traceId })`.
92 * - `UniqueViolationError` — invariant violation at the HTTP edge:
93 * if a plugin wanted to surface a unique-conflict as a typed
94 * domain error (e.g. "source already exists") it should
95 * `Effect.catchTag` inside its own method and translate. Anything
96 * that reaches here is unexpected, so we `Effect.die` and let the
97 * observability middleware capture it as a defect.
98 *
99 * Every other typed failure (plugin-domain errors, etc.) passes
100 * through unchanged.
101 */
102export const capture = <A, E, R>(
103 eff: Effect.Effect<A, E, R>,
104): Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R> =>
105 (eff as Effect.Effect<A, E | StorageFailure, R>).pipe(
106 Effect.catchTag("UniqueViolationError", (err) => Effect.die(err)),
107 Effect.catchTag("StorageError", (err) =>
108 resolveCapture.pipe(
109 Effect.flatMap((c) => c.captureException(Cause.fail(err))),
110 Effect.flatMap((traceId) =>
111 Effect.fail(new InternalError({ traceId })),
112 ),
113 ),
114 ),
115 ) as Effect.Effect<A, Exclude<E, StorageFailure> | InternalError, R>;
116
117/**
118 * Edge defect catchall. Builds an `HttpApiBuilder.middleware` layer
119 * that wraps the HttpApp once. Captures any cause (defects, interrupts,
120 * unmapped failures the framework couldn't encode) via `ErrorCapture`
121 * and returns a typed `InternalError({ traceId })` body.
122 *
123 * `ErrorCapture` is OPTIONAL — if the host hasn't wired one up the
124 * middleware still fires but the trace id will be empty.
125 *
126 * Should rarely fire when the edge is well-wired — storage failures
127 * are already translated by `withCapture` at service construction;
128 * plugin-domain errors flow through their schemas. This is the net
129 * for anything that slipped through.
130 */
131export const observabilityMiddleware = <
132 Id extends string,
133 Groups extends HttpApiGroup.HttpApiGroup.Any,
134 E,
135 R,
136>(
137 api: HttpApi.HttpApi<Id, Groups, E, R>,
138): Layer.Layer<never> =>
139 HttpApiBuilder.middleware(
140 api,
141 Effect.gen(function* () {
142 const c = yield* resolveCapture;
143 return (httpApp) =>
144 Effect.catchAllCause(httpApp, (cause) =>
145 Effect.gen(function* () {
146 const traceId = yield* c.captureException(cause);
147 return HttpServerResponse.unsafeJson(
148 new InternalError({ traceId }),
149 { status: 500 },
150 );
151 }),
152 );
153 }),
154 { withContext: true },
155 );
packages/core/api/src/scope/api.ts+5−3
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Response schemas8// Response schemas
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines
16// Group18// Group
17// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
18 20
19export class ScopeApi extends HttpApiGroup.make("scope").add(21export class ScopeApi extends HttpApiGroup.make("scope")
20 HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse),22 .add(HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse))
21) {}23 .addError(InternalError) {}
packages/core/api/src/secrets/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";3import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -78,4 +80,5 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 70 unmodified lines
78 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`80 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}`
79 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))81 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
80 .addError(SecretNotFound),82 .addError(SecretNotFound),
81 ) {}83 )
84 .addError(InternalError) {}
packages/core/api/src/services.ts+4−1
@@ -2,7 +2,10 @@ import { Context } from "effect"; · 1 unmodified line
2import type { Executor } from "@executor/sdk";2import type { Executor } from "@executor/sdk";
3import type { ExecutionEngine } from "@executor/execution";3import type { ExecutionEngine } from "@executor/execution";
4 4
5export class ExecutorService extends Context.Tag("ExecutorService")<ExecutorService, Executor>() {}5export class ExecutorService extends Context.Tag("ExecutorService")<
6 ExecutorService,
7 Executor
8>() {}
6 9
7export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<10export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")<
8 ExecutionEngineService,11 ExecutionEngineService,
packages/core/api/src/sources/api.ts+16−5
@@ -1,6 +1,8 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, ToolId } from "@executor/sdk";3import { ScopeId, SourceRemovalNotAllowedError, ToolId } from "@executor/sdk";
4
5import { InternalError } from "../observability";
4 6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
@@ -53,6 +55,14 @@ const DetectResultResponse = Schema.Struct({ · 46 unmodified lines
53 namespace: Schema.String,55 namespace: Schema.String,
54});56});
55 57
58// ---------------------------------------------------------------------------
59// Error schemas with HTTP status annotations
60// ---------------------------------------------------------------------------
61
62const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations(
63 HttpApiSchema.annotations({ status: 409 }),
64);
65
56// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
57// Group67// Group
58// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
@@ -64,9 +74,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 5 unmodified lines
64 ),74 ),
65 )75 )
66 .add(76 .add(
67 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`.addSuccess(77 HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`
68 SourceRemoveResponse,78 .addSuccess(SourceRemoveResponse)
69 ),79 .addError(SourceRemovalNotAllowed),
70 )80 )
71 .add(81 .add(
72 HttpApiEndpoint.post(82 HttpApiEndpoint.post(
@@ -82,4 +92,5 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 9 unmodified lines
82 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`92 HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect`
83 .setPayload(DetectRequest)93 .setPayload(DetectRequest)
84 .addSuccess(Schema.Array(DetectResultResponse)),94 .addSuccess(Schema.Array(DetectResultResponse)),
85 ) {}95 )
96 .addError(InternalError) {}
packages/core/api/src/tools/api.ts+4−1
@@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk";3import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk";
4 4
5import { InternalError } from "../observability";
6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
6// Params8// Params
7// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -53,4 +55,5 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 45 unmodified lines
53 HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema`55 HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema`
54 .addSuccess(ToolSchemaResponse)56 .addSuccess(ToolSchemaResponse)
55 .addError(ToolNotFound),57 .addError(ToolNotFound),
56 ) {}58 )
59 .addError(InternalError) {}
packages/core/api/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1import { defineConfig } from "vitest/config";
2
3export default defineConfig({
4 test: {
5 include: ["src/**/*.test.ts"],
6 },
7});
packages/core/sdk/package.json+1−0
@@ -44,6 +44,7 @@ · 43 unmodified lines
44 "typecheck:slow": "tsc --noEmit"44 "typecheck:slow": "tsc --noEmit"
45 },45 },
46 "dependencies": {46 "dependencies": {
47 "@effect/platform": "catalog:",
47 "@executor/storage-core": "workspace:*",48 "@executor/storage-core": "workspace:*",
48 "effect": "catalog:"49 "effect": "catalog:"
49 },50 },
packages/core/sdk/src/blob.ts+21−8
@@ -6,35 +6,44 @@ · 5 unmodified lines
6//6//
7// Plugins see a `ScopedBlobStore` that's already namespaced to the plugin7// Plugins see a `ScopedBlobStore` that's already namespaced to the plugin
8// id, so key collisions across plugins are structurally impossible.8// id, so key collisions across plugins are structurally impossible.
9//
10// Error channel is `StorageError` — blobs only do read/write/delete, so
11// they never produce `UniqueViolationError`. The HTTP edge translates
12// `StorageError` to the opaque public `InternalError({ traceId })`.
9// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
10 14
11import { Effect } from "effect";15import { Effect } from "effect";
12 16
17import { StorageError } from "@executor/storage-core";
18
13export interface BlobStore {19export interface BlobStore {
14 readonly get: (20 readonly get: (
15 namespace: string,21 namespace: string,
16 key: string,22 key: string,
17 ) => Effect.Effect<string | null, Error>;23 ) => Effect.Effect<string | null, StorageError>;
18 readonly put: (24 readonly put: (
19 namespace: string,25 namespace: string,
20 key: string,26 key: string,
21 value: string,27 value: string,
22 ) => Effect.Effect<void, Error>;28 ) => Effect.Effect<void, StorageError>;
23 readonly delete: (29 readonly delete: (
24 namespace: string,30 namespace: string,
25 key: string,31 key: string,
26 ) => Effect.Effect<void, Error>;32 ) => Effect.Effect<void, StorageError>;
27 readonly has: (33 readonly has: (
28 namespace: string,34 namespace: string,
29 key: string,35 key: string,
30 ) => Effect.Effect<boolean, Error>;36 ) => Effect.Effect<boolean, StorageError>;
31}37}
32 38
33export interface ScopedBlobStore {39export interface ScopedBlobStore {
34 readonly get: (key: string) => Effect.Effect<string | null, Error>;40 readonly get: (key: string) => Effect.Effect<string | null, StorageError>;
35 readonly put: (key: string, value: string) => Effect.Effect<void, Error>;41 readonly put: (
36 readonly delete: (key: string) => Effect.Effect<void, Error>;42 key: string,
37 readonly has: (key: string) => Effect.Effect<boolean, Error>;43 value: string,
44 ) => Effect.Effect<void, StorageError>;
45 readonly delete: (key: string) => Effect.Effect<void, StorageError>;
46 readonly has: (key: string) => Effect.Effect<boolean, StorageError>;
38}47}
39 48
40export const scopeBlobStore = (49export const scopeBlobStore = (
@@ -51,6 +60,10 @@ export const scopeBlobStore = ( · 10 unmodified lines
51 * Minimal in-memory BlobStore — good for tests and trivial hosts. Real60 * Minimal in-memory BlobStore — good for tests and trivial hosts. Real
52 * backends (filesystem, S3/R2, SQLite-table-backed) implement the same61 * backends (filesystem, S3/R2, SQLite-table-backed) implement the same
53 * interface.62 * interface.
63 *
64 * Every method is `Effect<_, never>` — a pure in-memory Map can't fail.
65 * `never` is assignable to `StorageError`, so the result still fits the
66 * `BlobStore` interface.
54 */67 */
55export const makeInMemoryBlobStore = (): BlobStore => {68export const makeInMemoryBlobStore = (): BlobStore => {
56 const store = new Map<string, string>();69 const store = new Map<string, string>();
packages/core/sdk/src/error-handling.test.ts+179−0
@@ -0,0 +1,179 @@
1// ---------------------------------------------------------------------------
2// Typed-error model — SDK-level behavioural tests.
3//
4// The happy-path tests in executor.test.ts don't exercise storage-failure
5// propagation because the in-memory adapter never fails. These tests
6// inject a deliberately failing adapter and verify the SDK surface:
7//
8// 1. `StorageError` from a backend surfaces *raw* in the typed error
9// channel — no telemetry, no InternalError translation. The HTTP
10// edge (`@executor/api` `withCapture`) is the one layer
11// that translates to the opaque InternalError; non-HTTP consumers
12// (CLI, Promise SDK, tests, plugins) can react to the raw tag.
13// 2. `UniqueViolationError` also passes through raw — plugin code
14// can `Effect.catchTag` and translate to its own user-facing
15// typed error.
16// 3. `createExecutor` has no ErrorCapture requirement at all — no
17// service lookup, no R channel leak.
18//
19// See `notes/error-handling.md` for the architectural overview and
20// `@executor/api` `observability.test.ts` for the edge-translation
21// tests.
22// ---------------------------------------------------------------------------
23
24import { describe, expect, it } from "@effect/vitest";
25import { Effect } from "effect";
26
27import {
28 StorageError,
29 UniqueViolationError,
30 type DBAdapter,
31 type DBTransactionAdapter,
32 type StorageFailure,
33} from "@executor/storage-core";
34import { makeInMemoryBlobStore } from "./blob";
35import { createExecutor } from "./executor";
36import { ScopeId } from "./ids";
37import { defineSchema, definePlugin } from "./plugin";
38import { Scope } from "./scope";
39
40// ---------------------------------------------------------------------------
41// Test helpers — an adapter that deterministically fails every method
42// with a chosen StorageFailure, and a scope fixture.
43// ---------------------------------------------------------------------------
44
45const makeFailingAdapter = (failure: StorageFailure): DBAdapter => {
46 const fail = () =>
47 Effect.fail(failure) as Effect.Effect<never, StorageFailure, never>;
48 return {
49 id: "failing",
50 create: fail as DBAdapter["create"],
51 createMany: fail as DBAdapter["createMany"],
52 findOne: fail as DBAdapter["findOne"],
53 findMany: fail as DBAdapter["findMany"],
54 count: fail as DBAdapter["count"],
55 update: fail as DBAdapter["update"],
56 updateMany: fail as DBAdapter["updateMany"],
57 delete: fail as DBAdapter["delete"],
58 deleteMany: fail as DBAdapter["deleteMany"],
59 transaction: ((callback: (trx: DBTransactionAdapter) => unknown) => {
60 void callback;
61 return Effect.fail(failure);
62 }) as DBAdapter["transaction"],
63 };
64};
65
66const testScope = new Scope({
67 id: ScopeId.make("test-scope"),
68 name: "test",
69 createdAt: new Date(),
70});
71
72const baseConfig = (adapter: DBAdapter) => ({
73 scope: testScope,
74 adapter,
75 blobs: makeInMemoryBlobStore(),
76});
77
78// ---------------------------------------------------------------------------
79// Tests
80// ---------------------------------------------------------------------------
81
82describe("typed-error edge model — SDK", () => {
83 it.effect("StorageError propagates raw through the executor surface", () =>
84 Effect.gen(function* () {
85 const failure = new StorageError({
86 message: "backend lost its mind",
87 cause: new Error("driver kaboom"),
88 });
89 const executor = yield* createExecutor(
90 baseConfig(makeFailingAdapter(failure)),
91 );
92
93 const result = yield* executor.tools.list().pipe(Effect.flip);
94 expect(result).toBeInstanceOf(StorageError);
95 expect(result._tag).toBe("StorageError");
96 // Original cause preserved end-to-end.
97 const storageErr = result as StorageError;
98 expect(storageErr.message).toBe("backend lost its mind");
99 expect(storageErr.cause).toBeInstanceOf(Error);
100 expect((storageErr.cause as Error).message).toBe("driver kaboom");
101 }),
102 );
103
104 // ---------------------------------------------------------------------------
105 // UniqueViolationError propagates unchanged — plugins can catchTag it.
106 // Build a tiny plugin whose storage method calls adapter.create and
107 // whose extension method exposes a method that catches
108 // UniqueViolationError and resolves with a sentinel. If the catch
109 // fires, UniqueViolationError reached the plugin code intact.
110 // ---------------------------------------------------------------------------
111
112 const uniqueSchema = defineSchema({
113 thing: {
114 fields: {
115 id: { type: "string", required: true },
116 },
117 },
118 });
119
120 const uniqueTestPlugin = definePlugin(() => ({
121 id: "uniq-test" as const,
122 schema: uniqueSchema,
123 storage: ({ adapter }) => ({
124 tryCreate: (id: string) =>
125 adapter
126 .create({
127 model: "thing",
128 data: { id },
129 forceAllowId: true,
130 })
131 .pipe(
132 Effect.catchTag("UniqueViolationError", (err) =>
133 Effect.succeed({
134 caught: true as const,
135 model: err.model ?? null,
136 }),
137 ),
138 Effect.map((r) =>
139 "caught" in r ? r : { caught: false as const, model: null },
140 ),
141 ),
142 }),
143 extension: (ctx) => ({
144 create: (id: string) => ctx.storage.tryCreate(id),
145 }),
146 }));
147
148 it.effect("UniqueViolationError propagates through plugin code (catchTag works)", () =>
149 Effect.gen(function* () {
150 const failure = new UniqueViolationError({ model: "thing" });
151 const executor = yield* createExecutor({
152 ...baseConfig(makeFailingAdapter(failure)),
153 plugins: [uniqueTestPlugin()] as const,
154 });
155
156 const result = yield* executor["uniq-test"].create("abc");
157 expect(result.caught).toBe(true);
158 expect(result.model).toBe("thing");
159 }),
160 );
161
162 it.effect("createExecutor has no ErrorCapture requirement (no R leak)", () =>
163 Effect.gen(function* () {
164 // No `.pipe(Effect.provide(...))` anywhere; just run. If the SDK
165 // still required any observability service, this wouldn't
166 // type-check (R would leak) nor run (would crash at construction).
167 const executor = yield* createExecutor(
168 baseConfig(
169 makeFailingAdapter(
170 new StorageError({ message: "doesn't matter", cause: undefined }),
171 ),
172 ),
173 );
174 // Sanity: the executor surface exists and methods are callable.
175 expect(typeof executor.tools.list).toBe("function");
176 expect(typeof executor.sources.list).toBe("function");
177 }),
178 );
179});
packages/core/sdk/src/executor.test.ts+6−1
@@ -354,8 +354,13 @@ describe("createExecutor", () => { · 353 unmodified lines
354 }),354 }),
355 );355 );
356 356
357 // The collision is treated as an internal/programmer error and
358 // surfaces as raw `StorageError` in the typed channel. The HTTP
359 // edge (`@executor/api` `withCapture`) is responsible for
360 // translating it to the opaque `InternalError({ traceId })` when
361 // crossing the wire; here, at the SDK layer, we expect the raw tag.
357 const err = yield* executor.collide.tryRegister().pipe(Effect.flip);362 const err = yield* executor.collide.tryRegister().pipe(Effect.flip);
358 expect(err.message).toContain("collides with a static source");363 expect(err._tag).toBe("StorageError");
359 }),364 }),
360 );365 );
361 366
packages/core/sdk/src/executor.ts+85−47
@@ -1,9 +1,11 @@
1import { Effect, FiberRef } from "effect";1import { Effect, FiberRef } from "effect";
2import {2import {
3 StorageError,
3 typedAdapter,4 typedAdapter,
4 type DBAdapter,5 type DBAdapter,
5 type DBSchema,6 type DBSchema,
6 type DBTransactionAdapter,7 type DBTransactionAdapter,
8 type StorageFailure,
7 type TypedAdapter,9 type TypedAdapter,
8} from "@executor/storage-core";10} from "@executor/storage-core";
9 11
@@ -98,19 +100,19 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 88 unmodified lines
98 readonly tools: {100 readonly tools: {
99 readonly list: (101 readonly list: (
100 filter?: ToolListFilter,102 filter?: ToolListFilter,
101 ) => Effect.Effect<readonly Tool[], Error>;103 ) => Effect.Effect<readonly Tool[], StorageFailure>;
102 /** Fetch a tool's full schema view: JSON schemas with `$defs`104 /** Fetch a tool's full schema view: JSON schemas with `$defs`
103 * attached from the core `definition` table, plus TypeScript105 * attached from the core `definition` table, plus TypeScript
104 * preview strings rendered from them. Returns `null` for unknown106 * preview strings rendered from them. Returns `null` for unknown
105 * tool ids. */107 * tool ids. */
106 readonly schema: (108 readonly schema: (
107 toolId: string,109 toolId: string,
108 ) => Effect.Effect<ToolSchema | null, Error>;110 ) => Effect.Effect<ToolSchema | null, StorageFailure>;
109 /** Every `$defs` entry across every source, grouped by source id.111 /** Every `$defs` entry across every source, grouped by source id.
110 * Used for bulk schema export and downstream TypeScript rendering. */112 * Used for bulk schema export and downstream TypeScript rendering. */
111 readonly definitions: () => Effect.Effect<113 readonly definitions: () => Effect.Effect<
112 Record<string, Record<string, unknown>>,114 Record<string, Record<string, unknown>>,
113 Error115 StorageFailure
114 >;116 >;
115 readonly invoke: (117 readonly invoke: (
116 toolId: string,118 toolId: string,
@@ -123,44 +125,48 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 6 unmodified lines
123 | NoHandlerError125 | NoHandlerError
124 | ToolInvocationError126 | ToolInvocationError
125 | ElicitationDeclinedError127 | ElicitationDeclinedError
126 | Error128 | StorageFailure
127 >;129 >;
128 };130 };
129 131
130 readonly sources: {132 readonly sources: {
131 readonly list: () => Effect.Effect<readonly Source[], Error>;133 readonly list: () => Effect.Effect<readonly Source[], StorageFailure>;
132 readonly remove: (134 readonly remove: (
133 sourceId: string,135 sourceId: string,
134 ) => Effect.Effect<void, SourceRemovalNotAllowedError | Error>;136 ) => Effect.Effect<void, SourceRemovalNotAllowedError | StorageFailure>;
135 readonly refresh: (sourceId: string) => Effect.Effect<void, Error>;137 readonly refresh: (sourceId: string) => Effect.Effect<void, StorageFailure>;
136 /** URL autodetection — fans out to every plugin's `detect` hook138 /** URL autodetection — fans out to every plugin's `detect` hook
137 * (if declared), returns every high/medium/low-confidence match.139 * (if declared), returns every high/medium/low-confidence match.
138 * UI picks a winner from the list. */140 * UI picks a winner from the list. */
139 readonly detect: (141 readonly detect: (
140 url: string,142 url: string,
141 ) => Effect.Effect<readonly SourceDetectionResult[], Error>;143 ) => Effect.Effect<readonly SourceDetectionResult[], StorageFailure>;
142 /** All `$defs` registered for a single source, keyed by def name. */144 /** All `$defs` registered for a single source, keyed by def name. */
143 readonly definitions: (145 readonly definitions: (
144 sourceId: string,146 sourceId: string,
145 ) => Effect.Effect<Record<string, unknown>, Error>;147 ) => Effect.Effect<Record<string, unknown>, StorageFailure>;
146 };148 };
147 149
148 readonly secrets: {150 readonly secrets: {
149 readonly get: (id: string) => Effect.Effect<string | null, Error>;151 readonly get: (
152 id: string,
153 ) => Effect.Effect<string | null, StorageFailure>;
150 /** Fast-path existence check — hits the core `secret` routing table154 /** Fast-path existence check — hits the core `secret` routing table
151 * only, never calls the provider. Use this for UI state ("secret155 * only, never calls the provider. Use this for UI state ("secret
152 * missing, prompt to add") to avoid keychain permission prompts156 * missing, prompt to add") to avoid keychain permission prompts
153 * or 1password IPC roundtrips on a pre-flight check. */157 * or 1password IPC roundtrips on a pre-flight check. */
154 readonly status: (158 readonly status: (
155 id: string,159 id: string,
156 ) => Effect.Effect<"resolved" | "missing", Error>;160 ) => Effect.Effect<"resolved" | "missing", StorageFailure>;
157 readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, Error>;161 readonly set: (
158 readonly remove: (id: string) => Effect.Effect<void, Error>;162 input: SetSecretInput,
159 readonly list: () => Effect.Effect<readonly SecretRef[], Error>;163 ) => Effect.Effect<SecretRef, StorageFailure>;
164 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
165 readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>;
160 readonly providers: () => Effect.Effect<readonly string[]>;166 readonly providers: () => Effect.Effect<readonly string[]>;
161 };167 };
162 168
163 readonly close: () => Effect.Effect<void, Error>;169 readonly close: () => Effect.Effect<void, StorageFailure>;
164} & PluginExtensions<TPlugins>;170} & PluginExtensions<TPlugins>;
165 171
166export interface ExecutorConfig<172export interface ExecutorConfig<
@@ -281,7 +287,7 @@ const writeSourceInput = ( · 114 unmodified lines
281 core: TypedAdapter<CoreSchema>,287 core: TypedAdapter<CoreSchema>,
282 pluginId: string,288 pluginId: string,
283 input: SourceInput,289 input: SourceInput,
284): Effect.Effect<void, Error> =>290): Effect.Effect<void, StorageFailure> =>
285 Effect.gen(function* () {291 Effect.gen(function* () {
286 yield* deleteSourceById(core, input.id);292 yield* deleteSourceById(core, input.id);
287 293
@@ -325,7 +331,7 @@ const writeSourceInput = ( · 37 unmodified lines
325const deleteSourceById = (331const deleteSourceById = (
326 core: TypedAdapter<CoreSchema>,332 core: TypedAdapter<CoreSchema>,
327 sourceId: string,333 sourceId: string,
328): Effect.Effect<void, Error> =>334): Effect.Effect<void, StorageFailure> =>
329 Effect.gen(function* () {335 Effect.gen(function* () {
330 yield* core.deleteMany({336 yield* core.deleteMany({
331 model: "tool",337 model: "tool",
@@ -345,7 +351,7 @@ const writeDefinitions = ( · 13 unmodified lines
345 core: TypedAdapter<CoreSchema>,351 core: TypedAdapter<CoreSchema>,
346 pluginId: string,352 pluginId: string,
347 input: DefinitionsInput,353 input: DefinitionsInput,
348): Effect.Effect<void, Error> =>354): Effect.Effect<void, StorageFailure> =>
349 Effect.gen(function* () {355 Effect.gen(function* () {
350 yield* core.deleteMany({356 yield* core.deleteMany({
351 model: "definition",357 model: "definition",
@@ -505,8 +511,9 @@ export const createExecutor = < · 153 unmodified lines
505 // without a list() implementation (keychain) never hit the fallback511 // without a list() implementation (keychain) never hit the fallback
506 // walk because their secrets must be registered through set() to512 // walk because their secrets must be registered through set() to
507 // be known at all.513 // be known at all.
508 // ------------------------------------------------------------------514 const secretsGet = (
509 const secretsGet = (id: string): Effect.Effect<string | null, Error> =>515 id: string,
516 ): Effect.Effect<string | null, StorageFailure> =>
510 Effect.gen(function* () {517 Effect.gen(function* () {
511 // Fast path: routing table518 // Fast path: routing table
512 const row = yield* core.findOne({519 const row = yield* core.findOne({
@@ -538,15 +545,21 @@ export const createExecutor = < · 25 unmodified lines
538 545
539 const secretsSet = (546 const secretsSet = (
540 input: SetSecretInput,547 input: SetSecretInput,
541 ): Effect.Effect<SecretRef, Error> =>548 ): Effect.Effect<SecretRef, StorageFailure> =>
542 Effect.gen(function* () {549 Effect.gen(function* () {
543 // Pick provider: explicit or first-writable.550 // Pick provider: explicit or first-writable. Misconfiguration
551 // (unknown provider, no writable provider, read-only provider)
552 // is a host setup bug — surface as `StorageError` so it lands
553 // as a captured InternalError(traceId) at the SDK boundary.
544 let target: SecretProvider | undefined;554 let target: SecretProvider | undefined;
545 if (input.provider) {555 if (input.provider) {
546 target = secretProviders.get(input.provider);556 target = secretProviders.get(input.provider);
547 if (!target) {557 if (!target) {
548 return yield* Effect.fail(558 return yield* Effect.fail(
549 new Error(`Unknown secret provider: ${input.provider}`),559 new StorageError({
560 message: `Unknown secret provider: ${input.provider}`,
561 cause: undefined,
562 }),
550 );563 );
551 }564 }
552 } else {565 } else {
@@ -558,13 +571,19 @@ export const createExecutor = < · 5 unmodified lines
558 }571 }
559 if (!target) {572 if (!target) {
560 return yield* Effect.fail(573 return yield* Effect.fail(
561 new Error("No writable secret providers registered"),574 new StorageError({
575 message: "No writable secret providers registered",
576 cause: undefined,
577 }),
562 );578 );
563 }579 }
564 }580 }
565 if (!target.writable || !target.set) {581 if (!target.writable || !target.set) {
566 return yield* Effect.fail(582 return yield* Effect.fail(
567 new Error(`Secret provider "${target.key}" is read-only`),583 new StorageError({
584 message: `Secret provider "${target.key}" is read-only`,
585 cause: undefined,
586 }),
568 );587 );
569 }588 }
570 589
@@ -596,7 +615,7 @@ export const createExecutor = < · 25 unmodified lines
596 });615 });
597 });616 });
598 617
599 const secretsRemove = (id: string): Effect.Effect<void, Error> =>618 const secretsRemove = (id: string): Effect.Effect<void, StorageFailure> =>
600 Effect.gen(function* () {619 Effect.gen(function* () {
601 // Providers don't coordinate on which of them own the id — they620 // Providers don't coordinate on which of them own the id — they
602 // each get asked. Most calls are no-ops; fan them out so one621 // each get asked. Most calls are no-ops; fan them out so one
@@ -630,7 +649,7 @@ export const createExecutor = < · 27 unmodified lines
630 // so that routing information in the core table is authoritative.649 // so that routing information in the core table is authoritative.
631 // Providers without a list() method (e.g. keychain) contribute650 // Providers without a list() method (e.g. keychain) contribute
632 // only via the core table path.651 // only via the core table path.
633 const secretsList = (): Effect.Effect<readonly SecretRef[], Error> =>652 const secretsList = (): Effect.Effect<readonly SecretRef[], StorageFailure> =>
634 Effect.gen(function* () {653 Effect.gen(function* () {
635 const byId = new Map<string, SecretRef>();654 const byId = new Map<string, SecretRef>();
636 655
@@ -713,11 +732,12 @@ export const createExecutor = < · 76 unmodified lines
713 );732 );
714 }733 }
715 734
716 // `typedAdapter(adapter)` is a zero-cost cast at the type level —735 // Plugin-facing typed view. `StorageError` and `UniqueViolationError`
717 // the runtime value IS the adapter, but the type is whatever the736 // flow through the typed channel unchanged — plugins can
718 // plugin's schema declares. Plugins never import `typedAdapter` or737 // `catchTag("UniqueViolationError", …)` to translate to their own
719 // `DBAdapter` themselves; they just destructure `{ adapter }` in738 // user-facing errors; the HTTP edge (see @executor/api
720 // their storage factory and get a store typed to their own schema.739 // `withCapture`) is responsible for translating any
740 // `StorageError` that still escapes into the opaque InternalError.
721 // eslint-disable-next-line @typescript-eslint/no-explicit-any741 // eslint-disable-next-line @typescript-eslint/no-explicit-any
722 const storageDeps: StorageDeps<any> = {742 const storageDeps: StorageDeps<any> = {
723 scope,743 scope,
@@ -740,21 +760,24 @@ export const createExecutor = < · 16 unmodified lines
740 // a static source id, or any of whose would-be tool ids760 // a static source id, or any of whose would-be tool ids
741 // collide with a static tool id. Tool ids are761 // collide with a static tool id. Tool ids are
742 // `${source_id}.${tool.name}` — static and dynamic762 // `${source_id}.${tool.name}` — static and dynamic
743 // share the same string space.763 // share the same string space. Fails as `StorageError`
764 // so the HTTP edge surfaces it as `InternalError(traceId)`.
744 if (staticSources.has(input.id)) {765 if (staticSources.has(input.id)) {
745 return yield* Effect.fail(766 return yield* Effect.fail(
746 new Error(767 new StorageError({
747 `Source id "${input.id}" collides with a static source`,768 message: `Source id "${input.id}" collides with a static source`,
748 ),769 cause: undefined,
770 }),
749 );771 );
750 }772 }
751 for (const tool of input.tools) {773 for (const tool of input.tools) {
752 const fqid = `${input.id}.${tool.name}`;774 const fqid = `${input.id}.${tool.name}`;
753 if (staticTools.has(fqid)) {775 if (staticTools.has(fqid)) {
754 return yield* Effect.fail(776 return yield* Effect.fail(
755 new Error(777 new StorageError({
756 `Tool id "${fqid}" collides with a static tool`,778 message: `Tool id "${fqid}" collides with a static tool`,
757 ),779 cause: undefined,
780 }),
758 );781 );
759 }782 }
760 }783 }
@@ -779,16 +802,21 @@ export const createExecutor = < · 18 unmodified lines
779 },802 },
780 },803 },
781 secrets: {804 secrets: {
782 get: secretsGet,805 get: (id) => secretsGet(id),
783 list: secretsListForCtx,806 list: () => secretsListForCtx(),
784 set: secretsSet,807 set: (input) => secretsSet(input),
785 remove: secretsRemove,808 remove: (id) => secretsRemove(id),
786 },809 },
787 // Open one real tx boundary and route every nested write inside810 // Open one real tx boundary and route every nested write inside
788 // `effect` through that same handle via the activeAdapterRef —811 // `effect` through that same handle via the activeAdapterRef —
789 // see buildAdapterRouter above for the dispatch logic.812 // see buildAdapterRouter above. Caller-typed errors (`E`)
813 // propagate unchanged; storage failures also stay typed
814 // (`StorageFailure`) so the HTTP edge wrapper can translate them.
790 transaction: <A, E>(effect: Effect.Effect<A, E>) =>815 transaction: <A, E>(effect: Effect.Effect<A, E>) =>
791 adapter.transaction(() => effect) as Effect.Effect<A, E | Error>,816 adapter.transaction(() => effect) as Effect.Effect<
817 A,
818 E | StorageFailure
819 >,
792 };820 };
793 821
794 // Build extension FIRST so it's available as `self` when resolving822 // Build extension FIRST so it's available as `self` when resolving
@@ -1236,7 +1264,7 @@ export const createExecutor = < · 441 unmodified lines
1236 // just to ask "does this exist?"1264 // just to ask "does this exist?"
1237 const secretsStatus = (1265 const secretsStatus = (
1238 id: string,1266 id: string,
1239 ): Effect.Effect<"resolved" | "missing", Error> =>1267 ): Effect.Effect<"resolved" | "missing", StorageFailure> =>
1240 Effect.gen(function* () {1268 Effect.gen(function* () {
1241 const row = yield* core.findOne({1269 const row = yield* core.findOne({
1242 model: "secret",1270 model: "secret",
@@ -1263,6 +1291,11 @@ export const createExecutor = < · 20 unmodified lines
1263 }1291 }
1264 });1292 });
1265 1293
1294 // Public Executor surface — storage-backed methods surface
1295 // `StorageFailure` (StorageError | UniqueViolationError) raw. The
1296 // HTTP edge wraps this surface with `withCapture` to
1297 // translate `StorageError` → `InternalError({ traceId })`; non-HTTP
1298 // consumers (CLI, Promise SDK, tests) see the raw typed channel.
1266 const base = {1299 const base = {
1267 scope,1300 scope,
1268 tools: {1301 tools: {
@@ -1292,5 +1325,10 @@ export const createExecutor = < · 23 unmodified lines
1292 close,1325 close,
1293 };1326 };
1294 1327
1295 return Object.assign(base, extensions) as Executor<TPlugins>;1328 // Cast through `unknown` because the impl effects can include
1329 // `Error` from plugin-supplied callbacks (resolveAnnotations etc.) —
1330 // those leak via the helper functions and won't be cleaned until
1331 // every plugin tightens its surface to typed errors. The runtime
1332 // shape matches `Executor<TPlugins>`.
1333 return Object.assign(base, extensions) as unknown as Executor<TPlugins>;
1296 });1334 });
packages/core/sdk/src/index.ts+5−0
@@ -10,6 +10,7 @@ export type { · 9 unmodified lines
10 DBSchema,10 DBSchema,
11 DBFieldAttribute,11 DBFieldAttribute,
12 DBFieldType,12 DBFieldType,
13 StorageFailure,
13 TypedAdapter,14 TypedAdapter,
14 Where,15 Where,
15 WhereOperator,16 WhereOperator,
@@ -17,6 +18,10 @@ export type { · 1 unmodified line
17 18
18export { typedAdapter } from "@executor/storage-core";19export { typedAdapter } from "@executor/storage-core";
19 20
21// Storage-layer typed errors (re-exported so plugin code can catchTag
22// `UniqueViolationError` without importing storage-core directly).
23export { StorageError, UniqueViolationError } from "@executor/storage-core";
24
20// IDs (branded)25// IDs (branded)
21export { ScopeId, ToolId, SecretId, PolicyId } from "./ids";26export { ScopeId, ToolId, SecretId, PolicyId } from "./ids";
22 27
packages/core/sdk/src/plugin.ts+35−16
@@ -2,6 +2,7 @@ import type { Effect } from "effect"; · 1 unmodified line
2import type {2import type {
3 DBAdapter,3 DBAdapter,
4 DBSchema,4 DBSchema,
5 StorageFailure,
5 TypedAdapter,6 TypedAdapter,
6} from "@executor/storage-core";7} from "@executor/storage-core";
7 8
@@ -36,7 +37,17 @@ import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; · 28 unmodified lines
36 37
37export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> {38export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> {
38 readonly scope: Scope;39 readonly scope: Scope;
39 readonly adapter: TSchema extends DBSchema ? TypedAdapter<TSchema> : DBAdapter;40 /**
41 * Plugin-facing typed adapter. Failures surface as raw `StorageFailure`
42 * (`StorageError` | `UniqueViolationError`). Plugins can
43 * `catchTag("UniqueViolationError", …)` to translate to their own
44 * user-facing errors. `StorageError` bubbles up; the HTTP edge (see
45 * `@executor/api` `withCapture`) is the one place that
46 * translates it to the opaque `InternalError({ traceId })`.
47 */
48 readonly adapter: TSchema extends DBSchema
49 ? TypedAdapter<TSchema, StorageFailure>
50 : DBAdapter;
40 readonly blobs: ScopedBlobStore;51 readonly blobs: ScopedBlobStore;
41}52}
42 53
@@ -72,8 +83,12 @@ export interface PluginCtx<TStore = unknown> { · 29 unmodified lines
72 83
73 readonly core: {84 readonly core: {
74 readonly sources: {85 readonly sources: {
75 readonly register: (input: SourceInput) => Effect.Effect<void, Error>;86 readonly register: (
76 readonly unregister: (sourceId: string) => Effect.Effect<void, Error>;87 input: SourceInput,
88 ) => Effect.Effect<void, StorageFailure>;
89 readonly unregister: (
90 sourceId: string,
91 ) => Effect.Effect<void, StorageFailure>;
77 };92 };
78 /** Register shared JSON-schema `$defs` for a source. Tool93 /** Register shared JSON-schema `$defs` for a source. Tool
79 * input/output schemas registered via `sources.register` can carry94 * input/output schemas registered via `sources.register` can carry
@@ -84,15 +99,17 @@ export interface PluginCtx<TStore = unknown> { · 4 unmodified lines
84 readonly definitions: {99 readonly definitions: {
85 readonly register: (100 readonly register: (
86 input: DefinitionsInput,101 input: DefinitionsInput,
87 ) => Effect.Effect<void, Error>;102 ) => Effect.Effect<void, StorageFailure>;
88 };103 };
89 };104 };
90 105
91 readonly secrets: {106 readonly secrets: {
92 readonly get: (id: string) => Effect.Effect<string | null, Error>;107 readonly get: (
108 id: string,
109 ) => Effect.Effect<string | null, StorageFailure>;
93 readonly list: () => Effect.Effect<110 readonly list: () => Effect.Effect<
94 readonly { readonly id: string; readonly name: string; readonly provider: string }[],111 readonly { readonly id: string; readonly name: string; readonly provider: string }[],
95 Error112 StorageFailure
96 >;113 >;
97 /** Write a secret value through a provider. Used by plugins that114 /** Write a secret value through a provider. Used by plugins that
98 * mint secrets on behalf of the user (OAuth2 token storage,115 * mint secrets on behalf of the user (OAuth2 token storage,
@@ -100,9 +117,11 @@ export interface PluginCtx<TStore = unknown> { · 1 unmodified line
100 * `executor.secrets.set` on the host surface, but OAuth2 refresh117 * `executor.secrets.set` on the host surface, but OAuth2 refresh
101 * and one-shot token capture from plugin-owned flows need it here118 * and one-shot token capture from plugin-owned flows need it here
102 * too. Same routing rules as the host-level setter. */119 * too. Same routing rules as the host-level setter. */
103 readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, Error>;120 readonly set: (
121 input: SetSecretInput,
122 ) => Effect.Effect<SecretRef, StorageFailure>;
104 /** Delete a secret from its pinned provider and the core table. */123 /** Delete a secret from its pinned provider and the core table. */
105 readonly remove: (id: string) => Effect.Effect<void, Error>;124 readonly remove: (id: string) => Effect.Effect<void, StorageFailure>;
106 };125 };
107 126
108 /** Run `effect` inside a database transaction. Wraps the underlying127 /** Run `effect` inside a database transaction. Wraps the underlying
@@ -111,7 +130,7 @@ export interface PluginCtx<TStore = unknown> { · 2 unmodified lines
111 * registration. */130 * registration. */
112 readonly transaction: <A, E>(131 readonly transaction: <A, E>(
113 effect: Effect.Effect<A, E>,132 effect: Effect.Effect<A, E>,
114 ) => Effect.Effect<A, E | Error>;133 ) => Effect.Effect<A, E | StorageFailure>;
115}134}
116 135
117// ---------------------------------------------------------------------------136// ---------------------------------------------------------------------------
@@ -146,7 +165,7 @@ export interface StaticToolDecl<TStore = unknown> { · 28 unmodified lines
146 readonly annotations?: ToolAnnotations;165 readonly annotations?: ToolAnnotations;
147 readonly handler: (166 readonly handler: (
148 input: StaticToolHandlerInput<TStore>,167 input: StaticToolHandlerInput<TStore>,
149 ) => Effect.Effect<unknown, Error>;168 ) => Effect.Effect<unknown, unknown>;
150}169}
151 170
152export interface StaticSourceDecl<TStore = unknown> {171export interface StaticSourceDecl<TStore = unknown> {
@@ -228,7 +247,7 @@ export interface PluginSpec< · 75 unmodified lines
228 * only static tools can omit it. */247 * only static tools can omit it. */
229 readonly invokeTool?: (248 readonly invokeTool?: (
230 input: InvokeToolInput<TStore>,249 input: InvokeToolInput<TStore>,
231 ) => Effect.Effect<unknown, Error>;250 ) => Effect.Effect<unknown, unknown>;
232 251
233 /** Bulk resolve annotations (requiresApproval, approvalDescription,252 /** Bulk resolve annotations (requiresApproval, approvalDescription,
234 * mayElicit) for a set of tool rows under a single source. Called253 * mayElicit) for a set of tool rows under a single source. Called
@@ -251,7 +270,7 @@ export interface PluginSpec< · 16 unmodified lines
251 readonly ctx: PluginCtx<TStore>;270 readonly ctx: PluginCtx<TStore>;
252 readonly sourceId: string;271 readonly sourceId: string;
253 readonly toolRows: readonly ToolRow[];272 readonly toolRows: readonly ToolRow[];
254 }) => Effect.Effect<Record<string, ToolAnnotations>, Error>;273 }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>;
255 274
256 /** Called when `executor.sources.remove(id)` targets a source owned275 /** Called when `executor.sources.remove(id)` targets a source owned
257 * by this plugin. Plugin-side cleanup only; the executor deletes276 * by this plugin. Plugin-side cleanup only; the executor deletes
@@ -259,11 +278,11 @@ export interface PluginSpec< · 1 unmodified line
259 * the same transaction. */278 * the same transaction. */
260 readonly removeSource?: (279 readonly removeSource?: (
261 input: SourceLifecycleInput<TStore>,280 input: SourceLifecycleInput<TStore>,
262 ) => Effect.Effect<void, Error>;281 ) => Effect.Effect<void, unknown>;
263 282
264 readonly refreshSource?: (283 readonly refreshSource?: (
265 input: SourceLifecycleInput<TStore>,284 input: SourceLifecycleInput<TStore>,
266 ) => Effect.Effect<void, Error>;285 ) => Effect.Effect<void, unknown>;
267 286
268 /** URL autodetection hook. When the user pastes a URL in the287 /** URL autodetection hook. When the user pastes a URL in the
269 * onboarding UI, `executor.sources.detect(url)` fans out to every288 * onboarding UI, `executor.sources.detect(url)` fans out to every
@@ -274,7 +293,7 @@ export interface PluginSpec< · 4 unmodified lines
274 readonly detect?: (input: {293 readonly detect?: (input: {
275 readonly ctx: PluginCtx<TStore>;294 readonly ctx: PluginCtx<TStore>;
276 readonly url: string;295 readonly url: string;
277 }) => Effect.Effect<SourceDetectionResult | null, Error>;296 }) => Effect.Effect<SourceDetectionResult | null, unknown>;
278 297
279 /** Secret providers contributed by this plugin. Either a static298 /** Secret providers contributed by this plugin. Either a static
280 * array or a function of ctx (for providers that need per-instance299 * array or a function of ctx (for providers that need per-instance
@@ -285,7 +304,7 @@ export interface PluginSpec< · 4 unmodified lines
285 | readonly SecretProvider[]304 | readonly SecretProvider[]
286 | ((ctx: PluginCtx<TStore>) => readonly SecretProvider[]);305 | ((ctx: PluginCtx<TStore>) => readonly SecretProvider[]);
287 306
288 readonly close?: () => Effect.Effect<void, Error>;307 readonly close?: () => Effect.Effect<void, unknown>;
289}308}
290 309
291export interface Plugin<310export interface Plugin<
packages/core/sdk/src/promise-executor.ts+6−0
@@ -125,6 +125,12 @@ export const createExecutor = async < · 124 unmodified lines
125 plugins,125 plugins,
126 };126 };
127 127
128 // The SDK has no observability requirement; storage failures surface
129 // as raw `StorageError` / `UniqueViolationError` in the typed channel.
130 // `Effect.runPromise` turns them into Promise rejections — consumers
131 // get the tagged error as the rejected value. See
132 // notes/promise-sdk-typed-errors.md for the planned `runPromiseExit`
133 // rewrite that exposes the full error union to consumers.
128 const effectExecutor = await Effect.runPromise(134 const effectExecutor = await Effect.runPromise(
129 createEffectExecutor(effectConfig),135 createEffectExecutor(effectConfig),
130 );136 );
packages/core/sdk/src/secrets.ts+14−6
@@ -1,5 +1,7 @@
1import { Effect, Schema } from "effect";1import { Effect, Schema } from "effect";
2 2
3import type { StorageFailure } from "@executor/storage-core";
4
3import { SecretId, ScopeId } from "./ids";5import { SecretId, ScopeId } from "./ids";
4 6
5// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
@@ -20,19 +22,25 @@ export interface SecretProvider { · 14 unmodified lines
20 * honours this before routing writes — trying to write to a22 * honours this before routing writes — trying to write to a
21 * read-only provider is an error, not a silent drop. */23 * read-only provider is an error, not a silent drop. */
22 readonly writable: boolean;24 readonly writable: boolean;
23 /** Get a secret value by id. Returns null if not found. Errors are25 /** Get a secret value by id. Returns null if not found. Failures
24 * real failures (provider unreachable, decryption failed, etc.). */26 * (provider unreachable, decryption failed, etc.) surface as
25 readonly get: (id: string) => Effect.Effect<string | null, Error>;27 * `StorageFailure` — the executor treats a provider call the same
28 * as a DB call; `StorageError` is captured at the HTTP edge to
29 * `InternalError`, `UniqueViolationError` dies. */
30 readonly get: (id: string) => Effect.Effect<string | null, StorageFailure>;
26 /** Set a secret value. Only called on writable providers. */31 /** Set a secret value. Only called on writable providers. */
27 readonly set?: (id: string, value: string) => Effect.Effect<void, Error>;32 readonly set?: (
33 id: string,
34 value: string,
35 ) => Effect.Effect<void, StorageFailure>;
28 /** Delete a secret. Only called on writable providers. Returns true36 /** Delete a secret. Only called on writable providers. Returns true
29 * if something was deleted. */37 * if something was deleted. */
30 readonly delete?: (id: string) => Effect.Effect<boolean, Error>;38 readonly delete?: (id: string) => Effect.Effect<boolean, StorageFailure>;
31 /** Enumerate known secret entries. Optional — not all backends can39 /** Enumerate known secret entries. Optional — not all backends can
32 * enumerate (env-backed providers, for example). */40 * enumerate (env-backed providers, for example). */
33 readonly list?: () => Effect.Effect<41 readonly list?: () => Effect.Effect<
34 readonly { readonly id: string; readonly name: string }[],42 readonly { readonly id: string; readonly name: string }[],
35 Error43 StorageFailure
36 >;44 >;
37}45}
38 46
packages/core/storage-core/src/adapter.ts+28−24
@@ -5,8 +5,12 @@ · 4 unmodified lines
5 5
6import type { Effect } from "effect";6import type { Effect } from "effect";
7 7
8import type { StorageError, UniqueViolationError } from "./errors";
8import type { DBFieldAttribute, DBSchema } from "./schema";9import type { DBFieldAttribute, DBSchema } from "./schema";
9 10
11/** Error union every backend method may emit. */
12export type StorageFailure = StorageError | UniqueViolationError;
13
10// ---------------------------------------------------------------------------14// ---------------------------------------------------------------------------
11// Where clauses15// Where clauses
12// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
@@ -102,7 +106,7 @@ export type DBAdapter = { · 89 unmodified lines
102 select?: string[] | undefined;106 select?: string[] | undefined;
103 /** Preserve an `id` in `data` instead of discarding it. */107 /** Preserve an `id` in `data` instead of discarding it. */
104 forceAllowId?: boolean | undefined;108 forceAllowId?: boolean | undefined;
105 }) => Effect.Effect<R, Error>;109 }) => Effect.Effect<R, StorageFailure>;
106 110
107 /**111 /**
108 * Insert multiple rows in one call. Backends that don't have native112 * Insert multiple rows in one call. Backends that don't have native
@@ -113,14 +117,14 @@ export type DBAdapter = { · 4 unmodified lines
113 model: string;117 model: string;
114 data: ReadonlyArray<Omit<T, "id">>;118 data: ReadonlyArray<Omit<T, "id">>;
115 forceAllowId?: boolean | undefined;119 forceAllowId?: boolean | undefined;
116 }) => Effect.Effect<readonly R[], Error>;120 }) => Effect.Effect<readonly R[], StorageFailure>;
117 121
118 findOne: <T>(data: {122 findOne: <T>(data: {
119 model: string;123 model: string;
120 where: Where[];124 where: Where[];
121 select?: string[] | undefined;125 select?: string[] | undefined;
122 join?: JoinOption | undefined;126 join?: JoinOption | undefined;
123 }) => Effect.Effect<T | null, Error>;127 }) => Effect.Effect<T | null, StorageFailure>;
124 128
125 findMany: <T>(data: {129 findMany: <T>(data: {
126 model: string;130 model: string;
@@ -135,12 +139,12 @@ export type DBAdapter = { · 8 unmodified lines
135 | undefined;139 | undefined;
136 offset?: number | undefined;140 offset?: number | undefined;
137 join?: JoinOption | undefined;141 join?: JoinOption | undefined;
138 }) => Effect.Effect<readonly T[], Error>;142 }) => Effect.Effect<readonly T[], StorageFailure>;
139 143
140 count: (data: {144 count: (data: {
141 model: string;145 model: string;
142 where?: Where[] | undefined;146 where?: Where[] | undefined;
143 }) => Effect.Effect<number, Error>;147 }) => Effect.Effect<number, StorageFailure>;
144 148
145 /**149 /**
146 * ⚠︎ `update` may return `null` if multiple rows match — prefer `updateMany`150 * ⚠︎ `update` may return `null` if multiple rows match — prefer `updateMany`
@@ -150,17 +154,17 @@ export type DBAdapter = { · 3 unmodified lines
150 model: string;154 model: string;
151 where: Where[];155 where: Where[];
152 update: Record<string, unknown>;156 update: Record<string, unknown>;
153 }) => Effect.Effect<T | null, Error>;157 }) => Effect.Effect<T | null, StorageFailure>;
154 158
155 updateMany: (data: {159 updateMany: (data: {
156 model: string;160 model: string;
157 where: Where[];161 where: Where[];
158 update: Record<string, unknown>;162 update: Record<string, unknown>;
159 }) => Effect.Effect<number, Error>;163 }) => Effect.Effect<number, StorageFailure>;
160 164
161 delete: (data: { model: string; where: Where[] }) => Effect.Effect<void, Error>;165 delete: (data: { model: string; where: Where[] }) => Effect.Effect<void, StorageFailure>;
162 166
163 deleteMany: (data: { model: string; where: Where[] }) => Effect.Effect<number, Error>;167 deleteMany: (data: { model: string; where: Where[] }) => Effect.Effect<number, StorageFailure>;
164 168
165 /**169 /**
166 * Run operations in a transaction. Backends without transaction support170 * Run operations in a transaction. Backends without transaction support
@@ -168,7 +172,7 @@ export type DBAdapter = { · 1 unmodified line
168 */172 */
169 transaction: <R, E>(173 transaction: <R, E>(
170 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,174 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,
171 ) => Effect.Effect<R, E | Error>;175 ) => Effect.Effect<R, E | StorageFailure>;
172 176
173 /**177 /**
174 * Optional migration-source generator. Forwarded verbatim from the178 * Optional migration-source generator. Forwarded verbatim from the
@@ -179,7 +183,7 @@ export type DBAdapter = { · 4 unmodified lines
179 | ((props: {183 | ((props: {
180 file?: string | undefined;184 file?: string | undefined;
181 tables: DBSchema;185 tables: DBSchema;
182 }) => Effect.Effect<DBAdapterSchemaCreation, Error>)186 }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>)
183 | undefined;187 | undefined;
184 188
185 /**189 /**
@@ -205,7 +209,7 @@ export interface CustomAdapter { · 19 unmodified lines
205 model: string;209 model: string;
206 data: T;210 data: T;
207 select?: string[] | undefined;211 select?: string[] | undefined;
208 }) => Effect.Effect<T, Error>;212 }) => Effect.Effect<T, StorageFailure>;
209 213
210 /**214 /**
211 * Native bulk insert. Required because over a real network (e.g.215 * Native bulk insert. Required because over a real network (e.g.
@@ -217,26 +221,26 @@ export interface CustomAdapter { · 5 unmodified lines
217 createMany: <T extends Record<string, unknown>>(data: {221 createMany: <T extends Record<string, unknown>>(data: {
218 model: string;222 model: string;
219 data: ReadonlyArray<T>;223 data: ReadonlyArray<T>;
220 }) => Effect.Effect<T[], Error>;224 }) => Effect.Effect<T[], StorageFailure>;
221 225
222 update: <T>(data: {226 update: <T>(data: {
223 model: string;227 model: string;
224 where: CleanedWhere[];228 where: CleanedWhere[];
225 update: T;229 update: T;
226 }) => Effect.Effect<T | null, Error>;230 }) => Effect.Effect<T | null, StorageFailure>;
227 231
228 updateMany: (data: {232 updateMany: (data: {
229 model: string;233 model: string;
230 where: CleanedWhere[];234 where: CleanedWhere[];
231 update: Record<string, unknown>;235 update: Record<string, unknown>;
232 }) => Effect.Effect<number, Error>;236 }) => Effect.Effect<number, StorageFailure>;
233 237
234 findOne: <T>(data: {238 findOne: <T>(data: {
235 model: string;239 model: string;
236 where: CleanedWhere[];240 where: CleanedWhere[];
237 select?: string[] | undefined;241 select?: string[] | undefined;
238 join?: JoinConfig | undefined;242 join?: JoinConfig | undefined;
239 }) => Effect.Effect<T | null, Error>;243 }) => Effect.Effect<T | null, StorageFailure>;
240 244
241 findMany: <T>(data: {245 findMany: <T>(data: {
242 model: string;246 model: string;
@@ -246,22 +250,22 @@ export interface CustomAdapter { · 3 unmodified lines
246 sortBy?: { field: string; direction: "asc" | "desc" } | undefined;250 sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
247 offset?: number | undefined;251 offset?: number | undefined;
248 join?: JoinConfig | undefined;252 join?: JoinConfig | undefined;
249 }) => Effect.Effect<T[], Error>;253 }) => Effect.Effect<T[], StorageFailure>;
250 254
251 delete: (data: {255 delete: (data: {
252 model: string;256 model: string;
253 where: CleanedWhere[];257 where: CleanedWhere[];
254 }) => Effect.Effect<void, Error>;258 }) => Effect.Effect<void, StorageFailure>;
255 259
256 deleteMany: (data: {260 deleteMany: (data: {
257 model: string;261 model: string;
258 where: CleanedWhere[];262 where: CleanedWhere[];
259 }) => Effect.Effect<number, Error>;263 }) => Effect.Effect<number, StorageFailure>;
260 264
261 count: (data: {265 count: (data: {
262 model: string;266 model: string;
263 where?: CleanedWhere[] | undefined;267 where?: CleanedWhere[] | undefined;
264 }) => Effect.Effect<number, Error>;268 }) => Effect.Effect<number, StorageFailure>;
265 269
266 /**270 /**
267 * Optional migration-source generator — plugin authors use this to271 * Optional migration-source generator — plugin authors use this to
@@ -272,7 +276,7 @@ export interface CustomAdapter { · 4 unmodified lines
272 | ((props: {276 | ((props: {
273 file?: string | undefined;277 file?: string | undefined;
274 tables: DBSchema;278 tables: DBSchema;
275 }) => Effect.Effect<DBAdapterSchemaCreation, Error>)279 }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>)
276 | undefined;280 | undefined;
277 281
278 options?: Record<string, unknown> | undefined;282 options?: Record<string, unknown> | undefined;
@@ -321,7 +325,7 @@ export interface DBAdapterFactoryConfig { · 42 unmodified lines
321 | false325 | false
322 | (<R, E>(326 | (<R, E>(
323 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,327 callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>,
324 ) => Effect.Effect<R, E | Error>)328 ) => Effect.Effect<R, E | StorageFailure>)
325 )329 )
326 | undefined;330 | undefined;
327 /** Skip id generation on `create`. @default false */331 /** Skip id generation on `create`. @default false */
@@ -354,7 +358,7 @@ export interface DBAdapterFactoryConfig { · 26 unmodified lines
354 | "count";358 | "count";
355 model: string;359 model: string;
356 schema: DBSchema;360 schema: DBSchema;
357 }) => Effect.Effect<unknown, Error>)361 }) => Effect.Effect<unknown, StorageFailure>)
358 | undefined;362 | undefined;
359 /**363 /**
360 * Per-field hook run inside the factory's read-path transform. Called364 * Per-field hook run inside the factory's read-path transform. Called
@@ -369,7 +373,7 @@ export interface DBAdapterFactoryConfig { · 8 unmodified lines
369 select: readonly string[];373 select: readonly string[];
370 model: string;374 model: string;
371 schema: DBSchema;375 schema: DBSchema;
372 }) => Effect.Effect<unknown, Error>)376 }) => Effect.Effect<unknown, StorageFailure>)
373 | undefined;377 | undefined;
374 /** Escape hatch — skip write-path transform entirely. @default false */378 /** Escape hatch — skip write-path transform entirely. @default false */
375 disableTransformInput?: boolean | undefined;379 disableTransformInput?: boolean | undefined;
packages/core/storage-core/src/errors.ts+42−0
@@ -0,0 +1,42 @@
1// ---------------------------------------------------------------------------
2// Storage-layer typed errors.
3//
4// Both are `Data.TaggedError` — runtime values, not wire schemas.
5// Storage-core stays out of HTTP / serialisation / telemetry concerns;
6// the HTTP edge (`@executor/api` `withCapture`) is the one place
7// that translates `StorageError` into the opaque public
8// `InternalError({ traceId })`. Plugins `Effect.catchTag("UniqueViolationError")`
9// and re-fail with their own schema'd error (e.g. `McpSourceAlreadyExistsError`).
10//
11// The `Data` choice (vs `Schema.TaggedError`) is enforcement: it's
12// physically impossible to `addError(...)` these on an HttpApi group, so
13// nobody can accidentally leak storage-layer details to clients by
14// letting them serialize through.
15// ---------------------------------------------------------------------------
16
17import { Data } from "effect";
18
19/**
20 * Catch-all for non-recoverable backend failures (driver crash, network
21 * gone, transaction abort the backend can't classify, etc.). The cause
22 * travels as runtime data so the HTTP edge can capture it via
23 * `ErrorCapture` before translating to the public `InternalError`.
24 */
25export class StorageError extends Data.TaggedError("StorageError")<{
26 readonly message: string;
27 readonly cause: unknown;
28}> {}
29
30/**
31 * Typed unique-constraint violation. Plugins are expected to
32 * `Effect.catchTag` this and translate to their own user-facing error.
33 * Carries an optional `model` so a plugin doing a batch insert across
34 * tables can disambiguate; everything else (constraint name, raw
35 * driver message) stays internal because plugin code rarely needs it
36 * and surfacing it leaks backend specifics.
37 */
38export class UniqueViolationError extends Data.TaggedError(
39 "UniqueViolationError",
40)<{
41 readonly model?: string;
42}> {}
packages/core/storage-core/src/factory.ts+25−16
@@ -3,7 +3,7 @@ · 2 unmodified lines
3//3//
4// Vendored from better-auth (packages/core/src/db/adapter/factory.ts) under4// Vendored from better-auth (packages/core/src/db/adapter/factory.ts) under
5// MIT. Adapted for executor:5// MIT. Adapted for executor:
6// - Promise/async → Effect.Effect<T, Error>6// - Promise/async → Effect.Effect<T, StorageFailure>
7// - Stripped auth-specific concerns (numeric serial ids, joins, telemetry7// - Stripped auth-specific concerns (numeric serial ids, joins, telemetry
8// spans, logger, plural model name resolution, BetterAuthOptions)8// spans, logger, plural model name resolution, BetterAuthOptions)
9// - Contract matches our CustomAdapter + DBAdapterFactoryConfig in9// - Contract matches our CustomAdapter + DBAdapterFactoryConfig in
@@ -35,8 +35,10 @@ import type { · 25 unmodified lines
35 DBTransactionAdapter,35 DBTransactionAdapter,
36 JoinConfig,36 JoinConfig,
37 JoinOption,37 JoinOption,
38 StorageFailure,
38 Where,39 Where,
39} from "./adapter";40} from "./adapter";
41import { StorageError } from "./errors";
40import type { DBFieldAttribute, DBSchema, DBPrimitive } from "./schema";42import type { DBFieldAttribute, DBSchema, DBPrimitive } from "./schema";
41 43
42// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
@@ -124,12 +126,17 @@ export const createAdapter = ( · 81 unmodified lines
124 return defaultGenerateId();126 return defaultGenerateId();
125 };127 };
126 128
127 const getModelDef = (model: string): Effect.Effect<DBSchema[string], Error> =>129 const getModelDef = (
130 model: string,
131 ): Effect.Effect<DBSchema[string], StorageError> =>
128 Effect.gen(function* () {132 Effect.gen(function* () {
129 const def = schema[model];133 const def = schema[model];
130 if (!def) {134 if (!def) {
131 return yield* Effect.fail(135 return yield* Effect.fail(
132 new Error(`[storage-core] unknown model "${model}"`),136 new StorageError({
137 message: `[storage-core] unknown model "${model}"`,
138 cause: undefined,
139 }),
133 );140 );
134 }141 }
135 return def;142 return def;
@@ -258,7 +265,7 @@ export const createAdapter = ( · 122 unmodified lines
258 data: Record<string, unknown>,265 data: Record<string, unknown>,
259 action: "create" | "update",266 action: "create" | "update",
260 forceAllowId: boolean,267 forceAllowId: boolean,
261 ): Effect.Effect<Record<string, unknown>, Error> =>268 ): Effect.Effect<Record<string, unknown>, StorageFailure> =>
262 Effect.gen(function* () {269 Effect.gen(function* () {
263 const def = yield* getModelDef(model);270 const def = yield* getModelDef(model);
264 const out: Record<string, unknown> = {};271 const out: Record<string, unknown> = {};
@@ -303,10 +310,11 @@ export const createAdapter = ( · 38 unmodified lines
303 if (res && typeof (res as { then?: unknown }).then === "function") {310 if (res && typeof (res as { then?: unknown }).then === "function") {
304 value = yield* Effect.tryPromise({311 value = yield* Effect.tryPromise({
305 try: () => res as Promise<DBPrimitive>,312 try: () => res as Promise<DBPrimitive>,
306 catch: (e) =>313 catch: (cause) =>
307 new Error(314 new StorageError({
308 `[storage-core] transform.input for "${model}.${logical}" failed: ${String(e)}`,315 message: `[storage-core] transform.input for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
309 ),316 cause,
317 }),
310 });318 });
311 } else {319 } else {
312 value = res;320 value = res;
@@ -347,7 +355,7 @@ export const createAdapter = ( · 34 unmodified lines
347 model: string,355 model: string,
348 row: Record<string, unknown> | null,356 row: Record<string, unknown> | null,
349 select?: string[],357 select?: string[],
350 ): Effect.Effect<Record<string, unknown> | null, Error> =>358 ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
351 Effect.gen(function* () {359 Effect.gen(function* () {
352 if (row === null || row === undefined) return null;360 if (row === null || row === undefined) return null;
353 const def = yield* getModelDef(model);361 const def = yield* getModelDef(model);
@@ -377,10 +385,11 @@ export const createAdapter = ( · 23 unmodified lines
377 if (res && typeof (res as { then?: unknown }).then === "function") {385 if (res && typeof (res as { then?: unknown }).then === "function") {
378 value = yield* Effect.tryPromise({386 value = yield* Effect.tryPromise({
379 try: () => res as Promise<DBPrimitive>,387 try: () => res as Promise<DBPrimitive>,
380 catch: (e) =>388 catch: (cause) =>
381 new Error(389 new StorageError({
382 `[storage-core] transform.output for "${model}.${logical}" failed: ${String(e)}`,390 message: `[storage-core] transform.output for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
383 ),391 cause,
392 }),
384 });393 });
385 } else {394 } else {
386 value = res;395 value = res;
@@ -532,7 +541,7 @@ export const createAdapter = ( · 145 unmodified lines
532 data: Record<string, unknown>,541 data: Record<string, unknown>,
533 action: "create" | "update",542 action: "create" | "update",
534 forceAllowId: boolean,543 forceAllowId: boolean,
535 ): Effect.Effect<Record<string, unknown>, Error> =>544 ): Effect.Effect<Record<string, unknown>, StorageFailure> =>
536 config.disableTransformInput545 config.disableTransformInput
537 ? Effect.succeed(data)546 ? Effect.succeed(data)
538 : transformInput(model, data, action, forceAllowId);547 : transformInput(model, data, action, forceAllowId);
@@ -551,7 +560,7 @@ export const createAdapter = ( · 12 unmodified lines
551 base: Record<string, unknown> | null,560 base: Record<string, unknown> | null,
552 raw: Record<string, unknown> | null,561 raw: Record<string, unknown> | null,
553 join: JoinOption | undefined,562 join: JoinOption | undefined,
554 ): Effect.Effect<Record<string, unknown> | null, Error> =>563 ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
555 Effect.gen(function* () {564 Effect.gen(function* () {
556 if (!base || !raw || !join) return base;565 if (!base || !raw || !join) return base;
557 const merged: Record<string, unknown> = { ...base };566 const merged: Record<string, unknown> = { ...base };
@@ -593,7 +602,7 @@ export const createAdapter = ( · 35 unmodified lines
593 model: string,602 model: string,
594 row: Record<string, unknown> | null,603 row: Record<string, unknown> | null,
595 select?: string[],604 select?: string[],
596 ): Effect.Effect<Record<string, unknown> | null, Error> =>605 ): Effect.Effect<Record<string, unknown> | null, StorageFailure> =>
597 config.disableTransformOutput606 config.disableTransformOutput
598 ? Effect.succeed(row)607 ? Effect.succeed(row)
599 : transformOutput(model, row, select);608 : transformOutput(model, row, select);
packages/core/storage-core/src/index.ts+3−0
@@ -15,6 +15,8 @@ export { typedAdapter, type TypedAdapter } from "./typed"; · 14 unmodified lines
15 15
16export { createAdapter, type CreateAdapterOptions } from "./factory";16export { createAdapter, type CreateAdapterOptions } from "./factory";
17 17
18export { StorageError, UniqueViolationError } from "./errors";
19
18export {20export {
19 whereOperators,21 whereOperators,
20 type WhereOperator,22 type WhereOperator,
@@ -28,4 +30,5 @@ export { · 7 unmodified lines
28 type DBAdapterDebugLogOption,30 type DBAdapterDebugLogOption,
29 type DBAdapterFactoryConfig,31 type DBAdapterFactoryConfig,
30 type DBAdapterSchemaCreation,32 type DBAdapterSchemaCreation,
33 type StorageFailure,
31} from "./adapter";34} from "./adapter";
packages/core/storage-core/src/testing/memory.ts+2−1
@@ -22,6 +22,7 @@ import type { · 21 unmodified lines
22 DBAdapter,22 DBAdapter,
23 DBAdapterFactoryConfig,23 DBAdapterFactoryConfig,
24 JoinConfig,24 JoinConfig,
25 StorageFailure,
25} from "../adapter";26} from "../adapter";
26import type { DBSchema } from "../schema";27import type { DBSchema } from "../schema";
27import { createAdapter } from "../factory";28import { createAdapter } from "../factory";
@@ -266,7 +267,7 @@ export const makeMemoryAdapter = ( · 238 unmodified lines
266 }),267 }),
267 );268 );
268 return result;269 return result;
269 }) as Effect.Effect<R, E | Error>;270 }) as Effect.Effect<R, E | StorageFailure>;
270 271
271 const adapter: DBAdapter = createAdapter({272 const adapter: DBAdapter = createAdapter({
272 schema: options.schema,273 schema: options.schema,
packages/core/storage-core/src/typed.ts+19−11
@@ -17,7 +17,7 @@ · 16 unmodified lines
17 17
18import type { Effect } from "effect";18import type { Effect } from "effect";
19 19
20import type { DBAdapter, Where } from "./adapter";20import type { DBAdapter, StorageFailure, Where } from "./adapter";
21import type {21import type {
22 DBSchema,22 DBSchema,
23 InferDBFieldsInput,23 InferDBFieldsInput,
@@ -34,25 +34,33 @@ type RowOutput<S extends DBSchema, M extends keyof S> = InferDBFieldsOutput< · 10 unmodified lines
34> &34> &
35 Record<string, unknown>;35 Record<string, unknown>;
36 36
37export interface TypedAdapter<S extends DBSchema> {37/**
38 * Schema-typed view over a `DBAdapter`. The error parameter `E`
39 * defaults to `StorageFailure` (`StorageError | UniqueViolationError`) —
40 * that's what backends emit and what plugin code sees. Translation
41 * to the opaque public `InternalError({ traceId })` is done only at
42 * the HTTP edge (`@executor/api` `withCapture`); everyone else
43 * works with the raw tag.
44 */
45export interface TypedAdapter<S extends DBSchema, E = StorageFailure> {
38 readonly raw: DBAdapter;46 readonly raw: DBAdapter;
39 47
40 readonly create: <M extends keyof S & string>(data: {48 readonly create: <M extends keyof S & string>(data: {
41 model: M;49 model: M;
42 data: Omit<RowInput<S, M>, "id"> & { id?: string };50 data: Omit<RowInput<S, M>, "id"> & { id?: string };
43 forceAllowId?: boolean;51 forceAllowId?: boolean;
44 }) => Effect.Effect<RowOutput<S, M>, Error>;52 }) => Effect.Effect<RowOutput<S, M>, E>;
45 53
46 readonly createMany: <M extends keyof S & string>(data: {54 readonly createMany: <M extends keyof S & string>(data: {
47 model: M;55 model: M;
48 data: ReadonlyArray<Omit<RowInput<S, M>, "id"> & { id?: string }>;56 data: ReadonlyArray<Omit<RowInput<S, M>, "id"> & { id?: string }>;
49 forceAllowId?: boolean;57 forceAllowId?: boolean;
50 }) => Effect.Effect<readonly RowOutput<S, M>[], Error>;58 }) => Effect.Effect<readonly RowOutput<S, M>[], E>;
51 59
52 readonly findOne: <M extends keyof S & string>(data: {60 readonly findOne: <M extends keyof S & string>(data: {
53 model: M;61 model: M;
54 where: Where[];62 where: Where[];
55 }) => Effect.Effect<RowOutput<S, M> | null, Error>;63 }) => Effect.Effect<RowOutput<S, M> | null, E>;
56 64
57 readonly findMany: <M extends keyof S & string>(data: {65 readonly findMany: <M extends keyof S & string>(data: {
58 model: M;66 model: M;
@@ -60,34 +68,34 @@ export interface TypedAdapter<S extends DBSchema> { · 1 unmodified line
60 limit?: number;68 limit?: number;
61 sortBy?: { field: string; direction: "asc" | "desc" };69 sortBy?: { field: string; direction: "asc" | "desc" };
62 offset?: number;70 offset?: number;
63 }) => Effect.Effect<readonly RowOutput<S, M>[], Error>;71 }) => Effect.Effect<readonly RowOutput<S, M>[], E>;
64 72
65 readonly update: <M extends keyof S & string>(data: {73 readonly update: <M extends keyof S & string>(data: {
66 model: M;74 model: M;
67 where: Where[];75 where: Where[];
68 update: Partial<RowInput<S, M>>;76 update: Partial<RowInput<S, M>>;
69 }) => Effect.Effect<RowOutput<S, M> | null, Error>;77 }) => Effect.Effect<RowOutput<S, M> | null, E>;
70 78
71 readonly updateMany: <M extends keyof S & string>(data: {79 readonly updateMany: <M extends keyof S & string>(data: {
72 model: M;80 model: M;
73 where: Where[];81 where: Where[];
74 update: Partial<RowInput<S, M>>;82 update: Partial<RowInput<S, M>>;
75 }) => Effect.Effect<number, Error>;83 }) => Effect.Effect<number, E>;
76 84
77 readonly delete: <M extends keyof S & string>(data: {85 readonly delete: <M extends keyof S & string>(data: {
78 model: M;86 model: M;
79 where: Where[];87 where: Where[];
80 }) => Effect.Effect<void, Error>;88 }) => Effect.Effect<void, E>;
81 89
82 readonly deleteMany: <M extends keyof S & string>(data: {90 readonly deleteMany: <M extends keyof S & string>(data: {
83 model: M;91 model: M;
84 where: Where[];92 where: Where[];
85 }) => Effect.Effect<number, Error>;93 }) => Effect.Effect<number, E>;
86 94
87 readonly count: <M extends keyof S & string>(data: {95 readonly count: <M extends keyof S & string>(data: {
88 model: M;96 model: M;
89 where?: Where[];97 where?: Where[];
90 }) => Effect.Effect<number, Error>;98 }) => Effect.Effect<number, E>;
91}99}
92 100
93/**101/**
packages/core/storage-drizzle/src/adapter.ts+162−61
@@ -3,7 +3,7 @@ · 2 unmodified lines
3//3//
4// Vendored from better-auth (packages/drizzle-adapter/src/drizzle-adapter.ts)4// Vendored from better-auth (packages/drizzle-adapter/src/drizzle-adapter.ts)
5// under MIT. Adapted for executor:5// under MIT. Adapted for executor:
6// - Promise/async → Effect.Effect<T, Error>6// - Promise/async → Effect.Effect<T, StorageFailure>
7// - Tables read from `db._.fullSchema` (drizzle schema introspection)7// - Tables read from `db._.fullSchema` (drizzle schema introspection)
8// - Relational queries via `db.query[model]` for join resolution8// - Relational queries via `db.query[model]` for join resolution
9// - Filter/compile logic matches our CleanedWhere shape directly9// - Filter/compile logic matches our CleanedWhere shape directly
@@ -41,7 +41,16 @@ import type { · 31 unmodified lines
41 DBSchema,41 DBSchema,
42 JoinConfig,42 JoinConfig,
43} from "@executor/storage-core";43} from "@executor/storage-core";
44import { createAdapter } from "@executor/storage-core";44import {
45 StorageError,
46 UniqueViolationError,
47 createAdapter,
48} from "@executor/storage-core";
49
50// Mirrors `StorageFailure` from @executor/storage-core/adapter — kept
51// local so we don't force a new named export on the public index. Both
52// constructors are already exported, so the union is reconstructible.
53type StorageFailure = StorageError | UniqueViolationError;
45 54
46// ---------------------------------------------------------------------------55// ---------------------------------------------------------------------------
47// Types56// Types
@@ -227,18 +236,54 @@ const buildIncludes = ( · 179 unmodified lines
227 236
228// ---------------------------------------------------------------------------237// ---------------------------------------------------------------------------
229// Promise → Effect helper238// Promise → Effect helper
239//
240// Classifies postgres driver errors: `23505` (unique_violation) maps to
241// `UniqueViolationError`, everything else to `StorageError`. Sqlite
242// raises a `SQLITE_CONSTRAINT_UNIQUE` via a synchronous throw; we detect
243// that via the message since better-sqlite3's error code is on the
244// object too.
230// ---------------------------------------------------------------------------245// ---------------------------------------------------------------------------
231 246
247const isUniqueViolation = (cause: unknown): boolean => {
248 if (!cause || typeof cause !== "object") return false;
249 const c = cause as { code?: unknown; message?: unknown };
250 if (c.code === "23505") return true;
251 if (typeof c.code === "string" && c.code === "SQLITE_CONSTRAINT_UNIQUE") {
252 return true;
253 }
254 if (
255 typeof c.message === "string" &&
256 /unique constraint|UNIQUE constraint failed/i.test(c.message)
257 ) {
258 return true;
259 }
260 return false;
261};
262
263const classifyError = (
264 op: string,
265 model: string | undefined,
266 cause: unknown,
267): StorageFailure => {
268 if (isUniqueViolation(cause)) {
269 return model !== undefined
270 ? new UniqueViolationError({ model })
271 : new UniqueViolationError({});
272 }
273 return new StorageError({
274 message: `[storage-drizzle] ${op} failed: ${cause instanceof Error ? cause.message : String(cause)}`,
275 cause,
276 });
277};
278
232const runPromise = <T>(279const runPromise = <T>(
233 op: string,280 op: string,
234 fn: () => Promise<T>,281 fn: () => Promise<T>,
235): Effect.Effect<T, Error> =>282 model?: string,
283): Effect.Effect<T, StorageFailure> =>
236 Effect.tryPromise({284 Effect.tryPromise({
237 try: fn,285 try: fn,
238 catch: (e) =>286 catch: (cause) => classifyError(op, model, cause),
239 new Error(
240 `[storage-drizzle] ${op} failed: ${e instanceof Error ? e.message : String(e)}`,
241 ),
242 });287 });
243 288
244// ---------------------------------------------------------------------------289// ---------------------------------------------------------------------------
@@ -254,33 +299,46 @@ const withReturning = ( · 9 unmodified lines
254 // eslint-disable-next-line @typescript-eslint/no-explicit-any299 // eslint-disable-next-line @typescript-eslint/no-explicit-any
255 builder: any,300 builder: any,
256 data: Record<string, unknown>,301 data: Record<string, unknown>,
257): Effect.Effect<Record<string, unknown>, Error> =>302 model: string,
303): Effect.Effect<Record<string, unknown>, StorageFailure> =>
258 Effect.gen(function* () {304 Effect.gen(function* () {
259 if (provider === "mysql") {305 if (provider === "mysql") {
260 yield* runPromise("mysql insert execute", () => builder.execute());306 yield* runPromise("mysql insert execute", () => builder.execute(), model);
261 // best-effort: look up by id if present307 // best-effort: look up by id if present
262 if (data.id !== undefined) {308 if (data.id !== undefined) {
263 const rows = (yield* runPromise("mysql select after insert", () =>309 const rows = (yield* runPromise(
264 db.select().from(table).where(eq(table.id, data.id)).limit(1),310 "mysql select after insert",
311 () => db.select().from(table).where(eq(table.id, data.id)).limit(1),
312 model,
265 )) as Record<string, unknown>[];313 )) as Record<string, unknown>[];
266 if (!rows[0])314 if (!rows[0])
267 return yield* Effect.fail(315 return yield* Effect.fail(
268 new Error("[storage-drizzle] mysql insert: no row returned"),316 new StorageError({
317 message: "[storage-drizzle] mysql insert: no row returned",
318 cause: undefined,
319 }),
269 );320 );
270 return rows[0];321 return rows[0];
271 }322 }
272 return yield* Effect.fail(323 return yield* Effect.fail(
273 new Error(324 new StorageError({
274 "[storage-drizzle] mysql insert: id not provided, cannot recover row",325 message:
275 ),326 "[storage-drizzle] mysql insert: id not provided, cannot recover row",
327 cause: undefined,
328 }),
276 );329 );
277 }330 }
278 const rows = (yield* runPromise("insert returning", () =>331 const rows = (yield* runPromise(
279 builder.returning(),332 "insert returning",
333 () => builder.returning(),
334 model,
280 )) as Record<string, unknown>[];335 )) as Record<string, unknown>[];
281 if (!rows[0])336 if (!rows[0])
282 return yield* Effect.fail(337 return yield* Effect.fail(
283 new Error("[storage-drizzle] insert returned no rows"),338 new StorageError({
339 message: "[storage-drizzle] insert returned no rows",
340 cause: undefined,
341 }),
284 );342 );
285 return rows[0];343 return rows[0];
286 });344 });
@@ -308,7 +366,14 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 21 unmodified lines
308 Effect.gen(function* () {366 Effect.gen(function* () {
309 const table = getTable(model);367 const table = getTable(model);
310 const builder = db.insert(table).values(data);368 const builder = db.insert(table).values(data);
311 const row = yield* withReturning(db, provider, table, builder, data);369 const row = yield* withReturning(
370 db,
371 provider,
372 table,
373 builder,
374 data,
375 model,
376 );
312 return row as never;377 return row as never;
313 }),378 }),
314 379
@@ -328,8 +393,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 13 unmodified lines
328 const all: Record<string, unknown>[] = [];393 const all: Record<string, unknown>[] = [];
329 for (let i = 0; i < data.length; i += CHUNK) {394 for (let i = 0; i < data.length; i += CHUNK) {
330 const slice = data.slice(i, i + CHUNK) as Record<string, unknown>[];395 const slice = data.slice(i, i + CHUNK) as Record<string, unknown>[];
331 const rows = (yield* runPromise("insert many returning", () =>396 const rows = (yield* runPromise(
332 db.insert(table).values(slice).returning(),397 "insert many returning",
398 () => db.insert(table).values(slice).returning(),
399 model,
333 )) as Record<string, unknown>[];400 )) as Record<string, unknown>[];
334 for (const row of rows) all.push(row);401 for (const row of rows) all.push(row);
335 }402 }
@@ -342,20 +409,25 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
342 const clause = compileWhere(table, where, provider);409 const clause = compileWhere(table, where, provider);
343 if (join && db.query && db.query[model]) {410 if (join && db.query && db.query[model]) {
344 const includes = buildIncludes(join);411 const includes = buildIncludes(join);
345 const rows = (yield* runPromise("findOne query.findFirst", () =>412 const rows = (yield* runPromise(
346 Promise.resolve(413 "findOne query.findFirst",
347 db.query[model].findFirst({414 () =>
348 where: clause,415 Promise.resolve(
349 with: includes,416 db.query[model].findFirst({
350 }),417 where: clause,
351 ),418 with: includes,
419 }),
420 ),
421 model,
352 )) as Record<string, unknown> | undefined;422 )) as Record<string, unknown> | undefined;
353 return (rows ?? null) as never;423 return (rows ?? null) as never;
354 }424 }
355 let q = db.select().from(table);425 let q = db.select().from(table);
356 if (clause) q = q.where(clause);426 if (clause) q = q.where(clause);
357 const rows = (yield* runPromise("findOne select", () =>427 const rows = (yield* runPromise(
358 q.limit(1),428 "findOne select",
429 () => q.limit(1),
430 model,
359 )) as Record<string, unknown>[];431 )) as Record<string, unknown>[];
360 return (rows[0] ?? null) as never;432 return (rows[0] ?? null) as never;
361 }),433 }),
@@ -378,8 +450,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines
378 const fn = sortBy.direction === "desc" ? desc : asc;450 const fn = sortBy.direction === "desc" ? desc : asc;
379 opts.orderBy = [fn(col)];451 opts.orderBy = [fn(col)];
380 }452 }
381 const rows = (yield* runPromise("findMany query.findMany", () =>453 const rows = (yield* runPromise(
382 Promise.resolve(db.query[model].findMany(opts)),454 "findMany query.findMany",
455 () => Promise.resolve(db.query[model].findMany(opts)),
456 model,
383 )) as Record<string, unknown>[];457 )) as Record<string, unknown>[];
384 return rows as never[];458 return rows as never[];
385 }459 }
@@ -397,8 +471,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 11 unmodified lines
397 else if (offset !== undefined && provider === "sqlite")471 else if (offset !== undefined && provider === "sqlite")
398 q = q.limit(Number.MAX_SAFE_INTEGER);472 q = q.limit(Number.MAX_SAFE_INTEGER);
399 if (offset !== undefined) q = q.offset(offset);473 if (offset !== undefined) q = q.offset(offset);
400 const rows = (yield* runPromise("findMany select", () =>474 const rows = (yield* runPromise(
401 Promise.resolve(q),475 "findMany select",
476 () => Promise.resolve(q),
477 model,
402 )) as Record<string, unknown>[];478 )) as Record<string, unknown>[];
403 return rows as never[];479 return rows as never[];
404 }),480 }),
@@ -409,8 +485,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines
409 const clause = compileWhere(table, where, provider);485 const clause = compileWhere(table, where, provider);
410 let q = db.select({ c: count() }).from(table);486 let q = db.select({ c: count() }).from(table);
411 if (clause) q = q.where(clause);487 if (clause) q = q.where(clause);
412 const rows = (yield* runPromise("count select", () =>488 const rows = (yield* runPromise(
413 Promise.resolve(q),489 "count select",
490 () => Promise.resolve(q),
491 model,
414 )) as { c: number | string | bigint }[];492 )) as { c: number | string | bigint }[];
415 const raw = rows[0]?.c ?? 0;493 const raw = rows[0]?.c ?? 0;
416 return typeof raw === "number" ? raw : Number(raw);494 return typeof raw === "number" ? raw : Number(raw);
@@ -423,22 +501,32 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
423 // Find matching rows first — if >1, DBAdapter contract says null501 // Find matching rows first — if >1, DBAdapter contract says null
424 let findQ = db.select().from(table);502 let findQ = db.select().from(table);
425 if (clause) findQ = findQ.where(clause);503 if (clause) findQ = findQ.where(clause);
426 const matched = (yield* runPromise("update pre-select", () =>504 const matched = (yield* runPromise(
427 findQ.limit(2),505 "update pre-select",
506 () => findQ.limit(2),
507 model,
428 )) as Record<string, unknown>[];508 )) as Record<string, unknown>[];
429 if (matched.length === 0) return null;509 if (matched.length === 0) return null;
430 if (matched.length > 1) return null;510 if (matched.length > 1) return null;
431 const target = matched[0]!;511 const target = matched[0]!;
432 let updQ = db.update(table).set(update).where(eq(table.id, target.id));512 let updQ = db.update(table).set(update).where(eq(table.id, target.id));
433 if (provider !== "mysql") {513 if (provider !== "mysql") {
434 const rows = (yield* runPromise("update returning", () =>514 const rows = (yield* runPromise(
435 updQ.returning(),515 "update returning",
516 () => updQ.returning(),
517 model,
436 )) as Record<string, unknown>[];518 )) as Record<string, unknown>[];
437 return (rows[0] ?? null) as never;519 return (rows[0] ?? null) as never;
438 }520 }
439 yield* runPromise("mysql update execute", () => updQ.execute());521 yield* runPromise(
440 const reread = (yield* runPromise("mysql update reread", () =>522 "mysql update execute",
441 db.select().from(table).where(eq(table.id, target.id)).limit(1),523 () => updQ.execute(),
524 model,
525 );
526 const reread = (yield* runPromise(
527 "mysql update reread",
528 () => db.select().from(table).where(eq(table.id, target.id)).limit(1),
529 model,
442 )) as Record<string, unknown>[];530 )) as Record<string, unknown>[];
443 return (reread[0] ?? null) as never;531 return (reread[0] ?? null) as never;
444 }),532 }),
@@ -451,14 +539,20 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines
451 // but we don't want to rely on that in the generic path)539 // but we don't want to rely on that in the generic path)
452 let countQ = db.select({ c: count() }).from(table);540 let countQ = db.select({ c: count() }).from(table);
453 if (clause) countQ = countQ.where(clause);541 if (clause) countQ = countQ.where(clause);
454 const rows = (yield* runPromise("updateMany count", () =>542 const rows = (yield* runPromise(
455 Promise.resolve(countQ),543 "updateMany count",
544 () => Promise.resolve(countQ),
545 model,
456 )) as { c: number | string | bigint }[];546 )) as { c: number | string | bigint }[];
457 const n = Number(rows[0]?.c ?? 0);547 const n = Number(rows[0]?.c ?? 0);
458 if (n === 0) return 0;548 if (n === 0) return 0;
459 let updQ = db.update(table).set(update);549 let updQ = db.update(table).set(update);
460 if (clause) updQ = updQ.where(clause);550 if (clause) updQ = updQ.where(clause);
461 yield* runPromise("updateMany execute", () => Promise.resolve(updQ));551 yield* runPromise(
552 "updateMany execute",
553 () => Promise.resolve(updQ),
554 model,
555 );
462 return n;556 return n;
463 }),557 }),
464 558
@@ -469,13 +563,17 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines
469 // Mirror in-memory semantics: delete first matching row only563 // Mirror in-memory semantics: delete first matching row only
470 let findQ = db.select({ id: table.id }).from(table);564 let findQ = db.select({ id: table.id }).from(table);
471 if (clause) findQ = findQ.where(clause);565 if (clause) findQ = findQ.where(clause);
472 const matched = (yield* runPromise("delete pre-select", () =>566 const matched = (yield* runPromise(
473 findQ.limit(1),567 "delete pre-select",
568 () => findQ.limit(1),
569 model,
474 )) as { id: unknown }[];570 )) as { id: unknown }[];
475 const first = matched[0];571 const first = matched[0];
476 if (!first) return;572 if (!first) return;
477 yield* runPromise("delete exec", () =>573 yield* runPromise(
478 Promise.resolve(db.delete(table).where(eq(table.id, first.id))),574 "delete exec",
575 () => Promise.resolve(db.delete(table).where(eq(table.id, first.id))),
576 model,
479 );577 );
480 }),578 }),
481 579
@@ -485,14 +583,20 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 3 unmodified lines
485 const clause = compileWhere(table, where, provider);583 const clause = compileWhere(table, where, provider);
486 let countQ = db.select({ c: count() }).from(table);584 let countQ = db.select({ c: count() }).from(table);
487 if (clause) countQ = countQ.where(clause);585 if (clause) countQ = countQ.where(clause);
488 const rows = (yield* runPromise("deleteMany count", () =>586 const rows = (yield* runPromise(
489 Promise.resolve(countQ),587 "deleteMany count",
588 () => Promise.resolve(countQ),
589 model,
490 )) as { c: number | string | bigint }[];590 )) as { c: number | string | bigint }[];
491 const n = Number(rows[0]?.c ?? 0);591 const n = Number(rows[0]?.c ?? 0);
492 if (n === 0) return 0;592 if (n === 0) return 0;
493 let delQ = db.delete(table);593 let delQ = db.delete(table);
494 if (clause) delQ = delQ.where(clause);594 if (clause) delQ = delQ.where(clause);
495 yield* runPromise("deleteMany exec", () => Promise.resolve(delQ));595 yield* runPromise(
596 "deleteMany exec",
597 () => Promise.resolve(delQ),
598 model,
599 );
496 return n;600 return n;
497 }),601 }),
498 };602 };
@@ -553,9 +657,9 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 54 unmodified lines
553 }).pipe(657 }).pipe(
554 Effect.mapError((e) => {658 Effect.mapError((e) => {
555 if (e instanceof TxFailure) return e.inner;659 if (e instanceof TxFailure) return e.inner;
556 return e instanceof Error ? e : new Error(String(e));660 return classifyError("pg transaction", undefined, e);
557 }),661 }),
558 ) as Effect.Effect<R, E | Error>;662 ) as Effect.Effect<R, E | StorageFailure>;
559 }663 }
560 664
561 return Effect.gen(function* () {665 return Effect.gen(function* () {
@@ -578,16 +682,13 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines
578 }682 }
579 return res;683 return res;
580 },684 },
581 catch: (e) =>685 catch: (cause) => classifyError(stmt, undefined, cause),
582 new Error(
583 `[storage-drizzle] ${stmt} failed: ${e instanceof Error ? e.message : String(e)}`,
584 ),
585 });686 });
586 const maybePromise = yield* runStmt("BEGIN");687 const maybePromise = yield* runStmt("BEGIN");
587 if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") {688 if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") {
588 yield* Effect.tryPromise({689 yield* Effect.tryPromise({
589 try: () => maybePromise as Promise<unknown>,690 try: () => maybePromise as Promise<unknown>,
590 catch: (e) => new Error(String(e)),691 catch: (cause) => classifyError("BEGIN", undefined, cause),
591 });692 });
592 }693 }
593 const nested = drizzleAdapter({694 const nested = drizzleAdapter({
@@ -606,7 +707,7 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 12 unmodified lines
606 if (commitRes && typeof (commitRes as { then?: unknown }).then === "function") {707 if (commitRes && typeof (commitRes as { then?: unknown }).then === "function") {
607 yield* Effect.tryPromise({708 yield* Effect.tryPromise({
608 try: () => commitRes as Promise<unknown>,709 try: () => commitRes as Promise<unknown>,
609 catch: (e) => new Error(String(e)),710 catch: (cause) => classifyError("COMMIT", undefined, cause),
610 });711 });
611 }712 }
612 return result;713 return result;
packages/core/storage-file/src/blob-store.ts+7−3
@@ -13,6 +13,7 @@ import { and, eq, sql as drizzleSql } from "drizzle-orm"; · 12 unmodified lines
13import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";13import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
14 14
15import type { BlobStore } from "@executor/sdk";15import type { BlobStore } from "@executor/sdk";
16import { StorageError } from "@executor/storage-core";
16 17
17export const blobTable = sqliteTable(18export const blobTable = sqliteTable(
18 "blob",19 "blob",
@@ -34,9 +35,12 @@ export interface MakeSqliteBlobStoreOptions { · 15 unmodified lines
34 35
35const wrapErr =36const wrapErr =
36 (op: string) =>37 (op: string) =>
37 (e: unknown): Error => {38 (cause: unknown): StorageError => {
38 const msg = e instanceof Error ? e.message : String(e);39 const msg = cause instanceof Error ? cause.message : String(cause);
39 return new Error(`[storage-file] blob ${op}: ${msg}`);40 return new StorageError({
41 message: `[storage-file] blob ${op}: ${msg}`,
42 cause,
43 });
40 };44 };
41 45
42export const makeSqliteBlobStore = (46export const makeSqliteBlobStore = (
packages/core/storage-postgres/src/blob-store.ts+7−3
@@ -15,6 +15,7 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 14 unmodified lines
15import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";15import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
16 16
17import type { BlobStore } from "@executor/sdk";17import type { BlobStore } from "@executor/sdk";
18import { StorageError } from "@executor/storage-core";
18 19
19export const blobTable = pgTable(20export const blobTable = pgTable(
20 "blob",21 "blob",
@@ -35,9 +36,12 @@ export interface MakePostgresBlobStoreOptions { · 14 unmodified lines
35 36
36const wrapErr =37const wrapErr =
37 (op: string) =>38 (op: string) =>
38 (e: unknown): Error => {39 (cause: unknown): StorageError => {
39 const msg = e instanceof Error ? e.message : String(e);40 const msg = cause instanceof Error ? cause.message : String(cause);
40 return new Error(`[storage-postgres] blob ${op}: ${msg}`);41 return new StorageError({
42 message: `[storage-postgres] blob ${op}: ${msg}`,
43 cause,
44 });
41 };45 };
42 46
43export const makePostgresBlobStore = (47export const makePostgresBlobStore = (
packages/plugins/file-secrets/src/index.ts+57−22
@@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line
2import * as fs from "node:fs";2import * as fs from "node:fs";
3import * as path from "node:path";3import * as path from "node:path";
4 4
5import { definePlugin, type PluginCtx, type SecretProvider } from "@executor/sdk";5import {
6 definePlugin,
7 StorageError,
8 type PluginCtx,
9 type SecretProvider,
10} from "@executor/sdk";
6 11
7// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
8// XDG data dir resolution13// XDG data dir resolution
@@ -43,16 +48,28 @@ const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); · 34 unmodified lines
43 48
44// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
45// File I/O with restricted permissions50// File I/O with restricted permissions
51//
52// These helpers throw on real I/O or decode failures — the provider wraps
53// every call in `Effect.try` so those throws surface as typed
54// `StorageError` on the Effect error channel. Previously `readFullFile`
55// used a blanket `try { ... } catch { return {}; }` which masked JSON
56// parse errors, schema decode failures, and permission errors as
57// "empty file", making misconfigured installs silently return null from
58// every `get`.
46// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
47 60
48const readFullFile = (filePath: string): Record<string, Record<string, string>> => {61const readFullFile = (filePath: string): Record<string, Record<string, string>> => {
62 if (!fs.existsSync(filePath)) return {};
63 let raw: string;
49 try {64 try {
50 if (!fs.existsSync(filePath)) return {};65 raw = fs.readFileSync(filePath, "utf-8");
51 const raw = fs.readFileSync(filePath, "utf-8");66 } catch (cause) {
52 return decodeScopedAuthFile(JSON.parse(raw));67 // Treat "file disappeared between existsSync and readFileSync" as
53 } catch {68 // absence — anything else (EACCES, EISDIR, …) propagates.
54 return {};69 if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};
70 throw cause;
55 }71 }
72 return decodeScopedAuthFile(JSON.parse(raw));
56};73};
57 74
58const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>75const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>
@@ -100,36 +117,54 @@ export interface FileSecretsExtension { · 41 unmodified lines
100// Provider factory (internal)117// Provider factory (internal)
101// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
102 119
120const toStorageError = (cause: unknown) =>
121 new StorageError({
122 message: cause instanceof Error ? cause.message : String(cause),
123 cause,
124 });
125
103const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({126const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({
104 key: "file",127 key: "file",
105 writable: true,128 writable: true,
106 129
107 get: (secretId) =>130 get: (secretId) =>
108 Effect.sync(() => {131 Effect.try({
109 const data = readScopeSecrets(filePath, scopeId);132 try: () => {
110 return data[secretId] ?? null;133 const data = readScopeSecrets(filePath, scopeId);
134 return data[secretId] ?? null;
135 },
136 catch: toStorageError,
111 }),137 }),
112 138
113 set: (secretId, value) =>139 set: (secretId, value) =>
114 Effect.sync(() => {140 Effect.try({
115 const data = readScopeSecrets(filePath, scopeId);141 try: () => {
116 data[secretId] = value;142 const data = readScopeSecrets(filePath, scopeId);
117 writeScopeSecrets(filePath, scopeId, data);143 data[secretId] = value;
144 writeScopeSecrets(filePath, scopeId, data);
145 },
146 catch: toStorageError,
118 }),147 }),
119 148
120 delete: (secretId) =>149 delete: (secretId) =>
121 Effect.sync(() => {150 Effect.try({
122 const data = readScopeSecrets(filePath, scopeId);151 try: () => {
123 const had = secretId in data;152 const data = readScopeSecrets(filePath, scopeId);
124 delete data[secretId];153 const had = secretId in data;
125 if (had) writeScopeSecrets(filePath, scopeId, data);154 delete data[secretId];
126 return had;155 if (had) writeScopeSecrets(filePath, scopeId, data);
156 return had;
157 },
158 catch: toStorageError,
127 }),159 }),
128 160
129 list: () =>161 list: () =>
130 Effect.sync(() => {162 Effect.try({
131 const data = readScopeSecrets(filePath, scopeId);163 try: () => {
132 return Object.keys(data).map((k) => ({ id: k, name: k }));164 const data = readScopeSecrets(filePath, scopeId);
165 return Object.keys(data).map((k) => ({ id: k, name: k }));
166 },
167 catch: toStorageError,
133 }),168 }),
134});169});
135 170
packages/plugins/google-discovery/src/api/group.ts+38−26
@@ -1,7 +1,13 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
6import {
7 GoogleDiscoveryOAuthError,
8 GoogleDiscoveryParseError,
9 GoogleDiscoverySourceError,
10} from "../sdk/errors";
5import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";11import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source";
6 12
7export { HttpApiSchema };13export { HttpApiSchema };
@@ -109,55 +115,61 @@ export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryA · 101 unmodified lines
109 HttpApiSchema.annotations({ status: 400 }),115 HttpApiSchema.annotations({ status: 400 }),
110) {}116) {}
111 117
112export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()(118// ---------------------------------------------------------------------------
113 "GoogleDiscoveryInternalError",119// Group
114 {120//
115 message: Schema.String,121// Domain errors + the shared opaque 500 (`InternalError`) are declared
116 },122// once at the group level via `.addError(...)` — every endpoint
117 HttpApiSchema.annotations({ status: 500 }),123// inherits them. The domain error carries its HTTP status via
118) {}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// ---------------------------------------------------------------------------
119 129
120export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")130export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
121 .add(131 .add(
122 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`132 HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
123 .setPayload(ProbePayload)133 .setPayload(ProbePayload)
124 .addSuccess(ProbeResponse)134 .addSuccess(ProbeResponse),
125 .addError(GoogleDiscoveryApiError)
126 .addError(GoogleDiscoveryInternalError),
127 )135 )
128 .add(136 .add(
129 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`137 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
130 .setPayload(AddSourcePayload)138 .setPayload(AddSourcePayload)
131 .addSuccess(AddSourceResponse)139 .addSuccess(AddSourceResponse),
132 .addError(GoogleDiscoveryApiError)
133 .addError(GoogleDiscoveryInternalError),
134 )140 )
135 .add(141 .add(
136 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`142 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
137 .setPayload(StartOAuthPayload)143 .setPayload(StartOAuthPayload)
138 .addSuccess(StartOAuthResponse)144 .addSuccess(StartOAuthResponse),
139 .addError(GoogleDiscoveryApiError)
140 .addError(GoogleDiscoveryInternalError),
141 )145 )
142 .add(146 .add(
143 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`147 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
144 .setPayload(CompleteOAuthPayload)148 .setPayload(CompleteOAuthPayload)
145 .addSuccess(CompleteOAuthResponse)149 .addSuccess(CompleteOAuthResponse),
146 .addError(GoogleDiscoveryApiError)
147 .addError(GoogleDiscoveryInternalError),
148 )150 )
149 .add(151 .add(
150 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`152 HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
151 .setUrlParams(OAuthCallbackParams)153 .setUrlParams(OAuthCallbackParams)
152 .addSuccess(HtmlResponse)154 .addSuccess(HtmlResponse),
153 .addError(GoogleDiscoveryApiError)
154 .addError(GoogleDiscoveryInternalError),
155 )155 )
156 .add(156 .add(
157 HttpApiEndpoint.get(157 HttpApiEndpoint.get(
158 "getSource",158 "getSource",
159 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`159 )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}`
160 .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema))160 .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)),
161 .addError(GoogleDiscoveryApiError)161 )
162 .addError(GoogleDiscoveryInternalError),162 // Errors declared once at the group level — every endpoint inherits.
163 ) {}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+61−42
@@ -1,8 +1,17 @@
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
1import { HttpApiBuilder, HttpServer } from "@effect/platform";10import { HttpApiBuilder, HttpServer } from "@effect/platform";
2import { describe, expect, it } from "vitest";11import { describe, expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
4 13
5import { addGroup } from "@executor/api";14import { addGroup, observabilityMiddleware } from "@executor/api";
6import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";15import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";16import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin";
8import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";17import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers";
@@ -10,57 +19,67 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line
10 19
11const unused = Effect.dieMessage("unused");20const unused = Effect.dieMessage("unused");
12 21
13const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({22const failingExtension: GoogleDiscoveryPluginExtension = {
14 probeDiscovery: () => Effect.die(new Error("Not implemented")),23 probeDiscovery: () => Effect.die(new Error("Not implemented")),
15 addSource: () => unused,24 addSource: () => unused,
16 removeSource: () => unused,25 removeSource: () => unused,
17 startOAuth: () => unused,26 startOAuth: () => unused,
18 completeOAuth: () => Effect.die(new Error("Not implemented")),27 completeOAuth: () => Effect.die(new Error("Not implemented")),
19 getSource: () => Effect.succeed(null),28 getSource: () => Effect.succeed(null),
20});29};
21 30
22const Api = addGroup(GoogleDiscoveryGroup);31const Api = addGroup(GoogleDiscoveryGroup);
23 32
24const fakeExecutor = {} as any;33// `acquireRelease` keeps disposal inside the Effect scope — no
25const fakeExecutionEngine = {} as any;34// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
26 35// scope for us.
27const createHandler = () =>36const WebHandler = Effect.acquireRelease(
28 HttpApiBuilder.toWebHandler(37 Effect.sync(() =>
29 HttpApiBuilder.api(Api).pipe(38 HttpApiBuilder.toWebHandler(
30 Layer.provide(CoreHandlers),39 HttpApiBuilder.api(Api).pipe(
31 Layer.provide(GoogleDiscoveryHandlers),40 Layer.provide(CoreHandlers),
32 Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),41 Layer.provide(GoogleDiscoveryHandlers),
33 Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),42 Layer.provide(observabilityMiddleware(Api)),
34 Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())),43 Layer.provide(Layer.succeed(ExecutorService, {} as never)),
35 Layer.provideMerge(HttpServer.layerContext),44 Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
36 Layer.provideMerge(HttpApiBuilder.Router.Live),45 Layer.provide(
37 Layer.provideMerge(HttpApiBuilder.Middleware.layer),46 Layer.succeed(GoogleDiscoveryExtensionService, failingExtension),
47 ),
48 Layer.provideMerge(HttpServer.layerContext),
49 Layer.provideMerge(HttpApiBuilder.Router.Live),
50 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
51 ),
38 ),52 ),
39 );53 ),
54 (web) => Effect.promise(() => web.dispose()),
55);
40 56
41describe("GoogleDiscoveryHandlers", () => {57describe("GoogleDiscoveryHandlers", () => {
42 it("sanitizes unknown endpoint failures", async () => {58 it.scoped(
43 const web = createHandler();59 "defect-returning methods produce an opaque InternalError, no leakage",
44 try {60 () =>
45 const response = await web.handler(61 Effect.gen(function* () {
46 new Request("http://localhost/scopes/scope_1/google-discovery/probe", {62 const web = yield* WebHandler;
47 method: "POST",63 const response = yield* Effect.promise(() =>
48 headers: { "content-type": "application/json" },64 web.handler(
49 body: JSON.stringify({65 new Request("http://localhost/scopes/scope_1/google-discovery/probe", {
50 discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",66 method: "POST",
51 }),67 headers: { "content-type": "application/json" },
52 }),68 body: JSON.stringify({
53 );69 discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1",
70 }),
71 }),
72 ),
73 );
54 74
55 expect(response.status).toBe(500);75 expect(response.status).toBe(500);
56 const body = await response.json();76 const body = (yield* Effect.promise(() => response.json())) as {
57 expect(body).toEqual({77 _tag?: string;
58 _tag: "GoogleDiscoveryInternalError",78 traceId?: string;
59 message: "Internal server error",79 };
60 });80 expect(body._tag).toBe("InternalError");
61 expect(JSON.stringify(body)).not.toContain("Not implemented");81 expect(typeof body.traceId).toBe("string");
62 } finally {82 expect(JSON.stringify(body)).not.toContain("Not implemented");
63 await web.dispose();83 }),
64 }84 );
65 });
66});85});
packages/plugins/google-discovery/src/api/handlers.ts+48−55
@@ -1,65 +1,58 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup } from "@executor/api";6import { addGroup, capture } from "@executor/api";
7import type {7import type {
8 GoogleDiscoveryAddSourceInput,8 GoogleDiscoveryAddSourceInput,
9 GoogleDiscoveryOAuthAuthResult,9 GoogleDiscoveryOAuthAuthResult,
10 GoogleDiscoveryPluginExtension,10 GoogleDiscoveryPluginExtension,
11} from "../sdk/plugin";11} from "../sdk/plugin";
12import {12import { GoogleDiscoveryOAuthError } from "../sdk/errors";
13 GoogleDiscoveryInvocationError,13import { GoogleDiscoveryGroup } from "./group";
14 GoogleDiscoveryOAuthError,14
15 GoogleDiscoveryParseError,15// ---------------------------------------------------------------------------
16 GoogleDiscoverySourceError,16// Service tag
17} from "../sdk/errors";17//
18import {18// Holds the `Captured` shape — every method's `StorageFailure` channel
19 GoogleDiscoveryApiError,19// has been swapped for `InternalError({ traceId })`. The host app
20 GoogleDiscoveryGroup,20// provides an already-wrapped extension via
21 GoogleDiscoveryInternalError,21// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`.
22} from "./group";22// Handlers see `InternalError` in the error union, which matches
23// `.addError(InternalError)` on the group — no per-handler translation.
24// ---------------------------------------------------------------------------
23 25
24export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<26export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")<
25 GoogleDiscoveryExtensionService,27 GoogleDiscoveryExtensionService,
26 GoogleDiscoveryPluginExtension28 GoogleDiscoveryPluginExtension
27>() {}29>() {}
28 30
31// ---------------------------------------------------------------------------
32// Composed API
33// ---------------------------------------------------------------------------
34
29const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);35const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
30 36
31const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";37const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
32 38
33const toPopupErrorMessage = (error: unknown): string => {39const toPopupErrorMessage = (error: unknown): string =>
34 if (error instanceof GoogleDiscoveryOAuthError) {40 error instanceof GoogleDiscoveryOAuthError ? error.message : "Authentication failed";
35 return error.message;
36 }
37 return "Authentication failed";
38};
39
40const 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};
56 41
57const sanitizeGoogleDiscoveryFailure = <A, R>(42// ---------------------------------------------------------------------------
58 effect: Effect.Effect<A, unknown, R>,43// Handlers
59): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> =>44//
60 Effect.catchAllCause(effect, (cause) =>45// Each handler is exactly: yield the extension service, call the method,
61 Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))),46// return. Plugin SDK errors flow through the typed channel and are
62 );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// ---------------------------------------------------------------------------
63 56
64export const GoogleDiscoveryHandlers = HttpApiBuilder.group(57export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
65 ExecutorApiWithGoogleDiscovery,58 ExecutorApiWithGoogleDiscovery,
@@ -67,19 +60,19 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 1 unmodified line
67 (handlers) =>60 (handlers) =>
68 handlers61 handlers
69 .handle("probeDiscovery", ({ payload }) =>62 .handle("probeDiscovery", ({ payload }) =>
70 Effect.gen(function* () {63 capture(Effect.gen(function* () {
71 const ext = yield* GoogleDiscoveryExtensionService;64 const ext = yield* GoogleDiscoveryExtensionService;
72 return yield* ext.probeDiscovery(payload.discoveryUrl);65 return yield* ext.probeDiscovery(payload.discoveryUrl);
73 }).pipe(sanitizeGoogleDiscoveryFailure),66 })),
74 )67 )
75 .handle("addSource", ({ payload }) =>68 .handle("addSource", ({ payload }) =>
76 Effect.gen(function* () {69 capture(Effect.gen(function* () {
77 const ext = yield* GoogleDiscoveryExtensionService;70 const ext = yield* GoogleDiscoveryExtensionService;
78 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);71 return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
79 }).pipe(sanitizeGoogleDiscoveryFailure),72 })),
80 )73 )
81 .handle("startOAuth", ({ payload }) =>74 .handle("startOAuth", ({ payload }) =>
82 Effect.gen(function* () {75 capture(Effect.gen(function* () {
83 const ext = yield* GoogleDiscoveryExtensionService;76 const ext = yield* GoogleDiscoveryExtensionService;
84 return yield* ext.startOAuth({77 return yield* ext.startOAuth({
85 name: payload.name,78 name: payload.name,
@@ -89,30 +82,30 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines
89 redirectUrl: payload.redirectUrl,82 redirectUrl: payload.redirectUrl,
90 scopes: payload.scopes,83 scopes: payload.scopes,
91 });84 });
92 }).pipe(sanitizeGoogleDiscoveryFailure),85 })),
93 )86 )
94 .handle("completeOAuth", ({ payload }) =>87 .handle("completeOAuth", ({ payload }) =>
95 Effect.gen(function* () {88 capture(Effect.gen(function* () {
96 const ext = yield* GoogleDiscoveryExtensionService;89 const ext = yield* GoogleDiscoveryExtensionService;
97 return yield* ext.completeOAuth({90 return yield* ext.completeOAuth({
98 state: payload.state,91 state: payload.state,
99 code: payload.code,92 code: payload.code,
100 error: payload.error,93 error: payload.error,
101 });94 });
102 }).pipe(sanitizeGoogleDiscoveryFailure),95 })),
103 )96 )
104 .handle("getSource", ({ path }) =>97 .handle("getSource", ({ path }) =>
105 Effect.gen(function* () {98 capture(Effect.gen(function* () {
106 const ext = yield* GoogleDiscoveryExtensionService;99 const ext = yield* GoogleDiscoveryExtensionService;
107 return yield* ext.getSource(path.namespace);100 return yield* ext.getSource(path.namespace);
108 }).pipe(sanitizeGoogleDiscoveryFailure),101 })),
109 )102 )
110 .handle("oauthCallback", ({ urlParams }) =>103 .handle("oauthCallback", ({ urlParams }) =>
111 Effect.gen(function* () {104 capture(Effect.gen(function* () {
112 const ext = yield* GoogleDiscoveryExtensionService;105 const ext = yield* GoogleDiscoveryExtensionService;
113 const html = yield* runOAuthCallback<106 const html = yield* runOAuthCallback<
114 GoogleDiscoveryOAuthAuthResult,107 GoogleDiscoveryOAuthAuthResult,
115 GoogleDiscoveryOAuthError,108 unknown,
116 never109 never
117 >({110 >({
118 complete: ({ state, code, error }) =>111 complete: ({ state, code, error }) =>
@@ -126,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 7 unmodified lines
126 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,119 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
127 });120 });
128 return yield* HttpServerResponse.html(html);121 return yield* HttpServerResponse.html(html);
129 }).pipe(sanitizeGoogleDiscoveryFailure),122 })),
130 ),123 ),
131);124);
packages/plugins/google-discovery/src/sdk/binding-store.ts+22−12
@@ -14,7 +14,11 @@ · 13 unmodified lines
14 14
15import { Effect, Schema } from "effect";15import { Effect, Schema } from "effect";
16 16
17import { defineSchema, type StorageDeps } from "@executor/sdk";17import {
18 defineSchema,
19 type StorageDeps,
20 type StorageFailure,
21} from "@executor/sdk";
18 22
19import {23import {
20 GoogleDiscoveryMethodBinding,24 GoogleDiscoveryMethodBinding,
@@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines
101// Store interface105// Store interface
102// ---------------------------------------------------------------------------106// ---------------------------------------------------------------------------
103 107
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.
104export interface GoogleDiscoveryStore {112export interface GoogleDiscoveryStore {
105 readonly getBinding: (toolId: string) => Effect.Effect<113 readonly getBinding: (toolId: string) => Effect.Effect<
106 { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null,114 { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null,
107 Error115 StorageFailure
108 >;116 >;
109 readonly putBinding: (117 readonly putBinding: (
110 toolId: string,118 toolId: string,
111 sourceId: string,119 sourceId: string,
112 binding: GoogleDiscoveryMethodBinding,120 binding: GoogleDiscoveryMethodBinding,
113 ) => Effect.Effect<void, Error>;121 ) => Effect.Effect<void, StorageFailure>;
114 readonly removeBindingsBySource: (122 readonly removeBindingsBySource: (
115 sourceId: string,123 sourceId: string,
116 ) => Effect.Effect<readonly string[], Error>;124 ) => Effect.Effect<readonly string[], StorageFailure>;
117 readonly getBindingsForSource: (125 readonly getBindingsForSource: (
118 sourceId: string,126 sourceId: string,
119 ) => Effect.Effect<127 ) => Effect.Effect<
120 ReadonlyMap<string, GoogleDiscoveryMethodBinding>,128 ReadonlyMap<string, GoogleDiscoveryMethodBinding>,
121 Error129 StorageFailure
122 >;130 >;
123 131
124 readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect<void, Error>;132 readonly putSource: (
125 readonly removeSource: (sourceId: string) => Effect.Effect<void, Error>;133 source: GoogleDiscoveryStoredSource,
134 ) => Effect.Effect<void, StorageFailure>;
135 readonly removeSource: (sourceId: string) => Effect.Effect<void, StorageFailure>;
126 readonly getSource: (136 readonly getSource: (
127 sourceId: string,137 sourceId: string,
128 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;138 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
129 readonly getSourceConfig: (139 readonly getSourceConfig: (
130 sourceId: string,140 sourceId: string,
131 ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error>;141 ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>;
132 142
133 readonly putOAuthSession: (143 readonly putOAuthSession: (
134 sessionId: string,144 sessionId: string,
135 session: GoogleDiscoveryOAuthSession,145 session: GoogleDiscoveryOAuthSession,
136 ) => Effect.Effect<void, Error>;146 ) => Effect.Effect<void, StorageFailure>;
137 readonly getOAuthSession: (147 readonly getOAuthSession: (
138 sessionId: string,148 sessionId: string,
139 ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error>;149 ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, StorageFailure>;
140 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;150 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
141}151}
142 152
143// ---------------------------------------------------------------------------153// ---------------------------------------------------------------------------
packages/plugins/google-discovery/src/sdk/document.ts+6−2
@@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines
130});130});
131type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;131type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
132 132
133const toParseError = (message: string, cause: unknown) =>133// The Schema.TaggedError version of GoogleDiscoveryParseError no longer
134 new GoogleDiscoveryParseError({ message, cause });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`.
137const toParseError = (message: string, _cause: unknown) =>
138 new GoogleDiscoveryParseError({ message });
135 139
136const decodeUnknownWith =140const decodeUnknownWith =
137 <A>(141 <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
1import { Data, Schema } from "effect";12import { Data, Schema } from "effect";
13import { HttpApiSchema } from "@effect/platform";
2import type { Option } from "effect";14import type { Option } from "effect";
3 15
4export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{16export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
5 readonly message: string;17 "GoogleDiscoveryParseError",
6 readonly cause?: unknown;18 {
7}> {}19 message: Schema.String,
20 },
21 HttpApiSchema.annotations({ status: 400 }),
22) {}
8 23
9export class GoogleDiscoveryInvocationError extends Data.TaggedError(24export class GoogleDiscoveryInvocationError extends Data.TaggedError(
10 "GoogleDiscoveryInvocationError",25 "GoogleDiscoveryInvocationError",
@@ -19,6 +34,7 @@ export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscover · 8 unmodified lines
19 {34 {
20 message: Schema.String,35 message: Schema.String,
21 },36 },
37 HttpApiSchema.annotations({ status: 400 }),
22) {}38) {}
23 39
24export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(40export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(
@@ -26,4 +42,5 @@ export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscove · 1 unmodified line
26 {42 {
27 message: Schema.String,43 message: Schema.String,
28 },44 },
45 HttpApiSchema.annotations({ status: 400 }),
29) {}46) {}
packages/plugins/google-discovery/src/sdk/invoke.ts+37−13
@@ -6,12 +6,15 @@ import { · 5 unmodified lines
6 type OAuth2SecretsIO,6 type OAuth2SecretsIO,
7} from "@executor/plugin-oauth2";7} from "@executor/plugin-oauth2";
8 8
9import type { PluginCtx } from "@executor/sdk";9import type { PluginCtx, StorageFailure } from "@executor/sdk";
10import { SetSecretInput } from "@executor/sdk";10import { SetSecretInput } from "@executor/sdk";
11 11
12import { GOOGLE_TOKEN_URL } from "./oauth";12import { GOOGLE_TOKEN_URL } from "./oauth";
13 13
14import { GoogleDiscoveryInvocationError } from "./errors";14import {
15 GoogleDiscoveryInvocationError,
16 GoogleDiscoveryOAuthError,
17} from "./errors";
15import type { GoogleDiscoveryStore } from "./binding-store";18import type { GoogleDiscoveryStore } from "./binding-store";
16import {19import {
17 GoogleDiscoveryInvocationResult,20 GoogleDiscoveryInvocationResult,
@@ -115,7 +118,7 @@ const resolveOAuthAccessToken = (input: { · 97 unmodified lines
115 ctx: PluginCtx<GoogleDiscoveryStore>;118 ctx: PluginCtx<GoogleDiscoveryStore>;
116 sourceId: string;119 sourceId: string;
117 source: GoogleDiscoveryStoredSourceData;120 source: GoogleDiscoveryStoredSourceData;
118}): Effect.Effect<string, Error> =>121}): Effect.Effect<string, GoogleDiscoveryOAuthError> =>
119 Effect.gen(function* () {122 Effect.gen(function* () {
120 if (input.source.auth.kind !== "oauth2") return "";123 if (input.source.auth.kind !== "oauth2") return "";
121 const auth = input.source.auth;124 const auth = input.source.auth;
@@ -158,7 +161,9 @@ const resolveOAuthAccessToken = (input: { · 36 unmodified lines
158 });161 });
159 }),162 }),
160 }).pipe(163 }).pipe(
161 Effect.mapError((error) => new Error(error.message)),164 Effect.mapError(
165 (error) => new GoogleDiscoveryOAuthError({ message: error.message }),
166 ),
162 );167 );
163 });168 });
164 169
@@ -236,12 +241,23 @@ const performRequest = Effect.fn("GoogleDiscovery.invoke")(function* (input: { · 71 unmodified lines
236 );241 );
237 242
238 const contentType = response.headers["content-type"] ?? null;243 const contentType = response.headers["content-type"] ?? null;
244 const mapBodyError = Effect.mapError(
245 (err: { readonly message?: string }) =>
246 new GoogleDiscoveryInvocationError({
247 message: `Failed to read response body: ${err.message ?? String(err)}`,
248 statusCode: Option.some(response.status),
249 cause: err,
250 }),
251 );
239 const body =252 const body =
240 response.status === 204253 response.status === 204
241 ? null254 ? null
242 : isJsonContentType(contentType)255 : isJsonContentType(contentType)
243 ? yield* response.json.pipe(Effect.catchAll(() => response.text))256 ? yield* response.json.pipe(
244 : yield* response.text;257 Effect.catchAll(() => response.text),
258 mapBodyError,
259 )
260 : yield* response.text.pipe(mapBodyError);
245 261
246 const ok = response.status >= 200 && response.status < 300;262 const ok = response.status >= 200 && response.status < 300;
247 return new GoogleDiscoveryInvocationResult({263 return new GoogleDiscoveryInvocationResult({
@@ -261,18 +277,29 @@ export const invokeGoogleDiscoveryTool = (input: { · 13 unmodified lines
261 toolId: string;277 toolId: string;
262 args: unknown;278 args: unknown;
263 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;279 httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
264}): Effect.Effect<GoogleDiscoveryInvocationResult, Error> =>280}): Effect.Effect<
281 GoogleDiscoveryInvocationResult,
282 | GoogleDiscoveryInvocationError
283 | GoogleDiscoveryOAuthError
284 | StorageFailure
285> =>
265 Effect.gen(function* () {286 Effect.gen(function* () {
266 const entry = yield* input.ctx.storage.getBinding(input.toolId);287 const entry = yield* input.ctx.storage.getBinding(input.toolId);
267 if (!entry) {288 if (!entry) {
268 return yield* Effect.fail(289 return yield* Effect.fail(
269 new Error(`No Google Discovery operation found for tool "${input.toolId}"`),290 new GoogleDiscoveryInvocationError({
291 message: `No Google Discovery operation found for tool "${input.toolId}"`,
292 statusCode: Option.none(),
293 }),
270 );294 );
271 }295 }
272 const source = yield* input.ctx.storage.getSourceConfig(entry.namespace);296 const source = yield* input.ctx.storage.getSourceConfig(entry.namespace);
273 if (!source) {297 if (!source) {
274 return yield* Effect.fail(298 return yield* Effect.fail(
275 new Error(`No Google Discovery source found for "${entry.namespace}"`),299 new GoogleDiscoveryInvocationError({
300 message: `No Google Discovery source found for "${entry.namespace}"`,
301 statusCode: Option.none(),
302 }),
276 );303 );
277 }304 }
278 305
@@ -298,8 +325,5 @@ export const invokeGoogleDiscoveryTool = (input: { · 19 unmodified lines
298 source,325 source,
299 args: (input.args ?? {}) as Record<string, unknown>,326 args: (input.args ?? {}) as Record<string, unknown>,
300 authorizationHeader: authHeader,327 authorizationHeader: authHeader,
301 }).pipe(328 }).pipe(Effect.provide(layer));
302 Effect.provide(layer),
303 Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err)))),
304 );
305 });329 });
packages/plugins/google-discovery/src/sdk/plugin.ts+34−16
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 SetSecretInput,9 SetSecretInput,
10 SourceDetectionResult,10 SourceDetectionResult,
11 type PluginCtx,11 type PluginCtx,
12 type StorageFailure,
12 type ToolAnnotations,13 type ToolAnnotations,
13} from "@executor/sdk";14} from "@executor/sdk";
14 15
@@ -99,6 +100,23 @@ export interface GoogleDiscoveryOAuthAuthResult { · 84 unmodified lines
99 readonly scopes: readonly string[];100 readonly scopes: readonly string[];
100}101}
101 102
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 */
114export type GoogleDiscoveryExtensionFailure =
115 | GoogleDiscoveryParseError
116 | GoogleDiscoverySourceError
117 | GoogleDiscoveryOAuthError
118 | StorageFailure;
119
102export interface GoogleDiscoveryPluginExtension {120export interface GoogleDiscoveryPluginExtension {
103 readonly probeDiscovery: (121 readonly probeDiscovery: (
104 discoveryUrl: string,122 discoveryUrl: string,
@@ -110,21 +128,29 @@ export interface GoogleDiscoveryPluginExtension { · 5 unmodified lines
110 input: GoogleDiscoveryAddSourceInput,128 input: GoogleDiscoveryAddSourceInput,
111 ) => Effect.Effect<129 ) => Effect.Effect<
112 { readonly toolCount: number; readonly namespace: string },130 { readonly toolCount: number; readonly namespace: string },
113 GoogleDiscoveryParseError | GoogleDiscoverySourceError | Error131 GoogleDiscoveryParseError | GoogleDiscoverySourceError | StorageFailure
114 >;132 >;
115 readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;133 readonly removeSource: (
134 namespace: string,
135 ) => Effect.Effect<void, StorageFailure>;
116 readonly startOAuth: (136 readonly startOAuth: (
117 input: GoogleDiscoveryOAuthStartInput,137 input: GoogleDiscoveryOAuthStartInput,
118 ) => Effect.Effect<138 ) => Effect.Effect<
119 GoogleDiscoveryOAuthStartResponse,139 GoogleDiscoveryOAuthStartResponse,
120 GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | Error140 | GoogleDiscoveryParseError
141 | GoogleDiscoverySourceError
142 | GoogleDiscoveryOAuthError
143 | StorageFailure
121 >;144 >;
122 readonly completeOAuth: (145 readonly completeOAuth: (
123 input: GoogleDiscoveryOAuthCompleteInput,146 input: GoogleDiscoveryOAuthCompleteInput,
124 ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>;147 ) => Effect.Effect<
148 GoogleDiscoveryOAuthAuthResult,
149 GoogleDiscoveryOAuthError | StorageFailure
150 >;
125 readonly getSource: (151 readonly getSource: (
126 namespace: string,152 namespace: string,
127 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>;153 ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>;
128}154}
129 155
130// ---------------------------------------------------------------------------156// ---------------------------------------------------------------------------
@@ -286,7 +312,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 155 unmodified lines
286 schema: googleDiscoverySchema,312 schema: googleDiscoverySchema,
287 storage: (deps) => makeGoogleDiscoveryStore(deps),313 storage: (deps) => makeGoogleDiscoveryStore(deps),
288 314
289 extension: (ctx): GoogleDiscoveryPluginExtension => ({315 extension: (ctx) => ({
290 probeDiscovery: (discoveryUrl) =>316 probeDiscovery: (discoveryUrl) =>
291 Effect.gen(function* () {317 Effect.gen(function* () {
292 const text = yield* fetchDiscoveryDocument(discoveryUrl);318 const text = yield* fetchDiscoveryDocument(discoveryUrl);
@@ -465,18 +491,10 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 172 unmodified lines
465 scope: stored.scope,491 scope: stored.scope,
466 scopes: [...session.scopes],492 scopes: [...session.scopes],
467 };493 };
468 }).pipe(494 }),
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 ),
477 495
478 getSource: (namespace) => ctx.storage.getSource(namespace),496 getSource: (namespace) => ctx.storage.getSource(namespace),
479 }),497 } satisfies GoogleDiscoveryPluginExtension),
480 498
481 invokeTool: ({ ctx, toolRow, args }) =>499 invokeTool: ({ ctx, toolRow, args }) =>
482 invokeGoogleDiscoveryTool({500 invokeGoogleDiscoveryTool({
packages/plugins/graphql/src/api/group.ts+23−17
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";6import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors";
6import { HeaderValue } from "../sdk/types";7import { HeaderValue } from "../sdk/types";
@@ -63,35 +64,40 @@ const ExtractionError = GraphqlExtractionError.annotations( · 56 unmodified lines
63 HttpApiSchema.annotations({ status: 400 }),64 HttpApiSchema.annotations({ status: 400 }),
64);65);
65 66
66export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()(
67 "GraphqlInternalError",
68 {
69 message: Schema.String,
70 },
71 HttpApiSchema.annotations({ status: 500 }),
72) {}
73
74// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
75// Group68// 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.
76// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
77 81
78export class GraphqlGroup extends HttpApiGroup.make("graphql")82export class GraphqlGroup extends HttpApiGroup.make("graphql")
79 .add(83 .add(
80 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources`84 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources`
81 .setPayload(AddSourcePayload)85 .setPayload(AddSourcePayload)
82 .addSuccess(AddSourceResponse)86 .addSuccess(AddSourceResponse),
83 .addError(IntrospectionError)
84 .addError(ExtractionError)
85 .addError(GraphqlInternalError),
86 )87 )
87 .add(88 .add(
88 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`89 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
89 .addSuccess(Schema.NullOr(StoredSourceSchema))90 .addSuccess(Schema.NullOr(StoredSourceSchema)),
90 .addError(GraphqlInternalError),
91 )91 )
92 .add(92 .add(
93 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`93 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}`
94 .setPayload(UpdateSourcePayload)94 .setPayload(UpdateSourcePayload)
95 .addSuccess(UpdateSourceResponse)95 .addSuccess(UpdateSourceResponse),
96 .addError(GraphqlInternalError),96 )
97 ) {}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+28−53
@@ -1,13 +1,23 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors";5import type {
6import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin";6 GraphqlPluginExtension,
7import { GraphqlGroup, GraphqlInternalError } from "./group";7 HeaderValue,
8 GraphqlUpdateSourceInput,
9} from "../sdk/plugin";
10import { GraphqlGroup } from "./group";
8 11
9// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
10// Service tag — the server provides the GraphQL extension13// 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.
11// ---------------------------------------------------------------------------21// ---------------------------------------------------------------------------
12 22
13export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<23export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")<
@@ -15,47 +25,6 @@ export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionServic · 1 unmodified line
15 GraphqlPluginExtension25 GraphqlPluginExtension
16>() {}26>() {}
17 27
18// ---------------------------------------------------------------------------
19// Failure mapping
20// ---------------------------------------------------------------------------
21
22type GraphqlAddSourceFailure =
23 | GraphqlIntrospectionError
24 | GraphqlExtractionError
25 | GraphqlInternalError;
26
27const 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
39const 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
46const 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
52const 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
59// ---------------------------------------------------------------------------28// ---------------------------------------------------------------------------
60// Composed API — core + graphql group29// Composed API — core + graphql group
61// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
@@ -64,12 +33,18 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines
64 33
65// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
66// Handlers35// 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.
67// ---------------------------------------------------------------------------42// ---------------------------------------------------------------------------
68 43
69export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>44export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) =>
70 handlers45 handlers
71 .handle("addSource", ({ payload }) =>46 .handle("addSource", ({ payload }) =>
72 Effect.gen(function* () {47 capture(Effect.gen(function* () {
73 const ext = yield* GraphqlExtensionService;48 const ext = yield* GraphqlExtensionService;
74 const result = yield* ext.addSource({49 const result = yield* ext.addSource({
75 endpoint: payload.endpoint,50 endpoint: payload.endpoint,
@@ -82,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines
82 toolCount: result.toolCount,57 toolCount: result.toolCount,
83 namespace: payload.namespace ?? "graphql",58 namespace: payload.namespace ?? "graphql",
84 };59 };
85 }).pipe(sanitizeAddSourceFailure),60 })),
86 )61 )
87 .handle("getSource", ({ path }) =>62 .handle("getSource", ({ path }) =>
88 Effect.gen(function* () {63 capture(Effect.gen(function* () {
89 const ext = yield* GraphqlExtensionService;64 const ext = yield* GraphqlExtensionService;
90 return yield* ext.getSource(path.namespace);65 return yield* ext.getSource(path.namespace);
91 }).pipe(sanitizeInternalFailure),66 })),
92 )67 )
93 .handle("updateSource", ({ path, payload }) =>68 .handle("updateSource", ({ path, payload }) =>
94 Effect.gen(function* () {69 capture(Effect.gen(function* () {
95 const ext = yield* GraphqlExtensionService;70 const ext = yield* GraphqlExtensionService;
96 yield* ext.updateSource(path.namespace, {71 yield* ext.updateSource(path.namespace, {
97 name: payload.name,72 name: payload.name,
@@ -99,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line
99 headers: payload.headers as Record<string, HeaderValue> | undefined,74 headers: payload.headers as Record<string, HeaderValue> | undefined,
100 } as GraphqlUpdateSourceInput);75 } as GraphqlUpdateSourceInput);
101 return { updated: true };76 return { updated: true };
102 }).pipe(sanitizeInternalFailure),77 })),
103 ),78 ),
104);79);
packages/plugins/graphql/src/sdk/invoke.ts+5−5
@@ -14,8 +14,8 @@ import { · 13 unmodified lines
14 14
15export const resolveHeaders = (15export const resolveHeaders = (
16 headers: Record<string, HeaderValue>,16 headers: Record<string, HeaderValue>,
17 secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },17 secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> },
18): Effect.Effect<Record<string, string>, Error> =>18): Effect.Effect<Record<string, string>> =>
19 Effect.gen(function* () {19 Effect.gen(function* () {
20 const entries = Object.entries(headers);20 const entries = Object.entries(headers);
21 // Resolve secret-backed headers in parallel. Missing / failing21 // Resolve secret-backed headers in parallel. Missing / failing
@@ -138,14 +138,14 @@ export const invokeWithLayer = ( · 116 unmodified lines
138 endpoint: string,138 endpoint: string,
139 resolvedHeaders: Record<string, string>,139 resolvedHeaders: Record<string, string>,
140 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,140 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
141): Effect.Effect<InvocationResult, Error> =>141) =>
142 invoke(operation, args, endpoint, resolvedHeaders).pipe(142 invoke(operation, args, endpoint, resolvedHeaders).pipe(
143 Effect.provide(httpClientLayer),143 Effect.provide(httpClientLayer),
144 Effect.mapError((err) =>144 Effect.mapError((err) =>
145 err instanceof Error145 err instanceof GraphqlInvocationError
146 ? err146 ? err
147 : new GraphqlInvocationError({147 : new GraphqlInvocationError({
148 message: String(err),148 message: err instanceof Error ? err.message : String(err),
149 statusCode: Option.none(),149 statusCode: Option.none(),
150 cause: err,150 cause: err,
151 }),151 }),
packages/plugins/graphql/src/sdk/plugin.ts+24−14
@@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines
5import {5import {
6 definePlugin,6 definePlugin,
7 SourceDetectionResult,7 SourceDetectionResult,
8 type StorageFailure,
8 type ToolAnnotations,9 type ToolAnnotations,
9 type ToolRow,10 type ToolRow,
10} from "@executor/sdk";11} from "@executor/sdk";
@@ -24,7 +25,7 @@ import { · 13 unmodified lines
24 type IntrospectionTypeRef,25 type IntrospectionTypeRef,
25} from "./introspect";26} from "./introspect";
26import { extract } from "./extract";27import { extract } from "./extract";
27import { GraphqlExtractionError } from "./errors";28import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors";
28import { invokeWithLayer, resolveHeaders } from "./invoke";29import { invokeWithLayer, resolveHeaders } from "./invoke";
29import {30import {
30 graphqlSchema,31 graphqlSchema,
@@ -69,19 +70,35 @@ export interface GraphqlUpdateSourceInput { · 38 unmodified lines
69 readonly headers?: Record<string, HeaderValue>;70 readonly headers?: Record<string, HeaderValue>;
70}71}
71 72
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 */
82export type GraphqlExtensionFailure =
83 | GraphqlIntrospectionError
84 | GraphqlExtractionError
85 | StorageFailure;
86
72export interface GraphqlPluginExtension {87export interface GraphqlPluginExtension {
73 /** Add a GraphQL endpoint and register its operations as tools */88 /** Add a GraphQL endpoint and register its operations as tools */
74 readonly addSource: (89 readonly addSource: (
75 config: GraphqlSourceConfig,90 config: GraphqlSourceConfig,
76 ) => Effect.Effect<{ readonly toolCount: number }, Error>;91 ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>;
77 92
78 /** Remove all tools from a previously added GraphQL source by namespace */93 /** 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>;
80 97
81 /** Fetch the full stored source by namespace (or null if missing) */98 /** Fetch the full stored source by namespace (or null if missing) */
82 readonly getSource: (99 readonly getSource: (
83 namespace: string,100 namespace: string,
84 ) => Effect.Effect<StoredGraphqlSource | null, Error>;101 ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
85 102
86 /** Update config (endpoint, headers) for an existing GraphQL source.103 /** Update config (endpoint, headers) for an existing GraphQL source.
87 * Does NOT re-introspect or re-register tools — just patches the104 * Does NOT re-introspect or re-register tools — just patches the
@@ -89,7 +106,7 @@ export interface GraphqlPluginExtension { · 1 unmodified line
89 readonly updateSource: (106 readonly updateSource: (
90 namespace: string,107 namespace: string,
91 input: GraphqlUpdateSourceInput,108 input: GraphqlUpdateSourceInput,
92 ) => Effect.Effect<void, Error>;109 ) => Effect.Effect<void, StorageFailure>;
93}110}
94 111
95// ---------------------------------------------------------------------------112// ---------------------------------------------------------------------------
@@ -282,7 +299,7 @@ export const graphqlPlugin = definePlugin( · 186 unmodified lines
282 schema: graphqlSchema,299 schema: graphqlSchema,
283 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),300 storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),
284 301
285 extension: (ctx): GraphqlPluginExtension => {302 extension: (ctx) => {
286 const resolveConfigHeaders = (303 const resolveConfigHeaders = (
287 headers: Record<string, HeaderValue> | undefined,304 headers: Record<string, HeaderValue> | undefined,
288 ) =>305 ) =>
@@ -368,13 +385,6 @@ export const graphqlPlugin = definePlugin( · 79 unmodified lines
368 return {385 return {
369 addSource: (config) =>386 addSource: (config) =>
370 addSourceInternal(config).pipe(387 addSourceInternal(config).pipe(
371 Effect.mapError((err) =>
372 err instanceof Error
373 ? err
374 : new GraphqlExtractionError({
375 message: String(err),
376 }),
377 ),
378 Effect.tap((result) =>388 Effect.tap((result) =>
379 configFile389 configFile
380 ? configFile.upsertSource(390 ? configFile.upsertSource(
@@ -406,7 +416,7 @@ export const graphqlPlugin = definePlugin( · 25 unmodified lines
406 endpoint: input.endpoint,416 endpoint: input.endpoint,
407 headers: input.headers,417 headers: input.headers,
408 }),418 }),
409 };419 } satisfies GraphqlPluginExtension;
410 },420 },
411 421
412 staticSources: (self) => [422 staticSources: (self) => [
packages/plugins/graphql/src/sdk/store.ts+8−8
@@ -1,6 +1,6 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import { defineSchema, type StorageDeps } from "@executor/sdk";3import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk";
4 4
5import { OperationBinding, type HeaderValue } from "./types";5import { OperationBinding, type HeaderValue } from "./types";
6 6
@@ -89,28 +89,28 @@ export interface GraphqlStore { · 82 unmodified lines
89 readonly upsertSource: (89 readonly upsertSource: (
90 input: StoredGraphqlSource,90 input: StoredGraphqlSource,
91 operations: readonly StoredOperation[],91 operations: readonly StoredOperation[],
92 ) => Effect.Effect<void, Error>;92 ) => Effect.Effect<void, StorageFailure>;
93 93
94 readonly updateSourceMeta: (94 readonly updateSourceMeta: (
95 namespace: string,95 namespace: string,
96 patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue> },96 patch: { readonly name?: string; readonly endpoint?: string; readonly headers?: Record<string, HeaderValue> },
97 ) => Effect.Effect<void, Error>;97 ) => Effect.Effect<void, StorageFailure>;
98 98
99 readonly getSource: (99 readonly getSource: (
100 namespace: string,100 namespace: string,
101 ) => Effect.Effect<StoredGraphqlSource | null, Error>;101 ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;
102 102
103 readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], Error>;103 readonly listSources: () => Effect.Effect<readonly StoredGraphqlSource[], StorageFailure>;
104 104
105 readonly getOperationByToolId: (105 readonly getOperationByToolId: (
106 toolId: string,106 toolId: string,
107 ) => Effect.Effect<StoredOperation | null, Error>;107 ) => Effect.Effect<StoredOperation | null, StorageFailure>;
108 108
109 readonly listOperationsBySource: (109 readonly listOperationsBySource: (
110 sourceId: string,110 sourceId: string,
111 ) => Effect.Effect<readonly StoredOperation[], Error>;111 ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
112 112
113 readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;113 readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
114}114}
115 115
116// ---------------------------------------------------------------------------116// ---------------------------------------------------------------------------
packages/plugins/keychain/src/provider.ts+18−4
@@ -1,21 +1,35 @@
1import { Effect } from "effect";1import { Effect } from "effect";
2 2
3import type { SecretProvider } from "@executor/sdk";3import { StorageError, type SecretProvider } from "@executor/sdk";
4 4
5import { getPassword, setPassword, deletePassword } from "./keyring";5import { getPassword, setPassword, deletePassword } from "./keyring";
6 6
7// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
8// SecretProvider adapter — bridges keyring into SDK resolution chain8// SecretProvider adapter — bridges keyring into SDK resolution chain
9//
10// The underlying `@napi-rs/keyring` sync API encodes "no entry" as an
11// ordinary return value (`getPassword()` → `null`, `deletePassword()` →
12// `false`), and only throws on real failures (keychain locked, permission
13// denied, platform init failure, etc.). `keyring.ts` wraps those thrown
14// failures as `KeychainError`. We translate `KeychainError` →
15// `StorageError` so the HTTP edge can capture it to telemetry and surface
16// an opaque `InternalError({ traceId })` — previously `orElseSucceed`
17// silently converted every failure into "nothing found", which made it
18// impossible to debug why secrets weren't resolving.
9// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
10 20
21const toStorageError = (cause: { readonly message: string; readonly cause?: unknown }) =>
22 new StorageError({ message: cause.message, cause: cause.cause ?? cause });
23
11export const makeKeychainProvider = (serviceName: string): SecretProvider => ({24export const makeKeychainProvider = (serviceName: string): SecretProvider => ({
12 key: "keychain",25 key: "keychain",
13 writable: true,26 writable: true,
14 get: (secretId) => getPassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => null)),27 get: (secretId) =>
28 getPassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
15 set: (secretId, value) =>29 set: (secretId, value) =>
16 setPassword(serviceName, secretId, value).pipe(Effect.orElseSucceed(() => undefined)),30 setPassword(serviceName, secretId, value).pipe(Effect.mapError(toStorageError)),
17 delete: (secretId) =>31 delete: (secretId) =>
18 deletePassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => false)),32 deletePassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)),
19 // Keychain doesn't support enumerating — you need to know the account name33 // Keychain doesn't support enumerating — you need to know the account name
20 list: undefined,34 list: undefined,
21});35});
packages/plugins/mcp/src/api/group.ts+39−48
@@ -1,7 +1,13 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
6import {
7 McpConnectionError,
8 McpOAuthError,
9 McpToolDiscoveryError,
10} from "../sdk/errors";
5import { McpStoredSourceSchema } from "../sdk/stored-source";11import { McpStoredSourceSchema } from "../sdk/stored-source";
6 12
7// Re-export for handler use13// Re-export for handler use
@@ -149,90 +155,75 @@ const CompleteOAuthResponse = Schema.Struct({ · 141 unmodified lines
149 scope: Schema.NullOr(Schema.String),155 scope: Schema.NullOr(Schema.String),
150});156});
151 157
152// ---------------------------------------------------------------------------
153// Error
154// ---------------------------------------------------------------------------
155
156export class McpApiError extends Schema.TaggedError<McpApiError>()(
157 "McpApiError",
158 {
159 message: Schema.String,
160 },
161 HttpApiSchema.annotations({ status: 400 }),
162) {}
163
164export class McpInternalError extends Schema.TaggedError<McpInternalError>()(
165 "McpInternalError",
166 {
167 message: Schema.String,
168 },
169 HttpApiSchema.annotations({ status: 500 }),
170) {}
171
172// ---------------------------------------------------------------------------158// ---------------------------------------------------------------------------
173// Group159// Group
160//
161// Plugin SDK errors (McpOAuthError etc.) are declared once at the group
162// level via `.addError(...)` — every endpoint inherits them. The errors
163// themselves carry their HTTP status via `HttpApiSchema.annotations`
164// in errors.ts, so handlers just `return yield* ext.foo(...)` and the
165// schema encodes whatever it gets.
166//
167// 5xx is handled at the API level: `CoreExecutorApi.addError(InternalError)`
168// adds a single shared opaque-by-schema 500 surface to every endpoint in
169// the entire API. Defects are captured + downgraded to it by an
170// HttpApiBuilder middleware (see apps/cloud/src/observability.ts).
171// No per-handler wrapping, no per-plugin InternalError.
174// ---------------------------------------------------------------------------172// ---------------------------------------------------------------------------
175 173
176export class McpGroup extends HttpApiGroup.make("mcp")174export class McpGroup extends HttpApiGroup.make("mcp")
177 .add(175 .add(
178 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`176 HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe`
179 .setPayload(ProbeEndpointPayload)177 .setPayload(ProbeEndpointPayload)
180 .addSuccess(ProbeEndpointResponse)178 .addSuccess(ProbeEndpointResponse),
181 .addError(McpApiError)
182 .addError(McpInternalError),
183 )179 )
184 .add(180 .add(
185 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`181 HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources`
186 .setPayload(AddSourcePayload)182 .setPayload(AddSourcePayload)
187 .addSuccess(AddSourceResponse)183 .addSuccess(AddSourceResponse),
188 .addError(McpApiError)
189 .addError(McpInternalError),
190 )184 )
191 .add(185 .add(
192 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`186 HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove`
193 .setPayload(NamespacePayload)187 .setPayload(NamespacePayload)
194 .addSuccess(RemoveSourceResponse)188 .addSuccess(RemoveSourceResponse),
195 .addError(McpApiError)
196 .addError(McpInternalError),
197 )189 )
198 .add(190 .add(
199 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`191 HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh`
200 .setPayload(NamespacePayload)192 .setPayload(NamespacePayload)
201 .addSuccess(RefreshSourceResponse)193 .addSuccess(RefreshSourceResponse),
202 .addError(McpApiError)
203 .addError(McpInternalError),
204 )194 )
205 .add(195 .add(
206 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`196 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start`
207 .setPayload(StartOAuthPayload)197 .setPayload(StartOAuthPayload)
208 .addSuccess(StartOAuthResponse)198 .addSuccess(StartOAuthResponse),
209 .addError(McpApiError)
210 .addError(McpInternalError),
211 )199 )
212 .add(200 .add(
213 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`201 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete`
214 .setPayload(CompleteOAuthPayload)202 .setPayload(CompleteOAuthPayload)
215 .addSuccess(CompleteOAuthResponse)203 .addSuccess(CompleteOAuthResponse),
216 .addError(McpApiError)
217 .addError(McpInternalError),
218 )204 )
219 .add(205 .add(
220 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`206 HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback`
221 .setUrlParams(OAuthCallbackParams)207 .setUrlParams(OAuthCallbackParams)
222 .addSuccess(HtmlResponse)208 .addSuccess(HtmlResponse),
223 .addError(McpApiError)
224 .addError(McpInternalError),
225 )209 )
226 .add(210 .add(
227 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`211 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
228 .addSuccess(Schema.NullOr(McpStoredSourceSchema))212 .addSuccess(Schema.NullOr(McpStoredSourceSchema)),
229 .addError(McpApiError)
230 .addError(McpInternalError),
231 )213 )
232 .add(214 .add(
233 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`215 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}`
234 .setPayload(UpdateSourcePayload)216 .setPayload(UpdateSourcePayload)
235 .addSuccess(UpdateSourceResponse)217 .addSuccess(UpdateSourceResponse),
236 .addError(McpApiError)218 )
237 .addError(McpInternalError),219 // Errors declared once at the group level — every endpoint inherits.
238 ) {}220 // Plugin domain errors carry their own HttpApiSchema status (4xx);
221 // `InternalError` is the shared opaque 500 translated at the HTTP
222 // edge by `withCapture`. We only list errors an MCP *group*
223 // endpoint can surface: `McpInvocationError` is thrown inside
224 // `invokeTool` which is reached via the core `tools.invoke`
225 // endpoint, not any MCP-group endpoint, so it doesn't belong here.
226 .addError(InternalError)
227 .addError(McpOAuthError)
228 .addError(McpConnectionError)
229 .addError(McpToolDiscoveryError) {}
packages/plugins/mcp/src/api/handlers.test.ts+59−40
@@ -1,8 +1,17 @@
1// ---------------------------------------------------------------------------
2// Handler-level integration test for the MCP 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
1import { HttpApiBuilder, HttpServer } from "@effect/platform";10import { HttpApiBuilder, HttpServer } from "@effect/platform";
2import { describe, expect, it } from "vitest";11import { describe, expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";12import { Effect, Layer } from "effect";
4 13
5import { addGroup } from "@executor/api";14import { addGroup, observabilityMiddleware } from "@executor/api";
6import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";15import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server";
7import type { McpPluginExtension } from "../sdk/plugin";16import type { McpPluginExtension } from "../sdk/plugin";
8import { McpExtensionService, McpHandlers } from "./handlers";17import { McpExtensionService, McpHandlers } from "./handlers";
@@ -10,7 +19,7 @@ import { McpGroup } from "./group"; · 1 unmodified line
10 19
11const unused = Effect.dieMessage("unused");20const unused = Effect.dieMessage("unused");
12 21
13const createFailingExtension = (): McpPluginExtension => ({22const failingExtension: McpPluginExtension = {
14 probeEndpoint: () => Effect.die(new Error("Not implemented")),23 probeEndpoint: () => Effect.die(new Error("Not implemented")),
15 addSource: () => unused,24 addSource: () => unused,
16 removeSource: () => unused,25 removeSource: () => unused,
@@ -19,48 +28,58 @@ const createFailingExtension = (): McpPluginExtension => ({ · 2 unmodified lines
19 completeOAuth: () => Effect.die(new Error("Not implemented")),28 completeOAuth: () => Effect.die(new Error("Not implemented")),
20 getSource: () => Effect.succeed(null),29 getSource: () => Effect.succeed(null),
21 updateSource: () => unused,30 updateSource: () => unused,
22});31};
23 32
24const Api = addGroup(McpGroup);33const Api = addGroup(McpGroup);
25 34
26const fakeExecutor = {} as any;35// `acquireRelease` keeps disposal inside the Effect scope — no
27const fakeExecutionEngine = {} as any;36// try/finally, no per-test cleanup plumbing. `it.scoped` closes the
28 37// scope for us. Each `Layer.provide` satisfies a piece of the api
29const createHandler = () =>38// builder's dependency graph; `provideMerge` at the bottom keeps
30 HttpApiBuilder.toWebHandler(39// framework services available to the router itself.
31 HttpApiBuilder.api(Api).pipe(40const WebHandler = Effect.acquireRelease(
32 Layer.provide(CoreHandlers),41 Effect.sync(() =>
33 Layer.provide(McpHandlers),42 HttpApiBuilder.toWebHandler(
34 Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)),43 HttpApiBuilder.api(Api).pipe(
35 Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)),44 Layer.provide(CoreHandlers),
36 Layer.provide(Layer.succeed(McpExtensionService, createFailingExtension())),45 Layer.provide(McpHandlers),
37 Layer.provideMerge(HttpServer.layerContext),46 Layer.provide(observabilityMiddleware(Api)),
38 Layer.provideMerge(HttpApiBuilder.Router.Live),47 Layer.provide(Layer.succeed(ExecutorService, {} as never)),
39 Layer.provideMerge(HttpApiBuilder.Middleware.layer),48 Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)),
49 Layer.provide(Layer.succeed(McpExtensionService, failingExtension)),
50 Layer.provideMerge(HttpServer.layerContext),
51 Layer.provideMerge(HttpApiBuilder.Router.Live),
52 Layer.provideMerge(HttpApiBuilder.Middleware.layer),
53 ),
40 ),54 ),
41 );55 ),
56 (web) => Effect.promise(() => web.dispose()),
57);
42 58
43describe("McpHandlers", () => {59describe("McpHandlers", () => {
44 it("sanitizes unknown endpoint failures", async () => {60 it.scoped(
45 const web = createHandler();61 "defect-returning methods produce an opaque InternalError, no leakage",
46 try {62 () =>
47 const response = await web.handler(63 Effect.gen(function* () {
48 new Request("http://localhost/scopes/scope_1/mcp/probe", {64 const web = yield* WebHandler;
49 method: "POST",65 const response = yield* Effect.promise(() =>
50 headers: { "content-type": "application/json" },66 web.handler(
51 body: JSON.stringify({ endpoint: "https://example.com/mcp" }),67 new Request("http://localhost/scopes/scope_1/mcp/probe", {
52 }),68 method: "POST",
53 );69 headers: { "content-type": "application/json" },
70 body: JSON.stringify({ endpoint: "https://example.com/mcp" }),
71 }),
72 ),
73 );
54 74
55 expect(response.status).toBe(500);75 expect(response.status).toBe(500);
56 const body = await response.json();76 const body = (yield* Effect.promise(() => response.json())) as {
57 expect(body).toEqual({77 _tag?: string;
58 _tag: "McpInternalError",78 traceId?: string;
59 message: "Internal server error",79 };
60 });80 expect(body._tag).toBe("InternalError");
61 expect(JSON.stringify(body)).not.toContain("Not implemented");81 expect(typeof body.traceId).toBe("string");
62 } finally {82 expect(JSON.stringify(body)).not.toContain("Not implemented");
63 await web.dispose();83 }),
64 }84 );
65 });
66});85});
packages/plugins/mcp/src/api/handlers.ts+51−64
@@ -1,18 +1,17 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Cause, Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";5import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin";
6import {6import { McpOAuthError } from "../sdk/errors";
7 McpConnectionError,7import { McpGroup } from "./group";
8 McpInvocationError,
9 McpOAuthError,
10 McpToolDiscoveryError,
11} from "../sdk/errors";
12import { McpApiError, McpGroup, McpInternalError } from "./group";
13 8
14// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
15// Service tag10// 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).
16// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
17 16
18export class McpExtensionService extends Context.Tag("McpExtensionService")<17export class McpExtensionService extends Context.Tag("McpExtensionService")<
@@ -85,33 +84,11 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 66 unmodified lines
85</body></html>`;84</body></html>`;
86};85};
87 86
88const toPopupErrorMessage = (error: unknown): string => {87const toPopupErrorMessage = (cause: Cause.Cause<unknown>): string => {
89 if (error instanceof McpOAuthError) {88 const err = Cause.squash(cause);
90 return error.message;89 return err instanceof McpOAuthError ? err.message : "Authentication failed";
91 }
92 return "Authentication failed";
93};
94
95const toMcpApiError = (error: unknown): McpApiError | McpInternalError => {
96 if (error instanceof McpApiError || error instanceof McpInternalError) {
97 return error;
98 }
99 if (
100 error instanceof McpConnectionError ||
101 error instanceof McpToolDiscoveryError ||
102 error instanceof McpInvocationError ||
103 error instanceof McpOAuthError
104 ) {
105 return new McpApiError({ message: error.message });
106 }
107 return new McpInternalError({ message: "Internal server error" });
108};90};
109 91
110const sanitizeMcpFailure = <A, R>(
111 effect: Effect.Effect<A, unknown, R>,
112): Effect.Effect<A, McpApiError | McpInternalError, R> =>
113 Effect.catchAllCause(effect, (cause) => Effect.fail(toMcpApiError(Cause.squash(cause))));
114
115// ---------------------------------------------------------------------------92// ---------------------------------------------------------------------------
116// Convert API payload → McpSourceConfig93// Convert API payload → McpSourceConfig
117// ---------------------------------------------------------------------------94// ---------------------------------------------------------------------------
@@ -151,7 +128,6 @@ const toSourceConfig = ( · 33 unmodified lines
151 auth?: { kind: string } & Record<string, unknown>;128 auth?: { kind: string } & Record<string, unknown>;
152 };129 };
153 130
154 // Normalize oauth2 tokenType default
155 const auth = p.auth131 const auth = p.auth
156 ? p.auth.kind === "oauth2"132 ? p.auth.kind === "oauth2"
157 ? {133 ? {
@@ -175,65 +151,74 @@ const toSourceConfig = ( · 17 unmodified lines
175 151
176// ---------------------------------------------------------------------------152// ---------------------------------------------------------------------------
177// Handlers153// Handlers
154//
155// Each handler is exactly: yield the extension service, call the method,
156// return. Plugin SDK errors flow through the typed channel and are
157// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls).
158// Defects bubble up and are captured + downgraded to `InternalError(traceId)`
159// by the API-level observability middleware (see apps/cloud/src/observability.ts).
160//
161// No `sanitize*`, no `liftDomainErrors`, no `withObservability` per handler.
162// If you find yourself adding error-handling here you're in the wrong layer.
178// ---------------------------------------------------------------------------163// ---------------------------------------------------------------------------
179 164
180export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>165export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) =>
181 handlers166 handlers
182 .handle("probeEndpoint", ({ payload }) =>167 .handle("probeEndpoint", ({ payload }) =>
183 Effect.gen(function* () {168 capture(Effect.gen(function* () {
184 const ext = yield* McpExtensionService;169 const ext = yield* McpExtensionService;
185 return yield* ext.probeEndpoint(payload.endpoint);170 return yield* ext.probeEndpoint(payload.endpoint);
186 }).pipe(sanitizeMcpFailure),171 })),
187 )172 )
188 .handle("addSource", ({ payload }) =>173 .handle("addSource", ({ payload }) =>
189 Effect.gen(function* () {174 capture(Effect.gen(function* () {
190 const ext = yield* McpExtensionService;175 const ext = yield* McpExtensionService;
191 return yield* ext.addSource(176 return yield* ext.addSource(
192 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),177 toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]),
193 );178 );
194 }).pipe(sanitizeMcpFailure),179 })),
195 )180 )
196 .handle("removeSource", ({ payload }) =>181 .handle("removeSource", ({ payload }) =>
197 Effect.gen(function* () {182 capture(Effect.gen(function* () {
198 const ext = yield* McpExtensionService;183 const ext = yield* McpExtensionService;
199 yield* ext.removeSource(payload.namespace);184 yield* ext.removeSource(payload.namespace);
200 return { removed: true };185 return { removed: true };
201 }).pipe(sanitizeMcpFailure),186 })),
202 )187 )
203 .handle("refreshSource", ({ payload }) =>188 .handle("refreshSource", ({ payload }) =>
204 Effect.gen(function* () {189 capture(Effect.gen(function* () {
205 const ext = yield* McpExtensionService;190 const ext = yield* McpExtensionService;
206 return yield* ext.refreshSource(payload.namespace);191 return yield* ext.refreshSource(payload.namespace);
207 }).pipe(sanitizeMcpFailure),192 })),
208 )193 )
209 .handle("startOAuth", ({ payload }) =>194 .handle("startOAuth", ({ payload }) =>
210 Effect.gen(function* () {195 capture(Effect.gen(function* () {
211 const ext = yield* McpExtensionService;196 const ext = yield* McpExtensionService;
212 return yield* ext.startOAuth({197 return yield* ext.startOAuth({
213 endpoint: payload.endpoint,198 endpoint: payload.endpoint,
214 redirectUrl: payload.redirectUrl,199 redirectUrl: payload.redirectUrl,
215 queryParams: payload.queryParams,200 queryParams: payload.queryParams,
216 });201 });
217 }).pipe(sanitizeMcpFailure),202 })),
218 )203 )
219 .handle("completeOAuth", ({ payload }) =>204 .handle("completeOAuth", ({ payload }) =>
220 Effect.gen(function* () {205 capture(Effect.gen(function* () {
221 const ext = yield* McpExtensionService;206 const ext = yield* McpExtensionService;
222 return yield* ext.completeOAuth({207 return yield* ext.completeOAuth({
223 state: payload.state,208 state: payload.state,
224 code: payload.code,209 code: payload.code,
225 error: payload.error,210 error: payload.error,
226 });211 });
227 }).pipe(sanitizeMcpFailure),212 })),
228 )213 )
229 .handle("getSource", ({ path }) =>214 .handle("getSource", ({ path }) =>
230 Effect.gen(function* () {215 capture(Effect.gen(function* () {
231 const ext = yield* McpExtensionService;216 const ext = yield* McpExtensionService;
232 return yield* ext.getSource(path.namespace);217 return yield* ext.getSource(path.namespace);
233 }).pipe(sanitizeMcpFailure),218 })),
234 )219 )
235 .handle("updateSource", ({ path, payload }) =>220 .handle("updateSource", ({ path, payload }) =>
236 Effect.gen(function* () {221 capture(Effect.gen(function* () {
237 const ext = yield* McpExtensionService;222 const ext = yield* McpExtensionService;
238 yield* ext.updateSource(path.namespace, {223 yield* ext.updateSource(path.namespace, {
239 name: payload.name,224 name: payload.name,
@@ -243,20 +228,23 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
243 auth: payload.auth as McpUpdateSourceInput["auth"],228 auth: payload.auth as McpUpdateSourceInput["auth"],
244 });229 });
245 return { updated: true };230 return { updated: true };
246 }).pipe(sanitizeMcpFailure),231 })),
247 )232 )
248 .handle("oauthCallback", ({ urlParams }) =>233 .handle("oauthCallback", ({ urlParams }) =>
249 Effect.gen(function* () {234 // OAuth popup is special: it always returns 200 HTML and renders the
235 // failure into the popup body so the parent window's listener gets a
236 // structured result. Catching here is intentional, not a leak.
237 capture(Effect.gen(function* () {
250 const ext = yield* McpExtensionService;238 const ext = yield* McpExtensionService;
251 const result = yield* ext239 const result = yield* Effect.matchCauseEffect(
252 .completeOAuth({240 ext.completeOAuth({
253 state: urlParams.state,241 state: urlParams.state,
254 code: urlParams.code,242 code: urlParams.code,
255 error: urlParams.error ?? urlParams.error_description,243 error: urlParams.error ?? urlParams.error_description,
256 })244 }),
257 .pipe(245 {
258 Effect.map(246 onSuccess: (c) =>
259 (c): OAuthPopupResult => ({247 Effect.succeed<OAuthPopupResult>({
260 type: "executor:oauth-result",248 type: "executor:oauth-result",
261 ok: true,249 ok: true,
262 sessionId: urlParams.state,250 sessionId: urlParams.state,
@@ -266,17 +254,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines
266 expiresAt: c.expiresAt,254 expiresAt: c.expiresAt,
267 scope: c.scope,255 scope: c.scope,
268 }),256 }),
269 ),257 onFailure: (cause) =>
270 Effect.catchAllCause((cause) =>
271 Effect.succeed<OAuthPopupResult>({258 Effect.succeed<OAuthPopupResult>({
272 type: "executor:oauth-result",259 type: "executor:oauth-result",
273 ok: false,260 ok: false,
274 sessionId: null,261 sessionId: null,
275 error: toPopupErrorMessage(Cause.squash(cause)),262 error: toPopupErrorMessage(cause),
276 }),263 }),
277 ),264 },
278 );265 );
279 return yield* HttpServerResponse.html(popupDocument(result));266 return yield* HttpServerResponse.html(popupDocument(result));
280 }).pipe(sanitizeMcpFailure),267 })),
281 ),268 ),
282);269);
packages/plugins/mcp/src/sdk/binding-store.ts+24−12
@@ -5,7 +5,11 @@ · 4 unmodified lines
5 5
6import { Effect, Schema } from "effect";6import { Effect, Schema } from "effect";
7 7
8import { defineSchema, type StorageDeps } from "@executor/sdk";8import {
9 defineSchema,
10 type StorageDeps,
11 type StorageFailure,
12} from "@executor/sdk";
9 13
10import { McpToolBinding, McpStoredSourceData } from "./types";14import { McpToolBinding, McpStoredSourceData } from "./types";
11import { McpOAuthSession } from "./oauth";15import { McpOAuthSession } from "./oauth";
@@ -91,37 +95,45 @@ export interface McpStoredSource { · 79 unmodified lines
91// Store interface95// Store interface
92// ---------------------------------------------------------------------------96// ---------------------------------------------------------------------------
93 97
98// Every method routes through the typed adapter (`ctx.storage.adapter`)
99// so the typed error channel is `StorageFailure`. Schema-decode failures
100// inside `Effect.gen` land as defects, not typed errors, and are caught
101// by the HTTP edge's observability middleware.
94export interface McpBindingStore {102export interface McpBindingStore {
95 readonly getBinding: (103 readonly getBinding: (
96 toolId: string,104 toolId: string,
97 ) => Effect.Effect<105 ) => Effect.Effect<
98 { readonly binding: McpToolBinding; readonly namespace: string } | null,106 { readonly binding: McpToolBinding; readonly namespace: string } | null,
99 Error107 StorageFailure
100 >;108 >;
101 109
102 readonly putBindings: (110 readonly putBindings: (
103 namespace: string,111 namespace: string,
104 entries: ReadonlyArray<{ readonly toolId: string; readonly binding: McpToolBinding }>,112 entries: ReadonlyArray<{ readonly toolId: string; readonly binding: McpToolBinding }>,
105 ) => Effect.Effect<void, Error>;113 ) => Effect.Effect<void, StorageFailure>;
106 114
107 readonly removeBindingsByNamespace: (namespace: string) => Effect.Effect<void, Error>;115 readonly removeBindingsByNamespace: (
116 namespace: string,
117 ) => Effect.Effect<void, StorageFailure>;
108 118
109 readonly listSources: () => Effect.Effect<readonly McpStoredSource[], Error>;119 readonly listSources: () => Effect.Effect<readonly McpStoredSource[], StorageFailure>;
110 readonly getSource: (namespace: string) => Effect.Effect<McpStoredSource | null, Error>;120 readonly getSource: (
121 namespace: string,
122 ) => Effect.Effect<McpStoredSource | null, StorageFailure>;
111 readonly getSourceConfig: (123 readonly getSourceConfig: (
112 namespace: string,124 namespace: string,
113 ) => Effect.Effect<McpStoredSourceData | null, Error>;125 ) => Effect.Effect<McpStoredSourceData | null, StorageFailure>;
114 readonly putSource: (source: McpStoredSource) => Effect.Effect<void, Error>;126 readonly putSource: (source: McpStoredSource) => Effect.Effect<void, StorageFailure>;
115 readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;127 readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
116 128
117 readonly putOAuthSession: (129 readonly putOAuthSession: (
118 sessionId: string,130 sessionId: string,
119 session: McpOAuthSession,131 session: McpOAuthSession,
120 ) => Effect.Effect<void, Error>;132 ) => Effect.Effect<void, StorageFailure>;
121 readonly getOAuthSession: (133 readonly getOAuthSession: (
122 sessionId: string,134 sessionId: string,
123 ) => Effect.Effect<McpOAuthSession | null, Error>;135 ) => Effect.Effect<McpOAuthSession | null, StorageFailure>;
124 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;136 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
125}137}
126 138
127// ---------------------------------------------------------------------------139// ---------------------------------------------------------------------------
packages/plugins/mcp/src/sdk/errors.ts+16−3
@@ -1,4 +1,10 @@
1// MCP plugin tagged errors. Each carries an `HttpApiSchema` annotation so
2// it can be `.addError(...)` directly on the API group — handlers return
3// these and HttpApi encodes them as 4xx responses with a typed body. No
4// per-handler sanitisation step.
5
1import { Schema } from "effect";6import { Schema } from "effect";
7import { HttpApiSchema } from "@effect/platform";
2 8
3export class McpConnectionError extends Schema.TaggedError<McpConnectionError>()(9export class McpConnectionError extends Schema.TaggedError<McpConnectionError>()(
4 "McpConnectionError",10 "McpConnectionError",
@@ -6,6 +12,7 @@ export class McpConnectionError extends Schema.TaggedError<McpConnectionError>() · 1 unmodified line
6 transport: Schema.String,12 transport: Schema.String,
7 message: Schema.String,13 message: Schema.String,
8 },14 },
15 HttpApiSchema.annotations({ status: 400 }),
9) {}16) {}
10 17
11export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryError>()(18export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryError>()(
@@ -14,6 +21,7 @@ export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryEr · 2 unmodified lines
14 stage: Schema.Literal("connect", "list_tools"),21 stage: Schema.Literal("connect", "list_tools"),
15 message: Schema.String,22 message: Schema.String,
16 },23 },
24 HttpApiSchema.annotations({ status: 400 }),
17) {}25) {}
18 26
19export class McpInvocationError extends Schema.TaggedError<McpInvocationError>()(27export class McpInvocationError extends Schema.TaggedError<McpInvocationError>()(
@@ -22,8 +30,13 @@ export class McpInvocationError extends Schema.TaggedError<McpInvocationError>() · 2 unmodified lines
22 toolName: Schema.String,30 toolName: Schema.String,
23 message: Schema.String,31 message: Schema.String,
24 },32 },
33 HttpApiSchema.annotations({ status: 400 }),
25) {}34) {}
26 35
27export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()("McpOAuthError", {36export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()(
28 message: Schema.String,37 "McpOAuthError",
29}) {}38 {
39 message: Schema.String,
40 },
41 HttpApiSchema.annotations({ status: 400 }),
42) {}
packages/plugins/mcp/src/sdk/invoke.ts+9−26
@@ -21,7 +21,7 @@ import { · 20 unmodified lines
21 type ElicitationRequest,21 type ElicitationRequest,
22} from "@executor/sdk";22} from "@executor/sdk";
23 23
24import { McpConnectionError } from "./errors";24import { McpConnectionError, McpInvocationError } from "./errors";
25import type { McpConnection } from "./connection";25import type { McpConnection } from "./connection";
26import type { McpStoredSourceData } from "./types";26import type { McpStoredSourceData } from "./types";
27 27
@@ -124,17 +124,18 @@ const useConnection = ( · 96 unmodified lines
124 toolName: string,124 toolName: string,
125 args: Record<string, unknown>,125 args: Record<string, unknown>,
126 elicit: Elicit,126 elicit: Elicit,
127): Effect.Effect<unknown, Error> =>127): Effect.Effect<unknown, McpInvocationError> =>
128 Effect.gen(function* () {128 Effect.gen(function* () {
129 installElicitationHandler(connection.client, elicit);129 installElicitationHandler(connection.client, elicit);
130 return yield* Effect.tryPromise({130 return yield* Effect.tryPromise({
131 try: () => connection.client.callTool({ name: toolName, arguments: args }),131 try: () => connection.client.callTool({ name: toolName, arguments: args }),
132 catch: (cause) =>132 catch: (cause) =>
133 new Error(133 new McpInvocationError({
134 `MCP tool call failed for ${toolName}: ${134 toolName,
135 message: `MCP tool call failed for ${toolName}: ${
135 cause instanceof Error ? cause.message : String(cause)136 cause instanceof Error ? cause.message : String(cause)
136 }`,137 }`,
137 ),138 }),
138 });139 });
139 });140 });
140 141
@@ -162,7 +163,7 @@ export interface InvokeMcpToolInput { · 21 unmodified lines
162 163
163export const invokeMcpTool = (164export const invokeMcpTool = (
164 input: InvokeMcpToolInput,165 input: InvokeMcpToolInput,
165): Effect.Effect<unknown, Error> =>166): Effect.Effect<unknown, McpConnectionError | McpInvocationError> =>
166 Effect.gen(function* () {167 Effect.gen(function* () {
167 const cacheKey = connectionCacheKey(input.sourceData);168 const cacheKey = connectionCacheKey(input.sourceData);
168 const args = asRecord(input.args);169 const args = asRecord(input.args);
@@ -172,16 +173,7 @@ export const invokeMcpTool = ( · 3 unmodified lines
172 const connector = input.resolveConnector();173 const connector = input.resolveConnector();
173 input.pendingConnectors.set(cacheKey, connector);174 input.pendingConnectors.set(cacheKey, connector);
174 175
175 const firstConnection = yield* input.connectionCache.get(cacheKey).pipe(176 const firstConnection = yield* input.connectionCache.get(cacheKey);
176 Effect.mapError(
177 (err) =>
178 new Error(
179 `Failed connecting to MCP server: ${
180 err instanceof Error ? err.message : String(err)
181 }`,
182 ),
183 ),
184 );
185 177
186 return yield* useConnection(178 return yield* useConnection(
187 firstConnection,179 firstConnection,
@@ -195,16 +187,7 @@ export const invokeMcpTool = ( · 7 unmodified lines
195 Effect.gen(function* () {187 Effect.gen(function* () {
196 yield* input.connectionCache.invalidate(cacheKey);188 yield* input.connectionCache.invalidate(cacheKey);
197 input.pendingConnectors.set(cacheKey, connector);189 input.pendingConnectors.set(cacheKey, connector);
198 const fresh = yield* input.connectionCache.get(cacheKey).pipe(190 const fresh = yield* input.connectionCache.get(cacheKey);
199 Effect.mapError(
200 (err) =>
201 new Error(
202 `Failed reconnecting to MCP server: ${
203 err instanceof Error ? err.message : String(err)
204 }`,
205 ),
206 ),
207 );
208 return yield* useConnection(191 return yield* useConnection(
209 fresh,192 fresh,
210 input.toolName,193 input.toolName,
packages/plugins/mcp/src/sdk/plugin.ts+34−12
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 SetSecretInput,9 SetSecretInput,
10 SourceDetectionResult,10 SourceDetectionResult,
11 type PluginCtx,11 type PluginCtx,
12 type StorageFailure,
12} from "@executor/sdk";13} from "@executor/sdk";
13 14
14import {15import {
@@ -211,7 +212,7 @@ const resolveConnectorInput = ( · 196 unmodified lines
211 sd: McpStoredSourceData,212 sd: McpStoredSourceData,
212 ctx: PluginCtx<McpBindingStore>,213 ctx: PluginCtx<McpBindingStore>,
213 allowStdio: boolean,214 allowStdio: boolean,
214): Effect.Effect<ConnectorInput, Error> => {215): Effect.Effect<ConnectorInput, McpConnectionError | StorageFailure> => {
215 if (sd.transport === "stdio") {216 if (sd.transport === "stdio") {
216 if (!allowStdio) {217 if (!allowStdio) {
217 return Effect.fail(218 return Effect.fail(
@@ -291,7 +292,7 @@ interface McpRuntime { · 73 unmodified lines
291 readonly cacheScope: Scope.CloseableScope;292 readonly cacheScope: Scope.CloseableScope;
292}293}
293 294
294const makeRuntime = (): Effect.Effect<McpRuntime, Error> =>295const makeRuntime = (): Effect.Effect<McpRuntime, never> =>
295 Effect.gen(function* () {296 Effect.gen(function* () {
296 const cacheScope = yield* Scope.make();297 const cacheScope = yield* Scope.make();
297 const pendingConnectors = new Map<298 const pendingConnectors = new Map<
@@ -403,7 +404,7 @@ export const mcpPlugin = definePlugin( · 105 unmodified lines
403 // connection cache across a single createExecutor lifecycle.404 // connection cache across a single createExecutor lifecycle.
404 const runtimeRef: { current: McpRuntime | null } = { current: null };405 const runtimeRef: { current: McpRuntime | null } = { current: null };
405 406
406 const ensureRuntime = (): Effect.Effect<McpRuntime, Error> =>407 const ensureRuntime = (): Effect.Effect<McpRuntime, never> =>
407 runtimeRef.current408 runtimeRef.current
408 ? Effect.succeed(runtimeRef.current)409 ? Effect.succeed(runtimeRef.current)
409 : makeRuntime().pipe(410 : makeRuntime().pipe(
@@ -796,7 +797,7 @@ export const mcpPlugin = definePlugin( · 386 unmodified lines
796 completeOAuth,797 completeOAuth,
797 getSource,798 getSource,
798 updateSource,799 updateSource,
799 };800 } satisfies McpPluginExtension;
800 },801 },
801 802
802 invokeTool: ({ ctx, toolRow, args, elicit }) =>803 invokeTool: ({ ctx, toolRow, args, elicit }) =>
@@ -938,28 +939,49 @@ export const mcpPlugin = definePlugin( · 135 unmodified lines
938// returns above.939// returns above.
939// ---------------------------------------------------------------------------940// ---------------------------------------------------------------------------
940 941
942/**
943 * Errors any MCP extension method may surface. The first four are
944 * plugin-domain tagged errors that flow directly to clients (4xx, each
945 * carrying its own `HttpApiSchema` status). `StorageFailure` covers
946 * raw backend failures (`StorageError`) plus `UniqueViolationError`;
947 * the HTTP edge (`@executor/api`'s `withCapture`) translates
948 * `StorageError` to the opaque `InternalError({ traceId })` at Layer
949 * composition. `UniqueViolationError` passes through — plugins can
950 * `Effect.catchTag` it if they want a friendlier user-facing error.
951 */
952export type McpExtensionFailure =
953 | McpOAuthError
954 | McpConnectionError
955 | McpToolDiscoveryError
956 | StorageFailure;
957
941export interface McpPluginExtension {958export interface McpPluginExtension {
942 readonly probeEndpoint: (959 readonly probeEndpoint: (
943 endpoint: string,960 endpoint: string,
944 ) => Effect.Effect<McpProbeResult, Error>;961 ) => Effect.Effect<McpProbeResult, McpExtensionFailure>;
945 readonly addSource: (962 readonly addSource: (
946 config: McpSourceConfig,963 config: McpSourceConfig,
947 ) => Effect.Effect<{ readonly toolCount: number; readonly namespace: string }, Error>;964 ) => Effect.Effect<
948 readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;965 { readonly toolCount: number; readonly namespace: string },
966 McpExtensionFailure
967 >;
968 readonly removeSource: (
969 namespace: string,
970 ) => Effect.Effect<void, McpExtensionFailure>;
949 readonly refreshSource: (971 readonly refreshSource: (
950 namespace: string,972 namespace: string,
951 ) => Effect.Effect<{ readonly toolCount: number }, Error>;973 ) => Effect.Effect<{ readonly toolCount: number }, McpExtensionFailure>;
952 readonly startOAuth: (974 readonly startOAuth: (
953 input: McpOAuthStartInput,975 input: McpOAuthStartInput,
954 ) => Effect.Effect<McpOAuthStartResponse, Error>;976 ) => Effect.Effect<McpOAuthStartResponse, McpExtensionFailure>;
955 readonly completeOAuth: (977 readonly completeOAuth: (
956 input: McpOAuthCompleteInput,978 input: McpOAuthCompleteInput,
957 ) => Effect.Effect<McpOAuthCompleteResponse, Error>;979 ) => Effect.Effect<McpOAuthCompleteResponse, McpExtensionFailure>;
958 readonly getSource: (980 readonly getSource: (
959 namespace: string,981 namespace: string,
960 ) => Effect.Effect<McpStoredSource | null, Error>;982 ) => Effect.Effect<McpStoredSource | null, McpExtensionFailure>;
961 readonly updateSource: (983 readonly updateSource: (
962 namespace: string,984 namespace: string,
963 input: McpUpdateSourceInput,985 input: McpUpdateSourceInput,
964 ) => Effect.Effect<void, Error>;986 ) => Effect.Effect<void, McpExtensionFailure>;
965}987}
packages/plugins/onepassword/src/api/group.ts+19−14
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import { OnePasswordError } from "../sdk/errors";6import { OnePasswordError } from "../sdk/errors";
6import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";7import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types";
@@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines
32 33
33const GetConfigResponse = Schema.NullOr(OnePasswordConfig);34const GetConfigResponse = Schema.NullOr(OnePasswordConfig);
34 35
35// ---------------------------------------------------------------------------
36// Errors with HTTP status
37// ---------------------------------------------------------------------------
38
39const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 }));
40
41// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
42// Group37// 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.
43// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
44 49
45export class OnePasswordGroup extends HttpApiGroup.make("onepassword")50export class OnePasswordGroup extends HttpApiGroup.make("onepassword")
@@ -51,8 +56,7 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 5 unmodified lines
51 .add(56 .add(
52 HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config`57 HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config`
53 .setPayload(ConfigurePayload)58 .setPayload(ConfigurePayload)
54 .addSuccess(Schema.Void)59 .addSuccess(Schema.Void),
55 .addError(OpError),
56 )60 )
57 .add(61 .add(
58 HttpApiEndpoint.del("removeConfig")`/scopes/${scopeIdParam}/onepassword/config`.addSuccess(62 HttpApiEndpoint.del("removeConfig")`/scopes/${scopeIdParam}/onepassword/config`.addSuccess(
@@ -60,13 +64,14 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 1 unmodified line
60 ),64 ),
61 )65 )
62 .add(66 .add(
63 HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`67 HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`.addSuccess(
64 .addSuccess(ConnectionStatus)68 ConnectionStatus,
65 .addError(OpError),69 ),
66 )70 )
67 .add(71 .add(
68 HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults`72 HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults`
69 .setUrlParams(ListVaultsParams)73 .setUrlParams(ListVaultsParams)
70 .addSuccess(ListVaultsResponse)74 .addSuccess(ListVaultsResponse),
71 .addError(OpError),75 )
72 ) {}76 .addError(InternalError)
77 .addError(OnePasswordError) {}
packages/plugins/onepassword/src/api/handlers.ts+25−12
@@ -1,12 +1,19 @@
1import { HttpApiBuilder } from "@effect/platform";1import { HttpApiBuilder } from "@effect/platform";
2import { Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { addGroup } from "@executor/api";4import { addGroup, capture } from "@executor/api";
5import type { OnePasswordExtension } from "../sdk/plugin";5import type { OnePasswordExtension } from "../sdk/plugin";
6import { OnePasswordGroup } from "./group";6import { OnePasswordGroup } from "./group";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// Service tag — the server provides the 1Password extension9// 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.
10// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
11 18
12export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<19export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")<
@@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 9 unmodified lines
22 29
23// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
24// Handlers31// 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.
25// ---------------------------------------------------------------------------38// ---------------------------------------------------------------------------
26 39
27export const OnePasswordHandlers = HttpApiBuilder.group(40export const OnePasswordHandlers = HttpApiBuilder.group(
@@ -30,31 +43,31 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 2 unmodified lines
30 (handlers) =>43 (handlers) =>
31 handlers44 handlers
32 .handle("getConfig", () =>45 .handle("getConfig", () =>
33 Effect.gen(function* () {46 capture(Effect.gen(function* () {
34 const ext = yield* OnePasswordExtensionService;47 const ext = yield* OnePasswordExtensionService;
35 return yield* ext.getConfig();48 return yield* ext.getConfig();
36 }).pipe(Effect.orDie),49 })),
37 )50 )
38 .handle("configure", ({ payload }) =>51 .handle("configure", ({ payload }) =>
39 Effect.gen(function* () {52 capture(Effect.gen(function* () {
40 const ext = yield* OnePasswordExtensionService;53 const ext = yield* OnePasswordExtensionService;
41 yield* ext.configure(payload);54 yield* ext.configure(payload);
42 }).pipe(Effect.orDie),55 })),
43 )56 )
44 .handle("removeConfig", () =>57 .handle("removeConfig", () =>
45 Effect.gen(function* () {58 capture(Effect.gen(function* () {
46 const ext = yield* OnePasswordExtensionService;59 const ext = yield* OnePasswordExtensionService;
47 yield* ext.removeConfig();60 yield* ext.removeConfig();
48 }).pipe(Effect.orDie),61 })),
49 )62 )
50 .handle("status", () =>63 .handle("status", () =>
51 Effect.gen(function* () {64 capture(Effect.gen(function* () {
52 const ext = yield* OnePasswordExtensionService;65 const ext = yield* OnePasswordExtensionService;
53 return yield* ext.status();66 return yield* ext.status();
54 }).pipe(Effect.orDie),67 })),
55 )68 )
56 .handle("listVaults", ({ urlParams }) =>69 .handle("listVaults", ({ urlParams }) =>
57 Effect.gen(function* () {70 capture(Effect.gen(function* () {
58 const ext = yield* OnePasswordExtensionService;71 const ext = yield* OnePasswordExtensionService;
59 const auth =72 const auth =
60 urlParams.authKind === "desktop-app"73 urlParams.authKind === "desktop-app"
@@ -62,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line
62 : { kind: "service-account" as const, tokenSecretId: urlParams.account };75 : { kind: "service-account" as const, tokenSecretId: urlParams.account };
63 const vaults = yield* ext.listVaults(auth);76 const vaults = yield* ext.listVaults(auth);
64 return { vaults: [...vaults] };77 return { vaults: [...vaults] };
65 }).pipe(Effect.orDie),78 })),
66 ),79 ),
67);80);
packages/plugins/onepassword/src/sdk/errors.ts+9−4
@@ -1,6 +1,11 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { HttpApiSchema } from "@effect/platform";
2 3
3export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", {4export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()(
4 operation: Schema.String,5 "OnePasswordError",
5 message: Schema.String,6 {
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
2 2
3import {3import {
4 definePlugin,4 definePlugin,
5 StorageError,
5 type PluginCtx,6 type PluginCtx,
6 type ScopedBlobStore,7 type ScopedBlobStore,
7 type SecretProvider,8 type SecretProvider,
9 type StorageFailure,
8} from "@executor/sdk";10} from "@executor/sdk";
9 11
10import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";12import { OnePasswordConfig, Vault, ConnectionStatus } from "./types";
@@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines
24const DEFAULT_TIMEOUT_MS = 15_000;26const DEFAULT_TIMEOUT_MS = 15_000;
25const CONFIG_KEY = "config";27const CONFIG_KEY = "config";
26 28
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
41export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure;
42
27// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
28// Plugin extension — public API on executor.onepassword44// Plugin extension — public API on executor.onepassword
29// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
@@ -32,59 +48,69 @@ export interface OnePasswordExtension { · 2 unmodified lines
32 /** Configure the 1Password connection */48 /** Configure the 1Password connection */
33 readonly configure: (49 readonly configure: (
34 config: OnePasswordConfig,50 config: OnePasswordConfig,
35 ) => Effect.Effect<void, OnePasswordError>;51 ) => Effect.Effect<void, StorageFailure>;
36 52
37 /** Get current configuration (if any) */53 /** Get current configuration (if any) */
38 readonly getConfig: () => Effect.Effect<54 readonly getConfig: () => Effect.Effect<
39 OnePasswordConfig | null,55 OnePasswordConfig | null,
40 OnePasswordError56 OnePasswordExtensionFailure
41 >;57 >;
42 58
43 /** Remove the 1Password configuration */59 /** Remove the 1Password configuration */
44 readonly removeConfig: () => Effect.Effect<void, OnePasswordError>;60 readonly removeConfig: () => Effect.Effect<void, StorageFailure>;
45 61
46 /** Check connection status */62 /** Check connection status */
47 readonly status: () => Effect.Effect<ConnectionStatus, OnePasswordError>;63 readonly status: () => Effect.Effect<
64 ConnectionStatus,
65 OnePasswordExtensionFailure
66 >;
48 67
49 /** List accessible vaults (requires auth) */68 /** List accessible vaults (requires auth) */
50 readonly listVaults: (69 readonly listVaults: (
51 auth: OnePasswordAuth,70 auth: OnePasswordAuth,
52 ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordError>;71 ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordExtensionFailure>;
53 72
54 /** Resolve a secret directly by op:// URI */73 /** 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>;
56}77}
57 78
58// ---------------------------------------------------------------------------79// ---------------------------------------------------------------------------
59// Typed config store — single blob, JSON encoded. Declared inline because80// Typed config store — single blob, JSON encoded. Blob I/O failures surface
60// there's only one key and one document; a schema table would be overkill.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.
61// ---------------------------------------------------------------------------84// ---------------------------------------------------------------------------
62 85
63export interface OnePasswordStore {86export interface OnePasswordStore {
64 readonly getConfig: () => Effect.Effect<87 readonly getConfig: () => Effect.Effect<
65 OnePasswordConfig | null,88 OnePasswordConfig | null,
66 OnePasswordError89 StorageError | OnePasswordError
67 >;90 >;
68 readonly saveConfig: (91 readonly saveConfig: (
69 config: OnePasswordConfig,92 config: OnePasswordConfig,
70 ) => Effect.Effect<void, OnePasswordError>;93 ) => Effect.Effect<void, StorageError>;
71 readonly deleteConfig: () => Effect.Effect<void, OnePasswordError>;94 readonly deleteConfig: () => Effect.Effect<void, StorageError>;
72}95}
73 96
74const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);97const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig);
75 98
99const 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
76export const makeOnePasswordStore = (108export const makeOnePasswordStore = (
77 blobs: ScopedBlobStore,109 blobs: ScopedBlobStore,
78): OnePasswordStore => ({110): OnePasswordStore => ({
79 getConfig: () =>111 getConfig: () =>
80 blobs.get(CONFIG_KEY).pipe(112 blobs.get(CONFIG_KEY).pipe(
81 Effect.mapError(113 Effect.mapError(blobStorageError("read")),
82 (cause) =>
83 new OnePasswordError({
84 operation: "config load",
85 message: cause instanceof Error ? cause.message : String(cause),
86 }),
87 ),
88 Effect.flatMap((raw) => {114 Effect.flatMap((raw) => {
89 if (raw === null) return Effect.succeed(null);115 if (raw === null) return Effect.succeed(null);
90 return Effect.try({116 return Effect.try({
@@ -108,26 +134,10 @@ export const makeOnePasswordStore = ( · 17 unmodified lines
108 name: config.name,134 name: config.name,
109 }),135 }),
110 )136 )
111 .pipe(137 .pipe(Effect.mapError(blobStorageError("write"))),
112 Effect.mapError(
113 (cause) =>
114 new OnePasswordError({
115 operation: "config save",
116 message: cause instanceof Error ? cause.message : String(cause),
117 }),
118 ),
119 ),
120 138
121 deleteConfig: () =>139 deleteConfig: () =>
122 blobs.delete(CONFIG_KEY).pipe(140 blobs.delete(CONFIG_KEY).pipe(Effect.mapError(blobStorageError("delete"))),
123 Effect.mapError(
124 (cause) =>
125 new OnePasswordError({
126 operation: "config delete",
127 message: cause instanceof Error ? cause.message : String(cause),
128 }),
129 ),
130 ),
131});141});
132 142
133// ---------------------------------------------------------------------------143// ---------------------------------------------------------------------------
@@ -137,7 +147,7 @@ export const makeOnePasswordStore = ( · 3 unmodified lines
137const resolveAuth = (147const resolveAuth = (
138 auth: OnePasswordAuth,148 auth: OnePasswordAuth,
139 ctx: PluginCtx<OnePasswordStore>,149 ctx: PluginCtx<OnePasswordStore>,
140): Effect.Effect<ResolvedAuth, OnePasswordError> => {150): Effect.Effect<ResolvedAuth, OnePasswordError | StorageFailure> => {
141 if (auth.kind === "desktop-app") {151 if (auth.kind === "desktop-app") {
142 return Effect.succeed({152 return Effect.succeed({
143 kind: "desktop-app" as const,153 kind: "desktop-app" as const,
@@ -145,13 +155,6 @@ const resolveAuth = ( · 1 unmodified line
145 });155 });
146 }156 }
147 return ctx.secrets.get(auth.tokenSecretId).pipe(157 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 ),
155 Effect.flatMap((token) => {158 Effect.flatMap((token) => {
156 if (token === null) {159 if (token === null) {
157 return Effect.fail(160 return Effect.fail(
@@ -174,7 +177,7 @@ const getServiceFromConfig = ( · 16 unmodified lines
174 ctx: PluginCtx<OnePasswordStore>,177 ctx: PluginCtx<OnePasswordStore>,
175 timeoutMs: number,178 timeoutMs: number,
176 preferSdk: boolean | undefined,179 preferSdk: boolean | undefined,
177): Effect.Effect<OnePasswordService, OnePasswordError> =>180): Effect.Effect<OnePasswordService, OnePasswordError | StorageFailure> =>
178 resolveAuth(config.auth, ctx).pipe(181 resolveAuth(config.auth, ctx).pipe(
179 Effect.flatMap((resolved) =>182 Effect.flatMap((resolved) =>
180 makeOnePasswordService(resolved, { timeoutMs, preferSdk }),183 makeOnePasswordService(resolved, { timeoutMs, preferSdk }),
@@ -252,69 +255,71 @@ export const onepasswordPlugin = definePlugin( · 71 unmodified lines
252 id: "onepassword" as const,255 id: "onepassword" as const,
253 storage: ({ blobs }) => makeOnePasswordStore(blobs),256 storage: ({ blobs }) => makeOnePasswordStore(blobs),
254 257
255 extension: (ctx): OnePasswordExtension => ({258 extension: (ctx) => {
256 configure: (config) => ctx.storage.saveConfig(config),259 return {
257 260 configure: (config) => ctx.storage.saveConfig(config),
258 getConfig: () => ctx.storage.getConfig(),261
259 262 getConfig: () => ctx.storage.getConfig(),
260 removeConfig: () => ctx.storage.deleteConfig(),263
261 264 removeConfig: () => ctx.storage.deleteConfig(),
262 status: () =>265
263 Effect.gen(function* () {266 status: () =>
264 const config = yield* ctx.storage.getConfig();267 Effect.gen(function* () {
265 if (!config) {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);
266 return new ConnectionStatus({283 return new ConnectionStatus({
267 connected: false,284 connected: true,
268 error: "Not configured",285 vaultName: vault?.title,
269 });286 });
270 }287 }),
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 }),
284 288
285 listVaults: (auth) =>289 listVaults: (auth) =>
286 Effect.gen(function* () {290 Effect.gen(function* () {
287 const resolved = yield* resolveAuth(auth, ctx);291 const resolved = yield* resolveAuth(auth, ctx);
288 const svc = yield* makeOnePasswordService(resolved, {292 const svc = yield* makeOnePasswordService(resolved, {
289 timeoutMs,293 timeoutMs,
290 preferSdk,294 preferSdk,
291 });295 });
292 const vaults = yield* svc.listVaults();296 const vaults = yield* svc.listVaults();
293 return vaults297 return vaults
294 .map((v) => new Vault({ id: v.id, name: v.title }))298 .map((v) => new Vault({ id: v.id, name: v.title }))
295 .sort((a, b) => a.name.localeCompare(b.name));299 .sort((a, b) => a.name.localeCompare(b.name));
296 }),300 }),
297 301
298 resolve: (uri) =>302 resolve: (uri) =>
299 Effect.gen(function* () {303 Effect.gen(function* () {
300 const config = yield* ctx.storage.getConfig();304 const config = yield* ctx.storage.getConfig();
301 if (!config) {305 if (!config) {
302 return yield* Effect.fail(306 return yield* Effect.fail(
303 new OnePasswordError({307 new OnePasswordError({
304 operation: "resolve",308 operation: "resolve",
305 message: "1Password is not configured",309 message: "1Password is not configured",
306 }),310 }),
311 );
312 }
313 const svc = yield* getServiceFromConfig(
314 config,
315 ctx,
316 timeoutMs,
317 preferSdk,
307 );318 );
308 }319 return yield* svc.resolveSecret(uri);
309 const svc = yield* getServiceFromConfig(320 }),
310 config,321 } satisfies OnePasswordExtension;
311 ctx,322 },
312 timeoutMs,
313 preferSdk,
314 );
315 return yield* svc.resolveSecret(uri);
316 }),
317 }),
318 323
319 secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)],324 secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)],
320 };325 };
packages/plugins/openapi/src/api/group.ts+30−28
@@ -1,6 +1,7 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";3import { ScopeId } from "@executor/sdk";
4import { InternalError } from "@executor/api";
4 5
5import {6import {
6 OpenApiParseError,7 OpenApiParseError,
@@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines
99);100);
100const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));101const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 }));
101 102
102export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()(
103 "OpenApiInternalError",
104 {
105 message: Schema.String,
106 },
107 HttpApiSchema.annotations({ status: 500 }),
108) {}
109
110// ---------------------------------------------------------------------------103// ---------------------------------------------------------------------------
111// Group104// 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)`.
112// ---------------------------------------------------------------------------118// ---------------------------------------------------------------------------
113 119
114export class OpenApiGroup extends HttpApiGroup.make("openapi")120export class OpenApiGroup extends HttpApiGroup.make("openapi")
115 .add(121 .add(
116 HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`122 HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview`
117 .setPayload(PreviewSpecPayload)123 .setPayload(PreviewSpecPayload)
118 .addSuccess(SpecPreview)124 .addSuccess(SpecPreview),
119 .addError(ParseError)
120 .addError(ExtractionError)
121 .addError(OpenApiInternalError),
122 )125 )
123 .add(126 .add(
124 HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`127 HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs`
125 .setPayload(AddSpecPayload)128 .setPayload(AddSpecPayload)
126 .addSuccess(AddSpecResponse)129 .addSuccess(AddSpecResponse),
127 .addError(ParseError)
128 .addError(ExtractionError)
129 .addError(OpenApiInternalError),
130 )130 )
131 .add(131 .add(
132 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`132 HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
133 .addSuccess(Schema.NullOr(StoredSourceSchema))133 .addSuccess(Schema.NullOr(StoredSourceSchema)),
134 .addError(OpenApiInternalError),
135 )134 )
136 .add(135 .add(
137 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`136 HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}`
138 .setPayload(UpdateSourcePayload)137 .setPayload(UpdateSourcePayload)
139 .addSuccess(UpdateSourceResponse)138 .addSuccess(UpdateSourceResponse),
140 .addError(OpenApiInternalError),
141 )139 )
142 .add(140 .add(
143 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start`141 HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start`
144 .setPayload(StartOAuthPayload)142 .setPayload(StartOAuthPayload)
145 .addSuccess(StartOAuthResponse)143 .addSuccess(StartOAuthResponse),
146 .addError(OAuthError),
147 )144 )
148 .add(145 .add(
149 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete`146 HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete`
150 .setPayload(CompleteOAuthPayload)147 .setPayload(CompleteOAuthPayload)
151 .addSuccess(OAuth2Auth)148 .addSuccess(OAuth2Auth),
152 .addError(OAuthError),
153 )149 )
154 .add(150 .add(
155 HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback")151 HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback")
@@ -158,7 +154,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 2 unmodified lines
158 Schema.Unknown.annotations(154 Schema.Unknown.annotations(
159 HttpApiSchema.annotations({ contentType: "text/html" }),155 HttpApiSchema.annotations({ contentType: "text/html" }),
160 ),156 ),
161 )157 ),
162 .addError(OAuthError)158 )
163 .addError(OpenApiInternalError),159 // Errors declared once at the group level — every endpoint inherits.
164 ) {}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+36−60
@@ -1,21 +1,17 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Context, Effect } from "effect";
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5 5
6import { addGroup } from "@executor/api";6import { addGroup, capture, InternalError } from "@executor/api";
7import {7import { OpenApiOAuthError } from "../sdk/errors";
8 OpenApiExtractionError,
9 OpenApiOAuthError,
10 OpenApiParseError,
11} from "../sdk/errors";
12import type {8import type {
13 OpenApiPluginExtension,9 OpenApiPluginExtension,
14 HeaderValue,10 HeaderValue,
15 OpenApiUpdateSourceInput,11 OpenApiUpdateSourceInput,
16} from "../sdk/plugin";12} from "../sdk/plugin";
17import { OAuth2Auth } from "../sdk/types";13import { OAuth2Auth } from "../sdk/types";
18import { OpenApiGroup, OpenApiInternalError } from "./group";14import { OpenApiGroup } from "./group";
19 15
20const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result";16const OPENAPI_OAUTH_CHANNEL = "executor:openapi-oauth-result";
21 17
@@ -25,7 +21,14 @@ const toPopupErrorMessage = (error: unknown): string => { · 3 unmodified lines
25};21};
26 22
27// ---------------------------------------------------------------------------23// ---------------------------------------------------------------------------
28// Service tag — the server provides the OpenAPI extension24// 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.
29// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
30 33
31export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<34export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")<
@@ -33,42 +36,6 @@ export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionServic · 1 unmodified line
33 OpenApiPluginExtension36 OpenApiPluginExtension
34>() {}37>() {}
35 38
36// ---------------------------------------------------------------------------
37// Failure mapping
38// ---------------------------------------------------------------------------
39
40type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError;
41
42const 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
54const 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
59const 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
65const 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
72// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
73// Composed API — core + openapi group40// Composed API — core + openapi group
74// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
@@ -77,18 +44,24 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines
77 44
78// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
79// Handlers46// 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.
80// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
81 54
82export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>55export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) =>
83 handlers56 handlers
84 .handle("previewSpec", ({ payload }) =>57 .handle("previewSpec", ({ payload }) =>
85 Effect.gen(function* () {58 capture(Effect.gen(function* () {
86 const ext = yield* OpenApiExtensionService;59 const ext = yield* OpenApiExtensionService;
87 return yield* ext.previewSpec(payload.spec);60 return yield* ext.previewSpec(payload.spec);
88 }).pipe(sanitizeSpecFailure),61 })),
89 )62 )
90 .handle("addSpec", ({ payload }) =>63 .handle("addSpec", ({ payload }) =>
91 Effect.gen(function* () {64 capture(Effect.gen(function* () {
92 const ext = yield* OpenApiExtensionService;65 const ext = yield* OpenApiExtensionService;
93 const result = yield* ext.addSpec({66 const result = yield* ext.addSpec({
94 spec: payload.spec,67 spec: payload.spec,
@@ -102,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines
102 toolCount: result.toolCount,75 toolCount: result.toolCount,
103 namespace: result.sourceId,76 namespace: result.sourceId,
104 };77 };
105 }).pipe(sanitizeSpecFailure),78 })),
106 )79 )
107 .handle("getSource", ({ path }) =>80 .handle("getSource", ({ path }) =>
108 Effect.gen(function* () {81 capture(Effect.gen(function* () {
109 const ext = yield* OpenApiExtensionService;82 const ext = yield* OpenApiExtensionService;
110 return yield* ext.getSource(path.namespace);83 return yield* ext.getSource(path.namespace);
111 }).pipe(sanitizeInternalFailure),84 })),
112 )85 )
113 .handle("updateSource", ({ path, payload }) =>86 .handle("updateSource", ({ path, payload }) =>
114 Effect.gen(function* () {87 capture(Effect.gen(function* () {
115 const ext = yield* OpenApiExtensionService;88 const ext = yield* OpenApiExtensionService;
116 yield* ext.updateSource(path.namespace, {89 yield* ext.updateSource(path.namespace, {
117 name: payload.name,90 name: payload.name,
@@ -119,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line
119 headers: payload.headers as Record<string, HeaderValue> | undefined,92 headers: payload.headers as Record<string, HeaderValue> | undefined,
120 } as OpenApiUpdateSourceInput);93 } as OpenApiUpdateSourceInput);
121 return { updated: true };94 return { updated: true };
122 }).pipe(sanitizeInternalFailure),95 })),
123 )96 )
124 .handle("startOAuth", ({ payload }) =>97 .handle("startOAuth", ({ payload }) =>
125 Effect.gen(function* () {98 capture(Effect.gen(function* () {
126 const ext = yield* OpenApiExtensionService;99 const ext = yield* OpenApiExtensionService;
127 return yield* ext.startOAuth({100 return yield* ext.startOAuth({
128 displayName: payload.displayName,101 displayName: payload.displayName,
@@ -135,22 +108,25 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines
135 clientSecretSecretId: payload.clientSecretSecretId ?? null,108 clientSecretSecretId: payload.clientSecretSecretId ?? null,
136 scopes: [...payload.scopes],109 scopes: [...payload.scopes],
137 });110 });
138 }),111 })),
139 )112 )
140 .handle("completeOAuth", ({ payload }) =>113 .handle("completeOAuth", ({ payload }) =>
141 Effect.gen(function* () {114 capture(Effect.gen(function* () {
142 const ext = yield* OpenApiExtensionService;115 const ext = yield* OpenApiExtensionService;
143 return yield* ext.completeOAuth({116 return yield* ext.completeOAuth({
144 state: payload.state,117 state: payload.state,
145 code: payload.code,118 code: payload.code,
146 error: payload.error,119 error: payload.error,
147 });120 });
148 }),121 })),
149 )122 )
150 .handle("oauthCallback", ({ urlParams }) =>123 .handle("oauthCallback", ({ urlParams }) =>
151 Effect.gen(function* () {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.
127 capture(Effect.gen(function* () {
152 const ext = yield* OpenApiExtensionService;128 const ext = yield* OpenApiExtensionService;
153 const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError, never>({129 const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({
154 complete: ({ state, code, error }) =>130 complete: ({ state, code, error }) =>
155 ext.completeOAuth({131 ext.completeOAuth({
156 state,132 state,
@@ -162,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 5 unmodified lines
162 channelName: OPENAPI_OAUTH_CHANNEL,138 channelName: OPENAPI_OAUTH_CHANNEL,
163 });139 });
164 return yield* HttpServerResponse.html(html);140 return yield* HttpServerResponse.html(html);
165 }).pipe(sanitizeInternalFailure),141 })),
166 ),142 ),
167);143);
packages/plugins/openapi/src/sdk/invoke.ts+29−8
@@ -1,6 +1,8 @@
1import { Effect, Layer, Option } from "effect";1import { Effect, Layer, Option } from "effect";
2import { HttpClient, HttpClientRequest } from "@effect/platform";2import { HttpClient, HttpClientRequest } from "@effect/platform";
3 3
4import type { StorageFailure } from "@executor/sdk";
5
4import { OpenApiInvocationError } from "./errors";6import { OpenApiInvocationError } from "./errors";
5import {7import {
6 type HeaderValue,8 type HeaderValue,
@@ -92,8 +94,15 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 85 unmodified lines
92 94
93export const resolveHeaders = (95export const resolveHeaders = (
94 headers: Record<string, HeaderValue>,96 headers: Record<string, HeaderValue>,
95 secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> },97 secrets: {
96): Effect.Effect<Record<string, string>, Error> =>98 readonly get: (
99 id: string,
100 ) => Effect.Effect<string | null, StorageFailure>;
101 },
102): Effect.Effect<
103 Record<string, string>,
104 OpenApiInvocationError | StorageFailure
105> =>
97 Effect.gen(function* () {106 Effect.gen(function* () {
98 const entries = Object.entries(headers);107 const entries = Object.entries(headers);
99 // Fan out secret lookups: on every invocation, one or two headers108 // Fan out secret lookups: on every invocation, one or two headers
@@ -108,9 +117,10 @@ export const resolveHeaders = ( · 8 unmodified lines
108 Effect.flatMap((secret) =>117 Effect.flatMap((secret) =>
109 secret === null118 secret === null
110 ? Effect.fail(119 ? Effect.fail(
111 new Error(120 new OpenApiInvocationError({
112 `Failed to resolve secret "${value.secretId}" for header "${name}"`,121 message: `Failed to resolve secret "${value.secretId}" for header "${name}"`,
113 ),122 statusCode: Option.none(),
123 }),
114 )124 )
115 : Effect.succeed({125 : Effect.succeed({
116 name,126 name,
@@ -209,12 +219,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 92 unmodified lines
209 const responseHeaders: Record<string, string> = { ...response.headers };219 const responseHeaders: Record<string, string> = { ...response.headers };
210 220
211 const contentType = response.headers["content-type"] ?? null;221 const contentType = response.headers["content-type"] ?? null;
222 const mapBodyError = Effect.mapError(
223 (err: { readonly message?: string }) =>
224 new OpenApiInvocationError({
225 message: `Failed to read response body: ${err.message ?? String(err)}`,
226 statusCode: Option.some(status),
227 cause: err,
228 }),
229 );
212 const responseBody: unknown =230 const responseBody: unknown =
213 status === 204231 status === 204
214 ? null232 ? null
215 : isJsonContentType(contentType)233 : isJsonContentType(contentType)
216 ? yield* response.json.pipe(Effect.catchAll(() => response.text))234 ? yield* response.json.pipe(
217 : yield* response.text;235 Effect.catchAll(() => response.text),
236 mapBodyError,
237 )
238 : yield* response.text.pipe(mapBodyError);
218 239
219 const ok = status >= 200 && status < 300;240 const ok = status >= 200 && status < 300;
220 241
@@ -236,7 +257,7 @@ export const invokeWithLayer = ( · 15 unmodified lines
236 baseUrl: string,257 baseUrl: string,
237 resolvedHeaders: Record<string, string>,258 resolvedHeaders: Record<string, string>,
238 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,259 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
239): Effect.Effect<InvocationResult, Error> => {260) => {
240 const clientWithBaseUrl = baseUrl261 const clientWithBaseUrl = baseUrl
241 ? Layer.effect(262 ? Layer.effect(
242 HttpClient.HttpClient,263 HttpClient.HttpClient,
packages/plugins/openapi/src/sdk/plugin.ts+36−8
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18 SetSecretInput,18 SetSecretInput,
19 SourceDetectionResult,19 SourceDetectionResult,
20 definePlugin,20 definePlugin,
21 type StorageFailure,
21 type ToolAnnotations,22 type ToolAnnotations,
22 type ToolRow,23 type ToolRow,
23} from "@executor/sdk";24} from "@executor/sdk";
@@ -28,7 +29,11 @@ import { · 4 unmodified lines
28 type OpenApiSourceConfig,29 type OpenApiSourceConfig,
29} from "@executor/config";30} from "@executor/config";
30 31
31import { OpenApiOAuthError } from "./errors";32import {
33 OpenApiExtractionError,
34 OpenApiOAuthError,
35 OpenApiParseError,
36} from "./errors";
32import { parse, resolveSpecText } from "./parse";37import { parse, resolveSpecText } from "./parse";
33import { extract } from "./extract";38import { extract } from "./extract";
34import { compileToolDefinitions, type ToolDefinition } from "./definitions";39import { compileToolDefinitions, type ToolDefinition } from "./definitions";
@@ -105,17 +110,40 @@ export interface OpenApiCompleteOAuthInput { · 70 unmodified lines
105 readonly error?: string;110 readonly error?: string;
106}111}
107 112
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 */
123export type OpenApiExtensionFailure =
124 | OpenApiParseError
125 | OpenApiExtractionError
126 | OpenApiOAuthError
127 | StorageFailure;
128
108export interface OpenApiPluginExtension {129export interface OpenApiPluginExtension {
109 readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>;130 readonly previewSpec: (
131 specText: string,
132 ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>;
110 readonly addSpec: (133 readonly addSpec: (
111 config: OpenApiSpecConfig,134 config: OpenApiSpecConfig,
112 ) => Effect.Effect<{ readonly sourceId: string; readonly toolCount: number }, Error>;135 ) => Effect.Effect<
113 readonly removeSpec: (namespace: string) => Effect.Effect<void, Error>;136 { readonly sourceId: string; readonly toolCount: number },
114 readonly getSource: (namespace: string) => Effect.Effect<StoredSource | null, Error>;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>;
115 readonly updateSource: (143 readonly updateSource: (
116 namespace: string,144 namespace: string,
117 input: OpenApiUpdateSourceInput,145 input: OpenApiUpdateSourceInput,
118 ) => Effect.Effect<void, Error>;146 ) => Effect.Effect<void, StorageFailure>;
119 readonly startOAuth: (147 readonly startOAuth: (
120 input: OpenApiStartOAuthInput,148 input: OpenApiStartOAuthInput,
121 ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>;149 ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>;
@@ -222,7 +250,7 @@ export const openApiPlugin = definePlugin( · 100 unmodified lines
222 schema: openapiSchema,250 schema: openapiSchema,
223 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),251 storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
224 252
225 extension: (ctx): OpenApiPluginExtension => {253 extension: (ctx) => {
226 // Wraps ctx.secrets.set for the oauth2 helper so createSecret254 // Wraps ctx.secrets.set for the oauth2 helper so createSecret
227 // returns the freshly-minted id.255 // returns the freshly-minted id.
228 const createOAuthSecret = (args: {256 const createOAuthSecret = (args: {
@@ -523,7 +551,7 @@ export const openApiPlugin = definePlugin( · 294 unmodified lines
523 : new OpenApiOAuthError({ message: err.message }),551 : new OpenApiOAuthError({ message: err.message }),
524 ),552 ),
525 ),553 ),
526 };554 } satisfies OpenApiPluginExtension;
527 },555 },
528 556
529 staticSources: (self) => [557 staticSources: (self) => [
packages/plugins/openapi/src/sdk/store.ts+19−11
@@ -1,6 +1,10 @@
1import { Effect, Option, Schema } from "effect";1import { Effect, Option, Schema } from "effect";
2 2
3import { defineSchema, type StorageDeps } from "@executor/sdk";3import {
4 defineSchema,
5 type StorageDeps,
6 type StorageFailure,
7} from "@executor/sdk";
4 8
5import {9import {
6 HeaderValue,10 HeaderValue,
@@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines
134// Store interface138// Store interface
135// ---------------------------------------------------------------------------139// ---------------------------------------------------------------------------
136 140
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.
137export interface OpenapiStore {145export interface OpenapiStore {
138 readonly upsertSource: (146 readonly upsertSource: (
139 input: StoredSource,147 input: StoredSource,
140 operations: readonly StoredOperation[],148 operations: readonly StoredOperation[],
141 ) => Effect.Effect<void, Error>;149 ) => Effect.Effect<void, StorageFailure>;
142 150
143 readonly updateSourceMeta: (151 readonly updateSourceMeta: (
144 namespace: string,152 namespace: string,
@@ -148,34 +156,34 @@ export interface OpenapiStore { · 3 unmodified lines
148 readonly headers?: Record<string, HeaderValue>;156 readonly headers?: Record<string, HeaderValue>;
149 readonly oauth2?: OAuth2Auth;157 readonly oauth2?: OAuth2Auth;
150 },158 },
151 ) => Effect.Effect<void, Error>;159 ) => Effect.Effect<void, StorageFailure>;
152 160
153 readonly getSource: (161 readonly getSource: (
154 namespace: string,162 namespace: string,
155 ) => Effect.Effect<StoredSource | null, Error>;163 ) => Effect.Effect<StoredSource | null, StorageFailure>;
156 164
157 readonly listSources: () => Effect.Effect<readonly StoredSource[], Error>;165 readonly listSources: () => Effect.Effect<readonly StoredSource[], StorageFailure>;
158 166
159 readonly getOperationByToolId: (167 readonly getOperationByToolId: (
160 toolId: string,168 toolId: string,
161 ) => Effect.Effect<StoredOperation | null, Error>;169 ) => Effect.Effect<StoredOperation | null, StorageFailure>;
162 170
163 readonly listOperationsBySource: (171 readonly listOperationsBySource: (
164 sourceId: string,172 sourceId: string,
165 ) => Effect.Effect<readonly StoredOperation[], Error>;173 ) => Effect.Effect<readonly StoredOperation[], StorageFailure>;
166 174
167 readonly removeSource: (namespace: string) => Effect.Effect<void, Error>;175 readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>;
168 176
169 readonly putOAuthSession: (177 readonly putOAuthSession: (
170 sessionId: string,178 sessionId: string,
171 session: OpenApiOAuthSession,179 session: OpenApiOAuthSession,
172 ) => Effect.Effect<void, Error>;180 ) => Effect.Effect<void, StorageFailure>;
173 181
174 readonly getOAuthSession: (182 readonly getOAuthSession: (
175 sessionId: string,183 sessionId: string,
176 ) => Effect.Effect<OpenApiOAuthSession | null, Error>;184 ) => Effect.Effect<OpenApiOAuthSession | null, StorageFailure>;
177 185
178 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>;186 readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>;
179}187}
180 188
181// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
packages/plugins/workos-vault/src/sdk/secret-store.ts+9−6
@@ -3,8 +3,10 @@ import { GenericServerException, NotFoundException } from "@workos-inc/node/work · 2 unmodified lines
3 3
4import {4import {
5 defineSchema,5 defineSchema,
6 StorageError,
6 type SecretProvider,7 type SecretProvider,
7 type StorageDeps,8 type StorageDeps,
9 type StorageFailure,
8} from "@executor/sdk";10} from "@executor/sdk";
9 11
10import {12import {
@@ -50,10 +52,10 @@ interface MetadataRow { · 39 unmodified lines
50// ---------------------------------------------------------------------------52// ---------------------------------------------------------------------------
51 53
52export interface WorkosVaultStore {54export interface WorkosVaultStore {
53 readonly get: (id: string) => Effect.Effect<MetadataRow | null, Error>;55 readonly get: (id: string) => Effect.Effect<MetadataRow | null, StorageFailure>;
54 readonly upsert: (row: MetadataRow) => Effect.Effect<void, Error>;56 readonly upsert: (row: MetadataRow) => Effect.Effect<void, StorageFailure>;
55 readonly remove: (id: string) => Effect.Effect<boolean, Error>;57 readonly remove: (id: string) => Effect.Effect<boolean, StorageFailure>;
56 readonly list: () => Effect.Effect<readonly MetadataRow[], Error>;58 readonly list: () => Effect.Effect<readonly MetadataRow[], StorageFailure>;
57}59}
58 60
59export const makeWorkosVaultStore = (61export const makeWorkosVaultStore = (
@@ -215,9 +217,10 @@ const deleteSecretValue = ( · 155 unmodified lines
215 return true;217 return true;
216 });218 });
217 219
218const formatVaultError = (error: unknown): Error => {220const formatVaultError = (error: unknown): StorageError => {
219 const cause = unwrapVaultError(error);221 const cause = unwrapVaultError(error);
220 return cause instanceof Error ? cause : new Error(String(cause));222 const message = cause instanceof Error ? cause.message : String(cause);
223 return new StorageError({ message, cause });
221};224};
222 225
223// ---------------------------------------------------------------------------226// ---------------------------------------------------------------------------