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
2 changes: 1 addition & 1 deletion apps/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@effect-agent/sandbox": "0.1.0-beta.74",
"@effect/ai-openai-compat": "catalog:effect",
"@effect/ai-openrouter": "catalog:effect",
"@maple-dev/effect-sdk": "workspace:*",
"@maple/db": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/infra": "workspace:*",
Expand All @@ -33,7 +34,6 @@
"@effect-agent/testing": "0.1.0-beta.74",
"@effect/language-service": "catalog:effect",
"@effect/vitest": "catalog:effect",
"@maple-dev/effect-sdk": "workspace:*",
"@types/node": "catalog:tooling",
"ai": "^6.0.196",
"gpt-tokenizer": "^3.0.1",
Expand Down
16 changes: 16 additions & 0 deletions apps/ai/src/mcp/expected-failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,20 @@ describe("MCP expected failures", () => {

expect(offenders.map((path) => path.slice(SRC_ROOT.length))).toEqual([])
})

// The check above only sees a tracer built through `MapleCloudflareSDK.make`.
// This Worker's request-facing tracer is built by `WorkerTelemetry` in the
// init instead, and it is the one serving the PUBLIC `/mcp` transport — the
// surface these identifiers were written for. It lost them once already, in
// the move out of apps/api, because nothing tied the two files together.
it("spreads the MCP identifiers into this Worker's own telemetry", () => {
const offenders = sourceFiles(SRC_ROOT).filter((path) => {
const source = readFileSync(path, "utf8")
return (
source.includes("WorkerTelemetry({") && !source.includes("MCP_ANTICIPATED_ERROR_IDENTIFIERS")
)
})

expect(offenders.map((path) => path.slice(SRC_ROOT.length))).toEqual([])
})
})
20 changes: 19 additions & 1 deletion apps/ai/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,14 @@ import {
} from "@maple/infra/env"
import { WorkerTelemetry } from "@maple/infra/worker-telemetry"
import * as Cloudflare from "alchemy/Cloudflare"
import * as AlchemyTelemetry from "alchemy/Telemetry"
import { Context, Effect, Layer } from "effect"
import { ChatSessionLive, ChatSessionObject } from "@ai/chat/ChatSession"
import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@ai/mcp/expected-failures"
import InvestigationFanoutWorkflow from "@ai/workflows/InvestigationFanoutWorkflow"
import { aiPorts, AiBindingLayers, bindAiClients } from "@ai/worker/bindings"
import { buildApp, makeFetch } from "@ai/worker/http"
import { AiObservabilityLive } from "@ai/worker/observability"

