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
72 changes: 72 additions & 0 deletions apps/ai/scripts/cold-path/build-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Build maple-ai the way the deploy does: alchemy's virtual entry (DO + Workflow
// class stubs), the Cloudflare Rolldown plugin, the runtime define, minify and
// `strictExecutionOrder: false`. Writes the bundle analyzer report beside it.
// PROBE=1 adds the offline graph probe as a second entry, for measure.mjs.
// OUT=apps/ai/node_modules/.cache/cold-path/bundle bun apps/ai/scripts/cold-path/build-bundle.mjs
import { createRequire } from "node:module"
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"

const alchemy = realpathSync("node_modules/alchemy")
const cloudflare = realpathSync("node_modules/@alchemy.run/cloudflare")
// Resolved through alchemy's own dependencies, the copy the deploy bundles with.
const alchemyRequire = createRequire(alchemy + "/package.json")
const { rolldown } = await import(alchemyRequire.resolve("rolldown"))
const { bundleAnalyzerPlugin } = await import(alchemyRequire.resolve("rolldown/experimental"))
const { default: cloudflareRolldown } = await import("@alchemy.run/cloudflare-runtime/rolldown")
const { makeEffectVirtualEntry } = await import(cloudflare + "/src/Workers/Sources/Rolldown.ts")

const cache = resolve("apps/ai/node_modules/.cache")
const out = resolve(process.env.OUT ?? "apps/ai/node_modules/.cache/cold-path/bundle")
if (!out.startsWith(cache + "/")) throw new Error("OUT must be under apps/ai/node_modules/.cache")
rmSync(out, { recursive: true, force: true })

const entryDir = resolve(cache, "cold-path-entry")
mkdirSync(entryDir, { recursive: true })
const entry = resolve(entryDir, "entry.ts")
writeFileSync(
entry,
makeEffectVirtualEntry(
{ ChatSession: { kind: "durableObject" }, SecurityAuditWorkflow: { kind: "workflow" } },
{ name: "maple", stage: "cold-path" },
)(resolve("apps/ai/src/worker.ts")),
)

const build = await rolldown({
input:
process.env.PROBE === "1"
? { worker: entry, probe: resolve("apps/ai/scripts/cold-path/graph-probe.ts") }
: { worker: entry },
external: ["lightningcss", "fsevents"],
transform: { define: { "globalThis.__ALCHEMY_RUNTIME__": "true" } },
moduleTypes: { ".sql": "text", ".txt": "text", ".html": "text" },
plugins: [
cloudflareRolldown({ compatibilityDate: "2026-10-01" }),
bundleAnalyzerPlugin({ fileName: "analyze-data.json", format: "json" }),
{
// Static vs dynamic edges per module, which the analyzer report does not separate.
name: "import-graph",
generateBundle() {
const graph = {}
for (const id of this.getModuleIds()) {
const info = this.getModuleInfo(id)
graph[id] = {
static: info?.importedIds ?? [],
dynamic: info?.dynamicallyImportedIds ?? [],
}
}
this.emitFile({ type: "asset", fileName: "import-graph.json", source: JSON.stringify(graph) })
},
},
],
checks: { unresolvedImport: false, ineffectiveDynamicImport: false },
})
await build.write({
format: "esm",
sourcemap: "hidden",
minify: true,
keepNames: true,
dir: out,
strictExecutionOrder: false,
})
console.log("built", out)
27 changes: 27 additions & 0 deletions apps/ai/scripts/cold-path/graph-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Offline probe of maple-ai's real request graphs. Every external call fails instead of running. */
import { Context, Effect, Layer } from "effect"
import { WorkerPlatformLive } from "@maple/infra/worker-http"
import { DatabasePgLive } from "@maple/backend/platform/DatabasePgLive"
import { buildApp, buildMcpApp } from "../../src/worker/http"
import { offlinePorts } from "../../test/offline-http-ports"

/** The route graph the first request builds: `/mcp`'s own, or the rest. */
export const build = (graph: "mcp" | "full") =>
Effect.runPromise((graph === "mcp" ? buildMcpApp : buildApp)(Context.empty(), offlinePorts))

