feat(sdk): typed errors propagate to one edge mapping (StorageError, InternalError, Telemetry)
Unified · Split
| @@ -6,7 +6,11 @@ · 5 unmodified lines | ||
| 6 | 6 | import { HttpApiBuilder, HttpRouter, HttpServer } from "@effect/platform"; |
| 7 | 7 | import { Layer } from "effect"; |
| 8 | 8 | |
| 9 | −import { CoreExecutorApi } from "@executor/api"; | |
| 9 | +import { | |
| 10 | + CoreExecutorApi, | |
| 11 | + InternalError, | |
| 12 | + observabilityMiddleware, | |
| 13 | +} from "@executor/api"; | |
| 10 | 14 | import { CoreHandlers } from "@executor/api/server"; |
| 11 | 15 | import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api"; |
| 12 | 16 | import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api"; |
| @@ -18,12 +22,16 @@ import { UserStoreService } from "../auth/context"; · 5 unmodified lines | ||
| 18 | 22 | import { WorkOSAuth } from "../auth/workos"; |
| 19 | 23 | import { AutumnService } from "../services/autumn"; |
| 20 | 24 | import { DbService } from "../services/db"; |
| 25 | +import { ErrorCaptureLive } from "../observability"; | |
| 21 | 26 | |
| 22 | 27 | export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup) |
| 23 | 28 | .add(McpGroup) |
| 24 | 29 | .add(GraphqlGroup) |
| 30 | + .addError(InternalError) | |
| 25 | 31 | .middleware(OrgAuth); |
| 26 | 32 | |
| 33 | +const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); | |
| 34 | + | |
| 27 | 35 | const DbLive = DbService.Live; |
| 28 | 36 | const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); |
| 29 | 37 | |
| @@ -47,6 +55,13 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll( · 17 unmodified lines | ||
| 47 | 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. | |
| 50 | 62 | export 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 | ); |
| @@ -34,6 +34,9 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 33 unmodified lines | ||
| 34 | 34 | Effect.gen(function* () { |
| 35 | 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 | 40 | const requestServices = Layer.mergeAll( |
| 38 | 41 | Layer.succeed(ExecutorService, executor), |
| 39 | 42 | Layer.succeed(ExecutionEngineService, engine), |
| @@ -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 | + | |
| 13 | +import * as Sentry from "@sentry/cloudflare"; | |
| 14 | +import { Cause, Effect, Layer } from "effect"; | |
| 15 | + | |
| 16 | +import { ErrorCapture } from "@executor/api"; | |
| 17 | + | |
| 18 | +export 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 | +); | |
| @@ -78,5 +78,9 @@ export const createScopedExecutor = ( · 77 unmodified lines | ||
| 78 | 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 | 85 | return yield* createExecutor({ scope, adapter, blobs, plugins }); |
| 82 | 86 | }); |
| @@ -7,7 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "@effect/platform"; |
| 8 | 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; |
| 9 | 9 | |
| 10 | −import { addGroup } from "@executor/api"; | |
| 10 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 11 | 11 | import { CoreHandlers, ExecutorService, ExecutionEngineService } from "@executor/api/server"; |
| 12 | 12 | import { createExecutionEngine } from "@executor/execution"; |
| 13 | 13 | import { makeQuickJsExecutor } from "@executor/runtime-quickjs"; |
| @@ -34,6 +34,7 @@ import { · 20 unmodified lines | ||
| 34 | 34 | } from "@executor/plugin-graphql/api"; |
| 35 | 35 | import { getExecutor } from "./executor"; |
| 36 | 36 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| 37 | +import { ErrorCaptureLive } from "./observability"; | |
| 37 | 38 | |
| 38 | 39 | // --------------------------------------------------------------------------- |
| 39 | 40 | // Local server API — core + all plugin groups |
| @@ -45,6 +46,13 @@ const LocalApi = addGroup(OpenApiGroup) · 5 unmodified lines | ||
| 45 | 46 | .add(OnePasswordGroup) |
| 46 | 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. | |
| 54 | +const LocalObservability = observabilityMiddleware(LocalApi); | |
| 55 | + | |
| 48 | 56 | const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( |
| 49 | 57 | Layer.provide(CoreHandlers), |
| 50 | 58 | Layer.provide( |
| @@ -56,6 +64,8 @@ const LocalApiBase = HttpApiBuilder.api(LocalApi).pipe( · 5 unmodified lines | ||
| 56 | 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 | 91 | const executor = await getExecutor(); |
| 82 | 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 | 96 | const pluginExtensions = Layer.mergeAll( |
| 85 | 97 | Layer.succeed(OpenApiExtensionService, executor.openapi), |
| 86 | 98 | Layer.succeed(McpExtensionService, executor.mcp), |
| @@ -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 | + | |
| 10 | +import { Cause, Effect, Layer } from "effect"; | |
| 11 | + | |
| 12 | +import { ErrorCapture } from "@executor/api"; | |
| 13 | + | |
| 14 | +const nextTraceId = () => | |
| 15 | + `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; | |
| 16 | + | |
| 17 | +export 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 | +); | |
| @@ -229,12 +229,15 @@ · 228 unmodified lines | ||
| 229 | 229 | "@effect/platform": "catalog:", |
| 230 | 230 | "@executor/execution": "workspace:*", |
| 231 | 231 | "@executor/sdk": "workspace:*", |
| 232 | + "@executor/storage-core": "workspace:*", | |
| 232 | 233 | "effect": "catalog:", |
| 233 | 234 | }, |
| 234 | 235 | "devDependencies": { |
| 236 | + "@effect/vitest": "catalog:", | |
| 235 | 237 | "@types/node": "catalog:", |
| 236 | 238 | "bun-types": "catalog:", |
| 237 | 239 | "typescript": "catalog:", |
| 240 | + "vitest": "catalog:", | |
| 238 | 241 | }, |
| 239 | 242 | }, |
| 240 | 243 | "packages/core/cli": { |
| @@ -307,6 +310,7 @@ · 66 unmodified lines | ||
| 307 | 310 | "name": "@executor/sdk", |
| 308 | 311 | "version": "0.0.1", |
| 309 | 312 | "dependencies": { |
| 313 | + "@effect/platform": "catalog:", | |
| 310 | 314 | "@executor/storage-core": "workspace:*", |
| 311 | 315 | "effect": "catalog:", |
| 312 | 316 | }, |
| @@ -0,0 +1,195 @@ | ||
| 1 | +# Error handling — model + plumbing | |
| 2 | + | |
| 3 | +## Principle | |
| 4 | + | |
| 5 | +Errors are typed values that propagate through the call graph carrying | |
| 6 | +their full causal structure. **Exactly one layer at the edge** consumes | |
| 7 | +them and maps them to a public response. Plugin code never imports | |
| 8 | +Sentry, never calls `captureException`, never wraps handlers with | |
| 9 | +`sanitize*` helpers. | |
| 10 | + | |
| 11 | +Two 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 | + | |
| 32 | +The SDK stays storage-typed. The HTTP edge (`@executor/api`) is the | |
| 33 | +**only** layer that translates `StorageError → InternalError` and | |
| 34 | +captures the cause to telemetry. Non-HTTP consumers (CLI, Promise | |
| 35 | +SDK, tests) see raw `StorageError` in the typed channel and can react | |
| 36 | +however they want. | |
| 37 | + | |
| 38 | +## Plumbing | |
| 39 | + | |
| 40 | +### Storage layer (`@executor/storage-core`) | |
| 41 | + | |
| 42 | +Emits 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 | + | |
| 50 | +Both are `Data.TaggedError`, not `Schema.TaggedError` — you physically | |
| 51 | +can't `addError(...)` them on an HttpApi group, which enforces "these | |
| 52 | +are internal types, not wire shapes". | |
| 53 | + | |
| 54 | +`DBAdapter` / `CustomAdapter` / `TypedAdapter` declare | |
| 55 | +`Effect<X, StorageFailure>` where `StorageFailure = StorageError | | |
| 56 | +UniqueViolationError`. No `Error`, no telemetry service in R. | |
| 57 | + | |
| 58 | +Storage-core has zero observability awareness — it just emits typed | |
| 59 | +values and lets consumers decide what to do with them. | |
| 60 | + | |
| 61 | +### SDK (`@executor/sdk`) | |
| 62 | + | |
| 63 | +The 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 | + | |
| 73 | +No `liftStorage`, no `wrapAdapterForPlugin`, no `ErrorCapture` tag | |
| 74 | +inside the SDK. The value proposition: an SDK consumer can write a CLI, | |
| 75 | +a script, a promise-based wrapper, whatever — and the typed channel | |
| 76 | +shows them exactly what can go wrong. | |
| 77 | + | |
| 78 | +### HTTP edge (`@executor/api/observability`) | |
| 79 | + | |
| 80 | +Owns 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 | + | |
| 129 | +Plugin authors write normal Effect code. Their extension method error | |
| 130 | +unions look like: | |
| 131 | + | |
| 132 | +```ts | |
| 133 | +Effect.Effect<X, MyPluginTypedError | StorageError, never> | |
| 134 | +``` | |
| 135 | + | |
| 136 | +Where `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 | |
| 139 | +edge translates it. | |
| 140 | + | |
| 141 | +Plugins never provide `ErrorCapture`, never import Sentry, never see | |
| 142 | +`InternalError` in their typed channel. | |
| 143 | + | |
| 144 | +### API groups | |
| 145 | + | |
| 146 | +Each group declares its typed errors once at the group level: | |
| 147 | + | |
| 148 | +```ts | |
| 149 | +class 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 | + | |
| 160 | +No per-endpoint `addError`. The framework encodes each tagged error by | |
| 161 | +its 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. | |
| @@ -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 | |
| 7 | +boundary: | |
| 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 | + | |
| 17 | +In other words: the Effect-side surface knows your method can fail with | |
| 18 | +`McpOAuthError | InternalError | UniqueViolationError`; the promise | |
| 19 | +consumer sees `Promise<X>` and has to take it on faith. | |
| 20 | + | |
| 21 | +## What we want (revisit) | |
| 22 | + | |
| 23 | +Switch the wrapper to `Effect.runPromiseExit(...)`. The promise always | |
| 24 | +resolves with an `Exit<A, E>`: | |
| 25 | + | |
| 26 | +```ts | |
| 27 | +const exit = await executor.openapi.addSpec({ ... }) | |
| 28 | +if (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 | + | |
| 36 | +The error union survives the Promise boundary. Defects are visible in | |
| 37 | +the cause. Consumers can write totality-checked `match` on the typed | |
| 38 | +union without any runtime guessing. | |
| 39 | + | |
| 40 | +## Open question for the revisit | |
| 41 | + | |
| 42 | +Do we expose Effect's `Exit` / `Cause` directly to promise consumers, | |
| 43 | +or 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 | + | |
| 57 | +Lean toward exposing `Exit`/`Cause`, but worth thinking through what | |
| 58 | +the `runPromiseExit` consumers actually want to do at the call site | |
| 59 | +before deciding. | |
| 60 | + | |
| 61 | +## Why punted | |
| 62 | + | |
| 63 | +Current refactor is about getting Effect-side typed errors right | |
| 64 | +end-to-end (storage → SDK → API). The Promise façade is downstream of | |
| 65 | +that — once the typed unions are stable on the Effect side, the | |
| 66 | +`runPromiseExit` rewrite is a small, well-defined change. | |
| 67 | + | |
| 68 | +Also: it's a breaking change for promise SDK consumers (return type | |
| 69 | +goes from `Promise<A>` to `Promise<Exit<A, E>>`). Better as its own | |
| 70 | +focused 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` | |
| @@ -9,17 +9,21 @@ · 8 unmodified lines | ||
| 9 | 9 | }, |
| 10 | 10 | "scripts": { |
| 11 | 11 | "typecheck": "tsgo --noEmit", |
| 12 | − "typecheck:slow": "tsc --noEmit" | |
| 12 | + "typecheck:slow": "tsc --noEmit", | |
| 13 | + "test": "vitest run" | |
| 13 | 14 | }, |
| 14 | 15 | "dependencies": { |
| 15 | 16 | "@effect/platform": "catalog:", |
| 16 | 17 | "@executor/execution": "workspace:*", |
| 17 | 18 | "@executor/sdk": "workspace:*", |
| 19 | + "@executor/storage-core": "workspace:*", | |
| 18 | 20 | "effect": "catalog:" |
| 19 | 21 | }, |
| 20 | 22 | "devDependencies": { |
| 23 | + "@effect/vitest": "catalog:", | |
| 21 | 24 | "@types/node": "catalog:", |
| 22 | 25 | "bun-types": "catalog:", |
| 23 | − "typescript": "catalog:" | |
| 26 | + "typescript": "catalog:", | |
| 27 | + "vitest": "catalog:" | |
| 24 | 28 | } |
| 25 | 29 | } |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | +import { InternalError } from "../observability"; | |
| 5 | + | |
| 4 | 6 | // --------------------------------------------------------------------------- |
| 5 | 7 | // Schemas |
| 6 | 8 | // --------------------------------------------------------------------------- |
| @@ -60,4 +62,5 @@ export class ExecutionsApi extends HttpApiGroup.make("executions") · 53 unmodified lines | ||
| 60 | 62 | .setPayload(ResumeRequest) |
| 61 | 63 | .addSuccess(ResumeResponse) |
| 62 | 64 | .addError(ExecutionNotFoundError), |
| 63 | − ) {} | |
| 65 | + ) | |
| 66 | + .addError(InternalError) {} | |
| @@ -4,11 +4,12 @@ import { Effect } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { ExecutorApi } from "../api"; |
| 5 | 5 | import { formatExecuteResult, formatPausedExecution } from "@executor/execution"; |
| 6 | 6 | import { ExecutionEngineService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("execute", ({ payload }) => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const engine = yield* ExecutionEngineService; |
| 13 | 14 | const outcome = yield* Effect.promise(() => engine.executeWithPause(payload.code)); |
| 14 | 15 | |
| @@ -28,10 +29,10 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 13 unmodified lines | ||
| 28 | 29 | text: formatted.text, |
| 29 | 30 | structured: formatted.structured, |
| 30 | 31 | }; |
| 31 | − }), | |
| 32 | + })), | |
| 32 | 33 | ) |
| 33 | 34 | .handle("resume", ({ path, payload }) => |
| 34 | − Effect.gen(function* () { | |
| 35 | + capture(Effect.gen(function* () { | |
| 35 | 36 | const engine = yield* ExecutionEngineService; |
| 36 | 37 | const result = yield* Effect.promise(() => |
| 37 | 38 | engine.resume(path.executionId, { |
| @@ -62,6 +63,6 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 24 unmodified lines | ||
| 62 | 63 | structured: formatted.structured, |
| 63 | 64 | isError: false, |
| 64 | 65 | }; |
| 65 | − }), | |
| 66 | + })), | |
| 66 | 67 | ), |
| 67 | 68 | ); |
| @@ -3,16 +3,17 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { ExecutorApi } from "../api"; |
| 5 | 5 | import { ExecutorService } from "../services"; |
| 6 | +import { capture } from "@executor/api"; | |
| 6 | 7 | |
| 7 | 8 | export const ScopeHandlers = HttpApiBuilder.group(ExecutorApi, "scope", (handlers) => |
| 8 | 9 | handlers.handle("info", () => |
| 9 | − Effect.gen(function* () { | |
| 10 | + capture(Effect.gen(function* () { | |
| 10 | 11 | const executor = yield* ExecutorService; |
| 11 | 12 | return { |
| 12 | 13 | id: executor.scope.id, |
| 13 | 14 | name: executor.scope.name, |
| 14 | 15 | dir: executor.scope.name, |
| 15 | 16 | }; |
| 16 | − }), | |
| 17 | + })), | |
| 17 | 18 | ), |
| 18 | 19 | ); |
| @@ -4,6 +4,7 @@ import { SecretNotFoundError, type SecretRef } from "@executor/sdk"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | const refToResponse = (ref: SecretRef) => ({ |
| 9 | 10 | id: ref.id, |
| @@ -16,48 +17,46 @@ const refToResponse = (ref: SecretRef) => ({ · 6 unmodified lines | ||
| 16 | 17 | export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (handlers) => |
| 17 | 18 | handlers |
| 18 | 19 | .handle("list", () => |
| 19 | − Effect.gen(function* () { | |
| 20 | + capture(Effect.gen(function* () { | |
| 20 | 21 | const executor = yield* ExecutorService; |
| 21 | − const refs = yield* executor.secrets.list().pipe(Effect.orDie); | |
| 22 | + const refs = yield* executor.secrets.list(); | |
| 22 | 23 | return refs.map(refToResponse); |
| 23 | − }), | |
| 24 | + })), | |
| 24 | 25 | ) |
| 25 | 26 | .handle("status", ({ path }) => |
| 26 | − Effect.gen(function* () { | |
| 27 | + capture(Effect.gen(function* () { | |
| 27 | 28 | const executor = yield* ExecutorService; |
| 28 | − const status = yield* executor.secrets.status(path.secretId).pipe(Effect.orDie); | |
| 29 | + const status = yield* executor.secrets.status(path.secretId); | |
| 29 | 30 | return { secretId: path.secretId, status }; |
| 30 | − }), | |
| 31 | + })), | |
| 31 | 32 | ) |
| 32 | 33 | .handle("set", ({ payload }) => |
| 33 | − Effect.gen(function* () { | |
| 34 | + capture(Effect.gen(function* () { | |
| 34 | 35 | const executor = yield* ExecutorService; |
| 35 | − const ref = yield* executor.secrets | |
| 36 | − .set({ | |
| 37 | − id: payload.id, | |
| 38 | − name: payload.name, | |
| 39 | − value: payload.value, | |
| 40 | − provider: payload.provider, | |
| 41 | − }) | |
| 42 | − .pipe(Effect.orDie); | |
| 36 | + const ref = yield* executor.secrets.set({ | |
| 37 | + id: payload.id, | |
| 38 | + name: payload.name, | |
| 39 | + value: payload.value, | |
| 40 | + provider: payload.provider, | |
| 41 | + }); | |
| 43 | 42 | return refToResponse(ref); |
| 44 | − }), | |
| 43 | + })), | |
| 45 | 44 | ) |
| 46 | 45 | .handle("resolve", ({ path }) => |
| 47 | − Effect.gen(function* () { | |
| 46 | + capture(Effect.gen(function* () { | |
| 48 | 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 | 49 | if (value === null) { |
| 51 | 50 | return yield* Effect.fail(new SecretNotFoundError({ secretId: path.secretId })); |
| 52 | 51 | } |
| 53 | 52 | return { secretId: path.secretId, value }; |
| 54 | − }), | |
| 53 | + })), | |
| 55 | 54 | ) |
| 56 | 55 | .handle("remove", ({ path }) => |
| 57 | − Effect.gen(function* () { | |
| 56 | + capture(Effect.gen(function* () { | |
| 58 | 57 | const executor = yield* ExecutorService; |
| 59 | − yield* executor.secrets.remove(path.secretId).pipe(Effect.orDie); | |
| 58 | + yield* executor.secrets.remove(path.secretId); | |
| 60 | 59 | return { removed: true }; |
| 61 | − }), | |
| 60 | + })), | |
| 62 | 61 | ), |
| 63 | 62 | ); |
| @@ -4,13 +4,14 @@ import { ToolId } from "@executor/sdk"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("list", () => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const executor = yield* ExecutorService; |
| 13 | − const sources = yield* executor.sources.list().pipe(Effect.orDie); | |
| 14 | + const sources = yield* executor.sources.list(); | |
| 14 | 15 | return sources.map((s) => ({ |
| 15 | 16 | id: s.id, |
| 16 | 17 | name: s.name, |
| @@ -21,28 +22,26 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 4 unmodified lines | ||
| 21 | 22 | canRefresh: s.canRefresh, |
| 22 | 23 | canEdit: s.canEdit, |
| 23 | 24 | })); |
| 24 | − }), | |
| 25 | + })), | |
| 25 | 26 | ) |
| 26 | 27 | .handle("remove", ({ path }) => |
| 27 | − Effect.gen(function* () { | |
| 28 | + capture(Effect.gen(function* () { | |
| 28 | 29 | const executor = yield* ExecutorService; |
| 29 | − yield* executor.sources.remove(path.sourceId).pipe(Effect.orDie); | |
| 30 | + yield* executor.sources.remove(path.sourceId); | |
| 30 | 31 | return { removed: true }; |
| 31 | − }), | |
| 32 | + })), | |
| 32 | 33 | ) |
| 33 | 34 | .handle("refresh", ({ path }) => |
| 34 | − Effect.gen(function* () { | |
| 35 | + capture(Effect.gen(function* () { | |
| 35 | 36 | const executor = yield* ExecutorService; |
| 36 | − yield* executor.sources.refresh(path.sourceId).pipe(Effect.orDie); | |
| 37 | + yield* executor.sources.refresh(path.sourceId); | |
| 37 | 38 | return { refreshed: true }; |
| 38 | − }), | |
| 39 | + })), | |
| 39 | 40 | ) |
| 40 | 41 | .handle("tools", ({ path }) => |
| 41 | − Effect.gen(function* () { | |
| 42 | + capture(Effect.gen(function* () { | |
| 42 | 43 | const executor = yield* ExecutorService; |
| 43 | − const tools = yield* executor.tools | |
| 44 | − .list({ sourceId: path.sourceId }) | |
| 45 | − .pipe(Effect.orDie); | |
| 44 | + const tools = yield* executor.tools.list({ sourceId: path.sourceId }); | |
| 46 | 45 | return tools.map((t) => ({ |
| 47 | 46 | id: ToolId.make(t.id), |
| 48 | 47 | pluginId: t.pluginId, |
| @@ -51,12 +50,12 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 2 unmodified lines | ||
| 51 | 50 | description: t.description, |
| 52 | 51 | mayElicit: t.annotations?.mayElicit, |
| 53 | 52 | })); |
| 54 | − }), | |
| 53 | + })), | |
| 55 | 54 | ) |
| 56 | 55 | .handle("detect", ({ payload }) => |
| 57 | − Effect.gen(function* () { | |
| 56 | + capture(Effect.gen(function* () { | |
| 58 | 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 | 59 | return results.map((r) => ({ |
| 61 | 60 | kind: r.kind, |
| 62 | 61 | confidence: r.confidence, |
| @@ -64,6 +63,6 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 1 unmodified line | ||
| 64 | 63 | name: r.name, |
| 65 | 64 | namespace: r.namespace, |
| 66 | 65 | })); |
| 67 | − }), | |
| 66 | + })), | |
| 68 | 67 | ), |
| 69 | 68 | ); |
| @@ -4,13 +4,14 @@ import { ToolId, ToolNotFoundError } from "@executor/sdk"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { ExecutorApi } from "../api"; |
| 6 | 6 | import { ExecutorService } from "../services"; |
| 7 | +import { capture } from "@executor/api"; | |
| 7 | 8 | |
| 8 | 9 | export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handlers) => |
| 9 | 10 | handlers |
| 10 | 11 | .handle("list", () => |
| 11 | − Effect.gen(function* () { | |
| 12 | + capture(Effect.gen(function* () { | |
| 12 | 13 | const executor = yield* ExecutorService; |
| 13 | − const tools = yield* executor.tools.list().pipe(Effect.orDie); | |
| 14 | + const tools = yield* executor.tools.list(); | |
| 14 | 15 | return tools.map((t) => ({ |
| 15 | 16 | id: ToolId.make(t.id), |
| 16 | 17 | pluginId: t.pluginId, |
| @@ -19,16 +20,16 @@ export const ToolsHandlers = HttpApiBuilder.group(ExecutorApi, "tools", (handler · 2 unmodified lines | ||
| 19 | 20 | description: t.description, |
| 20 | 21 | mayElicit: t.annotations?.mayElicit, |
| 21 | 22 | })); |
| 22 | − }), | |
| 23 | + })), | |
| 23 | 24 | ) |
| 24 | 25 | .handle("schema", ({ path }) => |
| 25 | − Effect.gen(function* () { | |
| 26 | + capture(Effect.gen(function* () { | |
| 26 | 27 | const executor = yield* ExecutorService; |
| 27 | − const schema = yield* executor.tools.schema(path.toolId).pipe(Effect.orDie); | |
| 28 | + const schema = yield* executor.tools.schema(path.toolId); | |
| 28 | 29 | if (schema === null) { |
| 29 | 30 | return yield* Effect.fail(new ToolNotFoundError({ toolId: path.toolId })); |
| 30 | 31 | } |
| 31 | 32 | return schema; |
| 32 | − }), | |
| 33 | + })), | |
| 33 | 34 | ), |
| 34 | 35 | ); |
| @@ -4,3 +4,10 @@ export { SourcesApi } from "./sources/api"; · 3 unmodified lines | ||
| 4 | 4 | export { SecretsApi } from "./secrets/api"; |
| 5 | 5 | export { ExecutionsApi } from "./executions/api"; |
| 6 | 6 | export { ScopeApi } from "./scope/api"; |
| 7 | +export { | |
| 8 | + InternalError, | |
| 9 | + ErrorCapture, | |
| 10 | + observabilityMiddleware, | |
| 11 | + capture, | |
| 12 | + type ErrorCaptureShape, | |
| 13 | +} from "./observability"; | |
| @@ -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 | + | |
| 8 | +import { describe, expect, it } from "@effect/vitest"; | |
| 9 | +import { Cause, Effect, Layer, Ref } from "effect"; | |
| 10 | +import { StorageError, UniqueViolationError } from "@executor/storage-core"; | |
| 11 | + | |
| 12 | +import { capture, ErrorCapture, InternalError } from "./observability"; | |
| 13 | + | |
| 14 | +// Recording ErrorCapture — returns a fixed trace id and accumulates | |
| 15 | +// every cause it sees in a Ref. | |
| 16 | +const 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 | + | |
| 31 | +describe("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 | + | |
| @@ -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 | + | |
| 33 | +import { Cause, Context, Effect, Layer, Option, Schema } from "effect"; | |
| 34 | +import { | |
| 35 | + HttpApiBuilder, | |
| 36 | + HttpApiSchema, | |
| 37 | + HttpServerResponse, | |
| 38 | + type HttpApi, | |
| 39 | + type HttpApiGroup, | |
| 40 | +} from "@effect/platform"; | |
| 41 | +import type { StorageFailure } from "@executor/storage-core"; | |
| 42 | + | |
| 43 | +/** Public 500 surface. Opaque by schema. */ | |
| 44 | +export 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 | + | |
| 53 | +export 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 | + | |
| 64 | +export 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. | |
| 78 | +const 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 | + */ | |
| 102 | +export 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 | + */ | |
| 131 | +export 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 | + ); | |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Response schemas |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -16,6 +18,6 @@ const ScopeInfoResponse = Schema.Struct({ · 8 unmodified lines | ||
| 16 | 18 | // Group |
| 17 | 19 | // --------------------------------------------------------------------------- |
| 18 | 20 | |
| 19 | −export class ScopeApi extends HttpApiGroup.make("scope").add( | |
| 20 | − HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse), | |
| 21 | −) {} | |
| 21 | +export class ScopeApi extends HttpApiGroup.make("scope") | |
| 22 | + .add(HttpApiEndpoint.get("info")`/scope`.addSuccess(ScopeInfoResponse)) | |
| 23 | + .addError(InternalError) {} | |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId, SecretId, SecretNotFoundError, SecretResolutionError } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -78,4 +80,5 @@ export class SecretsApi extends HttpApiGroup.make("secrets") · 70 unmodified lines | ||
| 78 | 80 | HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/secrets/${secretIdParam}` |
| 79 | 81 | .addSuccess(Schema.Struct({ removed: Schema.Boolean })) |
| 80 | 82 | .addError(SecretNotFound), |
| 81 | − ) {} | |
| 83 | + ) | |
| 84 | + .addError(InternalError) {} | |
| @@ -2,7 +2,10 @@ import { Context } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import type { Executor } from "@executor/sdk"; |
| 3 | 3 | import type { ExecutionEngine } from "@executor/execution"; |
| 4 | 4 | |
| 5 | −export class ExecutorService extends Context.Tag("ExecutorService")<ExecutorService, Executor>() {} | |
| 5 | +export class ExecutorService extends Context.Tag("ExecutorService")< | |
| 6 | + ExecutorService, | |
| 7 | + Executor | |
| 8 | +>() {} | |
| 6 | 9 | |
| 7 | 10 | export class ExecutionEngineService extends Context.Tag("ExecutionEngineService")< |
| 8 | 11 | ExecutionEngineService, |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | −import { ScopeId, ToolId } from "@executor/sdk"; | |
| 3 | +import { ScopeId, SourceRemovalNotAllowedError, ToolId } from "@executor/sdk"; | |
| 4 | + | |
| 5 | +import { InternalError } from "../observability"; | |
| 4 | 6 | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| @@ -53,6 +55,14 @@ const DetectResultResponse = Schema.Struct({ · 46 unmodified lines | ||
| 53 | 55 | namespace: Schema.String, |
| 54 | 56 | }); |
| 55 | 57 | |
| 58 | +// --------------------------------------------------------------------------- | |
| 59 | +// Error schemas with HTTP status annotations | |
| 60 | +// --------------------------------------------------------------------------- | |
| 61 | + | |
| 62 | +const SourceRemovalNotAllowed = SourceRemovalNotAllowedError.annotations( | |
| 63 | + HttpApiSchema.annotations({ status: 409 }), | |
| 64 | +); | |
| 65 | + | |
| 56 | 66 | // --------------------------------------------------------------------------- |
| 57 | 67 | // Group |
| 58 | 68 | // --------------------------------------------------------------------------- |
| @@ -64,9 +74,9 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 5 unmodified lines | ||
| 64 | 74 | ), |
| 65 | 75 | ) |
| 66 | 76 | .add( |
| 67 | − HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}`.addSuccess( | |
| 68 | − SourceRemoveResponse, | |
| 69 | − ), | |
| 77 | + HttpApiEndpoint.del("remove")`/scopes/${scopeIdParam}/sources/${sourceIdParam}` | |
| 78 | + .addSuccess(SourceRemoveResponse) | |
| 79 | + .addError(SourceRemovalNotAllowed), | |
| 70 | 80 | ) |
| 71 | 81 | .add( |
| 72 | 82 | HttpApiEndpoint.post( |
| @@ -82,4 +92,5 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 9 unmodified lines | ||
| 82 | 92 | HttpApiEndpoint.post("detect")`/scopes/${scopeIdParam}/sources/detect` |
| 83 | 93 | .setPayload(DetectRequest) |
| 84 | 94 | .addSuccess(Schema.Array(DetectResultResponse)), |
| 85 | − ) {} | |
| 95 | + ) | |
| 96 | + .addError(InternalError) {} | |
| @@ -2,6 +2,8 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId, ToolId, ToolNotFoundError } from "@executor/sdk"; |
| 4 | 4 | |
| 5 | +import { InternalError } from "../observability"; | |
| 6 | + | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| 6 | 8 | // Params |
| 7 | 9 | // --------------------------------------------------------------------------- |
| @@ -53,4 +55,5 @@ export class ToolsApi extends HttpApiGroup.make("tools") · 45 unmodified lines | ||
| 53 | 55 | HttpApiEndpoint.get("schema")`/scopes/${scopeIdParam}/tools/${toolIdParam}/schema` |
| 54 | 56 | .addSuccess(ToolSchemaResponse) |
| 55 | 57 | .addError(ToolNotFound), |
| 56 | − ) {} | |
| 58 | + ) | |
| 59 | + .addError(InternalError) {} | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["src/**/*.test.ts"], | |
| 6 | + }, | |
| 7 | +}); | |
| @@ -44,6 +44,7 @@ · 43 unmodified lines | ||
| 44 | 44 | "typecheck:slow": "tsc --noEmit" |
| 45 | 45 | }, |
| 46 | 46 | "dependencies": { |
| 47 | + "@effect/platform": "catalog:", | |
| 47 | 48 | "@executor/storage-core": "workspace:*", |
| 48 | 49 | "effect": "catalog:" |
| 49 | 50 | }, |
| @@ -6,35 +6,44 @@ · 5 unmodified lines | ||
| 6 | 6 | // |
| 7 | 7 | // Plugins see a `ScopedBlobStore` that's already namespaced to the plugin |
| 8 | 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 | |
| 11 | 15 | import { Effect } from "effect"; |
| 12 | 16 | |
| 17 | +import { StorageError } from "@executor/storage-core"; | |
| 18 | + | |
| 13 | 19 | export interface BlobStore { |
| 14 | 20 | readonly get: ( |
| 15 | 21 | namespace: string, |
| 16 | 22 | key: string, |
| 17 | − ) => Effect.Effect<string | null, Error>; | |
| 23 | + ) => Effect.Effect<string | null, StorageError>; | |
| 18 | 24 | readonly put: ( |
| 19 | 25 | namespace: string, |
| 20 | 26 | key: string, |
| 21 | 27 | value: string, |
| 22 | − ) => Effect.Effect<void, Error>; | |
| 28 | + ) => Effect.Effect<void, StorageError>; | |
| 23 | 29 | readonly delete: ( |
| 24 | 30 | namespace: string, |
| 25 | 31 | key: string, |
| 26 | − ) => Effect.Effect<void, Error>; | |
| 32 | + ) => Effect.Effect<void, StorageError>; | |
| 27 | 33 | readonly has: ( |
| 28 | 34 | namespace: string, |
| 29 | 35 | key: string, |
| 30 | − ) => Effect.Effect<boolean, Error>; | |
| 36 | + ) => Effect.Effect<boolean, StorageError>; | |
| 31 | 37 | } |
| 32 | 38 | |
| 33 | 39 | export interface ScopedBlobStore { |
| 34 | − readonly get: (key: string) => Effect.Effect<string | null, Error>; | |
| 35 | − readonly put: (key: string, value: string) => Effect.Effect<void, Error>; | |
| 36 | − readonly delete: (key: string) => Effect.Effect<void, Error>; | |
| 37 | − readonly has: (key: string) => Effect.Effect<boolean, Error>; | |
| 40 | + readonly get: (key: string) => Effect.Effect<string | null, StorageError>; | |
| 41 | + readonly put: ( | |
| 42 | + key: string, | |
| 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 | |
| 40 | 49 | export const scopeBlobStore = ( |
| @@ -51,6 +60,10 @@ export const scopeBlobStore = ( · 10 unmodified lines | ||
| 51 | 60 | * Minimal in-memory BlobStore — good for tests and trivial hosts. Real |
| 52 | 61 | * backends (filesystem, S3/R2, SQLite-table-backed) implement the same |
| 53 | 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 | */ |
| 55 | 68 | export const makeInMemoryBlobStore = (): BlobStore => { |
| 56 | 69 | const store = new Map<string, string>(); |
| @@ -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 | + | |
| 24 | +import { describe, expect, it } from "@effect/vitest"; | |
| 25 | +import { Effect } from "effect"; | |
| 26 | + | |
| 27 | +import { | |
| 28 | + StorageError, | |
| 29 | + UniqueViolationError, | |
| 30 | + type DBAdapter, | |
| 31 | + type DBTransactionAdapter, | |
| 32 | + type StorageFailure, | |
| 33 | +} from "@executor/storage-core"; | |
| 34 | +import { makeInMemoryBlobStore } from "./blob"; | |
| 35 | +import { createExecutor } from "./executor"; | |
| 36 | +import { ScopeId } from "./ids"; | |
| 37 | +import { defineSchema, definePlugin } from "./plugin"; | |
| 38 | +import { 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 | + | |
| 45 | +const 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 | + | |
| 66 | +const testScope = new Scope({ | |
| 67 | + id: ScopeId.make("test-scope"), | |
| 68 | + name: "test", | |
| 69 | + createdAt: new Date(), | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const baseConfig = (adapter: DBAdapter) => ({ | |
| 73 | + scope: testScope, | |
| 74 | + adapter, | |
| 75 | + blobs: makeInMemoryBlobStore(), | |
| 76 | +}); | |
| 77 | + | |
| 78 | +// --------------------------------------------------------------------------- | |
| 79 | +// Tests | |
| 80 | +// --------------------------------------------------------------------------- | |
| 81 | + | |
| 82 | +describe("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 | +}); | |
| @@ -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 | 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 | |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { Effect, FiberRef } from "effect"; |
| 2 | 2 | import { |
| 3 | + StorageError, | |
| 3 | 4 | typedAdapter, |
| 4 | 5 | type DBAdapter, |
| 5 | 6 | type DBSchema, |
| 6 | 7 | type DBTransactionAdapter, |
| 8 | + type StorageFailure, | |
| 7 | 9 | type TypedAdapter, |
| 8 | 10 | } from "@executor/storage-core"; |
| 9 | 11 | |
| @@ -98,19 +100,19 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 88 unmodified lines | ||
| 98 | 100 | readonly tools: { |
| 99 | 101 | readonly list: ( |
| 100 | 102 | filter?: ToolListFilter, |
| 101 | − ) => Effect.Effect<readonly Tool[], Error>; | |
| 103 | + ) => Effect.Effect<readonly Tool[], StorageFailure>; | |
| 102 | 104 | /** Fetch a tool's full schema view: JSON schemas with `$defs` |
| 103 | 105 | * attached from the core `definition` table, plus TypeScript |
| 104 | 106 | * preview strings rendered from them. Returns `null` for unknown |
| 105 | 107 | * tool ids. */ |
| 106 | 108 | readonly schema: ( |
| 107 | 109 | toolId: string, |
| 108 | − ) => Effect.Effect<ToolSchema | null, Error>; | |
| 110 | + ) => Effect.Effect<ToolSchema | null, StorageFailure>; | |
| 109 | 111 | /** Every `$defs` entry across every source, grouped by source id. |
| 110 | 112 | * Used for bulk schema export and downstream TypeScript rendering. */ |
| 111 | 113 | readonly definitions: () => Effect.Effect< |
| 112 | 114 | Record<string, Record<string, unknown>>, |
| 113 | − Error | |
| 115 | + StorageFailure | |
| 114 | 116 | >; |
| 115 | 117 | readonly invoke: ( |
| 116 | 118 | toolId: string, |
| @@ -123,44 +125,48 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = []> = { · 6 unmodified lines | ||
| 123 | 125 | | NoHandlerError |
| 124 | 126 | | ToolInvocationError |
| 125 | 127 | | ElicitationDeclinedError |
| 126 | − | Error | |
| 128 | + | StorageFailure | |
| 127 | 129 | >; |
| 128 | 130 | }; |
| 129 | 131 | |
| 130 | 132 | readonly sources: { |
| 131 | − readonly list: () => Effect.Effect<readonly Source[], Error>; | |
| 133 | + readonly list: () => Effect.Effect<readonly Source[], StorageFailure>; | |
| 132 | 134 | readonly remove: ( |
| 133 | 135 | sourceId: string, |
| 134 | − ) => Effect.Effect<void, SourceRemovalNotAllowedError | Error>; | |
| 135 | − readonly refresh: (sourceId: string) => Effect.Effect<void, Error>; | |
| 136 | + ) => Effect.Effect<void, SourceRemovalNotAllowedError | StorageFailure>; | |
| 137 | + readonly refresh: (sourceId: string) => Effect.Effect<void, StorageFailure>; | |
| 136 | 138 | /** URL autodetection — fans out to every plugin's `detect` hook |
| 137 | 139 | * (if declared), returns every high/medium/low-confidence match. |
| 138 | 140 | * UI picks a winner from the list. */ |
| 139 | 141 | readonly detect: ( |
| 140 | 142 | url: string, |
| 141 | − ) => Effect.Effect<readonly SourceDetectionResult[], Error>; | |
| 143 | + ) => Effect.Effect<readonly SourceDetectionResult[], StorageFailure>; | |
| 142 | 144 | /** All `$defs` registered for a single source, keyed by def name. */ |
| 143 | 145 | readonly definitions: ( |
| 144 | 146 | sourceId: string, |
| 145 | − ) => Effect.Effect<Record<string, unknown>, Error>; | |
| 147 | + ) => Effect.Effect<Record<string, unknown>, StorageFailure>; | |
| 146 | 148 | }; |
| 147 | 149 | |
| 148 | 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 | 154 | /** Fast-path existence check — hits the core `secret` routing table |
| 151 | 155 | * only, never calls the provider. Use this for UI state ("secret |
| 152 | 156 | * missing, prompt to add") to avoid keychain permission prompts |
| 153 | 157 | * or 1password IPC roundtrips on a pre-flight check. */ |
| 154 | 158 | readonly status: ( |
| 155 | 159 | id: string, |
| 156 | − ) => Effect.Effect<"resolved" | "missing", Error>; | |
| 157 | − readonly set: (input: SetSecretInput) => Effect.Effect<SecretRef, Error>; | |
| 158 | − readonly remove: (id: string) => Effect.Effect<void, Error>; | |
| 159 | − readonly list: () => Effect.Effect<readonly SecretRef[], Error>; | |
| 160 | + ) => Effect.Effect<"resolved" | "missing", StorageFailure>; | |
| 161 | + readonly set: ( | |
| 162 | + input: SetSecretInput, | |
| 163 | + ) => Effect.Effect<SecretRef, StorageFailure>; | |
| 164 | + readonly remove: (id: string) => Effect.Effect<void, StorageFailure>; | |
| 165 | + readonly list: () => Effect.Effect<readonly SecretRef[], StorageFailure>; | |
| 160 | 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 | 170 | } & PluginExtensions<TPlugins>; |
| 165 | 171 | |
| 166 | 172 | export interface ExecutorConfig< |
| @@ -281,7 +287,7 @@ const writeSourceInput = ( · 114 unmodified lines | ||
| 281 | 287 | core: TypedAdapter<CoreSchema>, |
| 282 | 288 | pluginId: string, |
| 283 | 289 | input: SourceInput, |
| 284 | −): Effect.Effect<void, Error> => | |
| 290 | +): Effect.Effect<void, StorageFailure> => | |
| 285 | 291 | Effect.gen(function* () { |
| 286 | 292 | yield* deleteSourceById(core, input.id); |
| 287 | 293 | |
| @@ -325,7 +331,7 @@ const writeSourceInput = ( · 37 unmodified lines | ||
| 325 | 331 | const deleteSourceById = ( |
| 326 | 332 | core: TypedAdapter<CoreSchema>, |
| 327 | 333 | sourceId: string, |
| 328 | −): Effect.Effect<void, Error> => | |
| 334 | +): Effect.Effect<void, StorageFailure> => | |
| 329 | 335 | Effect.gen(function* () { |
| 330 | 336 | yield* core.deleteMany({ |
| 331 | 337 | model: "tool", |
| @@ -345,7 +351,7 @@ const writeDefinitions = ( · 13 unmodified lines | ||
| 345 | 351 | core: TypedAdapter<CoreSchema>, |
| 346 | 352 | pluginId: string, |
| 347 | 353 | input: DefinitionsInput, |
| 348 | −): Effect.Effect<void, Error> => | |
| 354 | +): Effect.Effect<void, StorageFailure> => | |
| 349 | 355 | Effect.gen(function* () { |
| 350 | 356 | yield* core.deleteMany({ |
| 351 | 357 | model: "definition", |
| @@ -505,8 +511,9 @@ export const createExecutor = < · 153 unmodified lines | ||
| 505 | 511 | // without a list() implementation (keychain) never hit the fallback |
| 506 | 512 | // walk because their secrets must be registered through set() to |
| 507 | 513 | // be known at all. |
| 508 | − // ------------------------------------------------------------------ | |
| 509 | − const secretsGet = (id: string): Effect.Effect<string | null, Error> => | |
| 514 | + const secretsGet = ( | |
| 515 | + id: string, | |
| 516 | + ): Effect.Effect<string | null, StorageFailure> => | |
| 510 | 517 | Effect.gen(function* () { |
| 511 | 518 | // Fast path: routing table |
| 512 | 519 | const row = yield* core.findOne({ |
| @@ -538,15 +545,21 @@ export const createExecutor = < · 25 unmodified lines | ||
| 538 | 545 | |
| 539 | 546 | const secretsSet = ( |
| 540 | 547 | input: SetSecretInput, |
| 541 | − ): Effect.Effect<SecretRef, Error> => | |
| 548 | + ): Effect.Effect<SecretRef, StorageFailure> => | |
| 542 | 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 | 554 | let target: SecretProvider | undefined; |
| 545 | 555 | if (input.provider) { |
| 546 | 556 | target = secretProviders.get(input.provider); |
| 547 | 557 | if (!target) { |
| 548 | 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 | 565 | } else { |
| @@ -558,13 +571,19 @@ export const createExecutor = < · 5 unmodified lines | ||
| 558 | 571 | } |
| 559 | 572 | if (!target) { |
| 560 | 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 | 581 | if (!target.writable || !target.set) { |
| 566 | 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 | 619 | Effect.gen(function* () { |
| 601 | 620 | // Providers don't coordinate on which of them own the id — they |
| 602 | 621 | // each get asked. Most calls are no-ops; fan them out so one |
| @@ -630,7 +649,7 @@ export const createExecutor = < · 27 unmodified lines | ||
| 630 | 649 | // so that routing information in the core table is authoritative. |
| 631 | 650 | // Providers without a list() method (e.g. keychain) contribute |
| 632 | 651 | // only via the core table path. |
| 633 | − const secretsList = (): Effect.Effect<readonly SecretRef[], Error> => | |
| 652 | + const secretsList = (): Effect.Effect<readonly SecretRef[], StorageFailure> => | |
| 634 | 653 | Effect.gen(function* () { |
| 635 | 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 — | |
| 717 | − // the runtime value IS the adapter, but the type is whatever the | |
| 718 | − // plugin's schema declares. Plugins never import `typedAdapter` or | |
| 719 | − // `DBAdapter` themselves; they just destructure `{ adapter }` in | |
| 720 | − // their storage factory and get a store typed to their own schema. | |
| 735 | + // Plugin-facing typed view. `StorageError` and `UniqueViolationError` | |
| 736 | + // flow through the typed channel unchanged — plugins can | |
| 737 | + // `catchTag("UniqueViolationError", …)` to translate to their own | |
| 738 | + // user-facing errors; the HTTP edge (see @executor/api | |
| 739 | + // `withCapture`) is responsible for translating any | |
| 740 | + // `StorageError` that still escapes into the opaque InternalError. | |
| 721 | 741 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 722 | 742 | const storageDeps: StorageDeps<any> = { |
| 723 | 743 | scope, |
| @@ -740,21 +760,24 @@ export const createExecutor = < · 16 unmodified lines | ||
| 740 | 760 | // a static source id, or any of whose would-be tool ids |
| 741 | 761 | // collide with a static tool id. Tool ids are |
| 742 | 762 | // `${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 | 765 | if (staticSources.has(input.id)) { |
| 745 | 766 | return yield* Effect.fail( |
| 746 | − new Error( | |
| 747 | − `Source id "${input.id}" collides with a static source`, | |
| 748 | − ), | |
| 767 | + new StorageError({ | |
| 768 | + message: `Source id "${input.id}" collides with a static source`, | |
| 769 | + cause: undefined, | |
| 770 | + }), | |
| 749 | 771 | ); |
| 750 | 772 | } |
| 751 | 773 | for (const tool of input.tools) { |
| 752 | 774 | const fqid = `${input.id}.${tool.name}`; |
| 753 | 775 | if (staticTools.has(fqid)) { |
| 754 | 776 | return yield* Effect.fail( |
| 755 | − new Error( | |
| 756 | − `Tool id "${fqid}" collides with a static tool`, | |
| 757 | − ), | |
| 777 | + new StorageError({ | |
| 778 | + message: `Tool id "${fqid}" collides with a static tool`, | |
| 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 | 804 | secrets: { |
| 782 | − get: secretsGet, | |
| 783 | − list: secretsListForCtx, | |
| 784 | − set: secretsSet, | |
| 785 | − remove: secretsRemove, | |
| 805 | + get: (id) => secretsGet(id), | |
| 806 | + list: () => secretsListForCtx(), | |
| 807 | + set: (input) => secretsSet(input), | |
| 808 | + remove: (id) => secretsRemove(id), | |
| 786 | 809 | }, |
| 787 | 810 | // Open one real tx boundary and route every nested write inside |
| 788 | 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 | 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 | 822 | // Build extension FIRST so it's available as `self` when resolving |
| @@ -1236,7 +1264,7 @@ export const createExecutor = < · 441 unmodified lines | ||
| 1236 | 1264 | // just to ask "does this exist?" |
| 1237 | 1265 | const secretsStatus = ( |
| 1238 | 1266 | id: string, |
| 1239 | − ): Effect.Effect<"resolved" | "missing", Error> => | |
| 1267 | + ): Effect.Effect<"resolved" | "missing", StorageFailure> => | |
| 1240 | 1268 | Effect.gen(function* () { |
| 1241 | 1269 | const row = yield* core.findOne({ |
| 1242 | 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 | 1299 | const base = { |
| 1267 | 1300 | scope, |
| 1268 | 1301 | tools: { |
| @@ -1292,5 +1325,10 @@ export const createExecutor = < · 23 unmodified lines | ||
| 1292 | 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 | }); |
| @@ -10,6 +10,7 @@ export type { · 9 unmodified lines | ||
| 10 | 10 | DBSchema, |
| 11 | 11 | DBFieldAttribute, |
| 12 | 12 | DBFieldType, |
| 13 | + StorageFailure, | |
| 13 | 14 | TypedAdapter, |
| 14 | 15 | Where, |
| 15 | 16 | WhereOperator, |
| @@ -17,6 +18,10 @@ export type { · 1 unmodified line | ||
| 17 | 18 | |
| 18 | 19 | export { 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). | |
| 23 | +export { StorageError, UniqueViolationError } from "@executor/storage-core"; | |
| 24 | + | |
| 20 | 25 | // IDs (branded) |
| 21 | 26 | export { ScopeId, ToolId, SecretId, PolicyId } from "./ids"; |
| 22 | 27 | |
| @@ -2,6 +2,7 @@ import type { Effect } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import type { |
| 3 | 3 | DBAdapter, |
| 4 | 4 | DBSchema, |
| 5 | + StorageFailure, | |
| 5 | 6 | TypedAdapter, |
| 6 | 7 | } from "@executor/storage-core"; |
| 7 | 8 | |
| @@ -36,7 +37,17 @@ import type { SecretProvider, SecretRef, SetSecretInput } from "./secrets"; · 28 unmodified lines | ||
| 36 | 37 | |
| 37 | 38 | export interface StorageDeps<TSchema extends DBSchema | undefined = undefined> { |
| 38 | 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 | 51 | readonly blobs: ScopedBlobStore; |
| 41 | 52 | } |
| 42 | 53 | |
| @@ -72,8 +83,12 @@ export interface PluginCtx<TStore = unknown> { · 29 unmodified lines | ||
| 72 | 83 | |
| 73 | 84 | readonly core: { |
| 74 | 85 | readonly sources: { |
| 75 | − readonly register: (input: SourceInput) => Effect.Effect<void, Error>; | |
| 76 | − readonly unregister: (sourceId: string) => Effect.Effect<void, Error>; | |
| 86 | + readonly register: ( | |
| 87 | + input: SourceInput, | |
| 88 | + ) => Effect.Effect<void, StorageFailure>; | |
| 89 | + readonly unregister: ( | |
| 90 | + sourceId: string, | |
| 91 | + ) => Effect.Effect<void, StorageFailure>; | |
| 77 | 92 | }; |
| 78 | 93 | /** Register shared JSON-schema `$defs` for a source. Tool |
| 79 | 94 | * input/output schemas registered via `sources.register` can carry |
| @@ -84,15 +99,17 @@ export interface PluginCtx<TStore = unknown> { · 4 unmodified lines | ||
| 84 | 99 | readonly definitions: { |
| 85 | 100 | readonly register: ( |
| 86 | 101 | input: DefinitionsInput, |
| 87 | − ) => Effect.Effect<void, Error>; | |
| 102 | + ) => Effect.Effect<void, StorageFailure>; | |
| 88 | 103 | }; |
| 89 | 104 | }; |
| 90 | 105 | |
| 91 | 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 | 110 | readonly list: () => Effect.Effect< |
| 94 | 111 | readonly { readonly id: string; readonly name: string; readonly provider: string }[], |
| 95 | − Error | |
| 112 | + StorageFailure | |
| 96 | 113 | >; |
| 97 | 114 | /** Write a secret value through a provider. Used by plugins that |
| 98 | 115 | * mint secrets on behalf of the user (OAuth2 token storage, |
| @@ -100,9 +117,11 @@ export interface PluginCtx<TStore = unknown> { · 1 unmodified line | ||
| 100 | 117 | * `executor.secrets.set` on the host surface, but OAuth2 refresh |
| 101 | 118 | * and one-shot token capture from plugin-owned flows need it here |
| 102 | 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 | 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 | 127 | /** Run `effect` inside a database transaction. Wraps the underlying |
| @@ -111,7 +130,7 @@ export interface PluginCtx<TStore = unknown> { · 2 unmodified lines | ||
| 111 | 130 | * registration. */ |
| 112 | 131 | readonly transaction: <A, E>( |
| 113 | 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 | 165 | readonly annotations?: ToolAnnotations; |
| 147 | 166 | readonly handler: ( |
| 148 | 167 | input: StaticToolHandlerInput<TStore>, |
| 149 | − ) => Effect.Effect<unknown, Error>; | |
| 168 | + ) => Effect.Effect<unknown, unknown>; | |
| 150 | 169 | } |
| 151 | 170 | |
| 152 | 171 | export interface StaticSourceDecl<TStore = unknown> { |
| @@ -228,7 +247,7 @@ export interface PluginSpec< · 75 unmodified lines | ||
| 228 | 247 | * only static tools can omit it. */ |
| 229 | 248 | readonly invokeTool?: ( |
| 230 | 249 | input: InvokeToolInput<TStore>, |
| 231 | − ) => Effect.Effect<unknown, Error>; | |
| 250 | + ) => Effect.Effect<unknown, unknown>; | |
| 232 | 251 | |
| 233 | 252 | /** Bulk resolve annotations (requiresApproval, approvalDescription, |
| 234 | 253 | * mayElicit) for a set of tool rows under a single source. Called |
| @@ -251,7 +270,7 @@ export interface PluginSpec< · 16 unmodified lines | ||
| 251 | 270 | readonly ctx: PluginCtx<TStore>; |
| 252 | 271 | readonly sourceId: string; |
| 253 | 272 | readonly toolRows: readonly ToolRow[]; |
| 254 | − }) => Effect.Effect<Record<string, ToolAnnotations>, Error>; | |
| 273 | + }) => Effect.Effect<Record<string, ToolAnnotations>, unknown>; | |
| 255 | 274 | |
| 256 | 275 | /** Called when `executor.sources.remove(id)` targets a source owned |
| 257 | 276 | * by this plugin. Plugin-side cleanup only; the executor deletes |
| @@ -259,11 +278,11 @@ export interface PluginSpec< · 1 unmodified line | ||
| 259 | 278 | * the same transaction. */ |
| 260 | 279 | readonly removeSource?: ( |
| 261 | 280 | input: SourceLifecycleInput<TStore>, |
| 262 | − ) => Effect.Effect<void, Error>; | |
| 281 | + ) => Effect.Effect<void, unknown>; | |
| 263 | 282 | |
| 264 | 283 | readonly refreshSource?: ( |
| 265 | 284 | input: SourceLifecycleInput<TStore>, |
| 266 | − ) => Effect.Effect<void, Error>; | |
| 285 | + ) => Effect.Effect<void, unknown>; | |
| 267 | 286 | |
| 268 | 287 | /** URL autodetection hook. When the user pastes a URL in the |
| 269 | 288 | * onboarding UI, `executor.sources.detect(url)` fans out to every |
| @@ -274,7 +293,7 @@ export interface PluginSpec< · 4 unmodified lines | ||
| 274 | 293 | readonly detect?: (input: { |
| 275 | 294 | readonly ctx: PluginCtx<TStore>; |
| 276 | 295 | readonly url: string; |
| 277 | − }) => Effect.Effect<SourceDetectionResult | null, Error>; | |
| 296 | + }) => Effect.Effect<SourceDetectionResult | null, unknown>; | |
| 278 | 297 | |
| 279 | 298 | /** Secret providers contributed by this plugin. Either a static |
| 280 | 299 | * array or a function of ctx (for providers that need per-instance |
| @@ -285,7 +304,7 @@ export interface PluginSpec< · 4 unmodified lines | ||
| 285 | 304 | | readonly SecretProvider[] |
| 286 | 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 | |
| 291 | 310 | export interface Plugin< |
| @@ -125,6 +125,12 @@ export const createExecutor = async < · 124 unmodified lines | ||
| 125 | 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 | 134 | const effectExecutor = await Effect.runPromise( |
| 129 | 135 | createEffectExecutor(effectConfig), |
| 130 | 136 | ); |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | import { Effect, Schema } from "effect"; |
| 2 | 2 | |
| 3 | +import type { StorageFailure } from "@executor/storage-core"; | |
| 4 | + | |
| 3 | 5 | import { SecretId, ScopeId } from "./ids"; |
| 4 | 6 | |
| 5 | 7 | // --------------------------------------------------------------------------- |
| @@ -20,19 +22,25 @@ export interface SecretProvider { · 14 unmodified lines | ||
| 20 | 22 | * honours this before routing writes — trying to write to a |
| 21 | 23 | * read-only provider is an error, not a silent drop. */ |
| 22 | 24 | readonly writable: boolean; |
| 23 | − /** Get a secret value by id. Returns null if not found. Errors are | |
| 24 | − * real failures (provider unreachable, decryption failed, etc.). */ | |
| 25 | − readonly get: (id: string) => Effect.Effect<string | null, Error>; | |
| 25 | + /** Get a secret value by id. Returns null if not found. Failures | |
| 26 | + * (provider unreachable, decryption failed, etc.) surface as | |
| 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 | 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 | 36 | /** Delete a secret. Only called on writable providers. Returns true |
| 29 | 37 | * if something was deleted. */ |
| 30 | − readonly delete?: (id: string) => Effect.Effect<boolean, Error>; | |
| 38 | + readonly delete?: (id: string) => Effect.Effect<boolean, StorageFailure>; | |
| 31 | 39 | /** Enumerate known secret entries. Optional — not all backends can |
| 32 | 40 | * enumerate (env-backed providers, for example). */ |
| 33 | 41 | readonly list?: () => Effect.Effect< |
| 34 | 42 | readonly { readonly id: string; readonly name: string }[], |
| 35 | − Error | |
| 43 | + StorageFailure | |
| 36 | 44 | >; |
| 37 | 45 | } |
| 38 | 46 | |
| @@ -5,8 +5,12 @@ · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import type { Effect } from "effect"; |
| 7 | 7 | |
| 8 | +import type { StorageError, UniqueViolationError } from "./errors"; | |
| 8 | 9 | import type { DBFieldAttribute, DBSchema } from "./schema"; |
| 9 | 10 | |
| 11 | +/** Error union every backend method may emit. */ | |
| 12 | +export type StorageFailure = StorageError | UniqueViolationError; | |
| 13 | + | |
| 10 | 14 | // --------------------------------------------------------------------------- |
| 11 | 15 | // Where clauses |
| 12 | 16 | // --------------------------------------------------------------------------- |
| @@ -102,7 +106,7 @@ export type DBAdapter = { · 89 unmodified lines | ||
| 102 | 106 | select?: string[] | undefined; |
| 103 | 107 | /** Preserve an `id` in `data` instead of discarding it. */ |
| 104 | 108 | forceAllowId?: boolean | undefined; |
| 105 | − }) => Effect.Effect<R, Error>; | |
| 109 | + }) => Effect.Effect<R, StorageFailure>; | |
| 106 | 110 | |
| 107 | 111 | /** |
| 108 | 112 | * Insert multiple rows in one call. Backends that don't have native |
| @@ -113,14 +117,14 @@ export type DBAdapter = { · 4 unmodified lines | ||
| 113 | 117 | model: string; |
| 114 | 118 | data: ReadonlyArray<Omit<T, "id">>; |
| 115 | 119 | forceAllowId?: boolean | undefined; |
| 116 | − }) => Effect.Effect<readonly R[], Error>; | |
| 120 | + }) => Effect.Effect<readonly R[], StorageFailure>; | |
| 117 | 121 | |
| 118 | 122 | findOne: <T>(data: { |
| 119 | 123 | model: string; |
| 120 | 124 | where: Where[]; |
| 121 | 125 | select?: string[] | undefined; |
| 122 | 126 | join?: JoinOption | undefined; |
| 123 | − }) => Effect.Effect<T | null, Error>; | |
| 127 | + }) => Effect.Effect<T | null, StorageFailure>; | |
| 124 | 128 | |
| 125 | 129 | findMany: <T>(data: { |
| 126 | 130 | model: string; |
| @@ -135,12 +139,12 @@ export type DBAdapter = { · 8 unmodified lines | ||
| 135 | 139 | | undefined; |
| 136 | 140 | offset?: number | undefined; |
| 137 | 141 | join?: JoinOption | undefined; |
| 138 | − }) => Effect.Effect<readonly T[], Error>; | |
| 142 | + }) => Effect.Effect<readonly T[], StorageFailure>; | |
| 139 | 143 | |
| 140 | 144 | count: (data: { |
| 141 | 145 | model: string; |
| 142 | 146 | where?: Where[] | undefined; |
| 143 | − }) => Effect.Effect<number, Error>; | |
| 147 | + }) => Effect.Effect<number, StorageFailure>; | |
| 144 | 148 | |
| 145 | 149 | /** |
| 146 | 150 | * ⚠︎ `update` may return `null` if multiple rows match — prefer `updateMany` |
| @@ -150,17 +154,17 @@ export type DBAdapter = { · 3 unmodified lines | ||
| 150 | 154 | model: string; |
| 151 | 155 | where: Where[]; |
| 152 | 156 | update: Record<string, unknown>; |
| 153 | − }) => Effect.Effect<T | null, Error>; | |
| 157 | + }) => Effect.Effect<T | null, StorageFailure>; | |
| 154 | 158 | |
| 155 | 159 | updateMany: (data: { |
| 156 | 160 | model: string; |
| 157 | 161 | where: Where[]; |
| 158 | 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 | 170 | * Run operations in a transaction. Backends without transaction support |
| @@ -168,7 +172,7 @@ export type DBAdapter = { · 1 unmodified line | ||
| 168 | 172 | */ |
| 169 | 173 | transaction: <R, E>( |
| 170 | 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 | 178 | * Optional migration-source generator. Forwarded verbatim from the |
| @@ -179,7 +183,7 @@ export type DBAdapter = { · 4 unmodified lines | ||
| 179 | 183 | | ((props: { |
| 180 | 184 | file?: string | undefined; |
| 181 | 185 | tables: DBSchema; |
| 182 | − }) => Effect.Effect<DBAdapterSchemaCreation, Error>) | |
| 186 | + }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>) | |
| 183 | 187 | | undefined; |
| 184 | 188 | |
| 185 | 189 | /** |
| @@ -205,7 +209,7 @@ export interface CustomAdapter { · 19 unmodified lines | ||
| 205 | 209 | model: string; |
| 206 | 210 | data: T; |
| 207 | 211 | select?: string[] | undefined; |
| 208 | − }) => Effect.Effect<T, Error>; | |
| 212 | + }) => Effect.Effect<T, StorageFailure>; | |
| 209 | 213 | |
| 210 | 214 | /** |
| 211 | 215 | * Native bulk insert. Required because over a real network (e.g. |
| @@ -217,26 +221,26 @@ export interface CustomAdapter { · 5 unmodified lines | ||
| 217 | 221 | createMany: <T extends Record<string, unknown>>(data: { |
| 218 | 222 | model: string; |
| 219 | 223 | data: ReadonlyArray<T>; |
| 220 | − }) => Effect.Effect<T[], Error>; | |
| 224 | + }) => Effect.Effect<T[], StorageFailure>; | |
| 221 | 225 | |
| 222 | 226 | update: <T>(data: { |
| 223 | 227 | model: string; |
| 224 | 228 | where: CleanedWhere[]; |
| 225 | 229 | update: T; |
| 226 | − }) => Effect.Effect<T | null, Error>; | |
| 230 | + }) => Effect.Effect<T | null, StorageFailure>; | |
| 227 | 231 | |
| 228 | 232 | updateMany: (data: { |
| 229 | 233 | model: string; |
| 230 | 234 | where: CleanedWhere[]; |
| 231 | 235 | update: Record<string, unknown>; |
| 232 | − }) => Effect.Effect<number, Error>; | |
| 236 | + }) => Effect.Effect<number, StorageFailure>; | |
| 233 | 237 | |
| 234 | 238 | findOne: <T>(data: { |
| 235 | 239 | model: string; |
| 236 | 240 | where: CleanedWhere[]; |
| 237 | 241 | select?: string[] | undefined; |
| 238 | 242 | join?: JoinConfig | undefined; |
| 239 | − }) => Effect.Effect<T | null, Error>; | |
| 243 | + }) => Effect.Effect<T | null, StorageFailure>; | |
| 240 | 244 | |
| 241 | 245 | findMany: <T>(data: { |
| 242 | 246 | model: string; |
| @@ -246,22 +250,22 @@ export interface CustomAdapter { · 3 unmodified lines | ||
| 246 | 250 | sortBy?: { field: string; direction: "asc" | "desc" } | undefined; |
| 247 | 251 | offset?: number | undefined; |
| 248 | 252 | join?: JoinConfig | undefined; |
| 249 | − }) => Effect.Effect<T[], Error>; | |
| 253 | + }) => Effect.Effect<T[], StorageFailure>; | |
| 250 | 254 | |
| 251 | 255 | delete: (data: { |
| 252 | 256 | model: string; |
| 253 | 257 | where: CleanedWhere[]; |
| 254 | − }) => Effect.Effect<void, Error>; | |
| 258 | + }) => Effect.Effect<void, StorageFailure>; | |
| 255 | 259 | |
| 256 | 260 | deleteMany: (data: { |
| 257 | 261 | model: string; |
| 258 | 262 | where: CleanedWhere[]; |
| 259 | − }) => Effect.Effect<number, Error>; | |
| 263 | + }) => Effect.Effect<number, StorageFailure>; | |
| 260 | 264 | |
| 261 | 265 | count: (data: { |
| 262 | 266 | model: string; |
| 263 | 267 | where?: CleanedWhere[] | undefined; |
| 264 | − }) => Effect.Effect<number, Error>; | |
| 268 | + }) => Effect.Effect<number, StorageFailure>; | |
| 265 | 269 | |
| 266 | 270 | /** |
| 267 | 271 | * Optional migration-source generator — plugin authors use this to |
| @@ -272,7 +276,7 @@ export interface CustomAdapter { · 4 unmodified lines | ||
| 272 | 276 | | ((props: { |
| 273 | 277 | file?: string | undefined; |
| 274 | 278 | tables: DBSchema; |
| 275 | − }) => Effect.Effect<DBAdapterSchemaCreation, Error>) | |
| 279 | + }) => Effect.Effect<DBAdapterSchemaCreation, StorageFailure>) | |
| 276 | 280 | | undefined; |
| 277 | 281 | |
| 278 | 282 | options?: Record<string, unknown> | undefined; |
| @@ -321,7 +325,7 @@ export interface DBAdapterFactoryConfig { · 42 unmodified lines | ||
| 321 | 325 | | false |
| 322 | 326 | | (<R, E>( |
| 323 | 327 | callback: (trx: DBTransactionAdapter) => Effect.Effect<R, E>, |
| 324 | − ) => Effect.Effect<R, E | Error>) | |
| 328 | + ) => Effect.Effect<R, E | StorageFailure>) | |
| 325 | 329 | ) |
| 326 | 330 | | undefined; |
| 327 | 331 | /** Skip id generation on `create`. @default false */ |
| @@ -354,7 +358,7 @@ export interface DBAdapterFactoryConfig { · 26 unmodified lines | ||
| 354 | 358 | | "count"; |
| 355 | 359 | model: string; |
| 356 | 360 | schema: DBSchema; |
| 357 | − }) => Effect.Effect<unknown, Error>) | |
| 361 | + }) => Effect.Effect<unknown, StorageFailure>) | |
| 358 | 362 | | undefined; |
| 359 | 363 | /** |
| 360 | 364 | * Per-field hook run inside the factory's read-path transform. Called |
| @@ -369,7 +373,7 @@ export interface DBAdapterFactoryConfig { · 8 unmodified lines | ||
| 369 | 373 | select: readonly string[]; |
| 370 | 374 | model: string; |
| 371 | 375 | schema: DBSchema; |
| 372 | − }) => Effect.Effect<unknown, Error>) | |
| 376 | + }) => Effect.Effect<unknown, StorageFailure>) | |
| 373 | 377 | | undefined; |
| 374 | 378 | /** Escape hatch — skip write-path transform entirely. @default false */ |
| 375 | 379 | disableTransformInput?: boolean | undefined; |
| @@ -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 | + | |
| 17 | +import { 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 | + */ | |
| 25 | +export 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 | + */ | |
| 38 | +export class UniqueViolationError extends Data.TaggedError( | |
| 39 | + "UniqueViolationError", | |
| 40 | +)<{ | |
| 41 | + readonly model?: string; | |
| 42 | +}> {} | |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // |
| 4 | 4 | // Vendored from better-auth (packages/core/src/db/adapter/factory.ts) under |
| 5 | 5 | // MIT. Adapted for executor: |
| 6 | −// - Promise/async → Effect.Effect<T, Error> | |
| 6 | +// - Promise/async → Effect.Effect<T, StorageFailure> | |
| 7 | 7 | // - Stripped auth-specific concerns (numeric serial ids, joins, telemetry |
| 8 | 8 | // spans, logger, plural model name resolution, BetterAuthOptions) |
| 9 | 9 | // - Contract matches our CustomAdapter + DBAdapterFactoryConfig in |
| @@ -35,8 +35,10 @@ import type { · 25 unmodified lines | ||
| 35 | 35 | DBTransactionAdapter, |
| 36 | 36 | JoinConfig, |
| 37 | 37 | JoinOption, |
| 38 | + StorageFailure, | |
| 38 | 39 | Where, |
| 39 | 40 | } from "./adapter"; |
| 41 | +import { StorageError } from "./errors"; | |
| 40 | 42 | import type { DBFieldAttribute, DBSchema, DBPrimitive } from "./schema"; |
| 41 | 43 | |
| 42 | 44 | // --------------------------------------------------------------------------- |
| @@ -124,12 +126,17 @@ export const createAdapter = ( · 81 unmodified lines | ||
| 124 | 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 | 132 | Effect.gen(function* () { |
| 129 | 133 | const def = schema[model]; |
| 130 | 134 | if (!def) { |
| 131 | 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 | 142 | return def; |
| @@ -258,7 +265,7 @@ export const createAdapter = ( · 122 unmodified lines | ||
| 258 | 265 | data: Record<string, unknown>, |
| 259 | 266 | action: "create" | "update", |
| 260 | 267 | forceAllowId: boolean, |
| 261 | − ): Effect.Effect<Record<string, unknown>, Error> => | |
| 268 | + ): Effect.Effect<Record<string, unknown>, StorageFailure> => | |
| 262 | 269 | Effect.gen(function* () { |
| 263 | 270 | const def = yield* getModelDef(model); |
| 264 | 271 | const out: Record<string, unknown> = {}; |
| @@ -303,10 +310,11 @@ export const createAdapter = ( · 38 unmodified lines | ||
| 303 | 310 | if (res && typeof (res as { then?: unknown }).then === "function") { |
| 304 | 311 | value = yield* Effect.tryPromise({ |
| 305 | 312 | try: () => res as Promise<DBPrimitive>, |
| 306 | − catch: (e) => | |
| 307 | − new Error( | |
| 308 | − `[storage-core] transform.input for "${model}.${logical}" failed: ${String(e)}`, | |
| 309 | − ), | |
| 313 | + catch: (cause) => | |
| 314 | + new StorageError({ | |
| 315 | + message: `[storage-core] transform.input for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 316 | + cause, | |
| 317 | + }), | |
| 310 | 318 | }); |
| 311 | 319 | } else { |
| 312 | 320 | value = res; |
| @@ -347,7 +355,7 @@ export const createAdapter = ( · 34 unmodified lines | ||
| 347 | 355 | model: string, |
| 348 | 356 | row: Record<string, unknown> | null, |
| 349 | 357 | select?: string[], |
| 350 | − ): Effect.Effect<Record<string, unknown> | null, Error> => | |
| 358 | + ): Effect.Effect<Record<string, unknown> | null, StorageFailure> => | |
| 351 | 359 | Effect.gen(function* () { |
| 352 | 360 | if (row === null || row === undefined) return null; |
| 353 | 361 | const def = yield* getModelDef(model); |
| @@ -377,10 +385,11 @@ export const createAdapter = ( · 23 unmodified lines | ||
| 377 | 385 | if (res && typeof (res as { then?: unknown }).then === "function") { |
| 378 | 386 | value = yield* Effect.tryPromise({ |
| 379 | 387 | try: () => res as Promise<DBPrimitive>, |
| 380 | − catch: (e) => | |
| 381 | − new Error( | |
| 382 | − `[storage-core] transform.output for "${model}.${logical}" failed: ${String(e)}`, | |
| 383 | − ), | |
| 388 | + catch: (cause) => | |
| 389 | + new StorageError({ | |
| 390 | + message: `[storage-core] transform.output for "${model}.${logical}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 391 | + cause, | |
| 392 | + }), | |
| 384 | 393 | }); |
| 385 | 394 | } else { |
| 386 | 395 | value = res; |
| @@ -532,7 +541,7 @@ export const createAdapter = ( · 145 unmodified lines | ||
| 532 | 541 | data: Record<string, unknown>, |
| 533 | 542 | action: "create" | "update", |
| 534 | 543 | forceAllowId: boolean, |
| 535 | − ): Effect.Effect<Record<string, unknown>, Error> => | |
| 544 | + ): Effect.Effect<Record<string, unknown>, StorageFailure> => | |
| 536 | 545 | config.disableTransformInput |
| 537 | 546 | ? Effect.succeed(data) |
| 538 | 547 | : transformInput(model, data, action, forceAllowId); |
| @@ -551,7 +560,7 @@ export const createAdapter = ( · 12 unmodified lines | ||
| 551 | 560 | base: Record<string, unknown> | null, |
| 552 | 561 | raw: Record<string, unknown> | null, |
| 553 | 562 | join: JoinOption | undefined, |
| 554 | − ): Effect.Effect<Record<string, unknown> | null, Error> => | |
| 563 | + ): Effect.Effect<Record<string, unknown> | null, StorageFailure> => | |
| 555 | 564 | Effect.gen(function* () { |
| 556 | 565 | if (!base || !raw || !join) return base; |
| 557 | 566 | const merged: Record<string, unknown> = { ...base }; |
| @@ -593,7 +602,7 @@ export const createAdapter = ( · 35 unmodified lines | ||
| 593 | 602 | model: string, |
| 594 | 603 | row: Record<string, unknown> | null, |
| 595 | 604 | select?: string[], |
| 596 | − ): Effect.Effect<Record<string, unknown> | null, Error> => | |
| 605 | + ): Effect.Effect<Record<string, unknown> | null, StorageFailure> => | |
| 597 | 606 | config.disableTransformOutput |
| 598 | 607 | ? Effect.succeed(row) |
| 599 | 608 | : transformOutput(model, row, select); |
| @@ -15,6 +15,8 @@ export { typedAdapter, type TypedAdapter } from "./typed"; · 14 unmodified lines | ||
| 15 | 15 | |
| 16 | 16 | export { createAdapter, type CreateAdapterOptions } from "./factory"; |
| 17 | 17 | |
| 18 | +export { StorageError, UniqueViolationError } from "./errors"; | |
| 19 | + | |
| 18 | 20 | export { |
| 19 | 21 | whereOperators, |
| 20 | 22 | type WhereOperator, |
| @@ -28,4 +30,5 @@ export { · 7 unmodified lines | ||
| 28 | 30 | type DBAdapterDebugLogOption, |
| 29 | 31 | type DBAdapterFactoryConfig, |
| 30 | 32 | type DBAdapterSchemaCreation, |
| 33 | + type StorageFailure, | |
| 31 | 34 | } from "./adapter"; |
| @@ -22,6 +22,7 @@ import type { · 21 unmodified lines | ||
| 22 | 22 | DBAdapter, |
| 23 | 23 | DBAdapterFactoryConfig, |
| 24 | 24 | JoinConfig, |
| 25 | + StorageFailure, | |
| 25 | 26 | } from "../adapter"; |
| 26 | 27 | import type { DBSchema } from "../schema"; |
| 27 | 28 | import { createAdapter } from "../factory"; |
| @@ -266,7 +267,7 @@ export const makeMemoryAdapter = ( · 238 unmodified lines | ||
| 266 | 267 | }), |
| 267 | 268 | ); |
| 268 | 269 | return result; |
| 269 | − }) as Effect.Effect<R, E | Error>; | |
| 270 | + }) as Effect.Effect<R, E | StorageFailure>; | |
| 270 | 271 | |
| 271 | 272 | const adapter: DBAdapter = createAdapter({ |
| 272 | 273 | schema: options.schema, |
| @@ -17,7 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import type { Effect } from "effect"; |
| 19 | 19 | |
| 20 | −import type { DBAdapter, Where } from "./adapter"; | |
| 20 | +import type { DBAdapter, StorageFailure, Where } from "./adapter"; | |
| 21 | 21 | import type { |
| 22 | 22 | DBSchema, |
| 23 | 23 | InferDBFieldsInput, |
| @@ -34,25 +34,33 @@ type RowOutput<S extends DBSchema, M extends keyof S> = InferDBFieldsOutput< · 10 unmodified lines | ||
| 34 | 34 | > & |
| 35 | 35 | Record<string, unknown>; |
| 36 | 36 | |
| 37 | −export 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 | + */ | |
| 45 | +export interface TypedAdapter<S extends DBSchema, E = StorageFailure> { | |
| 38 | 46 | readonly raw: DBAdapter; |
| 39 | 47 | |
| 40 | 48 | readonly create: <M extends keyof S & string>(data: { |
| 41 | 49 | model: M; |
| 42 | 50 | data: Omit<RowInput<S, M>, "id"> & { id?: string }; |
| 43 | 51 | forceAllowId?: boolean; |
| 44 | − }) => Effect.Effect<RowOutput<S, M>, Error>; | |
| 52 | + }) => Effect.Effect<RowOutput<S, M>, E>; | |
| 45 | 53 | |
| 46 | 54 | readonly createMany: <M extends keyof S & string>(data: { |
| 47 | 55 | model: M; |
| 48 | 56 | data: ReadonlyArray<Omit<RowInput<S, M>, "id"> & { id?: string }>; |
| 49 | 57 | forceAllowId?: boolean; |
| 50 | − }) => Effect.Effect<readonly RowOutput<S, M>[], Error>; | |
| 58 | + }) => Effect.Effect<readonly RowOutput<S, M>[], E>; | |
| 51 | 59 | |
| 52 | 60 | readonly findOne: <M extends keyof S & string>(data: { |
| 53 | 61 | model: M; |
| 54 | 62 | where: Where[]; |
| 55 | − }) => Effect.Effect<RowOutput<S, M> | null, Error>; | |
| 63 | + }) => Effect.Effect<RowOutput<S, M> | null, E>; | |
| 56 | 64 | |
| 57 | 65 | readonly findMany: <M extends keyof S & string>(data: { |
| 58 | 66 | model: M; |
| @@ -60,34 +68,34 @@ export interface TypedAdapter<S extends DBSchema> { · 1 unmodified line | ||
| 60 | 68 | limit?: number; |
| 61 | 69 | sortBy?: { field: string; direction: "asc" | "desc" }; |
| 62 | 70 | offset?: number; |
| 63 | − }) => Effect.Effect<readonly RowOutput<S, M>[], Error>; | |
| 71 | + }) => Effect.Effect<readonly RowOutput<S, M>[], E>; | |
| 64 | 72 | |
| 65 | 73 | readonly update: <M extends keyof S & string>(data: { |
| 66 | 74 | model: M; |
| 67 | 75 | where: Where[]; |
| 68 | 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 | 79 | readonly updateMany: <M extends keyof S & string>(data: { |
| 72 | 80 | model: M; |
| 73 | 81 | where: Where[]; |
| 74 | 82 | update: Partial<RowInput<S, M>>; |
| 75 | − }) => Effect.Effect<number, Error>; | |
| 83 | + }) => Effect.Effect<number, E>; | |
| 76 | 84 | |
| 77 | 85 | readonly delete: <M extends keyof S & string>(data: { |
| 78 | 86 | model: M; |
| 79 | 87 | where: Where[]; |
| 80 | − }) => Effect.Effect<void, Error>; | |
| 88 | + }) => Effect.Effect<void, E>; | |
| 81 | 89 | |
| 82 | 90 | readonly deleteMany: <M extends keyof S & string>(data: { |
| 83 | 91 | model: M; |
| 84 | 92 | where: Where[]; |
| 85 | − }) => Effect.Effect<number, Error>; | |
| 93 | + }) => Effect.Effect<number, E>; | |
| 86 | 94 | |
| 87 | 95 | readonly count: <M extends keyof S & string>(data: { |
| 88 | 96 | model: M; |
| 89 | 97 | where?: Where[]; |
| 90 | − }) => Effect.Effect<number, Error>; | |
| 98 | + }) => Effect.Effect<number, E>; | |
| 91 | 99 | } |
| 92 | 100 | |
| 93 | 101 | /** |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | // |
| 4 | 4 | // Vendored from better-auth (packages/drizzle-adapter/src/drizzle-adapter.ts) |
| 5 | 5 | // under MIT. Adapted for executor: |
| 6 | −// - Promise/async → Effect.Effect<T, Error> | |
| 6 | +// - Promise/async → Effect.Effect<T, StorageFailure> | |
| 7 | 7 | // - Tables read from `db._.fullSchema` (drizzle schema introspection) |
| 8 | 8 | // - Relational queries via `db.query[model]` for join resolution |
| 9 | 9 | // - Filter/compile logic matches our CleanedWhere shape directly |
| @@ -41,7 +41,16 @@ import type { · 31 unmodified lines | ||
| 41 | 41 | DBSchema, |
| 42 | 42 | JoinConfig, |
| 43 | 43 | } from "@executor/storage-core"; |
| 44 | −import { createAdapter } from "@executor/storage-core"; | |
| 44 | +import { | |
| 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. | |
| 53 | +type StorageFailure = StorageError | UniqueViolationError; | |
| 45 | 54 | |
| 46 | 55 | // --------------------------------------------------------------------------- |
| 47 | 56 | // Types |
| @@ -227,18 +236,54 @@ const buildIncludes = ( · 179 unmodified lines | ||
| 227 | 236 | |
| 228 | 237 | // --------------------------------------------------------------------------- |
| 229 | 238 | // 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 | |
| 247 | +const 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 | + | |
| 263 | +const 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 | + | |
| 232 | 279 | const runPromise = <T>( |
| 233 | 280 | op: string, |
| 234 | 281 | fn: () => Promise<T>, |
| 235 | −): Effect.Effect<T, Error> => | |
| 282 | + model?: string, | |
| 283 | +): Effect.Effect<T, StorageFailure> => | |
| 236 | 284 | Effect.tryPromise({ |
| 237 | 285 | try: fn, |
| 238 | − catch: (e) => | |
| 239 | − new Error( | |
| 240 | − `[storage-drizzle] ${op} failed: ${e instanceof Error ? e.message : String(e)}`, | |
| 241 | − ), | |
| 286 | + catch: (cause) => classifyError(op, model, cause), | |
| 242 | 287 | }); |
| 243 | 288 | |
| 244 | 289 | // --------------------------------------------------------------------------- |
| @@ -254,33 +299,46 @@ const withReturning = ( · 9 unmodified lines | ||
| 254 | 299 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 255 | 300 | builder: any, |
| 256 | 301 | data: Record<string, unknown>, |
| 257 | −): Effect.Effect<Record<string, unknown>, Error> => | |
| 302 | + model: string, | |
| 303 | +): Effect.Effect<Record<string, unknown>, StorageFailure> => | |
| 258 | 304 | Effect.gen(function* () { |
| 259 | 305 | if (provider === "mysql") { |
| 260 | − yield* runPromise("mysql insert execute", () => builder.execute()); | |
| 306 | + yield* runPromise("mysql insert execute", () => builder.execute(), model); | |
| 261 | 307 | // best-effort: look up by id if present |
| 262 | 308 | if (data.id !== undefined) { |
| 263 | − const rows = (yield* runPromise("mysql select after insert", () => | |
| 264 | − db.select().from(table).where(eq(table.id, data.id)).limit(1), | |
| 309 | + const rows = (yield* runPromise( | |
| 310 | + "mysql select after insert", | |
| 311 | + () => db.select().from(table).where(eq(table.id, data.id)).limit(1), | |
| 312 | + model, | |
| 265 | 313 | )) as Record<string, unknown>[]; |
| 266 | 314 | if (!rows[0]) |
| 267 | 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 | 321 | return rows[0]; |
| 271 | 322 | } |
| 272 | 323 | return yield* Effect.fail( |
| 273 | − new Error( | |
| 274 | − "[storage-drizzle] mysql insert: id not provided, cannot recover row", | |
| 275 | − ), | |
| 324 | + new StorageError({ | |
| 325 | + message: | |
| 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", () => | |
| 279 | − builder.returning(), | |
| 331 | + const rows = (yield* runPromise( | |
| 332 | + "insert returning", | |
| 333 | + () => builder.returning(), | |
| 334 | + model, | |
| 280 | 335 | )) as Record<string, unknown>[]; |
| 281 | 336 | if (!rows[0]) |
| 282 | 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 | 343 | return rows[0]; |
| 286 | 344 | }); |
| @@ -308,7 +366,14 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 21 unmodified lines | ||
| 308 | 366 | Effect.gen(function* () { |
| 309 | 367 | const table = getTable(model); |
| 310 | 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 | 377 | return row as never; |
| 313 | 378 | }), |
| 314 | 379 | |
| @@ -328,8 +393,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 13 unmodified lines | ||
| 328 | 393 | const all: Record<string, unknown>[] = []; |
| 329 | 394 | for (let i = 0; i < data.length; i += CHUNK) { |
| 330 | 395 | const slice = data.slice(i, i + CHUNK) as Record<string, unknown>[]; |
| 331 | − const rows = (yield* runPromise("insert many returning", () => | |
| 332 | − db.insert(table).values(slice).returning(), | |
| 396 | + const rows = (yield* runPromise( | |
| 397 | + "insert many returning", | |
| 398 | + () => db.insert(table).values(slice).returning(), | |
| 399 | + model, | |
| 333 | 400 | )) as Record<string, unknown>[]; |
| 334 | 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 | 409 | const clause = compileWhere(table, where, provider); |
| 343 | 410 | if (join && db.query && db.query[model]) { |
| 344 | 411 | const includes = buildIncludes(join); |
| 345 | − const rows = (yield* runPromise("findOne query.findFirst", () => | |
| 346 | − Promise.resolve( | |
| 347 | − db.query[model].findFirst({ | |
| 348 | − where: clause, | |
| 349 | − with: includes, | |
| 350 | − }), | |
| 351 | − ), | |
| 412 | + const rows = (yield* runPromise( | |
| 413 | + "findOne query.findFirst", | |
| 414 | + () => | |
| 415 | + Promise.resolve( | |
| 416 | + db.query[model].findFirst({ | |
| 417 | + where: clause, | |
| 418 | + with: includes, | |
| 419 | + }), | |
| 420 | + ), | |
| 421 | + model, | |
| 352 | 422 | )) as Record<string, unknown> | undefined; |
| 353 | 423 | return (rows ?? null) as never; |
| 354 | 424 | } |
| 355 | 425 | let q = db.select().from(table); |
| 356 | 426 | if (clause) q = q.where(clause); |
| 357 | − const rows = (yield* runPromise("findOne select", () => | |
| 358 | − q.limit(1), | |
| 427 | + const rows = (yield* runPromise( | |
| 428 | + "findOne select", | |
| 429 | + () => q.limit(1), | |
| 430 | + model, | |
| 359 | 431 | )) as Record<string, unknown>[]; |
| 360 | 432 | return (rows[0] ?? null) as never; |
| 361 | 433 | }), |
| @@ -378,8 +450,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines | ||
| 378 | 450 | const fn = sortBy.direction === "desc" ? desc : asc; |
| 379 | 451 | opts.orderBy = [fn(col)]; |
| 380 | 452 | } |
| 381 | − const rows = (yield* runPromise("findMany query.findMany", () => | |
| 382 | − Promise.resolve(db.query[model].findMany(opts)), | |
| 453 | + const rows = (yield* runPromise( | |
| 454 | + "findMany query.findMany", | |
| 455 | + () => Promise.resolve(db.query[model].findMany(opts)), | |
| 456 | + model, | |
| 383 | 457 | )) as Record<string, unknown>[]; |
| 384 | 458 | return rows as never[]; |
| 385 | 459 | } |
| @@ -397,8 +471,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 11 unmodified lines | ||
| 397 | 471 | else if (offset !== undefined && provider === "sqlite") |
| 398 | 472 | q = q.limit(Number.MAX_SAFE_INTEGER); |
| 399 | 473 | if (offset !== undefined) q = q.offset(offset); |
| 400 | − const rows = (yield* runPromise("findMany select", () => | |
| 401 | − Promise.resolve(q), | |
| 474 | + const rows = (yield* runPromise( | |
| 475 | + "findMany select", | |
| 476 | + () => Promise.resolve(q), | |
| 477 | + model, | |
| 402 | 478 | )) as Record<string, unknown>[]; |
| 403 | 479 | return rows as never[]; |
| 404 | 480 | }), |
| @@ -409,8 +485,10 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines | ||
| 409 | 485 | const clause = compileWhere(table, where, provider); |
| 410 | 486 | let q = db.select({ c: count() }).from(table); |
| 411 | 487 | if (clause) q = q.where(clause); |
| 412 | − const rows = (yield* runPromise("count select", () => | |
| 413 | − Promise.resolve(q), | |
| 488 | + const rows = (yield* runPromise( | |
| 489 | + "count select", | |
| 490 | + () => Promise.resolve(q), | |
| 491 | + model, | |
| 414 | 492 | )) as { c: number | string | bigint }[]; |
| 415 | 493 | const raw = rows[0]?.c ?? 0; |
| 416 | 494 | return typeof raw === "number" ? raw : Number(raw); |
| @@ -423,22 +501,32 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines | ||
| 423 | 501 | // Find matching rows first — if >1, DBAdapter contract says null |
| 424 | 502 | let findQ = db.select().from(table); |
| 425 | 503 | if (clause) findQ = findQ.where(clause); |
| 426 | − const matched = (yield* runPromise("update pre-select", () => | |
| 427 | − findQ.limit(2), | |
| 504 | + const matched = (yield* runPromise( | |
| 505 | + "update pre-select", | |
| 506 | + () => findQ.limit(2), | |
| 507 | + model, | |
| 428 | 508 | )) as Record<string, unknown>[]; |
| 429 | 509 | if (matched.length === 0) return null; |
| 430 | 510 | if (matched.length > 1) return null; |
| 431 | 511 | const target = matched[0]!; |
| 432 | 512 | let updQ = db.update(table).set(update).where(eq(table.id, target.id)); |
| 433 | 513 | if (provider !== "mysql") { |
| 434 | − const rows = (yield* runPromise("update returning", () => | |
| 435 | − updQ.returning(), | |
| 514 | + const rows = (yield* runPromise( | |
| 515 | + "update returning", | |
| 516 | + () => updQ.returning(), | |
| 517 | + model, | |
| 436 | 518 | )) as Record<string, unknown>[]; |
| 437 | 519 | return (rows[0] ?? null) as never; |
| 438 | 520 | } |
| 439 | − yield* runPromise("mysql update execute", () => updQ.execute()); | |
| 440 | − const reread = (yield* runPromise("mysql update reread", () => | |
| 441 | − db.select().from(table).where(eq(table.id, target.id)).limit(1), | |
| 521 | + yield* runPromise( | |
| 522 | + "mysql update execute", | |
| 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 | 530 | )) as Record<string, unknown>[]; |
| 443 | 531 | return (reread[0] ?? null) as never; |
| 444 | 532 | }), |
| @@ -451,14 +539,20 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 6 unmodified lines | ||
| 451 | 539 | // but we don't want to rely on that in the generic path) |
| 452 | 540 | let countQ = db.select({ c: count() }).from(table); |
| 453 | 541 | if (clause) countQ = countQ.where(clause); |
| 454 | − const rows = (yield* runPromise("updateMany count", () => | |
| 455 | − Promise.resolve(countQ), | |
| 542 | + const rows = (yield* runPromise( | |
| 543 | + "updateMany count", | |
| 544 | + () => Promise.resolve(countQ), | |
| 545 | + model, | |
| 456 | 546 | )) as { c: number | string | bigint }[]; |
| 457 | 547 | const n = Number(rows[0]?.c ?? 0); |
| 458 | 548 | if (n === 0) return 0; |
| 459 | 549 | let updQ = db.update(table).set(update); |
| 460 | 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 | 556 | return n; |
| 463 | 557 | }), |
| 464 | 558 | |
| @@ -469,13 +563,17 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 4 unmodified lines | ||
| 469 | 563 | // Mirror in-memory semantics: delete first matching row only |
| 470 | 564 | let findQ = db.select({ id: table.id }).from(table); |
| 471 | 565 | if (clause) findQ = findQ.where(clause); |
| 472 | − const matched = (yield* runPromise("delete pre-select", () => | |
| 473 | − findQ.limit(1), | |
| 566 | + const matched = (yield* runPromise( | |
| 567 | + "delete pre-select", | |
| 568 | + () => findQ.limit(1), | |
| 569 | + model, | |
| 474 | 570 | )) as { id: unknown }[]; |
| 475 | 571 | const first = matched[0]; |
| 476 | 572 | if (!first) return; |
| 477 | − yield* runPromise("delete exec", () => | |
| 478 | − Promise.resolve(db.delete(table).where(eq(table.id, first.id))), | |
| 573 | + yield* runPromise( | |
| 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 | 583 | const clause = compileWhere(table, where, provider); |
| 486 | 584 | let countQ = db.select({ c: count() }).from(table); |
| 487 | 585 | if (clause) countQ = countQ.where(clause); |
| 488 | − const rows = (yield* runPromise("deleteMany count", () => | |
| 489 | − Promise.resolve(countQ), | |
| 586 | + const rows = (yield* runPromise( | |
| 587 | + "deleteMany count", | |
| 588 | + () => Promise.resolve(countQ), | |
| 589 | + model, | |
| 490 | 590 | )) as { c: number | string | bigint }[]; |
| 491 | 591 | const n = Number(rows[0]?.c ?? 0); |
| 492 | 592 | if (n === 0) return 0; |
| 493 | 593 | let delQ = db.delete(table); |
| 494 | 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 | 600 | return n; |
| 497 | 601 | }), |
| 498 | 602 | }; |
| @@ -553,9 +657,9 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 54 unmodified lines | ||
| 553 | 657 | }).pipe( |
| 554 | 658 | Effect.mapError((e) => { |
| 555 | 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 | 665 | return Effect.gen(function* () { |
| @@ -578,16 +682,13 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 16 unmodified lines | ||
| 578 | 682 | } |
| 579 | 683 | return res; |
| 580 | 684 | }, |
| 581 | − catch: (e) => | |
| 582 | − new Error( | |
| 583 | − `[storage-drizzle] ${stmt} failed: ${e instanceof Error ? e.message : String(e)}`, | |
| 584 | − ), | |
| 685 | + catch: (cause) => classifyError(stmt, undefined, cause), | |
| 585 | 686 | }); |
| 586 | 687 | const maybePromise = yield* runStmt("BEGIN"); |
| 587 | 688 | if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") { |
| 588 | 689 | yield* Effect.tryPromise({ |
| 589 | 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 | 694 | const nested = drizzleAdapter({ |
| @@ -606,7 +707,7 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 12 unmodified lines | ||
| 606 | 707 | if (commitRes && typeof (commitRes as { then?: unknown }).then === "function") { |
| 607 | 708 | yield* Effect.tryPromise({ |
| 608 | 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 | 713 | return result; |
| @@ -13,6 +13,7 @@ import { and, eq, sql as drizzleSql } from "drizzle-orm"; · 12 unmodified lines | ||
| 13 | 13 | import { primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
| 14 | 14 | |
| 15 | 15 | import type { BlobStore } from "@executor/sdk"; |
| 16 | +import { StorageError } from "@executor/storage-core"; | |
| 16 | 17 | |
| 17 | 18 | export const blobTable = sqliteTable( |
| 18 | 19 | "blob", |
| @@ -34,9 +35,12 @@ export interface MakeSqliteBlobStoreOptions { · 15 unmodified lines | ||
| 34 | 35 | |
| 35 | 36 | const wrapErr = |
| 36 | 37 | (op: string) => |
| 37 | − (e: unknown): Error => { | |
| 38 | − const msg = e instanceof Error ? e.message : String(e); | |
| 39 | − return new Error(`[storage-file] blob ${op}: ${msg}`); | |
| 38 | + (cause: unknown): StorageError => { | |
| 39 | + const msg = cause instanceof Error ? cause.message : String(cause); | |
| 40 | + return new StorageError({ | |
| 41 | + message: `[storage-file] blob ${op}: ${msg}`, | |
| 42 | + cause, | |
| 43 | + }); | |
| 40 | 44 | }; |
| 41 | 45 | |
| 42 | 46 | export const makeSqliteBlobStore = ( |
| @@ -15,6 +15,7 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 14 unmodified lines | ||
| 15 | 15 | import { pgTable, primaryKey, text } from "drizzle-orm/pg-core"; |
| 16 | 16 | |
| 17 | 17 | import type { BlobStore } from "@executor/sdk"; |
| 18 | +import { StorageError } from "@executor/storage-core"; | |
| 18 | 19 | |
| 19 | 20 | export const blobTable = pgTable( |
| 20 | 21 | "blob", |
| @@ -35,9 +36,12 @@ export interface MakePostgresBlobStoreOptions { · 14 unmodified lines | ||
| 35 | 36 | |
| 36 | 37 | const wrapErr = |
| 37 | 38 | (op: string) => |
| 38 | − (e: unknown): Error => { | |
| 39 | − const msg = e instanceof Error ? e.message : String(e); | |
| 40 | − return new Error(`[storage-postgres] blob ${op}: ${msg}`); | |
| 39 | + (cause: unknown): StorageError => { | |
| 40 | + const msg = cause instanceof Error ? cause.message : String(cause); | |
| 41 | + return new StorageError({ | |
| 42 | + message: `[storage-postgres] blob ${op}: ${msg}`, | |
| 43 | + cause, | |
| 44 | + }); | |
| 41 | 45 | }; |
| 42 | 46 | |
| 43 | 47 | export const makePostgresBlobStore = ( |
| @@ -2,7 +2,12 @@ import { Effect, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | import * as fs from "node:fs"; |
| 3 | 3 | import * as path from "node:path"; |
| 4 | 4 | |
| 5 | −import { definePlugin, type PluginCtx, type SecretProvider } from "@executor/sdk"; | |
| 5 | +import { | |
| 6 | + definePlugin, | |
| 7 | + StorageError, | |
| 8 | + type PluginCtx, | |
| 9 | + type SecretProvider, | |
| 10 | +} from "@executor/sdk"; | |
| 6 | 11 | |
| 7 | 12 | // --------------------------------------------------------------------------- |
| 8 | 13 | // XDG data dir resolution |
| @@ -43,16 +48,28 @@ const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); · 34 unmodified lines | ||
| 43 | 48 | |
| 44 | 49 | // --------------------------------------------------------------------------- |
| 45 | 50 | // 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 | |
| 48 | 61 | const readFullFile = (filePath: string): Record<string, Record<string, string>> => { |
| 62 | + if (!fs.existsSync(filePath)) return {}; | |
| 63 | + let raw: string; | |
| 49 | 64 | try { |
| 50 | − if (!fs.existsSync(filePath)) return {}; | |
| 51 | − const raw = fs.readFileSync(filePath, "utf-8"); | |
| 52 | − return decodeScopedAuthFile(JSON.parse(raw)); | |
| 53 | − } catch { | |
| 54 | − return {}; | |
| 65 | + raw = fs.readFileSync(filePath, "utf-8"); | |
| 66 | + } catch (cause) { | |
| 67 | + // Treat "file disappeared between existsSync and readFileSync" as | |
| 68 | + // absence — anything else (EACCES, EISDIR, …) propagates. | |
| 69 | + if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {}; | |
| 70 | + throw cause; | |
| 55 | 71 | } |
| 72 | + return decodeScopedAuthFile(JSON.parse(raw)); | |
| 56 | 73 | }; |
| 57 | 74 | |
| 58 | 75 | const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> => |
| @@ -100,36 +117,54 @@ export interface FileSecretsExtension { · 41 unmodified lines | ||
| 100 | 117 | // Provider factory (internal) |
| 101 | 118 | // --------------------------------------------------------------------------- |
| 102 | 119 | |
| 120 | +const toStorageError = (cause: unknown) => | |
| 121 | + new StorageError({ | |
| 122 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 123 | + cause, | |
| 124 | + }); | |
| 125 | + | |
| 103 | 126 | const makeScopedProvider = (filePath: string, scopeId: string): SecretProvider => ({ |
| 104 | 127 | key: "file", |
| 105 | 128 | writable: true, |
| 106 | 129 | |
| 107 | 130 | get: (secretId) => |
| 108 | − Effect.sync(() => { | |
| 109 | − const data = readScopeSecrets(filePath, scopeId); | |
| 110 | − return data[secretId] ?? null; | |
| 131 | + Effect.try({ | |
| 132 | + try: () => { | |
| 133 | + const data = readScopeSecrets(filePath, scopeId); | |
| 134 | + return data[secretId] ?? null; | |
| 135 | + }, | |
| 136 | + catch: toStorageError, | |
| 111 | 137 | }), |
| 112 | 138 | |
| 113 | 139 | set: (secretId, value) => |
| 114 | − Effect.sync(() => { | |
| 115 | − const data = readScopeSecrets(filePath, scopeId); | |
| 116 | − data[secretId] = value; | |
| 117 | − writeScopeSecrets(filePath, scopeId, data); | |
| 140 | + Effect.try({ | |
| 141 | + try: () => { | |
| 142 | + const data = readScopeSecrets(filePath, scopeId); | |
| 143 | + data[secretId] = value; | |
| 144 | + writeScopeSecrets(filePath, scopeId, data); | |
| 145 | + }, | |
| 146 | + catch: toStorageError, | |
| 118 | 147 | }), |
| 119 | 148 | |
| 120 | 149 | delete: (secretId) => |
| 121 | − Effect.sync(() => { | |
| 122 | − const data = readScopeSecrets(filePath, scopeId); | |
| 123 | − const had = secretId in data; | |
| 124 | − delete data[secretId]; | |
| 125 | − if (had) writeScopeSecrets(filePath, scopeId, data); | |
| 126 | − return had; | |
| 150 | + Effect.try({ | |
| 151 | + try: () => { | |
| 152 | + const data = readScopeSecrets(filePath, scopeId); | |
| 153 | + const had = secretId in data; | |
| 154 | + delete data[secretId]; | |
| 155 | + if (had) writeScopeSecrets(filePath, scopeId, data); | |
| 156 | + return had; | |
| 157 | + }, | |
| 158 | + catch: toStorageError, | |
| 127 | 159 | }), |
| 128 | 160 | |
| 129 | 161 | list: () => |
| 130 | − Effect.sync(() => { | |
| 131 | − const data = readScopeSecrets(filePath, scopeId); | |
| 132 | − return Object.keys(data).map((k) => ({ id: k, name: k })); | |
| 162 | + Effect.try({ | |
| 163 | + try: () => { | |
| 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 | |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 6 | +import { | |
| 7 | + GoogleDiscoveryOAuthError, | |
| 8 | + GoogleDiscoveryParseError, | |
| 9 | + GoogleDiscoverySourceError, | |
| 10 | +} from "../sdk/errors"; | |
| 5 | 11 | import { GoogleDiscoveryStoredSourceSchema } from "../sdk/stored-source"; |
| 6 | 12 | |
| 7 | 13 | export { HttpApiSchema }; |
| @@ -109,55 +115,61 @@ export class GoogleDiscoveryApiError extends Schema.TaggedError<GoogleDiscoveryA · 101 unmodified lines | ||
| 109 | 115 | HttpApiSchema.annotations({ status: 400 }), |
| 110 | 116 | ) {} |
| 111 | 117 | |
| 112 | −export class GoogleDiscoveryInternalError extends Schema.TaggedError<GoogleDiscoveryInternalError>()( | |
| 113 | − "GoogleDiscoveryInternalError", | |
| 114 | − { | |
| 115 | − message: Schema.String, | |
| 116 | − }, | |
| 117 | − HttpApiSchema.annotations({ status: 500 }), | |
| 118 | −) {} | |
| 118 | +// --------------------------------------------------------------------------- | |
| 119 | +// Group | |
| 120 | +// | |
| 121 | +// Domain errors + the shared opaque 500 (`InternalError`) are declared | |
| 122 | +// once at the group level via `.addError(...)` — every endpoint | |
| 123 | +// inherits them. The domain error carries its HTTP status via | |
| 124 | +// `HttpApiSchema.annotations`; `InternalError` is the public 5xx | |
| 125 | +// surface, translated from `StorageError` at the HTTP edge by | |
| 126 | +// `withCapture`. No per-endpoint `.addError(...)`, no per-handler | |
| 127 | +// InternalError — handlers just `return yield* ext.foo(...)`. | |
| 128 | +// --------------------------------------------------------------------------- | |
| 119 | 129 | |
| 120 | 130 | export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") |
| 121 | 131 | .add( |
| 122 | 132 | HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` |
| 123 | 133 | .setPayload(ProbePayload) |
| 124 | − .addSuccess(ProbeResponse) | |
| 125 | − .addError(GoogleDiscoveryApiError) | |
| 126 | − .addError(GoogleDiscoveryInternalError), | |
| 134 | + .addSuccess(ProbeResponse), | |
| 127 | 135 | ) |
| 128 | 136 | .add( |
| 129 | 137 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` |
| 130 | 138 | .setPayload(AddSourcePayload) |
| 131 | − .addSuccess(AddSourceResponse) | |
| 132 | − .addError(GoogleDiscoveryApiError) | |
| 133 | − .addError(GoogleDiscoveryInternalError), | |
| 139 | + .addSuccess(AddSourceResponse), | |
| 134 | 140 | ) |
| 135 | 141 | .add( |
| 136 | 142 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` |
| 137 | 143 | .setPayload(StartOAuthPayload) |
| 138 | − .addSuccess(StartOAuthResponse) | |
| 139 | − .addError(GoogleDiscoveryApiError) | |
| 140 | − .addError(GoogleDiscoveryInternalError), | |
| 144 | + .addSuccess(StartOAuthResponse), | |
| 141 | 145 | ) |
| 142 | 146 | .add( |
| 143 | 147 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` |
| 144 | 148 | .setPayload(CompleteOAuthPayload) |
| 145 | − .addSuccess(CompleteOAuthResponse) | |
| 146 | − .addError(GoogleDiscoveryApiError) | |
| 147 | − .addError(GoogleDiscoveryInternalError), | |
| 149 | + .addSuccess(CompleteOAuthResponse), | |
| 148 | 150 | ) |
| 149 | 151 | .add( |
| 150 | 152 | HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` |
| 151 | 153 | .setUrlParams(OAuthCallbackParams) |
| 152 | − .addSuccess(HtmlResponse) | |
| 153 | − .addError(GoogleDiscoveryApiError) | |
| 154 | − .addError(GoogleDiscoveryInternalError), | |
| 154 | + .addSuccess(HtmlResponse), | |
| 155 | 155 | ) |
| 156 | 156 | .add( |
| 157 | 157 | HttpApiEndpoint.get( |
| 158 | 158 | "getSource", |
| 159 | 159 | )`/scopes/${scopeIdParam}/google-discovery/sources/${namespaceParam}` |
| 160 | − .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)) | |
| 161 | − .addError(GoogleDiscoveryApiError) | |
| 162 | − .addError(GoogleDiscoveryInternalError), | |
| 163 | − ) {} | |
| 160 | + .addSuccess(Schema.NullOr(GoogleDiscoveryStoredSourceSchema)), | |
| 161 | + ) | |
| 162 | + // Errors declared once at the group level — every endpoint inherits. | |
| 163 | + // `InternalError` is the shared opaque 500 translated at the HTTP edge | |
| 164 | + // by `withCapture`. The others are 4xx domain errors carrying their | |
| 165 | + // status via `HttpApiSchema.annotations`; handlers return them through | |
| 166 | + // the typed channel and HttpApi encodes them directly. We only list | |
| 167 | + // errors a Google Discovery *group* endpoint can surface — | |
| 168 | + // `GoogleDiscoveryInvocationError` is thrown inside `invokeTool` which | |
| 169 | + // is reached via the core `tools.invoke` endpoint, not any Google | |
| 170 | + // Discovery-group endpoint, so it doesn't belong here. | |
| 171 | + .addError(InternalError) | |
| 172 | + .addError(GoogleDiscoveryApiError) | |
| 173 | + .addError(GoogleDiscoveryOAuthError) | |
| 174 | + .addError(GoogleDiscoveryParseError) | |
| 175 | + .addError(GoogleDiscoverySourceError) {} | |
| @@ -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 | + | |
| 1 | 10 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 2 | −import { describe, expect, it } from "vitest"; | |
| 11 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | 12 | import { Effect, Layer } from "effect"; |
| 4 | 13 | |
| 5 | −import { addGroup } from "@executor/api"; | |
| 14 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 6 | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 7 | 16 | import type { GoogleDiscoveryPluginExtension } from "../sdk/plugin"; |
| 8 | 17 | import { GoogleDiscoveryExtensionService, GoogleDiscoveryHandlers } from "./handlers"; |
| @@ -10,57 +19,67 @@ import { GoogleDiscoveryGroup } from "./group"; · 1 unmodified line | ||
| 10 | 19 | |
| 11 | 20 | const unused = Effect.dieMessage("unused"); |
| 12 | 21 | |
| 13 | −const createFailingExtension = (): GoogleDiscoveryPluginExtension => ({ | |
| 22 | +const failingExtension: GoogleDiscoveryPluginExtension = { | |
| 14 | 23 | probeDiscovery: () => Effect.die(new Error("Not implemented")), |
| 15 | 24 | addSource: () => unused, |
| 16 | 25 | removeSource: () => unused, |
| 17 | 26 | startOAuth: () => unused, |
| 18 | 27 | completeOAuth: () => Effect.die(new Error("Not implemented")), |
| 19 | 28 | getSource: () => Effect.succeed(null), |
| 20 | −}); | |
| 29 | +}; | |
| 21 | 30 | |
| 22 | 31 | const Api = addGroup(GoogleDiscoveryGroup); |
| 23 | 32 | |
| 24 | −const fakeExecutor = {} as any; | |
| 25 | −const fakeExecutionEngine = {} as any; | |
| 26 | − | |
| 27 | −const createHandler = () => | |
| 28 | − HttpApiBuilder.toWebHandler( | |
| 29 | − HttpApiBuilder.api(Api).pipe( | |
| 30 | − Layer.provide(CoreHandlers), | |
| 31 | − Layer.provide(GoogleDiscoveryHandlers), | |
| 32 | − Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)), | |
| 33 | − Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)), | |
| 34 | − Layer.provide(Layer.succeed(GoogleDiscoveryExtensionService, createFailingExtension())), | |
| 35 | − Layer.provideMerge(HttpServer.layerContext), | |
| 36 | − Layer.provideMerge(HttpApiBuilder.Router.Live), | |
| 37 | − Layer.provideMerge(HttpApiBuilder.Middleware.layer), | |
| 33 | +// `acquireRelease` keeps disposal inside the Effect scope — no | |
| 34 | +// try/finally, no per-test cleanup plumbing. `it.scoped` closes the | |
| 35 | +// scope for us. | |
| 36 | +const WebHandler = Effect.acquireRelease( | |
| 37 | + Effect.sync(() => | |
| 38 | + HttpApiBuilder.toWebHandler( | |
| 39 | + HttpApiBuilder.api(Api).pipe( | |
| 40 | + Layer.provide(CoreHandlers), | |
| 41 | + Layer.provide(GoogleDiscoveryHandlers), | |
| 42 | + Layer.provide(observabilityMiddleware(Api)), | |
| 43 | + Layer.provide(Layer.succeed(ExecutorService, {} as never)), | |
| 44 | + Layer.provide(Layer.succeed(ExecutionEngineService, {} as never)), | |
| 45 | + Layer.provide( | |
| 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 | |
| 41 | 57 | describe("GoogleDiscoveryHandlers", () => { |
| 42 | − it("sanitizes unknown endpoint failures", async () => { | |
| 43 | − const web = createHandler(); | |
| 44 | − try { | |
| 45 | − const response = await web.handler( | |
| 46 | − new Request("http://localhost/scopes/scope_1/google-discovery/probe", { | |
| 47 | − method: "POST", | |
| 48 | − headers: { "content-type": "application/json" }, | |
| 49 | − body: JSON.stringify({ | |
| 50 | − discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1", | |
| 51 | − }), | |
| 52 | − }), | |
| 53 | − ); | |
| 58 | + it.scoped( | |
| 59 | + "defect-returning methods produce an opaque InternalError, no leakage", | |
| 60 | + () => | |
| 61 | + Effect.gen(function* () { | |
| 62 | + const web = yield* WebHandler; | |
| 63 | + const response = yield* Effect.promise(() => | |
| 64 | + web.handler( | |
| 65 | + new Request("http://localhost/scopes/scope_1/google-discovery/probe", { | |
| 66 | + method: "POST", | |
| 67 | + headers: { "content-type": "application/json" }, | |
| 68 | + body: JSON.stringify({ | |
| 69 | + discoveryUrl: "https://example.googleapis.com/$discovery/rest?version=v1", | |
| 70 | + }), | |
| 71 | + }), | |
| 72 | + ), | |
| 73 | + ); | |
| 54 | 74 | |
| 55 | − expect(response.status).toBe(500); | |
| 56 | − const body = await response.json(); | |
| 57 | − expect(body).toEqual({ | |
| 58 | − _tag: "GoogleDiscoveryInternalError", | |
| 59 | − message: "Internal server error", | |
| 60 | − }); | |
| 61 | − expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 62 | − } finally { | |
| 63 | − await web.dispose(); | |
| 64 | − } | |
| 65 | − }); | |
| 75 | + expect(response.status).toBe(500); | |
| 76 | + const body = (yield* Effect.promise(() => response.json())) as { | |
| 77 | + _tag?: string; | |
| 78 | + traceId?: string; | |
| 79 | + }; | |
| 80 | + expect(body._tag).toBe("InternalError"); | |
| 81 | + expect(typeof body.traceId).toBe("string"); | |
| 82 | + expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 83 | + }), | |
| 84 | + ); | |
| 66 | 85 | }); |
| @@ -1,65 +1,58 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | |
| 6 | −import { addGroup } from "@executor/api"; | |
| 6 | +import { addGroup, capture } from "@executor/api"; | |
| 7 | 7 | import type { |
| 8 | 8 | GoogleDiscoveryAddSourceInput, |
| 9 | 9 | GoogleDiscoveryOAuthAuthResult, |
| 10 | 10 | GoogleDiscoveryPluginExtension, |
| 11 | 11 | } from "../sdk/plugin"; |
| 12 | −import { | |
| 13 | − GoogleDiscoveryInvocationError, | |
| 14 | − GoogleDiscoveryOAuthError, | |
| 15 | − GoogleDiscoveryParseError, | |
| 16 | − GoogleDiscoverySourceError, | |
| 17 | −} from "../sdk/errors"; | |
| 18 | −import { | |
| 19 | − GoogleDiscoveryApiError, | |
| 20 | − GoogleDiscoveryGroup, | |
| 21 | − GoogleDiscoveryInternalError, | |
| 22 | −} from "./group"; | |
| 12 | +import { GoogleDiscoveryOAuthError } from "../sdk/errors"; | |
| 13 | +import { GoogleDiscoveryGroup } from "./group"; | |
| 14 | + | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | +// Service tag | |
| 17 | +// | |
| 18 | +// Holds the `Captured` shape — every method's `StorageFailure` channel | |
| 19 | +// has been swapped for `InternalError({ traceId })`. The host app | |
| 20 | +// provides an already-wrapped extension via | |
| 21 | +// `Layer.succeed(GoogleDiscoveryExtensionService, withCapture(executor.googleDiscovery))`. | |
| 22 | +// Handlers see `InternalError` in the error union, which matches | |
| 23 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 24 | +// --------------------------------------------------------------------------- | |
| 23 | 25 | |
| 24 | 26 | export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscoveryExtensionService")< |
| 25 | 27 | GoogleDiscoveryExtensionService, |
| 26 | 28 | GoogleDiscoveryPluginExtension |
| 27 | 29 | >() {} |
| 28 | 30 | |
| 31 | +// --------------------------------------------------------------------------- | |
| 32 | +// Composed API | |
| 33 | +// --------------------------------------------------------------------------- | |
| 34 | + | |
| 29 | 35 | const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); |
| 30 | 36 | |
| 31 | 37 | const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result"; |
| 32 | 38 | |
| 33 | −const toPopupErrorMessage = (error: unknown): string => { | |
| 34 | − if (error instanceof GoogleDiscoveryOAuthError) { | |
| 35 | − return error.message; | |
| 36 | − } | |
| 37 | − return "Authentication failed"; | |
| 38 | −}; | |
| 39 | − | |
| 40 | −const toGoogleDiscoveryApiError = ( | |
| 41 | − error: unknown, | |
| 42 | −): GoogleDiscoveryApiError | GoogleDiscoveryInternalError => { | |
| 43 | − if (error instanceof GoogleDiscoveryApiError || error instanceof GoogleDiscoveryInternalError) { | |
| 44 | − return error; | |
| 45 | − } | |
| 46 | − if ( | |
| 47 | − error instanceof GoogleDiscoveryParseError || | |
| 48 | − error instanceof GoogleDiscoverySourceError || | |
| 49 | − error instanceof GoogleDiscoveryOAuthError || | |
| 50 | − error instanceof GoogleDiscoveryInvocationError | |
| 51 | − ) { | |
| 52 | − return new GoogleDiscoveryApiError({ message: error.message }); | |
| 53 | − } | |
| 54 | − return new GoogleDiscoveryInternalError({ message: "Internal server error" }); | |
| 55 | −}; | |
| 39 | +const toPopupErrorMessage = (error: unknown): string => | |
| 40 | + error instanceof GoogleDiscoveryOAuthError ? error.message : "Authentication failed"; | |
| 56 | 41 | |
| 57 | −const sanitizeGoogleDiscoveryFailure = <A, R>( | |
| 58 | − effect: Effect.Effect<A, unknown, R>, | |
| 59 | −): Effect.Effect<A, GoogleDiscoveryApiError | GoogleDiscoveryInternalError, R> => | |
| 60 | − Effect.catchAllCause(effect, (cause) => | |
| 61 | − Effect.fail(toGoogleDiscoveryApiError(Cause.squash(cause))), | |
| 62 | − ); | |
| 42 | +// --------------------------------------------------------------------------- | |
| 43 | +// Handlers | |
| 44 | +// | |
| 45 | +// Each handler is exactly: yield the extension service, call the method, | |
| 46 | +// return. Plugin SDK errors flow through the typed channel and are | |
| 47 | +// schema-encoded to 4xx by HttpApi (see group.ts `.addError(...)` calls). | |
| 48 | +// `StorageFailure` has already been translated to `InternalError` by | |
| 49 | +// `withCapture` on the service instance; defects bubble up and are | |
| 50 | +// captured + downgraded to `InternalError(traceId)` by the API-level | |
| 51 | +// observability middleware. | |
| 52 | +// | |
| 53 | +// No `sanitize*`, no `liftDomainErrors`, no per-handler error mapping. | |
| 54 | +// If you find yourself adding error-handling here you're in the wrong layer. | |
| 55 | +// --------------------------------------------------------------------------- | |
| 63 | 56 | |
| 64 | 57 | export const GoogleDiscoveryHandlers = HttpApiBuilder.group( |
| 65 | 58 | ExecutorApiWithGoogleDiscovery, |
| @@ -67,19 +60,19 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 1 unmodified line | ||
| 67 | 60 | (handlers) => |
| 68 | 61 | handlers |
| 69 | 62 | .handle("probeDiscovery", ({ payload }) => |
| 70 | − Effect.gen(function* () { | |
| 63 | + capture(Effect.gen(function* () { | |
| 71 | 64 | const ext = yield* GoogleDiscoveryExtensionService; |
| 72 | 65 | return yield* ext.probeDiscovery(payload.discoveryUrl); |
| 73 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 66 | + })), | |
| 74 | 67 | ) |
| 75 | 68 | .handle("addSource", ({ payload }) => |
| 76 | − Effect.gen(function* () { | |
| 69 | + capture(Effect.gen(function* () { | |
| 77 | 70 | const ext = yield* GoogleDiscoveryExtensionService; |
| 78 | 71 | return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); |
| 79 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 72 | + })), | |
| 80 | 73 | ) |
| 81 | 74 | .handle("startOAuth", ({ payload }) => |
| 82 | − Effect.gen(function* () { | |
| 75 | + capture(Effect.gen(function* () { | |
| 83 | 76 | const ext = yield* GoogleDiscoveryExtensionService; |
| 84 | 77 | return yield* ext.startOAuth({ |
| 85 | 78 | name: payload.name, |
| @@ -89,30 +82,30 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 3 unmodified lines | ||
| 89 | 82 | redirectUrl: payload.redirectUrl, |
| 90 | 83 | scopes: payload.scopes, |
| 91 | 84 | }); |
| 92 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 85 | + })), | |
| 93 | 86 | ) |
| 94 | 87 | .handle("completeOAuth", ({ payload }) => |
| 95 | − Effect.gen(function* () { | |
| 88 | + capture(Effect.gen(function* () { | |
| 96 | 89 | const ext = yield* GoogleDiscoveryExtensionService; |
| 97 | 90 | return yield* ext.completeOAuth({ |
| 98 | 91 | state: payload.state, |
| 99 | 92 | code: payload.code, |
| 100 | 93 | error: payload.error, |
| 101 | 94 | }); |
| 102 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 95 | + })), | |
| 103 | 96 | ) |
| 104 | 97 | .handle("getSource", ({ path }) => |
| 105 | − Effect.gen(function* () { | |
| 98 | + capture(Effect.gen(function* () { | |
| 106 | 99 | const ext = yield* GoogleDiscoveryExtensionService; |
| 107 | 100 | return yield* ext.getSource(path.namespace); |
| 108 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 101 | + })), | |
| 109 | 102 | ) |
| 110 | 103 | .handle("oauthCallback", ({ urlParams }) => |
| 111 | − Effect.gen(function* () { | |
| 104 | + capture(Effect.gen(function* () { | |
| 112 | 105 | const ext = yield* GoogleDiscoveryExtensionService; |
| 113 | 106 | const html = yield* runOAuthCallback< |
| 114 | 107 | GoogleDiscoveryOAuthAuthResult, |
| 115 | − GoogleDiscoveryOAuthError, | |
| 108 | + unknown, | |
| 116 | 109 | never |
| 117 | 110 | >({ |
| 118 | 111 | complete: ({ state, code, error }) => |
| @@ -126,6 +119,6 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 7 unmodified lines | ||
| 126 | 119 | channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL, |
| 127 | 120 | }); |
| 128 | 121 | return yield* HttpServerResponse.html(html); |
| 129 | − }).pipe(sanitizeGoogleDiscoveryFailure), | |
| 122 | + })), | |
| 130 | 123 | ), |
| 131 | 124 | ); |
| @@ -14,7 +14,11 @@ · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | import { Effect, Schema } from "effect"; |
| 16 | 16 | |
| 17 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 17 | +import { | |
| 18 | + defineSchema, | |
| 19 | + type StorageDeps, | |
| 20 | + type StorageFailure, | |
| 21 | +} from "@executor/sdk"; | |
| 18 | 22 | |
| 19 | 23 | import { |
| 20 | 24 | GoogleDiscoveryMethodBinding, |
| @@ -101,43 +105,49 @@ const decodeJson = (value: unknown): unknown => { · 80 unmodified lines | ||
| 101 | 105 | // 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. | |
| 104 | 112 | export interface GoogleDiscoveryStore { |
| 105 | 113 | readonly getBinding: (toolId: string) => Effect.Effect< |
| 106 | 114 | { readonly namespace: string; readonly binding: GoogleDiscoveryMethodBinding } | null, |
| 107 | − Error | |
| 115 | + StorageFailure | |
| 108 | 116 | >; |
| 109 | 117 | readonly putBinding: ( |
| 110 | 118 | toolId: string, |
| 111 | 119 | sourceId: string, |
| 112 | 120 | binding: GoogleDiscoveryMethodBinding, |
| 113 | − ) => Effect.Effect<void, Error>; | |
| 121 | + ) => Effect.Effect<void, StorageFailure>; | |
| 114 | 122 | readonly removeBindingsBySource: ( |
| 115 | 123 | sourceId: string, |
| 116 | − ) => Effect.Effect<readonly string[], Error>; | |
| 124 | + ) => Effect.Effect<readonly string[], StorageFailure>; | |
| 117 | 125 | readonly getBindingsForSource: ( |
| 118 | 126 | sourceId: string, |
| 119 | 127 | ) => Effect.Effect< |
| 120 | 128 | ReadonlyMap<string, GoogleDiscoveryMethodBinding>, |
| 121 | − Error | |
| 129 | + StorageFailure | |
| 122 | 130 | >; |
| 123 | 131 | |
| 124 | − readonly putSource: (source: GoogleDiscoveryStoredSource) => Effect.Effect<void, Error>; | |
| 125 | − readonly removeSource: (sourceId: string) => Effect.Effect<void, Error>; | |
| 132 | + readonly putSource: ( | |
| 133 | + source: GoogleDiscoveryStoredSource, | |
| 134 | + ) => Effect.Effect<void, StorageFailure>; | |
| 135 | + readonly removeSource: (sourceId: string) => Effect.Effect<void, StorageFailure>; | |
| 126 | 136 | readonly getSource: ( |
| 127 | 137 | sourceId: string, |
| 128 | − ) => Effect.Effect<GoogleDiscoveryStoredSource | null, Error>; | |
| 138 | + ) => Effect.Effect<GoogleDiscoveryStoredSource | null, StorageFailure>; | |
| 129 | 139 | readonly getSourceConfig: ( |
| 130 | 140 | sourceId: string, |
| 131 | − ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error>; | |
| 141 | + ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, StorageFailure>; | |
| 132 | 142 | |
| 133 | 143 | readonly putOAuthSession: ( |
| 134 | 144 | sessionId: string, |
| 135 | 145 | session: GoogleDiscoveryOAuthSession, |
| 136 | − ) => Effect.Effect<void, Error>; | |
| 146 | + ) => Effect.Effect<void, StorageFailure>; | |
| 137 | 147 | readonly getOAuthSession: ( |
| 138 | 148 | sessionId: string, |
| 139 | − ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error>; | |
| 140 | − readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>; | |
| 149 | + ) => Effect.Effect<GoogleDiscoveryOAuthSession | null, StorageFailure>; | |
| 150 | + readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>; | |
| 141 | 151 | } |
| 142 | 152 | |
| 143 | 153 | // --------------------------------------------------------------------------- |
| @@ -130,8 +130,12 @@ const DiscoveryDocumentModel = Schema.Struct({ · 129 unmodified lines | ||
| 130 | 130 | }); |
| 131 | 131 | type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; |
| 132 | 132 | |
| 133 | −const toParseError = (message: string, cause: unknown) => | |
| 134 | − new GoogleDiscoveryParseError({ message, cause }); | |
| 133 | +// The Schema.TaggedError version of GoogleDiscoveryParseError no longer | |
| 134 | +// carries a `cause` field — it would leak raw decoder internals over the | |
| 135 | +// wire. The decoder failure still shows up on the Effect cause chain for | |
| 136 | +// server-side logging; the client only sees the user-facing `message`. | |
| 137 | +const toParseError = (message: string, _cause: unknown) => | |
| 138 | + new GoogleDiscoveryParseError({ message }); | |
| 135 | 139 | |
| 136 | 140 | const decodeUnknownWith = |
| 137 | 141 | <A>( |
| @@ -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 | + | |
| 1 | 12 | import { Data, Schema } from "effect"; |
| 13 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 14 | import type { Option } from "effect"; |
| 3 | 15 | |
| 4 | −export class GoogleDiscoveryParseError extends Data.TaggedError("GoogleDiscoveryParseError")<{ | |
| 5 | − readonly message: string; | |
| 6 | − readonly cause?: unknown; | |
| 7 | −}> {} | |
| 16 | +export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()( | |
| 17 | + "GoogleDiscoveryParseError", | |
| 18 | + { | |
| 19 | + message: Schema.String, | |
| 20 | + }, | |
| 21 | + HttpApiSchema.annotations({ status: 400 }), | |
| 22 | +) {} | |
| 8 | 23 | |
| 9 | 24 | export class GoogleDiscoveryInvocationError extends Data.TaggedError( |
| 10 | 25 | "GoogleDiscoveryInvocationError", |
| @@ -19,6 +34,7 @@ export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscover · 8 unmodified lines | ||
| 19 | 34 | { |
| 20 | 35 | message: Schema.String, |
| 21 | 36 | }, |
| 37 | + HttpApiSchema.annotations({ status: 400 }), | |
| 22 | 38 | ) {} |
| 23 | 39 | |
| 24 | 40 | export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()( |
| @@ -26,4 +42,5 @@ export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscove · 1 unmodified line | ||
| 26 | 42 | { |
| 27 | 43 | message: Schema.String, |
| 28 | 44 | }, |
| 45 | + HttpApiSchema.annotations({ status: 400 }), | |
| 29 | 46 | ) {} |
| @@ -6,12 +6,15 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type OAuth2SecretsIO, |
| 7 | 7 | } from "@executor/plugin-oauth2"; |
| 8 | 8 | |
| 9 | −import type { PluginCtx } from "@executor/sdk"; | |
| 9 | +import type { PluginCtx, StorageFailure } from "@executor/sdk"; | |
| 10 | 10 | import { SetSecretInput } from "@executor/sdk"; |
| 11 | 11 | |
| 12 | 12 | import { GOOGLE_TOKEN_URL } from "./oauth"; |
| 13 | 13 | |
| 14 | −import { GoogleDiscoveryInvocationError } from "./errors"; | |
| 14 | +import { | |
| 15 | + GoogleDiscoveryInvocationError, | |
| 16 | + GoogleDiscoveryOAuthError, | |
| 17 | +} from "./errors"; | |
| 15 | 18 | import type { GoogleDiscoveryStore } from "./binding-store"; |
| 16 | 19 | import { |
| 17 | 20 | GoogleDiscoveryInvocationResult, |
| @@ -115,7 +118,7 @@ const resolveOAuthAccessToken = (input: { · 97 unmodified lines | ||
| 115 | 118 | ctx: PluginCtx<GoogleDiscoveryStore>; |
| 116 | 119 | sourceId: string; |
| 117 | 120 | source: GoogleDiscoveryStoredSourceData; |
| 118 | −}): Effect.Effect<string, Error> => | |
| 121 | +}): Effect.Effect<string, GoogleDiscoveryOAuthError> => | |
| 119 | 122 | Effect.gen(function* () { |
| 120 | 123 | if (input.source.auth.kind !== "oauth2") return ""; |
| 121 | 124 | const auth = input.source.auth; |
| @@ -158,7 +161,9 @@ const resolveOAuthAccessToken = (input: { · 36 unmodified lines | ||
| 158 | 161 | }); |
| 159 | 162 | }), |
| 160 | 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 | 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 | 252 | const body = |
| 240 | 253 | response.status === 204 |
| 241 | 254 | ? null |
| 242 | 255 | : isJsonContentType(contentType) |
| 243 | − ? yield* response.json.pipe(Effect.catchAll(() => response.text)) | |
| 244 | − : yield* response.text; | |
| 256 | + ? yield* response.json.pipe( | |
| 257 | + Effect.catchAll(() => response.text), | |
| 258 | + mapBodyError, | |
| 259 | + ) | |
| 260 | + : yield* response.text.pipe(mapBodyError); | |
| 245 | 261 | |
| 246 | 262 | const ok = response.status >= 200 && response.status < 300; |
| 247 | 263 | return new GoogleDiscoveryInvocationResult({ |
| @@ -261,18 +277,29 @@ export const invokeGoogleDiscoveryTool = (input: { · 13 unmodified lines | ||
| 261 | 277 | toolId: string; |
| 262 | 278 | args: unknown; |
| 263 | 279 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; |
| 264 | −}): Effect.Effect<GoogleDiscoveryInvocationResult, Error> => | |
| 280 | +}): Effect.Effect< | |
| 281 | + GoogleDiscoveryInvocationResult, | |
| 282 | + | GoogleDiscoveryInvocationError | |
| 283 | + | GoogleDiscoveryOAuthError | |
| 284 | + | StorageFailure | |
| 285 | +> => | |
| 265 | 286 | Effect.gen(function* () { |
| 266 | 287 | const entry = yield* input.ctx.storage.getBinding(input.toolId); |
| 267 | 288 | if (!entry) { |
| 268 | 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 | 296 | const source = yield* input.ctx.storage.getSourceConfig(entry.namespace); |
| 273 | 297 | if (!source) { |
| 274 | 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 | 325 | source, |
| 299 | 326 | args: (input.args ?? {}) as Record<string, unknown>, |
| 300 | 327 | authorizationHeader: authHeader, |
| 301 | − }).pipe( | |
| 302 | − Effect.provide(layer), | |
| 303 | − Effect.mapError((err) => (err instanceof Error ? err : new Error(String(err)))), | |
| 304 | − ); | |
| 328 | + }).pipe(Effect.provide(layer)); | |
| 305 | 329 | }); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | SetSecretInput, |
| 10 | 10 | SourceDetectionResult, |
| 11 | 11 | type PluginCtx, |
| 12 | + type StorageFailure, | |
| 12 | 13 | type ToolAnnotations, |
| 13 | 14 | } from "@executor/sdk"; |
| 14 | 15 | |
| @@ -99,6 +100,23 @@ export interface GoogleDiscoveryOAuthAuthResult { · 84 unmodified lines | ||
| 99 | 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 | + */ | |
| 114 | +export type GoogleDiscoveryExtensionFailure = | |
| 115 | + | GoogleDiscoveryParseError | |
| 116 | + | GoogleDiscoverySourceError | |
| 117 | + | GoogleDiscoveryOAuthError | |
| 118 | + | StorageFailure; | |
| 119 | + | |
| 102 | 120 | export interface GoogleDiscoveryPluginExtension { |
| 103 | 121 | readonly probeDiscovery: ( |
| 104 | 122 | discoveryUrl: string, |
| @@ -110,21 +128,29 @@ export interface GoogleDiscoveryPluginExtension { · 5 unmodified lines | ||
| 110 | 128 | input: GoogleDiscoveryAddSourceInput, |
| 111 | 129 | ) => Effect.Effect< |
| 112 | 130 | { readonly toolCount: number; readonly namespace: string }, |
| 113 | − GoogleDiscoveryParseError | GoogleDiscoverySourceError | Error | |
| 131 | + 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 | 136 | readonly startOAuth: ( |
| 117 | 137 | input: GoogleDiscoveryOAuthStartInput, |
| 118 | 138 | ) => Effect.Effect< |
| 119 | 139 | GoogleDiscoveryOAuthStartResponse, |
| 120 | − GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | Error | |
| 140 | + | GoogleDiscoveryParseError | |
| 141 | + | GoogleDiscoverySourceError | |
| 142 | + | GoogleDiscoveryOAuthError | |
| 143 | + | StorageFailure | |
| 121 | 144 | >; |
| 122 | 145 | readonly completeOAuth: ( |
| 123 | 146 | input: GoogleDiscoveryOAuthCompleteInput, |
| 124 | − ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>; | |
| 147 | + ) => Effect.Effect< | |
| 148 | + GoogleDiscoveryOAuthAuthResult, | |
| 149 | + GoogleDiscoveryOAuthError | StorageFailure | |
| 150 | + >; | |
| 125 | 151 | readonly getSource: ( |
| 126 | 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 | 312 | schema: googleDiscoverySchema, |
| 287 | 313 | storage: (deps) => makeGoogleDiscoveryStore(deps), |
| 288 | 314 | |
| 289 | − extension: (ctx): GoogleDiscoveryPluginExtension => ({ | |
| 315 | + extension: (ctx) => ({ | |
| 290 | 316 | probeDiscovery: (discoveryUrl) => |
| 291 | 317 | Effect.gen(function* () { |
| 292 | 318 | const text = yield* fetchDiscoveryDocument(discoveryUrl); |
| @@ -465,18 +491,10 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ · 172 unmodified lines | ||
| 465 | 491 | scope: stored.scope, |
| 466 | 492 | scopes: [...session.scopes], |
| 467 | 493 | }; |
| 468 | − }).pipe( | |
| 469 | − Effect.mapError((err) => | |
| 470 | − err instanceof GoogleDiscoveryOAuthError | |
| 471 | − ? err | |
| 472 | − : new GoogleDiscoveryOAuthError({ | |
| 473 | − message: err instanceof Error ? err.message : String(err), | |
| 474 | − }), | |
| 475 | − ), | |
| 476 | − ), | |
| 494 | + }), | |
| 477 | 495 | |
| 478 | 496 | getSource: (namespace) => ctx.storage.getSource(namespace), |
| 479 | − }), | |
| 497 | + } satisfies GoogleDiscoveryPluginExtension), | |
| 480 | 498 | |
| 481 | 499 | invokeTool: ({ ctx, toolRow, args }) => |
| 482 | 500 | invokeGoogleDiscoveryTool({ |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { GraphqlIntrospectionError, GraphqlExtractionError } from "../sdk/errors"; |
| 6 | 7 | import { HeaderValue } from "../sdk/types"; |
| @@ -63,35 +64,40 @@ const ExtractionError = GraphqlExtractionError.annotations( · 56 unmodified lines | ||
| 63 | 64 | HttpApiSchema.annotations({ status: 400 }), |
| 64 | 65 | ); |
| 65 | 66 | |
| 66 | −export class GraphqlInternalError extends Schema.TaggedError<GraphqlInternalError>()( | |
| 67 | − "GraphqlInternalError", | |
| 68 | − { | |
| 69 | − message: Schema.String, | |
| 70 | − }, | |
| 71 | − HttpApiSchema.annotations({ status: 500 }), | |
| 72 | −) {} | |
| 73 | − | |
| 74 | 67 | // --------------------------------------------------------------------------- |
| 75 | 68 | // 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 | |
| 78 | 82 | export class GraphqlGroup extends HttpApiGroup.make("graphql") |
| 79 | 83 | .add( |
| 80 | 84 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/graphql/sources` |
| 81 | 85 | .setPayload(AddSourcePayload) |
| 82 | − .addSuccess(AddSourceResponse) | |
| 83 | − .addError(IntrospectionError) | |
| 84 | − .addError(ExtractionError) | |
| 85 | − .addError(GraphqlInternalError), | |
| 86 | + .addSuccess(AddSourceResponse), | |
| 86 | 87 | ) |
| 87 | 88 | .add( |
| 88 | 89 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` |
| 89 | − .addSuccess(Schema.NullOr(StoredSourceSchema)) | |
| 90 | − .addError(GraphqlInternalError), | |
| 90 | + .addSuccess(Schema.NullOr(StoredSourceSchema)), | |
| 91 | 91 | ) |
| 92 | 92 | .add( |
| 93 | 93 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/graphql/sources/${namespaceParam}` |
| 94 | 94 | .setPayload(UpdateSourcePayload) |
| 95 | − .addSuccess(UpdateSourceResponse) | |
| 96 | − .addError(GraphqlInternalError), | |
| 97 | − ) {} | |
| 95 | + .addSuccess(UpdateSourceResponse), | |
| 96 | + ) | |
| 97 | + // Errors declared once at the group level — every endpoint inherits. | |
| 98 | + // Plugin domain errors carry their own HttpApiSchema status (4xx); | |
| 99 | + // `InternalError` is the shared opaque 500 translated at the HTTP | |
| 100 | + // edge by `withCapture`. | |
| 101 | + .addError(InternalError) | |
| 102 | + .addError(IntrospectionError) | |
| 103 | + .addError(ExtractionError) {} | |
| @@ -1,13 +1,23 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | −import { addGroup } from "@executor/api"; | |
| 5 | −import { GraphqlExtractionError, GraphqlIntrospectionError } from "../sdk/errors"; | |
| 6 | −import type { GraphqlPluginExtension, HeaderValue, GraphqlUpdateSourceInput } from "../sdk/plugin"; | |
| 7 | −import { GraphqlGroup, GraphqlInternalError } from "./group"; | |
| 4 | +import { addGroup, capture } from "@executor/api"; | |
| 5 | +import type { | |
| 6 | + GraphqlPluginExtension, | |
| 7 | + HeaderValue, | |
| 8 | + GraphqlUpdateSourceInput, | |
| 9 | +} from "../sdk/plugin"; | |
| 10 | +import { GraphqlGroup } from "./group"; | |
| 8 | 11 | |
| 9 | 12 | // --------------------------------------------------------------------------- |
| 10 | −// Service tag — the server provides the GraphQL extension | |
| 13 | +// Service tag | |
| 14 | +// | |
| 15 | +// Holds the `Captured` shape — every method's `StorageError` channel has | |
| 16 | +// been swapped for `InternalError({ traceId })`. The host app provides an | |
| 17 | +// already-wrapped extension via | |
| 18 | +// `Layer.succeed(GraphqlExtensionService, withCapture(executor.graphql))`. | |
| 19 | +// Handlers see `InternalError` in the error union, which matches | |
| 20 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 11 | 21 | // --------------------------------------------------------------------------- |
| 12 | 22 | |
| 13 | 23 | export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionService")< |
| @@ -15,47 +25,6 @@ export class GraphqlExtensionService extends Context.Tag("GraphqlExtensionServic · 1 unmodified line | ||
| 15 | 25 | GraphqlPluginExtension |
| 16 | 26 | >() {} |
| 17 | 27 | |
| 18 | −// --------------------------------------------------------------------------- | |
| 19 | −// Failure mapping | |
| 20 | −// --------------------------------------------------------------------------- | |
| 21 | − | |
| 22 | −type GraphqlAddSourceFailure = | |
| 23 | − | GraphqlIntrospectionError | |
| 24 | − | GraphqlExtractionError | |
| 25 | − | GraphqlInternalError; | |
| 26 | − | |
| 27 | −const toGraphqlAddSourceFailure = (error: unknown): GraphqlAddSourceFailure => { | |
| 28 | − if ( | |
| 29 | − error instanceof GraphqlIntrospectionError || | |
| 30 | − error instanceof GraphqlExtractionError || | |
| 31 | − error instanceof GraphqlInternalError | |
| 32 | − ) { | |
| 33 | − return error; | |
| 34 | − } | |
| 35 | − const message = error instanceof Error ? error.message : String(error); | |
| 36 | − return new GraphqlInternalError({ message }); | |
| 37 | −}; | |
| 38 | − | |
| 39 | −const sanitizeAddSourceFailure = <A, R>( | |
| 40 | − effect: Effect.Effect<A, unknown, R>, | |
| 41 | −): Effect.Effect<A, GraphqlAddSourceFailure, R> => | |
| 42 | − Effect.catchAllCause(effect, (cause) => | |
| 43 | − Effect.fail(toGraphqlAddSourceFailure(Cause.squash(cause))), | |
| 44 | − ); | |
| 45 | − | |
| 46 | −const toGraphqlInternalError = (error: unknown): GraphqlInternalError => { | |
| 47 | − if (error instanceof GraphqlInternalError) return error; | |
| 48 | − const message = error instanceof Error ? error.message : String(error); | |
| 49 | − return new GraphqlInternalError({ message }); | |
| 50 | −}; | |
| 51 | − | |
| 52 | −const sanitizeInternalFailure = <A, R>( | |
| 53 | − effect: Effect.Effect<A, unknown, R>, | |
| 54 | −): Effect.Effect<A, GraphqlInternalError, R> => | |
| 55 | − Effect.catchAllCause(effect, (cause) => | |
| 56 | − Effect.fail(toGraphqlInternalError(Cause.squash(cause))), | |
| 57 | − ); | |
| 58 | − | |
| 59 | 28 | // --------------------------------------------------------------------------- |
| 60 | 29 | // Composed API — core + graphql group |
| 61 | 30 | // --------------------------------------------------------------------------- |
| @@ -64,12 +33,18 @@ const ExecutorApiWithGraphql = addGroup(GraphqlGroup); · 2 unmodified lines | ||
| 64 | 33 | |
| 65 | 34 | // --------------------------------------------------------------------------- |
| 66 | 35 | // 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 | |
| 69 | 44 | export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "graphql", (handlers) => |
| 70 | 45 | handlers |
| 71 | 46 | .handle("addSource", ({ payload }) => |
| 72 | − Effect.gen(function* () { | |
| 47 | + capture(Effect.gen(function* () { | |
| 73 | 48 | const ext = yield* GraphqlExtensionService; |
| 74 | 49 | const result = yield* ext.addSource({ |
| 75 | 50 | endpoint: payload.endpoint, |
| @@ -82,16 +57,16 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 6 unmodified lines | ||
| 82 | 57 | toolCount: result.toolCount, |
| 83 | 58 | namespace: payload.namespace ?? "graphql", |
| 84 | 59 | }; |
| 85 | − }).pipe(sanitizeAddSourceFailure), | |
| 60 | + })), | |
| 86 | 61 | ) |
| 87 | 62 | .handle("getSource", ({ path }) => |
| 88 | − Effect.gen(function* () { | |
| 63 | + capture(Effect.gen(function* () { | |
| 89 | 64 | const ext = yield* GraphqlExtensionService; |
| 90 | 65 | return yield* ext.getSource(path.namespace); |
| 91 | − }).pipe(sanitizeInternalFailure), | |
| 66 | + })), | |
| 92 | 67 | ) |
| 93 | 68 | .handle("updateSource", ({ path, payload }) => |
| 94 | − Effect.gen(function* () { | |
| 69 | + capture(Effect.gen(function* () { | |
| 95 | 70 | const ext = yield* GraphqlExtensionService; |
| 96 | 71 | yield* ext.updateSource(path.namespace, { |
| 97 | 72 | name: payload.name, |
| @@ -99,6 +74,6 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra · 1 unmodified line | ||
| 99 | 74 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 100 | 75 | } as GraphqlUpdateSourceInput); |
| 101 | 76 | return { updated: true }; |
| 102 | − }).pipe(sanitizeInternalFailure), | |
| 77 | + })), | |
| 103 | 78 | ), |
| 104 | 79 | ); |
| @@ -14,8 +14,8 @@ import { · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | export const resolveHeaders = ( |
| 16 | 16 | headers: Record<string, HeaderValue>, |
| 17 | − secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> }, | |
| 18 | −): Effect.Effect<Record<string, string>, Error> => | |
| 17 | + secrets: { readonly get: (id: string) => Effect.Effect<string | null, unknown> }, | |
| 18 | +): Effect.Effect<Record<string, string>> => | |
| 19 | 19 | Effect.gen(function* () { |
| 20 | 20 | const entries = Object.entries(headers); |
| 21 | 21 | // Resolve secret-backed headers in parallel. Missing / failing |
| @@ -138,14 +138,14 @@ export const invokeWithLayer = ( · 116 unmodified lines | ||
| 138 | 138 | endpoint: string, |
| 139 | 139 | resolvedHeaders: Record<string, string>, |
| 140 | 140 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 141 | −): Effect.Effect<InvocationResult, Error> => | |
| 141 | +) => | |
| 142 | 142 | invoke(operation, args, endpoint, resolvedHeaders).pipe( |
| 143 | 143 | Effect.provide(httpClientLayer), |
| 144 | 144 | Effect.mapError((err) => |
| 145 | − err instanceof Error | |
| 145 | + err instanceof GraphqlInvocationError | |
| 146 | 146 | ? err |
| 147 | 147 | : new GraphqlInvocationError({ |
| 148 | − message: String(err), | |
| 148 | + message: err instanceof Error ? err.message : String(err), | |
| 149 | 149 | statusCode: Option.none(), |
| 150 | 150 | cause: err, |
| 151 | 151 | }), |
| @@ -5,6 +5,7 @@ import type { Layer } from "effect"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | definePlugin, |
| 7 | 7 | SourceDetectionResult, |
| 8 | + type StorageFailure, | |
| 8 | 9 | type ToolAnnotations, |
| 9 | 10 | type ToolRow, |
| 10 | 11 | } from "@executor/sdk"; |
| @@ -24,7 +25,7 @@ import { · 13 unmodified lines | ||
| 24 | 25 | type IntrospectionTypeRef, |
| 25 | 26 | } from "./introspect"; |
| 26 | 27 | import { extract } from "./extract"; |
| 27 | −import { GraphqlExtractionError } from "./errors"; | |
| 28 | +import { GraphqlExtractionError, GraphqlIntrospectionError } from "./errors"; | |
| 28 | 29 | import { invokeWithLayer, resolveHeaders } from "./invoke"; |
| 29 | 30 | import { |
| 30 | 31 | graphqlSchema, |
| @@ -69,19 +70,35 @@ export interface GraphqlUpdateSourceInput { · 38 unmodified lines | ||
| 69 | 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 | + */ | |
| 82 | +export type GraphqlExtensionFailure = | |
| 83 | + | GraphqlIntrospectionError | |
| 84 | + | GraphqlExtractionError | |
| 85 | + | StorageFailure; | |
| 86 | + | |
| 72 | 87 | export interface GraphqlPluginExtension { |
| 73 | 88 | /** Add a GraphQL endpoint and register its operations as tools */ |
| 74 | 89 | readonly addSource: ( |
| 75 | 90 | config: GraphqlSourceConfig, |
| 76 | − ) => Effect.Effect<{ readonly toolCount: number }, Error>; | |
| 91 | + ) => Effect.Effect<{ readonly toolCount: number }, GraphqlExtensionFailure>; | |
| 77 | 92 | |
| 78 | 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 | 98 | /** Fetch the full stored source by namespace (or null if missing) */ |
| 82 | 99 | readonly getSource: ( |
| 83 | 100 | namespace: string, |
| 84 | − ) => Effect.Effect<StoredGraphqlSource | null, Error>; | |
| 101 | + ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>; | |
| 85 | 102 | |
| 86 | 103 | /** Update config (endpoint, headers) for an existing GraphQL source. |
| 87 | 104 | * Does NOT re-introspect or re-register tools — just patches the |
| @@ -89,7 +106,7 @@ export interface GraphqlPluginExtension { · 1 unmodified line | ||
| 89 | 106 | readonly updateSource: ( |
| 90 | 107 | namespace: string, |
| 91 | 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 | 299 | schema: graphqlSchema, |
| 283 | 300 | storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), |
| 284 | 301 | |
| 285 | − extension: (ctx): GraphqlPluginExtension => { | |
| 302 | + extension: (ctx) => { | |
| 286 | 303 | const resolveConfigHeaders = ( |
| 287 | 304 | headers: Record<string, HeaderValue> | undefined, |
| 288 | 305 | ) => |
| @@ -368,13 +385,6 @@ export const graphqlPlugin = definePlugin( · 79 unmodified lines | ||
| 368 | 385 | return { |
| 369 | 386 | addSource: (config) => |
| 370 | 387 | addSourceInternal(config).pipe( |
| 371 | − Effect.mapError((err) => | |
| 372 | − err instanceof Error | |
| 373 | − ? err | |
| 374 | − : new GraphqlExtractionError({ | |
| 375 | − message: String(err), | |
| 376 | − }), | |
| 377 | − ), | |
| 378 | 388 | Effect.tap((result) => |
| 379 | 389 | configFile |
| 380 | 390 | ? configFile.upsertSource( |
| @@ -406,7 +416,7 @@ export const graphqlPlugin = definePlugin( · 25 unmodified lines | ||
| 406 | 416 | endpoint: input.endpoint, |
| 407 | 417 | headers: input.headers, |
| 408 | 418 | }), |
| 409 | − }; | |
| 419 | + } satisfies GraphqlPluginExtension; | |
| 410 | 420 | }, |
| 411 | 421 | |
| 412 | 422 | staticSources: (self) => [ |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | 2 | |
| 3 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 3 | +import { defineSchema, type StorageDeps, type StorageFailure } from "@executor/sdk"; | |
| 4 | 4 | |
| 5 | 5 | import { OperationBinding, type HeaderValue } from "./types"; |
| 6 | 6 | |
| @@ -89,28 +89,28 @@ export interface GraphqlStore { · 82 unmodified lines | ||
| 89 | 89 | readonly upsertSource: ( |
| 90 | 90 | input: StoredGraphqlSource, |
| 91 | 91 | operations: readonly StoredOperation[], |
| 92 | − ) => Effect.Effect<void, Error>; | |
| 92 | + ) => Effect.Effect<void, StorageFailure>; | |
| 93 | 93 | |
| 94 | 94 | readonly updateSourceMeta: ( |
| 95 | 95 | namespace: string, |
| 96 | 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 | 99 | readonly getSource: ( |
| 100 | 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 | 105 | readonly getOperationByToolId: ( |
| 106 | 106 | toolId: string, |
| 107 | − ) => Effect.Effect<StoredOperation | null, Error>; | |
| 107 | + ) => Effect.Effect<StoredOperation | null, StorageFailure>; | |
| 108 | 108 | |
| 109 | 109 | readonly listOperationsBySource: ( |
| 110 | 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 | // --------------------------------------------------------------------------- |
| @@ -1,21 +1,35 @@ | ||
| 1 | 1 | import { Effect } from "effect"; |
| 2 | 2 | |
| 3 | −import type { SecretProvider } from "@executor/sdk"; | |
| 3 | +import { StorageError, type SecretProvider } from "@executor/sdk"; | |
| 4 | 4 | |
| 5 | 5 | import { getPassword, setPassword, deletePassword } from "./keyring"; |
| 6 | 6 | |
| 7 | 7 | // --------------------------------------------------------------------------- |
| 8 | 8 | // 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 | |
| 21 | +const toStorageError = (cause: { readonly message: string; readonly cause?: unknown }) => | |
| 22 | + new StorageError({ message: cause.message, cause: cause.cause ?? cause }); | |
| 23 | + | |
| 11 | 24 | export const makeKeychainProvider = (serviceName: string): SecretProvider => ({ |
| 12 | 25 | key: "keychain", |
| 13 | 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 | 29 | set: (secretId, value) => |
| 16 | − setPassword(serviceName, secretId, value).pipe(Effect.orElseSucceed(() => undefined)), | |
| 30 | + setPassword(serviceName, secretId, value).pipe(Effect.mapError(toStorageError)), | |
| 17 | 31 | delete: (secretId) => |
| 18 | − deletePassword(serviceName, secretId).pipe(Effect.orElseSucceed(() => false)), | |
| 32 | + deletePassword(serviceName, secretId).pipe(Effect.mapError(toStorageError)), | |
| 19 | 33 | // Keychain doesn't support enumerating — you need to know the account name |
| 20 | 34 | list: undefined, |
| 21 | 35 | }); |
| @@ -1,7 +1,13 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 6 | +import { | |
| 7 | + McpConnectionError, | |
| 8 | + McpOAuthError, | |
| 9 | + McpToolDiscoveryError, | |
| 10 | +} from "../sdk/errors"; | |
| 5 | 11 | import { McpStoredSourceSchema } from "../sdk/stored-source"; |
| 6 | 12 | |
| 7 | 13 | // Re-export for handler use |
| @@ -149,90 +155,75 @@ const CompleteOAuthResponse = Schema.Struct({ · 141 unmodified lines | ||
| 149 | 155 | scope: Schema.NullOr(Schema.String), |
| 150 | 156 | }); |
| 151 | 157 | |
| 152 | −// --------------------------------------------------------------------------- | |
| 153 | −// Error | |
| 154 | −// --------------------------------------------------------------------------- | |
| 155 | − | |
| 156 | −export class McpApiError extends Schema.TaggedError<McpApiError>()( | |
| 157 | − "McpApiError", | |
| 158 | − { | |
| 159 | − message: Schema.String, | |
| 160 | − }, | |
| 161 | − HttpApiSchema.annotations({ status: 400 }), | |
| 162 | −) {} | |
| 163 | − | |
| 164 | −export class McpInternalError extends Schema.TaggedError<McpInternalError>()( | |
| 165 | − "McpInternalError", | |
| 166 | − { | |
| 167 | − message: Schema.String, | |
| 168 | − }, | |
| 169 | − HttpApiSchema.annotations({ status: 500 }), | |
| 170 | −) {} | |
| 171 | − | |
| 172 | 158 | // --------------------------------------------------------------------------- |
| 173 | 159 | // 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 | |
| 176 | 174 | export class McpGroup extends HttpApiGroup.make("mcp") |
| 177 | 175 | .add( |
| 178 | 176 | HttpApiEndpoint.post("probeEndpoint")`/scopes/${scopeIdParam}/mcp/probe` |
| 179 | 177 | .setPayload(ProbeEndpointPayload) |
| 180 | − .addSuccess(ProbeEndpointResponse) | |
| 181 | − .addError(McpApiError) | |
| 182 | − .addError(McpInternalError), | |
| 178 | + .addSuccess(ProbeEndpointResponse), | |
| 183 | 179 | ) |
| 184 | 180 | .add( |
| 185 | 181 | HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/mcp/sources` |
| 186 | 182 | .setPayload(AddSourcePayload) |
| 187 | − .addSuccess(AddSourceResponse) | |
| 188 | − .addError(McpApiError) | |
| 189 | − .addError(McpInternalError), | |
| 183 | + .addSuccess(AddSourceResponse), | |
| 190 | 184 | ) |
| 191 | 185 | .add( |
| 192 | 186 | HttpApiEndpoint.post("removeSource")`/scopes/${scopeIdParam}/mcp/sources/remove` |
| 193 | 187 | .setPayload(NamespacePayload) |
| 194 | − .addSuccess(RemoveSourceResponse) | |
| 195 | − .addError(McpApiError) | |
| 196 | − .addError(McpInternalError), | |
| 188 | + .addSuccess(RemoveSourceResponse), | |
| 197 | 189 | ) |
| 198 | 190 | .add( |
| 199 | 191 | HttpApiEndpoint.post("refreshSource")`/scopes/${scopeIdParam}/mcp/sources/refresh` |
| 200 | 192 | .setPayload(NamespacePayload) |
| 201 | − .addSuccess(RefreshSourceResponse) | |
| 202 | − .addError(McpApiError) | |
| 203 | − .addError(McpInternalError), | |
| 193 | + .addSuccess(RefreshSourceResponse), | |
| 204 | 194 | ) |
| 205 | 195 | .add( |
| 206 | 196 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/mcp/oauth/start` |
| 207 | 197 | .setPayload(StartOAuthPayload) |
| 208 | − .addSuccess(StartOAuthResponse) | |
| 209 | − .addError(McpApiError) | |
| 210 | − .addError(McpInternalError), | |
| 198 | + .addSuccess(StartOAuthResponse), | |
| 211 | 199 | ) |
| 212 | 200 | .add( |
| 213 | 201 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/mcp/oauth/complete` |
| 214 | 202 | .setPayload(CompleteOAuthPayload) |
| 215 | − .addSuccess(CompleteOAuthResponse) | |
| 216 | − .addError(McpApiError) | |
| 217 | − .addError(McpInternalError), | |
| 203 | + .addSuccess(CompleteOAuthResponse), | |
| 218 | 204 | ) |
| 219 | 205 | .add( |
| 220 | 206 | HttpApiEndpoint.get("oauthCallback")`/mcp/oauth/callback` |
| 221 | 207 | .setUrlParams(OAuthCallbackParams) |
| 222 | − .addSuccess(HtmlResponse) | |
| 223 | − .addError(McpApiError) | |
| 224 | − .addError(McpInternalError), | |
| 208 | + .addSuccess(HtmlResponse), | |
| 225 | 209 | ) |
| 226 | 210 | .add( |
| 227 | 211 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 228 | − .addSuccess(Schema.NullOr(McpStoredSourceSchema)) | |
| 229 | − .addError(McpApiError) | |
| 230 | − .addError(McpInternalError), | |
| 212 | + .addSuccess(Schema.NullOr(McpStoredSourceSchema)), | |
| 231 | 213 | ) |
| 232 | 214 | .add( |
| 233 | 215 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/mcp/sources/${namespaceParam}` |
| 234 | 216 | .setPayload(UpdateSourcePayload) |
| 235 | − .addSuccess(UpdateSourceResponse) | |
| 236 | − .addError(McpApiError) | |
| 237 | − .addError(McpInternalError), | |
| 238 | − ) {} | |
| 217 | + .addSuccess(UpdateSourceResponse), | |
| 218 | + ) | |
| 219 | + // Errors declared once at the group level — every endpoint inherits. | |
| 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) {} | |
| @@ -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 | + | |
| 1 | 10 | import { HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 2 | −import { describe, expect, it } from "vitest"; | |
| 11 | +import { describe, expect, it } from "@effect/vitest"; | |
| 3 | 12 | import { Effect, Layer } from "effect"; |
| 4 | 13 | |
| 5 | −import { addGroup } from "@executor/api"; | |
| 14 | +import { addGroup, observabilityMiddleware } from "@executor/api"; | |
| 6 | 15 | import { CoreHandlers, ExecutionEngineService, ExecutorService } from "@executor/api/server"; |
| 7 | 16 | import type { McpPluginExtension } from "../sdk/plugin"; |
| 8 | 17 | import { McpExtensionService, McpHandlers } from "./handlers"; |
| @@ -10,7 +19,7 @@ import { McpGroup } from "./group"; · 1 unmodified line | ||
| 10 | 19 | |
| 11 | 20 | const unused = Effect.dieMessage("unused"); |
| 12 | 21 | |
| 13 | −const createFailingExtension = (): McpPluginExtension => ({ | |
| 22 | +const failingExtension: McpPluginExtension = { | |
| 14 | 23 | probeEndpoint: () => Effect.die(new Error("Not implemented")), |
| 15 | 24 | addSource: () => unused, |
| 16 | 25 | removeSource: () => unused, |
| @@ -19,48 +28,58 @@ const createFailingExtension = (): McpPluginExtension => ({ · 2 unmodified lines | ||
| 19 | 28 | completeOAuth: () => Effect.die(new Error("Not implemented")), |
| 20 | 29 | getSource: () => Effect.succeed(null), |
| 21 | 30 | updateSource: () => unused, |
| 22 | −}); | |
| 31 | +}; | |
| 23 | 32 | |
| 24 | 33 | const Api = addGroup(McpGroup); |
| 25 | 34 | |
| 26 | −const fakeExecutor = {} as any; | |
| 27 | −const fakeExecutionEngine = {} as any; | |
| 28 | − | |
| 29 | −const createHandler = () => | |
| 30 | − HttpApiBuilder.toWebHandler( | |
| 31 | − HttpApiBuilder.api(Api).pipe( | |
| 32 | − Layer.provide(CoreHandlers), | |
| 33 | − Layer.provide(McpHandlers), | |
| 34 | − Layer.provide(Layer.succeed(ExecutorService, fakeExecutor)), | |
| 35 | − Layer.provide(Layer.succeed(ExecutionEngineService, fakeExecutionEngine)), | |
| 36 | − Layer.provide(Layer.succeed(McpExtensionService, createFailingExtension())), | |
| 37 | − Layer.provideMerge(HttpServer.layerContext), | |
| 38 | − Layer.provideMerge(HttpApiBuilder.Router.Live), | |
| 39 | − Layer.provideMerge(HttpApiBuilder.Middleware.layer), | |
| 35 | +// `acquireRelease` keeps disposal inside the Effect scope — no | |
| 36 | +// try/finally, no per-test cleanup plumbing. `it.scoped` closes the | |
| 37 | +// scope for us. Each `Layer.provide` satisfies a piece of the api | |
| 38 | +// builder's dependency graph; `provideMerge` at the bottom keeps | |
| 39 | +// framework services available to the router itself. | |
| 40 | +const WebHandler = Effect.acquireRelease( | |
| 41 | + Effect.sync(() => | |
| 42 | + HttpApiBuilder.toWebHandler( | |
| 43 | + HttpApiBuilder.api(Api).pipe( | |
| 44 | + Layer.provide(CoreHandlers), | |
| 45 | + Layer.provide(McpHandlers), | |
| 46 | + Layer.provide(observabilityMiddleware(Api)), | |
| 47 | + Layer.provide(Layer.succeed(ExecutorService, {} as never)), | |
| 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 | |
| 43 | 59 | describe("McpHandlers", () => { |
| 44 | − it("sanitizes unknown endpoint failures", async () => { | |
| 45 | − const web = createHandler(); | |
| 46 | − try { | |
| 47 | − const response = await web.handler( | |
| 48 | − new Request("http://localhost/scopes/scope_1/mcp/probe", { | |
| 49 | − method: "POST", | |
| 50 | − headers: { "content-type": "application/json" }, | |
| 51 | − body: JSON.stringify({ endpoint: "https://example.com/mcp" }), | |
| 52 | − }), | |
| 53 | − ); | |
| 60 | + it.scoped( | |
| 61 | + "defect-returning methods produce an opaque InternalError, no leakage", | |
| 62 | + () => | |
| 63 | + Effect.gen(function* () { | |
| 64 | + const web = yield* WebHandler; | |
| 65 | + const response = yield* Effect.promise(() => | |
| 66 | + web.handler( | |
| 67 | + new Request("http://localhost/scopes/scope_1/mcp/probe", { | |
| 68 | + method: "POST", | |
| 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); | |
| 56 | − const body = await response.json(); | |
| 57 | − expect(body).toEqual({ | |
| 58 | − _tag: "McpInternalError", | |
| 59 | − message: "Internal server error", | |
| 60 | − }); | |
| 61 | − expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 62 | − } finally { | |
| 63 | − await web.dispose(); | |
| 64 | − } | |
| 65 | − }); | |
| 75 | + expect(response.status).toBe(500); | |
| 76 | + const body = (yield* Effect.promise(() => response.json())) as { | |
| 77 | + _tag?: string; | |
| 78 | + traceId?: string; | |
| 79 | + }; | |
| 80 | + expect(body._tag).toBe("InternalError"); | |
| 81 | + expect(typeof body.traceId).toBe("string"); | |
| 82 | + expect(JSON.stringify(body)).not.toContain("Not implemented"); | |
| 83 | + }), | |
| 84 | + ); | |
| 66 | 85 | }); |
| @@ -1,18 +1,17 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | −import { addGroup } from "@executor/api"; | |
| 4 | +import { addGroup, capture } from "@executor/api"; | |
| 5 | 5 | import type { McpPluginExtension, McpSourceConfig, McpUpdateSourceInput } from "../sdk/plugin"; |
| 6 | −import { | |
| 7 | − McpConnectionError, | |
| 8 | − McpInvocationError, | |
| 9 | − McpOAuthError, | |
| 10 | − McpToolDiscoveryError, | |
| 11 | −} from "../sdk/errors"; | |
| 12 | −import { McpApiError, McpGroup, McpInternalError } from "./group"; | |
| 6 | +import { McpOAuthError } from "../sdk/errors"; | |
| 7 | +import { McpGroup } from "./group"; | |
| 13 | 8 | |
| 14 | 9 | // --------------------------------------------------------------------------- |
| 15 | −// Service tag | |
| 10 | +// Service tag — holds the raw extension shape the executor produces. | |
| 11 | +// Handlers wrap their generator bodies with `capture(...)` from | |
| 12 | +// `@executor/api`, which translates `StorageError` to `InternalError` | |
| 13 | +// at the edge; that's why the tag type matches the SDK shape directly | |
| 14 | +// (no `Captured<>` inversion). | |
| 16 | 15 | // --------------------------------------------------------------------------- |
| 17 | 16 | |
| 18 | 17 | export class McpExtensionService extends Context.Tag("McpExtensionService")< |
| @@ -85,33 +84,11 @@ const popupDocument = (payload: OAuthPopupResult): string => { · 66 unmodified lines | ||
| 85 | 84 | </body></html>`; |
| 86 | 85 | }; |
| 87 | 86 | |
| 88 | −const toPopupErrorMessage = (error: unknown): string => { | |
| 89 | − if (error instanceof McpOAuthError) { | |
| 90 | − return error.message; | |
| 91 | − } | |
| 92 | − return "Authentication failed"; | |
| 93 | −}; | |
| 94 | − | |
| 95 | −const 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" }); | |
| 87 | +const toPopupErrorMessage = (cause: Cause.Cause<unknown>): string => { | |
| 88 | + const err = Cause.squash(cause); | |
| 89 | + return err instanceof McpOAuthError ? err.message : "Authentication failed"; | |
| 108 | 90 | }; |
| 109 | 91 | |
| 110 | −const 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 | 93 | // Convert API payload → McpSourceConfig |
| 117 | 94 | // --------------------------------------------------------------------------- |
| @@ -151,7 +128,6 @@ const toSourceConfig = ( · 33 unmodified lines | ||
| 151 | 128 | auth?: { kind: string } & Record<string, unknown>; |
| 152 | 129 | }; |
| 153 | 130 | |
| 154 | − // Normalize oauth2 tokenType default | |
| 155 | 131 | const auth = p.auth |
| 156 | 132 | ? p.auth.kind === "oauth2" |
| 157 | 133 | ? { |
| @@ -175,65 +151,74 @@ const toSourceConfig = ( · 17 unmodified lines | ||
| 175 | 151 | |
| 176 | 152 | // --------------------------------------------------------------------------- |
| 177 | 153 | // 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 | |
| 180 | 165 | export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (handlers) => |
| 181 | 166 | handlers |
| 182 | 167 | .handle("probeEndpoint", ({ payload }) => |
| 183 | − Effect.gen(function* () { | |
| 168 | + capture(Effect.gen(function* () { | |
| 184 | 169 | const ext = yield* McpExtensionService; |
| 185 | 170 | return yield* ext.probeEndpoint(payload.endpoint); |
| 186 | − }).pipe(sanitizeMcpFailure), | |
| 171 | + })), | |
| 187 | 172 | ) |
| 188 | 173 | .handle("addSource", ({ payload }) => |
| 189 | − Effect.gen(function* () { | |
| 174 | + capture(Effect.gen(function* () { | |
| 190 | 175 | const ext = yield* McpExtensionService; |
| 191 | 176 | return yield* ext.addSource( |
| 192 | 177 | toSourceConfig(payload as Parameters<typeof toSourceConfig>[0]), |
| 193 | 178 | ); |
| 194 | − }).pipe(sanitizeMcpFailure), | |
| 179 | + })), | |
| 195 | 180 | ) |
| 196 | 181 | .handle("removeSource", ({ payload }) => |
| 197 | − Effect.gen(function* () { | |
| 182 | + capture(Effect.gen(function* () { | |
| 198 | 183 | const ext = yield* McpExtensionService; |
| 199 | 184 | yield* ext.removeSource(payload.namespace); |
| 200 | 185 | return { removed: true }; |
| 201 | − }).pipe(sanitizeMcpFailure), | |
| 186 | + })), | |
| 202 | 187 | ) |
| 203 | 188 | .handle("refreshSource", ({ payload }) => |
| 204 | − Effect.gen(function* () { | |
| 189 | + capture(Effect.gen(function* () { | |
| 205 | 190 | const ext = yield* McpExtensionService; |
| 206 | 191 | return yield* ext.refreshSource(payload.namespace); |
| 207 | − }).pipe(sanitizeMcpFailure), | |
| 192 | + })), | |
| 208 | 193 | ) |
| 209 | 194 | .handle("startOAuth", ({ payload }) => |
| 210 | − Effect.gen(function* () { | |
| 195 | + capture(Effect.gen(function* () { | |
| 211 | 196 | const ext = yield* McpExtensionService; |
| 212 | 197 | return yield* ext.startOAuth({ |
| 213 | 198 | endpoint: payload.endpoint, |
| 214 | 199 | redirectUrl: payload.redirectUrl, |
| 215 | 200 | queryParams: payload.queryParams, |
| 216 | 201 | }); |
| 217 | − }).pipe(sanitizeMcpFailure), | |
| 202 | + })), | |
| 218 | 203 | ) |
| 219 | 204 | .handle("completeOAuth", ({ payload }) => |
| 220 | − Effect.gen(function* () { | |
| 205 | + capture(Effect.gen(function* () { | |
| 221 | 206 | const ext = yield* McpExtensionService; |
| 222 | 207 | return yield* ext.completeOAuth({ |
| 223 | 208 | state: payload.state, |
| 224 | 209 | code: payload.code, |
| 225 | 210 | error: payload.error, |
| 226 | 211 | }); |
| 227 | − }).pipe(sanitizeMcpFailure), | |
| 212 | + })), | |
| 228 | 213 | ) |
| 229 | 214 | .handle("getSource", ({ path }) => |
| 230 | − Effect.gen(function* () { | |
| 215 | + capture(Effect.gen(function* () { | |
| 231 | 216 | const ext = yield* McpExtensionService; |
| 232 | 217 | return yield* ext.getSource(path.namespace); |
| 233 | − }).pipe(sanitizeMcpFailure), | |
| 218 | + })), | |
| 234 | 219 | ) |
| 235 | 220 | .handle("updateSource", ({ path, payload }) => |
| 236 | − Effect.gen(function* () { | |
| 221 | + capture(Effect.gen(function* () { | |
| 237 | 222 | const ext = yield* McpExtensionService; |
| 238 | 223 | yield* ext.updateSource(path.namespace, { |
| 239 | 224 | name: payload.name, |
| @@ -243,20 +228,23 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | ||
| 243 | 228 | auth: payload.auth as McpUpdateSourceInput["auth"], |
| 244 | 229 | }); |
| 245 | 230 | return { updated: true }; |
| 246 | − }).pipe(sanitizeMcpFailure), | |
| 231 | + })), | |
| 247 | 232 | ) |
| 248 | 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 | 238 | const ext = yield* McpExtensionService; |
| 251 | − const result = yield* ext | |
| 252 | − .completeOAuth({ | |
| 239 | + const result = yield* Effect.matchCauseEffect( | |
| 240 | + ext.completeOAuth({ | |
| 253 | 241 | state: urlParams.state, |
| 254 | 242 | code: urlParams.code, |
| 255 | 243 | error: urlParams.error ?? urlParams.error_description, |
| 256 | − }) | |
| 257 | − .pipe( | |
| 258 | − Effect.map( | |
| 259 | − (c): OAuthPopupResult => ({ | |
| 244 | + }), | |
| 245 | + { | |
| 246 | + onSuccess: (c) => | |
| 247 | + Effect.succeed<OAuthPopupResult>({ | |
| 260 | 248 | type: "executor:oauth-result", |
| 261 | 249 | ok: true, |
| 262 | 250 | sessionId: urlParams.state, |
| @@ -266,17 +254,16 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 3 unmodified lines | ||
| 266 | 254 | expiresAt: c.expiresAt, |
| 267 | 255 | scope: c.scope, |
| 268 | 256 | }), |
| 269 | − ), | |
| 270 | − Effect.catchAllCause((cause) => | |
| 257 | + onFailure: (cause) => | |
| 271 | 258 | Effect.succeed<OAuthPopupResult>({ |
| 272 | 259 | type: "executor:oauth-result", |
| 273 | 260 | ok: false, |
| 274 | 261 | sessionId: null, |
| 275 | − error: toPopupErrorMessage(Cause.squash(cause)), | |
| 262 | + error: toPopupErrorMessage(cause), | |
| 276 | 263 | }), |
| 277 | − ), | |
| 278 | − ); | |
| 264 | + }, | |
| 265 | + ); | |
| 279 | 266 | return yield* HttpServerResponse.html(popupDocument(result)); |
| 280 | − }).pipe(sanitizeMcpFailure), | |
| 267 | + })), | |
| 281 | 268 | ), |
| 282 | 269 | ); |
| @@ -5,7 +5,11 @@ · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { Effect, Schema } from "effect"; |
| 7 | 7 | |
| 8 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 8 | +import { | |
| 9 | + defineSchema, | |
| 10 | + type StorageDeps, | |
| 11 | + type StorageFailure, | |
| 12 | +} from "@executor/sdk"; | |
| 9 | 13 | |
| 10 | 14 | import { McpToolBinding, McpStoredSourceData } from "./types"; |
| 11 | 15 | import { McpOAuthSession } from "./oauth"; |
| @@ -91,37 +95,45 @@ export interface McpStoredSource { · 79 unmodified lines | ||
| 91 | 95 | // 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. | |
| 94 | 102 | export interface McpBindingStore { |
| 95 | 103 | readonly getBinding: ( |
| 96 | 104 | toolId: string, |
| 97 | 105 | ) => Effect.Effect< |
| 98 | 106 | { readonly binding: McpToolBinding; readonly namespace: string } | null, |
| 99 | − Error | |
| 107 | + StorageFailure | |
| 100 | 108 | >; |
| 101 | 109 | |
| 102 | 110 | readonly putBindings: ( |
| 103 | 111 | namespace: string, |
| 104 | 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>; | |
| 110 | − readonly getSource: (namespace: string) => Effect.Effect<McpStoredSource | null, Error>; | |
| 119 | + readonly listSources: () => Effect.Effect<readonly McpStoredSource[], StorageFailure>; | |
| 120 | + readonly getSource: ( | |
| 121 | + namespace: string, | |
| 122 | + ) => Effect.Effect<McpStoredSource | null, StorageFailure>; | |
| 111 | 123 | readonly getSourceConfig: ( |
| 112 | 124 | namespace: string, |
| 113 | − ) => Effect.Effect<McpStoredSourceData | null, Error>; | |
| 114 | − readonly putSource: (source: McpStoredSource) => Effect.Effect<void, Error>; | |
| 115 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 125 | + ) => Effect.Effect<McpStoredSourceData | null, StorageFailure>; | |
| 126 | + readonly putSource: (source: McpStoredSource) => Effect.Effect<void, StorageFailure>; | |
| 127 | + readonly removeSource: (namespace: string) => Effect.Effect<void, StorageFailure>; | |
| 116 | 128 | |
| 117 | 129 | readonly putOAuthSession: ( |
| 118 | 130 | sessionId: string, |
| 119 | 131 | session: McpOAuthSession, |
| 120 | − ) => Effect.Effect<void, Error>; | |
| 132 | + ) => Effect.Effect<void, StorageFailure>; | |
| 121 | 133 | readonly getOAuthSession: ( |
| 122 | 134 | sessionId: string, |
| 123 | − ) => Effect.Effect<McpOAuthSession | null, Error>; | |
| 124 | − readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, Error>; | |
| 135 | + ) => Effect.Effect<McpOAuthSession | null, StorageFailure>; | |
| 136 | + readonly deleteOAuthSession: (sessionId: string) => Effect.Effect<void, StorageFailure>; | |
| 125 | 137 | } |
| 126 | 138 | |
| 127 | 139 | // --------------------------------------------------------------------------- |
| @@ -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 | + | |
| 1 | 6 | import { Schema } from "effect"; |
| 7 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 8 | |
| 3 | 9 | export class McpConnectionError extends Schema.TaggedError<McpConnectionError>()( |
| 4 | 10 | "McpConnectionError", |
| @@ -6,6 +12,7 @@ export class McpConnectionError extends Schema.TaggedError<McpConnectionError>() · 1 unmodified line | ||
| 6 | 12 | transport: Schema.String, |
| 7 | 13 | message: Schema.String, |
| 8 | 14 | }, |
| 15 | + HttpApiSchema.annotations({ status: 400 }), | |
| 9 | 16 | ) {} |
| 10 | 17 | |
| 11 | 18 | export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryError>()( |
| @@ -14,6 +21,7 @@ export class McpToolDiscoveryError extends Schema.TaggedError<McpToolDiscoveryEr · 2 unmodified lines | ||
| 14 | 21 | stage: Schema.Literal("connect", "list_tools"), |
| 15 | 22 | message: Schema.String, |
| 16 | 23 | }, |
| 24 | + HttpApiSchema.annotations({ status: 400 }), | |
| 17 | 25 | ) {} |
| 18 | 26 | |
| 19 | 27 | export class McpInvocationError extends Schema.TaggedError<McpInvocationError>()( |
| @@ -22,8 +30,13 @@ export class McpInvocationError extends Schema.TaggedError<McpInvocationError>() · 2 unmodified lines | ||
| 22 | 30 | toolName: Schema.String, |
| 23 | 31 | message: Schema.String, |
| 24 | 32 | }, |
| 33 | + HttpApiSchema.annotations({ status: 400 }), | |
| 25 | 34 | ) {} |
| 26 | 35 | |
| 27 | −export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()("McpOAuthError", { | |
| 28 | − message: Schema.String, | |
| 29 | −}) {} | |
| 36 | +export class McpOAuthError extends Schema.TaggedError<McpOAuthError>()( | |
| 37 | + "McpOAuthError", | |
| 38 | + { | |
| 39 | + message: Schema.String, | |
| 40 | + }, | |
| 41 | + HttpApiSchema.annotations({ status: 400 }), | |
| 42 | +) {} | |
| @@ -21,7 +21,7 @@ import { · 20 unmodified lines | ||
| 21 | 21 | type ElicitationRequest, |
| 22 | 22 | } from "@executor/sdk"; |
| 23 | 23 | |
| 24 | −import { McpConnectionError } from "./errors"; | |
| 24 | +import { McpConnectionError, McpInvocationError } from "./errors"; | |
| 25 | 25 | import type { McpConnection } from "./connection"; |
| 26 | 26 | import type { McpStoredSourceData } from "./types"; |
| 27 | 27 | |
| @@ -124,17 +124,18 @@ const useConnection = ( · 96 unmodified lines | ||
| 124 | 124 | toolName: string, |
| 125 | 125 | args: Record<string, unknown>, |
| 126 | 126 | elicit: Elicit, |
| 127 | −): Effect.Effect<unknown, Error> => | |
| 127 | +): Effect.Effect<unknown, McpInvocationError> => | |
| 128 | 128 | Effect.gen(function* () { |
| 129 | 129 | installElicitationHandler(connection.client, elicit); |
| 130 | 130 | return yield* Effect.tryPromise({ |
| 131 | 131 | try: () => connection.client.callTool({ name: toolName, arguments: args }), |
| 132 | 132 | catch: (cause) => |
| 133 | − new Error( | |
| 134 | − `MCP tool call failed for ${toolName}: ${ | |
| 133 | + new McpInvocationError({ | |
| 134 | + toolName, | |
| 135 | + message: `MCP tool call failed for ${toolName}: ${ | |
| 135 | 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 | |
| 163 | 164 | export const invokeMcpTool = ( |
| 164 | 165 | input: InvokeMcpToolInput, |
| 165 | −): Effect.Effect<unknown, Error> => | |
| 166 | +): Effect.Effect<unknown, McpConnectionError | McpInvocationError> => | |
| 166 | 167 | Effect.gen(function* () { |
| 167 | 168 | const cacheKey = connectionCacheKey(input.sourceData); |
| 168 | 169 | const args = asRecord(input.args); |
| @@ -172,16 +173,7 @@ export const invokeMcpTool = ( · 3 unmodified lines | ||
| 172 | 173 | const connector = input.resolveConnector(); |
| 173 | 174 | input.pendingConnectors.set(cacheKey, connector); |
| 174 | 175 | |
| 175 | − const firstConnection = yield* input.connectionCache.get(cacheKey).pipe( | |
| 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 | − ); | |
| 176 | + const firstConnection = yield* input.connectionCache.get(cacheKey); | |
| 185 | 177 | |
| 186 | 178 | return yield* useConnection( |
| 187 | 179 | firstConnection, |
| @@ -195,16 +187,7 @@ export const invokeMcpTool = ( · 7 unmodified lines | ||
| 195 | 187 | Effect.gen(function* () { |
| 196 | 188 | yield* input.connectionCache.invalidate(cacheKey); |
| 197 | 189 | input.pendingConnectors.set(cacheKey, connector); |
| 198 | − const fresh = yield* input.connectionCache.get(cacheKey).pipe( | |
| 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 | − ); | |
| 190 | + const fresh = yield* input.connectionCache.get(cacheKey); | |
| 208 | 191 | return yield* useConnection( |
| 209 | 192 | fresh, |
| 210 | 193 | input.toolName, |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | SetSecretInput, |
| 10 | 10 | SourceDetectionResult, |
| 11 | 11 | type PluginCtx, |
| 12 | + type StorageFailure, | |
| 12 | 13 | } from "@executor/sdk"; |
| 13 | 14 | |
| 14 | 15 | import { |
| @@ -211,7 +212,7 @@ const resolveConnectorInput = ( · 196 unmodified lines | ||
| 211 | 212 | sd: McpStoredSourceData, |
| 212 | 213 | ctx: PluginCtx<McpBindingStore>, |
| 213 | 214 | allowStdio: boolean, |
| 214 | −): Effect.Effect<ConnectorInput, Error> => { | |
| 215 | +): Effect.Effect<ConnectorInput, McpConnectionError | StorageFailure> => { | |
| 215 | 216 | if (sd.transport === "stdio") { |
| 216 | 217 | if (!allowStdio) { |
| 217 | 218 | return Effect.fail( |
| @@ -291,7 +292,7 @@ interface McpRuntime { · 73 unmodified lines | ||
| 291 | 292 | readonly cacheScope: Scope.CloseableScope; |
| 292 | 293 | } |
| 293 | 294 | |
| 294 | −const makeRuntime = (): Effect.Effect<McpRuntime, Error> => | |
| 295 | +const makeRuntime = (): Effect.Effect<McpRuntime, never> => | |
| 295 | 296 | Effect.gen(function* () { |
| 296 | 297 | const cacheScope = yield* Scope.make(); |
| 297 | 298 | const pendingConnectors = new Map< |
| @@ -403,7 +404,7 @@ export const mcpPlugin = definePlugin( · 105 unmodified lines | ||
| 403 | 404 | // connection cache across a single createExecutor lifecycle. |
| 404 | 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 | 408 | runtimeRef.current |
| 408 | 409 | ? Effect.succeed(runtimeRef.current) |
| 409 | 410 | : makeRuntime().pipe( |
| @@ -796,7 +797,7 @@ export const mcpPlugin = definePlugin( · 386 unmodified lines | ||
| 796 | 797 | completeOAuth, |
| 797 | 798 | getSource, |
| 798 | 799 | updateSource, |
| 799 | − }; | |
| 800 | + } satisfies McpPluginExtension; | |
| 800 | 801 | }, |
| 801 | 802 | |
| 802 | 803 | invokeTool: ({ ctx, toolRow, args, elicit }) => |
| @@ -938,28 +939,49 @@ export const mcpPlugin = definePlugin( · 135 unmodified lines | ||
| 938 | 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 | + */ | |
| 952 | +export type McpExtensionFailure = | |
| 953 | + | McpOAuthError | |
| 954 | + | McpConnectionError | |
| 955 | + | McpToolDiscoveryError | |
| 956 | + | StorageFailure; | |
| 957 | + | |
| 941 | 958 | export interface McpPluginExtension { |
| 942 | 959 | readonly probeEndpoint: ( |
| 943 | 960 | endpoint: string, |
| 944 | − ) => Effect.Effect<McpProbeResult, Error>; | |
| 961 | + ) => Effect.Effect<McpProbeResult, McpExtensionFailure>; | |
| 945 | 962 | readonly addSource: ( |
| 946 | 963 | config: McpSourceConfig, |
| 947 | − ) => Effect.Effect<{ readonly toolCount: number; readonly namespace: string }, Error>; | |
| 948 | − readonly removeSource: (namespace: string) => Effect.Effect<void, Error>; | |
| 964 | + ) => Effect.Effect< | |
| 965 | + { readonly toolCount: number; readonly namespace: string }, | |
| 966 | + McpExtensionFailure | |
| 967 | + >; | |
| 968 | + readonly removeSource: ( | |
| 969 | + namespace: string, | |
| 970 | + ) => Effect.Effect<void, McpExtensionFailure>; | |
| 949 | 971 | readonly refreshSource: ( |
| 950 | 972 | namespace: string, |
| 951 | − ) => Effect.Effect<{ readonly toolCount: number }, Error>; | |
| 973 | + ) => Effect.Effect<{ readonly toolCount: number }, McpExtensionFailure>; | |
| 952 | 974 | readonly startOAuth: ( |
| 953 | 975 | input: McpOAuthStartInput, |
| 954 | − ) => Effect.Effect<McpOAuthStartResponse, Error>; | |
| 976 | + ) => Effect.Effect<McpOAuthStartResponse, McpExtensionFailure>; | |
| 955 | 977 | readonly completeOAuth: ( |
| 956 | 978 | input: McpOAuthCompleteInput, |
| 957 | − ) => Effect.Effect<McpOAuthCompleteResponse, Error>; | |
| 979 | + ) => Effect.Effect<McpOAuthCompleteResponse, McpExtensionFailure>; | |
| 958 | 980 | readonly getSource: ( |
| 959 | 981 | namespace: string, |
| 960 | − ) => Effect.Effect<McpStoredSource | null, Error>; | |
| 982 | + ) => Effect.Effect<McpStoredSource | null, McpExtensionFailure>; | |
| 961 | 983 | readonly updateSource: ( |
| 962 | 984 | namespace: string, |
| 963 | 985 | input: McpUpdateSourceInput, |
| 964 | − ) => Effect.Effect<void, Error>; | |
| 986 | + ) => Effect.Effect<void, McpExtensionFailure>; | |
| 965 | 987 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { OnePasswordError } from "../sdk/errors"; |
| 6 | 7 | import { OnePasswordConfig, Vault, ConnectionStatus } from "../sdk/types"; |
| @@ -32,14 +33,18 @@ const ListVaultsResponse = Schema.Struct({ · 25 unmodified lines | ||
| 32 | 33 | |
| 33 | 34 | const GetConfigResponse = Schema.NullOr(OnePasswordConfig); |
| 34 | 35 | |
| 35 | −// --------------------------------------------------------------------------- | |
| 36 | −// Errors with HTTP status | |
| 37 | −// --------------------------------------------------------------------------- | |
| 38 | − | |
| 39 | −const OpError = OnePasswordError.annotations(HttpApiSchema.annotations({ status: 502 })); | |
| 40 | − | |
| 41 | 36 | // --------------------------------------------------------------------------- |
| 42 | 37 | // 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 | |
| 45 | 50 | export class OnePasswordGroup extends HttpApiGroup.make("onepassword") |
| @@ -51,8 +56,7 @@ export class OnePasswordGroup extends HttpApiGroup.make("onepassword") · 5 unmodified lines | ||
| 51 | 56 | .add( |
| 52 | 57 | HttpApiEndpoint.put("configure")`/scopes/${scopeIdParam}/onepassword/config` |
| 53 | 58 | .setPayload(ConfigurePayload) |
| 54 | − .addSuccess(Schema.Void) | |
| 55 | − .addError(OpError), | |
| 59 | + .addSuccess(Schema.Void), | |
| 56 | 60 | ) |
| 57 | 61 | .add( |
| 58 | 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 | 66 | .add( |
| 63 | − HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status` | |
| 64 | − .addSuccess(ConnectionStatus) | |
| 65 | − .addError(OpError), | |
| 67 | + HttpApiEndpoint.get("status")`/scopes/${scopeIdParam}/onepassword/status`.addSuccess( | |
| 68 | + ConnectionStatus, | |
| 69 | + ), | |
| 66 | 70 | ) |
| 67 | 71 | .add( |
| 68 | 72 | HttpApiEndpoint.get("listVaults")`/scopes/${scopeIdParam}/onepassword/vaults` |
| 69 | 73 | .setUrlParams(ListVaultsParams) |
| 70 | − .addSuccess(ListVaultsResponse) | |
| 71 | − .addError(OpError), | |
| 72 | − ) {} | |
| 74 | + .addSuccess(ListVaultsResponse), | |
| 75 | + ) | |
| 76 | + .addError(InternalError) | |
| 77 | + .addError(OnePasswordError) {} | |
| @@ -1,12 +1,19 @@ | ||
| 1 | 1 | import { HttpApiBuilder } from "@effect/platform"; |
| 2 | 2 | import { Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | −import { addGroup } from "@executor/api"; | |
| 4 | +import { addGroup, capture } from "@executor/api"; | |
| 5 | 5 | import type { OnePasswordExtension } from "../sdk/plugin"; |
| 6 | 6 | import { OnePasswordGroup } from "./group"; |
| 7 | 7 | |
| 8 | 8 | // --------------------------------------------------------------------------- |
| 9 | −// Service tag — the server provides the 1Password extension | |
| 9 | +// Service tag | |
| 10 | +// | |
| 11 | +// Holds the `Captured` shape — every method's `StorageFailure` channel has | |
| 12 | +// been swapped for `InternalError({ traceId })`. The host provides an | |
| 13 | +// already-wrapped extension via | |
| 14 | +// `Layer.succeed(OnePasswordExtensionService, withCapture(executor).onepassword)`. | |
| 15 | +// Handlers see `InternalError` in the error union, which matches | |
| 16 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 10 | 17 | // --------------------------------------------------------------------------- |
| 11 | 18 | |
| 12 | 19 | export class OnePasswordExtensionService extends Context.Tag("OnePasswordExtensionService")< |
| @@ -22,6 +29,12 @@ const ExecutorApiWithOnePassword = addGroup(OnePasswordGroup); · 9 unmodified lines | ||
| 22 | 29 | |
| 23 | 30 | // --------------------------------------------------------------------------- |
| 24 | 31 | // 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 | |
| 27 | 40 | export const OnePasswordHandlers = HttpApiBuilder.group( |
| @@ -30,31 +43,31 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 2 unmodified lines | ||
| 30 | 43 | (handlers) => |
| 31 | 44 | handlers |
| 32 | 45 | .handle("getConfig", () => |
| 33 | − Effect.gen(function* () { | |
| 46 | + capture(Effect.gen(function* () { | |
| 34 | 47 | const ext = yield* OnePasswordExtensionService; |
| 35 | 48 | return yield* ext.getConfig(); |
| 36 | − }).pipe(Effect.orDie), | |
| 49 | + })), | |
| 37 | 50 | ) |
| 38 | 51 | .handle("configure", ({ payload }) => |
| 39 | − Effect.gen(function* () { | |
| 52 | + capture(Effect.gen(function* () { | |
| 40 | 53 | const ext = yield* OnePasswordExtensionService; |
| 41 | 54 | yield* ext.configure(payload); |
| 42 | − }).pipe(Effect.orDie), | |
| 55 | + })), | |
| 43 | 56 | ) |
| 44 | 57 | .handle("removeConfig", () => |
| 45 | − Effect.gen(function* () { | |
| 58 | + capture(Effect.gen(function* () { | |
| 46 | 59 | const ext = yield* OnePasswordExtensionService; |
| 47 | 60 | yield* ext.removeConfig(); |
| 48 | − }).pipe(Effect.orDie), | |
| 61 | + })), | |
| 49 | 62 | ) |
| 50 | 63 | .handle("status", () => |
| 51 | − Effect.gen(function* () { | |
| 64 | + capture(Effect.gen(function* () { | |
| 52 | 65 | const ext = yield* OnePasswordExtensionService; |
| 53 | 66 | return yield* ext.status(); |
| 54 | − }).pipe(Effect.orDie), | |
| 67 | + })), | |
| 55 | 68 | ) |
| 56 | 69 | .handle("listVaults", ({ urlParams }) => |
| 57 | − Effect.gen(function* () { | |
| 70 | + capture(Effect.gen(function* () { | |
| 58 | 71 | const ext = yield* OnePasswordExtensionService; |
| 59 | 72 | const auth = |
| 60 | 73 | urlParams.authKind === "desktop-app" |
| @@ -62,6 +75,6 @@ export const OnePasswordHandlers = HttpApiBuilder.group( · 1 unmodified line | ||
| 62 | 75 | : { kind: "service-account" as const, tokenSecretId: urlParams.account }; |
| 63 | 76 | const vaults = yield* ext.listVaults(auth); |
| 64 | 77 | return { vaults: [...vaults] }; |
| 65 | − }).pipe(Effect.orDie), | |
| 78 | + })), | |
| 66 | 79 | ), |
| 67 | 80 | ); |
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import { Schema } from "effect"; |
| 2 | +import { HttpApiSchema } from "@effect/platform"; | |
| 2 | 3 | |
| 3 | −export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()("OnePasswordError", { | |
| 4 | − operation: Schema.String, | |
| 5 | − message: Schema.String, | |
| 6 | −}) {} | |
| 4 | +export class OnePasswordError extends Schema.TaggedError<OnePasswordError>()( | |
| 5 | + "OnePasswordError", | |
| 6 | + { | |
| 7 | + operation: Schema.String, | |
| 8 | + message: Schema.String, | |
| 9 | + }, | |
| 10 | + HttpApiSchema.annotations({ status: 502 }), | |
| 11 | +) {} | |
| @@ -2,9 +2,11 @@ import { Effect, Schema } from "effect"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | definePlugin, |
| 5 | + StorageError, | |
| 5 | 6 | type PluginCtx, |
| 6 | 7 | type ScopedBlobStore, |
| 7 | 8 | type SecretProvider, |
| 9 | + type StorageFailure, | |
| 8 | 10 | } from "@executor/sdk"; |
| 9 | 11 | |
| 10 | 12 | import { OnePasswordConfig, Vault, ConnectionStatus } from "./types"; |
| @@ -24,6 +26,20 @@ const CREDENTIAL_FIELD = "credential"; · 13 unmodified lines | ||
| 24 | 26 | const DEFAULT_TIMEOUT_MS = 15_000; |
| 25 | 27 | const 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 | + | |
| 41 | +export type OnePasswordExtensionFailure = OnePasswordError | StorageFailure; | |
| 42 | + | |
| 27 | 43 | // --------------------------------------------------------------------------- |
| 28 | 44 | // Plugin extension — public API on executor.onepassword |
| 29 | 45 | // --------------------------------------------------------------------------- |
| @@ -32,59 +48,69 @@ export interface OnePasswordExtension { · 2 unmodified lines | ||
| 32 | 48 | /** Configure the 1Password connection */ |
| 33 | 49 | readonly configure: ( |
| 34 | 50 | config: OnePasswordConfig, |
| 35 | − ) => Effect.Effect<void, OnePasswordError>; | |
| 51 | + ) => Effect.Effect<void, StorageFailure>; | |
| 36 | 52 | |
| 37 | 53 | /** Get current configuration (if any) */ |
| 38 | 54 | readonly getConfig: () => Effect.Effect< |
| 39 | 55 | OnePasswordConfig | null, |
| 40 | − OnePasswordError | |
| 56 | + OnePasswordExtensionFailure | |
| 41 | 57 | >; |
| 42 | 58 | |
| 43 | 59 | /** Remove the 1Password configuration */ |
| 44 | − readonly removeConfig: () => Effect.Effect<void, OnePasswordError>; | |
| 60 | + readonly removeConfig: () => Effect.Effect<void, StorageFailure>; | |
| 45 | 61 | |
| 46 | 62 | /** Check connection status */ |
| 47 | − readonly status: () => Effect.Effect<ConnectionStatus, OnePasswordError>; | |
| 63 | + readonly status: () => Effect.Effect< | |
| 64 | + ConnectionStatus, | |
| 65 | + OnePasswordExtensionFailure | |
| 66 | + >; | |
| 48 | 67 | |
| 49 | 68 | /** List accessible vaults (requires auth) */ |
| 50 | 69 | readonly listVaults: ( |
| 51 | 70 | auth: OnePasswordAuth, |
| 52 | − ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordError>; | |
| 71 | + ) => Effect.Effect<ReadonlyArray<Vault>, OnePasswordExtensionFailure>; | |
| 53 | 72 | |
| 54 | 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 because | |
| 60 | −// there's only one key and one document; a schema table would be overkill. | |
| 80 | +// Typed config store — single blob, JSON encoded. Blob I/O failures surface | |
| 81 | +// as `StorageError` (HTTP edge translates to `InternalError`); decode | |
| 82 | +// failures stay `OnePasswordError` — the blob's contents are a plugin | |
| 83 | +// concern, not an infrastructure one. | |
| 61 | 84 | // --------------------------------------------------------------------------- |
| 62 | 85 | |
| 63 | 86 | export interface OnePasswordStore { |
| 64 | 87 | readonly getConfig: () => Effect.Effect< |
| 65 | 88 | OnePasswordConfig | null, |
| 66 | − OnePasswordError | |
| 89 | + StorageError | OnePasswordError | |
| 67 | 90 | >; |
| 68 | 91 | readonly saveConfig: ( |
| 69 | 92 | config: OnePasswordConfig, |
| 70 | − ) => Effect.Effect<void, OnePasswordError>; | |
| 71 | − readonly deleteConfig: () => Effect.Effect<void, OnePasswordError>; | |
| 93 | + ) => Effect.Effect<void, StorageError>; | |
| 94 | + readonly deleteConfig: () => Effect.Effect<void, StorageError>; | |
| 72 | 95 | } |
| 73 | 96 | |
| 74 | 97 | const decodeConfig = Schema.decodeUnknownSync(OnePasswordConfig); |
| 75 | 98 | |
| 99 | +const blobStorageError = (operation: string) => | |
| 100 | + (cause: unknown): StorageError => | |
| 101 | + new StorageError({ | |
| 102 | + message: `onepassword blob ${operation}: ${ | |
| 103 | + cause instanceof Error ? cause.message : String(cause) | |
| 104 | + }`, | |
| 105 | + cause, | |
| 106 | + }); | |
| 107 | + | |
| 76 | 108 | export const makeOnePasswordStore = ( |
| 77 | 109 | blobs: ScopedBlobStore, |
| 78 | 110 | ): OnePasswordStore => ({ |
| 79 | 111 | getConfig: () => |
| 80 | 112 | blobs.get(CONFIG_KEY).pipe( |
| 81 | − Effect.mapError( | |
| 82 | − (cause) => | |
| 83 | − new OnePasswordError({ | |
| 84 | − operation: "config load", | |
| 85 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 86 | − }), | |
| 87 | − ), | |
| 113 | + Effect.mapError(blobStorageError("read")), | |
| 88 | 114 | Effect.flatMap((raw) => { |
| 89 | 115 | if (raw === null) return Effect.succeed(null); |
| 90 | 116 | return Effect.try({ |
| @@ -108,26 +134,10 @@ export const makeOnePasswordStore = ( · 17 unmodified lines | ||
| 108 | 134 | name: config.name, |
| 109 | 135 | }), |
| 110 | 136 | ) |
| 111 | − .pipe( | |
| 112 | − Effect.mapError( | |
| 113 | − (cause) => | |
| 114 | − new OnePasswordError({ | |
| 115 | − operation: "config save", | |
| 116 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 117 | − }), | |
| 118 | − ), | |
| 119 | − ), | |
| 137 | + .pipe(Effect.mapError(blobStorageError("write"))), | |
| 120 | 138 | |
| 121 | 139 | deleteConfig: () => |
| 122 | − blobs.delete(CONFIG_KEY).pipe( | |
| 123 | − Effect.mapError( | |
| 124 | − (cause) => | |
| 125 | − new OnePasswordError({ | |
| 126 | − operation: "config delete", | |
| 127 | − message: cause instanceof Error ? cause.message : String(cause), | |
| 128 | − }), | |
| 129 | − ), | |
| 130 | − ), | |
| 140 | + blobs.delete(CONFIG_KEY).pipe(Effect.mapError(blobStorageError("delete"))), | |
| 131 | 141 | }); |
| 132 | 142 | |
| 133 | 143 | // --------------------------------------------------------------------------- |
| @@ -137,7 +147,7 @@ export const makeOnePasswordStore = ( · 3 unmodified lines | ||
| 137 | 147 | const resolveAuth = ( |
| 138 | 148 | auth: OnePasswordAuth, |
| 139 | 149 | ctx: PluginCtx<OnePasswordStore>, |
| 140 | −): Effect.Effect<ResolvedAuth, OnePasswordError> => { | |
| 150 | +): Effect.Effect<ResolvedAuth, OnePasswordError | StorageFailure> => { | |
| 141 | 151 | if (auth.kind === "desktop-app") { |
| 142 | 152 | return Effect.succeed({ |
| 143 | 153 | kind: "desktop-app" as const, |
| @@ -145,13 +155,6 @@ const resolveAuth = ( · 1 unmodified line | ||
| 145 | 155 | }); |
| 146 | 156 | } |
| 147 | 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 | 158 | Effect.flatMap((token) => { |
| 156 | 159 | if (token === null) { |
| 157 | 160 | return Effect.fail( |
| @@ -174,7 +177,7 @@ const getServiceFromConfig = ( · 16 unmodified lines | ||
| 174 | 177 | ctx: PluginCtx<OnePasswordStore>, |
| 175 | 178 | timeoutMs: number, |
| 176 | 179 | preferSdk: boolean | undefined, |
| 177 | −): Effect.Effect<OnePasswordService, OnePasswordError> => | |
| 180 | +): Effect.Effect<OnePasswordService, OnePasswordError | StorageFailure> => | |
| 178 | 181 | resolveAuth(config.auth, ctx).pipe( |
| 179 | 182 | Effect.flatMap((resolved) => |
| 180 | 183 | makeOnePasswordService(resolved, { timeoutMs, preferSdk }), |
| @@ -252,69 +255,71 @@ export const onepasswordPlugin = definePlugin( · 71 unmodified lines | ||
| 252 | 255 | id: "onepassword" as const, |
| 253 | 256 | storage: ({ blobs }) => makeOnePasswordStore(blobs), |
| 254 | 257 | |
| 255 | − extension: (ctx): OnePasswordExtension => ({ | |
| 256 | − configure: (config) => ctx.storage.saveConfig(config), | |
| 257 | − | |
| 258 | − getConfig: () => ctx.storage.getConfig(), | |
| 259 | − | |
| 260 | − removeConfig: () => ctx.storage.deleteConfig(), | |
| 261 | − | |
| 262 | − status: () => | |
| 263 | − Effect.gen(function* () { | |
| 264 | − const config = yield* ctx.storage.getConfig(); | |
| 265 | − if (!config) { | |
| 258 | + extension: (ctx) => { | |
| 259 | + return { | |
| 260 | + configure: (config) => ctx.storage.saveConfig(config), | |
| 261 | + | |
| 262 | + getConfig: () => ctx.storage.getConfig(), | |
| 263 | + | |
| 264 | + removeConfig: () => ctx.storage.deleteConfig(), | |
| 265 | + | |
| 266 | + status: () => | |
| 267 | + Effect.gen(function* () { | |
| 268 | + const config = yield* ctx.storage.getConfig(); | |
| 269 | + if (!config) { | |
| 270 | + return new ConnectionStatus({ | |
| 271 | + connected: false, | |
| 272 | + error: "Not configured", | |
| 273 | + }); | |
| 274 | + } | |
| 275 | + const svc = yield* getServiceFromConfig( | |
| 276 | + config, | |
| 277 | + ctx, | |
| 278 | + timeoutMs, | |
| 279 | + preferSdk, | |
| 280 | + ); | |
| 281 | + const vaults = yield* svc.listVaults(); | |
| 282 | + const vault = vaults.find((v) => v.id === config.vaultId); | |
| 266 | 283 | return new ConnectionStatus({ |
| 267 | − connected: false, | |
| 268 | − error: "Not configured", | |
| 284 | + connected: true, | |
| 285 | + vaultName: vault?.title, | |
| 269 | 286 | }); |
| 270 | − } | |
| 271 | − const svc = yield* getServiceFromConfig( | |
| 272 | − config, | |
| 273 | − ctx, | |
| 274 | − timeoutMs, | |
| 275 | − preferSdk, | |
| 276 | − ); | |
| 277 | − const vaults = yield* svc.listVaults(); | |
| 278 | − const vault = vaults.find((v) => v.id === config.vaultId); | |
| 279 | − return new ConnectionStatus({ | |
| 280 | − connected: true, | |
| 281 | − vaultName: vault?.title, | |
| 282 | − }); | |
| 283 | − }), | |
| 287 | + }), | |
| 284 | 288 | |
| 285 | − listVaults: (auth) => | |
| 286 | − Effect.gen(function* () { | |
| 287 | − const resolved = yield* resolveAuth(auth, ctx); | |
| 288 | − const svc = yield* makeOnePasswordService(resolved, { | |
| 289 | − timeoutMs, | |
| 290 | − preferSdk, | |
| 291 | − }); | |
| 292 | − const vaults = yield* svc.listVaults(); | |
| 293 | − return vaults | |
| 294 | − .map((v) => new Vault({ id: v.id, name: v.title })) | |
| 295 | − .sort((a, b) => a.name.localeCompare(b.name)); | |
| 296 | − }), | |
| 289 | + listVaults: (auth) => | |
| 290 | + Effect.gen(function* () { | |
| 291 | + const resolved = yield* resolveAuth(auth, ctx); | |
| 292 | + const svc = yield* makeOnePasswordService(resolved, { | |
| 293 | + timeoutMs, | |
| 294 | + preferSdk, | |
| 295 | + }); | |
| 296 | + const vaults = yield* svc.listVaults(); | |
| 297 | + return vaults | |
| 298 | + .map((v) => new Vault({ id: v.id, name: v.title })) | |
| 299 | + .sort((a, b) => a.name.localeCompare(b.name)); | |
| 300 | + }), | |
| 297 | 301 | |
| 298 | − resolve: (uri) => | |
| 299 | − Effect.gen(function* () { | |
| 300 | − const config = yield* ctx.storage.getConfig(); | |
| 301 | − if (!config) { | |
| 302 | − return yield* Effect.fail( | |
| 303 | − new OnePasswordError({ | |
| 304 | − operation: "resolve", | |
| 305 | − message: "1Password is not configured", | |
| 306 | − }), | |
| 302 | + resolve: (uri) => | |
| 303 | + Effect.gen(function* () { | |
| 304 | + const config = yield* ctx.storage.getConfig(); | |
| 305 | + if (!config) { | |
| 306 | + return yield* Effect.fail( | |
| 307 | + new OnePasswordError({ | |
| 308 | + operation: "resolve", | |
| 309 | + message: "1Password is not configured", | |
| 310 | + }), | |
| 311 | + ); | |
| 312 | + } | |
| 313 | + const svc = yield* getServiceFromConfig( | |
| 314 | + config, | |
| 315 | + ctx, | |
| 316 | + timeoutMs, | |
| 317 | + preferSdk, | |
| 307 | 318 | ); |
| 308 | − } | |
| 309 | − const svc = yield* getServiceFromConfig( | |
| 310 | − config, | |
| 311 | − ctx, | |
| 312 | − timeoutMs, | |
| 313 | − preferSdk, | |
| 314 | − ); | |
| 315 | − return yield* svc.resolveSecret(uri); | |
| 316 | − }), | |
| 317 | − }), | |
| 319 | + return yield* svc.resolveSecret(uri); | |
| 320 | + }), | |
| 321 | + } satisfies OnePasswordExtension; | |
| 322 | + }, | |
| 318 | 323 | |
| 319 | 324 | secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)], |
| 320 | 325 | }; |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ScopeId } from "@executor/sdk"; |
| 4 | +import { InternalError } from "@executor/api"; | |
| 4 | 5 | |
| 5 | 6 | import { |
| 6 | 7 | OpenApiParseError, |
| @@ -99,57 +100,52 @@ const ExtractionError = OpenApiExtractionError.annotations( · 92 unmodified lines | ||
| 99 | 100 | ); |
| 100 | 101 | const OAuthError = OpenApiOAuthError.annotations(HttpApiSchema.annotations({ status: 400 })); |
| 101 | 102 | |
| 102 | −export class OpenApiInternalError extends Schema.TaggedError<OpenApiInternalError>()( | |
| 103 | − "OpenApiInternalError", | |
| 104 | − { | |
| 105 | − message: Schema.String, | |
| 106 | − }, | |
| 107 | − HttpApiSchema.annotations({ status: 500 }), | |
| 108 | −) {} | |
| 109 | − | |
| 110 | 103 | // --------------------------------------------------------------------------- |
| 111 | 104 | // 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 | |
| 114 | 120 | export class OpenApiGroup extends HttpApiGroup.make("openapi") |
| 115 | 121 | .add( |
| 116 | 122 | HttpApiEndpoint.post("previewSpec")`/scopes/${scopeIdParam}/openapi/preview` |
| 117 | 123 | .setPayload(PreviewSpecPayload) |
| 118 | − .addSuccess(SpecPreview) | |
| 119 | − .addError(ParseError) | |
| 120 | − .addError(ExtractionError) | |
| 121 | − .addError(OpenApiInternalError), | |
| 124 | + .addSuccess(SpecPreview), | |
| 122 | 125 | ) |
| 123 | 126 | .add( |
| 124 | 127 | HttpApiEndpoint.post("addSpec")`/scopes/${scopeIdParam}/openapi/specs` |
| 125 | 128 | .setPayload(AddSpecPayload) |
| 126 | − .addSuccess(AddSpecResponse) | |
| 127 | − .addError(ParseError) | |
| 128 | − .addError(ExtractionError) | |
| 129 | − .addError(OpenApiInternalError), | |
| 129 | + .addSuccess(AddSpecResponse), | |
| 130 | 130 | ) |
| 131 | 131 | .add( |
| 132 | 132 | HttpApiEndpoint.get("getSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` |
| 133 | − .addSuccess(Schema.NullOr(StoredSourceSchema)) | |
| 134 | − .addError(OpenApiInternalError), | |
| 133 | + .addSuccess(Schema.NullOr(StoredSourceSchema)), | |
| 135 | 134 | ) |
| 136 | 135 | .add( |
| 137 | 136 | HttpApiEndpoint.patch("updateSource")`/scopes/${scopeIdParam}/openapi/sources/${namespaceParam}` |
| 138 | 137 | .setPayload(UpdateSourcePayload) |
| 139 | − .addSuccess(UpdateSourceResponse) | |
| 140 | − .addError(OpenApiInternalError), | |
| 138 | + .addSuccess(UpdateSourceResponse), | |
| 141 | 139 | ) |
| 142 | 140 | .add( |
| 143 | 141 | HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/openapi/oauth/start` |
| 144 | 142 | .setPayload(StartOAuthPayload) |
| 145 | − .addSuccess(StartOAuthResponse) | |
| 146 | − .addError(OAuthError), | |
| 143 | + .addSuccess(StartOAuthResponse), | |
| 147 | 144 | ) |
| 148 | 145 | .add( |
| 149 | 146 | HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/openapi/oauth/complete` |
| 150 | 147 | .setPayload(CompleteOAuthPayload) |
| 151 | − .addSuccess(OAuth2Auth) | |
| 152 | − .addError(OAuthError), | |
| 148 | + .addSuccess(OAuth2Auth), | |
| 153 | 149 | ) |
| 154 | 150 | .add( |
| 155 | 151 | HttpApiEndpoint.get("oauthCallback", "/openapi/oauth/callback") |
| @@ -158,7 +154,13 @@ export class OpenApiGroup extends HttpApiGroup.make("openapi") · 2 unmodified lines | ||
| 158 | 154 | Schema.Unknown.annotations( |
| 159 | 155 | HttpApiSchema.annotations({ contentType: "text/html" }), |
| 160 | 156 | ), |
| 161 | − ) | |
| 162 | − .addError(OAuthError) | |
| 163 | − .addError(OpenApiInternalError), | |
| 164 | − ) {} | |
| 157 | + ), | |
| 158 | + ) | |
| 159 | + // Errors declared once at the group level — every endpoint inherits. | |
| 160 | + // Plugin domain errors carry their own HttpApiSchema status (4xx); | |
| 161 | + // `InternalError` is the shared opaque 500 translated at the HTTP | |
| 162 | + // edge by `withCapture`. | |
| 163 | + .addError(InternalError) | |
| 164 | + .addError(ParseError) | |
| 165 | + .addError(ExtractionError) | |
| 166 | + .addError(OAuthError) {} | |
| @@ -1,21 +1,17 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | −import { Cause, Context, Effect } from "effect"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; |
| 5 | 5 | |
| 6 | −import { addGroup } from "@executor/api"; | |
| 7 | −import { | |
| 8 | − OpenApiExtractionError, | |
| 9 | − OpenApiOAuthError, | |
| 10 | − OpenApiParseError, | |
| 11 | −} from "../sdk/errors"; | |
| 6 | +import { addGroup, capture, InternalError } from "@executor/api"; | |
| 7 | +import { OpenApiOAuthError } from "../sdk/errors"; | |
| 12 | 8 | import type { |
| 13 | 9 | OpenApiPluginExtension, |
| 14 | 10 | HeaderValue, |
| 15 | 11 | OpenApiUpdateSourceInput, |
| 16 | 12 | } from "../sdk/plugin"; |
| 17 | 13 | import { OAuth2Auth } from "../sdk/types"; |
| 18 | −import { OpenApiGroup, OpenApiInternalError } from "./group"; | |
| 14 | +import { OpenApiGroup } from "./group"; | |
| 19 | 15 | |
| 20 | 16 | const 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 extension | |
| 24 | +// Service tag | |
| 25 | +// | |
| 26 | +// Holds the `Captured` shape — every method's `StorageFailure` | |
| 27 | +// channel has been swapped for `InternalError({ traceId })`. The cloud | |
| 28 | +// app provides an already-wrapped extension via | |
| 29 | +// `Layer.succeed(OpenApiExtensionService, withCapture(executor.openapi))`. | |
| 30 | +// Handlers see `InternalError` in the error union, which matches | |
| 31 | +// `.addError(InternalError)` on the group — no per-handler translation. | |
| 29 | 32 | // --------------------------------------------------------------------------- |
| 30 | 33 | |
| 31 | 34 | export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionService")< |
| @@ -33,42 +36,6 @@ export class OpenApiExtensionService extends Context.Tag("OpenApiExtensionServic · 1 unmodified line | ||
| 33 | 36 | OpenApiPluginExtension |
| 34 | 37 | >() {} |
| 35 | 38 | |
| 36 | −// --------------------------------------------------------------------------- | |
| 37 | −// Failure mapping | |
| 38 | −// --------------------------------------------------------------------------- | |
| 39 | − | |
| 40 | −type OpenApiSpecFailure = OpenApiParseError | OpenApiExtractionError | OpenApiInternalError; | |
| 41 | − | |
| 42 | −const toOpenApiSpecFailure = (error: unknown): OpenApiSpecFailure => { | |
| 43 | − if ( | |
| 44 | − error instanceof OpenApiParseError || | |
| 45 | − error instanceof OpenApiExtractionError || | |
| 46 | − error instanceof OpenApiInternalError | |
| 47 | − ) { | |
| 48 | − return error; | |
| 49 | − } | |
| 50 | − const message = error instanceof Error ? error.message : String(error); | |
| 51 | − return new OpenApiInternalError({ message }); | |
| 52 | −}; | |
| 53 | − | |
| 54 | −const sanitizeSpecFailure = <A, R>( | |
| 55 | − effect: Effect.Effect<A, unknown, R>, | |
| 56 | −): Effect.Effect<A, OpenApiSpecFailure, R> => | |
| 57 | − Effect.catchAllCause(effect, (cause) => Effect.fail(toOpenApiSpecFailure(Cause.squash(cause)))); | |
| 58 | − | |
| 59 | −const toOpenApiInternalError = (error: unknown): OpenApiInternalError => { | |
| 60 | − if (error instanceof OpenApiInternalError) return error; | |
| 61 | − const message = error instanceof Error ? error.message : String(error); | |
| 62 | − return new OpenApiInternalError({ message }); | |
| 63 | −}; | |
| 64 | − | |
| 65 | −const sanitizeInternalFailure = <A, R>( | |
| 66 | − effect: Effect.Effect<A, unknown, R>, | |
| 67 | −): Effect.Effect<A, OpenApiInternalError, R> => | |
| 68 | − Effect.catchAllCause(effect, (cause) => | |
| 69 | − Effect.fail(toOpenApiInternalError(Cause.squash(cause))), | |
| 70 | − ); | |
| 71 | − | |
| 72 | 39 | // --------------------------------------------------------------------------- |
| 73 | 40 | // Composed API — core + openapi group |
| 74 | 41 | // --------------------------------------------------------------------------- |
| @@ -77,18 +44,24 @@ const ExecutorApiWithOpenApi = addGroup(OpenApiGroup); · 2 unmodified lines | ||
| 77 | 44 | |
| 78 | 45 | // --------------------------------------------------------------------------- |
| 79 | 46 | // 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 | |
| 82 | 55 | export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "openapi", (handlers) => |
| 83 | 56 | handlers |
| 84 | 57 | .handle("previewSpec", ({ payload }) => |
| 85 | − Effect.gen(function* () { | |
| 58 | + capture(Effect.gen(function* () { | |
| 86 | 59 | const ext = yield* OpenApiExtensionService; |
| 87 | 60 | return yield* ext.previewSpec(payload.spec); |
| 88 | − }).pipe(sanitizeSpecFailure), | |
| 61 | + })), | |
| 89 | 62 | ) |
| 90 | 63 | .handle("addSpec", ({ payload }) => |
| 91 | − Effect.gen(function* () { | |
| 64 | + capture(Effect.gen(function* () { | |
| 92 | 65 | const ext = yield* OpenApiExtensionService; |
| 93 | 66 | const result = yield* ext.addSpec({ |
| 94 | 67 | spec: payload.spec, |
| @@ -102,16 +75,16 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 7 unmodified lines | ||
| 102 | 75 | toolCount: result.toolCount, |
| 103 | 76 | namespace: result.sourceId, |
| 104 | 77 | }; |
| 105 | − }).pipe(sanitizeSpecFailure), | |
| 78 | + })), | |
| 106 | 79 | ) |
| 107 | 80 | .handle("getSource", ({ path }) => |
| 108 | − Effect.gen(function* () { | |
| 81 | + capture(Effect.gen(function* () { | |
| 109 | 82 | const ext = yield* OpenApiExtensionService; |
| 110 | 83 | return yield* ext.getSource(path.namespace); |
| 111 | − }).pipe(sanitizeInternalFailure), | |
| 84 | + })), | |
| 112 | 85 | ) |
| 113 | 86 | .handle("updateSource", ({ path, payload }) => |
| 114 | − Effect.gen(function* () { | |
| 87 | + capture(Effect.gen(function* () { | |
| 115 | 88 | const ext = yield* OpenApiExtensionService; |
| 116 | 89 | yield* ext.updateSource(path.namespace, { |
| 117 | 90 | name: payload.name, |
| @@ -119,10 +92,10 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 1 unmodified line | ||
| 119 | 92 | headers: payload.headers as Record<string, HeaderValue> | undefined, |
| 120 | 93 | } as OpenApiUpdateSourceInput); |
| 121 | 94 | return { updated: true }; |
| 122 | − }).pipe(sanitizeInternalFailure), | |
| 95 | + })), | |
| 123 | 96 | ) |
| 124 | 97 | .handle("startOAuth", ({ payload }) => |
| 125 | − Effect.gen(function* () { | |
| 98 | + capture(Effect.gen(function* () { | |
| 126 | 99 | const ext = yield* OpenApiExtensionService; |
| 127 | 100 | return yield* ext.startOAuth({ |
| 128 | 101 | displayName: payload.displayName, |
| @@ -135,22 +108,25 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 6 unmodified lines | ||
| 135 | 108 | clientSecretSecretId: payload.clientSecretSecretId ?? null, |
| 136 | 109 | scopes: [...payload.scopes], |
| 137 | 110 | }); |
| 138 | − }), | |
| 111 | + })), | |
| 139 | 112 | ) |
| 140 | 113 | .handle("completeOAuth", ({ payload }) => |
| 141 | − Effect.gen(function* () { | |
| 114 | + capture(Effect.gen(function* () { | |
| 142 | 115 | const ext = yield* OpenApiExtensionService; |
| 143 | 116 | return yield* ext.completeOAuth({ |
| 144 | 117 | state: payload.state, |
| 145 | 118 | code: payload.code, |
| 146 | 119 | error: payload.error, |
| 147 | 120 | }); |
| 148 | − }), | |
| 121 | + })), | |
| 149 | 122 | ) |
| 150 | 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 | 128 | const ext = yield* OpenApiExtensionService; |
| 153 | − const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError, never>({ | |
| 129 | + const html = yield* runOAuthCallback<OAuth2Auth, OpenApiOAuthError | InternalError, never>({ | |
| 154 | 130 | complete: ({ state, code, error }) => |
| 155 | 131 | ext.completeOAuth({ |
| 156 | 132 | state, |
| @@ -162,6 +138,6 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 5 unmodified lines | ||
| 162 | 138 | channelName: OPENAPI_OAUTH_CHANNEL, |
| 163 | 139 | }); |
| 164 | 140 | return yield* HttpServerResponse.html(html); |
| 165 | − }).pipe(sanitizeInternalFailure), | |
| 141 | + })), | |
| 166 | 142 | ), |
| 167 | 143 | ); |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { Effect, Layer, Option } from "effect"; |
| 2 | 2 | import { HttpClient, HttpClientRequest } from "@effect/platform"; |
| 3 | 3 | |
| 4 | +import type { StorageFailure } from "@executor/sdk"; | |
| 5 | + | |
| 4 | 6 | import { OpenApiInvocationError } from "./errors"; |
| 5 | 7 | import { |
| 6 | 8 | type HeaderValue, |
| @@ -92,8 +94,15 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 85 unmodified lines | ||
| 92 | 94 | |
| 93 | 95 | export const resolveHeaders = ( |
| 94 | 96 | headers: Record<string, HeaderValue>, |
| 95 | − secrets: { readonly get: (id: string) => Effect.Effect<string | null, Error> }, | |
| 96 | −): Effect.Effect<Record<string, string>, Error> => | |
| 97 | + secrets: { | |
| 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 | 106 | Effect.gen(function* () { |
| 98 | 107 | const entries = Object.entries(headers); |
| 99 | 108 | // Fan out secret lookups: on every invocation, one or two headers |
| @@ -108,9 +117,10 @@ export const resolveHeaders = ( · 8 unmodified lines | ||
| 108 | 117 | Effect.flatMap((secret) => |
| 109 | 118 | secret === null |
| 110 | 119 | ? Effect.fail( |
| 111 | − new Error( | |
| 112 | − `Failed to resolve secret "${value.secretId}" for header "${name}"`, | |
| 113 | − ), | |
| 120 | + new OpenApiInvocationError({ | |
| 121 | + message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, | |
| 122 | + statusCode: Option.none(), | |
| 123 | + }), | |
| 114 | 124 | ) |
| 115 | 125 | : Effect.succeed({ |
| 116 | 126 | name, |
| @@ -209,12 +219,23 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 92 unmodified lines | ||
| 209 | 219 | const responseHeaders: Record<string, string> = { ...response.headers }; |
| 210 | 220 | |
| 211 | 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 | 230 | const responseBody: unknown = |
| 213 | 231 | status === 204 |
| 214 | 232 | ? null |
| 215 | 233 | : isJsonContentType(contentType) |
| 216 | − ? yield* response.json.pipe(Effect.catchAll(() => response.text)) | |
| 217 | − : yield* response.text; | |
| 234 | + ? yield* response.json.pipe( | |
| 235 | + Effect.catchAll(() => response.text), | |
| 236 | + mapBodyError, | |
| 237 | + ) | |
| 238 | + : yield* response.text.pipe(mapBodyError); | |
| 218 | 239 | |
| 219 | 240 | const ok = status >= 200 && status < 300; |
| 220 | 241 | |
| @@ -236,7 +257,7 @@ export const invokeWithLayer = ( · 15 unmodified lines | ||
| 236 | 257 | baseUrl: string, |
| 237 | 258 | resolvedHeaders: Record<string, string>, |
| 238 | 259 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, |
| 239 | −): Effect.Effect<InvocationResult, Error> => { | |
| 260 | +) => { | |
| 240 | 261 | const clientWithBaseUrl = baseUrl |
| 241 | 262 | ? Layer.effect( |
| 242 | 263 | HttpClient.HttpClient, |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | SetSecretInput, |
| 19 | 19 | SourceDetectionResult, |
| 20 | 20 | definePlugin, |
| 21 | + type StorageFailure, | |
| 21 | 22 | type ToolAnnotations, |
| 22 | 23 | type ToolRow, |
| 23 | 24 | } from "@executor/sdk"; |
| @@ -28,7 +29,11 @@ import { · 4 unmodified lines | ||
| 28 | 29 | type OpenApiSourceConfig, |
| 29 | 30 | } from "@executor/config"; |
| 30 | 31 | |
| 31 | −import { OpenApiOAuthError } from "./errors"; | |
| 32 | +import { | |
| 33 | + OpenApiExtractionError, | |
| 34 | + OpenApiOAuthError, | |
| 35 | + OpenApiParseError, | |
| 36 | +} from "./errors"; | |
| 32 | 37 | import { parse, resolveSpecText } from "./parse"; |
| 33 | 38 | import { extract } from "./extract"; |
| 34 | 39 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; |
| @@ -105,17 +110,40 @@ export interface OpenApiCompleteOAuthInput { · 70 unmodified lines | ||
| 105 | 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 | + */ | |
| 123 | +export type OpenApiExtensionFailure = | |
| 124 | + | OpenApiParseError | |
| 125 | + | OpenApiExtractionError | |
| 126 | + | OpenApiOAuthError | |
| 127 | + | StorageFailure; | |
| 128 | + | |
| 108 | 129 | export interface OpenApiPluginExtension { |
| 109 | − readonly previewSpec: (specText: string) => Effect.Effect<SpecPreview, Error>; | |
| 130 | + readonly previewSpec: ( | |
| 131 | + specText: string, | |
| 132 | + ) => Effect.Effect<SpecPreview, OpenApiParseError | OpenApiExtractionError>; | |
| 110 | 133 | readonly addSpec: ( |
| 111 | 134 | config: OpenApiSpecConfig, |
| 112 | − ) => Effect.Effect<{ readonly sourceId: string; readonly toolCount: number }, Error>; | |
| 113 | − readonly removeSpec: (namespace: string) => Effect.Effect<void, Error>; | |
| 114 | − readonly getSource: (namespace: string) => Effect.Effect<StoredSource | null, Error>; | |
| 135 | + ) => Effect.Effect< | |
| 136 | + { readonly sourceId: string; readonly toolCount: number }, | |
| 137 | + OpenApiParseError | OpenApiExtractionError | StorageFailure | |
| 138 | + >; | |
| 139 | + readonly removeSpec: (namespace: string) => Effect.Effect<void, StorageFailure>; | |
| 140 | + readonly getSource: ( | |
| 141 | + namespace: string, | |
| 142 | + ) => Effect.Effect<StoredSource | null, StorageFailure>; | |
| 115 | 143 | readonly updateSource: ( |
| 116 | 144 | namespace: string, |
| 117 | 145 | input: OpenApiUpdateSourceInput, |
| 118 | − ) => Effect.Effect<void, Error>; | |
| 146 | + ) => Effect.Effect<void, StorageFailure>; | |
| 119 | 147 | readonly startOAuth: ( |
| 120 | 148 | input: OpenApiStartOAuthInput, |
| 121 | 149 | ) => Effect.Effect<OpenApiStartOAuthResponse, OpenApiOAuthError>; |
| @@ -222,7 +250,7 @@ export const openApiPlugin = definePlugin( · 100 unmodified lines | ||
| 222 | 250 | schema: openapiSchema, |
| 223 | 251 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), |
| 224 | 252 | |
| 225 | − extension: (ctx): OpenApiPluginExtension => { | |
| 253 | + extension: (ctx) => { | |
| 226 | 254 | // Wraps ctx.secrets.set for the oauth2 helper so createSecret |
| 227 | 255 | // returns the freshly-minted id. |
| 228 | 256 | const createOAuthSecret = (args: { |
| @@ -523,7 +551,7 @@ export const openApiPlugin = definePlugin( · 294 unmodified lines | ||
| 523 | 551 | : new OpenApiOAuthError({ message: err.message }), |
| 524 | 552 | ), |
| 525 | 553 | ), |
| 526 | − }; | |
| 554 | + } satisfies OpenApiPluginExtension; | |
| 527 | 555 | }, |
| 528 | 556 | |
| 529 | 557 | staticSources: (self) => [ |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { Effect, Option, Schema } from "effect"; |
| 2 | 2 | |
| 3 | −import { defineSchema, type StorageDeps } from "@executor/sdk"; | |
| 3 | +import { | |
| 4 | + defineSchema, | |
| 5 | + type StorageDeps, | |
| 6 | + type StorageFailure, | |
| 7 | +} from "@executor/sdk"; | |
| 4 | 8 | |
| 5 | 9 | import { |
| 6 | 10 | HeaderValue, |
| @@ -134,11 +138,15 @@ const decodeHeaders = (value: unknown): Record<string, HeaderValue> => { · 127 unmodified lines | ||
| 134 | 138 | // 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. | |
| 137 | 145 | export interface OpenapiStore { |
| 138 | 146 | readonly upsertSource: ( |
| 139 | 147 | input: StoredSource, |
| 140 | 148 | operations: readonly StoredOperation[], |
| 141 | − ) => Effect.Effect<void, Error>; | |
| 149 | + ) => Effect.Effect<void, StorageFailure>; | |
| 142 | 150 | |
| 143 | 151 | readonly updateSourceMeta: ( |
| 144 | 152 | namespace: string, |
| @@ -148,34 +156,34 @@ export interface OpenapiStore { · 3 unmodified lines | ||
| 148 | 156 | readonly headers?: Record<string, HeaderValue>; |
| 149 | 157 | readonly oauth2?: OAuth2Auth; |
| 150 | 158 | }, |
| 151 | − ) => Effect.Effect<void, Error>; | |
| 159 | + ) => Effect.Effect<void, StorageFailure>; | |
| 152 | 160 | |
| 153 | 161 | readonly getSource: ( |
| 154 | 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 | 167 | readonly getOperationByToolId: ( |
| 160 | 168 | toolId: string, |
| 161 | − ) => Effect.Effect<StoredOperation | null, Error>; | |
| 169 | + ) => Effect.Effect<StoredOperation | null, StorageFailure>; | |
| 162 | 170 | |
| 163 | 171 | readonly listOperationsBySource: ( |
| 164 | 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 | 177 | readonly putOAuthSession: ( |
| 170 | 178 | sessionId: string, |
| 171 | 179 | session: OpenApiOAuthSession, |
| 172 | − ) => Effect.Effect<void, Error>; | |
| 180 | + ) => Effect.Effect<void, StorageFailure>; | |
| 173 | 181 | |
| 174 | 182 | readonly getOAuthSession: ( |
| 175 | 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 | // --------------------------------------------------------------------------- |
| @@ -3,8 +3,10 @@ import { GenericServerException, NotFoundException } from "@workos-inc/node/work · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | defineSchema, |
| 6 | + StorageError, | |
| 6 | 7 | type SecretProvider, |
| 7 | 8 | type StorageDeps, |
| 9 | + type StorageFailure, | |
| 8 | 10 | } from "@executor/sdk"; |
| 9 | 11 | |
| 10 | 12 | import { |
| @@ -50,10 +52,10 @@ interface MetadataRow { · 39 unmodified lines | ||
| 50 | 52 | // --------------------------------------------------------------------------- |
| 51 | 53 | |
| 52 | 54 | export interface WorkosVaultStore { |
| 53 | − readonly get: (id: string) => Effect.Effect<MetadataRow | null, Error>; | |
| 54 | − readonly upsert: (row: MetadataRow) => Effect.Effect<void, Error>; | |
| 55 | − readonly remove: (id: string) => Effect.Effect<boolean, Error>; | |
| 56 | − readonly list: () => Effect.Effect<readonly MetadataRow[], Error>; | |
| 55 | + readonly get: (id: string) => Effect.Effect<MetadataRow | null, StorageFailure>; | |
| 56 | + readonly upsert: (row: MetadataRow) => Effect.Effect<void, StorageFailure>; | |
| 57 | + readonly remove: (id: string) => Effect.Effect<boolean, StorageFailure>; | |
| 58 | + readonly list: () => Effect.Effect<readonly MetadataRow[], StorageFailure>; | |
| 57 | 59 | } |
| 58 | 60 | |
| 59 | 61 | export const makeWorkosVaultStore = ( |
| @@ -215,9 +217,10 @@ const deleteSecretValue = ( · 155 unmodified lines | ||
| 215 | 217 | return true; |
| 216 | 218 | }); |
| 217 | 219 | |
| 218 | −const formatVaultError = (error: unknown): Error => { | |
| 220 | +const formatVaultError = (error: unknown): StorageError => { | |
| 219 | 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 | // --------------------------------------------------------------------------- |