/**
* The AI worker's resource bindings, split from the `Config`-sourced env so
Expand Down Expand Up @@ -169,7 +172,22 @@ export default MapleAi.make(
// implementation; yielding the class above is what forces this to run,
// so the class reaches the generated entry's exports.
ChatSessionLive,
WorkerTelemetry({ serviceName: "maple-ai" }),
WorkerTelemetry({
serviceName: "maple-ai",
// Both carried over from apps/api with the surfaces they describe.
// `dropSpanNames` keeps the MCP server's notification spans out of
// export; the MCP identifiers are what keep an expected 400/401 —
// a tool call that does not decode, a missing credential — exporting
// with an `Ok` status and no exception event, per CLAUDE.md's rule
// that only 5xx is an `Error` span. `chat/turn-runner.ts` and the
// fan-out Workflow pass the same set to their own tracers; this is
// the public `/mcp` transport's.
dropSpanNames: ["McpServer/Notifications."],
anticipatedErrorIdentifiers: MCP_ANTICIPATED_ERROR_IDENTIFIERS,
}),
// The references the bridge's `HttpMiddleware.tracer` reads, built into
// every event beside the SDK; they cannot live in the app graph.
AlchemyTelemetry.layer(AiObservabilityLive),
),
),
),
Expand Down
22 changes: 22 additions & 0 deletions apps/ai/src/worker/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Layer } from "effect"
import { HttpMiddleware } from "effect/unstable/http"

/**
* The one reference `HttpMiddleware.tracer` reads that this Worker has to set
* itself. The bridge's tracer runs outside the app graph, so it cannot live
* there — the Worker registers this beside the SDK exporters, exactly as
* `apps/api/src/http/api-observability.ts` does for the api.
*
* Only the filter. Header redaction is left at Effect's defaults
* (`authorization`, `cookie`, `set-cookie`, `x-api-key`), which already cover
* every credential reaching this Worker: the provider webhook signatures api
* adds to its own list are received on api's routes and never forwarded here.
*
* `/health` and `OPTIONS` are skipped for the reason CLAUDE.md gives for the
* api: this Worker's traces land in Maple's own org, so a liveness probe that
* spans is self-traffic nothing reads.
*/
export const AiObservabilityLive = Layer.succeed(
HttpMiddleware.TracerDisabledWhen,
(request: { url: string; method: string }) => request.url === "/health" || request.method === "OPTIONS",
)
11 changes: 9 additions & 2 deletions apps/ai/src/workflows/InvestigationFanoutWorkflow.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
/**
* The investigation fan-out Workflow, in alchemy's form: yielded from the api
* The investigation fan-out Workflow, in alchemy's form: yielded from the AI
* Worker's init, which binds it as `InvestigationFanoutWorkflow`, registers the
* physical workflow and exports the class from the generated entry. N lens
* agents run in parallel, then one validator promotes a single cause and
* records why each rival lost.
*
* The api and alerting Workers bind the same physical workflow cross-script
* under this class name — see `@maple/domain/investigation-fanout`.
*/
import { ChatSessionObject } from "@ai/chat/ChatSession"
import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@ai/mcp/expected-failures"
Expand Down Expand Up @@ -34,7 +37,11 @@ export default class InvestigationFanoutWorkflow extends Cloudflare.Workflow<Inv
// here so the run gets the typed stubs of the class the Worker hosts, and
// the application database, bound to the host Worker under `MAPLE_DB`.
const chatSessions = yield* ChatSessionObject
yield* MapleDb("api")
// `"ai"` is the HOST this workflow runs on, which is what picks the
// Hyperdrive config `MAPLE_DB` binds on this script. Naming api's here
// bound a second, differently-resolved `MAPLE_DB` on maple-ai — harmless
// only while both consumers resolve to the same config.
yield* MapleDb("ai")
return Effect.fn("InvestigationFanoutWorkflow")(function* (
payload: InvestigationFanoutWorkflowPayload,
) {
Expand Down
7 changes: 3 additions & 4 deletions apps/api/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,9 @@ export default class MapleApi extends Cloudflare.Worker<MapleApi>()(
ApiBindingLayers,
Cloudflare.Workers.CronEventSourceLive,
Cloudflare.Queues.EventSourceLive,
WorkerTelemetry({
serviceName: "maple-api",
dropSpanNames: ["McpServer/Notifications."],
}),
// No `dropSpanNames`: the MCP server's notification spans are maple-ai's
// to drop now, and its telemetry config is where that option lives.
WorkerTelemetry({ serviceName: "maple-api" }),
// The references the bridge's `HttpMiddleware.tracer` reads, built into
// every event beside the SDK; they cannot live in the app graph.
AlchemyTelemetry.layer(ApiObservabilityLive),
Expand Down
106 changes: 106 additions & 0 deletions apps/api/src/worker/ai-forward.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { forwardsToAi, forwardToAi, isCloudflareFetcher } from "./ai-forward"

/**
* The predicate IS the contract between the two Workers: a path it misses 404s
* from api's own router, and one it over-matches never reaches api's routes at
* all. Both failures are silent in a typecheck, hence the table.
*/
describe("forwardsToAi", () => {
it("forwards the MCP transport and the chat surfaces", () => {
for (const path of [
"/mcp",
"/mcp/",
"/mcp/anything",
"/api/chat/sessions/org:tab/history",
"/api/chat/sessions/org:tab/events",
"/api/chat/sessions/org:tab/messages",
"/api/chat/sessions/org:tab/abort",
"/internal/chat/apply",
]) {
assert.isTrue(forwardsToAi(path), path)
}
})

it("keeps every other path on api, including neighbours of the forwarded ones", () => {
for (const path of [
"/",
"/health",
"/mcp-something",
"/mcpx",
"/.well-known/oauth-protected-resource/mcp",
"/oauth/authorize",
"/api/chatter",
"/api/chat",
"/internal/chatty",
"/internal/ai-sessions",
"/v2/errors",
]) {
assert.isFalse(forwardsToAi(path), path)
}
})
})

describe("isCloudflareFetcher", () => {
it("accepts a binding exposing fetch and rejects everything else", () => {
assert.isTrue(isCloudflareFetcher({ fetch: () => new Response("ok") }))
for (const value of [undefined, null, {}, "AI_WORKER", { fetch: "nope" }]) {
assert.isFalse(isCloudflareFetcher(value), JSON.stringify(value))
}
})
})

/**
* SAFETY: the forward calls `fetch` and nothing else, so the stub implements
* that and is asserted to the binding's type rather than growing a `connect`
* this path never reaches.
*/
const echoFetcher = (seen: Array<Request>) =>
({
fetch: (input: RequestInfo | URL) => {
if (input instanceof Request) seen.push(input)
return Promise.resolve(new Response("forwarded", { status: 207 }))
},
}) as Fetcher

describe("forwardToAi", () => {
it.effect("hands maple-ai this hop's span as traceparent, and keeps the rest of the request", () =>
Effect.gen(function* () {
const seen: Array<Request> = []
const request = HttpServerRequest.fromWeb(
new Request("https://api.maple.dev/mcp", {
method: "POST",
headers: { authorization: "Bearer key", "content-type": "application/json" },
body: '{"method":"tools/list"}',
}),
)

const response = yield* forwardToAi(echoFetcher(seen), request)
const span = yield* Effect.currentSpan

assert.strictEqual(response.status, 207)
const forwarded = seen[0]
assert.isDefined(forwarded)
assert.strictEqual(forwarded.method, "POST")
assert.strictEqual(forwarded.headers.get("authorization"), "Bearer key")
assert.strictEqual(new URL(forwarded.url).host, "api.maple.dev")
// Parented to api's own server span, so the two Workers share one trace
// and the service map gains the edge between them.
assert.strictEqual(forwarded.headers.get("traceparent"), `00-${span.traceId}-${span.spanId}-01`)
assert.strictEqual(yield* Effect.promise(() => forwarded.text()), '{"method":"tools/list"}')
}).pipe(Effect.withSpan("test-root")),
)

it.effect("forwards unchanged when nothing is tracing", () =>
Effect.gen(function* () {
const seen: Array<Request> = []
const request = HttpServerRequest.fromWeb(new Request("https://api.maple.dev/mcp"))

yield* forwardToAi(echoFetcher(seen), request)

assert.isNull(seen[0]?.headers.get("traceparent") ?? null)
}),
)
})
102 changes: 102 additions & 0 deletions apps/api/src/worker/ai-forward.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// BOUNDARY: This module owns unparsed external values and narrows them before domain use.
/**
* Forwarding the agent surfaces to maple-ai.
*
* The api keeps the hostname and hands `/mcp` and the chat paths to the AI
* Worker over a service binding, which is what keeps `/mcp`'s OAuth issuer and
* its RFC 8707 resource identifiers on this origin — moving them would
* invalidate every registered MCP client.
*
* Its own module for two reasons: the path predicate is the contract between
* the two Workers and is worth testing without building a route graph, and the
* binding arrives as an unparsed `env` value that has to be narrowed rather
* than asserted.
*/
import * as Cloudflare from "alchemy/Cloudflare"
import { Effect, Option } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { API_CORS_RESPONSE_HEADERS } from "../http/api-cors"

/**
* The paths maple-ai serves.
*
* `/mcp` is matched exactly rather than by prefix so a future `/mcp-something`
* on this origin is not silently swallowed. Kept in step with
* `MapleAiApi` and the AI Worker's raw routers: anything this misses 404s from
* api's own router, and anything it over-matches never reaches api's routes at
* all.
*/
export const forwardsToAi = (path: string): boolean =>
path === "/mcp" ||
path.startsWith("/mcp/") ||
path.startsWith("/api/chat/") ||
path.startsWith("/internal/chat/")

/**
* A service binding off `env`, narrowed rather than cast.
*
* Only `fetch` is checked, because that is all the forward calls; a binding
* that is present but not a fetcher is then a logged 503 instead of a defect
* inside alchemy's adapter.
*/
export const isCloudflareFetcher = (value: unknown): value is Fetcher =>
typeof value === "object" && value !== null && typeof (value as { fetch?: unknown }).fetch === "function"

/** maple-ai could not be reached. CORS headers included: the dashboard's chat calls this from a browser. */
export const aiUnavailableResponse = (): HttpServerResponse.HttpServerResponse =>
HttpServerResponse.text("maple-ai is unavailable", {
status: 503,
headers: API_CORS_RESPONSE_HEADERS,
})

/** The W3C header carrying this hop's span, sampled flag included, as `apps/web`'s fetch wrapper writes it. */
const traceparentOf = (span: { readonly traceId: string; readonly spanId: string }): string =>
`00-${span.traceId}-${span.spanId}-01`

/**
* Forward one request to maple-ai and answer with its response.
*
* Byte-transparent but for one header: the same method, the original `Host`
* (which is what keeps `/mcp`'s OAuth `resource_metadata` pointing at this
* origin's well-known), every other header, and both bodies as streams. The
* chat tail is an open `text/event-stream`, so buffering either side would turn
* a live transcript into a hang.
*
* The exception is `traceparent`, which is replaced with THIS hop's server
* span. Without it maple-ai starts a new root trace and one `/mcp` call reads
* as two unrelated traces with no edge between the Workers on the service map.
* Replacing rather than preserving is what parents ai's span to api's: a client
* that sent its own `traceparent` is already this span's parent, so the trace
* stays whole either way.
*
* The one expected failure is a request that cannot be rendered to the web
* shape, which on workerd means never — the bridge's request already IS one, so
* `toWeb` hands it back untouched. It is answered as the same 503 an absent
* binding gets rather than widened into this handler's error channel, where it
* would not match the bridge's `HttpEffect`. A binding call that rejects stays a
* defect the bridge renders and reports, exactly as before the split.
*/
export const forwardToAi = (
fetcher: Fetcher,
request: HttpServerRequest.HttpServerRequest,
): Effect.Effect<HttpServerResponse.HttpServerResponse> =>
Effect.gen(function* () {
const web = yield* HttpServerRequest.toWeb(request)
const span = yield* Effect.option(Effect.currentSpan)
const forwarded = Option.match(span, {
onNone: () => web,
onSome: (current) => {
const headers = new Headers(web.headers)
headers.set("traceparent", traceparentOf(current))
return new Request(web, { headers })
},
})
return yield* Cloudflare.fromCloudflareFetcher(fetcher).fetch(HttpServerRequest.fromWeb(forwarded))
}).pipe(
Effect.catch((error) =>
Effect.logError("Forwarding to the AI worker failed", error).pipe(
Effect.annotateLogs({ method: request.method, path: request.url }),
Effect.as(aiUnavailableResponse()),
),
),
)
Loading