/** The tool executor graph the first `tools/*` request imports and builds. */
export const buildTools = () =>
Effect.runPromise(
Effect.gen(function* () {
const { McpServicesLive } = yield* Effect.promise(
() => import("../../src/runtime/mcp-service-graph"),
)
return yield* Layer.build(
McpServicesLive.pipe(
Layer.provide(WorkerPlatformLive),
Layer.provide(DatabasePgLive),
Layer.provide(offlinePorts),
),
)
}).pipe(Effect.scoped),
)
66 changes: 66 additions & 0 deletions apps/ai/scripts/cold-path/measure.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Fresh-process desktop V8 proxy of maple-ai's cold path. CPU includes runtime/GC work, no I/O.
// Run from the repo root: bun apps/ai/scripts/cold-path/measure.mjs <PROBE=1 bundle> [runs]
import { spawnSync } from "node:child_process"
import { resolve } from "node:path"
import { pathToFileURL } from "node:url"

const [dir, count = "11"] = process.argv.slice(2)
if (!dir) throw new Error("Expected bundle directory")
if (process.env.MAPLE_CPU_PROBE_CHILD === "1") {
globalThis.caches = { default: {} }
globalThis.HTMLRewriter = class {}
globalThis.fetch = async () => {
throw new Error("External I/O is forbidden in the CPU probe")
}
const base = pathToFileURL(resolve(dir) + "/")
const results = {}
const measure = async (name, action) => {
const cpu = process.cpuUsage()
const wall = performance.now()
const value = await action()
const used = process.cpuUsage(cpu)
results[name] = { wallMs: performance.now() - wall, cpuMs: (used.user + used.system) / 1000 }
return value
}
await measure("startup", () => import(new URL("worker.js", base)))
const probe = await measure("probeImport", () => import(new URL("probe.js", base)))
await measure("routeGraph", () => probe.build("mcp"))
await measure("toolGraph", () => probe.buildTools())
console.log("MEASUREMENT " + JSON.stringify(results))
} else {
const runs = Array.from({ length: Number(count) }, () => {
const child = spawnSync(
"node",
[
"--no-warnings",
"--loader",
pathToFileURL(resolve("apps/api/scripts/cold-path/cf-loader.mjs")).href,
import.meta.filename,
dir,
],
{ encoding: "utf8", env: { ...process.env, MAPLE_CPU_PROBE_CHILD: "1" } },
)
const line = child.stdout.split("\n").find((l) => l.startsWith("MEASUREMENT "))
if (child.status !== 0 || !line) throw new Error(child.stderr || child.stdout)
return JSON.parse(line.slice(12))
})
const median = (xs) => xs.toSorted((a, b) => a - b)[Math.floor(xs.length / 2)]
const keys = Object.keys(runs[0])
const summary = Object.fromEntries(
keys.map((key) => [
key,
{ wallMs: median(runs.map((r) => r[key].wallMs)), cpuMs: median(runs.map((r) => r[key].cpuMs)) },
]),
)
console.log(
JSON.stringify(
{
engine: spawnSync("node", ["--version"], { encoding: "utf8" }).stdout.trim(),
runs: runs.length,
median: summary,
},
null,
2,
),
)
}
35 changes: 35 additions & 0 deletions apps/ai/src/runtime/auth-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive"
import { Env } from "@maple/backend/platform/Env"
import { AuditLogService } from "@maple/backend/services/audit/AuditLogService"
import { AuthService } from "@maple/backend/services/auth/AuthService"
import { McpToolRateLimiter } from "@maple/backend/services/auth/McpToolRateLimiter"
import { SessionAuthorizationLive } from "@maple/backend/services/auth/SessionAuthorizationLive"
import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService"
import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService"
import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService"
import { Layer } from "effect"

