Unified · Split
| @@ -17,21 +17,15 @@ The API route now runs the control-plane server in-process. · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | Database selection priority: |
| 19 | 19 | |
| 20 | −- `CONTROL_PLANE_DATABASE_URL` | |
| 21 | 20 | - `DATABASE_URL` |
| 22 | −- `POSTGRES_URL` | |
| 23 | 21 | |
| 24 | 22 | If none are set, fallback is local PGlite data at: |
| 25 | 23 | |
| 26 | −- `CONTROL_PLANE_DATA_DIR` | |
| 27 | 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 | 26 | MCP install URL generation: |
| 32 | 27 | |
| 33 | −- Derives from existing control-plane/frontend config. | |
| 34 | −- Prioritizes server-side `CONTROL_PLANE_UPSTREAM_URL` (or server/base control-plane URL) when available. | |
| 28 | +- Derives from existing frontend origin and deployment metadata. | |
| 35 | 29 | - In local dev with no DB URL, defaults to local PGlite-backed control-plane in this Next.js app. |
| 36 | 30 | |
| 37 | 31 | WorkOS auth setup (optional but recommended): |
| @@ -1,39 +1,8 @@ | ||
| 1 | 1 | import { ToolsView } from "../../../components/tools/tools-view"; |
| 2 | − | |
| 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 | −}; | |
| 2 | +import { configuredExternalOriginFromEnv } from "../../../lib/external-origin"; | |
| 20 | 3 | |
| 21 | 4 | const resolveMcpBaseUrl = (): string | null => { |
| 22 | − const candidates = [ | |
| 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; | |
| 5 | + return configuredExternalOriginFromEnv(); | |
| 37 | 6 | }; |
| 38 | 7 | |
| 39 | 8 | const ToolsPage = () => <ToolsView mcpBaseUrl={resolveMcpBaseUrl()} />; |
| @@ -65,9 +65,6 @@ type SourceGroup = { · 64 unmodified lines | ||
| 65 | 65 | toolCount: number; |
| 66 | 66 | }; |
| 67 | 67 | |
| 68 | −const configuredControlPlaneBaseUrl = | |
| 69 | − process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL?.trim() ?? ""; | |
| 70 | − | |
| 71 | 68 | const isLocalHost = (hostname: string): boolean => |
| 72 | 69 | hostname === "localhost" |
| 73 | 70 | || hostname === "127.0.0.1" |
| @@ -406,13 +403,6 @@ export function ToolsView(props: { · 332 unmodified lines | ||
| 406 | 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 | 406 | if (typeof window === "undefined") { |
| 417 | 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 | 119 | const resolveDatabaseUrl = (): string | undefined => { |
| 120 | − const override = trim(process.env.CONTROL_PLANE_DATABASE_URL); | |
| 121 | − if (override) { | |
| 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; | |
| 120 | + const value = trim(process.env.DATABASE_URL); | |
| 121 | + return value ? deriveRuntimeDatabaseUrl(value) : undefined; | |
| 135 | 122 | }; |
| 136 | 123 | |
| 137 | 124 | const resolveControlPlaneDataDir = (): string => |
| 138 | − trim(process.env.CONTROL_PLANE_DATA_DIR) ?? defaultControlPlaneDataDir; | |
| 125 | + defaultControlPlaneDataDir; | |
| 139 | 126 | |
| 140 | 127 | const resolveStateRootDir = (): string => |
| 141 | − trim(process.env.CONTROL_PLANE_STATE_ROOT_DIR) ?? defaultControlPlaneStateRootDir; | |
| 128 | + defaultControlPlaneStateRootDir; | |
| 129 | + | |
| 130 | +const openApiSyncRetryDelayMs = 300; | |
| 142 | 131 | |
| 143 | 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 | 153 | if (cause instanceof Error) { |
| 145 | 154 | return cause.message; |
| 146 | 155 | } |
| @@ -252,6 +261,18 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 105 unmodified lines | ||
| 252 | 261 | const sourceManager = makeSourceManagerService(toolArtifactStore); |
| 253 | 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 | 276 | const persistSourceErrorState = (source: Parameters<typeof sourceStore.upsert>[0], message: string) => { |
| 256 | 277 | const failedSource = { |
| 257 | 278 | ...source, |
| @@ -276,10 +297,11 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 18 unmodified lines | ||
| 276 | 297 | return source; |
| 277 | 298 | } |
| 278 | 299 | |
| 279 | − const openApiSpecResult = yield* Effect.tryPromise({ | |
| 280 | − try: () => fetchOpenApiDocument(source.endpoint), | |
| 281 | − catch: (cause) => String(cause), | |
| 282 | − }).pipe(Effect.either); | |
| 300 | + let openApiSpecResult = yield* fetchOpenApiSpec(source.endpoint); | |
| 301 | + if (openApiSpecResult._tag === "Left") { | |
| 302 | + yield* Effect.sleep(openApiSyncRetryDelayMs); | |
| 303 | + openApiSpecResult = yield* fetchOpenApiSpec(source.endpoint); | |
| 304 | + } | |
| 283 | 305 | |
| 284 | 306 | if (openApiSpecResult._tag === "Left") { |
| 285 | 307 | const details = formatCause(openApiSpecResult.left); |
| @@ -294,12 +316,17 @@ const createControlPlaneRuntime = async (): Promise<ControlPlaneRuntime> => { · 8 unmodified lines | ||
| 294 | 316 | return yield* persistSourceErrorState(source, message); |
| 295 | 317 | } |
| 296 | 318 | |
| 297 | − const refreshedResult = yield* sourceManager | |
| 298 | − .refreshOpenApiArtifact({ | |
| 319 | + let refreshedResult = yield* refreshOpenApiTools({ | |
| 320 | + source, | |
| 321 | + openApiSpec: openApiSpecResult.right, | |
| 322 | + }); | |
| 323 | + if (refreshedResult._tag === "Left") { | |
| 324 | + yield* Effect.sleep(openApiSyncRetryDelayMs); | |
| 325 | + refreshedResult = yield* refreshOpenApiTools({ | |
| 299 | 326 | source, |
| 300 | 327 | openApiSpec: openApiSpecResult.right, |
| 301 | − }) | |
| 302 | − .pipe(Effect.either); | |
| 328 | + }); | |
| 329 | + } | |
| 303 | 330 | |
| 304 | 331 | if (refreshedResult._tag === "Left") { |
| 305 | 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 | 3 | const firstHeaderValue = (value: string | null): string => { |
| 2 | 4 | if (!value) { |
| 3 | 5 | return ""; |
| @@ -6,32 +8,8 @@ const firstHeaderValue = (value: string | null): string => { · 2 unmodified lines | ||
| 6 | 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 | 11 | export const getExternalOrigin = (request: Request): string => { |
| 34 | − const configured = configuredExternalOrigin(); | |
| 12 | + const configured = configuredExternalOriginFromEnv(); | |
| 35 | 13 | if (configured) { |
| 36 | 14 | return configured; |
| 37 | 15 | } |
| @@ -80,16 +80,6 @@ const trim = (value: string | undefined): string | undefined => { · 79 unmodified lines | ||
| 80 | 80 | }; |
| 81 | 81 | |
| 82 | 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 | 83 | return process.env.NODE_ENV === "production" ? 1 : 10; |
| 94 | 84 | }; |
| 95 | 85 | |