- cloud: resolves userId from the authenticated session and builds a [userOrgScope, orgScope] stack via createScopedExecutor(userId, orgId, orgName). userId threaded through execution-stack, mcp-session, and MCP dispatch init. - cloud env refactor: deletes the lazy Proxy apps/cloud/src/env.ts in favor of cloudflare:workers env imported at call sites; adds env-augment.d.ts to type secrets not present in generated wrangler types. - cloud/server.ts: env-guarded OTEL wiring (skips instrument() when AXIOM_TOKEN absent); captures rawFetch before instrument() mutates it so dev requests still land on the Hono app. - cloud/services/db.ts: prefers DATABASE_URL over the hyperdrive proxy so local dev works without the tunnel. - vite.config.ts: devCrashGuard plugin that prints uncaught Worker errors with a stable exit. - hosts/mcp: optional debug flag surfaces verbose session logging, wired up from cloud mcp-session. - local/examples/marketing: migrated to scopes: [scope].
Unified · Split
| @@ -1,9 +1,9 @@ | ||
| 1 | +import { env } from "cloudflare:workers"; | |
| 1 | 2 | import { Effect } from "effect"; |
| 2 | 3 | import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; |
| 3 | 4 | import { autumnHandler } from "autumn-js/backend"; |
| 4 | 5 | |
| 5 | 6 | import { WorkOSAuth } from "../auth/workos"; |
| 6 | −import { server } from "../env"; | |
| 7 | 7 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; |
| 8 | 8 | import { SharedServices } from "./layers"; |
| 9 | 9 | |
| @@ -59,7 +59,7 @@ export const AutumnApiApp = Effect.gen(function* () { · 49 unmodified lines | ||
| 59 | 59 | email: session.email, |
| 60 | 60 | }, |
| 61 | 61 | clientOptions: { |
| 62 | − secretKey: server.AUTUMN_SECRET_KEY, | |
| 62 | + secretKey: env.AUTUMN_SECRET_KEY ?? "", | |
| 63 | 63 | }, |
| 64 | 64 | pathPrefix: "/autumn", |
| 65 | 65 | }), |
| @@ -27,12 +27,18 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 26 unmodified lines | ||
| 27 | 27 | const session = yield* workos.authenticateRequest(webRequest); |
| 28 | 28 | if (!session || !session.organizationId) return null; |
| 29 | 29 | |
| 30 | − return yield* authorizeOrganization(session.userId, session.organizationId); | |
| 30 | + const org = yield* authorizeOrganization(session.userId, session.organizationId); | |
| 31 | + if (!org) return null; | |
| 32 | + return { org, userId: session.userId }; | |
| 31 | 33 | }); |
| 32 | 34 | |
| 33 | −const createProtectedApp = (organizationId: string, organizationName: string) => | |
| 35 | +const createProtectedApp = (userId: string, organizationId: string, organizationName: string) => | |
| 34 | 36 | Effect.gen(function* () { |
| 35 | − const { executor, engine } = yield* makeExecutionStack(organizationId, organizationName); | |
| 37 | + const { executor, engine } = yield* makeExecutionStack( | |
| 38 | + userId, | |
| 39 | + organizationId, | |
| 40 | + organizationName, | |
| 41 | + ); | |
| 36 | 42 | |
| 37 | 43 | const requestServices = Layer.mergeAll( |
| 38 | 44 | Layer.succeed(ExecutorService, executor), |
| @@ -58,8 +64,8 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 19 unmodified lines | ||
| 58 | 64 | |
| 59 | 65 | export const ProtectedApiApp = Effect.gen(function* () { |
| 60 | 66 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 61 | − const org = yield* lookupOrgForRequest(request); | |
| 62 | − if (!org) { | |
| 67 | + const session = yield* lookupOrgForRequest(request); | |
| 68 | + if (!session) { | |
| 63 | 69 | return yield* Effect.fail( |
| 64 | 70 | new HttpResponseError({ |
| 65 | 71 | status: 403, |
| @@ -69,7 +75,7 @@ export const ProtectedApiApp = Effect.gen(function* () { · 3 unmodified lines | ||
| 69 | 75 | ); |
| 70 | 76 | } |
| 71 | 77 | |
| 72 | − const app = yield* createProtectedApp(org.id, org.name); | |
| 78 | + const app = yield* createProtectedApp(session.userId, session.org.id, session.org.name); | |
| 73 | 79 | return yield* app; |
| 74 | 80 | }).pipe( |
| 75 | 81 | Effect.provide(SharedServices), |
| @@ -6,9 +6,9 @@ import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; · 5 unmodified lines | ||
| 6 | 6 | import { SessionContext } from "./middleware"; |
| 7 | 7 | import { UserStoreService } from "./context"; |
| 8 | 8 | import { authorizeOrganization } from "./authorize-organization"; |
| 9 | +import { env } from "cloudflare:workers"; | |
| 9 | 10 | import { WorkOSError } from "./errors"; |
| 10 | 11 | import { WorkOSAuth } from "./workos"; |
| 11 | −import { server } from "../env"; | |
| 12 | 12 | |
| 13 | 13 | const COOKIE_OPTIONS = { |
| 14 | 14 | path: "/", |
| @@ -40,7 +40,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 25 unmodified lines | ||
| 40 | 40 | // Use the explicit public site URL — in dev, the request's Host |
| 41 | 41 | // header points at the internal proxy target, not the public URL |
| 42 | 42 | // WorkOS needs to redirect back to. |
| 43 | − const origin = server.VITE_PUBLIC_SITE_URL; | |
| 43 | + const origin = env.VITE_PUBLIC_SITE_URL ?? ""; | |
| 44 | 44 | const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`); |
| 45 | 45 | return HttpServerResponse.redirect(url, { status: 302 }); |
| 46 | 46 | }), |
| @@ -2,10 +2,10 @@ · 1 unmodified line | ||
| 2 | 2 | // WorkOS AuthKit — Effect-native sealed session management |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | +import { env } from "cloudflare:workers"; | |
| 5 | 6 | import { Context, Effect, Layer } from "effect"; |
| 6 | 7 | import { WorkOS } from "@workos-inc/node/worker"; |
| 7 | 8 | import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; |
| 8 | −import { server } from "../env"; | |
| 9 | 9 | |
| 10 | 10 | const COOKIE_NAME = "wos-session"; |
| 11 | 11 | |
| @@ -14,9 +14,9 @@ const COOKIE_NAME = "wos-session"; · 2 unmodified lines | ||
| 14 | 14 | // --------------------------------------------------------------------------- |
| 15 | 15 | |
| 16 | 16 | const make = Effect.gen(function* () { |
| 17 | − const apiKey = server.WORKOS_API_KEY; | |
| 18 | − const clientId = server.WORKOS_CLIENT_ID; | |
| 19 | − const cookiePassword = server.WORKOS_COOKIE_PASSWORD; | |
| 17 | + const apiKey = env.WORKOS_API_KEY; | |
| 18 | + const clientId = env.WORKOS_CLIENT_ID; | |
| 19 | + const cookiePassword = env.WORKOS_COOKIE_PASSWORD; | |
| 20 | 20 | |
| 21 | 21 | if (!cookiePassword || cookiePassword.length < 32) { |
| 22 | 22 | return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters")); |
| @@ -0,0 +1,33 @@ | ||
| 1 | +// Augment the wrangler-generated `Cloudflare.Env` with secrets / vars set at | |
| 2 | +// deploy time (via `wrangler secret put`, dashboard, or `.dev.vars`) that | |
| 3 | +// don't show up in `wrangler types` output because they aren't declared in | |
| 4 | +// wrangler.jsonc, but are what `env.X` resolves to at runtime. | |
| 5 | +declare global { | |
| 6 | + namespace Cloudflare { | |
| 7 | + interface Env { | |
| 8 | + // Observability | |
| 9 | + AXIOM_TOKEN?: string; | |
| 10 | + AXIOM_DATASET?: string; | |
| 11 | + AXIOM_TRACES_URL?: string; | |
| 12 | + AXIOM_TRACES_SAMPLE_RATIO?: string; | |
| 13 | + SENTRY_DSN?: string; | |
| 14 | + | |
| 15 | + // Datastore (dev only — prod uses HYPERDRIVE binding) | |
| 16 | + DATABASE_URL?: string; | |
| 17 | + | |
| 18 | + // Billing | |
| 19 | + AUTUMN_SECRET_KEY?: string; | |
| 20 | + | |
| 21 | + // MCP | |
| 22 | + MCP_SESSION_REQUEST_SCOPED_RUNTIME?: string; | |
| 23 | + EXECUTOR_MCP_DEBUG?: string; | |
| 24 | + MCP_AUTHKIT_DOMAIN?: string; | |
| 25 | + MCP_RESOURCE_ORIGIN?: string; | |
| 26 | + | |
| 27 | + // Shared with frontend | |
| 28 | + VITE_PUBLIC_SITE_URL?: string; | |
| 29 | + } | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +export {}; | |
| @@ -1,91 +0,0 @@ | ||
| 1 | −// --------------------------------------------------------------------------- | |
| 2 | −// Env accessors for apps/cloud. | |
| 3 | −// --------------------------------------------------------------------------- | |
| 4 | −// | |
| 5 | −// `server` / `shared` are lazy Proxies over `cloudflare:workers` `env`, with | |
| 6 | −// a `process.env` fallback for the node-pool tests (which stub | |
| 7 | −// `cloudflare:workers` to an empty `env` and rely on vitest to populate | |
| 8 | −// `process.env`). Reads happen at the call site, not at module load, so | |
| 9 | −// secrets land correctly in both the edge isolate and the Durable Object | |
| 10 | −// isolate — the latter previously captured empty strings at env.ts | |
| 11 | −// module-load time and silently broke `DoTelemetryLive`, `AutumnService` | |
| 12 | −// billing writes, and likely other DO-reachable secret readers. | |
| 13 | −// | |
| 14 | −// Types below mirror the prior `createEnv(...)`-derived shapes so every | |
| 15 | −// caller (`server.X`) keeps the same typed surface. | |
| 16 | −// --------------------------------------------------------------------------- | |
| 17 | − | |
| 18 | −import { env } from "cloudflare:workers"; | |
| 19 | − | |
| 20 | −type SharedEnv = Readonly<{ | |
| 21 | − NODE_ENV: "development" | "test" | "production"; | |
| 22 | −}>; | |
| 23 | − | |
| 24 | −type ServerEnv = SharedEnv & | |
| 25 | − Readonly<{ | |
| 26 | − DATABASE_URL: string; | |
| 27 | − MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false" | "true"; | |
| 28 | − WORKOS_API_KEY: string; | |
| 29 | − WORKOS_CLIENT_ID: string; | |
| 30 | − WORKOS_COOKIE_PASSWORD: string; | |
| 31 | − VITE_PUBLIC_SITE_URL: string; | |
| 32 | − MCP_AUTHKIT_DOMAIN: string; | |
| 33 | − MCP_RESOURCE_ORIGIN: string; | |
| 34 | − AUTUMN_SECRET_KEY: string; | |
| 35 | − SENTRY_DSN: string; | |
| 36 | − AXIOM_TOKEN: string; | |
| 37 | − AXIOM_DATASET: string; | |
| 38 | − AXIOM_TRACES_URL: string; | |
| 39 | − AXIOM_TRACES_SAMPLE_RATIO: string; | |
| 40 | − }>; | |
| 41 | − | |
| 42 | −type WebEnv = Readonly<Record<string, never>>; | |
| 43 | − | |
| 44 | −const SERVER_DEFAULTS: Record<keyof ServerEnv, string> = { | |
| 45 | − NODE_ENV: "development", | |
| 46 | − DATABASE_URL: "", | |
| 47 | − MCP_SESSION_REQUEST_SCOPED_RUNTIME: "false", | |
| 48 | − WORKOS_API_KEY: "", | |
| 49 | − WORKOS_CLIENT_ID: "", | |
| 50 | − WORKOS_COOKIE_PASSWORD: "", | |
| 51 | − VITE_PUBLIC_SITE_URL: "", | |
| 52 | − MCP_AUTHKIT_DOMAIN: "https://signin.executor.sh", | |
| 53 | − MCP_RESOURCE_ORIGIN: "https://executor.sh", | |
| 54 | − AUTUMN_SECRET_KEY: "", | |
| 55 | − SENTRY_DSN: "", | |
| 56 | − AXIOM_TOKEN: "", | |
| 57 | − AXIOM_DATASET: "executor-cloud", | |
| 58 | − AXIOM_TRACES_URL: "https://api.axiom.co/v1/traces", | |
| 59 | − AXIOM_TRACES_SAMPLE_RATIO: "1", | |
| 60 | −}; | |
| 61 | − | |
| 62 | −const SHARED_DEFAULTS: Record<keyof SharedEnv, string> = { | |
| 63 | − NODE_ENV: "development", | |
| 64 | −}; | |
| 65 | − | |
| 66 | −const readFromWorkerEnv = (key: string): string | undefined => { | |
| 67 | − const bag = env as unknown as Record<string, unknown>; | |
| 68 | − const v = bag[key]; | |
| 69 | − return typeof v === "string" && v.length > 0 ? v : undefined; | |
| 70 | −}; | |
| 71 | − | |
| 72 | −const readFromProcessEnv = (key: string): string | undefined => { | |
| 73 | − if (typeof process === "undefined" || !process.env) return undefined; | |
| 74 | − const v = process.env[key]; | |
| 75 | − return typeof v === "string" && v.length > 0 ? v : undefined; | |
| 76 | −}; | |
| 77 | − | |
| 78 | −const read = (key: string): string | undefined => | |
| 79 | − readFromWorkerEnv(key) ?? readFromProcessEnv(key); | |
| 80 | − | |
| 81 | −const makeEnvProxy = <T extends object>(defaults: Record<string, string>): T => | |
| 82 | − new Proxy({} as T, { | |
| 83 | − get: (_target, key) => { | |
| 84 | − if (typeof key !== "string") return undefined; | |
| 85 | − return read(key) ?? defaults[key] ?? ""; | |
| 86 | − }, | |
| 87 | − }); | |
| 88 | − | |
| 89 | −export const shared: SharedEnv = makeEnvProxy<SharedEnv>(SHARED_DEFAULTS); | |
| 90 | −export const server: ServerEnv = makeEnvProxy<ServerEnv>(SERVER_DEFAULTS); | |
| 91 | −export const web: WebEnv = {} as WebEnv; | |
| @@ -122,7 +122,7 @@ const buildScopedExecutor = ( · 121 unmodified lines | ||
| 122 | 122 | name: scopeName, |
| 123 | 123 | createdAt: new Date(), |
| 124 | 124 | }); |
| 125 | − return yield* createExecutor({ scope, adapter, blobs, plugins }); | |
| 125 | + return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins }); | |
| 126 | 126 | }); |
| 127 | 127 | |
| 128 | 128 | // Builds a scope, wires a real execution engine + MCP server, and yields |
| @@ -25,7 +25,6 @@ import type { DrizzleDb, DbServiceShape } from "./services/db"; · 24 unmodified lines | ||
| 25 | 25 | import { CoreSharedServices } from "./api/core-shared-services"; |
| 26 | 26 | import { UserStoreService } from "./auth/context"; |
| 27 | 27 | import { resolveOrganization } from "./auth/resolve-organization"; |
| 28 | −import { server } from "./env"; | |
| 29 | 28 | import { DbService, combinedSchema } from "./services/db"; |
| 30 | 29 | import { makeExecutionStack } from "./services/execution-stack"; |
| 31 | 30 | import { DoTelemetryLive } from "./services/telemetry"; |
| @@ -36,6 +35,7 @@ import { DoTelemetryLive } from "./services/telemetry"; · 4 unmodified lines | ||
| 36 | 35 | |
| 37 | 36 | export type McpSessionInit = { |
| 38 | 37 | organizationId: string; |
| 38 | + userId: string; | |
| 39 | 39 | }; |
| 40 | 40 | |
| 41 | 41 | export type IncomingTraceHeaders = { |
| @@ -109,6 +109,7 @@ type DbHandle = DbServiceShape & { end: () => Promise<void> }; · 67 unmodified lines | ||
| 109 | 109 | type SessionMeta = { |
| 110 | 110 | readonly organizationId: string; |
| 111 | 111 | readonly organizationName: string; |
| 112 | + readonly userId: string; | |
| 112 | 113 | }; |
| 113 | 114 | |
| 114 | 115 | /** |
| @@ -124,7 +125,12 @@ const makeDbHandle = (options: { · 9 unmodified lines | ||
| 124 | 125 | readonly idleTimeout: number; |
| 125 | 126 | readonly maxLifetime: number; |
| 126 | 127 | }): DbHandle => { |
| 127 | − const connectionString = env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL; | |
| 128 | + // Prefer DATABASE_URL so local dev connects directly to PGlite and | |
| 129 | + // bypasses Miniflare's Hyperdrive proxy, which resolves to | |
| 130 | + // `*.hyperdrive.local:5432` and times out — the resulting ECONNRESET | |
| 131 | + // surfaces as an unhandled socket error that kills the vite dev server. | |
| 132 | + // Matches the priority in `services/db.ts`. | |
| 133 | + const connectionString = env.DATABASE_URL || env.HYPERDRIVE?.connectionString || ""; | |
| 128 | 134 | const sql = postgres(connectionString, { |
| 129 | 135 | max: 1, |
| 130 | 136 | idle_timeout: options.idleTimeout, |
| @@ -159,6 +165,7 @@ const makeSessionServices = (dbHandle: DbHandle) => · 28 unmodified lines | ||
| 159 | 165 | |
| 160 | 166 | const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( |
| 161 | 167 | organizationId: string, |
| 168 | + userId: string, | |
| 162 | 169 | ) { |
| 163 | 170 | const org = yield* resolveOrganization(organizationId); |
| 164 | 171 | if (!org) { |
| @@ -167,10 +174,11 @@ const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function · 2 unmodified lines | ||
| 167 | 174 | return { |
| 168 | 175 | organizationId: org.id, |
| 169 | 176 | organizationName: org.name, |
| 177 | + userId, | |
| 170 | 178 | } satisfies SessionMeta; |
| 171 | 179 | }); |
| 172 | 180 | |
| 173 | −const requestScopedRuntimeEnabled = server.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true"; | |
| 181 | +const requestScopedRuntimeEnabled = env.MCP_SESSION_REQUEST_SCOPED_RUNTIME === "true"; | |
| 174 | 182 | |
| 175 | 183 | // --------------------------------------------------------------------------- |
| 176 | 184 | // Durable Object |
| @@ -238,6 +246,7 @@ export class McpSessionDO extends DurableObject { · 61 unmodified lines | ||
| 238 | 246 | const self = this; |
| 239 | 247 | return Effect.gen(function* () { |
| 240 | 248 | const { executor, engine } = yield* makeExecutionStack( |
| 249 | + sessionMeta.userId, | |
| 241 | 250 | sessionMeta.organizationId, |
| 242 | 251 | sessionMeta.organizationName, |
| 243 | 252 | ); |
| @@ -251,6 +260,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines | ||
| 251 | 260 | engine, |
| 252 | 261 | description, |
| 253 | 262 | parentSpan: () => self.currentRequestSpan ?? undefined, |
| 263 | + debug: env.EXECUTOR_MCP_DEBUG === "true", | |
| 254 | 264 | }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); |
| 255 | 265 | const transport = new WorkerTransport({ |
| 256 | 266 | sessionIdGenerator: () => self.ctx.id.toString(), |
| @@ -272,9 +282,10 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines | ||
| 272 | 282 | return Effect.gen(function* () { |
| 273 | 283 | const dbHandle = makeRequestScopedDb(); |
| 274 | 284 | try { |
| 275 | − const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe( | |
| 276 | − Effect.provide(makeResolveOrganizationServices(dbHandle)), | |
| 277 | − ); | |
| 285 | + const sessionMeta = yield* resolveSessionMeta( | |
| 286 | + token.organizationId, | |
| 287 | + token.userId, | |
| 288 | + ).pipe(Effect.provide(makeResolveOrganizationServices(dbHandle))); | |
| 278 | 289 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( |
| 279 | 290 | Effect.withSpan("mcp.session.save_meta"), |
| 280 | 291 | ); |
| @@ -20,15 +20,14 @@ import * as Sentry from "@sentry/cloudflare"; · 19 unmodified lines | ||
| 20 | 20 | import { Context, Effect, Layer, Option, Schema } from "effect"; |
| 21 | 21 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 22 | 22 | |
| 23 | −import { server } from "./env"; | |
| 24 | 23 | import { TelemetryLive } from "./services/telemetry"; |
| 25 | 24 | |
| 26 | 25 | // --------------------------------------------------------------------------- |
| 27 | 26 | // Constants |
| 28 | 27 | // --------------------------------------------------------------------------- |
| 29 | 28 | |
| 30 | −const AUTHKIT_DOMAIN = server.MCP_AUTHKIT_DOMAIN; | |
| 31 | −const RESOURCE_ORIGIN = server.MCP_RESOURCE_ORIGIN; | |
| 29 | +const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh"; | |
| 30 | +const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh"; | |
| 32 | 31 | |
| 33 | 32 | const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); |
| 34 | 33 | |
| @@ -526,7 +525,9 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 491 unmodified lines | ||
| 526 | 525 | const ns = env.MCP_SESSION; |
| 527 | 526 | const stub = ns.get(ns.newUniqueId()); |
| 528 | 527 | const propagation = yield* currentPropagationHeaders(request); |
| 529 | − yield* Effect.promise(() => stub.init({ organizationId }, propagation)); | |
| 528 | + yield* Effect.promise(() => | |
| 529 | + stub.init({ organizationId, userId: token.accountId }, propagation), | |
| 530 | + ); | |
| 530 | 531 | const propagated = withPropagationHeaders(request, propagation); |
| 531 | 532 | const raw = yield* Effect.promise( |
| 532 | 533 | () => stub.handleRequest(propagated) as Promise<Response>, |
| @@ -21,6 +21,9 @@ export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( · 20 unmodified lines | ||
| 21 | 21 | captureException: (cause) => |
| 22 | 22 | Effect.sync(() => { |
| 23 | 23 | const squashed = Cause.squash(cause); |
| 24 | + // Mirror to console so dev terminals see the defect; Sentry gets the | |
| 25 | + // full structured cause for production correlation. | |
| 26 | + console.error("[api] unhandled cause:", Cause.pretty(cause)); | |
| 24 | 27 | const eventId = Sentry.captureException(squashed, (scope) => { |
| 25 | 28 | scope.setExtra("cause", Cause.pretty(cause)); |
| 26 | 29 | return scope; |
| @@ -3,8 +3,8 @@ import { Effect } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { UserStoreService } from "../auth/context"; |
| 5 | 5 | import { AuthContext } from "../auth/middleware"; |
| 6 | +import { env } from "cloudflare:workers"; | |
| 6 | 7 | import { WorkOSAuth } from "../auth/workos"; |
| 7 | −import { server } from "../env"; | |
| 8 | 8 | import { AutumnService } from "../services/autumn"; |
| 9 | 9 | import { OrgHttpApi } from "./compose"; |
| 10 | 10 | import { Forbidden } from "./api"; |
| @@ -144,7 +144,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 133 unmodified lines | ||
| 144 | 144 | const workos = yield* WorkOSAuth; |
| 145 | 145 | const { link } = yield* workos.generateDomainVerificationPortalLink( |
| 146 | 146 | auth.organizationId, |
| 147 | − server.VITE_PUBLIC_SITE_URL ? `${server.VITE_PUBLIC_SITE_URL}/org` : "/org", | |
| 147 | + env.VITE_PUBLIC_SITE_URL ? `${env.VITE_PUBLIC_SITE_URL}/org` : "/org", | |
| 148 | 148 | ); |
| 149 | 149 | return { link }; |
| 150 | 150 | }), |
| @@ -3,7 +3,6 @@ import handler from "@tanstack/react-start/server-entry"; · 2 unmodified lines | ||
| 3 | 3 | import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers"; |
| 4 | 4 | |
| 5 | 5 | import { McpSessionDO as McpSessionDOBase } from "./mcp-session"; |
| 6 | −import { server } from "./env"; | |
| 7 | 6 | |
| 8 | 7 | // --------------------------------------------------------------------------- |
| 9 | 8 | // OTEL config for the main fetch handler — `otel-cf-workers` owns the global |
| @@ -15,40 +14,36 @@ import { server } from "./env"; · 5 unmodified lines | ||
| 15 | 14 | // DOMException "Illegal invocation". |
| 16 | 15 | // --------------------------------------------------------------------------- |
| 17 | 16 | |
| 18 | −const parseSampleRatio = (value: string): number => { | |
| 17 | +const parseSampleRatio = (value: string | undefined): number => { | |
| 19 | 18 | const n = Number(value); |
| 20 | 19 | if (!Number.isFinite(n)) return 1; |
| 21 | 20 | return Math.min(1, Math.max(0, n)); |
| 22 | 21 | }; |
| 23 | 22 | |
| 24 | −const otelConfig: TraceConfig = { | |
| 23 | +const otelConfig = (env: Env): TraceConfig => ({ | |
| 25 | 24 | service: { name: "executor-cloud", version: "1.0.0" }, |
| 26 | 25 | exporter: { |
| 27 | − url: server.AXIOM_TRACES_URL, | |
| 26 | + url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", | |
| 28 | 27 | headers: { |
| 29 | − Authorization: `Bearer ${server.AXIOM_TOKEN}`, | |
| 30 | − "X-Axiom-Dataset": server.AXIOM_DATASET, | |
| 28 | + Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`, | |
| 29 | + "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud", | |
| 31 | 30 | }, |
| 32 | 31 | }, |
| 33 | 32 | sampling: { |
| 34 | 33 | headSampler: { |
| 35 | 34 | // Keep remote parent decisions and make local sampling policy explicit. |
| 36 | 35 | acceptRemote: true, |
| 37 | − ratio: parseSampleRatio(server.AXIOM_TRACES_SAMPLE_RATIO), | |
| 36 | + ratio: parseSampleRatio(env.AXIOM_TRACES_SAMPLE_RATIO), | |
| 38 | 37 | }, |
| 39 | 38 | }, |
| 40 | −}; | |
| 39 | +}); | |
| 41 | 40 | |
| 42 | 41 | // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim |
| 43 | 42 | // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers |
| 44 | 43 | // from finding its WorkerTracer and breaks the whole request path with |
| 45 | 44 | // "global tracer is not of type WorkerTracer". |
| 46 | −// | |
| 47 | −// The `_env` parameter is unused — `server` from `./env` already gives us | |
| 48 | −// typed access to every secret. It's only in the signature so Sentry's | |
| 49 | −// generics infer the DO's `Env` type correctly. | |
| 50 | −const sentryOptions = (_env: Env) => ({ | |
| 51 | − dsn: server.SENTRY_DSN, | |
| 45 | +const sentryOptions = (env: Env) => ({ | |
| 46 | + dsn: env.SENTRY_DSN, | |
| 52 | 47 | tracesSampleRate: 0, |
| 53 | 48 | enableLogs: true, |
| 54 | 49 | sendDefaultPii: true, |
| @@ -76,6 +71,28 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( · 21 unmodified lines | ||
| 76 | 71 | // Worker fetch handler |
| 77 | 72 | // --------------------------------------------------------------------------- |
| 78 | 73 | |
| 79 | −const instrumentedHandler = instrument({ fetch: handler.fetch }, otelConfig); | |
| 74 | +// Skip OTLP wiring when no Axiom token is configured (dev without secrets). | |
| 75 | +// Otherwise the exporter ships every span with `Bearer ` (empty), which | |
| 76 | +// returns 401 on every batch and eventually drops the keep-alive socket — | |
| 77 | +// the Node http agent's unhandled `'error'` then crashes the process with | |
| 78 | +// ECONNRESET. It also registers otel-cf-workers' `WorkerTracer` as the | |
| 79 | +// global tracer; spans started outside its config ALS then die with | |
| 80 | +// "Config is undefined". Matches the gate in `DoTelemetryLive`. | |
| 81 | +// `instrument()` mutates the handler it's given (replaces `.fetch` with the | |
| 82 | +// proxied version), so capture the raw fetch first and then build the | |
| 83 | +// instrumented handler from a separate object. | |
| 84 | +const rawFetch = handler.fetch; | |
| 85 | +const instrumentedHandler = instrument({ fetch: rawFetch }, otelConfig); | |
| 86 | + | |
| 87 | +const dispatchHandler = { | |
| 88 | + fetch: (request: Request, env: Env, ctx: unknown) => { | |
| 89 | + const fn = env.AXIOM_TOKEN ? instrumentedHandler.fetch! : rawFetch; | |
| 90 | + return (fn as (req: Request, env: Env, ctx: unknown) => Response | Promise<Response>)( | |
| 91 | + request, | |
| 92 | + env, | |
| 93 | + ctx, | |
| 94 | + ); | |
| 95 | + }, | |
| 96 | +}; | |
| 80 | 97 | |
| 81 | −export default Sentry.withSentry(sentryOptions, instrumentedHandler); | |
| 98 | +export default Sentry.withSentry(sentryOptions, dispatchHandler); | |
| @@ -181,7 +181,7 @@ const createTestScopedExecutor = (scopeId: string, scopeName: string) => · 180 unmodified lines | ||
| 181 | 181 | name: scopeName, |
| 182 | 182 | createdAt: new Date(), |
| 183 | 183 | }); |
| 184 | − return yield* createExecutor({ scope, adapter, blobs, plugins }); | |
| 184 | + return yield* createExecutor({ scopes: [scope], adapter, blobs, plugins }); | |
| 185 | 185 | }); |
| 186 | 186 | |
| 187 | 187 | // --------------------------------------------------------------------------- |
| @@ -2,12 +2,11 @@ · 1 unmodified line | ||
| 2 | 2 | // Autumn billing service — wraps the autumn-js SDK with Effect |
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | |
| 5 | +import { env } from "cloudflare:workers"; | |
| 5 | 6 | import * as Sentry from "@sentry/cloudflare"; |
| 6 | 7 | import { Autumn } from "autumn-js"; |
| 7 | 8 | import { Context, Data, Effect, Layer } from "effect"; |
| 8 | 9 | |
| 9 | −import { server } from "../env"; | |
| 10 | − | |
| 11 | 10 | // --------------------------------------------------------------------------- |
| 12 | 11 | // Errors |
| 13 | 12 | // --------------------------------------------------------------------------- |
| @@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines | ||
| 36 | 35 | // --------------------------------------------------------------------------- |
| 37 | 36 | |
| 38 | 37 | const make = Effect.sync(() => { |
| 39 | − const secretKey = server.AUTUMN_SECRET_KEY; | |
| 38 | + const secretKey = env.AUTUMN_SECRET_KEY; | |
| 40 | 39 | |
| 41 | 40 | if (!secretKey) { |
| 42 | 41 | const notConfigured = Effect.die( |
| @@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines | ||
| 18 | 18 | import postgres, { type Sql } from "postgres"; |
| 19 | 19 | import * as cloudSchema from "./schema"; |
| 20 | 20 | import * as executorSchema from "./executor-schema"; |
| 21 | −import { server } from "../env"; | |
| 22 | 21 | |
| 23 | 22 | // Exported so every drizzle() call in the cloud app shares one schema |
| 24 | 23 | // object. Historically `mcp-session.ts` built its own and forgot to spread |
| @@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines | ||
| 38 | 37 | // In local dev prefer an explicit DATABASE_URL (direct connection to |
| 39 | 38 | // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy. |
| 40 | 39 | // In production fall back to the Hyperdrive binding. |
| 41 | − if (server.DATABASE_URL) { | |
| 42 | − return server.DATABASE_URL; | |
| 43 | − } | |
| 44 | − return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL; | |
| 40 | + return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || ""; | |
| 45 | 41 | }; |
| 46 | 42 | |
| 47 | 43 | const makeSql = (): Sql => |
| @@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines | ||
| 14 | 14 | import { AutumnService } from "./autumn"; |
| 15 | 15 | import { createScopedExecutor } from "./executor"; |
| 16 | 16 | |
| 17 | −export const makeExecutionStack = (organizationId: string, organizationName: string) => | |
| 17 | +export const makeExecutionStack = ( | |
| 18 | + userId: string, | |
| 19 | + organizationId: string, | |
| 20 | + organizationName: string, | |
| 21 | +) => | |
| 18 | 22 | Effect.gen(function* () { |
| 19 | − const executor = yield* createScopedExecutor(organizationId, organizationName).pipe( | |
| 20 | − Effect.withSpan("McpSessionDO.createScopedExecutor"), | |
| 21 | − ); | |
| 23 | + const executor = yield* createScopedExecutor( | |
| 24 | + userId, | |
| 25 | + organizationId, | |
| 26 | + organizationName, | |
| 27 | + ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor")); | |
| 22 | 28 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); |
| 23 | 29 | const autumn = yield* AutumnService; |
| 24 | 30 | const engine = withExecutionUsageTracking( |
| @@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines | ||
| 25 | 25 | import { graphqlPlugin } from "@executor/plugin-graphql"; |
| 26 | 26 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; |
| 27 | 27 | |
| 28 | +import { env } from "cloudflare:workers"; | |
| 28 | 29 | import { DbService } from "./db"; |
| 29 | −import { server } from "../env"; | |
| 30 | 30 | |
| 31 | 31 | // --------------------------------------------------------------------------- |
| 32 | 32 | // Plugin list — one place, used for both the runtime and the CLI config |
| @@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines | ||
| 45 | 45 | graphqlPlugin(), |
| 46 | 46 | workosVaultPlugin({ |
| 47 | 47 | credentials: { |
| 48 | − apiKey: server.WORKOS_API_KEY, | |
| 49 | − clientId: server.WORKOS_CLIENT_ID, | |
| 48 | + apiKey: env.WORKOS_API_KEY, | |
| 49 | + clientId: env.WORKOS_CLIENT_ID, | |
| 50 | 50 | }, |
| 51 | 51 | }), |
| 52 | 52 | ] as const; |
| 53 | 53 | |
| 54 | 54 | // --------------------------------------------------------------------------- |
| 55 | −// Create a fresh executor for a scope (stateless, per-request). | |
| 55 | +// Create a fresh executor for a (user, org) pair (stateless, per-request). | |
| 56 | 56 | // |
| 57 | −// Today "scope" is the WorkOS organization — orgs are the only scope | |
| 58 | −// level the cloud app exposes. When workspace / user scopes land, this | |
| 59 | −// function grows to accept a `ScopeStack` instead of a single scope id, | |
| 60 | −// and nothing downstream (executor, plugins, storage) has to change. | |
| 57 | +// Scope stack is `[userOrgScope, orgScope]` — innermost first. The | |
| 58 | +// user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally | |
| 59 | +// includes the org id so the same WorkOS user in a different org gets a | |
| 60 | +// distinct scope row; future workspace scopes can slot in between without | |
| 61 | +// conflicting with a hypothetical global user scope. | |
| 62 | +// | |
| 63 | +// OAuth tokens land at `ctx.scopes[0]` (the user-org scope) by default, so | |
| 64 | +// a member's access/refresh tokens can't leak to other members via | |
| 65 | +// `secrets.list`, while source rows and org-wide credentials live on the | |
| 66 | +// outer scope. | |
| 61 | 67 | // --------------------------------------------------------------------------- |
| 62 | 68 | |
| 63 | 69 | export const createScopedExecutor = ( |
| 64 | − scopeId: string, | |
| 65 | − scopeName: string, | |
| 70 | + userId: string, | |
| 71 | + organizationId: string, | |
| 72 | + organizationName: string, | |
| 66 | 73 | ) => |
| 67 | 74 | Effect.gen(function* () { |
| 68 | 75 | const { db } = yield* DbService; |
| @@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines | ||
| 72 | 79 | const adapter = makePostgresAdapter({ db, schema }); |
| 73 | 80 | const blobs = makePostgresBlobStore({ db }); |
| 74 | 81 | |
| 75 | − const scope = new Scope({ | |
| 76 | − id: ScopeId.make(scopeId), | |
| 77 | − name: scopeName, | |
| 82 | + const orgScope = new Scope({ | |
| 83 | + id: ScopeId.make(organizationId), | |
| 84 | + name: organizationName, | |
| 85 | + createdAt: new Date(), | |
| 86 | + }); | |
| 87 | + const userOrgScope = new Scope({ | |
| 88 | + id: ScopeId.make(`user-org:${userId}:${organizationId}`), | |
| 89 | + name: `Personal · ${organizationName}`, | |
| 78 | 90 | createdAt: new Date(), |
| 79 | 91 | }); |
| 80 | 92 | |
| @@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line | ||
| 82 | 94 | // the opaque `InternalError({ traceId })` happens at the HTTP edge |
| 83 | 95 | // via `withCapture` (see `api/protected-layers.ts`). That's |
| 84 | 96 | // where `ErrorCaptureLive` (Sentry) gets wired in. |
| 85 | − return yield* createExecutor({ scope, adapter, blobs, plugins }); | |
| 97 | + return yield* createExecutor({ | |
| 98 | + scopes: [userOrgScope, orgScope], | |
| 99 | + adapter, | |
| 100 | + blobs, | |
| 101 | + plugins, | |
| 102 | + }); | |
| 86 | 103 | }); |
| @@ -53,10 +53,9 @@ import { · 52 unmodified lines | ||
| 53 | 53 | ATTR_SERVICE_NAME, |
| 54 | 54 | ATTR_SERVICE_VERSION, |
| 55 | 55 | } from "@opentelemetry/semantic-conventions"; |
| 56 | +import { env } from "cloudflare:workers"; | |
| 56 | 57 | import { Effect, Layer } from "effect"; |
| 57 | 58 | |
| 58 | −import { server } from "../env"; | |
| 59 | − | |
| 60 | 59 | const SERVICE_NAME = "executor-cloud"; |
| 61 | 60 | const SERVICE_VERSION = "1.0.0"; |
| 62 | 61 | |
| @@ -71,7 +70,7 @@ export const TelemetryLive: Layer.Layer<never> = OtelTracer.layerGlobal.pipe( · 8 unmodified lines | ||
| 71 | 70 | let installed = false; |
| 72 | 71 | const ensureGlobalTracerProvider = (): boolean => { |
| 73 | 72 | if (installed) return true; |
| 74 | − if (!server.AXIOM_TOKEN) return false; | |
| 73 | + if (!env.AXIOM_TOKEN) return false; | |
| 75 | 74 | const provider = new WebTracerProvider({ |
| 76 | 75 | resource: resourceFromAttributes({ |
| 77 | 76 | [ATTR_SERVICE_NAME]: SERVICE_NAME, |
| @@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines | ||
| 80 | 79 | spanProcessors: [ |
| 81 | 80 | new SimpleSpanProcessor( |
| 82 | 81 | new OTLPTraceExporter({ |
| 83 | − url: server.AXIOM_TRACES_URL, | |
| 82 | + url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", | |
| 84 | 83 | headers: { |
| 85 | − Authorization: `Bearer ${server.AXIOM_TOKEN}`, | |
| 86 | − "X-Axiom-Dataset": server.AXIOM_DATASET, | |
| 84 | + Authorization: `Bearer ${env.AXIOM_TOKEN}`, | |
| 85 | + "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud", | |
| 87 | 86 | }, |
| 88 | 87 | }), |
| 89 | 88 | ), |
| @@ -1,12 +1,40 @@ | ||
| 1 | −import { defineConfig } from "vite"; | |
| 1 | +import { defineConfig, type Plugin } from "vite"; | |
| 2 | 2 | import { cloudflare } from "@cloudflare/vite-plugin"; |
| 3 | 3 | import { tanstackStart } from "@tanstack/react-start/plugin/vite"; |
| 4 | 4 | import react from "@vitejs/plugin-react"; |
| 5 | 5 | import tailwindcss from "@tailwindcss/vite"; |
| 6 | 6 | |
| 7 | +// Dev-only: the cloudflare vite-plugin bridges outbound fetches (JWKS, | |
| 8 | +// OAuth metadata proxy, etc.) through node undici in the host process. If | |
| 9 | +// a pooled keep-alive socket gets RST'd while no listener is attached, the | |
| 10 | +// `'error'` emit is unhandled and tears down the whole dev server. Log | |
| 11 | +// enough to identify the offender and keep the server alive. | |
| 12 | +const devCrashGuard = (): Plugin => { | |
| 13 | + let installed = false; | |
| 14 | + const install = () => { | |
| 15 | + if (installed) return; | |
| 16 | + installed = true; | |
| 17 | + process.on("uncaughtException", (err, origin) => { | |
| 18 | + console.error( | |
| 19 | + `[dev-crash-guard] uncaughtException (origin=${origin}):`, | |
| 20 | + err, | |
| 21 | + ); | |
| 22 | + }); | |
| 23 | + process.on("unhandledRejection", (reason, promise) => { | |
| 24 | + console.error("[dev-crash-guard] unhandledRejection:", reason, promise); | |
| 25 | + }); | |
| 26 | + }; | |
| 27 | + return { | |
| 28 | + name: "dev-crash-guard", | |
| 29 | + apply: "serve", | |
| 30 | + configureServer: install, | |
| 31 | + }; | |
| 32 | +}; | |
| 33 | + | |
| 7 | 34 | export default defineConfig({ |
| 8 | 35 | resolve: { tsconfigPaths: true }, |
| 9 | 36 | plugins: [ |
| 37 | + devCrashGuard(), | |
| 10 | 38 | tailwindcss(), |
| 11 | 39 | cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), |
| 12 | 40 | tanstackStart(), |
| @@ -84,10 +84,16 @@ const addSourceFromConfig = ( · 83 unmodified lines | ||
| 84 | 84 | executor: LocalExecutor, |
| 85 | 85 | source: SourceConfig, |
| 86 | 86 | ): Effect.Effect<void, unknown> => { |
| 87 | + // `executor.jsonc` is a single-scope artifact today — the file isn't | |
| 88 | + // aware of per-user tenancy. Pin replayed sources to the outermost | |
| 89 | + // scope so a future `[user, org]` stack still sees them via org | |
| 90 | + // fall-through. | |
| 91 | + const scope = executor.scopes.at(-1)!.id as string; | |
| 87 | 92 | switch (source.kind) { |
| 88 | 93 | case "openapi": |
| 89 | 94 | return executor.openapi.addSpec({ |
| 90 | 95 | spec: source.spec, |
| 96 | + scope, | |
| 91 | 97 | baseUrl: source.baseUrl, |
| 92 | 98 | namespace: source.namespace, |
| 93 | 99 | headers: translateHeaders(source.headers), |
| @@ -96,6 +102,7 @@ const addSourceFromConfig = ( · 2 unmodified lines | ||
| 96 | 102 | case "graphql": |
| 97 | 103 | return executor.graphql.addSource({ |
| 98 | 104 | endpoint: source.endpoint, |
| 105 | + scope, | |
| 99 | 106 | namespace: source.namespace, |
| 100 | 107 | headers: translateHeaders(source.headers) as Record<string, string> | undefined, |
| 101 | 108 | }).pipe(Effect.asVoid); |
| @@ -104,6 +111,7 @@ const addSourceFromConfig = ( · 2 unmodified lines | ||
| 104 | 111 | if (source.transport === "stdio") { |
| 105 | 112 | return executor.mcp.addSource({ |
| 106 | 113 | transport: "stdio", |
| 114 | + scope, | |
| 107 | 115 | name: source.name, |
| 108 | 116 | command: source.command, |
| 109 | 117 | args: source.args ? [...source.args] : undefined, |
| @@ -114,6 +122,7 @@ const addSourceFromConfig = ( · 4 unmodified lines | ||
| 114 | 122 | } |
| 115 | 123 | return executor.mcp.addSource({ |
| 116 | 124 | transport: "remote", |
| 125 | + scope, | |
| 117 | 126 | name: source.name, |
| 118 | 127 | endpoint: source.endpoint, |
| 119 | 128 | remoteTransport: source.remoteTransport, |
| @@ -154,7 +154,7 @@ const createLocalExecutorLayer = () => { · 153 unmodified lines | ||
| 154 | 154 | }); |
| 155 | 155 | |
| 156 | 156 | const executor = yield* createExecutor({ |
| 157 | − scope, | |
| 157 | + scopes: [scope], | |
| 158 | 158 | adapter, |
| 159 | 159 | blobs, |
| 160 | 160 | plugins, |
| @@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request }) => { · 82 unmodified lines | ||
| 83 | 83 | yield* executor.openapi.addSpec({ |
| 84 | 84 | spec: match.endpoint, |
| 85 | 85 | namespace: match.namespace, |
| 86 | + scope: "test-scope", | |
| 86 | 87 | }); |
| 87 | 88 | } else if (match.kind === "graphql") { |
| 88 | 89 | yield* executor.graphql.addSource({ |
| 89 | 90 | endpoint: match.endpoint, |
| 90 | 91 | namespace: match.namespace, |
| 92 | + scope: "test-scope", | |
| 91 | 93 | }); |
| 92 | 94 | } else { |
| 93 | 95 | // For kinds we can't fully add (e.g. Google Discovery needs auth), |
| @@ -83,7 +83,7 @@ const plugins = [ · 82 unmodified lines | ||
| 83 | 83 | ] as const; |
| 84 | 84 | |
| 85 | 85 | const config = { |
| 86 | − scope, | |
| 86 | + scopes: [scope], | |
| 87 | 87 | adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }), |
| 88 | 88 | blobs: makeInMemoryBlobStore(), |
| 89 | 89 | plugins, |
| @@ -208,6 +208,7 @@ const program = Effect.gen(function* () { · 118 unmodified lines | ||
| 208 | 208 | yield* executor.secrets.set( |
| 209 | 209 | new SetSecretInput({ |
| 210 | 210 | id: SecretId.make("example-api-token"), |
| 211 | + scope: "example-scope" as SetSecretInput["scope"], | |
| 211 | 212 | name: "Example API Token", |
| 212 | 213 | value: "sk-example-redacted", |
| 213 | 214 | provider: "file", |
| @@ -262,6 +263,7 @@ const program = Effect.gen(function* () { · 48 unmodified lines | ||
| 262 | 263 | spec: exampleOpenApiSpec, |
| 263 | 264 | namespace: "example-api", |
| 264 | 265 | baseUrl: "https://example.com/api", |
| 266 | + scope: "example-scope", | |
| 265 | 267 | }); |
| 266 | 268 | console.log("Registered OpenAPI source:", addSpecResult); |
| 267 | 269 | |
| @@ -371,6 +373,7 @@ const program = Effect.gen(function* () { · 103 unmodified lines | ||
| 371 | 373 | endpoint: "https://example.com/graphql", |
| 372 | 374 | introspectionJson, |
| 373 | 375 | namespace: "example-graphql", |
| 376 | + scope: "example-scope", | |
| 374 | 377 | }); |
| 375 | 378 | console.log("Registered GraphQL source:", gqlResult); |
| 376 | 379 | |
| @@ -12,7 +12,7 @@ import { graphqlPlugin } from "@executor/plugin-graphql/promise"; · 11 unmodified lines | ||
| 12 | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | |
| 14 | 14 | const executor = await createExecutor({ |
| 15 | − scope: { name: "my-app" }, | |
| 15 | + scopes: [{ id: "my-app", name: "my-app" }], | |
| 16 | 16 | plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const, |
| 17 | 17 | }); |
| 18 | 18 | |
| @@ -24,6 +24,7 @@ await executor.mcp.addSource({ · 5 unmodified lines | ||
| 24 | 24 | transport: "remote", |
| 25 | 25 | name: "Context7", |
| 26 | 26 | endpoint: "https://mcp.context7.com/mcp", |
| 27 | + scope: "my-app", | |
| 27 | 28 | }); |
| 28 | 29 | |
| 29 | 30 | // Stdio server |
| @@ -41,6 +42,7 @@ await executor.mcp.addSource({ · 11 unmodified lines | ||
| 41 | 42 | await executor.openapi.addSpec({ |
| 42 | 43 | spec: "https://petstore3.swagger.io/api/v3/openapi.json", |
| 43 | 44 | namespace: "petstore", |
| 45 | + scope: "my-app", | |
| 44 | 46 | }); |
| 45 | 47 | |
| 46 | 48 | // With auth headers (static or secret-backed) |
| @@ -62,6 +64,7 @@ await executor.openapi.addSpec({ · 15 unmodified lines | ||
| 62 | 64 | await executor.graphql.addSource({ |
| 63 | 65 | endpoint: "https://graphql.anilist.co", |
| 64 | 66 | namespace: "anilist", |
| 67 | + scope: "my-app", | |
| 65 | 68 | }); |
| 66 | 69 | |
| 67 | 70 | // --------------------------------------------------------------------------- |
| @@ -97,6 +100,7 @@ if (anilistTool) { · 29 unmodified lines | ||
| 97 | 100 | await executor.secrets.set( |
| 98 | 101 | new SetSecretInput({ |
| 99 | 102 | id: SecretId.make("api-key"), |
| 103 | + scope: "my-app" as SetSecretInput["scope"], | |
| 100 | 104 | name: "Shared API Key", |
| 101 | 105 | value: "sk_...", |
| 102 | 106 | }), |
| @@ -66,6 +66,10 @@ type SharedMcpServerConfig = { · 65 unmodified lines | ||
| 66 | 66 | * under whichever request triggered it; see the Cloud DO). |
| 67 | 67 | */ |
| 68 | 68 | readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); |
| 69 | + /** | |
| 70 | + * Enable verbose MCP capability / elicitation debug logging. | |
| 71 | + */ | |
| 72 | + readonly debug?: boolean; | |
| 69 | 73 | }; |
| 70 | 74 | |
| 71 | 75 | export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = |
| @@ -85,9 +89,21 @@ const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean · 13 unmodified lines | ||
| 85 | 89 | return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; |
| 86 | 90 | }; |
| 87 | 91 | |
| 92 | +const readDebugDefault = (): boolean => { | |
| 93 | + if (typeof process === "undefined" || !process.env) return false; | |
| 94 | + const value = process.env.EXECUTOR_MCP_DEBUG; | |
| 95 | + return value === "1" || value === "true"; | |
| 96 | +}; | |
| 97 | + | |
| 88 | 98 | const supportsManagedElicitation = (server: McpServer): boolean => |
| 89 | 99 | getElicitationSupport(server).form; |
| 90 | 100 | |
| 101 | +const capabilitySnapshot = (server: McpServer) => ({ | |
| 102 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 103 | + elicitationSupport: getElicitationSupport(server), | |
| 104 | + managedElicitation: supportsManagedElicitation(server), | |
| 105 | +}); | |
| 106 | + | |
| 91 | 107 | type ElicitInputParams = |
| 92 | 108 | | { |
| 93 | 109 | mode?: "form"; |
| @@ -118,7 +134,10 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 24 unmodified lines | ||
| 118 | 134 | ); |
| 119 | 135 | |
| 120 | 136 | const makeMcpElicitationHandler = |
| 121 | − (server: McpServer): ElicitationHandler => | |
| 137 | + ( | |
| 138 | + server: McpServer, | |
| 139 | + debugLog?: (event: string, data: Record<string, unknown>) => void, | |
| 140 | + ): ElicitationHandler => | |
| 122 | 141 | (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { |
| 123 | 142 | const { url: supportsUrl } = getElicitationSupport(server); |
| 124 | 143 | |
| @@ -133,19 +152,51 @@ const makeMcpElicitationHandler = · 8 unmodified lines | ||
| 133 | 152 | : elicitationRequestToParams(ctx.request); |
| 134 | 153 | |
| 135 | 154 | return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => { |
| 155 | + debugLog?.("elicitation.request", { | |
| 156 | + requestTag: ctx.request._tag, | |
| 157 | + supportsUrl, | |
| 158 | + message: ctx.request.message, | |
| 159 | + hasRequestedSchema: | |
| 160 | + ctx.request._tag === "FormElicitation" | |
| 161 | + ? Object.keys(ctx.request.requestedSchema).length > 0 | |
| 162 | + : false, | |
| 163 | + url: ctx.request._tag === "UrlElicitation" ? ctx.request.url : undefined, | |
| 164 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 165 | + }); | |
| 136 | 166 | try { |
| 137 | 167 | const response = await server.server.elicitInput( |
| 138 | 168 | params as Parameters<typeof server.server.elicitInput>[0], |
| 139 | 169 | ); |
| 140 | 170 | |
| 171 | + debugLog?.("elicitation.response", { | |
| 172 | + requestTag: ctx.request._tag, | |
| 173 | + action: response.action, | |
| 174 | + hasContent: | |
| 175 | + typeof response.content === "object" && | |
| 176 | + response.content !== null && | |
| 177 | + Object.keys(response.content).length > 0, | |
| 178 | + }); | |
| 179 | + | |
| 141 | 180 | return { |
| 142 | 181 | action: response.action, |
| 143 | 182 | content: response.content, |
| 144 | 183 | }; |
| 145 | 184 | } catch (err) { |
| 185 | + debugLog?.("elicitation.error", { | |
| 186 | + requestTag: ctx.request._tag, | |
| 187 | + error: | |
| 188 | + err instanceof Error | |
| 189 | + ? { name: err.name, message: err.message, stack: err.stack } | |
| 190 | + : { message: String(err) }, | |
| 191 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 192 | + }); | |
| 146 | 193 | console.error( |
| 147 | 194 | "[executor] elicitInput failed — falling back to cancel.", |
| 148 | − err instanceof Error ? err.message : err, | |
| 195 | + JSON.stringify({ | |
| 196 | + error: err instanceof Error ? err.message : String(err), | |
| 197 | + requestTag: ctx.request._tag, | |
| 198 | + ...capabilitySnapshot(server), | |
| 199 | + }), | |
| 149 | 200 | ); |
| 150 | 201 | return { action: "cancel" }; |
| 151 | 202 | } |
| @@ -205,6 +256,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 53 unmodified lines | ||
| 205 | 256 | // deferred past the outer Effect's await), so we use the runtime to |
| 206 | 257 | // re-enter Effect-land at each callback edge. |
| 207 | 258 | const runtime = yield* Effect.runtime<never>(); |
| 259 | + const debugEnabled = config.debug ?? readDebugDefault(); | |
| 260 | + const debugLog = (event: string, data: Record<string, unknown>) => { | |
| 261 | + if (!debugEnabled) return; | |
| 262 | + try { | |
| 263 | + console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`); | |
| 264 | + } catch { | |
| 265 | + console.error(`[executor:mcp] ${event}`, data); | |
| 266 | + } | |
| 267 | + }; | |
| 208 | 268 | |
| 209 | 269 | const resolveParentSpan = (): Tracer.AnySpan | undefined => { |
| 210 | 270 | const ps = config.parentSpan; |
| @@ -228,13 +288,27 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines | ||
| 228 | 288 | |
| 229 | 289 | const executeCode = (code: string): Effect.Effect<McpToolResult, E> => |
| 230 | 290 | Effect.gen(function* () { |
| 291 | + debugLog("execute.call", { | |
| 292 | + managedElicitation: supportsManagedElicitation(server), | |
| 293 | + elicitationSupport: getElicitationSupport(server), | |
| 294 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 295 | + codeLength: code.length, | |
| 296 | + }); | |
| 231 | 297 | if (supportsManagedElicitation(server)) { |
| 232 | 298 | const result = yield* engine.execute(code, { |
| 233 | − onElicitation: makeMcpElicitationHandler(server), | |
| 299 | + onElicitation: makeMcpElicitationHandler(server, debugLog), | |
| 234 | 300 | }); |
| 235 | 301 | return toMcpResult(formatExecuteResult(result)); |
| 236 | 302 | } |
| 237 | 303 | const outcome = yield* engine.executeWithPause(code); |
| 304 | + debugLog("execute.paused_flow_result", { | |
| 305 | + status: outcome.status, | |
| 306 | + executionId: outcome.status === "paused" ? outcome.execution.id : undefined, | |
| 307 | + interactionKind: | |
| 308 | + outcome.status === "paused" | |
| 309 | + ? outcome.execution.elicitationContext.request._tag | |
| 310 | + : undefined, | |
| 311 | + }); | |
| 238 | 312 | return outcome.status === "completed" |
| 239 | 313 | ? toMcpResult(formatExecuteResult(outcome.result)) |
| 240 | 314 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); |
| @@ -253,8 +327,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines | ||
| 253 | 327 | content: Record<string, unknown> | undefined, |
| 254 | 328 | ): Effect.Effect<McpToolResult, E> => |
| 255 | 329 | Effect.gen(function* () { |
| 330 | + debugLog("resume.call", { | |
| 331 | + executionId, | |
| 332 | + action, | |
| 333 | + hasContent: content !== undefined, | |
| 334 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 335 | + }); | |
| 256 | 336 | const outcome = yield* engine.resume(executionId, { action, content }); |
| 257 | 337 | if (!outcome) { |
| 338 | + debugLog("resume.missing_execution", { executionId }); | |
| 258 | 339 | return { |
| 259 | 340 | content: [ |
| 260 | 341 | { type: "text" as const, text: `No paused execution: ${executionId}` }, |
| @@ -262,6 +343,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 1 unmodified line | ||
| 262 | 343 | isError: true, |
| 263 | 344 | } satisfies McpToolResult; |
| 264 | 345 | } |
| 346 | + debugLog("resume.result", { | |
| 347 | + executionId, | |
| 348 | + status: outcome.status, | |
| 349 | + nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined, | |
| 350 | + interactionKind: | |
| 351 | + outcome.status === "paused" | |
| 352 | + ? outcome.execution.elicitationContext.request._tag | |
| 353 | + : undefined, | |
| 354 | + }); | |
| 265 | 355 | return outcome.status === "completed" |
| 266 | 356 | ? toMcpResult(formatExecuteResult(outcome.result)) |
| 267 | 357 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); |
| @@ -331,6 +421,19 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 63 unmodified lines | ||
| 331 | 421 | } else { |
| 332 | 422 | resumeTool.enable(); |
| 333 | 423 | } |
| 424 | + console.error( | |
| 425 | + "[executor] MCP capability snapshot", | |
| 426 | + JSON.stringify({ | |
| 427 | + ...capabilitySnapshot(server), | |
| 428 | + resumeEnabled: !supportsManagedElicitation(server), | |
| 429 | + }), | |
| 430 | + ); | |
| 431 | + debugLog("tool.visibility", { | |
| 432 | + clientCapabilities: server.server.getClientCapabilities() ?? null, | |
| 433 | + elicitationSupport: getElicitationSupport(server), | |
| 434 | + managedElicitation: supportsManagedElicitation(server), | |
| 435 | + resumeEnabled: !supportsManagedElicitation(server), | |
| 436 | + }); | |
| 334 | 437 | }; |
| 335 | 438 | |
| 336 | 439 | yield* Effect.sync(() => { |