/**
* What authenticates a request here.
*
* `/mcp` resolves its own tenant inside the transport, from an API key, an MCP
* OAuth bearer or a session cookie, so it needs `ApiKeysService` and the tool
* rate limiter rather than a route-level authorization layer. The chat routes
* are session-only, the same as they were on api.
*
* `McpOAuthRateLimiter` is deliberately absent: the OAuth endpoints stayed on
* api, which still owns its own limiter for them.
*/
export const AiAuthLive = Layer.mergeAll(SessionAuthorizationLive).pipe(
// `/mcp` falls back to session auth when the bearer is neither an API key nor
// an MCP OAuth token, so the transport resolves tenants through this too.
Layer.provideMerge(AuthService.layer),
Layer.provideMerge(McpToolRateLimiter.layer),
Layer.provideMerge(ApiKeysService.layer),
// `/mcp` checks a requested agent identity against the org's actors.
Layer.provideMerge(ErrorActorsService.layer),
Layer.provideMerge(OrganizationRegionService.layer),
// Denied attempts and audited reads are recorded from inside the auth layers.
Layer.provideMerge(AuditLogService.layer.pipe(Layer.provide(Env.layer))),
Layer.provideMerge(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)),
)
79 changes: 6 additions & 73 deletions apps/ai/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive"
/**
* Every route the AI Worker serves, as one layer.
* Every route the AI Worker serves except `/mcp`, as one layer. `/mcp` is its
* own graph (`./mcp-http-graph`), so a cold `/mcp` request never loads these.
*
* Several surfaces, and they are deliberately different shapes:
*
* - `/mcp` — the public MCP transport, a raw router because the protocol is
* JSON-RPC over one POST rather than a set of typed endpoints.
* - `/api/chat/sessions/*`: the transport for an investigation's follow-up
* conversation, raw because `HttpApi` cannot model an open `text/event-stream`.
* - `/internal/chat/apply` — a typed `HttpApi` group, because deciding an
Expand All @@ -19,16 +17,13 @@ import { EdgeCacheServiceLive } from "@maple/backend/platform/CacheBackendLive"
* gate, typed for the same reason. Reached only over a service binding: api
* does not forward it, and the callers are the Workers that open incidents.
*
* The api still owns the hostname. It forwards the public ones here over a service
* binding, which is what keeps `/mcp`'s OAuth issuer and RFC 8707 resource
* identifiers on api's origin — moving them would invalidate every registered
* MCP client.
* The api still owns the hostname and forwards the public ones here over a
* service binding.
*/
import { MapleAiApi } from "@maple/domain/http"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/http"
import { HttpApiBuilder } from "effect/http-api"
import { McpLive } from "../mcp/app"
import { HttpChatLive } from "../routes/internal/chat.http"
import { HttpTriageLive } from "../routes/internal/triage.http"
import { HttpMcpToolsLive } from "../routes/internal/mcp-tools.http"
Expand All @@ -38,51 +33,14 @@ import { WorkersAiGateway } from "../platform/WorkersAiHttpClient"
import { ChatSessionsRouter } from "../routes/chat-sessions.http"
import { HealthRouter } from "../routes/health"
import { API_CORS_OPTIONS } from "@maple/backend/http/api-cors"
import { Env } from "@maple/backend/platform/Env"
import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService"
import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService"
import { AuthService } from "@maple/backend/services/auth/AuthService"
import { AuditLogService } from "@maple/backend/services/audit/AuditLogService"
import { McpToolRateLimiter } from "@maple/backend/services/auth/McpToolRateLimiter"
import { SessionAuthorizationLive } from "@maple/backend/services/auth/SessionAuthorizationLive"
import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService"
import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService"
import { SecurityAuditCredits } from "@maple/backend/services/security-audit/SecurityAuditCredits"
import { SecurityAuditRepositorySize } from "@maple/backend/services/security-audit/SecurityAuditRepositorySize"
import { SecurityAuditStore } from "@maple/backend/services/security-audit/SecurityAuditStore"
import { ApiErrorBoundaryLive } from "@maple/backend/http/error-boundary"
import type { AiPortsLayer } from "../worker/bindings"
import { rawRoutes } from "./raw-routes"

/**
* Services a raw router's handlers still expect from the request context, beyond the Worker's
* ports, which every request carries. Each is a runtime "Service not found".
*/
type LeakedRequestServices<Routes extends Layer.Any> =
Layer.Services<Routes> extends infer Marker
? Marker extends HttpRouter.Request<"Requires", infer Service>
? Exclude<Service, Layer.Success<AiPortsLayer>>
: never
: never

/**
* A raw `HttpRouter` handler runs in the request's own context — unlike an `HttpApiBuilder`
* group, nothing carries the router's build context into it — so a service it reads per request
* has to arrive through `HttpRouter.provideRequest` (see `ChatSessionsRouter`). Read inside the
* handler instead, it compiles, because the isolate builder erases the marker, and fails every
* request with "Service not found", which is what took the chat routes down on 2026-09-08. This
* turns that into a build failure naming the leaked service.
*
* Carried over from apps/api verbatim. It is worth more here, not less: this Worker is almost
* entirely raw routers.
*/
const rawRoutes = <Routes extends Layer.Any>(
routes: Routes &
([LeakedRequestServices<Routes>] extends [never]
? unknown
: { readonly leakedRequestServices: LeakedRequestServices<Routes> }),
) => routes

