Showing the changes to apps/cloud/src/engine/execution-stack.ts (1 of 4 files). Show all
Unified · Split
| @@ -41,6 +41,7 @@ import { · 40 unmodified lines | ||
| 41 | 41 | HostConfig, |
| 42 | 42 | PluginsProvider, |
| 43 | 43 | collectTables, |
| 44 | + type HostConfigShape, | |
| 44 | 45 | } from "@executor-js/api/server"; |
| 45 | 46 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 46 | 47 | import type { AnyPlugin } from "@executor-js/sdk"; |
| @@ -88,18 +89,69 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 41 unmodified lines | ||
| 88 | 89 | */ |
| 89 | 90 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 90 | 91 | |
| 91 | −export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ | |
| 92 | − // SSRF / private-network egress guard. Config-driven, NOT a test flag: | |
| 93 | − // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); | |
| 94 | − // the e2e dev-server env opts in with `"true"` so in-scenario fixture | |
| 95 | − // servers on localhost are reachable. See `hosted-http-client.ts`. | |
| 96 | − allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", | |
| 97 | − webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", | |
| 98 | − oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`, | |
| 99 | − // WorkOS Vault is cloud's credential storage implementation detail, not a | |
| 100 | − // user-selectable provider surface. | |
| 101 | − exposeCredentialProviders: false, | |
| 102 | −})); | |
| 92 | +/** | |
| 93 | + * Cloud's host config, parameterized by the ONE seam its two execution planes | |
| 94 | + * genuinely disagree about: where a deferred tool-catalog refresh runs. | |
| 95 | + * | |
| 96 | + * The MCP session Durable Object holds its database handle for the life of the | |
| 97 | + * session, so it can hand the batch straight to `ctx.waitUntil` and forget it. | |
| 98 | + * The HTTP plane's handle dies with its request and gets no seam at all (see | |
| 99 | + * {@link CloudHostConfig}). Nothing else about the config differs, which is why | |
| 100 | + * this is a parameter rather than a second config module. | |
| 101 | + */ | |
| 102 | +export const makeCloudHostConfig = ( | |
| 103 | + deferToolSync: HostConfigShape["deferToolSync"], | |
| 104 | +): Layer.Layer<HostConfig> => | |
| 105 | + Layer.sync(HostConfig, () => ({ | |
| 106 | + // SSRF / private-network egress guard. Config-driven, NOT a test flag: | |
| 107 | + // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); | |
| 108 | + // the e2e dev-server env opts in with `"true"` so in-scenario fixture | |
| 109 | + // servers on localhost are reachable. See `hosted-http-client.ts`. | |
| 110 | + allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", | |
| 111 | + webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", | |
| 112 | + oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`, | |
| 113 | + // WorkOS Vault is cloud's credential storage implementation detail, not a | |
| 114 | + // user-selectable provider surface. | |
| 115 | + exposeCredentialProviders: false, | |
| 116 | + deferToolSync, | |
| 117 | + })); | |
| 118 | + | |
| 119 | +/** | |
| 120 | + * The HTTP plane's config, and the one seam it CANNOT fill: `deferToolSync` is | |
| 121 | + * absent, so a tools read over `/api/*` still refreshes an expired catalog | |
| 122 | + * inline. | |
| 123 | + * | |
| 124 | + * Not an omission. The postgres pool a request's executor closes over is | |
| 125 | + * acquired and released inside `requestScopedMiddleware`'s `Effect.scoped`, | |
| 126 | + * which wraps the ROUTE HANDLER — so the pool is torn down while the handler's | |
| 127 | + * `HttpServerResponse` is still a value, strictly before `HttpEffect.toHandled` | |
| 128 | + * writes it. There is therefore no window on this plane that is both after the | |
| 129 | + * response and before the socket closes: work scheduled with `waitUntil` finds | |
| 130 | + * a dead pool, and work drained inside the scope is simply inline work with | |
| 131 | + * extra steps. `api.request-scope.node.test.ts` pins that ordering, so a future | |
| 132 | + * change to the request lifecycle will say so rather than making this comment | |
| 133 | + * quietly wrong. | |
| 134 | + * | |
| 135 | + * Holding the pool open past the response instead — deferring the scope close | |
| 136 | + * into `waitUntil` — is the one thing that would make the window exist, and it | |
| 137 | + * is the thing `closePostgres` was written to prevent. That finalizer awaits | |
| 138 | + * the teardown precisely so live-plus-closing sockets stay bounded by what is | |
| 139 | + * in flight; unbounded, the backlog is the sustained-load cascade recorded in | |
| 140 | + * `db/db.ts`. Trading a socket leak for a background refresh is not a trade | |
| 141 | + * this plane should make, and it would make it on EVERY request, not just the | |
| 142 | + * ones that deferred. | |
| 143 | + * | |
| 144 | + * The alternative — giving the batch a short-lived pool of its own via | |
| 145 | + * `makeDbLayer()` — does not fit either: the task the sdk hands over is already | |
| 146 | + * bound to the executor that built it, so re-targeting it means rebuilding a | |
| 147 | + * whole second executor in the background. That is a background scheduler, a | |
| 148 | + * different feature with a different failure surface, not a wiring detail. | |
| 149 | + * | |
| 150 | + * Cloud's high-volume tools read is `tools/list` over MCP, and that plane DOES | |
| 151 | + * defer: the session Durable Object holds its database handle for the life of | |
| 152 | + * the session (see `mcp/session-durable-object.ts`). | |
| 153 | + */ | |
| 154 | +export const CloudHostConfig: Layer.Layer<HostConfig> = makeCloudHostConfig(undefined); | |
| 103 | 155 | |
| 104 | 156 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| 105 | 157 | CodeExecutorProvider, |
| @@ -114,13 +166,16 @@ export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Laye · 8 unmodified lines | ||
| 114 | 166 | * exported so that overlay builds over the SAME four seams. There is no neutral |
| 115 | 167 | * no-op-decorator variant anymore: every cloud execution meters. |
| 116 | 168 | */ |
| 169 | +export const makeCloudExecutionSeamsLayer = ( | |
| 170 | + hostConfig: Layer.Layer<HostConfig>, | |
| 171 | +): Layer.Layer< | |
| 172 | + DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider, | |
| 173 | + never, | |
| 174 | + DbService | |
| 175 | +> => Layer.mergeAll(CloudDbProvider, CloudPluginsProvider, hostConfig, CloudCodeExecutorProvider); | |
| 176 | + | |
| 117 | 177 | export const CloudExecutionSeamsLayer: Layer.Layer< |
| 118 | 178 | DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider, |
| 119 | 179 | never, |
| 120 | 180 | DbService |
| 121 | −> = Layer.mergeAll( | |
| 122 | − CloudDbProvider, | |
| 123 | − CloudPluginsProvider, | |
| 124 | − CloudHostConfig, | |
| 125 | − CloudCodeExecutorProvider, | |
| 126 | −); | |
| 181 | +> = makeCloudExecutionSeamsLayer(CloudHostConfig); | |