Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
outputs:
desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}
cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }}
steps:
- uses: actions/checkout@v4

Expand All @@ -41,6 +42,14 @@ jobs:
- "packages/kernel/runtime-quickjs/**"
- "packages/plugins/**"
- "packages/react/**"
cloud_closure:
- ".github/workflows/**"
- "bun.lock"
- "package.json"
- "turbo.json"
- "apps/cloud/**"
- "packages/**"

selfhost_docker_smoke:
- ".github/workflows/**"
- ".dockerignore"
Expand Down Expand Up @@ -302,6 +311,50 @@ jobs:
path: e2e/runs/
retention-days: 7

cloud-closure:
name: Cloud evaluated closure
needs: changes
if: needs.changes.outputs.cloud_closure == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
env:
TURBO_API: ${{ vars.TURBO_API }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
# Every cold isolate evaluates this closure before it can answer a
# request, and it only ever grows by accident - a barrel export or a new
# module-scope import quietly pulls megabytes into the server graph. The
# budget is a ratchet just above today's size, not a discovered limit:
# when it trips, make the new dependency lazy rather than raising it.
START_CLOSURE_BUDGET_MB: 13.5
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11

- name: Cache Bun package cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-1.3.11-

- uses: actions/setup-node@v4
with:
node-version: 24

- run: bun install --frozen-lockfile

- run: bun run build
working-directory: apps/cloud

- run: node scripts/start-closure.mjs
working-directory: apps/cloud

desktop-smoke:
name: Desktop smoke build
needs: changes
Expand Down
1 change: 0 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"@executor-js/runtime-quickjs": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@jitl/quickjs-wasmfile-release-sync": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@sentry/bun": "^10.57.0",
"effect": "catalog:",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Cause from "effect/Cause";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";

Expand Down
115 changes: 115 additions & 0 deletions apps/cloud/scripts/start-closure.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// ---------------------------------------------------------------------------
// Measure the Worker's *evaluated* module closures from the build output.
// ---------------------------------------------------------------------------
//
// Upload size does not predict cold-isolate cost: a Worker can ship megabytes
// that never load, and the bytes that matter are the ones an isolate must
// evaluate before it can answer. Rollup already separates static from dynamic
// edges, so we can compute that split directly instead of reading totals.
//
// Two closures matter:
// startup - statically reachable from the Worker entry. Evaluated on every
// cold isolate before any request is served.
// start - the TanStack Start server graph, reached through the lazy
// `loadEntries` dynamic imports. Evaluated on the first request
// that enters the app.
//
// Anything reachable only through a dynamic import is not counted: making a
// heavy dependency lazy is exactly the outcome this rewards.
//
// Scope note: this measures bytes, which is a proxy for cold-start cost, not a
// proven cause of any particular regression. The Aug 2026 MCP incident was
// NOT explained by this number - reverting the offending packages moved the
// evaluated closure by 0.02 MB while restoring production latency, so
// module-scope execution cost, not size, drove that one. Treat a budget breach
// as "this will make cold starts worse", not as "this is why the site is slow".
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";

const DIST = resolve(process.argv[2] ?? "dist/server");
const ENTRY = join(DIST, "index.js");

// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`
// (all static) and `import("./x.js")` (dynamic). Matching the emitted output
// rather than source means we see the graph as the runtime sees it.
const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;

const listChunks = (dir) =>
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory()
? listChunks(join(dir, e.name))
: e.name.endsWith(".js")
? [join(dir, e.name)]
: [],
);

const graph = new Map();
for (const file of listChunks(DIST)) {
const code = readFileSync(file, "utf8");
const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1])));
// A specifier inside `import(...)` also matches STATIC_RE's `import` branch,
// so subtract the dynamic set rather than trusting the static matches alone.
const staticDeps = new Set(
[...code.matchAll(STATIC_RE)]
.map((m) => resolve(dirname(file), m[1]))
.filter((p) => !dynamic.has(p)),
);
graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });
}

/** Bytes evaluated when `roots` are loaded, following static edges only. */
const closure = (roots) => {
const seen = new Set();
const queue = [...roots];
while (queue.length > 0) {
const file = queue.pop();
if (seen.has(file) || !graph.has(file)) continue;
seen.add(file);
queue.push(...graph.get(file).static);
}
return seen;
};

const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
const name = (f) => relative(DIST, f);

const startup = closure([ENTRY]);
// The lazy server-graph entries Start pulls on first request.
const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
/(start|router|tanstack)/.test(name(f)),
);
const start = closure(startRoots);
const evaluated = new Set([...startup, ...start]);

const report = (label, files) => {
console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`);
const own = [...files].filter((f) => !startup.has(f) || label === "startup");
for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) {
console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`);
}
};

report("startup", startup);
report("start", start);
console.log(`\ntotal evaluated on a warm-path request: ${mb(bytes(evaluated))}`);
const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
console.log(
`deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
);

const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
if (budget > 0) {
const actual = bytes(evaluated) / 1024 / 1024;
console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`);
if (actual > budget) {
console.error(
`\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` +
`Every cold isolate pays to evaluate this closure before it can answer. Move the\n` +
`new weight behind a dynamic import rather than raising the budget; run this\n` +
`script with no budget set to see the biggest members and what is already lazy.`,
);
process.exit(1);
}
}
6 changes: 0 additions & 6 deletions apps/cloud/src/auth/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
Effect.gen(function* () {
const owner = yield* requireSelectedOrganization;
const stub = getMcpSessionStub(params.mcpSessionId);
if (!stub) {
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
}
const result = yield* Effect.promise(() =>
stub.getPausedExecutionForApproval(params.executionId, {
accountId: owner.accountId,
Expand All @@ -655,9 +652,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group(
Effect.gen(function* () {
const owner = yield* requireSelectedOrganization;
const stub = getMcpSessionStub(params.mcpSessionId);
if (!stub) {
return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
}
const result = yield* Effect.promise(() =>
stub.resumeExecutionForApproval(
params.executionId,
Expand Down
4 changes: 0 additions & 4 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,6 @@ declare global {
MCP_RESOURCE_ORIGIN?: string;
MCP_SESSION_TIMEOUT_MS?: string;
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
/** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */
MCP_REQUEST_STATE_KEY?: string;
/** Emergency rollback for inbound MCP 2026-07-28 traffic only. */
MCP_2026_07_28_ENABLED?: string;
NODE_ENV?: string;

// Shared with frontend
Expand Down
10 changes: 3 additions & 7 deletions apps/cloud/src/mcp-session.e2e.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug
// here; see db/db.schema.test.ts)
// - `createExecutionEngine` with an in-process code executor
// - `buildMcpServer` for the MCP request surface
// - `createExecutorMcpServer` for the MCP request surface
// - Real `@modelcontextprotocol/sdk` Client → server round-trips
//
// This test replicates the DO's init path (minus the WorkerTransport and
Expand All @@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js";

import { buildMcpServer } from "@executor-js/host-mcp/tool-server";
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
import { createExecutionEngine } from "@executor-js/execution";
import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
import { collectTables } from "@executor-js/api/server";
Expand Down Expand Up @@ -138,12 +138,8 @@ const openSession = (
Effect.gen(function* () {
const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options);
const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() });
const mcpServer = yield* buildMcpServer({
const mcpServer = yield* createExecutorMcpServer({
engine,
appsEnabled: false,
requestStateSigningKey: new Uint8Array(32).fill(23),
requestStatePrincipal: `cloud-mcp-test:${organizationId}`,
sessionful: true,
elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined,
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
Expand Down
Loading
Loading