- 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 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 2 | import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; | 3 | import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; |
| 3 | import { autumnHandler } from "autumn-js/backend"; | 4 | import { autumnHandler } from "autumn-js/backend"; |
| 4 | 5 | ||
| 5 | import { WorkOSAuth } from "../auth/workos"; | 6 | import { WorkOSAuth } from "../auth/workos"; |
| 6 | import { server } from "../env"; | ||
| 7 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; | 7 | import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; |
| 8 | import { SharedServices } from "./layers"; | 8 | import { SharedServices } from "./layers"; |
| 9 | 9 | ||
| @@ -59,7 +59,7 @@ export const AutumnApiApp = Effect.gen(function* () { · 49 unmodified lines | |||
| 59 | email: session.email, | 59 | email: session.email, |
| 60 | }, | 60 | }, |
| 61 | clientOptions: { | 61 | clientOptions: { |
| 62 | secretKey: server.AUTUMN_SECRET_KEY, | 62 | secretKey: env.AUTUMN_SECRET_KEY ?? "", |
| 63 | }, | 63 | }, |
| 64 | pathPrefix: "/autumn", | 64 | pathPrefix: "/autumn", |
| 65 | }), | 65 | }), |
| @@ -27,12 +27,18 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => · 26 unmodified lines | |||
| 27 | const session = yield* workos.authenticateRequest(webRequest); | 27 | const session = yield* workos.authenticateRequest(webRequest); |
| 28 | if (!session || !session.organizationId) return null; | 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 | Effect.gen(function* () { | 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 | const requestServices = Layer.mergeAll( | 43 | const requestServices = Layer.mergeAll( |
| 38 | Layer.succeed(ExecutorService, executor), | 44 | Layer.succeed(ExecutorService, executor), |
| @@ -58,8 +64,8 @@ const createProtectedApp = (organizationId: string, organizationName: string) => · 19 unmodified lines | |||
| 58 | 64 | ||
| 59 | export const ProtectedApiApp = Effect.gen(function* () { | 65 | export const ProtectedApiApp = Effect.gen(function* () { |
| 60 | const request = yield* HttpServerRequest.HttpServerRequest; | 66 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 61 | const org = yield* lookupOrgForRequest(request); | 67 | const session = yield* lookupOrgForRequest(request); |
| 62 | if (!org) { | 68 | if (!session) { |
| 63 | return yield* Effect.fail( | 69 | return yield* Effect.fail( |
| 64 | new HttpResponseError({ | 70 | new HttpResponseError({ |
| 65 | status: 403, | 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 | return yield* app; | 79 | return yield* app; |
| 74 | }).pipe( | 80 | }).pipe( |
| 75 | Effect.provide(SharedServices), | 81 | Effect.provide(SharedServices), |
| @@ -6,9 +6,9 @@ import { AUTH_PATHS, CloudAuthApi, CloudAuthPublicApi } from "./api"; · 5 unmodified lines | |||
| 6 | import { SessionContext } from "./middleware"; | 6 | import { SessionContext } from "./middleware"; |
| 7 | import { UserStoreService } from "./context"; | 7 | import { UserStoreService } from "./context"; |
| 8 | import { authorizeOrganization } from "./authorize-organization"; | 8 | import { authorizeOrganization } from "./authorize-organization"; |
| 9 | import { env } from "cloudflare:workers"; | ||
| 9 | import { WorkOSError } from "./errors"; | 10 | import { WorkOSError } from "./errors"; |
| 10 | import { WorkOSAuth } from "./workos"; | 11 | import { WorkOSAuth } from "./workos"; |
| 11 | import { server } from "../env"; | ||
| 12 | 12 | ||
| 13 | const COOKIE_OPTIONS = { | 13 | const COOKIE_OPTIONS = { |
| 14 | path: "/", | 14 | path: "/", |
| @@ -40,7 +40,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 25 unmodified lines | |||
| 40 | // Use the explicit public site URL — in dev, the request's Host | 40 | // Use the explicit public site URL — in dev, the request's Host |
| 41 | // header points at the internal proxy target, not the public URL | 41 | // header points at the internal proxy target, not the public URL |
| 42 | // WorkOS needs to redirect back to. | 42 | // WorkOS needs to redirect back to. |
| 43 | const origin = server.VITE_PUBLIC_SITE_URL; | 43 | const origin = env.VITE_PUBLIC_SITE_URL ?? ""; |
| 44 | const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`); | 44 | const url = workos.getAuthorizationUrl(`${origin}${AUTH_PATHS.callback}`); |
| 45 | return HttpServerResponse.redirect(url, { status: 302 }); | 45 | return HttpServerResponse.redirect(url, { status: 302 }); |
| 46 | }), | 46 | }), |
| @@ -2,10 +2,10 @@ · 1 unmodified line | |||
| 2 | // WorkOS AuthKit — Effect-native sealed session management | 2 | // WorkOS AuthKit — Effect-native sealed session management |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | ||
| 5 | import { Context, Effect, Layer } from "effect"; | 6 | import { Context, Effect, Layer } from "effect"; |
| 6 | import { WorkOS } from "@workos-inc/node/worker"; | 7 | import { WorkOS } from "@workos-inc/node/worker"; |
| 7 | import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; | 8 | import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; |
| 8 | import { server } from "../env"; | ||
| 9 | 9 | ||
| 10 | const COOKIE_NAME = "wos-session"; | 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 | const make = Effect.gen(function* () { | 16 | const make = Effect.gen(function* () { |
| 17 | const apiKey = server.WORKOS_API_KEY; | 17 | const apiKey = env.WORKOS_API_KEY; |
| 18 | const clientId = server.WORKOS_CLIENT_ID; | 18 | const clientId = env.WORKOS_CLIENT_ID; |
| 19 | const cookiePassword = server.WORKOS_COOKIE_PASSWORD; | 19 | const cookiePassword = env.WORKOS_COOKIE_PASSWORD; |
| 20 | 20 | ||
| 21 | if (!cookiePassword || cookiePassword.length < 32) { | 21 | if (!cookiePassword || cookiePassword.length < 32) { |
| 22 | return yield* Effect.die(new Error("WORKOS_COOKIE_PASSWORD must be at least 32 characters")); | 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 | name: scopeName, | 122 | name: scopeName, |
| 123 | createdAt: new Date(), | 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 | // Builds a scope, wires a real execution engine + MCP server, and yields | 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 | import { CoreSharedServices } from "./api/core-shared-services"; | 25 | import { CoreSharedServices } from "./api/core-shared-services"; |
| 26 | import { UserStoreService } from "./auth/context"; | 26 | import { UserStoreService } from "./auth/context"; |
| 27 | import { resolveOrganization } from "./auth/resolve-organization"; | 27 | import { resolveOrganization } from "./auth/resolve-organization"; |
| 28 | import { server } from "./env"; | ||
| 29 | import { DbService, combinedSchema } from "./services/db"; | 28 | import { DbService, combinedSchema } from "./services/db"; |
| 30 | import { makeExecutionStack } from "./services/execution-stack"; | 29 | import { makeExecutionStack } from "./services/execution-stack"; |
| 31 | import { DoTelemetryLive } from "./services/telemetry"; | 30 | import { DoTelemetryLive } from "./services/telemetry"; |
| @@ -36,6 +35,7 @@ import { DoTelemetryLive } from "./services/telemetry"; · 4 unmodified lines | |||
| 36 | 35 | ||
| 37 | export type McpSessionInit = { | 36 | export type McpSessionInit = { |
| 38 | organizationId: string; | 37 | organizationId: string; |
| 38 | userId: string; | ||
| 39 | }; | 39 | }; |
| 40 | 40 | ||
| 41 | export type IncomingTraceHeaders = { | 41 | export type IncomingTraceHeaders = { |
| @@ -109,6 +109,7 @@ type DbHandle = DbServiceShape & { end: () => Promise<void> }; · 67 unmodified lines | |||
| 109 | type SessionMeta = { | 109 | type SessionMeta = { |
| 110 | readonly organizationId: string; | 110 | readonly organizationId: string; |
| 111 | readonly organizationName: string; | 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 | readonly idleTimeout: number; | 125 | readonly idleTimeout: number; |
| 125 | readonly maxLifetime: number; | 126 | readonly maxLifetime: number; |
| 126 | }): DbHandle => { | 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 | const sql = postgres(connectionString, { | 134 | const sql = postgres(connectionString, { |
| 129 | max: 1, | 135 | max: 1, |
| 130 | idle_timeout: options.idleTimeout, | 136 | idle_timeout: options.idleTimeout, |
| @@ -159,6 +165,7 @@ const makeSessionServices = (dbHandle: DbHandle) => · 28 unmodified lines | |||
| 159 | 165 | ||
| 160 | const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( | 166 | const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function* ( |
| 161 | organizationId: string, | 167 | organizationId: string, |
| 168 | userId: string, | ||
| 162 | ) { | 169 | ) { |
| 163 | const org = yield* resolveOrganization(organizationId); | 170 | const org = yield* resolveOrganization(organizationId); |
| 164 | if (!org) { | 171 | if (!org) { |
| @@ -167,10 +174,11 @@ const resolveSessionMeta = Effect.fn("McpSessionDO.resolveSessionMeta")(function · 2 unmodified lines | |||
| 167 | return { | 174 | return { |
| 168 | organizationId: org.id, | 175 | organizationId: org.id, |
| 169 | organizationName: org.name, | 176 | organizationName: org.name, |
| 177 | userId, | ||
| 170 | } satisfies SessionMeta; | 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 | // Durable Object | 184 | // Durable Object |
| @@ -238,6 +246,7 @@ export class McpSessionDO extends DurableObject { · 61 unmodified lines | |||
| 238 | const self = this; | 246 | const self = this; |
| 239 | return Effect.gen(function* () { | 247 | return Effect.gen(function* () { |
| 240 | const { executor, engine } = yield* makeExecutionStack( | 248 | const { executor, engine } = yield* makeExecutionStack( |
| 249 | sessionMeta.userId, | ||
| 241 | sessionMeta.organizationId, | 250 | sessionMeta.organizationId, |
| 242 | sessionMeta.organizationName, | 251 | sessionMeta.organizationName, |
| 243 | ); | 252 | ); |
| @@ -251,6 +260,7 @@ export class McpSessionDO extends DurableObject { · 7 unmodified lines | |||
| 251 | engine, | 260 | engine, |
| 252 | description, | 261 | description, |
| 253 | parentSpan: () => self.currentRequestSpan ?? undefined, | 262 | parentSpan: () => self.currentRequestSpan ?? undefined, |
| 263 | debug: env.EXECUTOR_MCP_DEBUG === "true", | ||
| 254 | }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); | 264 | }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); |
| 255 | const transport = new WorkerTransport({ | 265 | const transport = new WorkerTransport({ |
| 256 | sessionIdGenerator: () => self.ctx.id.toString(), | 266 | sessionIdGenerator: () => self.ctx.id.toString(), |
| @@ -272,9 +282,10 @@ export class McpSessionDO extends DurableObject { · 15 unmodified lines | |||
| 272 | return Effect.gen(function* () { | 282 | return Effect.gen(function* () { |
| 273 | const dbHandle = makeRequestScopedDb(); | 283 | const dbHandle = makeRequestScopedDb(); |
| 274 | try { | 284 | try { |
| 275 | const sessionMeta = yield* resolveSessionMeta(token.organizationId).pipe( | 285 | const sessionMeta = yield* resolveSessionMeta( |
| 276 | Effect.provide(makeResolveOrganizationServices(dbHandle)), | 286 | token.organizationId, |
| 277 | ); | 287 | token.userId, |
| 288 | ).pipe(Effect.provide(makeResolveOrganizationServices(dbHandle))); | ||
| 278 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( | 289 | yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( |
| 279 | Effect.withSpan("mcp.session.save_meta"), | 290 | Effect.withSpan("mcp.session.save_meta"), |
| 280 | ); | 291 | ); |
| @@ -20,15 +20,14 @@ import * as Sentry from "@sentry/cloudflare"; · 19 unmodified lines | |||
| 20 | import { Context, Effect, Layer, Option, Schema } from "effect"; | 20 | import { Context, Effect, Layer, Option, Schema } from "effect"; |
| 21 | import { createRemoteJWKSet, jwtVerify } from "jose"; | 21 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 22 | 22 | ||
| 23 | import { server } from "./env"; | ||
| 24 | import { TelemetryLive } from "./services/telemetry"; | 23 | import { TelemetryLive } from "./services/telemetry"; |
| 25 | 24 | ||
| 26 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| 27 | // Constants | 26 | // Constants |
| 28 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 29 | 28 | ||
| 30 | const AUTHKIT_DOMAIN = server.MCP_AUTHKIT_DOMAIN; | 29 | const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh"; |
| 31 | const RESOURCE_ORIGIN = server.MCP_RESOURCE_ORIGIN; | 30 | const RESOURCE_ORIGIN = env.MCP_RESOURCE_ORIGIN ?? "https://executor.sh"; |
| 32 | 31 | ||
| 33 | const jwks = createRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)); | 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 | const ns = env.MCP_SESSION; | 525 | const ns = env.MCP_SESSION; |
| 527 | const stub = ns.get(ns.newUniqueId()); | 526 | const stub = ns.get(ns.newUniqueId()); |
| 528 | const propagation = yield* currentPropagationHeaders(request); | 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 | const propagated = withPropagationHeaders(request, propagation); | 531 | const propagated = withPropagationHeaders(request, propagation); |
| 531 | const raw = yield* Effect.promise( | 532 | const raw = yield* Effect.promise( |
| 532 | () => stub.handleRequest(propagated) as Promise<Response>, | 533 | () => stub.handleRequest(propagated) as Promise<Response>, |
| @@ -21,6 +21,9 @@ export const ErrorCaptureLive: Layer.Layer<ErrorCapture> = Layer.succeed( · 20 unmodified lines | |||
| 21 | captureException: (cause) => | 21 | captureException: (cause) => |
| 22 | Effect.sync(() => { | 22 | Effect.sync(() => { |
| 23 | const squashed = Cause.squash(cause); | 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 | const eventId = Sentry.captureException(squashed, (scope) => { | 27 | const eventId = Sentry.captureException(squashed, (scope) => { |
| 25 | scope.setExtra("cause", Cause.pretty(cause)); | 28 | scope.setExtra("cause", Cause.pretty(cause)); |
| 26 | return scope; | 29 | return scope; |
| @@ -3,8 +3,8 @@ import { Effect } from "effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { UserStoreService } from "../auth/context"; | 4 | import { UserStoreService } from "../auth/context"; |
| 5 | import { AuthContext } from "../auth/middleware"; | 5 | import { AuthContext } from "../auth/middleware"; |
| 6 | import { env } from "cloudflare:workers"; | ||
| 6 | import { WorkOSAuth } from "../auth/workos"; | 7 | import { WorkOSAuth } from "../auth/workos"; |
| 7 | import { server } from "../env"; | ||
| 8 | import { AutumnService } from "../services/autumn"; | 8 | import { AutumnService } from "../services/autumn"; |
| 9 | import { OrgHttpApi } from "./compose"; | 9 | import { OrgHttpApi } from "./compose"; |
| 10 | import { Forbidden } from "./api"; | 10 | import { Forbidden } from "./api"; |
| @@ -144,7 +144,7 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => · 133 unmodified lines | |||
| 144 | const workos = yield* WorkOSAuth; | 144 | const workos = yield* WorkOSAuth; |
| 145 | const { link } = yield* workos.generateDomainVerificationPortalLink( | 145 | const { link } = yield* workos.generateDomainVerificationPortalLink( |
| 146 | auth.organizationId, | 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 | return { link }; | 149 | return { link }; |
| 150 | }), | 150 | }), |
| @@ -3,7 +3,6 @@ import handler from "@tanstack/react-start/server-entry"; · 2 unmodified lines | |||
| 3 | import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers"; | 3 | import { instrument, type TraceConfig } from "@microlabs/otel-cf-workers"; |
| 4 | 4 | ||
| 5 | import { McpSessionDO as McpSessionDOBase } from "./mcp-session"; | 5 | import { McpSessionDO as McpSessionDOBase } from "./mcp-session"; |
| 6 | import { server } from "./env"; | ||
| 7 | 6 | ||
| 8 | // --------------------------------------------------------------------------- | 7 | // --------------------------------------------------------------------------- |
| 9 | // OTEL config for the main fetch handler — `otel-cf-workers` owns the global | 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 | // DOMException "Illegal invocation". | 14 | // DOMException "Illegal invocation". |
| 16 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
| 17 | 16 | ||
| 18 | const parseSampleRatio = (value: string): number => { | 17 | const parseSampleRatio = (value: string | undefined): number => { |
| 19 | const n = Number(value); | 18 | const n = Number(value); |
| 20 | if (!Number.isFinite(n)) return 1; | 19 | if (!Number.isFinite(n)) return 1; |
| 21 | return Math.min(1, Math.max(0, n)); | 20 | return Math.min(1, Math.max(0, n)); |
| 22 | }; | 21 | }; |
| 23 | 22 | ||
| 24 | const otelConfig: TraceConfig = { | 23 | const otelConfig = (env: Env): TraceConfig => ({ |
| 25 | service: { name: "executor-cloud", version: "1.0.0" }, | 24 | service: { name: "executor-cloud", version: "1.0.0" }, |
| 26 | exporter: { | 25 | exporter: { |
| 27 | url: server.AXIOM_TRACES_URL, | 26 | url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", |
| 28 | headers: { | 27 | headers: { |
| 29 | Authorization: `Bearer ${server.AXIOM_TOKEN}`, | 28 | Authorization: `Bearer ${env.AXIOM_TOKEN ?? ""}`, |
| 30 | "X-Axiom-Dataset": server.AXIOM_DATASET, | 29 | "X-Axiom-Dataset": env.AXIOM_DATASET ?? "executor-cloud", |
| 31 | }, | 30 | }, |
| 32 | }, | 31 | }, |
| 33 | sampling: { | 32 | sampling: { |
| 34 | headSampler: { | 33 | headSampler: { |
| 35 | // Keep remote parent decisions and make local sampling policy explicit. | 34 | // Keep remote parent decisions and make local sampling policy explicit. |
| 36 | acceptRemote: true, | 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 | // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim | 41 | // otel-cf-workers owns the global TracerProvider. Sentry's OTEL compat shim |
| 43 | // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers | 42 | // registers a ProxyTracerProvider of its own, which prevents otel-cf-workers |
| 44 | // from finding its WorkerTracer and breaks the whole request path with | 43 | // from finding its WorkerTracer and breaks the whole request path with |
| 45 | // "global tracer is not of type WorkerTracer". | 44 | // "global tracer is not of type WorkerTracer". |
| 46 | // | 45 | const sentryOptions = (env: Env) => ({ |
| 47 | // The `_env` parameter is unused — `server` from `./env` already gives us | 46 | dsn: env.SENTRY_DSN, |
| 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, | ||
| 52 | tracesSampleRate: 0, | 47 | tracesSampleRate: 0, |
| 53 | enableLogs: true, | 48 | enableLogs: true, |
| 54 | sendDefaultPii: true, | 49 | sendDefaultPii: true, |
| @@ -76,6 +71,28 @@ export const McpSessionDO = Sentry.instrumentDurableObjectWithSentry( · 21 unmodified lines | |||
| 76 | // Worker fetch handler | 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 | name: scopeName, | 181 | name: scopeName, |
| 182 | createdAt: new Date(), | 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 | // Autumn billing service — wraps the autumn-js SDK with Effect | 2 | // Autumn billing service — wraps the autumn-js SDK with Effect |
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | ||
| 5 | import { env } from "cloudflare:workers"; | ||
| 5 | import * as Sentry from "@sentry/cloudflare"; | 6 | import * as Sentry from "@sentry/cloudflare"; |
| 6 | import { Autumn } from "autumn-js"; | 7 | import { Autumn } from "autumn-js"; |
| 7 | import { Context, Data, Effect, Layer } from "effect"; | 8 | import { Context, Data, Effect, Layer } from "effect"; |
| 8 | 9 | ||
| 9 | import { server } from "../env"; | ||
| 10 | |||
| 11 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 12 | // Errors | 11 | // Errors |
| 13 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| @@ -36,7 +35,7 @@ export type IAutumnService = Readonly<{ · 22 unmodified lines | |||
| 36 | // --------------------------------------------------------------------------- | 35 | // --------------------------------------------------------------------------- |
| 37 | 36 | ||
| 38 | const make = Effect.sync(() => { | 37 | const make = Effect.sync(() => { |
| 39 | const secretKey = server.AUTUMN_SECRET_KEY; | 38 | const secretKey = env.AUTUMN_SECRET_KEY; |
| 40 | 39 | ||
| 41 | if (!secretKey) { | 40 | if (!secretKey) { |
| 42 | const notConfigured = Effect.die( | 41 | const notConfigured = Effect.die( |
| @@ -18,7 +18,6 @@ import type { PgDatabase } from "drizzle-orm/pg-core"; · 17 unmodified lines | |||
| 18 | import postgres, { type Sql } from "postgres"; | 18 | import postgres, { type Sql } from "postgres"; |
| 19 | import * as cloudSchema from "./schema"; | 19 | import * as cloudSchema from "./schema"; |
| 20 | import * as executorSchema from "./executor-schema"; | 20 | import * as executorSchema from "./executor-schema"; |
| 21 | import { server } from "../env"; | ||
| 22 | 21 | ||
| 23 | // Exported so every drizzle() call in the cloud app shares one schema | 22 | // Exported so every drizzle() call in the cloud app shares one schema |
| 24 | // object. Historically `mcp-session.ts` built its own and forgot to spread | 23 | // object. Historically `mcp-session.ts` built its own and forgot to spread |
| @@ -38,10 +37,7 @@ const resolveConnectionString = () => { · 13 unmodified lines | |||
| 38 | // In local dev prefer an explicit DATABASE_URL (direct connection to | 37 | // In local dev prefer an explicit DATABASE_URL (direct connection to |
| 39 | // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy. | 38 | // the PGlite socket server) so we bypass Miniflare's Hyperdrive proxy. |
| 40 | // In production fall back to the Hyperdrive binding. | 39 | // In production fall back to the Hyperdrive binding. |
| 41 | if (server.DATABASE_URL) { | 40 | return env.DATABASE_URL || env.HYPERDRIVE?.connectionString || ""; |
| 42 | return server.DATABASE_URL; | ||
| 43 | } | ||
| 44 | return env.HYPERDRIVE?.connectionString ?? server.DATABASE_URL; | ||
| 45 | }; | 41 | }; |
| 46 | 42 | ||
| 47 | const makeSql = (): Sql => | 43 | const makeSql = (): Sql => |
| @@ -14,11 +14,17 @@ import { withExecutionUsageTracking } from "../api/execution-usage"; · 13 unmodified lines | |||
| 14 | import { AutumnService } from "./autumn"; | 14 | import { AutumnService } from "./autumn"; |
| 15 | import { createScopedExecutor } from "./executor"; | 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 | Effect.gen(function* () { | 22 | Effect.gen(function* () { |
| 19 | const executor = yield* createScopedExecutor(organizationId, organizationName).pipe( | 23 | const executor = yield* createScopedExecutor( |
| 20 | Effect.withSpan("McpSessionDO.createScopedExecutor"), | 24 | userId, |
| 21 | ); | 25 | organizationId, |
| 26 | organizationName, | ||
| 27 | ).pipe(Effect.withSpan("McpSessionDO.createScopedExecutor")); | ||
| 22 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); | 28 | const codeExecutor = makeDynamicWorkerExecutor({ loader: env.LOADER }); |
| 23 | const autumn = yield* AutumnService; | 29 | const autumn = yield* AutumnService; |
| 24 | const engine = withExecutionUsageTracking( | 30 | const engine = withExecutionUsageTracking( |
| @@ -25,8 +25,8 @@ import { mcpPlugin } from "@executor/plugin-mcp"; · 24 unmodified lines | |||
| 25 | import { graphqlPlugin } from "@executor/plugin-graphql"; | 25 | import { graphqlPlugin } from "@executor/plugin-graphql"; |
| 26 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; | 26 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; |
| 27 | 27 | ||
| 28 | import { env } from "cloudflare:workers"; | ||
| 28 | import { DbService } from "./db"; | 29 | import { DbService } from "./db"; |
| 29 | import { server } from "../env"; | ||
| 30 | 30 | ||
| 31 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 32 | // Plugin list — one place, used for both the runtime and the CLI config | 32 | // Plugin list — one place, used for both the runtime and the CLI config |
| @@ -45,24 +45,31 @@ const createOrgPlugins = () => · 12 unmodified lines | |||
| 45 | graphqlPlugin(), | 45 | graphqlPlugin(), |
| 46 | workosVaultPlugin({ | 46 | workosVaultPlugin({ |
| 47 | credentials: { | 47 | credentials: { |
| 48 | apiKey: server.WORKOS_API_KEY, | 48 | apiKey: env.WORKOS_API_KEY, |
| 49 | clientId: server.WORKOS_CLIENT_ID, | 49 | clientId: env.WORKOS_CLIENT_ID, |
| 50 | }, | 50 | }, |
| 51 | }), | 51 | }), |
| 52 | ] as const; | 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 | 57 | // Scope stack is `[userOrgScope, orgScope]` — innermost first. The |
| 58 | // level the cloud app exposes. When workspace / user scopes land, this | 58 | // user-within-org scope id (`user-org:${userId}:${orgId}`) intentionally |
| 59 | // function grows to accept a `ScopeStack` instead of a single scope id, | 59 | // includes the org id so the same WorkOS user in a different org gets a |
| 60 | // and nothing downstream (executor, plugins, storage) has to change. | 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 | export const createScopedExecutor = ( | 69 | export const createScopedExecutor = ( |
| 64 | scopeId: string, | 70 | userId: string, |
| 65 | scopeName: string, | 71 | organizationId: string, |
| 72 | organizationName: string, | ||
| 66 | ) => | 73 | ) => |
| 67 | Effect.gen(function* () { | 74 | Effect.gen(function* () { |
| 68 | const { db } = yield* DbService; | 75 | const { db } = yield* DbService; |
| @@ -72,9 +79,14 @@ export const createScopedExecutor = ( · 3 unmodified lines | |||
| 72 | const adapter = makePostgresAdapter({ db, schema }); | 79 | const adapter = makePostgresAdapter({ db, schema }); |
| 73 | const blobs = makePostgresBlobStore({ db }); | 80 | const blobs = makePostgresBlobStore({ db }); |
| 74 | 81 | ||
| 75 | const scope = new Scope({ | 82 | const orgScope = new Scope({ |
| 76 | id: ScopeId.make(scopeId), | 83 | id: ScopeId.make(organizationId), |
| 77 | name: scopeName, | 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 | createdAt: new Date(), | 90 | createdAt: new Date(), |
| 79 | }); | 91 | }); |
| 80 | 92 | ||
| @@ -82,5 +94,10 @@ export const createScopedExecutor = ( · 1 unmodified line | |||
| 82 | // the opaque `InternalError({ traceId })` happens at the HTTP edge | 94 | // the opaque `InternalError({ traceId })` happens at the HTTP edge |
| 83 | // via `withCapture` (see `api/protected-layers.ts`). That's | 95 | // via `withCapture` (see `api/protected-layers.ts`). That's |
| 84 | // where `ErrorCaptureLive` (Sentry) gets wired in. | 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 | ATTR_SERVICE_NAME, | 53 | ATTR_SERVICE_NAME, |
| 54 | ATTR_SERVICE_VERSION, | 54 | ATTR_SERVICE_VERSION, |
| 55 | } from "@opentelemetry/semantic-conventions"; | 55 | } from "@opentelemetry/semantic-conventions"; |
| 56 | import { env } from "cloudflare:workers"; | ||
| 56 | import { Effect, Layer } from "effect"; | 57 | import { Effect, Layer } from "effect"; |
| 57 | 58 | ||
| 58 | import { server } from "../env"; | ||
| 59 | |||
| 60 | const SERVICE_NAME = "executor-cloud"; | 59 | const SERVICE_NAME = "executor-cloud"; |
| 61 | const SERVICE_VERSION = "1.0.0"; | 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 | let installed = false; | 70 | let installed = false; |
| 72 | const ensureGlobalTracerProvider = (): boolean => { | 71 | const ensureGlobalTracerProvider = (): boolean => { |
| 73 | if (installed) return true; | 72 | if (installed) return true; |
| 74 | if (!server.AXIOM_TOKEN) return false; | 73 | if (!env.AXIOM_TOKEN) return false; |
| 75 | const provider = new WebTracerProvider({ | 74 | const provider = new WebTracerProvider({ |
| 76 | resource: resourceFromAttributes({ | 75 | resource: resourceFromAttributes({ |
| 77 | [ATTR_SERVICE_NAME]: SERVICE_NAME, | 76 | [ATTR_SERVICE_NAME]: SERVICE_NAME, |
| @@ -80,10 +79,10 @@ const ensureGlobalTracerProvider = (): boolean => { · 2 unmodified lines | |||
| 80 | spanProcessors: [ | 79 | spanProcessors: [ |
| 81 | new SimpleSpanProcessor( | 80 | new SimpleSpanProcessor( |
| 82 | new OTLPTraceExporter({ | 81 | new OTLPTraceExporter({ |
| 83 | url: server.AXIOM_TRACES_URL, | 82 | url: env.AXIOM_TRACES_URL ?? "https://api.axiom.co/v1/traces", |
| 84 | headers: { | 83 | headers: { |
| 85 | Authorization: `Bearer ${server.AXIOM_TOKEN}`, | 84 | Authorization: `Bearer ${env.AXIOM_TOKEN}`, |
| 86 | "X-Axiom-Dataset": server.AXIOM_DATASET, | 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 | import { cloudflare } from "@cloudflare/vite-plugin"; | 2 | import { cloudflare } from "@cloudflare/vite-plugin"; |
| 3 | import { tanstackStart } from "@tanstack/react-start/plugin/vite"; | 3 | import { tanstackStart } from "@tanstack/react-start/plugin/vite"; |
| 4 | import react from "@vitejs/plugin-react"; | 4 | import react from "@vitejs/plugin-react"; |
| 5 | import tailwindcss from "@tailwindcss/vite"; | 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 | export default defineConfig({ | 34 | export default defineConfig({ |
| 8 | resolve: { tsconfigPaths: true }, | 35 | resolve: { tsconfigPaths: true }, |
| 9 | plugins: [ | 36 | plugins: [ |
| 37 | devCrashGuard(), | ||
| 10 | tailwindcss(), | 38 | tailwindcss(), |
| 11 | cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), | 39 | cloudflare({ viteEnvironment: { name: "ssr" }, inspectorPort: false }), |
| 12 | tanstackStart(), | 40 | tanstackStart(), |
| @@ -84,10 +84,16 @@ const addSourceFromConfig = ( · 83 unmodified lines | |||
| 84 | executor: LocalExecutor, | 84 | executor: LocalExecutor, |
| 85 | source: SourceConfig, | 85 | source: SourceConfig, |
| 86 | ): Effect.Effect<void, unknown> => { | 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 | switch (source.kind) { | 92 | switch (source.kind) { |
| 88 | case "openapi": | 93 | case "openapi": |
| 89 | return executor.openapi.addSpec({ | 94 | return executor.openapi.addSpec({ |
| 90 | spec: source.spec, | 95 | spec: source.spec, |
| 96 | scope, | ||
| 91 | baseUrl: source.baseUrl, | 97 | baseUrl: source.baseUrl, |
| 92 | namespace: source.namespace, | 98 | namespace: source.namespace, |
| 93 | headers: translateHeaders(source.headers), | 99 | headers: translateHeaders(source.headers), |
| @@ -96,6 +102,7 @@ const addSourceFromConfig = ( · 2 unmodified lines | |||
| 96 | case "graphql": | 102 | case "graphql": |
| 97 | return executor.graphql.addSource({ | 103 | return executor.graphql.addSource({ |
| 98 | endpoint: source.endpoint, | 104 | endpoint: source.endpoint, |
| 105 | scope, | ||
| 99 | namespace: source.namespace, | 106 | namespace: source.namespace, |
| 100 | headers: translateHeaders(source.headers) as Record<string, string> | undefined, | 107 | headers: translateHeaders(source.headers) as Record<string, string> | undefined, |
| 101 | }).pipe(Effect.asVoid); | 108 | }).pipe(Effect.asVoid); |
| @@ -104,6 +111,7 @@ const addSourceFromConfig = ( · 2 unmodified lines | |||
| 104 | if (source.transport === "stdio") { | 111 | if (source.transport === "stdio") { |
| 105 | return executor.mcp.addSource({ | 112 | return executor.mcp.addSource({ |
| 106 | transport: "stdio", | 113 | transport: "stdio", |
| 114 | scope, | ||
| 107 | name: source.name, | 115 | name: source.name, |
| 108 | command: source.command, | 116 | command: source.command, |
| 109 | args: source.args ? [...source.args] : undefined, | 117 | args: source.args ? [...source.args] : undefined, |
| @@ -114,6 +122,7 @@ const addSourceFromConfig = ( · 4 unmodified lines | |||
| 114 | } | 122 | } |
| 115 | return executor.mcp.addSource({ | 123 | return executor.mcp.addSource({ |
| 116 | transport: "remote", | 124 | transport: "remote", |
| 125 | scope, | ||
| 117 | name: source.name, | 126 | name: source.name, |
| 118 | endpoint: source.endpoint, | 127 | endpoint: source.endpoint, |
| 119 | remoteTransport: source.remoteTransport, | 128 | remoteTransport: source.remoteTransport, |
| @@ -154,7 +154,7 @@ const createLocalExecutorLayer = () => { · 153 unmodified lines | |||
| 154 | }); | 154 | }); |
| 155 | 155 | ||
| 156 | const executor = yield* createExecutor({ | 156 | const executor = yield* createExecutor({ |
| 157 | scope, | 157 | scopes: [scope], |
| 158 | adapter, | 158 | adapter, |
| 159 | blobs, | 159 | blobs, |
| 160 | plugins, | 160 | plugins, |
| @@ -83,11 +83,13 @@ export const POST: APIRoute = async ({ request }) => { · 82 unmodified lines | |||
| 83 | yield* executor.openapi.addSpec({ | 83 | yield* executor.openapi.addSpec({ |
| 84 | spec: match.endpoint, | 84 | spec: match.endpoint, |
| 85 | namespace: match.namespace, | 85 | namespace: match.namespace, |
| 86 | scope: "test-scope", | ||
| 86 | }); | 87 | }); |
| 87 | } else if (match.kind === "graphql") { | 88 | } else if (match.kind === "graphql") { |
| 88 | yield* executor.graphql.addSource({ | 89 | yield* executor.graphql.addSource({ |
| 89 | endpoint: match.endpoint, | 90 | endpoint: match.endpoint, |
| 90 | namespace: match.namespace, | 91 | namespace: match.namespace, |
| 92 | scope: "test-scope", | ||
| 91 | }); | 93 | }); |
| 92 | } else { | 94 | } else { |
| 93 | // For kinds we can't fully add (e.g. Google Discovery needs auth), | 95 | // For kinds we can't fully add (e.g. Google Discovery needs auth), |
| @@ -83,7 +83,7 @@ const plugins = [ · 82 unmodified lines | |||
| 83 | ] as const; | 83 | ] as const; |
| 84 | 84 | ||
| 85 | const config = { | 85 | const config = { |
| 86 | scope, | 86 | scopes: [scope], |
| 87 | adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }), | 87 | adapter: makeMemoryAdapter({ schema: collectSchemas(plugins) }), |
| 88 | blobs: makeInMemoryBlobStore(), | 88 | blobs: makeInMemoryBlobStore(), |
| 89 | plugins, | 89 | plugins, |
| @@ -208,6 +208,7 @@ const program = Effect.gen(function* () { · 118 unmodified lines | |||
| 208 | yield* executor.secrets.set( | 208 | yield* executor.secrets.set( |
| 209 | new SetSecretInput({ | 209 | new SetSecretInput({ |
| 210 | id: SecretId.make("example-api-token"), | 210 | id: SecretId.make("example-api-token"), |
| 211 | scope: "example-scope" as SetSecretInput["scope"], | ||
| 211 | name: "Example API Token", | 212 | name: "Example API Token", |
| 212 | value: "sk-example-redacted", | 213 | value: "sk-example-redacted", |
| 213 | provider: "file", | 214 | provider: "file", |
| @@ -262,6 +263,7 @@ const program = Effect.gen(function* () { · 48 unmodified lines | |||
| 262 | spec: exampleOpenApiSpec, | 263 | spec: exampleOpenApiSpec, |
| 263 | namespace: "example-api", | 264 | namespace: "example-api", |
| 264 | baseUrl: "https://example.com/api", | 265 | baseUrl: "https://example.com/api", |
| 266 | scope: "example-scope", | ||
| 265 | }); | 267 | }); |
| 266 | console.log("Registered OpenAPI source:", addSpecResult); | 268 | console.log("Registered OpenAPI source:", addSpecResult); |
| 267 | 269 | ||
| @@ -371,6 +373,7 @@ const program = Effect.gen(function* () { · 103 unmodified lines | |||
| 371 | endpoint: "https://example.com/graphql", | 373 | endpoint: "https://example.com/graphql", |
| 372 | introspectionJson, | 374 | introspectionJson, |
| 373 | namespace: "example-graphql", | 375 | namespace: "example-graphql", |
| 376 | scope: "example-scope", | ||
| 374 | }); | 377 | }); |
| 375 | console.log("Registered GraphQL source:", gqlResult); | 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 | const executor = await createExecutor({ | 14 | const executor = await createExecutor({ |
| 15 | scope: { name: "my-app" }, | 15 | scopes: [{ id: "my-app", name: "my-app" }], |
| 16 | plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const, | 16 | plugins: [mcpPlugin(), openApiPlugin(), graphqlPlugin()] as const, |
| 17 | }); | 17 | }); |
| 18 | 18 | ||
| @@ -24,6 +24,7 @@ await executor.mcp.addSource({ · 5 unmodified lines | |||
| 24 | transport: "remote", | 24 | transport: "remote", |
| 25 | name: "Context7", | 25 | name: "Context7", |
| 26 | endpoint: "https://mcp.context7.com/mcp", | 26 | endpoint: "https://mcp.context7.com/mcp", |
| 27 | scope: "my-app", | ||
| 27 | }); | 28 | }); |
| 28 | 29 | ||
| 29 | // Stdio server | 30 | // Stdio server |
| @@ -41,6 +42,7 @@ await executor.mcp.addSource({ · 11 unmodified lines | |||
| 41 | await executor.openapi.addSpec({ | 42 | await executor.openapi.addSpec({ |
| 42 | spec: "https://petstore3.swagger.io/api/v3/openapi.json", | 43 | spec: "https://petstore3.swagger.io/api/v3/openapi.json", |
| 43 | namespace: "petstore", | 44 | namespace: "petstore", |
| 45 | scope: "my-app", | ||
| 44 | }); | 46 | }); |
| 45 | 47 | ||
| 46 | // With auth headers (static or secret-backed) | 48 | // With auth headers (static or secret-backed) |
| @@ -62,6 +64,7 @@ await executor.openapi.addSpec({ · 15 unmodified lines | |||
| 62 | await executor.graphql.addSource({ | 64 | await executor.graphql.addSource({ |
| 63 | endpoint: "https://graphql.anilist.co", | 65 | endpoint: "https://graphql.anilist.co", |
| 64 | namespace: "anilist", | 66 | namespace: "anilist", |
| 67 | scope: "my-app", | ||
| 65 | }); | 68 | }); |
| 66 | 69 | ||
| 67 | // --------------------------------------------------------------------------- | 70 | // --------------------------------------------------------------------------- |
| @@ -97,6 +100,7 @@ if (anilistTool) { · 29 unmodified lines | |||
| 97 | await executor.secrets.set( | 100 | await executor.secrets.set( |
| 98 | new SetSecretInput({ | 101 | new SetSecretInput({ |
| 99 | id: SecretId.make("api-key"), | 102 | id: SecretId.make("api-key"), |
| 103 | scope: "my-app" as SetSecretInput["scope"], | ||
| 100 | name: "Shared API Key", | 104 | name: "Shared API Key", |
| 101 | value: "sk_...", | 105 | value: "sk_...", |
| 102 | }), | 106 | }), |
| @@ -66,6 +66,10 @@ type SharedMcpServerConfig = { · 65 unmodified lines | |||
| 66 | * under whichever request triggered it; see the Cloud DO). | 66 | * under whichever request triggered it; see the Cloud DO). |
| 67 | */ | 67 | */ |
| 68 | readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); | 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 | export type ExecutorMcpServerConfig<E extends Cause.YieldableError = Cause.YieldableError> = | 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 | return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; | 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 | const supportsManagedElicitation = (server: McpServer): boolean => | 98 | const supportsManagedElicitation = (server: McpServer): boolean => |
| 89 | getElicitationSupport(server).form; | 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 | type ElicitInputParams = | 107 | type ElicitInputParams = |
| 92 | | { | 108 | | { |
| 93 | mode?: "form"; | 109 | mode?: "form"; |
| @@ -118,7 +134,10 @@ const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputPa · 24 unmodified lines | |||
| 118 | ); | 134 | ); |
| 119 | 135 | ||
| 120 | const makeMcpElicitationHandler = | 136 | const makeMcpElicitationHandler = |
| 121 | (server: McpServer): ElicitationHandler => | 137 | ( |
| 138 | server: McpServer, | ||
| 139 | debugLog?: (event: string, data: Record<string, unknown>) => void, | ||
| 140 | ): ElicitationHandler => | ||
| 122 | (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { | 141 | (ctx: ElicitationContext): Effect.Effect<typeof ElicitationResponse.Type> => { |
| 123 | const { url: supportsUrl } = getElicitationSupport(server); | 142 | const { url: supportsUrl } = getElicitationSupport(server); |
| 124 | 143 | ||
| @@ -133,19 +152,51 @@ const makeMcpElicitationHandler = · 8 unmodified lines | |||
| 133 | : elicitationRequestToParams(ctx.request); | 152 | : elicitationRequestToParams(ctx.request); |
| 134 | 153 | ||
| 135 | return Effect.promise(async (): Promise<typeof ElicitationResponse.Type> => { | 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 | try { | 166 | try { |
| 137 | const response = await server.server.elicitInput( | 167 | const response = await server.server.elicitInput( |
| 138 | params as Parameters<typeof server.server.elicitInput>[0], | 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 | return { | 180 | return { |
| 142 | action: response.action, | 181 | action: response.action, |
| 143 | content: response.content, | 182 | content: response.content, |
| 144 | }; | 183 | }; |
| 145 | } catch (err) { | 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 | console.error( | 193 | console.error( |
| 147 | "[executor] elicitInput failed — falling back to cancel.", | 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 | return { action: "cancel" }; | 201 | return { action: "cancel" }; |
| 151 | } | 202 | } |
| @@ -205,6 +256,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 53 unmodified lines | |||
| 205 | // deferred past the outer Effect's await), so we use the runtime to | 256 | // deferred past the outer Effect's await), so we use the runtime to |
| 206 | // re-enter Effect-land at each callback edge. | 257 | // re-enter Effect-land at each callback edge. |
| 207 | const runtime = yield* Effect.runtime<never>(); | 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 | const resolveParentSpan = (): Tracer.AnySpan | undefined => { | 269 | const resolveParentSpan = (): Tracer.AnySpan | undefined => { |
| 210 | const ps = config.parentSpan; | 270 | const ps = config.parentSpan; |
| @@ -228,13 +288,27 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 17 unmodified lines | |||
| 228 | 288 | ||
| 229 | const executeCode = (code: string): Effect.Effect<McpToolResult, E> => | 289 | const executeCode = (code: string): Effect.Effect<McpToolResult, E> => |
| 230 | Effect.gen(function* () { | 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 | if (supportsManagedElicitation(server)) { | 297 | if (supportsManagedElicitation(server)) { |
| 232 | const result = yield* engine.execute(code, { | 298 | const result = yield* engine.execute(code, { |
| 233 | onElicitation: makeMcpElicitationHandler(server), | 299 | onElicitation: makeMcpElicitationHandler(server, debugLog), |
| 234 | }); | 300 | }); |
| 235 | return toMcpResult(formatExecuteResult(result)); | 301 | return toMcpResult(formatExecuteResult(result)); |
| 236 | } | 302 | } |
| 237 | const outcome = yield* engine.executeWithPause(code); | 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 | return outcome.status === "completed" | 312 | return outcome.status === "completed" |
| 239 | ? toMcpResult(formatExecuteResult(outcome.result)) | 313 | ? toMcpResult(formatExecuteResult(outcome.result)) |
| 240 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); | 314 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); |
| @@ -253,8 +327,15 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 12 unmodified lines | |||
| 253 | content: Record<string, unknown> | undefined, | 327 | content: Record<string, unknown> | undefined, |
| 254 | ): Effect.Effect<McpToolResult, E> => | 328 | ): Effect.Effect<McpToolResult, E> => |
| 255 | Effect.gen(function* () { | 329 | Effect.gen(function* () { |
| 330 | debugLog("resume.call", { | ||
| 331 | executionId, | ||
| 332 | action, | ||
| 333 | hasContent: content !== undefined, | ||
| 334 | clientCapabilities: server.server.getClientCapabilities() ?? null, | ||
| 335 | }); | ||
| 256 | const outcome = yield* engine.resume(executionId, { action, content }); | 336 | const outcome = yield* engine.resume(executionId, { action, content }); |
| 257 | if (!outcome) { | 337 | if (!outcome) { |
| 338 | debugLog("resume.missing_execution", { executionId }); | ||
| 258 | return { | 339 | return { |
| 259 | content: [ | 340 | content: [ |
| 260 | { type: "text" as const, text: `No paused execution: ${executionId}` }, | 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 | isError: true, | 343 | isError: true, |
| 263 | } satisfies McpToolResult; | 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 | return outcome.status === "completed" | 355 | return outcome.status === "completed" |
| 266 | ? toMcpResult(formatExecuteResult(outcome.result)) | 356 | ? toMcpResult(formatExecuteResult(outcome.result)) |
| 267 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); | 357 | : toMcpPausedResult(formatPausedExecution(outcome.execution)); |
| @@ -331,6 +421,19 @@ export const createExecutorMcpServer = <E extends Cause.YieldableError>( · 63 unmodified lines | |||
| 331 | } else { | 421 | } else { |
| 332 | resumeTool.enable(); | 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 | yield* Effect.sync(() => { | 439 | yield* Effect.sync(() => { |