Unified · Split
| @@ -17,21 +17,15 @@ The API route now runs the control-plane server in-process. · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | Database selection priority: | 18 | Database selection priority: |
| 19 | 19 | ||
| 20 | - `CONTROL_PLANE_DATABASE_URL` | ||
| 21 | - `DATABASE_URL` | 20 | - `DATABASE_URL` |
| 22 | - `POSTGRES_URL` | ||
| 23 | 21 | ||
| 24 | If none are set, fallback is local PGlite data at: | 22 | If none are set, fallback is local PGlite data at: |
| 25 | 23 | ||
| 26 | - `CONTROL_PLANE_DATA_DIR` | ||
| 27 | - default: `.executor-v2/web-state/control-plane-pgdata` | 24 | - default: `.executor-v2/web-state/control-plane-pgdata` |
| 28 | 25 | ||
| 29 | If needed, `NEXT_PUBLIC_CONTROL_PLANE_BASE_URL` can override the browser base URL. | ||
| 30 | |||
| 31 | MCP install URL generation: | 26 | MCP install URL generation: |
| 32 | 27 | ||
| 33 | - Derives from existing control-plane/frontend config. | 28 | - Derives from existing frontend origin and deployment metadata. |
| 34 | - Prioritizes server-side `CONTROL_PLANE_UPSTREAM_URL` (or server/base control-plane URL) when available. | ||
| 35 | - In local dev with no DB URL, defaults to local PGlite-backed control-plane in this Next.js app. | 29 | - In local dev with no DB URL, defaults to local PGlite-backed control-plane in this Next.js app. |
| 36 | 30 | ||
| 37 | WorkOS auth setup (optional but recommended): | 31 | WorkOS auth setup (optional but recommended): |
| @@ -1,39 +1,8 @@ | |||
| 1 | import { ToolsView } from "../../../components/tools/tools-view"; | 1 | import { ToolsView } from "../../../components/tools/tools-view"; |
| 2 | 2 | import { configuredExternalOriginFromEnv } from "../../../lib/external-origin"; | |
| 3 | const normalizeOriginCandidate = (value: string | undefined): string | null => { | ||
| 4 | const trimmed = value?.trim(); | ||
| 5 | if (!trimmed) { | ||
| 6 | return null; | ||
| 7 | } | ||
| 8 | |||
| 9 | const withProtocol = | ||
| 10 | trimmed.startsWith("http://") || trimmed.startsWith("https://") | ||
| 11 | ? trimmed | ||
| 12 | : `https://${trimmed}`; | ||
| 13 | |||
| 14 | try { | ||
| 15 | return new URL(withProtocol).origin; | ||
| 16 | } catch { | ||
| 17 | return null; | ||
| 18 | } | ||
| 19 | }; | ||
| 20 | 3 | ||
| 21 | const resolveMcpBaseUrl = (): string | null => { | 4 | const resolveMcpBaseUrl = (): string | null => { |
| 22 | const candidates = [ | 5 | return configuredExternalOriginFromEnv(); |
| 23 | process.env.EXECUTOR_PUBLIC_ORIGIN, | ||
| 24 | process.env.NEXT_PUBLIC_APP_ORIGIN, | ||
| 25 | process.env.VERCEL_PROJECT_PRODUCTION_URL, | ||
| 26 | process.env.VERCEL_URL, | ||
| 27 | ]; | ||
| 28 | |||
| 29 | for (const candidate of candidates) { | ||
| 30 | const origin = normalizeOriginCandidate(candidate); | ||
| 31 | if (origin) { | ||
| 32 | return origin; | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | return null; | ||
| 37 | }; | 6 | }; |
| 38 | 7 | ||
| 39 | const ToolsPage = () => <ToolsView mcpBaseUrl={resolveMcpBaseUrl()} />; | 8 | const ToolsPage = () => <ToolsView mcpBaseUrl={resolveMcpBaseUrl()} />; |
| @@ -65,9 +65,6 @@ type SourceGroup = { · 64 unmodified lines | |||
| 65 | toolCount: number; | 65 | toolCount: number; |
| 66 | }; | 66 | }; |
| 67 | 67 | ||
| 68 | const configuredControlPlaneBaseUrl = | ||
| 69 | process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL?.trim() ?? ""; | ||
| 70 | |||
| 71 | const isLocalHost = (hostname: string): boolean => | 68 | const isLocalHost = (hostname: string): boolean => |
| 72 | hostname === "localhost" | 69 | hostname === "localhost" |
| 73 | || hostname === "127.0.0.1" | 70 | || hostname === "127.0.0.1" |
| @@ -406,13 +403,6 @@ export function ToolsView(props: { · 332 unmodified lines | |||
| 406 | return configured.toString(); | 403 | return configured.toString(); |
| 407 | } | 404 | } |
| 408 | 405 | ||
| 409 | if (configuredControlPlaneBaseUrl.length > 0 && /^https?:\/\//i.test(configuredControlPlaneBaseUrl)) { | ||
| 410 | const configured = new URL(configuredControlPlaneBaseUrl); | ||
| 411 | configured.pathname = "/v1/mcp"; | ||
| 412 | configured.searchParams.set("workspaceId", workspaceId); | ||
| 413 | return configured.toString(); | ||
| 414 | } | ||
| 415 | |||
| 416 | if (typeof window === "undefined") { | 406 | if (typeof window === "undefined") { |
| 417 | return `http://127.0.0.1:8788/v1/mcp?workspaceId=${encodeURIComponent(workspaceId)}`; | 407 | return `http://127.0.0.1:8788/v1/mcp?workspaceId=${encodeURIComponent(workspaceId)}`; |
| 418 | } | 408 | } |
| @@ -117,30 +117,39 @@ const toAccountScopedIds = (subject: string) => { · 116 unmodified lines | |||
| 117 | }; | 117 | }; |
| 118 | 118 | ||
| 119 | const resolveDatabaseUrl = (): string | undefined => { | 119 | const resolveDatabaseUrl = (): string | undefined => { |
| 120 | const override = trim(process.env.CONTROL_PLANE_DATABASE_URL); | 120 | const value = trim(process.env.DATABASE_URL); |
| 121 | if (override) { | 121 | return value ? deriveRuntimeDatabaseUrl(value) : undefined; |
| 122 | return override; | ||
| 123 | } | ||
| 124 | |||
| 125 | const candidates = [process.env.DATABASE_URL, process.env.POSTGRES_URL]; | ||
| 126 | |||
| 127 | for (const candidate of candidates) { | ||
| 128 | const value = trim(candidate); | ||
| 129 | if (value) { | ||
| 130 | return deriveRuntimeDatabaseUrl(value); | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | return undefined; | ||
| 135 | }; | 122 | }; |
| 136 | 123 | ||
| 137 | const resolveControlPlaneDataDir = (): string => | 124 | const resolveControlPlaneDataDir = (): string => |
| 138 | trim(process.env.CONTROL_PLANE_DATA_DIR) ?? defaultControlPlaneDataDir; | 125 | defaultControlPlaneDataDir; |
| 139 | 126 | ||
| 140 | const resolveStateRootDir = (): string => | 127 | const resolveStateRootDir = (): string => |
| 141 | trim(process.env.CONTROL_PLANE_STATE_ROOT_DIR) ?? defaultControlPlaneStateRootDir; | 128 | defaultControlPlaneStateRootDir; |
| 129 | |||
| 130 | const openApiSyncRetryDelayMs = 300; | ||
| 142 | 131 | ||
| 143 | const formatCause = (cause: unknown): string => { | 132 | const formatCause = (cause: unknown): string => { |
| 133 | if (cause && typeof cause === "object") { | ||
| 134 | const maybeError = cause as { | ||
| 135 | message?: unknown; | ||
| 136 | details?: unknown; | ||
| 137 | }; | ||
| 138 | |||
| 139 | const details = typeof maybeError.details === "string" ? maybeError.details.trim() : ""; | ||
| 140 | const message = typeof maybeError.message === "string" ? maybeError.message.trim() : ""; | ||
| 141 | |||
| 142 | if (details.length > 0) { | ||
| 143 | return message.length > 0 && message !== details | ||
| 144 | ? `${message}: ${details}` | ||
| 145 | : details; | ||
| 146 | } | ||
| 147 | |||
| 148 | if (message.length > 0) { | ||
| 149 | return message; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 144 | if (cause instanceof Error) { | 153 | if (cause instanceof Error) { |
| 145 | return cause.message; | 154 | return cause.message; |
| 146 | } | 155 | } |
| @@ -252,6 +261,18 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 105 unmodified lines | |||
| 252 | const sourceManager = makeSourceManagerService(toolArtifactStore); | 261 | const sourceManager = makeSourceManagerService(toolArtifactStore); |
| 253 | const baseSourcesService = makeControlPlaneSourcesService(sourceCatalog); | 262 | const baseSourcesService = makeControlPlaneSourcesService(sourceCatalog); |
| 254 | 263 | ||
| 264 | const fetchOpenApiSpec = (endpoint: string) => | ||
| 265 | Effect.tryPromise({ | ||
| 266 | try: () => fetchOpenApiDocument(endpoint), | ||
| 267 | catch: (cause) => String(cause), | ||
| 268 | }).pipe(Effect.either); | ||
| 269 | |||
| 270 | const refreshOpenApiTools = (input: { | ||
| 271 | source: Parameters<typeof sourceManager.refreshOpenApiArtifact>[0]["source"]; | ||
| 272 | openApiSpec: unknown; | ||
| 273 | }) => | ||
| 274 | sourceManager.refreshOpenApiArtifact(input).pipe(Effect.either); | ||
| 275 | |||
| 255 | const persistSourceErrorState = (source: Parameters<typeof sourceStore.upsert>[0], message: string) => { | 276 | const persistSourceErrorState = (source: Parameters<typeof sourceStore.upsert>[0], message: string) => { |
| 256 | const failedSource = { | 277 | const failedSource = { |
| 257 | ...source, | 278 | ...source, |
| @@ -276,10 +297,11 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 18 unmodified lines | |||
| 276 | return source; | 297 | return source; |
| 277 | } | 298 | } |
| 278 | 299 | ||
| 279 | const openApiSpecResult = yield* Effect.tryPromise({ | 300 | let openApiSpecResult = yield* fetchOpenApiSpec(source.endpoint); |
| 280 | try: () => fetchOpenApiDocument(source.endpoint), | 301 | if (openApiSpecResult._tag === "Left") { |
| 281 | catch: (cause) => String(cause), | 302 | yield* Effect.sleep(openApiSyncRetryDelayMs); |
| 282 | }).pipe(Effect.either); | 303 | openApiSpecResult = yield* fetchOpenApiSpec(source.endpoint); |
| 304 | } | ||
| 283 | 305 | ||
| 284 | if (openApiSpecResult._tag === "Left") { | 306 | if (openApiSpecResult._tag === "Left") { |
| 285 | const details = formatCause(openApiSpecResult.left); | 307 | const details = formatCause(openApiSpecResult.left); |
| @@ -294,12 +316,17 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 8 unmodified lines | |||
| 294 | return yield* persistSourceErrorState(source, message); | 316 | return yield* persistSourceErrorState(source, message); |
| 295 | } | 317 | } |
| 296 | 318 | ||
| 297 | const refreshedResult = yield* sourceManager | 319 | let refreshedResult = yield* refreshOpenApiTools({ |
| 298 | .refreshOpenApiArtifact({ | 320 | source, |
| 321 | openApiSpec: openApiSpecResult.right, | ||
| 322 | }); | ||
| 323 | if (refreshedResult._tag === "Left") { | ||
| 324 | yield* Effect.sleep(openApiSyncRetryDelayMs); | ||
| 325 | refreshedResult = yield* refreshOpenApiTools({ | ||
| 299 | source, | 326 | source, |
| 300 | openApiSpec: openApiSpecResult.right, | 327 | openApiSpec: openApiSpecResult.right, |
| 301 | }) | 328 | }); |
| 302 | .pipe(Effect.either); | 329 | } |
| 303 | 330 | ||
| 304 | if (refreshedResult._tag === "Left") { | 331 | if (refreshedResult._tag === "Left") { |
| 305 | const details = formatCause(refreshedResult.left); | 332 | const details = formatCause(refreshedResult.left); |
| @@ -0,0 +1,35 @@ | |||
| 1 | const normalizeOriginCandidate = (value: string | undefined): string | null => { | ||
| 2 | const trimmed = value?.trim(); | ||
| 3 | if (!trimmed) { | ||
| 4 | return null; | ||
| 5 | } | ||
| 6 | |||
| 7 | const withProtocol = | ||
| 8 | trimmed.startsWith("http://") || trimmed.startsWith("https://") | ||
| 9 | ? trimmed | ||
| 10 | : `https://${trimmed}`; | ||
| 11 | |||
| 12 | try { | ||
| 13 | return new URL(withProtocol).origin; | ||
| 14 | } catch { | ||
| 15 | return null; | ||
| 16 | } | ||
| 17 | }; | ||
| 18 | |||
| 19 | export const configuredExternalOriginFromEnv = (): string | null => { | ||
| 20 | const candidates = [ | ||
| 21 | process.env.EXECUTOR_PUBLIC_ORIGIN, | ||
| 22 | process.env.NEXT_PUBLIC_APP_ORIGIN, | ||
| 23 | process.env.VERCEL_PROJECT_PRODUCTION_URL, | ||
| 24 | process.env.VERCEL_URL, | ||
| 25 | ]; | ||
| 26 | |||
| 27 | for (const candidate of candidates) { | ||
| 28 | const origin = normalizeOriginCandidate(candidate); | ||
| 29 | if (origin) { | ||
| 30 | return origin; | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | return null; | ||
| 35 | }; | ||
| @@ -1,3 +1,5 @@ | |||
| 1 | import { configuredExternalOriginFromEnv } from "../external-origin"; | ||
| 2 | |||
| 1 | const firstHeaderValue = (value: string | null): string => { | 3 | const firstHeaderValue = (value: string | null): string => { |
| 2 | if (!value) { | 4 | if (!value) { |
| 3 | return ""; | 5 | return ""; |
| @@ -6,32 +8,8 @@ const firstHeaderValue = (value: string | null): string => { · 2 unmodified lines | |||
| 6 | return value.split(",")[0]?.trim() ?? ""; | 8 | return value.split(",")[0]?.trim() ?? ""; |
| 7 | }; | 9 | }; |
| 8 | 10 | ||
| 9 | const configuredExternalOrigin = (): string | null => { | ||
| 10 | const configured = | ||
| 11 | process.env.EXECUTOR_PUBLIC_ORIGIN | ||
| 12 | ?? process.env.NEXT_PUBLIC_APP_ORIGIN | ||
| 13 | ?? process.env.VERCEL_PROJECT_PRODUCTION_URL | ||
| 14 | ?? process.env.VERCEL_URL | ||
| 15 | ?? ""; | ||
| 16 | |||
| 17 | if (!configured.trim()) { | ||
| 18 | return null; | ||
| 19 | } | ||
| 20 | |||
| 21 | try { | ||
| 22 | const normalized = | ||
| 23 | configured.startsWith("http://") || configured.startsWith("https://") | ||
| 24 | ? configured | ||
| 25 | : `https://${configured}`; | ||
| 26 | |||
| 27 | return new URL(normalized).origin; | ||
| 28 | } catch { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | }; | ||
| 32 | |||
| 33 | export const getExternalOrigin = (request: Request): string => { | 11 | export const getExternalOrigin = (request: Request): string => { |
| 34 | const configured = configuredExternalOrigin(); | 12 | const configured = configuredExternalOriginFromEnv(); |
| 35 | if (configured) { | 13 | if (configured) { |
| 36 | return configured; | 14 | return configured; |
| 37 | } | 15 | } |
| @@ -80,16 +80,6 @@ const trim = (value: string | undefined): string | undefined => { · 79 unmodified lines | |||
| 80 | }; | 80 | }; |
| 81 | 81 | ||
| 82 | const resolvePostgresMaxConnections = (): number => { | 82 | const resolvePostgresMaxConnections = (): number => { |
| 83 | const configured = process.env.CONTROL_PLANE_POSTGRES_MAX_CONNECTIONS | ||
| 84 | ?? process.env.POSTGRES_MAX_CONNECTIONS; | ||
| 85 | |||
| 86 | if (configured) { | ||
| 87 | const parsed = Number.parseInt(configured, 10); | ||
| 88 | if (Number.isFinite(parsed) && parsed > 0) { | ||
| 89 | return parsed; | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | return process.env.NODE_ENV === "production" ? 1 : 10; | 83 | return process.env.NODE_ENV === "production" ? 1 : 10; |
| 94 | }; | 84 | }; |
| 95 | 85 | ||