const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter, McpLive))
const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter))

/**
* The decision model the triage route asks, built once per isolate from the
Expand Down Expand Up @@ -113,28 +71,3 @@ const AiInternalRoutes = HttpApiBuilder.layer(MapleAiApi).pipe(
export const AllRoutes = Layer.mergeAll(AiInternalRoutes, RawRoutes).pipe(
Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)),
)

/**
* What authenticates a request here.
*
* `/mcp` resolves its own tenant inside the transport, from an API key, an MCP
* OAuth bearer or a session cookie, so it needs `ApiKeysService` and the tool
* rate limiter rather than a route-level authorization layer. The chat routes
* are session-only, the same as they were on api.
*
* `McpOAuthRateLimiter` is deliberately absent: the OAuth endpoints stayed on
* api, which still owns its own limiter for them.
*/
export const AiAuthLive = Layer.mergeAll(SessionAuthorizationLive).pipe(
// `/mcp` falls back to session auth when the bearer is neither an API key nor
// an MCP OAuth token, so the transport resolves tenants through this too.
Layer.provideMerge(AuthService.layer),
Layer.provideMerge(McpToolRateLimiter.layer),
Layer.provideMerge(ApiKeysService.layer),
// `/mcp` checks a requested agent identity against the org's actors.
Layer.provideMerge(ErrorActorsService.layer),
Layer.provideMerge(OrganizationRegionService.layer),
// Denied attempts and audited reads are recorded from inside the auth layers.
Layer.provideMerge(AuditLogService.layer.pipe(Layer.provide(Env.layer))),
Layer.provideMerge(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)),
)
76 changes: 76 additions & 0 deletions apps/ai/src/runtime/mcp-http-graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
import * as Cloudflare from "@alchemy.run/cloudflare"
import { IsolateAge } from "@maple/infra/isolate-age"
import type { HttpEffect } from "alchemy/Http"
import { Context, Effect, Schema, Scope } from "effect"
import { buildApp, buildMcpApp, makeFetch } from "../worker/http"
import { offlinePorts } from "../../test/offline-http-ports"

class UnexpectedGraphBuild extends Schema.TaggedError<UnexpectedGraphBuild>()("UnexpectedGraphBuild", {
message: Schema.String,
}) {}

const serve = (fetch: HttpEffect, method: string, path: string, body?: string) =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const responseEffect: Effect.Effect<Response, never, Scope.Scope> =
Cloudflare.Workers.makeRequestHandler(fetch)({
kind: "Cloudflare.Workers.WorkerEvent",
type: "fetch",
input: new Request(`http://ai.maple.test${path}`, {
method,
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body,
}),
})!
const response = yield* responseEffect
return {
status: response.status,
challenge: response.headers.get("www-authenticate") !== null,
}
}),
).pipe(Effect.provide(offlinePorts)),
)

const initialize = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1" } },
})

describe("the maple-ai route graphs", () => {
it("answers /mcp from its own graph without building the other routes", async () => {
const mcpApp = buildMcpApp(Context.empty(), offlinePorts)
const rest = Effect.fail(new UnexpectedGraphBuild({ message: "a /mcp request built the rest graph" }))
const fetch = await Effect.runPromise(
makeFetch(rest, offlinePorts, mcpApp).pipe(Effect.provide(IsolateAge.layer)),
)

const unauthenticated = await serve(fetch, "POST", "/mcp", initialize)
expect({ status: unauthenticated.status, challenge: unauthenticated.challenge }).toEqual({
status: 401,
challenge: true,
})
// The rest graph is still what every other path selects.
expect((await serve(fetch, "GET", "/internal/mcp/tools")).status).toBe(503)
})

it("serves the other routes from the rest graph without building /mcp", async () => {
const app = buildApp(Context.empty(), offlinePorts)
const mcp = Effect.fail(
new UnexpectedGraphBuild({ message: "a catalog request built the /mcp graph" }),
)
const fetch = await Effect.runPromise(
makeFetch(app, offlinePorts, mcp).pipe(Effect.provide(IsolateAge.layer)),
)

// Session-only, so unauthenticated it is the rest graph's 401: not `/mcp`'s 404, nor a 503.
expect((await serve(fetch, "GET", "/internal/mcp/tools")).status).toBe(401)
expect((await serve(fetch, "POST", "/mcp", initialize)).status).toBe(503)
})
})
Loading
Loading