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