diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99239b6a3..3cf73f103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -203,7 +203,7 @@ jobs: # so the Alchemy-entrypoints typecheck resolves each graph through # that app's node_modules. - shard: quality - install-filters: "@maple/api @maple/electric-sync @maple/alerting @maple/sandbox @maple/web @maple/landing @maple/local-ui" + install-filters: "@maple/api @maple/ai @maple/electric-sync @maple/alerting @maple/sandbox @maple/web @maple/landing @maple/local-ui" - shard: effect-lint install-filters: "" - shard: build-web @@ -219,7 +219,7 @@ jobs: install-filters: "@maple/web" - shard: typecheck-rest install-filters: >- - @maple/alerting @maple/cli @maple/clickhouse-builder-docs + @maple/ai @maple/alerting @maple/cli @maple/clickhouse-builder-docs @maple/electric-sync @maple/landing @maple/local-ui @maple/sandbox @maple/scraper ./lib/* ./examples/* - shard: typecheck-packages @@ -238,7 +238,7 @@ jobs: install-filters: "" - shard: test-rest install-filters: >- - @maple/alerting @maple/cli @maple/electric-sync @maple/landing + @maple/ai @maple/alerting @maple/cli @maple/electric-sync @maple/landing @maple/local-ui @maple/sandbox @maple/scraper ./lib/* steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index dcb8cfe85..6364cdc53 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -48,10 +48,10 @@ jobs: - uses: ./.github/actions/bun-install with: - filters: "@maple/api" + filters: "@maple/ai" - name: Run MCP evals - run: bun run --filter @maple/api eval + run: bun run --filter @maple/ai eval env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # Override to eval a different model (defaults to the prod kimi-k2.5). diff --git a/.github/workflows/token-cost.yml b/.github/workflows/token-cost.yml index 1b3f0d328..e6f822cad 100644 --- a/.github/workflows/token-cost.yml +++ b/.github/workflows/token-cost.yml @@ -8,7 +8,7 @@ on: pull_request: branches: [main] paths: - - "apps/api/src/mcp/**" + - "apps/ai/src/mcp/**" - "packages/domain/src/**" - ".github/actions/bun-install/action.yml" - ".github/workflows/token-cost.yml" @@ -32,11 +32,14 @@ jobs: - uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 - # This script only needs the API dependency closure. Installing all - # workspaces accounted for roughly half of this job's wall time. + # This script only needs the agent Worker's dependency closure, which + # includes apps/api: the tools import api's services by path alias, and + # those modules resolve `@maple/domain/*` out of api's own node_modules. + # Installing all workspaces accounted for roughly half of this job's + # wall time. - uses: ./.github/actions/bun-install with: - filters: "@maple/api @maple-dev/effect-sdk @maple-dev/browser" + filters: "@maple/ai @maple/api @maple-dev/effect-sdk @maple-dev/browser" - name: Restore turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -52,16 +55,19 @@ jobs: run: bun run alchemy:build-deps - name: Measure (PR head) - run: bun run --filter @maple/api measure-tokens -- -o "$RUNNER_TEMP/head.json" | tee "$RUNNER_TEMP/head.txt" + run: bun run --filter @maple/ai measure-tokens -- -o "$RUNNER_TEMP/head.json" | tee "$RUNNER_TEMP/head.txt" # Re-measure against the base source (reusing the same node_modules) for a # best-effort delta. Source-only swap avoids a second install. - name: Measure (base) continue-on-error: true run: | - git checkout "${{ github.event.pull_request.base.sha }}" -- apps/api/src packages/domain/src || true - bun run --filter @maple/api measure-tokens -- -o "$RUNNER_TEMP/base.json" || echo '{"total":0}' > "$RUNNER_TEMP/base.json" - git checkout HEAD -- apps/api/src packages/domain/src || true + # The agent Worker did not exist before the split, so on an older + # base both the pathspec and the script are absent. Either way the + # base reads 0 and the delta is the whole cost, once. + git checkout "${{ github.event.pull_request.base.sha }}" -- apps/ai/src apps/api/src packages/domain/src || true + bun run --filter @maple/ai measure-tokens -- -o "$RUNNER_TEMP/base.json" || echo '{"total":0}' > "$RUNNER_TEMP/base.json" + git checkout HEAD -- apps/ai/src apps/api/src packages/domain/src || true - name: Summary run: | diff --git a/CLAUDE.md b/CLAUDE.md index 397d71323..262073707 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ you want the form. ```bash bun dev # everything, ONE `alchemy dev` stack → https://[.].localhost -bun dev api web # a subset (api, alerting, electric-sync, web, landing, ingest, local-ui, scraper) +bun dev api web # a subset (api, ai, alerting, electric-sync, web, landing, ingest, local-ui, scraper) bun --filter=@maple/web dev # single app on its raw port, no portless proxy bun run test # Vitest via turbo (NOT `bun test` — that's Bun's own runner) bun typecheck @@ -50,6 +50,23 @@ Toolchain (bun/node/rust/python) is pinned in [`mise.toml`](mise.toml); `mise ru first-time install + `.env.local` + portless CA. mise is optional but bump versions there when upgrading a runtime (keep `bun` in sync with `packageManager`). +## The AI Worker (`apps/ai`) + +Every agent surface runs in its own Worker: the public MCP server and its ~47 tools, the chat agent +and its `ChatSession` Durable Object, and the autonomous investigation fan-out. They moved together +because all three reach the same tool registry in-process — extracting any one alone leaves the +registry behind, which is why the first attempt was worth 1%. + +`api.maple.dev/mcp` is still the public address. `apps/api` forwards `/mcp`, `/api/chat/*` and +`/internal/chat/*` over a service binding, ahead of building its route graph, which keeps the OAuth +issuer and the RFC 8707 resource identifiers on api's origin. OAuth itself (`McpOAuthService`, the +discovery and consent endpoints) stays in `apps/api`; maple-ai validates the ordinary API key it +mints. + +**The alias convention is the opposite of what it looks like.** In `apps/ai`, `@/` is *apps/api's* +source and `@ai/` is its own. This program compiles api's modules too, and those spell their +internal imports `@/` — point it at `apps/ai` and every one resolves into the wrong tree. + ## Warehouse queries **No Tinybird pipes/endpoints exist.** All backend queries use the ClickHouse DSL in @@ -180,11 +197,10 @@ Workers via the Hyperdrive binding `MAPLE_DB`. it had diverged exactly where it mattered — it has `AWS/StageConfig.ts` where the real package has `AWS/Environment.ts` + `AWS/AuthProvider.ts` — and a code review cited its line numbers as fact for a bug in the live code. -- **LLM core:** `@opencode-ai/ai` — opencode's Effect-native LLM core, on npm and pinned exactly - (`0.0.0-beta-18050`; the `dev`/`beta` channels carry no semver, so a bump is a read of the diff). - Only `apps/api` depends on it, and every piece of Maple behaviour — layer wiring, the Workers AI - binding shim, model/provider selection, error mapping — lives at the seam in - `apps/api/src/platform/Llm.ts`, never in a wrapper around the package. +- **LLM core:** Effect AI (`@effect/ai-openrouter`, `@effect/ai-openai-compat`) plus + `@effect-agent/*`. Only `apps/ai` depends on them, and every piece of Maple behaviour — layer + wiring, the Workers AI binding shim, model/provider selection, error mapping — lives at the seam + in `apps/ai/src/platform/Llm.ts`, never in a wrapper around the packages. - **Span status codes:** Title case — `"Ok"`, `"Error"`, `"Unset"`. - **UI:** shadcn/Base UI + Tailwind 4 (`npx shadcn@latest add `), Recharts, Nucleo icons. Find an icon in the local Nucleo DB, then port it into `apps/web/src/components/icons/` by copying @@ -198,7 +214,7 @@ Workers via the Hyperdrive binding `MAPLE_DB`. When an org has connected GitHub, every agent surface (chat, investigation lanes, public MCP) gets `sandbox_grep`, `sandbox_list_files`, `sandbox_read_file` and `sandbox_exec` -(`apps/api/src/mcp/tools/sandbox.ts`). They run against a **full git clone at an exact commit** +(`apps/ai/src/mcp/tools/sandbox.ts`). They run against a **full git clone at an exact commit** inside Cloudflare's Sandbox container, so history works (`git log`, `git blame`, `git show`). `git grep` and `git ls-files` back the search and listing tools, because the image ships git and not ripgrep — and its git is old enough to lack `git grep --max-count`, which is the kind of thing @@ -224,7 +240,8 @@ arguments**, because `/proc//cmdline` is readable by the account agent comm Testing it has three layers, and the top one is the only one that catches the image: ```bash -bun run --cwd apps/api test src/services/sandbox src/mcp/tools/sandbox # argument vectors, real git +bun run --cwd apps/ai test src/mcp/tools/sandbox # the tools +bun run --cwd apps/api test src/services/sandbox # argument vectors, real git bun run --cwd apps/sandbox test # the generated scripts, as text bun run --cwd apps/sandbox verify:image # the scripts, inside the image ``` diff --git a/alchemy.run.ts b/alchemy.run.ts index 462dd19e2..9d5ae792a 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -24,6 +24,7 @@ import { } from "@maple/infra/aws" import { ApiWorker, + AiWorker, SandboxWorker, stageDeploysSandbox, formatMapleStage, @@ -38,6 +39,7 @@ import * as Acm from "@maple/infra/acm" import { optionalPlain, plainWithDefault } from "@maple/infra/env" import * as Portless from "@maple/alchemy-portless" import { DEV_PROCESS_APPS, selectedDevApps, type DevApp } from "@maple/infra/dev-urls" +import MapleAiLive, { MapleAi } from "./apps/ai/src/worker.ts" import Alerting from "./apps/alerting/src/worker.ts" import MapleApi from "./apps/api/src/worker.ts" import MapleSandbox from "./apps/sandbox/alchemy.run.ts" @@ -218,9 +220,18 @@ export default Alchemy.Stack( // sees a Worker this deploy created rather than stored state, and only on // the stages that run it — see `stageDeploysSandbox`. const sandbox = stageDeploysSandbox(stage) ? yield* MapleSandbox : undefined - const api = yield* sandbox === undefined - ? MapleApi - : Effect.provideService(MapleApi, SandboxWorker, sandbox) + // Every agent surface — the MCP server and its tools, the chat agent, the + // investigation fan-out. Yielded before api because api binds it, and a + // `Worker.ref` cannot see a sibling this deploy creates. + // The root IS the entry point, and the AI Worker hosts the chat Durable + // Object: yielding the Worker resolves the class, and its Live layer is what + // registers the class in the deployed bundle's exports. + // oxlint-disable-next-line effecttsgo/strict-effect-provide + const ai = yield* Effect.provide(MapleAi, MapleAiLive) + yield* serveWorker("ai", ai) + const api = yield* Effect.provideService(MapleApi, AiWorker, ai).pipe((withAi) => + sandbox === undefined ? withAi : Effect.provideService(withAi, SandboxWorker, sandbox), + ) yield* serveWorker("api", api) // Self-hosted ElectricSQL on ECS Fargate (prd/stg — dev stages use the diff --git a/apps/ai/package.json b/apps/ai/package.json new file mode 100644 index 000000000..8aec98c71 --- /dev/null +++ b/apps/ai/package.json @@ -0,0 +1,44 @@ +{ + "name": "@maple/ai", + "private": true, + "type": "module", + "scripts": { + "eval": "vitest run --config vitest.eval.config.ts", + "eval:check": "bun run scripts/eval-runtime-check.ts", + "eval:widgets": "bun run scripts/grade-widget-eval.ts", + "mcp:docs": "bun run scripts/generate-dashboard-skill.ts", + "measure-tokens": "bun run scripts/measure-token-cost.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@effect-agent/capabilities": "0.1.0-beta.74", + "@effect-agent/core": "0.1.0-beta.74", + "@effect-agent/engine": "0.1.0-beta.74", + "@effect-agent/sandbox": "0.1.0-beta.74", + "@effect/ai-openai-compat": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", + "@maple/db": "workspace:*", + "@maple/domain": "workspace:*", + "@maple/infra": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/query-model": "workspace:*", + "@maple/widgets": "workspace:*", + "drizzle-orm": "^0.45.1", + "effect": "catalog:effect" + }, + "devDependencies": { + "@ai-sdk/openai-compatible": "^2.0.48", + "@cloudflare/workers-types": "catalog:alchemy", + "@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", + "typescript": "catalog:tooling", + "vitest": "catalog:", + "vitest-evals": "^0.4.0" + } +} diff --git a/apps/api/scripts/eval-runtime-check.ts b/apps/ai/scripts/eval-runtime-check.ts similarity index 84% rename from apps/api/scripts/eval-runtime-check.ts rename to apps/ai/scripts/eval-runtime-check.ts index f6503743e..2ae63eb90 100644 --- a/apps/api/scripts/eval-runtime-check.ts +++ b/apps/ai/scripts/eval-runtime-check.ts @@ -8,10 +8,10 @@ * Exits non-zero on failure. Keep it as a dev utility — the LLM path * (execution.eval.ts) only adds tool *selection* on top of what this exercises. */ -import { installFakeWarehouse, restoreWarehouse } from "@/mcp/__evals__/fake-warehouse" -import { makeEvalRuntime, runToolDirect } from "@/mcp/__evals__/eval-runtime" -import { FIXTURES } from "@/mcp/__evals__/utils" -import { LARGE_TRACE_SPAN_COUNT } from "@/mcp/__evals__/fixtures" +import { installFakeWarehouse, restoreWarehouse } from "@ai/mcp/__evals__/fake-warehouse" +import { makeEvalRuntime, runToolDirect } from "@ai/mcp/__evals__/eval-runtime" +import { FIXTURES } from "@ai/mcp/__evals__/utils" +import { LARGE_TRACE_SPAN_COUNT } from "@ai/mcp/__evals__/fixtures" const main = async () => { installFakeWarehouse() diff --git a/apps/api/scripts/generate-dashboard-skill.ts b/apps/ai/scripts/generate-dashboard-skill.ts similarity index 92% rename from apps/api/scripts/generate-dashboard-skill.ts rename to apps/ai/scripts/generate-dashboard-skill.ts index 929fd3c86..aadefade2 100644 --- a/apps/api/scripts/generate-dashboard-skill.ts +++ b/apps/ai/scripts/generate-dashboard-skill.ts @@ -10,12 +10,12 @@ * moved to v3, and its gauge example paired `unit: "percent"` with a 0–100 arc — * the very mistake the write path now warns about. * - * bun run --cwd apps/api mcp:docs # write - * bun run --cwd apps/api mcp:docs --check # verify (CI) + * bun run --cwd apps/ai mcp:docs # write + * bun run --cwd apps/ai mcp:docs --check # verify (CI) */ import { readFileSync, writeFileSync } from "node:fs" import { resolve } from "node:path" -import { DASHBOARD_SCHEMA_SECTIONS, renderDashboardSchemaSection } from "@/mcp/lib/dashboard-schema-doc" +import { DASHBOARD_SCHEMA_SECTIONS, renderDashboardSchemaSection } from "@ai/mcp/lib/dashboard-schema-doc" const SKILL_PATH = resolve(import.meta.dirname, "../../../skills/maple-dashboard-widgets/SKILL.md") @@ -29,7 +29,7 @@ const INTRO = ` # Maple dashboard widgets via MCP Everything below is generated from the live widget schema by -\`bun run --cwd apps/api mcp:docs\`. **Do not edit this file by hand** — edit +\`bun run --cwd apps/ai mcp:docs\`. **Do not edit this file by hand** — edit \`apps/api/src/mcp/lib/dashboard-schema-doc.ts\` and regenerate. The same module backs the \`describe_dashboard_schema\` MCP tool, so an agent at runtime and a reader here see one truth. @@ -70,7 +70,7 @@ if (process.argv.includes("--check")) { const current = readFileSync(SKILL_PATH, "utf8") if (current !== content) { console.error( - "skills/maple-dashboard-widgets/SKILL.md is out of date.\nRun: bun run --cwd apps/api mcp:docs", + "skills/maple-dashboard-widgets/SKILL.md is out of date.\nRun: bun run --cwd apps/ai mcp:docs", ) process.exit(1) } diff --git a/apps/api/scripts/grade-widget-eval.ts b/apps/ai/scripts/grade-widget-eval.ts similarity index 97% rename from apps/api/scripts/grade-widget-eval.ts rename to apps/ai/scripts/grade-widget-eval.ts index 0e867bf02..e01268969 100644 --- a/apps/api/scripts/grade-widget-eval.ts +++ b/apps/ai/scripts/grade-widget-eval.ts @@ -17,7 +17,7 @@ import { readFileSync } from "node:fs" import { Schema } from "effect" import { DashboardWidgetSchema, WIDGET_TYPES, type PanelType } from "@maple/domain/http" -import { validateWidgetRenderability } from "@/mcp/lib/validate-widget-renderability" +import { validateWidgetRenderability } from "@ai/mcp/lib/validate-widget-renderability" import { TASKS } from "./widget-eval-tasks" const decodeWidget = Schema.decodeUnknownSync(DashboardWidgetSchema) diff --git a/apps/api/scripts/measure-token-cost.ts b/apps/ai/scripts/measure-token-cost.ts similarity index 96% rename from apps/api/scripts/measure-token-cost.ts rename to apps/ai/scripts/measure-token-cost.ts index 262981def..d00c38cac 100644 --- a/apps/api/scripts/measure-token-cost.ts +++ b/apps/ai/scripts/measure-token-cost.ts @@ -8,7 +8,7 @@ */ import { writeFileSync } from "node:fs" import { encode } from "gpt-tokenizer" -import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" +import { mapleToolCatalog, toInputSchema } from "@ai/mcp/tools/registry" interface ToolTokens { readonly name: string diff --git a/apps/api/scripts/widget-eval-tasks.ts b/apps/ai/scripts/widget-eval-tasks.ts similarity index 100% rename from apps/api/scripts/widget-eval-tasks.ts rename to apps/ai/scripts/widget-eval-tasks.ts diff --git a/apps/api/src/chat/ChatSession.test.ts b/apps/ai/src/chat/ChatSession.test.ts similarity index 100% rename from apps/api/src/chat/ChatSession.test.ts rename to apps/ai/src/chat/ChatSession.test.ts diff --git a/apps/api/src/chat/ChatSession.ts b/apps/ai/src/chat/ChatSession.ts similarity index 93% rename from apps/api/src/chat/ChatSession.ts rename to apps/ai/src/chat/ChatSession.ts index f7e90dc03..435740dc6 100644 --- a/apps/api/src/chat/ChatSession.ts +++ b/apps/ai/src/chat/ChatSession.ts @@ -45,7 +45,7 @@ import { type ChatToolCall, type ChatTurnTenantEncoded, } from "@maple/domain/chat-session" -import type { ChatSessionStub } from "./session" +import { type ChatSessionStub } from "@maple/domain/chat-session-stub" /** What the class reads off its Durable Object state: the SQLite handle and the object's own `waitUntil`. */ interface ChatSessionState { @@ -673,8 +673,30 @@ export const activateChatSession = Effect.map( ([state, env]) => Effect.sync(() => chatSessionRpc(new ChatSession(state.raw, env))), ) -/** The Durable Object: one per `":"`, SQLite-backed, bound to the api Worker as `ChatSession`. */ -export default class ChatSessionObject extends Cloudflare.DurableObject()( - "ChatSession", - activateChatSession, -) {} +/** + * The Durable Object: one per `":"`, SQLite-backed, hosted by this Worker and bound + * as `ChatSession` — the name `chatSessionStub` reads off `env` on both sides. + * + * `transferredFrom` names apps/api, which hosted this class until the agent surfaces moved here. + * Alchemy turns that into a data-preserving `transferred_classes` migration, so live transcripts + * follow the class rather than being stranded in a namespace nothing binds any more. Without it + * the api's own deploy fails with `DurableObjectTransferRequired`, because dropping a locally + * hosted class while keeping a cross-script reference to it is exactly the shape that silently + * destroys a namespace, and alchemy refuses it before any upload. + * + * It is inert once every stage has transferred — a fresh stage creates the class outright — so it + * stays here rather than being cleaned up later and breaking whichever stage lagged behind. + * + * The props-carrying class form is what makes room for that: the single-argument overload takes an + * implementation and no props, so the implementation moves to `ChatSessionLive` below. + */ +export class ChatSessionObject extends Cloudflare.DurableObject< + ChatSessionObject, + EffectRpc +>()("ChatSession", { transferredFrom: "api" }) {} + +/** The activation, as the layer the host Worker provides. */ +// `` pinned: the activation's requirements are all `DurableObjectServices`, +// which `.make` already discharges, but inference otherwise widens them into the +// layer's own requirements and they surface all the way up in `alchemy.run.ts`. +export const ChatSessionLive = ChatSessionObject.make(activateChatSession) diff --git a/apps/api/src/chat/ChatSessionObject.test.ts b/apps/ai/src/chat/ChatSessionObject.test.ts similarity index 100% rename from apps/api/src/chat/ChatSessionObject.test.ts rename to apps/ai/src/chat/ChatSessionObject.test.ts diff --git a/apps/api/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts similarity index 98% rename from apps/api/src/chat/agents.test.ts rename to apps/ai/src/chat/agents.test.ts index 107e85739..ec71a4463 100644 --- a/apps/api/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -9,7 +9,7 @@ import { ChatMode, makeChatSessionId } from "@maple/domain/chat-session" import { evaluatePermission } from "@maple/domain/permission" import { assert, describe, it } from "vitest" import { AGENTS, agentForSession, buildSystemPrompt, delegationToolName, spawnableFor } from "./agents" -import { mapleToolCatalog } from "@/mcp/tools/registry" +import { mapleToolCatalog } from "@ai/mcp/tools/registry" const subagents = Object.values(AGENTS).filter((agent) => agent.mode === "subagent") diff --git a/apps/api/src/chat/agents.ts b/apps/ai/src/chat/agents.ts similarity index 98% rename from apps/api/src/chat/agents.ts rename to apps/ai/src/chat/agents.ts index ef4b6f2d3..92830ff27 100644 --- a/apps/api/src/chat/agents.ts +++ b/apps/ai/src/chat/agents.ts @@ -20,10 +20,10 @@ import { PermissionRule } from "@maple/domain/permission" // The specific file, not the `./loop` barrel: the barrel re-exports `turn.ts`, which imports this // module back. `budgets.ts` depends on nothing but `effect`. import { MAX_TOOL_CALLS, REPEATED_TOOL_CALLS, TOOL_CONCURRENCY, TURN_MAX_DURATION } from "./budgets" -import { buildHypothesisSystemPrompt, hypothesisRuleset } from "@/workflows/hypothesis-catalogue" -import { PLANNER_MAX_STEPS, PLANNER_SYSTEM_PROMPT, PLANNER_TOOL_NAMES } from "@/workflows/planner-prompt" +import { buildHypothesisSystemPrompt, hypothesisRuleset } from "@ai/workflows/hypothesis-catalogue" +import { PLANNER_MAX_STEPS, PLANNER_SYSTEM_PROMPT, PLANNER_TOOL_NAMES } from "@ai/workflows/planner-prompt" import type { PermissionRuleset } from "@maple/domain/permission" -import type { ResolvedModel } from "@/platform/Llm" +import type { ResolvedModel } from "@ai/platform/Llm" import { DEFAULT_RULESET, READ_ONLY_RULESET } from "./permissions" import { EXPLORE_SYSTEM_PROMPT, diff --git a/apps/api/src/chat/budgets.ts b/apps/ai/src/chat/budgets.ts similarity index 100% rename from apps/api/src/chat/budgets.ts rename to apps/ai/src/chat/budgets.ts diff --git a/apps/api/src/chat/delegation.test.ts b/apps/ai/src/chat/delegation.test.ts similarity index 97% rename from apps/api/src/chat/delegation.test.ts rename to apps/ai/src/chat/delegation.test.ts index fe97133f9..012a5c10a 100644 --- a/apps/api/src/chat/delegation.test.ts +++ b/apps/ai/src/chat/delegation.test.ts @@ -12,8 +12,8 @@ import { Effect, Schema } from "effect" import { Model } from "effect/unstable/ai" import { ScriptedModel } from "@effect-agent/testing/ScriptedModel" import { assert, describe, it } from "vitest" -import type { McpToolExecutorApi } from "@/mcp/dispatcher" -import type { ResolvedModel } from "@/platform/Llm" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" +import type { ResolvedModel } from "@ai/platform/Llm" import type { TenantContext } from "@/services/auth/tenant-context" import { makeChatSessionId } from "@maple/domain/chat-session" import type { ScriptedStreamPart, ScriptedTurnInput } from "@effect-agent/testing/ScriptedModel" @@ -75,7 +75,7 @@ describe("buildDelegation", () => { }) it("names delegation tools apart from every Maple tool", async () => { - const { mapleToolCatalog } = await import("@/mcp/tools/registry") + const { mapleToolCatalog } = await import("@ai/mcp/tools/registry") const registry = new Set(mapleToolCatalog.map((definition) => definition.name)) for (const agent of spawners) { diff --git a/apps/api/src/chat/delegation.ts b/apps/ai/src/chat/delegation.ts similarity index 98% rename from apps/api/src/chat/delegation.ts rename to apps/ai/src/chat/delegation.ts index 7bfa87ccb..ac9f2d352 100644 --- a/apps/api/src/chat/delegation.ts +++ b/apps/ai/src/chat/delegation.ts @@ -34,8 +34,8 @@ import { IdGenerator } from "@effect-agent/core/IdGenerator" import * as Output from "@effect-agent/engine/Output" import { Effect, Layer, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi } from "@/mcp/dispatcher" -import type { LlmClients, ResolvedModel } from "@/platform/Llm" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" +import type { LlmClients, ResolvedModel } from "@ai/platform/Llm" import type { TenantContext } from "@/services/auth/tenant-context" import { agentPolicyFor, diff --git a/apps/api/src/chat/events.test.ts b/apps/ai/src/chat/events.test.ts similarity index 97% rename from apps/api/src/chat/events.test.ts rename to apps/ai/src/chat/events.test.ts index 3e5bd058a..d05eb4d49 100644 --- a/apps/api/src/chat/events.test.ts +++ b/apps/ai/src/chat/events.test.ts @@ -80,10 +80,9 @@ describe("toChatEvents", () => { toChatEvents(event("ToolCallSucceeded", { toolCallId: "c", result: "rows" }), base), [{ type: "tool-result", messageId: "msg-1", callId: "c", output: "rows" }], ) - assert.deepEqual( - toChatEvents(event("ToolCallFailed", { toolCallId: "c", message: "nope" }), base), - [{ type: "tool-result", messageId: "msg-1", callId: "c", output: "nope", isError: true }], - ) + assert.deepEqual(toChatEvents(event("ToolCallFailed", { toolCallId: "c", message: "nope" }), base), [ + { type: "tool-result", messageId: "msg-1", callId: "c", output: "nope", isError: true }, + ]) }) describe("terminal reasons", () => { diff --git a/apps/api/src/chat/events.ts b/apps/ai/src/chat/events.ts similarity index 100% rename from apps/api/src/chat/events.ts rename to apps/ai/src/chat/events.ts diff --git a/apps/api/src/chat/permissions.ts b/apps/ai/src/chat/permissions.ts similarity index 93% rename from apps/api/src/chat/permissions.ts rename to apps/ai/src/chat/permissions.ts index 9d4d99e3c..cebbabf08 100644 --- a/apps/api/src/chat/permissions.ts +++ b/apps/ai/src/chat/permissions.ts @@ -8,8 +8,8 @@ * cannot drift by accident. */ import { PermissionRule, type PermissionRuleset } from "@maple/domain/permission" -import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" -import { mapleToolCatalog } from "@/mcp/tools/registry" +import { MUTATING_TOOL_NAMES } from "@ai/mcp/tools/mutating" +import { mapleToolCatalog } from "@ai/mcp/tools/registry" /** * Today's behaviour, expressed as data: everything runs, mutations stop and ask. diff --git a/apps/api/src/chat/prompts.ts b/apps/ai/src/chat/prompts.ts similarity index 100% rename from apps/api/src/chat/prompts.ts rename to apps/ai/src/chat/prompts.ts diff --git a/apps/api/src/chat/run.test.ts b/apps/ai/src/chat/run.test.ts similarity index 100% rename from apps/api/src/chat/run.test.ts rename to apps/ai/src/chat/run.test.ts diff --git a/apps/api/src/chat/run.ts b/apps/ai/src/chat/run.ts similarity index 98% rename from apps/api/src/chat/run.ts rename to apps/ai/src/chat/run.ts index 97301fc8a..fa87cbdf2 100644 --- a/apps/api/src/chat/run.ts +++ b/apps/ai/src/chat/run.ts @@ -13,8 +13,8 @@ import { IdGenerator } from "@effect-agent/core/IdGenerator" import { ThreadId } from "@effect-agent/core/Identifiers" import { Effect, Layer, Schema, Stream } from "effect" import { Prompt, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi } from "@/mcp/dispatcher" -import type { ResolvedModel } from "@/platform/Llm" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" +import type { ResolvedModel } from "@ai/platform/Llm" import type { TenantContext } from "@/services/auth/tenant-context" import { agentForSession, chatAgent } from "./agents" import { buildDelegation } from "./delegation" diff --git a/apps/api/src/chat/tools.test.ts b/apps/ai/src/chat/tools.test.ts similarity index 100% rename from apps/api/src/chat/tools.test.ts rename to apps/ai/src/chat/tools.test.ts diff --git a/apps/api/src/chat/tools.ts b/apps/ai/src/chat/tools.ts similarity index 97% rename from apps/api/src/chat/tools.ts rename to apps/ai/src/chat/tools.ts index f78eaf750..617e7fa7d 100644 --- a/apps/api/src/chat/tools.ts +++ b/apps/ai/src/chat/tools.ts @@ -18,8 +18,9 @@ import { InvestigationId, UserId } from "@maple/domain/primitives" import type { RunBudgetHook, RunUsageDelta } from "@effect-agent/engine/RunOptions" import { Effect, Option, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi, McpToolSurface } from "@/mcp/dispatcher" -import { buildMapleToolkit, MapleToolFailure, summarizeToolFailure } from "@/mcp/tools/llm-tools" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" +import { buildMapleToolkit, MapleToolFailure, summarizeToolFailure } from "@ai/mcp/tools/llm-tools" import type { TenantContext } from "@/services/auth/tenant-context" const decodeInvestigationIdOption = Schema.decodeUnknownOption(InvestigationId) diff --git a/apps/api/src/chat/turn-metering.test.ts b/apps/ai/src/chat/turn-metering.test.ts similarity index 100% rename from apps/api/src/chat/turn-metering.test.ts rename to apps/ai/src/chat/turn-metering.test.ts diff --git a/apps/api/src/chat/turn-runner.ts b/apps/ai/src/chat/turn-runner.ts similarity index 98% rename from apps/api/src/chat/turn-runner.ts rename to apps/ai/src/chat/turn-runner.ts index 93d6b8f34..9edf318fb 100644 --- a/apps/api/src/chat/turn-runner.ts +++ b/apps/ai/src/chat/turn-runner.ts @@ -19,7 +19,7 @@ * thread the worker env through. */ import * as MapleCloudflareSDK from "@maple-dev/effect-sdk/cloudflare" -import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@/mcp/expected-failures" +import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@ai/mcp/expected-failures" import { decodeChatTurnTenant, investigationIdFromChatSessionId, @@ -207,8 +207,8 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis { McpToolExecutor }, ] = await Promise.all([ import("../runtime/mcp-service-graph"), - import("../platform/DatabasePgLive"), - import("../platform/pg-connection-source"), + import("@/platform/DatabasePgLive"), + import("@/platform/pg-connection-source"), import("../platform/Llm"), import("./tools"), import("../mcp/dispatcher"), diff --git a/apps/api/src/mcp/__evals__/BASELINE.md b/apps/ai/src/mcp/__evals__/BASELINE.md similarity index 100% rename from apps/api/src/mcp/__evals__/BASELINE.md rename to apps/ai/src/mcp/__evals__/BASELINE.md diff --git a/apps/api/src/mcp/__evals__/README.md b/apps/ai/src/mcp/__evals__/README.md similarity index 100% rename from apps/api/src/mcp/__evals__/README.md rename to apps/ai/src/mcp/__evals__/README.md diff --git a/apps/api/src/mcp/__evals__/cli-scenarios.eval.ts b/apps/ai/src/mcp/__evals__/cli-scenarios.eval.ts similarity index 100% rename from apps/api/src/mcp/__evals__/cli-scenarios.eval.ts rename to apps/ai/src/mcp/__evals__/cli-scenarios.eval.ts diff --git a/apps/api/src/mcp/__evals__/disambiguation.eval.ts b/apps/ai/src/mcp/__evals__/disambiguation.eval.ts similarity index 100% rename from apps/api/src/mcp/__evals__/disambiguation.eval.ts rename to apps/ai/src/mcp/__evals__/disambiguation.eval.ts diff --git a/apps/api/src/mcp/__evals__/eval-runtime.ts b/apps/ai/src/mcp/__evals__/eval-runtime.ts similarity index 96% rename from apps/api/src/mcp/__evals__/eval-runtime.ts rename to apps/ai/src/mcp/__evals__/eval-runtime.ts index 97887f007..3f9a224c2 100644 --- a/apps/api/src/mcp/__evals__/eval-runtime.ts +++ b/apps/ai/src/mcp/__evals__/eval-runtime.ts @@ -1,10 +1,10 @@ import { ConfigProvider, Effect, Layer, ManagedRuntime, Schema } from "effect" import { OrgId, UserId } from "@maple/domain/http" -import { McpServicesLive } from "@/runtime/mcp-service-graph" +import { McpServicesLive } from "@ai/runtime/mcp-service-graph" import { Env } from "@/platform/Env" import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { createTestDb } from "@/platform/test-pglite" -import { McpToolExecutor } from "@/mcp/dispatcher" +import { McpToolExecutor } from "@ai/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { FIXTURES } from "./utils" diff --git a/apps/api/src/mcp/__evals__/execution.eval.ts b/apps/ai/src/mcp/__evals__/execution.eval.ts similarity index 100% rename from apps/api/src/mcp/__evals__/execution.eval.ts rename to apps/ai/src/mcp/__evals__/execution.eval.ts diff --git a/apps/api/src/mcp/__evals__/fake-warehouse.ts b/apps/ai/src/mcp/__evals__/fake-warehouse.ts similarity index 100% rename from apps/api/src/mcp/__evals__/fake-warehouse.ts rename to apps/ai/src/mcp/__evals__/fake-warehouse.ts diff --git a/apps/api/src/mcp/__evals__/fixtures.ts b/apps/ai/src/mcp/__evals__/fixtures.ts similarity index 100% rename from apps/api/src/mcp/__evals__/fixtures.ts rename to apps/ai/src/mcp/__evals__/fixtures.ts diff --git a/apps/api/src/mcp/__evals__/issue-workflow.eval.ts b/apps/ai/src/mcp/__evals__/issue-workflow.eval.ts similarity index 100% rename from apps/api/src/mcp/__evals__/issue-workflow.eval.ts rename to apps/ai/src/mcp/__evals__/issue-workflow.eval.ts diff --git a/apps/api/src/mcp/__evals__/model.ts b/apps/ai/src/mcp/__evals__/model.ts similarity index 100% rename from apps/api/src/mcp/__evals__/model.ts rename to apps/ai/src/mcp/__evals__/model.ts diff --git a/apps/api/src/mcp/__evals__/observability.eval.ts b/apps/ai/src/mcp/__evals__/observability.eval.ts similarity index 100% rename from apps/api/src/mcp/__evals__/observability.eval.ts rename to apps/ai/src/mcp/__evals__/observability.eval.ts diff --git a/apps/api/src/mcp/__evals__/regression.test.ts b/apps/ai/src/mcp/__evals__/regression.test.ts similarity index 100% rename from apps/api/src/mcp/__evals__/regression.test.ts rename to apps/ai/src/mcp/__evals__/regression.test.ts diff --git a/apps/api/src/mcp/__evals__/scorers.ts b/apps/ai/src/mcp/__evals__/scorers.ts similarity index 100% rename from apps/api/src/mcp/__evals__/scorers.ts rename to apps/ai/src/mcp/__evals__/scorers.ts diff --git a/apps/api/src/mcp/__evals__/tools.ts b/apps/ai/src/mcp/__evals__/tools.ts similarity index 93% rename from apps/api/src/mcp/__evals__/tools.ts rename to apps/ai/src/mcp/__evals__/tools.ts index 88bde2542..c1f1c8bea 100644 --- a/apps/api/src/mcp/__evals__/tools.ts +++ b/apps/ai/src/mcp/__evals__/tools.ts @@ -1,7 +1,7 @@ import { jsonSchema, tool, type ToolSet } from "ai" import { Effect, type ManagedRuntime } from "effect" -import { McpToolExecutor } from "@/mcp/dispatcher" -import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" +import { McpToolExecutor } from "@ai/mcp/dispatcher" +import { mapleToolCatalog, toInputSchema } from "@ai/mcp/tools/registry" import type { TenantContext } from "@/services/auth/tenant-context" /** diff --git a/apps/api/src/mcp/__evals__/utils.ts b/apps/ai/src/mcp/__evals__/utils.ts similarity index 100% rename from apps/api/src/mcp/__evals__/utils.ts rename to apps/ai/src/mcp/__evals__/utils.ts diff --git a/apps/api/src/mcp/app.test.ts b/apps/ai/src/mcp/app.test.ts similarity index 100% rename from apps/api/src/mcp/app.test.ts rename to apps/ai/src/mcp/app.test.ts diff --git a/apps/api/src/mcp/app.ts b/apps/ai/src/mcp/app.ts similarity index 100% rename from apps/api/src/mcp/app.ts rename to apps/ai/src/mcp/app.ts diff --git a/apps/api/src/mcp/dispatcher.test.ts b/apps/ai/src/mcp/dispatcher.test.ts similarity index 97% rename from apps/api/src/mcp/dispatcher.test.ts rename to apps/ai/src/mcp/dispatcher.test.ts index a13b2a14a..a3aa392ce 100644 --- a/apps/api/src/mcp/dispatcher.test.ts +++ b/apps/ai/src/mcp/dispatcher.test.ts @@ -1,6 +1,6 @@ import { assert, describe, expect, it } from "@effect/vitest" import { Context, Effect, Schema, Tracer } from "effect" -import type { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc" +import type { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import { McpToolExecutor, listMcpTools } from "./dispatcher" import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "./expected-failures" import { mapleToolCatalog, toInputSchema } from "./tools/registry" @@ -94,11 +94,11 @@ describe("MCP dispatcher", () => { const error = yield* Effect.flip( executor.execute(TENANT, "not_a_maple_tool", {}, "mcp") as Effect.Effect< never, - InternalRpcToolNotFoundError, + McpToolNotFoundError, never >, ) - expect(error._tag).toBe("@maple/internal-rpc/ToolNotFoundError") + expect(error._tag).toBe("@maple/mcp/ToolNotFoundError") expect(error.name).toBe("not_a_maple_tool") }), ) diff --git a/apps/api/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts similarity index 86% rename from apps/api/src/mcp/dispatcher.ts rename to apps/ai/src/mcp/dispatcher.ts index e7e306730..f0edbe6ba 100644 --- a/apps/api/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -1,5 +1,6 @@ // BOUNDARY: This module owns unparsed external values and narrows them before domain use. -import { InternalRpcToolNotFoundError, type InternalMcpToolDescriptor } from "@maple/domain/internal-rpc" +import { McpToolNotFoundError, type McpToolDescriptor } from "@maple/domain/mcp-tool-contract" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" import { Context, Effect, Layer } from "effect" import { executeRegisteredMcpToolUnscoped, mapleToolCatalog, toInputSchema } from "./tools/registry" import type { McpToolResult } from "./tools/types" @@ -18,9 +19,9 @@ import { recordMcpToolAudit } from "@/services/audit/audit-access" * evaluate first could observe the tool catalog as `undefined`. Deferring removes the * ordering dependency entirely rather than papering over one edge of the cycle. */ -let toolDescriptors: ReadonlyArray | undefined +let toolDescriptors: ReadonlyArray | undefined -const listToolDescriptors = (): ReadonlyArray => +const listToolDescriptors = (): ReadonlyArray => (toolDescriptors ??= mapleToolCatalog.map((definition) => ({ name: definition.name, description: definition.description, @@ -122,32 +123,13 @@ const callMcpToolUnscoped = Effect.fn("McpToolDispatcher.call")(function* (name: ) }) -/** - * Which entry point drove this tool call. - * - * Four surfaces share one dispatcher, and until this existed none of them were - * distinguishable in telemetry: the public-vs-internal traffic split had to be - * inferred from the ratio of `tools/call` spans to executor spans. Required - * rather than defaulted, for the same reason `tenant` is — a caller that forgets - * it should not silently be counted as somebody else. - */ -export type McpToolSurface = - /** The public MCP transport (`mcp/server.ts`). */ - | "mcp" - /** The in-process AI chat agent (`chat/turn-runner.ts`). */ - | "chat" - /** Agent workflow passes (`workflows/agent-pass.ts`). */ - | "workflow" - /** Worker-to-worker internal RPC (`internal-rpc.ts`). */ - | "rpc" - export interface McpToolExecutorApi { readonly execute: ( tenant: TenantContext, name: string, input: unknown, surface: McpToolSurface, - ) => Effect.Effect + ) => Effect.Effect } /** diff --git a/apps/api/src/mcp/expected-failures.test.ts b/apps/ai/src/mcp/expected-failures.test.ts similarity index 100% rename from apps/api/src/mcp/expected-failures.test.ts rename to apps/ai/src/mcp/expected-failures.test.ts diff --git a/apps/api/src/mcp/expected-failures.ts b/apps/ai/src/mcp/expected-failures.ts similarity index 100% rename from apps/api/src/mcp/expected-failures.ts rename to apps/ai/src/mcp/expected-failures.ts diff --git a/apps/api/src/mcp/lib/chart-statistics.test.ts b/apps/ai/src/mcp/lib/chart-statistics.test.ts similarity index 100% rename from apps/api/src/mcp/lib/chart-statistics.test.ts rename to apps/ai/src/mcp/lib/chart-statistics.test.ts diff --git a/apps/api/src/mcp/lib/chart-statistics.ts b/apps/ai/src/mcp/lib/chart-statistics.ts similarity index 100% rename from apps/api/src/mcp/lib/chart-statistics.ts rename to apps/ai/src/mcp/lib/chart-statistics.ts diff --git a/apps/api/src/mcp/lib/dashboard-docs-drift.test.ts b/apps/ai/src/mcp/lib/dashboard-docs-drift.test.ts similarity index 100% rename from apps/api/src/mcp/lib/dashboard-docs-drift.test.ts rename to apps/ai/src/mcp/lib/dashboard-docs-drift.test.ts diff --git a/apps/api/src/mcp/lib/dashboard-mutations.test.ts b/apps/ai/src/mcp/lib/dashboard-mutations.test.ts similarity index 99% rename from apps/api/src/mcp/lib/dashboard-mutations.test.ts rename to apps/ai/src/mcp/lib/dashboard-mutations.test.ts index 90a91323e..6ee72d095 100644 --- a/apps/api/src/mcp/lib/dashboard-mutations.test.ts +++ b/apps/ai/src/mcp/lib/dashboard-mutations.test.ts @@ -19,8 +19,8 @@ import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { decodeDataSourceJson, decodeWidgetJson, withDashboardMutation } from "./dashboard-mutations" import { CurrentMcpTenant } from "./query-warehouse" -import { registerUpdateDashboardTool } from "@/mcp/tools/update-dashboard" -import type { McpToolError, McpToolRegistrar, McpToolResult } from "@/mcp/tools/types" +import { registerUpdateDashboardTool } from "@ai/mcp/tools/update-dashboard" +import type { McpToolError, McpToolRegistrar, McpToolResult } from "@ai/mcp/tools/types" const trackedDbs: TestDb[] = [] diff --git a/apps/api/src/mcp/lib/dashboard-mutations.ts b/apps/ai/src/mcp/lib/dashboard-mutations.ts similarity index 98% rename from apps/api/src/mcp/lib/dashboard-mutations.ts rename to apps/ai/src/mcp/lib/dashboard-mutations.ts index e20165e21..61f8720de 100644 --- a/apps/api/src/mcp/lib/dashboard-mutations.ts +++ b/apps/ai/src/mcp/lib/dashboard-mutations.ts @@ -15,9 +15,9 @@ import { widgetTypeByVisualization, withWidgets, } from "@maple/domain/http" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" -import { McpQueryError } from "@/mcp/tools/types" +import { McpQueryError } from "@ai/mcp/tools/types" const decodeDashboardId = Schema.decodeUnknownEffect(DashboardId) diff --git a/apps/api/src/mcp/lib/dashboard-schema-doc.test.ts b/apps/ai/src/mcp/lib/dashboard-schema-doc.test.ts similarity index 100% rename from apps/api/src/mcp/lib/dashboard-schema-doc.test.ts rename to apps/ai/src/mcp/lib/dashboard-schema-doc.test.ts diff --git a/apps/api/src/mcp/lib/dashboard-schema-doc.ts b/apps/ai/src/mcp/lib/dashboard-schema-doc.ts similarity index 100% rename from apps/api/src/mcp/lib/dashboard-schema-doc.ts rename to apps/ai/src/mcp/lib/dashboard-schema-doc.ts diff --git a/apps/api/src/mcp/lib/format-query-result.ts b/apps/ai/src/mcp/lib/format-query-result.ts similarity index 98% rename from apps/api/src/mcp/lib/format-query-result.ts rename to apps/ai/src/mcp/lib/format-query-result.ts index db4afe45a..9d2aea7d8 100644 --- a/apps/api/src/mcp/lib/format-query-result.ts +++ b/apps/ai/src/mcp/lib/format-query-result.ts @@ -1,7 +1,7 @@ import { formatDurationFromMs, formatNumber, formatPercent, formatTable } from "./format" import { formatNextSteps } from "./next-steps" import { createDualContent } from "./structured-output" -import type { McpToolResult } from "@/mcp/tools/types" +import type { McpToolResult } from "@ai/mcp/tools/types" import type { QueryEngineExecuteResponse } from "@maple/query-engine" import type { QueryDataQueryContext, QueryDataUnit } from "@maple/domain" diff --git a/apps/api/src/mcp/lib/format.ts b/apps/ai/src/mcp/lib/format.ts similarity index 100% rename from apps/api/src/mcp/lib/format.ts rename to apps/ai/src/mcp/lib/format.ts diff --git a/apps/api/src/mcp/lib/inspect-widget.ts b/apps/ai/src/mcp/lib/inspect-widget.ts similarity index 100% rename from apps/api/src/mcp/lib/inspect-widget.ts rename to apps/ai/src/mcp/lib/inspect-widget.ts diff --git a/apps/api/src/mcp/lib/limits.test.ts b/apps/ai/src/mcp/lib/limits.test.ts similarity index 100% rename from apps/api/src/mcp/lib/limits.test.ts rename to apps/ai/src/mcp/lib/limits.test.ts diff --git a/apps/api/src/mcp/lib/limits.ts b/apps/ai/src/mcp/lib/limits.ts similarity index 100% rename from apps/api/src/mcp/lib/limits.ts rename to apps/ai/src/mcp/lib/limits.ts diff --git a/apps/api/src/mcp/lib/map-http-error.ts b/apps/ai/src/mcp/lib/map-http-error.ts similarity index 87% rename from apps/api/src/mcp/lib/map-http-error.ts rename to apps/ai/src/mcp/lib/map-http-error.ts index c7d00c876..20adcf32c 100644 --- a/apps/api/src/mcp/lib/map-http-error.ts +++ b/apps/ai/src/mcp/lib/map-http-error.ts @@ -1,5 +1,5 @@ import type { SelfDescribingHttpError } from "@maple/domain/http" -import { McpQueryError } from "@/mcp/tools/types" +import { McpQueryError } from "@ai/mcp/tools/types" /** Adapt an HTTP-domain failure at the MCP protocol boundary without reclassifying its tag. */ export const toMcpHttpError = diff --git a/apps/api/src/mcp/lib/map-warehouse-error.test.ts b/apps/ai/src/mcp/lib/map-warehouse-error.test.ts similarity index 100% rename from apps/api/src/mcp/lib/map-warehouse-error.test.ts rename to apps/ai/src/mcp/lib/map-warehouse-error.test.ts diff --git a/apps/api/src/mcp/lib/map-warehouse-error.ts b/apps/ai/src/mcp/lib/map-warehouse-error.ts similarity index 97% rename from apps/api/src/mcp/lib/map-warehouse-error.ts rename to apps/ai/src/mcp/lib/map-warehouse-error.ts index aadf41a75..eebaa9e9e 100644 --- a/apps/api/src/mcp/lib/map-warehouse-error.ts +++ b/apps/ai/src/mcp/lib/map-warehouse-error.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { type WarehouseError, WarehouseSchemaDriftError } from "@maple/domain" import { warehouseHandlers, warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers" -import { McpQueryError } from "@/mcp/tools/types" +import { McpQueryError } from "@ai/mcp/tools/types" export { warehouseHandlers, warehouseReadHandlers } diff --git a/apps/api/src/mcp/lib/next-steps.ts b/apps/ai/src/mcp/lib/next-steps.ts similarity index 100% rename from apps/api/src/mcp/lib/next-steps.ts rename to apps/ai/src/mcp/lib/next-steps.ts diff --git a/apps/api/src/mcp/lib/panel-type.test.ts b/apps/ai/src/mcp/lib/panel-type.test.ts similarity index 100% rename from apps/api/src/mcp/lib/panel-type.test.ts rename to apps/ai/src/mcp/lib/panel-type.test.ts diff --git a/apps/api/src/mcp/lib/panel-type.ts b/apps/ai/src/mcp/lib/panel-type.ts similarity index 100% rename from apps/api/src/mcp/lib/panel-type.ts rename to apps/ai/src/mcp/lib/panel-type.ts diff --git a/apps/api/src/mcp/lib/query-spec-tokens.test.ts b/apps/ai/src/mcp/lib/query-spec-tokens.test.ts similarity index 100% rename from apps/api/src/mcp/lib/query-spec-tokens.test.ts rename to apps/ai/src/mcp/lib/query-spec-tokens.test.ts diff --git a/apps/api/src/mcp/lib/query-spec-tokens.ts b/apps/ai/src/mcp/lib/query-spec-tokens.ts similarity index 100% rename from apps/api/src/mcp/lib/query-spec-tokens.ts rename to apps/ai/src/mcp/lib/query-spec-tokens.ts diff --git a/apps/api/src/mcp/lib/query-warehouse.ts b/apps/ai/src/mcp/lib/query-warehouse.ts similarity index 92% rename from apps/api/src/mcp/lib/query-warehouse.ts rename to apps/ai/src/mcp/lib/query-warehouse.ts index 57b1696e5..d4cc1409d 100644 --- a/apps/api/src/mcp/lib/query-warehouse.ts +++ b/apps/ai/src/mcp/lib/query-warehouse.ts @@ -1,10 +1,10 @@ import { HttpServerRequest } from "effect/unstable/http" import type { WarehouseQueryName } from "@maple/domain" import { Context, Effect } from "effect" -import { resolveMcpTenantContext } from "@/mcp/lib/resolve-tenant" +import { resolveMcpTenantContext } from "@ai/mcp/lib/resolve-tenant" import type { TenantContext } from "@/services/auth/tenant-context" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { McpAuthMissingError } from "@/mcp/tools/types" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { McpAuthMissingError } from "@ai/mcp/tools/types" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" import { WarehouseExecutor } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/lib/raw-sql-widget.test.ts b/apps/ai/src/mcp/lib/raw-sql-widget.test.ts similarity index 100% rename from apps/api/src/mcp/lib/raw-sql-widget.test.ts rename to apps/ai/src/mcp/lib/raw-sql-widget.test.ts diff --git a/apps/api/src/mcp/lib/raw-sql-widget.ts b/apps/ai/src/mcp/lib/raw-sql-widget.ts similarity index 100% rename from apps/api/src/mcp/lib/raw-sql-widget.ts rename to apps/ai/src/mcp/lib/raw-sql-widget.ts diff --git a/apps/api/src/mcp/lib/render-trace.test.ts b/apps/ai/src/mcp/lib/render-trace.test.ts similarity index 100% rename from apps/api/src/mcp/lib/render-trace.test.ts rename to apps/ai/src/mcp/lib/render-trace.test.ts diff --git a/apps/api/src/mcp/lib/render-trace.ts b/apps/ai/src/mcp/lib/render-trace.ts similarity index 100% rename from apps/api/src/mcp/lib/render-trace.ts rename to apps/ai/src/mcp/lib/render-trace.ts diff --git a/apps/api/src/mcp/lib/resolve-actor.test.ts b/apps/ai/src/mcp/lib/resolve-actor.test.ts similarity index 100% rename from apps/api/src/mcp/lib/resolve-actor.test.ts rename to apps/ai/src/mcp/lib/resolve-actor.test.ts diff --git a/apps/api/src/mcp/lib/resolve-actor.ts b/apps/ai/src/mcp/lib/resolve-actor.ts similarity index 97% rename from apps/api/src/mcp/lib/resolve-actor.ts rename to apps/ai/src/mcp/lib/resolve-actor.ts index 17013a825..ee80d3060 100644 --- a/apps/api/src/mcp/lib/resolve-actor.ts +++ b/apps/ai/src/mcp/lib/resolve-actor.ts @@ -2,7 +2,7 @@ import { Effect } from "effect" import { isReservedAgentName } from "@maple/domain/system-agents" import type { TenantContext } from "@/services/auth/tenant-context" import { ErrorActorsService } from "@/services/errors/ErrorActorsService" -import { McpQueryError } from "@/mcp/tools/types" +import { McpQueryError } from "@ai/mcp/tools/types" /** * Agent-actor name derived from an MCP client's `initialize` clientInfo.name. diff --git a/apps/api/src/mcp/lib/resolve-dashboard-time-range.test.ts b/apps/ai/src/mcp/lib/resolve-dashboard-time-range.test.ts similarity index 100% rename from apps/api/src/mcp/lib/resolve-dashboard-time-range.test.ts rename to apps/ai/src/mcp/lib/resolve-dashboard-time-range.test.ts diff --git a/apps/api/src/mcp/lib/resolve-dashboard-time-range.ts b/apps/ai/src/mcp/lib/resolve-dashboard-time-range.ts similarity index 100% rename from apps/api/src/mcp/lib/resolve-dashboard-time-range.ts rename to apps/ai/src/mcp/lib/resolve-dashboard-time-range.ts diff --git a/apps/ai/src/mcp/lib/resolve-tenant.oauth.test.ts b/apps/ai/src/mcp/lib/resolve-tenant.oauth.test.ts new file mode 100644 index 000000000..840a258f4 --- /dev/null +++ b/apps/ai/src/mcp/lib/resolve-tenant.oauth.test.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto" +import { afterEach, describe, expect, it } from "@effect/vitest" +import { OrgId, RoleName, UserId } from "@maple/domain/http" +import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { Env } from "@/platform/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { AuthService } from "@/services/auth/AuthService" +import { McpOAuthService } from "@/services/auth/McpOAuthService" +import { ApiKeysService } from "@/services/org/ApiKeysService" +import { resolveMcpTenantContext } from "@ai/mcp/lib/resolve-tenant" + +/** + * The seam between the two Workers. + * + * `McpOAuthService` stayed on apps/api, which serves the OAuth endpoints and owns + * the issuer. The code that accepts what it mints moved here. Nothing else pins + * that a token issued on one side is honoured on the other, or — the half that + * actually protects anything — that it is refused for a resource it was not + * bound to. This test used to live inside `McpOAuthService.test.ts`, where both + * halves were one process; it belongs on the side that would break. + */ +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const config = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_APP_BASE_URL: "https://app.example.com", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + }), + ) + +const makeLayer = (testDb: TestDb) => { + const base = Layer.mergeAll(testDb.layer, Env.layer.pipe(Layer.provide(config()))) + return Layer.mergeAll( + McpOAuthService.layer.pipe(Layer.provide(base)), + ApiKeysService.layer.pipe(Layer.provide(base)), + AuthService.layer.pipe(Layer.provide(base)), + base, + ) +} + +const orgId = Schema.decodeUnknownSync(OrgId)("org_mcp") +const userId = Schema.decodeUnknownSync(UserId)("user_mcp") +const memberRole = Schema.decodeUnknownSync(RoleName)("org:member") +const resource = "https://api.example.com/mcp" +const redirectUri = "http://127.0.0.1:49152/callback" +const verifier = "maple-mcp-oauth-verifier-that-is-long-enough-1234567890" +const challenge = createHash("sha256").update(verifier).digest("base64url") + +/** Register → authorize → approve → exchange, the shortest path to a live grant. */ +const issueGrant = Effect.fnUntraced(function* (oauth: McpOAuthService) { + const client = yield* oauth.register( + { clientName: "seam-test", redirectUris: [redirectUri] }, + "127.0.0.1", + ) + const started = yield* oauth.startAuthorization( + { + clientId: client.client_id, + redirectUri, + responseType: "code", + codeChallenge: challenge, + codeChallengeMethod: "S256", + resource, + expectedResource: resource, + }, + "127.0.0.1", + ) + const requestId = new URL(started.consentUrl).searchParams.get("request_id")! + const approved = yield* oauth.approve(requestId, { + orgId, + userId, + roles: [memberRole], + userEmail: null, + }) + return yield* oauth.exchangeAuthorizationCode( + { + code: new URL(approved.redirectUri).searchParams.get("code")!, + clientId: client.client_id, + redirectUri, + codeVerifier: verifier, + resource, + }, + "127.0.0.1", + ) +}) + +describe("an MCP OAuth token, resolved by the AI worker", () => { + it.effect("carries the org and roles the grant was approved with", () => { + const db = createTestDb(createdDbs) + return Effect.gen(function* () { + const tokens = yield* issueGrant(yield* McpOAuthService) + const tenant = yield* resolveMcpTenantContext( + new Request(resource, { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }), + ) + expect(tenant.orgId).toBe(orgId) + expect(tenant.roles).toEqual([memberRole]) + }).pipe(Effect.provide(makeLayer(db))) + }) + + it.effect("is refused for a resource it was not bound to", () => { + const db = createTestDb(createdDbs) + return Effect.gen(function* () { + const tokens = yield* issueGrant(yield* McpOAuthService) + // RFC 8707 audience binding. Without this check a token minted for one + // deployment's `/mcp` would authenticate against another's. + const failure = yield* resolveMcpTenantContext( + new Request("https://other.example.com/mcp", { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }), + ).pipe(Effect.flip) + expect(failure._tag).toBe("@maple/mcp/errors/McpAuthInvalidError") + if (failure._tag === "@maple/mcp/errors/McpAuthInvalidError") { + expect(failure.reason).toBe("invalid_target") + } + }).pipe(Effect.provide(makeLayer(db))) + }) +}) diff --git a/apps/api/src/mcp/lib/resolve-tenant.ts b/apps/ai/src/mcp/lib/resolve-tenant.ts similarity index 98% rename from apps/api/src/mcp/lib/resolve-tenant.ts rename to apps/ai/src/mcp/lib/resolve-tenant.ts index b75032b37..8d86aecd5 100644 --- a/apps/api/src/mcp/lib/resolve-tenant.ts +++ b/apps/ai/src/mcp/lib/resolve-tenant.ts @@ -10,8 +10,8 @@ import { McpAuthMissingError, McpAuthUnavailableError, McpInvalidTenantError, -} from "@/mcp/tools/types" -import { recordExpectedMcpFailure } from "@/mcp/expected-failures" +} from "@ai/mcp/tools/types" +import { recordExpectedMcpFailure } from "@ai/mcp/expected-failures" /** Exported so the audit layer classifies the same token the same way. */ export const INTERNAL_SERVICE_PREFIX = "maple_svc_" diff --git a/apps/api/src/mcp/lib/run-raw-sql.test.ts b/apps/ai/src/mcp/lib/run-raw-sql.test.ts similarity index 100% rename from apps/api/src/mcp/lib/run-raw-sql.test.ts rename to apps/ai/src/mcp/lib/run-raw-sql.test.ts diff --git a/apps/api/src/mcp/lib/run-raw-sql.ts b/apps/ai/src/mcp/lib/run-raw-sql.ts similarity index 100% rename from apps/api/src/mcp/lib/run-raw-sql.ts rename to apps/ai/src/mcp/lib/run-raw-sql.ts diff --git a/apps/api/src/mcp/lib/span-tree.test.ts b/apps/ai/src/mcp/lib/span-tree.test.ts similarity index 100% rename from apps/api/src/mcp/lib/span-tree.test.ts rename to apps/ai/src/mcp/lib/span-tree.test.ts diff --git a/apps/api/src/mcp/lib/span-tree.ts b/apps/ai/src/mcp/lib/span-tree.ts similarity index 100% rename from apps/api/src/mcp/lib/span-tree.ts rename to apps/ai/src/mcp/lib/span-tree.ts diff --git a/apps/api/src/mcp/lib/structured-output.ts b/apps/ai/src/mcp/lib/structured-output.ts similarity index 100% rename from apps/api/src/mcp/lib/structured-output.ts rename to apps/ai/src/mcp/lib/structured-output.ts diff --git a/apps/api/src/mcp/lib/time.test.ts b/apps/ai/src/mcp/lib/time.test.ts similarity index 100% rename from apps/api/src/mcp/lib/time.test.ts rename to apps/ai/src/mcp/lib/time.test.ts diff --git a/apps/api/src/mcp/lib/time.ts b/apps/ai/src/mcp/lib/time.ts similarity index 100% rename from apps/api/src/mcp/lib/time.ts rename to apps/ai/src/mcp/lib/time.ts diff --git a/apps/api/src/mcp/lib/validate-widget-renderability.test.ts b/apps/ai/src/mcp/lib/validate-widget-renderability.test.ts similarity index 100% rename from apps/api/src/mcp/lib/validate-widget-renderability.test.ts rename to apps/ai/src/mcp/lib/validate-widget-renderability.test.ts diff --git a/apps/api/src/mcp/lib/validate-widget-renderability.ts b/apps/ai/src/mcp/lib/validate-widget-renderability.ts similarity index 100% rename from apps/api/src/mcp/lib/validate-widget-renderability.ts rename to apps/ai/src/mcp/lib/validate-widget-renderability.ts diff --git a/apps/api/src/mcp/prompts/debug-errors.ts b/apps/ai/src/mcp/prompts/debug-errors.ts similarity index 100% rename from apps/api/src/mcp/prompts/debug-errors.ts rename to apps/ai/src/mcp/prompts/debug-errors.ts diff --git a/apps/api/src/mcp/prompts/incident-triage.ts b/apps/ai/src/mcp/prompts/incident-triage.ts similarity index 100% rename from apps/api/src/mcp/prompts/incident-triage.ts rename to apps/ai/src/mcp/prompts/incident-triage.ts diff --git a/apps/api/src/mcp/prompts/latency-analysis.ts b/apps/ai/src/mcp/prompts/latency-analysis.ts similarity index 100% rename from apps/api/src/mcp/prompts/latency-analysis.ts rename to apps/ai/src/mcp/prompts/latency-analysis.ts diff --git a/apps/api/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts similarity index 100% rename from apps/api/src/mcp/resources/instructions.ts rename to apps/ai/src/mcp/resources/instructions.ts diff --git a/apps/api/src/mcp/server.ts b/apps/ai/src/mcp/server.ts similarity index 96% rename from apps/api/src/mcp/server.ts rename to apps/ai/src/mcp/server.ts index d9ac9d48d..c76ce750e 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -45,7 +45,7 @@ export const McpToolsLive = Layer.effectDiscard( return yield* executor.execute(tenant, descriptor.name, payload, "mcp").pipe( Effect.map(toCallToolResult), - Effect.catchTag("@maple/internal-rpc/ToolNotFoundError", (error) => + Effect.catchTag("@maple/mcp/ToolNotFoundError", (error) => Effect.succeed(toBoundaryErrorResult(error)), ), ) diff --git a/apps/api/src/mcp/tools/__tests__/audit-setup.test.ts b/apps/ai/src/mcp/tools/__tests__/audit-setup.test.ts similarity index 97% rename from apps/api/src/mcp/tools/__tests__/audit-setup.test.ts rename to apps/ai/src/mcp/tools/__tests__/audit-setup.test.ts index eecd42615..deed1be47 100644 --- a/apps/api/src/mcp/tools/__tests__/audit-setup.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/audit-setup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" import type { AuditCheckResult } from "@maple/domain/setup-audit" -import { byUrgency, formatAffected } from "@/mcp/tools/audit-setup" +import { byUrgency, formatAffected } from "@ai/mcp/tools/audit-setup" const check = (overrides: Partial = {}): AuditCheckResult => ({ id: "CFG-ALERT-01", diff --git a/apps/api/src/mcp/tools/__tests__/dashboard-concurrency.test.ts b/apps/ai/src/mcp/tools/__tests__/dashboard-concurrency.test.ts similarity index 100% rename from apps/api/src/mcp/tools/__tests__/dashboard-concurrency.test.ts rename to apps/ai/src/mcp/tools/__tests__/dashboard-concurrency.test.ts diff --git a/apps/api/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts b/apps/ai/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts similarity index 95% rename from apps/api/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts rename to apps/ai/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts index cf1b43701..20a80d8af 100644 --- a/apps/api/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts @@ -3,7 +3,7 @@ // here we only guard the tool's severity mapping and coverage-gap derivation. import { describe, expect, it } from "vitest" -import { deriveCoverageGaps, kindToSeverity } from "@/mcp/tools/get-instrumentation-recommendations" +import { deriveCoverageGaps, kindToSeverity } from "@ai/mcp/tools/get-instrumentation-recommendations" describe("kindToSeverity", () => { it("maps rename and double-emission to warn", () => { diff --git a/apps/api/src/mcp/tools/__tests__/query-funnel.test.ts b/apps/ai/src/mcp/tools/__tests__/query-funnel.test.ts similarity index 94% rename from apps/api/src/mcp/tools/__tests__/query-funnel.test.ts rename to apps/ai/src/mcp/tools/__tests__/query-funnel.test.ts index 7865d5b45..eb91db87f 100644 --- a/apps/api/src/mcp/tools/__tests__/query-funnel.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/query-funnel.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "@effect/vitest" import { Context, Effect, Option, Schema } from "effect" import { WarehouseExecutor, productEventsFunnel } from "@maple/query-engine/observability" import { CH } from "@maple/query-engine" -import type { McpToolRequirements } from "@/mcp/tools/runtime-requirements" -import type { McpToolRegistrar, McpToolResult } from "@/mcp/tools/types" -import { registerQueryFunnelTool } from "@/mcp/tools/query-funnel" -import { registerListProductEventsTool } from "@/mcp/tools/list-product-events" -import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" +import type { McpToolRequirements } from "@ai/mcp/tools/runtime-requirements" +import type { McpToolRegistrar, McpToolResult } from "@ai/mcp/tools/types" +import { registerQueryFunnelTool } from "@ai/mcp/tools/query-funnel" +import { registerListProductEventsTool } from "@ai/mcp/tools/list-product-events" +import { mapleToolCatalog, toInputSchema } from "@ai/mcp/tools/registry" import { compiledQueryOf } from "@maple/query-engine/execution" // Capture the handler the tool registers so its validation paths can be driven diff --git a/apps/api/src/mcp/tools/__tests__/run-sql-unknown-column.test.ts b/apps/ai/src/mcp/tools/__tests__/run-sql-unknown-column.test.ts similarity index 100% rename from apps/api/src/mcp/tools/__tests__/run-sql-unknown-column.test.ts rename to apps/ai/src/mcp/tools/__tests__/run-sql-unknown-column.test.ts diff --git a/apps/api/src/mcp/tools/__tests__/run-sql-unknown-table.test.ts b/apps/ai/src/mcp/tools/__tests__/run-sql-unknown-table.test.ts similarity index 100% rename from apps/api/src/mcp/tools/__tests__/run-sql-unknown-table.test.ts rename to apps/ai/src/mcp/tools/__tests__/run-sql-unknown-table.test.ts diff --git a/apps/api/src/mcp/tools/add-dashboard-widget.ts b/apps/ai/src/mcp/tools/add-dashboard-widget.ts similarity index 97% rename from apps/api/src/mcp/tools/add-dashboard-widget.ts rename to apps/ai/src/mcp/tools/add-dashboard-widget.ts index f8d5dbe2f..ac8f4d59a 100644 --- a/apps/api/src/mcp/tools/add-dashboard-widget.ts +++ b/apps/ai/src/mcp/tools/add-dashboard-widget.ts @@ -8,7 +8,7 @@ import { } from "./types" import { Effect, Schema } from "effect" import { MCP_VISUALIZATIONS, RawSqlDisplayType } from "@maple/domain/http" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { decodeDataSourceJson, decodeDisplayJson, @@ -19,17 +19,17 @@ import { generateWidgetId, withDashboardMutation, type DashboardWidget, -} from "@/mcp/lib/dashboard-mutations" -import { buildRawSqlDataSource, validateRawSql, withScalarReduction } from "@/mcp/lib/raw-sql-widget" +} from "@ai/mcp/lib/dashboard-mutations" +import { buildRawSqlDataSource, validateRawSql, withScalarReduction } from "@ai/mcp/lib/raw-sql-widget" import { makeProductEventsFunnelDataSource } from "@maple/widgets/dashboard" -import { PANEL_TYPE_LIST_MD, resolvePanelType } from "@/mcp/lib/panel-type" -import { formatRenderIssues, validateWidgetRenderability } from "@/mcp/lib/validate-widget-renderability" +import { PANEL_TYPE_LIST_MD, resolvePanelType } from "@ai/mcp/lib/panel-type" +import { formatRenderIssues, validateWidgetRenderability } from "@ai/mcp/lib/validate-widget-renderability" import { collectBlockingBuilderWarnings, formatValidationSummary, inspectWidgetsAfterMutation, -} from "@/mcp/lib/inspect-widget" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +} from "@ai/mcp/lib/inspect-widget" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" const TOOL = "add_dashboard_widget" diff --git a/apps/ai/src/mcp/tools/alert-read-models.boundary.test.ts b/apps/ai/src/mcp/tools/alert-read-models.boundary.test.ts new file mode 100644 index 000000000..74aafb481 --- /dev/null +++ b/apps/ai/src/mcp/tools/alert-read-models.boundary.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +const readModule = (path: string): string => readFileSync(new URL(path, import.meta.url), "utf8") + +const importSpecifiers = (source: string): ReadonlyArray => + Array.from(source.matchAll(/(?:from\s+|import\s*\()["']([^"']+)["']/g), (match) => match[1]!) + +/** + * The MCP half of `AlertReadModelsService`'s boundary, which lived beside the + * service until the tools moved Workers. It is kept because the distinction is + * easy to lose: a read handler that reaches for `AlertsService` pulls the whole + * evaluation and dispatch graph in behind it, and these tools only ever read. + */ +describe("alert read tools", () => { + it("read through AlertReadModelsService, never AlertsService", () => { + for (const path of [ + "./list-alert-incidents.ts", + "./get-incident-timeline.ts", + "./list-alert-checks.ts", + ]) { + const imports = importSpecifiers(readModule(path)) + expect(imports).toContain("@/services/alerts/AlertReadModelsService") + expect(imports).not.toContain("@/services/alerts/AlertsService") + } + }) +}) diff --git a/apps/api/src/mcp/tools/audit-setup.ts b/apps/ai/src/mcp/tools/audit-setup.ts similarity index 96% rename from apps/api/src/mcp/tools/audit-setup.ts rename to apps/ai/src/mcp/tools/audit-setup.ts index 5cdbd213a..2f8e0316f 100644 --- a/apps/api/src/mcp/tools/audit-setup.ts +++ b/apps/ai/src/mcp/tools/audit-setup.ts @@ -1,9 +1,9 @@ import { McpQueryError, optionalBooleanParam, type McpToolRegistrar } from "./types" -import { formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import type { AuditCheckResult, AuditSeverity } from "@maple/domain/setup-audit" import { SetupAuditService } from "@/services/org/SetupAuditService" diff --git a/apps/api/src/mcp/tools/claim-error-issue.ts b/apps/ai/src/mcp/tools/claim-error-issue.ts similarity index 93% rename from apps/api/src/mcp/tools/claim-error-issue.ts rename to apps/ai/src/mcp/tools/claim-error-issue.ts index c4f73ca0c..fd4e45140 100644 --- a/apps/api/src/mcp/tools/claim-error-issue.ts +++ b/apps/ai/src/mcp/tools/claim-error-issue.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { ErrorsService } from "@/services/errors/ErrorsService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/comment-on-error-issue.ts b/apps/ai/src/mcp/tools/comment-on-error-issue.ts similarity index 93% rename from apps/api/src/mcp/tools/comment-on-error-issue.ts rename to apps/ai/src/mcp/tools/comment-on-error-issue.ts index 606130ab2..4b8f9dfed 100644 --- a/apps/api/src/mcp/tools/comment-on-error-issue.ts +++ b/apps/ai/src/mcp/tools/comment-on-error-issue.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/compare-periods.ts b/apps/ai/src/mcp/tools/compare-periods.ts similarity index 97% rename from apps/api/src/mcp/tools/compare-periods.ts rename to apps/ai/src/mcp/tools/compare-periods.ts index f3432aabe..2e6beb25f 100644 --- a/apps/api/src/mcp/tools/compare-periods.ts +++ b/apps/ai/src/mcp/tools/compare-periods.ts @@ -1,11 +1,11 @@ import { McpQueryError, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { queryWarehouse } from "@/mcp/lib/query-warehouse" +import { queryWarehouse } from "@ai/mcp/lib/query-warehouse" import { getSpamPatternsParam } from "@/services/errors/spam-patterns" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatPercent, formatDurationFromMs, formatNumber, formatTable } from "@/mcp/lib/format" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatPercent, formatDurationFromMs, formatNumber, formatTable } from "@ai/mcp/lib/format" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { formatWarehouseDateTime } from "@maple/query-engine" export function registerComparePeriodsTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/create-alert-rule.ts b/apps/ai/src/mcp/tools/create-alert-rule.ts similarity index 98% rename from apps/api/src/mcp/tools/create-alert-rule.ts rename to apps/ai/src/mcp/tools/create-alert-rule.ts index 462803086..04788e62e 100644 --- a/apps/api/src/mcp/tools/create-alert-rule.ts +++ b/apps/ai/src/mcp/tools/create-alert-rule.ts @@ -7,9 +7,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Match, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { toMcpHttpError } from "@/mcp/lib/map-http-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { toMcpHttpError } from "@ai/mcp/lib/map-http-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertRulesService } from "@/services/alerts/AlertRulesService" import { AlertRuleUpsertRequest } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/create-dashboard.ts b/apps/ai/src/mcp/tools/create-dashboard.ts similarity index 98% rename from apps/api/src/mcp/tools/create-dashboard.ts rename to apps/ai/src/mcp/tools/create-dashboard.ts index ac8e04338..05e7685cb 100644 --- a/apps/api/src/mcp/tools/create-dashboard.ts +++ b/apps/ai/src/mcp/tools/create-dashboard.ts @@ -1,7 +1,7 @@ import { McpQueryError, optionalStringParam, requiredStringParam, type McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { DashboardTemplateParameterKey, @@ -20,7 +20,7 @@ import { collectBlockingBuilderWarnings, formatValidationSummary, inspectWidgetsAfterMutation, -} from "@/mcp/lib/inspect-widget" +} from "@ai/mcp/lib/inspect-widget" import { chartDisplayForMetric, makeQueryBuilderBreakdownDataSource, @@ -28,10 +28,10 @@ import { makeQueryDraft, } from "@/dashboard-templates/helpers" import type { TemplateParameterValues, WidgetDef } from "@/dashboard-templates" -import { validateDashboardTimeRange } from "@/mcp/lib/resolve-dashboard-time-range" +import { validateDashboardTimeRange } from "@ai/mcp/lib/resolve-dashboard-time-range" import { MAX_LIST_RANGE_SECONDS, MAX_QUERY_RANGE_SECONDS, formatRangeSeconds } from "@maple/query-engine" import { makeRouteDataSource } from "@maple/widgets/dashboard" -import { collectDocumentRenderWarnings } from "@/mcp/lib/validate-widget-renderability" +import { collectDocumentRenderWarnings } from "@ai/mcp/lib/validate-widget-renderability" const decodePortableDashboard = Schema.decodeUnknownEffect(PortableDashboardDocument) const PortableDashboardFromJson = Schema.fromJsonString(PortableDashboardDocument) diff --git a/apps/api/src/mcp/tools/delete-alert-rule.ts b/apps/ai/src/mcp/tools/delete-alert-rule.ts similarity index 95% rename from apps/api/src/mcp/tools/delete-alert-rule.ts rename to apps/ai/src/mcp/tools/delete-alert-rule.ts index cfecdd657..3396c3465 100644 --- a/apps/api/src/mcp/tools/delete-alert-rule.ts +++ b/apps/ai/src/mcp/tools/delete-alert-rule.ts @@ -1,7 +1,7 @@ import { McpQueryError, requiredBooleanParam, requiredStringParam, type McpToolRegistrar } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertRulesService } from "@/services/alerts/AlertRulesService" import { AlertRuleId } from "@maple/domain" diff --git a/apps/api/src/mcp/tools/describe-dashboard-schema.ts b/apps/ai/src/mcp/tools/describe-dashboard-schema.ts similarity index 97% rename from apps/api/src/mcp/tools/describe-dashboard-schema.ts rename to apps/ai/src/mcp/tools/describe-dashboard-schema.ts index 6fa3c08aa..8a8abd541 100644 --- a/apps/api/src/mcp/tools/describe-dashboard-schema.ts +++ b/apps/ai/src/mcp/tools/describe-dashboard-schema.ts @@ -5,7 +5,7 @@ import { isDashboardSchemaSection, renderDashboardSchemaIndex, renderDashboardSchemaSection, -} from "@/mcp/lib/dashboard-schema-doc" +} from "@ai/mcp/lib/dashboard-schema-doc" const TOOL = "describe_dashboard_schema" diff --git a/apps/api/src/mcp/tools/describe-warehouse-tables.ts b/apps/ai/src/mcp/tools/describe-warehouse-tables.ts similarity index 100% rename from apps/api/src/mcp/tools/describe-warehouse-tables.ts rename to apps/ai/src/mcp/tools/describe-warehouse-tables.ts diff --git a/apps/api/src/mcp/tools/diagnose-service.ts b/apps/ai/src/mcp/tools/diagnose-service.ts similarity index 92% rename from apps/api/src/mcp/tools/diagnose-service.ts rename to apps/ai/src/mcp/tools/diagnose-service.ts index da641f560..859a16581 100644 --- a/apps/api/src/mcp/tools/diagnose-service.ts +++ b/apps/ai/src/mcp/tools/diagnose-service.ts @@ -1,11 +1,11 @@ import { optionalStringParam, optionalTimeParam, requiredStringParam, type McpToolRegistrar } from "./types" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatDurationFromMs, formatPercent, formatNumber, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatDurationFromMs, formatPercent, formatNumber, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { diagnoseService } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/error-detail.ts b/apps/ai/src/mcp/tools/error-detail.ts similarity index 94% rename from apps/api/src/mcp/tools/error-detail.ts rename to apps/ai/src/mcp/tools/error-detail.ts index b678fec02..8217936d6 100644 --- a/apps/api/src/mcp/tools/error-detail.ts +++ b/apps/ai/src/mcp/tools/error-detail.ts @@ -7,13 +7,13 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatDurationFromMs, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatDurationFromMs, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { errorDetail } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/explore-attributes.ts b/apps/ai/src/mcp/tools/explore-attributes.ts similarity index 93% rename from apps/api/src/mcp/tools/explore-attributes.ts rename to apps/ai/src/mcp/tools/explore-attributes.ts index 81268b507..3bd0b5a9d 100644 --- a/apps/api/src/mcp/tools/explore-attributes.ts +++ b/apps/ai/src/mcp/tools/explore-attributes.ts @@ -1,13 +1,13 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { queryWarehouse } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit } from "@/mcp/lib/limits" -import { formatNumber, formatTable } from "@/mcp/lib/format" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { queryWarehouse } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatNumber, formatTable } from "@ai/mcp/lib/format" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { exploreAttributeKeys, exploreAttributeValues } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/find-errors.ts b/apps/ai/src/mcp/tools/find-errors.ts similarity index 92% rename from apps/api/src/mcp/tools/find-errors.ts rename to apps/ai/src/mcp/tools/find-errors.ts index f9de413ab..4b60b1ad9 100644 --- a/apps/api/src/mcp/tools/find-errors.ts +++ b/apps/ai/src/mcp/tools/find-errors.ts @@ -5,13 +5,13 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatNumber, formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatNumber, formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { findErrors } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/find-slow-traces.ts b/apps/ai/src/mcp/tools/find-slow-traces.ts similarity index 88% rename from apps/api/src/mcp/tools/find-slow-traces.ts rename to apps/ai/src/mcp/tools/find-slow-traces.ts index 8d7e97068..91ca8a490 100644 --- a/apps/api/src/mcp/tools/find-slow-traces.ts +++ b/apps/ai/src/mcp/tools/find-slow-traces.ts @@ -1,12 +1,12 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit } from "@/mcp/lib/limits" -import { formatDurationFromMs, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatDurationFromMs, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { findSlowTraces } from "@maple/query-engine/observability" export function registerFindSlowTracesTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/get-alert-rule.ts b/apps/ai/src/mcp/tools/get-alert-rule.ts similarity index 95% rename from apps/api/src/mcp/tools/get-alert-rule.ts rename to apps/ai/src/mcp/tools/get-alert-rule.ts index 23203754d..36449aaf3 100644 --- a/apps/api/src/mcp/tools/get-alert-rule.ts +++ b/apps/ai/src/mcp/tools/get-alert-rule.ts @@ -1,9 +1,9 @@ import { requiredStringParam, type McpToolRegistrar } from "./types" -import { toMcpHttpError } from "@/mcp/lib/map-http-error" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { toMcpHttpError } from "@ai/mcp/lib/map-http-error" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertRulesService } from "@/services/alerts/AlertRulesService" const comparatorLabel: Record = { diff --git a/apps/api/src/mcp/tools/get-dashboard.ts b/apps/ai/src/mcp/tools/get-dashboard.ts similarity index 95% rename from apps/api/src/mcp/tools/get-dashboard.ts rename to apps/ai/src/mcp/tools/get-dashboard.ts index 1d32101f7..143c94441 100644 --- a/apps/api/src/mcp/tools/get-dashboard.ts +++ b/apps/ai/src/mcp/tools/get-dashboard.ts @@ -1,7 +1,7 @@ import { McpQueryError, requiredStringParam, type McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" export function registerGetDashboardTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/get-incident-timeline.ts b/apps/ai/src/mcp/tools/get-incident-timeline.ts similarity index 96% rename from apps/api/src/mcp/tools/get-incident-timeline.ts rename to apps/ai/src/mcp/tools/get-incident-timeline.ts index 78310ecfa..6c0975b39 100644 --- a/apps/api/src/mcp/tools/get-incident-timeline.ts +++ b/apps/ai/src/mcp/tools/get-incident-timeline.ts @@ -1,8 +1,8 @@ import { McpQueryError, optionalNumberParam, optionalStringParam, type McpToolRegistrar } from "./types" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" const comparatorLabel: Record = { diff --git a/apps/api/src/mcp/tools/get-instrumentation-recommendations.ts b/apps/ai/src/mcp/tools/get-instrumentation-recommendations.ts similarity index 96% rename from apps/api/src/mcp/tools/get-instrumentation-recommendations.ts rename to apps/ai/src/mcp/tools/get-instrumentation-recommendations.ts index a60f0cb76..6ae014ca7 100644 --- a/apps/api/src/mcp/tools/get-instrumentation-recommendations.ts +++ b/apps/ai/src/mcp/tools/get-instrumentation-recommendations.ts @@ -5,13 +5,13 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { formatNumber, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatNumber, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { resolveTimeRange } from "@/mcp/lib/time" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { resolveTimeRange } from "@ai/mcp/lib/time" import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { RecommendationIssueStatus, type RecommendationIssueKind } from "@maple/domain/http" import { exploreAttributeKeys } from "@maple/query-engine/observability" diff --git a/apps/api/src/mcp/tools/get-service-top-operations.ts b/apps/ai/src/mcp/tools/get-service-top-operations.ts similarity index 90% rename from apps/api/src/mcp/tools/get-service-top-operations.ts rename to apps/ai/src/mcp/tools/get-service-top-operations.ts index 7e7312ca6..e626e6f17 100644 --- a/apps/api/src/mcp/tools/get-service-top-operations.ts +++ b/apps/ai/src/mcp/tools/get-service-top-operations.ts @@ -6,14 +6,14 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit } from "@/mcp/lib/limits" -import { formatTable } from "@/mcp/lib/format" -import { formatMetricValue } from "@/mcp/lib/format-query-result" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { createDualContent } from "@/mcp/lib/structured-output" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatTable } from "@ai/mcp/lib/format" +import { formatMetricValue } from "@ai/mcp/lib/format-query-result" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" import { Effect, Option, Schema } from "effect" import { topOperations } from "@maple/query-engine/observability" import { TracesMetric } from "@maple/query-engine" diff --git a/apps/api/src/mcp/tools/get-session-traces.ts b/apps/ai/src/mcp/tools/get-session-traces.ts similarity index 93% rename from apps/api/src/mcp/tools/get-session-traces.ts rename to apps/ai/src/mcp/tools/get-session-traces.ts index fb4568fdf..a71e17cc3 100644 --- a/apps/api/src/mcp/tools/get-session-traces.ts +++ b/apps/ai/src/mcp/tools/get-session-traces.ts @@ -1,11 +1,11 @@ import { requiredStringParam, optionalNumberParam, type McpToolRegistrar } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { clampLimit } from "@/mcp/lib/limits" -import { formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { getSessionTraces } from "@maple/query-engine/observability" export function registerGetSessionTracesTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/get-session-transcript.ts b/apps/ai/src/mcp/tools/get-session-transcript.ts similarity index 94% rename from apps/api/src/mcp/tools/get-session-transcript.ts rename to apps/ai/src/mcp/tools/get-session-transcript.ts index 4837855f7..bb0278390 100644 --- a/apps/api/src/mcp/tools/get-session-transcript.ts +++ b/apps/ai/src/mcp/tools/get-session-transcript.ts @@ -5,13 +5,13 @@ import { optionalBooleanParam, type McpToolRegistrar, } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { truncate } from "@/mcp/lib/format" -import { clampLimit, clampOffset } from "@/mcp/lib/limits" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { truncate } from "@ai/mcp/lib/format" +import { clampLimit, clampOffset } from "@ai/mcp/lib/limits" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { getSessionTranscript, type SessionTranscriptOutput } from "@maple/query-engine/observability" const KNOWN_EVENT_TYPES = ["navigation", "click", "input", "console", "network", "error"] as const diff --git a/apps/api/src/mcp/tools/inspect-chart-data.ts b/apps/ai/src/mcp/tools/inspect-chart-data.ts similarity index 97% rename from apps/api/src/mcp/tools/inspect-chart-data.ts rename to apps/ai/src/mcp/tools/inspect-chart-data.ts index 781e6b901..f45c43235 100644 --- a/apps/api/src/mcp/tools/inspect-chart-data.ts +++ b/apps/ai/src/mcp/tools/inspect-chart-data.ts @@ -8,20 +8,20 @@ import { } from "./types" import { Effect, Schema } from "effect" import { dataSourceEndpoint } from "@maple/widgets/dashboard" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { inspectWidget, type InspectWidgetTimeRange, type RawSqlInspectionData, -} from "@/mcp/lib/inspect-widget" -import { formatTable, truncate } from "@/mcp/lib/format" +} from "@ai/mcp/lib/inspect-widget" +import { formatTable, truncate } from "@ai/mcp/lib/format" import { resolveDashboardTimeRange, type DashboardTimeRangeInput, -} from "@/mcp/lib/resolve-dashboard-time-range" -import { resolveTimeRange } from "@/mcp/lib/time" +} from "@ai/mcp/lib/resolve-dashboard-time-range" +import { resolveTimeRange } from "@ai/mcp/lib/time" import type { InspectChartDataData, InspectChartQueryResult } from "@maple/domain" function formatNumber(value: number | null): string { diff --git a/apps/api/src/mcp/tools/inspect-span.ts b/apps/ai/src/mcp/tools/inspect-span.ts similarity index 91% rename from apps/api/src/mcp/tools/inspect-span.ts rename to apps/ai/src/mcp/tools/inspect-span.ts index b473dc94f..129b8a1b2 100644 --- a/apps/api/src/mcp/tools/inspect-span.ts +++ b/apps/ai/src/mcp/tools/inspect-span.ts @@ -1,10 +1,10 @@ import { requiredStringParam, optionalStringParam, type McpToolRegistrar } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor } from "@/mcp/lib/query-warehouse" -import { truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor } from "@ai/mcp/lib/query-warehouse" +import { truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { spanDetail } from "@maple/query-engine/observability" export function registerInspectSpanTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/inspect-trace.ts b/apps/ai/src/mcp/tools/inspect-trace.ts similarity index 93% rename from apps/api/src/mcp/tools/inspect-trace.ts rename to apps/ai/src/mcp/tools/inspect-trace.ts index 5ce6b56e8..7a7519250 100644 --- a/apps/api/src/mcp/tools/inspect-trace.ts +++ b/apps/ai/src/mcp/tools/inspect-trace.ts @@ -5,13 +5,13 @@ import { requiredStringParam, type McpToolRegistrar, } from "./types" -import { clampLimit } from "@/mcp/lib/limits" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor } from "@/mcp/lib/query-warehouse" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { clampLimit } from "@ai/mcp/lib/limits" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor } from "@ai/mcp/lib/query-warehouse" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { renderTraceOverview } from "@/mcp/lib/render-trace" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { renderTraceOverview } from "@ai/mcp/lib/render-trace" import { inspectTrace, type SpanNode } from "@maple/query-engine/observability" /** diff --git a/apps/api/src/mcp/tools/link-pull-request.ts b/apps/ai/src/mcp/tools/link-pull-request.ts similarity index 93% rename from apps/api/src/mcp/tools/link-pull-request.ts rename to apps/ai/src/mcp/tools/link-pull-request.ts index 0e004003c..d09a73467 100644 --- a/apps/api/src/mcp/tools/link-pull-request.ts +++ b/apps/ai/src/mcp/tools/link-pull-request.ts @@ -1,8 +1,8 @@ import { McpQueryError, requiredStringParam, validationError, type McpToolRegistrar } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { IssueFixVerificationService } from "@/services/errors/IssueFixVerificationService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/list-alert-checks.ts b/apps/ai/src/mcp/tools/list-alert-checks.ts similarity index 95% rename from apps/api/src/mcp/tools/list-alert-checks.ts rename to apps/ai/src/mcp/tools/list-alert-checks.ts index 2e35da686..32908d995 100644 --- a/apps/api/src/mcp/tools/list-alert-checks.ts +++ b/apps/ai/src/mcp/tools/list-alert-checks.ts @@ -5,11 +5,11 @@ import { requiredStringParam, type McpToolRegistrar, } from "./types" -import { formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" import { AlertRuleId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/list-alert-incidents.ts b/apps/ai/src/mcp/tools/list-alert-incidents.ts similarity index 94% rename from apps/api/src/mcp/tools/list-alert-incidents.ts rename to apps/ai/src/mcp/tools/list-alert-incidents.ts index 81702e232..1d6a08b61 100644 --- a/apps/api/src/mcp/tools/list-alert-incidents.ts +++ b/apps/ai/src/mcp/tools/list-alert-incidents.ts @@ -1,9 +1,9 @@ import { McpQueryError, optionalNumberParam, optionalStringParam, type McpToolRegistrar } from "./types" -import { formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertReadModelsService } from "@/services/alerts/AlertReadModelsService" const comparatorLabel: Record = { diff --git a/apps/api/src/mcp/tools/list-alert-rules.ts b/apps/ai/src/mcp/tools/list-alert-rules.ts similarity index 92% rename from apps/api/src/mcp/tools/list-alert-rules.ts rename to apps/ai/src/mcp/tools/list-alert-rules.ts index 788afb8fc..f9d3201ee 100644 --- a/apps/api/src/mcp/tools/list-alert-rules.ts +++ b/apps/ai/src/mcp/tools/list-alert-rules.ts @@ -1,10 +1,10 @@ import { optionalBooleanParam, optionalStringParam, type McpToolRegistrar } from "./types" -import { formatTable } from "@/mcp/lib/format" -import { toMcpHttpError } from "@/mcp/lib/map-http-error" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatTable } from "@ai/mcp/lib/format" +import { toMcpHttpError } from "@ai/mcp/lib/map-http-error" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertRulesService } from "@/services/alerts/AlertRulesService" const comparatorLabel: Record = { diff --git a/apps/api/src/mcp/tools/list-dashboards.ts b/apps/ai/src/mcp/tools/list-dashboards.ts similarity index 92% rename from apps/api/src/mcp/tools/list-dashboards.ts rename to apps/ai/src/mcp/tools/list-dashboards.ts index 595b10203..3f25171cf 100644 --- a/apps/api/src/mcp/tools/list-dashboards.ts +++ b/apps/ai/src/mcp/tools/list-dashboards.ts @@ -1,9 +1,9 @@ import { McpQueryError, optionalStringParam, type McpToolRegistrar } from "./types" -import { formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { DASHBOARD_TEMPLATES } from "@/dashboard-templates" diff --git a/apps/api/src/mcp/tools/list-error-incidents.ts b/apps/ai/src/mcp/tools/list-error-incidents.ts similarity index 94% rename from apps/api/src/mcp/tools/list-error-incidents.ts rename to apps/ai/src/mcp/tools/list-error-incidents.ts index f260b4cca..df6d824ec 100644 --- a/apps/api/src/mcp/tools/list-error-incidents.ts +++ b/apps/ai/src/mcp/tools/list-error-incidents.ts @@ -1,8 +1,8 @@ import { McpQueryError, optionalStringParam, validationError, type McpToolRegistrar } from "./types" -import { formatTable } from "@/mcp/lib/format" +import { formatTable } from "@ai/mcp/lib/format" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/list-error-issue-events.ts b/apps/ai/src/mcp/tools/list-error-issue-events.ts similarity index 93% rename from apps/api/src/mcp/tools/list-error-issue-events.ts rename to apps/ai/src/mcp/tools/list-error-issue-events.ts index 7a5dad10e..9d99ff120 100644 --- a/apps/api/src/mcp/tools/list-error-issue-events.ts +++ b/apps/ai/src/mcp/tools/list-error-issue-events.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { formatTable } from "@/mcp/lib/format" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { formatTable } from "@ai/mcp/lib/format" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/list-error-issues.ts b/apps/ai/src/mcp/tools/list-error-issues.ts similarity index 97% rename from apps/api/src/mcp/tools/list-error-issues.ts rename to apps/ai/src/mcp/tools/list-error-issues.ts index 50f283dfd..0d1161eba 100644 --- a/apps/api/src/mcp/tools/list-error-issues.ts +++ b/apps/ai/src/mcp/tools/list-error-issues.ts @@ -7,11 +7,11 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { formatNumber, formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { formatNumber, formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { ErrorIssueReadModelsService } from "@/services/errors/ErrorIssueReadModelsService" import { IssueKind, IssueSeverity, WORKFLOW_STATE_ORDER, WorkflowState } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/list-metrics.ts b/apps/ai/src/mcp/tools/list-metrics.ts similarity index 93% rename from apps/api/src/mcp/tools/list-metrics.ts rename to apps/ai/src/mcp/tools/list-metrics.ts index 10eb56252..d11081a95 100644 --- a/apps/api/src/mcp/tools/list-metrics.ts +++ b/apps/ai/src/mcp/tools/list-metrics.ts @@ -1,11 +1,11 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { queryWarehouse, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit, clampOffset } from "@/mcp/lib/limits" -import { formatNumber, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { queryWarehouse, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit, clampOffset } from "@ai/mcp/lib/limits" +import { formatNumber, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" export function registerListMetricsTool(server: McpToolRegistrar) { server.tool( diff --git a/apps/api/src/mcp/tools/list-product-events.ts b/apps/ai/src/mcp/tools/list-product-events.ts similarity index 93% rename from apps/api/src/mcp/tools/list-product-events.ts rename to apps/ai/src/mcp/tools/list-product-events.ts index 7f3605926..e7b3510fa 100644 --- a/apps/api/src/mcp/tools/list-product-events.ts +++ b/apps/ai/src/mcp/tools/list-product-events.ts @@ -1,11 +1,11 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit } from "@/mcp/lib/limits" -import { formatTable, formatNumber, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { createDualContent } from "@/mcp/lib/structured-output" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatTable, formatNumber, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { Effect, Schema } from "effect" import type { ListProductEventsData } from "@maple/domain" import { productEventNames } from "@maple/query-engine/observability" diff --git a/apps/api/src/mcp/tools/list-services.ts b/apps/ai/src/mcp/tools/list-services.ts similarity index 89% rename from apps/api/src/mcp/tools/list-services.ts rename to apps/ai/src/mcp/tools/list-services.ts index aae5b983f..b78879997 100644 --- a/apps/api/src/mcp/tools/list-services.ts +++ b/apps/ai/src/mcp/tools/list-services.ts @@ -1,10 +1,10 @@ import { optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatPercent, formatDurationFromMs, formatNumber, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { createDualContent } from "@/mcp/lib/structured-output" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatPercent, formatDurationFromMs, formatNumber, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" import { Array as Arr, Effect, Schema } from "effect" import { listServices } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/llm-tools.test.ts b/apps/ai/src/mcp/tools/llm-tools.test.ts similarity index 98% rename from apps/api/src/mcp/tools/llm-tools.test.ts rename to apps/ai/src/mcp/tools/llm-tools.test.ts index 11637eb73..23fb9219c 100644 --- a/apps/api/src/mcp/tools/llm-tools.test.ts +++ b/apps/ai/src/mcp/tools/llm-tools.test.ts @@ -9,7 +9,7 @@ import { OrgId, UserId } from "@maple/domain" import { Effect, Result, Schema } from "effect" import { assert, describe, it } from "vitest" -import type { McpToolExecutorApi } from "@/mcp/dispatcher" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { makeRecordingTracer } from "@/testing/recording-tracer" import { APPROVAL_NOTE, buildMapleToolkit } from "./llm-tools" diff --git a/apps/api/src/mcp/tools/llm-tools.ts b/apps/ai/src/mcp/tools/llm-tools.ts similarity index 96% rename from apps/api/src/mcp/tools/llm-tools.ts rename to apps/ai/src/mcp/tools/llm-tools.ts index da39ac0db..a3bbb2581 100644 --- a/apps/api/src/mcp/tools/llm-tools.ts +++ b/apps/ai/src/mcp/tools/llm-tools.ts @@ -14,10 +14,11 @@ */ import { Cause, Effect, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi, McpToolSurface } from "@/mcp/dispatcher" -import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" -import { truncateToolOutput } from "@/mcp/tools/tool-output" -import { withToolCallContent } from "@/platform/genai-spans" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" +import { mapleToolCatalog, toInputSchema } from "@ai/mcp/tools/registry" +import { truncateToolOutput } from "@ai/mcp/tools/tool-output" +import { withToolCallContent } from "@ai/platform/genai-spans" import type { TenantContext } from "@/services/auth/tenant-context" /** diff --git a/apps/api/src/mcp/tools/mine-log-patterns.ts b/apps/ai/src/mcp/tools/mine-log-patterns.ts similarity index 93% rename from apps/api/src/mcp/tools/mine-log-patterns.ts rename to apps/ai/src/mcp/tools/mine-log-patterns.ts index 7b2b65a0e..4be414b91 100644 --- a/apps/api/src/mcp/tools/mine-log-patterns.ts +++ b/apps/ai/src/mcp/tools/mine-log-patterns.ts @@ -1,11 +1,11 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_LOG_PATTERN_MAX_HOURS } from "@/mcp/lib/time" -import { truncate, formatNumber } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_LOG_PATTERN_MAX_HOURS } from "@ai/mcp/lib/time" +import { truncate, formatNumber } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { mineLogPatterns } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/mutating.test.ts b/apps/ai/src/mcp/tools/mutating.test.ts similarity index 97% rename from apps/api/src/mcp/tools/mutating.test.ts rename to apps/ai/src/mcp/tools/mutating.test.ts index 5641b06b7..6e422a852 100644 --- a/apps/api/src/mcp/tools/mutating.test.ts +++ b/apps/ai/src/mcp/tools/mutating.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { mapleToolCatalog } from "./registry" import { MUTATING_TOOL_NAMES } from "./mutating" import { evaluatePermission, isToolVisible } from "@maple/domain/permission" -import { DEFAULT_RULESET, READ_ONLY_RULESET } from "@/chat/permissions" +import { DEFAULT_RULESET, READ_ONLY_RULESET } from "@ai/chat/permissions" describe("MUTATING_TOOL_NAMES", () => { it("every approval-gated tool exists in the registry", () => { diff --git a/apps/api/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts similarity index 100% rename from apps/api/src/mcp/tools/mutating.ts rename to apps/ai/src/mcp/tools/mutating.ts diff --git a/apps/api/src/mcp/tools/propose-fix.ts b/apps/ai/src/mcp/tools/propose-fix.ts similarity index 95% rename from apps/api/src/mcp/tools/propose-fix.ts rename to apps/ai/src/mcp/tools/propose-fix.ts index a9e893a0a..57df449bd 100644 --- a/apps/api/src/mcp/tools/propose-fix.ts +++ b/apps/ai/src/mcp/tools/propose-fix.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { ErrorsService } from "@/services/errors/ErrorsService" import { ErrorIssueId } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/query-data.ts b/apps/ai/src/mcp/tools/query-data.ts similarity index 97% rename from apps/api/src/mcp/tools/query-data.ts rename to apps/ai/src/mcp/tools/query-data.ts index c23fbab10..a9b14abd3 100644 --- a/apps/api/src/mcp/tools/query-data.ts +++ b/apps/ai/src/mcp/tools/query-data.ts @@ -8,10 +8,10 @@ import { type McpToolRegistrar, type McpToolResult, } from "./types" -import { resolveTimeRange } from "@/mcp/lib/time" -import { describeInvalidQuerySpec } from "@/mcp/lib/query-spec-tokens" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { describeInvalidQuerySpec } from "@ai/mcp/lib/query-spec-tokens" import { Effect, Match, Schema } from "effect" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { MetricType, @@ -27,8 +27,8 @@ import { type MetricsTimeseriesQuery, type MetricsBreakdownQuery, } from "@maple/query-engine" -import { formatQueryResult } from "@/mcp/lib/format-query-result" -import { warehouseErrorText, warehouseReadHandlers } from "@/mcp/lib/map-warehouse-error" +import { formatQueryResult } from "@ai/mcp/lib/format-query-result" +import { warehouseErrorText, warehouseReadHandlers } from "@ai/mcp/lib/map-warehouse-error" import { CommitSha, DeploymentEnvironment, diff --git a/apps/api/src/mcp/tools/query-funnel.ts b/apps/ai/src/mcp/tools/query-funnel.ts similarity index 97% rename from apps/api/src/mcp/tools/query-funnel.ts rename to apps/ai/src/mcp/tools/query-funnel.ts index 500edbcca..966c73835 100644 --- a/apps/api/src/mcp/tools/query-funnel.ts +++ b/apps/ai/src/mcp/tools/query-funnel.ts @@ -6,13 +6,13 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit } from "@/mcp/lib/limits" -import { formatTable, formatNumber, formatPercent, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" -import { createDualContent } from "@/mcp/lib/structured-output" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_DISCOVERY_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit } from "@ai/mcp/lib/limits" +import { formatTable, formatNumber, formatPercent, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { Effect, Result, Schema } from "effect" import { FUNNEL_MAX_STEPS, diff --git a/apps/api/src/mcp/tools/register-agent.ts b/apps/ai/src/mcp/tools/register-agent.ts similarity index 95% rename from apps/api/src/mcp/tools/register-agent.ts rename to apps/ai/src/mcp/tools/register-agent.ts index 5e0006543..1734a6240 100644 --- a/apps/api/src/mcp/tools/register-agent.ts +++ b/apps/ai/src/mcp/tools/register-agent.ts @@ -6,8 +6,8 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AuditLogService } from "@/services/audit/AuditLogService" import { ErrorActorsService } from "@/services/errors/ErrorActorsService" diff --git a/apps/api/src/mcp/tools/registry.test.ts b/apps/ai/src/mcp/tools/registry.test.ts similarity index 100% rename from apps/api/src/mcp/tools/registry.test.ts rename to apps/ai/src/mcp/tools/registry.test.ts diff --git a/apps/api/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts similarity index 99% rename from apps/api/src/mcp/tools/registry.ts rename to apps/ai/src/mcp/tools/registry.ts index 6272990ed..d648bbdf8 100644 --- a/apps/api/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -1,5 +1,5 @@ // BOUNDARY: This module owns unparsed external values and narrows them before domain use. -import { InternalRpcToolNotFoundError } from "@maple/domain/internal-rpc" +import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract" import { Effect, Schema } from "effect" import { registerAddDashboardWidgetTool } from "./add-dashboard-widget" import { registerDescribeWarehouseTablesTool } from "./describe-warehouse-tables" @@ -267,7 +267,7 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu ) { const definition = mapleToolDefinitions.find((candidate) => candidate.name === name) if (!definition) { - return yield* new InternalRpcToolNotFoundError({ + return yield* new McpToolNotFoundError({ name, message: `Unknown MCP tool: ${name}`, }) diff --git a/apps/api/src/mcp/tools/release-error-issue.ts b/apps/ai/src/mcp/tools/release-error-issue.ts similarity index 92% rename from apps/api/src/mcp/tools/release-error-issue.ts rename to apps/ai/src/mcp/tools/release-error-issue.ts index 7381fef02..417f4f2d5 100644 --- a/apps/api/src/mcp/tools/release-error-issue.ts +++ b/apps/ai/src/mcp/tools/release-error-issue.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowService" import { ErrorIssueId, WorkflowState } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/remove-dashboard-widget.ts b/apps/ai/src/mcp/tools/remove-dashboard-widget.ts similarity index 93% rename from apps/api/src/mcp/tools/remove-dashboard-widget.ts rename to apps/ai/src/mcp/tools/remove-dashboard-widget.ts index e38c379a8..884023bb8 100644 --- a/apps/api/src/mcp/tools/remove-dashboard-widget.ts +++ b/apps/ai/src/mcp/tools/remove-dashboard-widget.ts @@ -1,7 +1,7 @@ import { McpQueryError, requiredStringParam, type McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { withDashboardMutation } from "@/mcp/lib/dashboard-mutations" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { withDashboardMutation } from "@ai/mcp/lib/dashboard-mutations" const TOOL = "remove_dashboard_widget" diff --git a/apps/api/src/mcp/tools/reorder-dashboard-widgets.ts b/apps/ai/src/mcp/tools/reorder-dashboard-widgets.ts similarity index 97% rename from apps/api/src/mcp/tools/reorder-dashboard-widgets.ts rename to apps/ai/src/mcp/tools/reorder-dashboard-widgets.ts index bec3e1c31..d89d0f364 100644 --- a/apps/api/src/mcp/tools/reorder-dashboard-widgets.ts +++ b/apps/ai/src/mcp/tools/reorder-dashboard-widgets.ts @@ -1,7 +1,7 @@ import { McpQueryError, requiredStringParam, type McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { withDashboardMutation } from "@/mcp/lib/dashboard-mutations" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { withDashboardMutation } from "@ai/mcp/lib/dashboard-mutations" const TOOL = "reorder_dashboard_widgets" diff --git a/apps/api/src/mcp/tools/replace-dashboard-widgets.ts b/apps/ai/src/mcp/tools/replace-dashboard-widgets.ts similarity index 95% rename from apps/api/src/mcp/tools/replace-dashboard-widgets.ts rename to apps/ai/src/mcp/tools/replace-dashboard-widgets.ts index 2bcb10b78..a9a3371c6 100644 --- a/apps/api/src/mcp/tools/replace-dashboard-widgets.ts +++ b/apps/ai/src/mcp/tools/replace-dashboard-widgets.ts @@ -1,23 +1,23 @@ import { McpQueryError, requiredStringParam, validationError, type McpToolRegistrar } from "./types" import { Effect, Result, Schema } from "effect" import { DashboardWidgetSchema } from "@maple/domain/http" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { defaultSizeForVisualization, findNextWidgetPosition, generateWidgetId, withDashboardMutation, type DashboardWidget, -} from "@/mcp/lib/dashboard-mutations" +} from "@ai/mcp/lib/dashboard-mutations" import { collectBlockingBuilderWarnings, formatValidationSummary, inspectWidgetsAfterMutation, -} from "@/mcp/lib/inspect-widget" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { validateWidgetRenderability } from "@/mcp/lib/validate-widget-renderability" -import { resolvePanelType } from "@/mcp/lib/panel-type" -import { withScalarReduction } from "@/mcp/lib/raw-sql-widget" +} from "@ai/mcp/lib/inspect-widget" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { validateWidgetRenderability } from "@ai/mcp/lib/validate-widget-renderability" +import { resolvePanelType } from "@ai/mcp/lib/panel-type" +import { withScalarReduction } from "@ai/mcp/lib/raw-sql-widget" const TOOL = "replace_dashboard_widgets" diff --git a/apps/api/src/mcp/tools/run-sql.ts b/apps/ai/src/mcp/tools/run-sql.ts similarity index 95% rename from apps/api/src/mcp/tools/run-sql.ts rename to apps/ai/src/mcp/tools/run-sql.ts index f7cc43ced..a2d0e7b79 100644 --- a/apps/api/src/mcp/tools/run-sql.ts +++ b/apps/ai/src/mcp/tools/run-sql.ts @@ -7,12 +7,12 @@ import { type McpToolResult, } from "./types" import { Effect, Schema } from "effect" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { autoBucketSeconds, runRawSql } from "@/mcp/lib/run-raw-sql" -import { createDualContent } from "@/mcp/lib/structured-output" -import { formatTable, truncate } from "@/mcp/lib/format" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { autoBucketSeconds, runRawSql } from "@ai/mcp/lib/run-raw-sql" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" import { McpQueryError } from "./types" import { describeWarehouseTable, listWarehouseTables } from "@/services/warehouse/warehouse-catalog" diff --git a/apps/api/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts similarity index 100% rename from apps/api/src/mcp/tools/runtime-requirements.ts rename to apps/ai/src/mcp/tools/runtime-requirements.ts diff --git a/apps/api/src/mcp/tools/sandbox.test.ts b/apps/ai/src/mcp/tools/sandbox.test.ts similarity index 99% rename from apps/api/src/mcp/tools/sandbox.test.ts rename to apps/ai/src/mcp/tools/sandbox.test.ts index 0d51d4337..f7bec350a 100644 --- a/apps/api/src/mcp/tools/sandbox.test.ts +++ b/apps/ai/src/mcp/tools/sandbox.test.ts @@ -2,7 +2,7 @@ import { assert, describe, it } from "@effect/vitest" import { OrgId, UserId } from "@maple/domain/http" import { SandboxOutputLimitError, SandboxImplementation } from "@effect-agent/sandbox/Sandbox" import { Effect, Layer, Schema } from "effect" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { RepoSandboxService, type RepoSandboxServiceApi, diff --git a/apps/api/src/mcp/tools/sandbox.ts b/apps/ai/src/mcp/tools/sandbox.ts similarity index 99% rename from apps/api/src/mcp/tools/sandbox.ts rename to apps/ai/src/mcp/tools/sandbox.ts index 2884034c6..3d074814c 100644 --- a/apps/api/src/mcp/tools/sandbox.ts +++ b/apps/ai/src/mcp/tools/sandbox.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { RepoSandboxService, SANDBOX_DEFAULT_TIMEOUT_SECONDS, diff --git a/apps/api/src/mcp/tools/search-logs.ts b/apps/ai/src/mcp/tools/search-logs.ts similarity index 92% rename from apps/api/src/mcp/tools/search-logs.ts rename to apps/ai/src/mcp/tools/search-logs.ts index 4f8c116c8..a03a861e5 100644 --- a/apps/api/src/mcp/tools/search-logs.ts +++ b/apps/ai/src/mcp/tools/search-logs.ts @@ -1,12 +1,12 @@ import { optionalNumberParam, optionalStringParam, optionalTimeParam, type McpToolRegistrar } from "./types" -import { toMcpQueryError } from "@/mcp/lib/map-warehouse-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit, clampOffset } from "@/mcp/lib/limits" -import { truncate, formatNumber } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { toMcpQueryError } from "@ai/mcp/lib/map-warehouse-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit, clampOffset } from "@ai/mcp/lib/limits" +import { truncate, formatNumber } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { searchLogs } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/search-sessions.ts b/apps/ai/src/mcp/tools/search-sessions.ts similarity index 95% rename from apps/api/src/mcp/tools/search-sessions.ts rename to apps/ai/src/mcp/tools/search-sessions.ts index 9de636ba2..09d8066af 100644 --- a/apps/api/src/mcp/tools/search-sessions.ts +++ b/apps/ai/src/mcp/tools/search-sessions.ts @@ -5,14 +5,14 @@ import { optionalTimeParam, type McpToolRegistrar, } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor, CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit, clampOffset } from "@/mcp/lib/limits" -import { formatTable, truncate } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor, CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit, clampOffset } from "@ai/mcp/lib/limits" +import { formatTable, truncate } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { searchSessions } from "@maple/query-engine/observability" export function registerSearchSessionsTool(server: McpToolRegistrar) { diff --git a/apps/api/src/mcp/tools/search-traces.ts b/apps/ai/src/mcp/tools/search-traces.ts similarity index 92% rename from apps/api/src/mcp/tools/search-traces.ts rename to apps/ai/src/mcp/tools/search-traces.ts index 2e0eb4274..0c1aa14b5 100644 --- a/apps/api/src/mcp/tools/search-traces.ts +++ b/apps/ai/src/mcp/tools/search-traces.ts @@ -6,16 +6,16 @@ import { validationError, type McpToolRegistrar, } from "./types" -import { warehouseToMcpHandlers } from "@/mcp/lib/map-warehouse-error" -import { withTenantExecutor } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@/mcp/lib/time" -import { clampLimit, clampOffset } from "@/mcp/lib/limits" -import { formatDurationFromMs, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { warehouseToMcpHandlers } from "@ai/mcp/lib/map-warehouse-error" +import { withTenantExecutor } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange, rangeExceededResult, MCP_SEARCH_MAX_HOURS } from "@ai/mcp/lib/time" +import { clampLimit, clampOffset } from "@ai/mcp/lib/limits" +import { formatDurationFromMs, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, Schema, pipe } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { searchTraces } from "@maple/query-engine/observability" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" export function registerSearchTracesTool(server: McpToolRegistrar) { server.tool( diff --git a/apps/api/src/mcp/tools/service-map.ts b/apps/ai/src/mcp/tools/service-map.ts similarity index 94% rename from apps/api/src/mcp/tools/service-map.ts rename to apps/ai/src/mcp/tools/service-map.ts index 9bead9544..7b8dcfea1 100644 --- a/apps/api/src/mcp/tools/service-map.ts +++ b/apps/ai/src/mcp/tools/service-map.ts @@ -1,10 +1,10 @@ import { optionalStringParam, optionalTimeParam, McpQueryError, type McpToolRegistrar } from "./types" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveTimeRange } from "@/mcp/lib/time" -import { formatNumber, formatDurationFromMs, formatPercent, formatTable } from "@/mcp/lib/format" -import { formatNextSteps } from "@/mcp/lib/next-steps" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveTimeRange } from "@ai/mcp/lib/time" +import { formatNumber, formatDurationFromMs, formatPercent, formatTable } from "@ai/mcp/lib/format" +import { formatNextSteps } from "@ai/mcp/lib/next-steps" import { Array as Arr, Effect, HashSet, Order, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" +import { createDualContent } from "@ai/mcp/lib/structured-output" import { serviceMap } from "@maple/query-engine/observability" import { provideWarehouseExecutorFromTenant } from "@/services/warehouse/WarehouseQueryService" diff --git a/apps/api/src/mcp/tools/set-issue-severity.ts b/apps/ai/src/mcp/tools/set-issue-severity.ts similarity index 94% rename from apps/api/src/mcp/tools/set-issue-severity.ts rename to apps/ai/src/mcp/tools/set-issue-severity.ts index 7f655f54b..3f7bfa1b6 100644 --- a/apps/api/src/mcp/tools/set-issue-severity.ts +++ b/apps/ai/src/mcp/tools/set-issue-severity.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActor } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActor } from "@ai/mcp/lib/resolve-actor" import { ErrorIssueWorkflowService } from "@/services/errors/ErrorIssueWorkflowService" import { ErrorIssueId, IssueSeverity } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/source-code.ts b/apps/ai/src/mcp/tools/source-code.ts similarity index 99% rename from apps/api/src/mcp/tools/source-code.ts rename to apps/ai/src/mcp/tools/source-code.ts index 6c83058ea..ab74079b1 100644 --- a/apps/api/src/mcp/tools/source-code.ts +++ b/apps/ai/src/mcp/tools/source-code.ts @@ -1,5 +1,5 @@ import { Effect, Schema } from "effect" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { VcsSourceService } from "@/services/integrations/vcs/VcsSourceService" import { optionalNumberParam, optionalStringParam, requiredStringParam, type McpToolRegistrar } from "./types" import { McpQueryError, validationError } from "./types" diff --git a/apps/api/src/mcp/tools/tool-output.test.ts b/apps/ai/src/mcp/tools/tool-output.test.ts similarity index 100% rename from apps/api/src/mcp/tools/tool-output.test.ts rename to apps/ai/src/mcp/tools/tool-output.test.ts diff --git a/apps/api/src/mcp/tools/tool-output.ts b/apps/ai/src/mcp/tools/tool-output.ts similarity index 100% rename from apps/api/src/mcp/tools/tool-output.ts rename to apps/ai/src/mcp/tools/tool-output.ts diff --git a/apps/api/src/mcp/tools/transition-error-issue.ts b/apps/ai/src/mcp/tools/transition-error-issue.ts similarity index 95% rename from apps/api/src/mcp/tools/transition-error-issue.ts rename to apps/ai/src/mcp/tools/transition-error-issue.ts index 097cc3957..31b18b7e8 100644 --- a/apps/api/src/mcp/tools/transition-error-issue.ts +++ b/apps/ai/src/mcp/tools/transition-error-issue.ts @@ -6,9 +6,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" -import { resolveActorId } from "@/mcp/lib/resolve-actor" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" +import { resolveActorId } from "@ai/mcp/lib/resolve-actor" import { ErrorsService } from "@/services/errors/ErrorsService" import { ErrorIssueId, diff --git a/apps/api/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts similarity index 100% rename from apps/api/src/mcp/tools/types.ts rename to apps/ai/src/mcp/tools/types.ts diff --git a/apps/api/src/mcp/tools/update-alert-rule.ts b/apps/ai/src/mcp/tools/update-alert-rule.ts similarity index 98% rename from apps/api/src/mcp/tools/update-alert-rule.ts rename to apps/ai/src/mcp/tools/update-alert-rule.ts index da058857a..d15f8a717 100644 --- a/apps/api/src/mcp/tools/update-alert-rule.ts +++ b/apps/ai/src/mcp/tools/update-alert-rule.ts @@ -7,9 +7,9 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { toMcpHttpError } from "@/mcp/lib/map-http-error" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { toMcpHttpError } from "@ai/mcp/lib/map-http-error" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { AlertsService } from "@/services/alerts/AlertsService" import { AlertRulesService } from "@/services/alerts/AlertRulesService" import { AlertRuleUpsertRequest, type AlertRuleDocument } from "@maple/domain/http" diff --git a/apps/api/src/mcp/tools/update-dashboard-widget.ts b/apps/ai/src/mcp/tools/update-dashboard-widget.ts similarity index 93% rename from apps/api/src/mcp/tools/update-dashboard-widget.ts rename to apps/ai/src/mcp/tools/update-dashboard-widget.ts index 3bcb44953..915396384 100644 --- a/apps/api/src/mcp/tools/update-dashboard-widget.ts +++ b/apps/ai/src/mcp/tools/update-dashboard-widget.ts @@ -1,16 +1,16 @@ import { McpQueryError, requiredStringParam, validationError, type McpToolRegistrar } from "./types" import { Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { decodeWidgetJson, withDashboardMutation } from "@/mcp/lib/dashboard-mutations" -import { formatRenderIssues, validateWidgetRenderability } from "@/mcp/lib/validate-widget-renderability" -import { resolvePanelType } from "@/mcp/lib/panel-type" -import { withScalarReduction } from "@/mcp/lib/raw-sql-widget" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { decodeWidgetJson, withDashboardMutation } from "@ai/mcp/lib/dashboard-mutations" +import { formatRenderIssues, validateWidgetRenderability } from "@ai/mcp/lib/validate-widget-renderability" +import { resolvePanelType } from "@ai/mcp/lib/panel-type" +import { withScalarReduction } from "@ai/mcp/lib/raw-sql-widget" import { collectBlockingBuilderWarnings, formatValidationSummary, inspectWidgetsAfterMutation, -} from "@/mcp/lib/inspect-widget" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +} from "@ai/mcp/lib/inspect-widget" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" const TOOL = "update_dashboard_widget" diff --git a/apps/api/src/mcp/tools/update-dashboard.ts b/apps/ai/src/mcp/tools/update-dashboard.ts similarity index 95% rename from apps/api/src/mcp/tools/update-dashboard.ts rename to apps/ai/src/mcp/tools/update-dashboard.ts index ba8db33e1..40765f619 100644 --- a/apps/api/src/mcp/tools/update-dashboard.ts +++ b/apps/ai/src/mcp/tools/update-dashboard.ts @@ -1,13 +1,13 @@ import { McpQueryError, optionalStringParam, requiredStringParam, type McpToolRegistrar } from "./types" import { Clock, Effect, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" import { DashboardDocument, DashboardId, PortableDashboardDocument } from "@maple/domain/http" import { IsoDateTimeString } from "@maple/domain" -import { validateDashboardTimeRange } from "@/mcp/lib/resolve-dashboard-time-range" +import { validateDashboardTimeRange } from "@ai/mcp/lib/resolve-dashboard-time-range" import { MAX_QUERY_RANGE_SECONDS, formatRangeSeconds } from "@maple/query-engine" -import { collectDocumentRenderWarnings } from "@/mcp/lib/validate-widget-renderability" +import { collectDocumentRenderWarnings } from "@ai/mcp/lib/validate-widget-renderability" const PortableDashboardFromJson = Schema.fromJsonString(PortableDashboardDocument) const decodeIsoDateTimeString = Schema.decodeUnknownSync(IsoDateTimeString) diff --git a/apps/api/src/mcp/tools/update-error-notification-policy.ts b/apps/ai/src/mcp/tools/update-error-notification-policy.ts similarity index 97% rename from apps/api/src/mcp/tools/update-error-notification-policy.ts rename to apps/ai/src/mcp/tools/update-error-notification-policy.ts index b3e45a897..754bdbdd5 100644 --- a/apps/api/src/mcp/tools/update-error-notification-policy.ts +++ b/apps/ai/src/mcp/tools/update-error-notification-policy.ts @@ -7,8 +7,8 @@ import { type McpToolRegistrar, } from "./types" import { Effect, Option, Schema } from "effect" -import { createDualContent } from "@/mcp/lib/structured-output" -import { CurrentMcpTenant } from "@/mcp/lib/query-warehouse" +import { createDualContent } from "@ai/mcp/lib/structured-output" +import { CurrentMcpTenant } from "@ai/mcp/lib/query-warehouse" import { ErrorPolicyService } from "@/services/errors/ErrorPolicyService" import { AlertDestinationId, AlertSeverity, ErrorNotificationPolicyUpsertRequest } from "@maple/domain/http" diff --git a/apps/api/src/mcp/transport/stateless-http.ts b/apps/ai/src/mcp/transport/stateless-http.ts similarity index 100% rename from apps/api/src/mcp/transport/stateless-http.ts rename to apps/ai/src/mcp/transport/stateless-http.ts diff --git a/apps/api/src/platform/Llm.test.ts b/apps/ai/src/platform/Llm.test.ts similarity index 100% rename from apps/api/src/platform/Llm.test.ts rename to apps/ai/src/platform/Llm.test.ts diff --git a/apps/api/src/platform/Llm.ts b/apps/ai/src/platform/Llm.ts similarity index 100% rename from apps/api/src/platform/Llm.ts rename to apps/ai/src/platform/Llm.ts diff --git a/apps/api/src/platform/WorkersAiHttpClient.test.ts b/apps/ai/src/platform/WorkersAiHttpClient.test.ts similarity index 100% rename from apps/api/src/platform/WorkersAiHttpClient.test.ts rename to apps/ai/src/platform/WorkersAiHttpClient.test.ts diff --git a/apps/api/src/platform/WorkersAiHttpClient.ts b/apps/ai/src/platform/WorkersAiHttpClient.ts similarity index 100% rename from apps/api/src/platform/WorkersAiHttpClient.ts rename to apps/ai/src/platform/WorkersAiHttpClient.ts diff --git a/apps/api/src/platform/genai-spans.test.ts b/apps/ai/src/platform/genai-spans.test.ts similarity index 100% rename from apps/api/src/platform/genai-spans.test.ts rename to apps/ai/src/platform/genai-spans.test.ts diff --git a/apps/api/src/platform/genai-spans.ts b/apps/ai/src/platform/genai-spans.ts similarity index 100% rename from apps/api/src/platform/genai-spans.ts rename to apps/ai/src/platform/genai-spans.ts diff --git a/apps/api/src/platform/model-call-span.test.ts b/apps/ai/src/platform/model-call-span.test.ts similarity index 98% rename from apps/api/src/platform/model-call-span.test.ts rename to apps/ai/src/platform/model-call-span.test.ts index 4739fb389..2b10b556d 100644 --- a/apps/api/src/platform/model-call-span.test.ts +++ b/apps/ai/src/platform/model-call-span.test.ts @@ -100,7 +100,9 @@ const endedModelCall = ( Effect.ignore, Effect.provide(Layer.provideMerge(resolveTriageModel(env).layer, layerLlm(env))), Effect.provideService(FetchHttpClient.Fetch, () => - Promise.resolve(new Response(body, { status, headers: { "content-type": "text/event-stream" } })), + Promise.resolve( + new Response(body, { status, headers: { "content-type": "text/event-stream" } }), + ), ), Effect.withTracer(recorder.tracer), ) @@ -235,7 +237,9 @@ describe("the model-call span", () => { assert.strictEqual(attributes?.get("gen_ai.usage.cache_read.input_tokens"), 30) assert.strictEqual(attributes?.get("gen_ai.usage.reasoning.output_tokens"), 5) assert.strictEqual(attributes?.get("gen_ai.usage.cost"), 0.0042) - const firstChunkMs = Math.round(Number(attributes?.get("gen_ai.response.time_to_first_chunk")) * 1000) + const firstChunkMs = Math.round( + Number(attributes?.get("gen_ai.response.time_to_first_chunk")) * 1000, + ) assert.isAtLeast(firstChunkMs, 0) assert.isAtMost(firstChunkMs, Number(attributes?.get("maple_ai.model_duration_ms"))) }), diff --git a/apps/ai/src/routes/health.ts b/apps/ai/src/routes/health.ts new file mode 100644 index 000000000..60abdf458 --- /dev/null +++ b/apps/ai/src/routes/health.ts @@ -0,0 +1,24 @@ +/** + * Liveness, answered without touching the layer graph, the database, or a + * binding — a check that builds the graph reports the graph's health, which is + * the thing most likely to be broken when you ask. + * + * A raw router rather than an `HttpApi` endpoint so it stays outside the typed + * surface entirely, matching the api's own `/health`. + */ +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" + +export const HealthRouter = HttpRouter.use((router) => + router.add("GET", "/health", () => + Effect.gen(function* () { + // The revision this isolate runs, so a deploy can assert the script now + // serving is the one it just uploaded. + const revision = (yield* Cloudflare.WorkerEnvironment).COMMIT_SHA + return HttpServerResponse.text("OK", { + headers: typeof revision === "string" ? { "x-maple-revision": revision } : undefined, + }) + }), + ), +) diff --git a/apps/api/src/routes/internal/chat.http.test.ts b/apps/ai/src/routes/internal/chat.http.test.ts similarity index 94% rename from apps/api/src/routes/internal/chat.http.test.ts rename to apps/ai/src/routes/internal/chat.http.test.ts index 91ccfe1e2..bf2a038bc 100644 --- a/apps/api/src/routes/internal/chat.http.test.ts +++ b/apps/ai/src/routes/internal/chat.http.test.ts @@ -5,12 +5,12 @@ import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { Context, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" -import { McpToolExecutor, type McpToolExecutorApi } from "@/mcp/dispatcher" +import { McpToolExecutor, type McpToolExecutorApi } from "@ai/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { HttpChatLive } from "./chat.http" -import { V1ErrorBoundaryLive } from "../v1/error-boundary" +import { V1ErrorBoundaryLive } from "@/routes/v1/error-boundary" -class ChatOnlyApi extends HttpApi.make("MapleInternalApi") +class ChatOnlyApi extends HttpApi.make("MapleAiApi") .add(ChatApiGroup) .middleware(V1SchemaErrors) .middleware(V1UnexpectedErrors) {} diff --git a/apps/api/src/routes/internal/chat.http.ts b/apps/ai/src/routes/internal/chat.http.ts similarity index 93% rename from apps/api/src/routes/internal/chat.http.ts rename to apps/ai/src/routes/internal/chat.http.ts index a45540bba..fa610a8c1 100644 --- a/apps/api/src/routes/internal/chat.http.ts +++ b/apps/ai/src/routes/internal/chat.http.ts @@ -6,15 +6,15 @@ import { ChatToolNotApplicableError, ChatToolNotFoundError, CurrentTenant, - MapleInternalApi, + MapleAiApi, } from "@maple/domain/http" import { Cause, Effect, Schema } from "effect" import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { orgIdFromChatSessionId } from "@maple/domain/chat-session" -import { chatSessionStub } from "@/chat/session" -import { mapleToolCatalog } from "@/mcp/tools/registry" -import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" -import { McpToolExecutor } from "@/mcp/dispatcher" +import { chatSessionStub } from "@maple/domain/chat-session-stub" +import { mapleToolCatalog } from "@ai/mcp/tools/registry" +import { MUTATING_TOOL_NAMES } from "@ai/mcp/tools/mutating" +import { McpToolExecutor } from "@ai/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { summarizeCause } from "@/platform/describe-cause" @@ -85,7 +85,7 @@ const recordApplyOutcome = ( ) }) -export const HttpChatLive = HttpApiBuilder.group(MapleInternalApi, "chat", (handlers) => +export const HttpChatLive = HttpApiBuilder.group(MapleAiApi, "chat", (handlers) => handlers.handle("apply", ({ payload }) => Effect.gen(function* () { const tool = payload.tool @@ -125,7 +125,7 @@ export const HttpChatLive = HttpApiBuilder.group(MapleInternalApi, "chat", (hand // A defect remains a transport failure, but it is declared and serialized instead // of falling through HttpApi as a bodyless 500. const result = yield* executor.execute(tenant, tool, payload.input, "chat").pipe( - Effect.catchTag("@maple/internal-rpc/ToolNotFoundError", () => + Effect.catchTag("@maple/mcp/ToolNotFoundError", () => Effect.fail(new ChatToolNotFoundError({ tool, message: `Unknown tool "${tool}".` })), ), Effect.catchDefect((defect) => executionDefect(tool, defect)), diff --git a/apps/api/src/routes/v1/chat-sessions.http.test.ts b/apps/ai/src/routes/v1/chat-sessions.http.test.ts similarity index 98% rename from apps/api/src/routes/v1/chat-sessions.http.test.ts rename to apps/ai/src/routes/v1/chat-sessions.http.test.ts index 10570f971..57754befe 100644 --- a/apps/api/src/routes/v1/chat-sessions.http.test.ts +++ b/apps/ai/src/routes/v1/chat-sessions.http.test.ts @@ -3,7 +3,7 @@ import { OrgId, UserId } from "@maple/domain/http" import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { ConfigProvider, Effect, Layer, Schema } from "effect" import { HttpRouter } from "effect/unstable/http" -import type { ChatSessionStub } from "@/chat/session" +import type { ChatSessionStub } from "@maple/domain/chat-session-stub" import { Env } from "@/platform/Env" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { AuthService } from "@/services/auth/AuthService" diff --git a/apps/api/src/routes/v1/chat-sessions.http.ts b/apps/ai/src/routes/v1/chat-sessions.http.ts similarity index 98% rename from apps/api/src/routes/v1/chat-sessions.http.ts rename to apps/ai/src/routes/v1/chat-sessions.http.ts index 84824ccad..96194021b 100644 --- a/apps/api/src/routes/v1/chat-sessions.http.ts +++ b/apps/ai/src/routes/v1/chat-sessions.http.ts @@ -30,15 +30,15 @@ import { orgIdFromChatSessionId, type ChatTurnTenantEncoded, } from "@maple/domain/chat-session" +import { chatSessionStub, type ChatSessionStub } from "@maple/domain/chat-session-stub" import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { Effect, Layer, Option, Schema, Stream } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { chatSessionStub, type ChatSessionStub } from "@/chat/session" import { AuthService } from "@/services/auth/AuthService" import type { TenantContext } from "@/services/auth/tenant-context" import { ApiKeysService } from "@/services/org/ApiKeysService" import { Env } from "@/platform/Env" -import { resolveHttpMcpTenant } from "@/mcp/lib/query-warehouse" +import { resolveHttpMcpTenant } from "@ai/mcp/lib/query-warehouse" const json = (body: unknown, status = 200) => HttpServerResponse.text(JSON.stringify(body), { diff --git a/apps/ai/src/runtime/graph-boundaries.test.ts b/apps/ai/src/runtime/graph-boundaries.test.ts new file mode 100644 index 000000000..1194005b6 --- /dev/null +++ b/apps/ai/src/runtime/graph-boundaries.test.ts @@ -0,0 +1,96 @@ +import { readFileSync } from "node:fs" +import { describe, expect, it } from "vitest" + +const readModule = (path: string): string => readFileSync(new URL(path, import.meta.url), "utf8") + +const importSpecifiers = (source: string): ReadonlyArray => + Array.from(source.matchAll(/(?:from\s+|import\s*\()["']([^"']+)["']/g), (match) => match[1]!) + +const layerMembers = (source: string, name: string): ReadonlyArray => { + const block = new RegExp(`const ${name} = Layer\\.mergeAll\\(([\\s\\S]*?)\\n\\)`).exec(source)?.[1] + if (block === undefined) throw new Error(`Layer ${name} was not found`) + return block + .split("\n") + .map((line) => line.trim().replace(/,$/, "")) + .filter((line) => line !== "") +} + +describe("AI runtime graph boundaries", () => { + it("keeps runtime entrypoints off the compatibility facade", () => { + const runtimeEntrypoints: ReadonlyArray< + readonly [ + source: string, + expectedImports: ReadonlyArray, + expectedRoots: ReadonlyArray, + ] + > = [ + [ + readModule("../chat/turn-runner.ts"), + ["../runtime/mcp-service-graph"], + ["InvestigationServicesLive"], + ], + [ + readModule("../mcp/__evals__/eval-runtime.ts"), + ["@ai/runtime/mcp-service-graph"], + ["McpServicesLive"], + ], + [ + readModule("../workflows/InvestigationFanoutWorkflow.run.ts"), + ["../runtime/mcp-service-graph"], + ["McpServicesLive"], + ], + ] + + for (const [source, expectedImports, expectedRoots] of runtimeEntrypoints) { + for (const expectedImport of expectedImports) + expect(importSpecifiers(source)).toContain(expectedImport) + expect(importSpecifiers(source).some((specifier) => /(?:^|\/)app$/.test(specifier))).toBe(false) + for (const root of expectedRoots) expect(source).toContain(root) + expect(source).not.toMatch(/\{\s*MainLive\s*\}/) + } + }) + + it("keeps the headless MCP root limited to registered tool requirements", () => { + const source = readModule("./mcp-service-graph.ts") + const imports = importSpecifiers(source) + + expect(layerMembers(source, "McpRuntimeServicesLive")).toEqual([ + "AlertReadModelsServiceLive", + "AlertRulesServiceLive", + "AlertsServiceLive", + // Lets `register_agent` (and issue-workflow mutations) write org audit entries. + "AuditLogServiceLive", + "DashboardPersistenceService.layer", + "ErrorActorsServiceLive", + "ErrorIssueReadModelsServiceLive", + "ErrorIssueWorkflowServiceLive", + "ErrorPolicyServiceLive", + "ErrorsServiceLive", + // Backs `link_pull_request`, and is what lets `propose_fix` turn its + // `pr_url` into a durable link rather than an event-payload string. + "IssueFixVerificationServiceLive", + "QueryEngineServiceLive", + "RecommendationIssueServiceLive", + // The agents' repository sandbox tools. + "RepoSandboxServiceLive", + "SetupAuditServiceLive", + "VcsSourceServiceLive", + "WarehouseQueryServiceLive", + ]) + expect(source).toContain( + "export const InvestigationServicesLive = Layer.mergeAll(McpServicesLive, InvestigationServiceLive)", + ) + expect(imports).not.toContain("@/runtime/service-graph") + for (const routeOnlyService of [ + "DailySpendService", + "CloudflareAnalyticsService", + "AnomalyDetectionService", + "AiTriageService", + "DigestService", + "DemoService", + "SlackIntegrationService", + ]) { + expect(imports.some((specifier) => specifier.endsWith(`/${routeOnlyService}`))).toBe(false) + } + }) +}) diff --git a/apps/ai/src/runtime/http-graph.ts b/apps/ai/src/runtime/http-graph.ts new file mode 100644 index 000000000..b044639bc --- /dev/null +++ b/apps/ai/src/runtime/http-graph.ts @@ -0,0 +1,97 @@ +/** + * Every route the AI Worker serves, as one layer. + * + * Three 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 dashboard's chat transport, raw because + * `HttpApi` cannot model an open `text/event-stream`. + * - `/internal/chat/apply` — a typed `HttpApi` group, because re-running an + * approval-gated mutation is an ordinary request/response with a schema + * worth pinning. + * + * The api still owns the hostname. It forwards all three 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. + */ +import { MapleAiApi } from "@maple/domain/http" +import { Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { McpLive } from "@ai/mcp/app" +import { HttpChatLive } from "@ai/routes/internal/chat.http" +import { ChatSessionsRouter } from "@ai/routes/v1/chat-sessions.http" +import { HealthRouter } from "@ai/routes/health" +import { API_CORS_OPTIONS } from "@/http/api-cors" +import { Env } from "@/platform/Env" +import { ApiKeysService } from "@/services/org/ApiKeysService" +import { AuthService } from "@/services/auth/AuthService" +import { AuditLogLive } from "@/runtime/warehouse-layer" +import { McpToolRateLimiter } from "@/services/auth/McpToolRateLimiter" +import { SessionAuthorizationLayer } from "@/services/auth/SessionAuthorizationLayer" +import { V1ErrorBoundaryLive } from "@/routes/v1/error-boundary" +import type { AiPortsLayer } from "@ai/worker/bindings" + +/** + * 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 = + Layer.Services extends infer Marker + ? Marker extends HttpRouter.Request<"Requires", infer Service> + ? Exclude> + : 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: Routes & + ([LeakedRequestServices] extends [never] + ? unknown + : { readonly leakedRequestServices: LeakedRequestServices }), +) => routes + +const RawRoutes = rawRoutes(Layer.mergeAll(HealthRouter, ChatSessionsRouter, McpLive)) + +const AiInternalRoutes = HttpApiBuilder.layer(MapleAiApi).pipe( + Layer.provide(HttpChatLive), + Layer.provide(V1ErrorBoundaryLive), +) + +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(SessionAuthorizationLayer).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), + // Denied attempts and audited reads are recorded from inside the auth layers. + Layer.provideMerge(AuditLogLive.pipe(Layer.provide(Env.layer))), + Layer.provideMerge(Env.layer), +) diff --git a/apps/api/src/runtime/mcp-service-graph.ts b/apps/ai/src/runtime/mcp-service-graph.ts similarity index 97% rename from apps/api/src/runtime/mcp-service-graph.ts rename to apps/ai/src/runtime/mcp-service-graph.ts index 8e906cf47..9a15ee850 100644 --- a/apps/api/src/runtime/mcp-service-graph.ts +++ b/apps/ai/src/runtime/mcp-service-graph.ts @@ -1,9 +1,9 @@ import { BucketCacheService } from "@maple/query-engine/caching" import { Layer } from "effect" -import { McpToolExecutor } from "@/mcp/dispatcher" +import { McpToolExecutor } from "@ai/mcp/dispatcher" import { EdgeCacheServiceLive } from "@/platform/CacheBackendLive" -import { AuditLogLive, OrgClickHouseSettingsLive, WarehouseLive } from "./warehouse-layer" -import { VcsSourceServiceLayer } from "./vcs-source-layer" +import { AuditLogLive, OrgClickHouseSettingsLive, WarehouseLive } from "@/runtime/warehouse-layer" +import { VcsSourceServiceLayer } from "@/runtime/vcs-source-layer" import { SandboxClient } from "@/sandbox/client" import { CloudflareRepoSandboxLive } from "@/services/sandbox/CloudflareRepoSandbox" import { RepoSandboxService } from "@/services/sandbox/RepoSandboxService" diff --git a/apps/ai/src/worker.ts b/apps/ai/src/worker.ts new file mode 100644 index 000000000..c74d3358d --- /dev/null +++ b/apps/ai/src/worker.ts @@ -0,0 +1,176 @@ +/** + * The AI Worker in alchemy's single-module form: this file is both the resource + * the root stack yields (`yield* MapleAi`) and the bundle alchemy deploys + * (`main: import.meta.url`). + * + * Everything Maple's agents do runs here rather than in `apps/api`: the public + * MCP server and its tools, the chat agent and its Durable Object, and the + * autonomous investigation fan-out. They moved together because they are one + * thing wearing three hats — all three reach the same tool registry in-process, + * so splitting any one of them out alone leaves the registry behind, which is + * exactly what made the first attempt at this worth 1%. + * + * Measured on the api's module graph before the move (rolldown, unminified): + * dropping the MCP registry, the chat routes and the two hosted classes takes it + * from 11.74 MB over 85 chunks to 9.34 MB over 50, and module evaluation from + * ~336 ms to ~278 ms. The other half is per-request: a `/mcp` call no longer + * builds `AllRoutes` and `ApiAuthLive`, and a `/v2` call no longer builds 47 + * tool schemas. + * + * `api.maple.dev/mcp` is still the public address. The api forwards `/mcp` and + * the chat paths here over a service binding, which keeps the OAuth issuer and + * the RFC 8707 resource identifiers on api's origin — moving them would + * invalidate every registered MCP client. + * + * Startup-CPU note (Cloudflare error 10021): the 47 tool schemas at module scope + * are what blew the upload-validation budget once already, so every heavy graph + * stays behind a dynamic import, exactly as `apps/api/src/worker/modules.ts` + * documents for the api. + */ +import { + cachedRecoverable, + CLOUDFLARE_WORKER_PLACEMENT, + MapleStack, + type MapleStage, + resolveWorkerName, +} from "@maple/infra/cloudflare" +import { + appUrlsEnv, + authEnv, + ingestKeyCryptoEnv, + merge, + optionalPlain, + optionalSecret, + selfObservabilityEnv, + tinybirdEnv, +} from "@maple/infra/env" +import { WorkerTelemetry } from "@maple/infra/worker-telemetry" +import * as Cloudflare from "alchemy/Cloudflare" +import { Context, Effect, Layer } from "effect" +import { ChatSessionLive, ChatSessionObject } from "@ai/chat/ChatSession" +import InvestigationFanoutWorkflow from "@ai/workflows/InvestigationFanoutWorkflow" +import { aiPorts, AiBindingLayers, bindAiClients } from "@ai/worker/bindings" +import { buildApp, makeFetch } from "@ai/worker/http" + +/** + * The AI worker's resource bindings, split from the `Config`-sourced env so + * `InferEnv` can derive `AiWorkerEnv` below. + * + * Empty until the surfaces land: the MCP tool rate limiter arrives with the + * transport, the AI gateway and the sandbox binding with the tools that use + * them, and the two hosted classes are yielded in the init rather than declared + * here. + */ +const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ + // Workers AI, for the models the agents call. The GATEWAY NAME is api's, + // unchanged: renaming it mints a new gateway and abandons its logs and + // analytics. Only the alchemy logical id moved. + ...(stage.kind === "dev" ? undefined : { AI: Cloudflare.AI.Gateway("maple-api-ai") }), +}) + +/** + * The AI worker's runtime env, derived from the declaration above. + * + * `Partial` for the same reason alerting's is: a binding's absence is a real + * runtime state. Configuration vars stay `unknown` on purpose — config is read + * through the Effect ConfigProvider, never off `env` directly. + */ +export type AiWorkerEnv = Partial>> & + Record + +/** + * Everything in the AI worker's env that comes from configuration rather than + * from a resource. The agents query the warehouse as the calling org and resolve + * their own tenants, so this is largely the api's set; the LLM provider keys + * arrive with `platform/Llm.ts`. + */ +const configuredEnv = (stage: MapleStage) => + merge( + // The tools query the warehouse as the calling org, and resolve their own + // tenants, so this is largely the api's set. + tinybirdEnv, + authEnv, + appUrlsEnv, + selfObservabilityEnv(stage), + ingestKeyCryptoEnv, + // Agent LLM path. `MAPLE_LLM_PROVIDER` flips between OpenRouter (default) and + // Workers AI; both stay wired, so a switch is this one var plus a redeploy. + // See `@ai/platform/Llm` for the provider-scoped model overrides. + optionalPlain("MAPLE_LLM_PROVIDER"), + optionalPlain("MAPLE_TRIAGE_MODEL_OPENROUTER"), + optionalPlain("MAPLE_TRIAGE_MODEL_WORKERS_AI"), + optionalSecret("OPENROUTER_API_KEY"), + // The chat agent authenticates to `/mcp` as an internal caller. + optionalSecret("INTERNAL_SERVICE_TOKEN"), + // Dev-only escape hatch from per-org BYO rows (see apps/api/src/resources/env.ts). + optionalPlain("MAPLE_IGNORE_ORG_CLICKHOUSE"), + ) + +/** + * Alchemy evaluates a Worker's props wherever the class is yielded — the + * deployed bundle included, where they are inert. `__ALCHEMY_RUNTIME__` folds to + * `true` there, so the stack-side branch below, and the `@maple/infra` modules + * only it reaches, are dead-code-eliminated from what ships. + */ +const props = Effect.gen(function* () { + if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } + const { stage, workerDev, devEnv } = yield* MapleStack + const env = yield* configuredEnv(stage) + return { + main: import.meta.url, + name: resolveWorkerName("ai", stage), + compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, + placement: CLOUDFLARE_WORKER_PLACEMENT, + // Under `bun dev`: a sticky port the app's route follows. + dev: workerDev("ai"), + // No public hostname. Reached only over the api's service binding, which is + // what keeps `/mcp` on api's origin and its OAuth identifiers unchanged. + workersDev: false, + // Same override, same reason, as the api's: without it the drizzle and + // Effect-Schema graph evaluates lazily inside the first Postgres call, which + // is what turned into CONNECT_TIMEOUTs on 2026-08-08. This worker carries + // that same graph. + build: { output: { strictExecutionOrder: false } }, + // `devEnv` last, so `.env.local` cannot override the inter-app URLs. + env: { ...makeWorkerBindings({ stage }), ...env, ...devEnv }, + } +}) + +export class MapleAi extends Cloudflare.Worker()("ai") {} + +export default MapleAi.make( + props, + Effect.gen(function* () { + // The classes this Worker hosts. Yielded here, which is what binds them, + // registers them at plan time and exports them from the generated entry — + // never a ref-form binding plus a hand-written class. + yield* ChatSessionObject + yield* InvestigationFanoutWorkflow + const clients = yield* bindAiClients + const env = yield* Cloudflare.WorkerEnvironment + const ports = aiPorts(clients, env) + // Captured before any event exists, so a graph built inside the first + // request cannot leak that request's context into every later one. See + // `forIsolate`. + const isolate = Context.omit( + Cloudflare.WorkerExecutionContext, + Layer.CurrentMemoMap, + )(yield* Effect.context()) + const app = yield* cachedRecoverable(buildApp(isolate, ports)) + return { fetch: makeFetch(app, ports) } + }).pipe( + // The Worker's init IS the entry point: the bridge builds telemetry into + // each event's scope and flushes it after. + // oxlint-disable-next-line effecttsgo/strict-effect-provide + Effect.provide( + Layer.mergeAll( + AiBindingLayers, + // The host Worker's layer also provides the Durable Object's + // 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" }), + ), + ), + ), +) diff --git a/apps/ai/src/worker/bindings.ts b/apps/ai/src/worker/bindings.ts new file mode 100644 index 000000000..3778fa9d0 --- /dev/null +++ b/apps/ai/src/worker/bindings.ts @@ -0,0 +1,80 @@ +/** + * The AI Worker's bindings, on alchemy's capabilities — the same shape as + * apps/api's: the init yields one typed client per resource it reaches at + * runtime, which attaches the native binding at plan time and reads it off the + * env in the isolate, and the clients become the Maple-owned ports the service + * graph depends on. + * + * Far fewer than api's, because the agents write through api's services rather + * than reaching resources directly. What is here is what the MCP transport and + * the tool registry touch on their own. + */ +import { MapleDb } from "@maple/infra/cloudflare" +import { workerEnvLayer } from "@maple/infra/worker-runtime" +import * as Cloudflare from "alchemy/Cloudflare" +import { RuntimeContext } from "alchemy/RuntimeContext" +import { Effect, Layer } from "effect" +import { McpToolsRateLimit, RateLimitBindingError, type RateLimiter } from "@/platform/bindings" +import { mapleDbConnectionLayer } from "@/platform/pg-connection-source" +import { + MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS, + MCP_TOOLS_RATE_LIMIT_REQUESTS, +} from "@/services/auth/McpToolRateLimiter" + +export const bindAiClients = Effect.gen(function* () { + // `MAPLE_DB` in the stage's flavor. The agents read and write the same + // application database api does — investigations, error issues, dashboards — + // so this is a connection budget of its own, not a share of api's. + yield* MapleDb("ai") + return { + // Authenticated POST /mcp, per credential. A short window so a runaway + // agent loop is cut off in seconds, at twice the v2 API's throughput. + // + // The `namespaceId` is carried over from apps/api unchanged: it is the + // Cloudflare-side identity of the bucket, so a new one would silently reset + // every client's budget at the cutover. + mcpToolsRateLimit: yield* Cloudflare.RateLimit("MCP_TOOLS_RATE_LIMITER", { + namespaceId: 2026082901, + simple: { limit: MCP_TOOLS_RATE_LIMIT_REQUESTS, period: MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS }, + }), + } +}) + +type AiBindingClients = Effect.Success + +/** The binding layers the init needs. */ +export const AiBindingLayers = Layer.mergeAll( + Cloudflare.Hyperdrive.ConnectBinding, + Cloudflare.Workers.RateLimitBinding, +) + +/** Discharge alchemy's phantom color, the way alchemy's own runtime helpers do. */ +const runtime = (effect: Effect.Effect): Effect.Effect => + effect as Effect.Effect + +const limiter = (client: Cloudflare.Workers.RateLimitClient): RateLimiter => ({ + limit: (key) => + runtime(client.limit({ key })).pipe( + Effect.mapError( + (error) => + new RateLimitBindingError({ + message: "Cloudflare rate-limit binding call failed", + cause: error.cause, + }), + ), + ), +}) + +/** + * The ports the service graph depends on, plus the env itself as + * `WorkerEnvironment` and the `ConfigProvider` — the one place a graph in this + * Worker gets its env from. + */ +export const aiPorts = (clients: AiBindingClients, env: Record) => + Layer.mergeAll( + Layer.succeed(McpToolsRateLimit, limiter(clients.mcpToolsRateLimit)), + mapleDbConnectionLayer(env), + workerEnvLayer(env), + ) + +export type AiPortsLayer = ReturnType diff --git a/apps/ai/src/worker/http.ts b/apps/ai/src/worker/http.ts new file mode 100644 index 000000000..3ea3bbb2d --- /dev/null +++ b/apps/ai/src/worker/http.ts @@ -0,0 +1,158 @@ +/** + * The AI Worker's request path: the route graph built once per isolate on the + * first request, and the `fetch` handler the bridge serves around it. + * + * Copied from apps/api rather than imported. The two diverge in what they carry + * around a request — api's has the v2 fallback, the isolate-age instrumentation + * and the CORS preflight it answers for the whole origin — and a shared version + * would have to grow a flag for each. What must not diverge is the isolate + * context handling below, so that comment is carried over verbatim. + */ +import type { HttpEffect } from "alchemy/Http" +import * as Cloudflare from "alchemy/Cloudflare" +import { type Context, Effect, Exit, FileSystem, Layer, Path, Scope } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Etag from "effect/unstable/http/Etag" +import * as HttpPlatform from "effect/unstable/http/HttpPlatform" +import { withPgConnectionScope } from "@/platform/pg-connection-scope" +import { layerPg } from "@/platform/DatabasePgLive" +import type { AiPortsLayer } from "@ai/worker/bindings" + +const WorkerFileSystemLive = FileSystem.layerNoop({}) + +const WorkerHttpPlatformLive = Layer.effect( + HttpPlatform.HttpPlatform, + HttpPlatform.make({ + platform: "web", + compression: HttpPlatform.makeCompressionWeb({ + algorithms: ["gzip", "deflate"], + transform: (algorithm) => HttpPlatform.compressionTransformWeb(algorithm), + }), + fileResponse: (_path, status, statusText, headers) => + HttpServerResponse.text("File responses are unavailable in the worker runtime", { + status, + statusText, + headers, + }), + fileWebResponse: (_file, status, statusText, headers) => + HttpServerResponse.text("File responses are unavailable in the worker runtime", { + status, + statusText, + headers, + }), + }), +).pipe(Layer.provideMerge(WorkerFileSystemLive), Layer.provideMerge(Etag.layer)) + +export const WorkerPlatformLive = Layer.mergeAll(Path.layer, WorkerHttpPlatformLive) + +/** + * A build run under the isolate's context — never the first event's fiber — on a + * scope closed only if the build fails (workerd has no teardown). + * + * The builds run lazily on the first event, inside that event's fiber, and the + * HttpApi group layers capture the fiber context they are built in and wrap + * every route handler in it, overriding the per-request one: a graph built + * inside request A served every later request with A's `HttpServerRequest` (its + * bearer, its content-type, its body), A's execution context and A's + * already-flushed span exporter. `isolate` is the context the init captured + * before any event existed. + */ +export const forIsolate = + (isolate: Context.Context) => + (build: Effect.Effect): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.make() + return yield* build.pipe( + Scope.provide(scope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(scope, exit) : Effect.void)), + ) + }).pipe(Effect.updateContext((_: Context.Context) => isolate)) + +/** + * SAFETY: `toHttpEffect` keeps the routes' error and requirement markers in the + * handler's type; the bridge's `safeHttpEffect` renders any escaping cause, so + * the markers are discharged here, once. + */ +const bridgeHandler = ( + handler: Effect.Effect< + HttpServerResponse.HttpServerResponse, + E, + R | Scope.Scope | HttpServerRequest.HttpServerRequest + >, +): HttpEffect => handler as HttpEffect + +/** + * The route graph as the bridge's handler, built for the isolate. + * + * The load-bearing parameter is the third: the graph may require nothing from + * the request context beyond the router and its own markers, so a service a + * handler reads per request fails the build naming itself instead of failing + * every request with "Service not found". + */ +export const buildIsolateHandler = ( + isolate: Context.Context, + routes: Layer.Layer< + ROut, + E, + HttpRouter.HttpRouter | HttpRouter.Request<"Error" | "GlobalError" | "Requires", unknown> + >, +) => forIsolate(isolate)(HttpRouter.toHttpEffect(routes)).pipe(Effect.map(bridgeHandler)) + +/** The route graph as one request handler, built once per isolate on the first request. */ +export const buildApp = (isolate: Context.Context, ports: AiPortsLayer) => + Effect.gen(function* () { + const [{ McpServicesLive }, { AllRoutes, AiAuthLive }] = yield* Effect.all([ + Effect.promise(() => import("@ai/runtime/mcp-service-graph")), + Effect.promise(() => import("@ai/runtime/http-graph")), + ]) + return yield* buildIsolateHandler( + isolate, + AllRoutes.pipe( + Layer.provideMerge(McpServicesLive), + Layer.provideMerge(AiAuthLive), + Layer.provideMerge(WorkerPlatformLive), + Layer.provideMerge(layerPg), + Layer.provide(ports), + ), + ) + }) + +/** + * The request handler the bridge serves. Liveness answers before the route graph + * exists: it needs neither the domain graph nor the database, and a cold isolate + * can report health when an unrelated binding is unavailable. + * + * No CORS preflight branch here, unlike api's: the api owns the origin and + * answers `OPTIONS` before it forwards, so a second set of headers from this + * Worker would be a duplicate `access-control-allow-origin`, which browsers + * reject outright. + */ +export const makeFetch = (app: Effect.Effect, ports: AiPortsLayer) => { + return Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const path = pathOf(request.url) + if (request.method === "GET" && path === "/health") { + const revision = (yield* Cloudflare.WorkerEnvironment).COMMIT_SHA + return HttpServerResponse.text("OK", { + headers: typeof revision === "string" ? { "x-maple-revision": revision } : undefined, + }) + } + + const built = yield* Effect.exit(app) + if (Exit.isFailure(built)) { + yield* Effect.logError("AI worker route graph failed to build", built.cause).pipe( + Effect.annotateLogs({ method: request.method, path }), + ) + return HttpServerResponse.text("maple-ai is unavailable", { status: 503 }) + } + return yield* withPgConnectionScope(built.value) + }).pipe( + // oxlint-disable-next-line effecttsgo/strict-effect-provide -- the request IS the boundary the ports belong to. + Effect.provide(ports), + ) +} + +const pathOf = (url: string): string => { + const query = url.indexOf("?") + return query === -1 ? url : url.slice(0, query) +} diff --git a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.test.ts b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.test.ts similarity index 99% rename from apps/api/src/workflows/InvestigationFanoutWorkflow.run.test.ts rename to apps/ai/src/workflows/InvestigationFanoutWorkflow.run.test.ts index d0ff45b82..b6a04a7eb 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.test.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.test.ts @@ -13,7 +13,7 @@ import { Effect, Layer, Schema } from "effect" import { TestClock } from "effect/testing" import { OpenAiClient } from "@effect/ai-openai-compat" import { OpenRouterClient } from "@effect/ai-openrouter" -import { McpToolExecutor } from "@/mcp/dispatcher" +import { McpToolExecutor } from "@ai/mcp/dispatcher" import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" import { runInvestigationFanout, diff --git a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts similarity index 98% rename from apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts rename to apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts index cd31ef848..60aa7d1e0 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts @@ -38,9 +38,10 @@ import { InvestigationSubjectSnapshot, LensVerdict, } from "@maple/domain/http" -import type { - InvestigationFanoutWorkflowPayload, - InvestigationFanoutWorkflowResult, +import { + widthFor, + type InvestigationFanoutWorkflowPayload, + type InvestigationFanoutWorkflowResult, } from "@maple/domain/investigation-fanout" import { InvestigationId, OrgId, UserId } from "@maple/domain/primitives" import { workerEnvLayer } from "@maple/infra/worker-runtime" @@ -48,8 +49,8 @@ import * as Cloudflare from "alchemy/Cloudflare" import { randomUUID } from "node:crypto" import { and, eq, sql } from "drizzle-orm" import { Cause, Clock, type Context, Effect, Exit, Layer, Option, Schema, type Scope } from "effect" -import type ChatSessionObject from "@/chat/ChatSession" -import type { McpToolExecutor } from "@/mcp/dispatcher" +import type { ChatSessionObject } from "@ai/chat/ChatSession" +import type { McpToolExecutor } from "@ai/mcp/dispatcher" import { Database } from "@/platform/DatabaseLive" import { type LlmCallTags, @@ -58,7 +59,7 @@ import { layerLlm, resolveLensModel, resolveTriageModel, -} from "@/platform/Llm" +} from "@ai/platform/Llm" import { msToDate } from "@/platform/time" import type { TenantContext } from "@/services/auth/tenant-context" import { trackTokenUsage } from "@/services/billing/autumn-tracker" @@ -68,10 +69,10 @@ import { subjectTypeOf, } from "@/services/errors/apply-diagnosis" import { McpServicesLive } from "../runtime/mcp-service-graph" -import { durableStep } from "./durable-step" +import { durableStep } from "@/workflows/durable-step" import { runHypothesisAgent, runSoloHypothesisAgent } from "./hypothesis-agent" -import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "./incident-context" -import { normalizePlan, widthFor, type NormalizedPlan, type PlannedHypothesis } from "./plan-normalize" +import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@maple/domain/incident-context" +import { normalizePlan, type NormalizedPlan, type PlannedHypothesis } from "./plan-normalize" import { runPlannerAgent } from "./planner-agent" import { runValidatorAgent } from "./validator-agent" @@ -336,7 +337,12 @@ const hypothesisOn = snapshot: snapshotOrNull(input.snapshot), model: resolveLensModel( env, - investigationTags("investigation-lens", input.orgId, input.investigationId, input.hypothesis.id), + investigationTags( + "investigation-lens", + input.orgId, + input.investigationId, + input.hypothesis.id, + ), ), tenant: tenantFor(input.orgId), deadlineAtMs: input.deadlineAtMs, @@ -436,7 +442,12 @@ const validatorOn = // does the reasoning the whole fan-out exists to enable. model: resolveTriageModel( env, - investigationTags("investigation-validator", input.orgId, input.investigationId, "validator"), + investigationTags( + "investigation-validator", + input.orgId, + input.investigationId, + "validator", + ), ), tenant: tenantFor(input.orgId), deadlineAtMs: input.deadlineAtMs, diff --git a/apps/api/src/workflows/InvestigationFanoutWorkflow.ts b/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts similarity index 94% rename from apps/api/src/workflows/InvestigationFanoutWorkflow.ts rename to apps/ai/src/workflows/InvestigationFanoutWorkflow.ts index 0f911c6f3..f622786bc 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts @@ -5,8 +5,8 @@ * agents run in parallel, then one validator promotes a single cause and * records why each rival lost. */ -import ChatSessionObject from "@/chat/ChatSession" -import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@/mcp/expected-failures" +import { ChatSessionObject } from "@ai/chat/ChatSession" +import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "@ai/mcp/expected-failures" import { layerPg } from "@/platform/DatabasePgLive" import { withPgConnectionScope } from "@/platform/pg-connection-scope" import { mapleDbConnectionLayer } from "@/platform/pg-connection-source" diff --git a/apps/api/src/workflows/__evals__/diagnosis-fixtures.ts b/apps/ai/src/workflows/__evals__/diagnosis-fixtures.ts similarity index 100% rename from apps/api/src/workflows/__evals__/diagnosis-fixtures.ts rename to apps/ai/src/workflows/__evals__/diagnosis-fixtures.ts diff --git a/apps/api/src/workflows/__evals__/diagnosis-scorers.test.ts b/apps/ai/src/workflows/__evals__/diagnosis-scorers.test.ts similarity index 100% rename from apps/api/src/workflows/__evals__/diagnosis-scorers.test.ts rename to apps/ai/src/workflows/__evals__/diagnosis-scorers.test.ts diff --git a/apps/api/src/workflows/__evals__/diagnosis-scorers.ts b/apps/ai/src/workflows/__evals__/diagnosis-scorers.ts similarity index 100% rename from apps/api/src/workflows/__evals__/diagnosis-scorers.ts rename to apps/ai/src/workflows/__evals__/diagnosis-scorers.ts diff --git a/apps/api/src/workflows/__evals__/diagnosis.eval.ts b/apps/ai/src/workflows/__evals__/diagnosis.eval.ts similarity index 97% rename from apps/api/src/workflows/__evals__/diagnosis.eval.ts rename to apps/ai/src/workflows/__evals__/diagnosis.eval.ts index e84a91a9d..69e9b1041 100644 --- a/apps/api/src/workflows/__evals__/diagnosis.eval.ts +++ b/apps/ai/src/workflows/__evals__/diagnosis.eval.ts @@ -25,9 +25,9 @@ import { generateObject, jsonSchema } from "ai" import { describe, it } from "vitest" import { describeEval, type TaskResult } from "vitest-evals" -import { INVESTIGATE_SYSTEM_PROMPT } from "@/chat/prompts" -import { PLANNER_SYSTEM_PROMPT } from "@/workflows/planner-prompt" -import { createEvalModel, hasEvalCredentials } from "@/mcp/__evals__/model" +import { INVESTIGATE_SYSTEM_PROMPT } from "@ai/chat/prompts" +import { PLANNER_SYSTEM_PROMPT } from "@ai/workflows/planner-prompt" +import { createEvalModel, hasEvalCredentials } from "@ai/mcp/__evals__/model" import { DIAGNOSIS_FIXTURES, type DiagnosisFixture } from "./diagnosis-fixtures" import { scoreCauseMatch, diff --git a/apps/api/src/workflows/agent-pass.test.ts b/apps/ai/src/workflows/agent-pass.test.ts similarity index 96% rename from apps/api/src/workflows/agent-pass.test.ts rename to apps/ai/src/workflows/agent-pass.test.ts index 3a0feead8..6a860f233 100644 --- a/apps/api/src/workflows/agent-pass.test.ts +++ b/apps/ai/src/workflows/agent-pass.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@effect/vitest" import { assert } from "vitest" import { Effect, Layer, Option, Schema } from "effect" import { Model, Tool, Toolkit } from "effect/unstable/ai" -import { MapleToolFailure } from "@/mcp/tools/llm-tools" +import { MapleToolFailure } from "@ai/mcp/tools/llm-tools" import { ScriptedModel, type ScriptedStreamPart, @@ -25,9 +25,9 @@ import { IdGenerator } from "@effect-agent/core/IdGenerator" import { MAPLE_NATIVE_SESSION_ID_ATTR, MAPLE_NATIVE_TURN_ID_ATTR } from "@maple/domain/gen-ai" import { PermissionRule } from "@maple/domain/permission" import { OrgId, UserId } from "@maple/domain" -import type { AgentDefinition } from "@/chat/agents" -import { McpToolExecutor } from "@/mcp/dispatcher" -import type { ResolvedModel } from "@/platform/Llm" +import type { AgentDefinition } from "@ai/chat/agents" +import { McpToolExecutor } from "@ai/mcp/dispatcher" +import type { ResolvedModel } from "@ai/platform/Llm" import type { TenantContext } from "@/services/auth/tenant-context" import { makeRecordingTracer } from "@/testing/recording-tracer" import { runAgentPass } from "./agent-pass" @@ -231,7 +231,11 @@ describe("runAgentPass", () => { assert.strictEqual(pass?.get(MAPLE_NATIVE_SESSION_ID_ATTR), "org_test:inv-1") assert.strictEqual(pass?.get(MAPLE_NATIVE_TURN_ID_ATTR), "pass-1") assert.isFalse( - spans.some((span) => span.name === "investigation.test" && span.attributes.has(MAPLE_NATIVE_SESSION_ID_ATTR)), + spans.some( + (span) => + span.name === "investigation.test" && + span.attributes.has(MAPLE_NATIVE_SESSION_ID_ATTR), + ), ) assert.strictEqual(pass?.get("gen_ai.agent.name"), "hypothesis-test") assert.strictEqual(pass?.get("gen_ai.agent.description"), "test lane") diff --git a/apps/api/src/workflows/agent-pass.ts b/apps/ai/src/workflows/agent-pass.ts similarity index 97% rename from apps/api/src/workflows/agent-pass.ts rename to apps/ai/src/workflows/agent-pass.ts index 44632a044..7a08fc694 100644 --- a/apps/api/src/workflows/agent-pass.ts +++ b/apps/ai/src/workflows/agent-pass.ts @@ -28,18 +28,18 @@ import { ThreadId } from "@effect-agent/core/Identifiers" import { IdGenerator } from "@effect-agent/core/IdGenerator" import * as AgentRuntime from "@effect-agent/engine/AgentRuntime" import { ThreadHistory } from "@effect-agent/engine/ThreadHistory" -import { agentPolicyFor, buildSystemPrompt, type AgentDefinition } from "@/chat/agents" -import { buildMapleToolkit } from "@/mcp/tools/llm-tools" +import { agentPolicyFor, buildSystemPrompt, type AgentDefinition } from "@ai/chat/agents" +import { buildMapleToolkit } from "@ai/mcp/tools/llm-tools" import { evaluatePermission } from "@maple/domain/permission" -import { accumulateUsage, makeRunUsage, type RunUsage } from "@/chat/tools" +import { accumulateUsage, makeRunUsage, type RunUsage } from "@ai/chat/tools" import { type LlmClients, type ResolvedModel, agentSessionSpanAttributes, genAiProviderName, -} from "@/platform/Llm" -import { invokeAgentAttributes } from "@/platform/genai-spans" -import { McpToolExecutor } from "@/mcp/dispatcher" +} from "@ai/platform/Llm" +import { invokeAgentAttributes } from "@ai/platform/genai-spans" +import { McpToolExecutor } from "@ai/mcp/dispatcher" import type { TenantContext } from "@/services/auth/tenant-context" import { summarizeCause } from "@/platform/describe-cause" diff --git a/apps/api/src/workflows/hypothesis-agent.ts b/apps/ai/src/workflows/hypothesis-agent.ts similarity index 96% rename from apps/api/src/workflows/hypothesis-agent.ts rename to apps/ai/src/workflows/hypothesis-agent.ts index 277e239db..65997fba5 100644 --- a/apps/api/src/workflows/hypothesis-agent.ts +++ b/apps/ai/src/workflows/hypothesis-agent.ts @@ -15,13 +15,13 @@ import type { InvestigationSubjectSnapshot, LensCandidate, } from "@maple/domain/http" -import type { ResolvedModel } from "@/platform/Llm" +import type { ResolvedModel } from "@ai/platform/Llm" import { Effect, Option } from "effect" -import { hypothesisAgent } from "@/chat/agents" +import { hypothesisAgent } from "@ai/chat/agents" import type { TenantContext } from "@/services/auth/tenant-context" import { runAgentPass } from "./agent-pass" import { submitCandidate, submitDiagnosis } from "./submit-tools" -import { buildIncidentContextMessage } from "./incident-context" +import { buildIncidentContextMessage } from "@maple/domain/incident-context" import type { PlannedHypothesis } from "./plan-normalize" export interface HypothesisAgentInput { diff --git a/apps/api/src/workflows/hypothesis-catalogue.ts b/apps/ai/src/workflows/hypothesis-catalogue.ts similarity index 100% rename from apps/api/src/workflows/hypothesis-catalogue.ts rename to apps/ai/src/workflows/hypothesis-catalogue.ts diff --git a/apps/api/src/workflows/plan-normalize.test.ts b/apps/ai/src/workflows/plan-normalize.test.ts similarity index 89% rename from apps/api/src/workflows/plan-normalize.test.ts rename to apps/ai/src/workflows/plan-normalize.test.ts index fd6e91566..22eb72388 100644 --- a/apps/api/src/workflows/plan-normalize.test.ts +++ b/apps/ai/src/workflows/plan-normalize.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from "vitest" import { InvestigationPlan, InvestigationSubject } from "@maple/domain/http" import { Option, Schema } from "effect" -import { normalizePlan, widthFor } from "./plan-normalize" +import { normalizePlan } from "./plan-normalize" const subject = Schema.decodeUnknownSync(InvestigationSubject)({ type: "incident", @@ -192,26 +192,3 @@ describe("normalizePlan", () => { expect(result.incidentStartedAt).toBe("2026-08-06T14:00:00.000Z") }) }) - -describe("widthFor", () => { - /** - * A null severity is unclassified, not unimportant. Error incidents carry no - * severity until someone triages them, so treating null as the floor would give - * the highest-volume incident kind the thinnest investigations. - */ - it("treats an unclassified incident as medium, not as the minimum", () => { - expect(widthFor(null, "error")).toBe(4) - expect(widthFor("medium", "error")).toBe(4) - }) - - it("scales with severity", () => { - expect(widthFor("critical", "error")).toBe(5) - expect(widthFor("high", "error")).toBe(4) - expect(widthFor("low", "error")).toBe(3) - }) - - /** An anomaly is already a narrow claim about one signal. */ - it("caps anomalies below the others regardless of severity", () => { - expect(widthFor("critical", "anomaly")).toBe(3) - }) -}) diff --git a/apps/api/src/workflows/plan-normalize.ts b/apps/ai/src/workflows/plan-normalize.ts similarity index 89% rename from apps/api/src/workflows/plan-normalize.ts rename to apps/ai/src/workflows/plan-normalize.ts index a91221715..aabc5e158 100644 --- a/apps/api/src/workflows/plan-normalize.ts +++ b/apps/ai/src/workflows/plan-normalize.ts @@ -24,7 +24,6 @@ import type { InvestigationPlan, InvestigationSubject, InvestigationSubjectSnapshot, - IssueSeverity, } from "@maple/domain/http" import { Option } from "effect" import { permittedTools, RESCUE_TOOL_NAMES, seedHypotheses, seedToolNames } from "./hypothesis-catalogue" @@ -64,38 +63,6 @@ export interface NormalizedPlan { readonly notes: ReadonlyArray } -/** - * How many hypotheses a subject of this shape deserves. - * - * This is the surviving half of the old `fanoutSize` table. The half that is - * gone decided *whether* to fan out at all — that question no longer exists, and - * conflating the two is what let a medium-severity alert compute a width of five - * and dispatch zero. - * - * An anomaly is capped below the others because an anomaly is already a narrow - * claim about one signal; five angles on it mostly produces four polite - * negatives. A null severity reads as medium rather than as "minimum": an - * unclassified incident is unclassified, not unimportant, and treating it as the - * floor is how error incidents — which carry no severity until someone triages - * them — would get the thinnest investigations. - */ -export const widthFor = ( - severity: IssueSeverity | null | undefined, - incidentKind: string | undefined, -): number => { - if (incidentKind === "anomaly") return 3 - switch (severity) { - case "critical": - return 5 - case "high": - return 4 - case "low": - return 3 - default: - return 4 - } -} - /** * `Pool exhaustion in payments-api` → `pool_exhaustion_in_payments_api`. * diff --git a/apps/api/src/workflows/planner-agent.ts b/apps/ai/src/workflows/planner-agent.ts similarity index 94% rename from apps/api/src/workflows/planner-agent.ts rename to apps/ai/src/workflows/planner-agent.ts index c20938dbb..f16d12c1a 100644 --- a/apps/api/src/workflows/planner-agent.ts +++ b/apps/ai/src/workflows/planner-agent.ts @@ -13,12 +13,12 @@ import type { InvestigationSubject, InvestigationSubjectSnapshot, } from "@maple/domain/http" -import type { ResolvedModel } from "@/platform/Llm" +import type { ResolvedModel } from "@ai/platform/Llm" import { Effect, Option } from "effect" -import { plannerAgent } from "@/chat/agents" +import { plannerAgent } from "@ai/chat/agents" import type { TenantContext } from "@/services/auth/tenant-context" import { runAgentPass } from "./agent-pass" -import { buildIncidentContextMessage } from "./incident-context" +import { buildIncidentContextMessage } from "@maple/domain/incident-context" import { submitPlan } from "./submit-tools" export interface PlannerAgentInput { diff --git a/apps/api/src/workflows/planner-prompt.ts b/apps/ai/src/workflows/planner-prompt.ts similarity index 100% rename from apps/api/src/workflows/planner-prompt.ts rename to apps/ai/src/workflows/planner-prompt.ts diff --git a/apps/api/src/workflows/submit-tools.test.ts b/apps/ai/src/workflows/submit-tools.test.ts similarity index 97% rename from apps/api/src/workflows/submit-tools.test.ts rename to apps/ai/src/workflows/submit-tools.test.ts index c573557af..4e5ef6460 100644 --- a/apps/api/src/workflows/submit-tools.test.ts +++ b/apps/ai/src/workflows/submit-tools.test.ts @@ -20,9 +20,9 @@ import { IdGenerator } from "@effect-agent/core/IdGenerator" import { ValidatorVerdict } from "@maple/domain/http" import { OrgId, UserId } from "@maple/domain" import { PermissionRule } from "@maple/domain/permission" -import type { AgentDefinition } from "@/chat/agents" -import { McpToolExecutor } from "@/mcp/dispatcher" -import type { ResolvedModel } from "@/platform/Llm" +import type { AgentDefinition } from "@ai/chat/agents" +import { McpToolExecutor } from "@ai/mcp/dispatcher" +import type { ResolvedModel } from "@ai/platform/Llm" import type { TenantContext } from "@/services/auth/tenant-context" import { runAgentPass } from "./agent-pass" import { submitCandidate, submitDiagnosis, submitPlan, submitVerdict } from "./submit-tools" diff --git a/apps/api/src/workflows/submit-tools.ts b/apps/ai/src/workflows/submit-tools.ts similarity index 98% rename from apps/api/src/workflows/submit-tools.ts rename to apps/ai/src/workflows/submit-tools.ts index d3a87979a..0e3342f7f 100644 --- a/apps/api/src/workflows/submit-tools.ts +++ b/apps/ai/src/workflows/submit-tools.ts @@ -20,7 +20,7 @@ import { AiTriageResult, InvestigationPlan, LensCandidate, ValidatorVerdict } from "@maple/domain/http" import { Effect, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import { MapleToolFailure } from "@/mcp/tools/llm-tools" +import { MapleToolFailure } from "@ai/mcp/tools/llm-tools" import { PLANNER_SUBMIT_DESCRIPTION, PLANNER_SUBMIT_TOOL } from "./planner-prompt" /** What a model is told when it calls a submit tool as an ordinary one. */ diff --git a/apps/api/src/workflows/validator-agent.ts b/apps/ai/src/workflows/validator-agent.ts similarity index 97% rename from apps/api/src/workflows/validator-agent.ts rename to apps/ai/src/workflows/validator-agent.ts index 0e3fe65e7..595f506c6 100644 --- a/apps/api/src/workflows/validator-agent.ts +++ b/apps/ai/src/workflows/validator-agent.ts @@ -16,13 +16,13 @@ */ import { ValidatorVerdict } from "@maple/domain/http" import type { InvestigationSubject, InvestigationSubjectSnapshot } from "@maple/domain/http" -import type { ResolvedModel } from "@/platform/Llm" +import type { ResolvedModel } from "@ai/platform/Llm" import { Effect, Option, Schema } from "effect" -import { AGENTS } from "@/chat/agents" +import { AGENTS } from "@ai/chat/agents" import type { TenantContext } from "@/services/auth/tenant-context" import { runAgentPass } from "./agent-pass" import { submitVerdict } from "./submit-tools" -import { buildIncidentContextMessage } from "./incident-context" +import { buildIncidentContextMessage } from "@maple/domain/incident-context" /** What one lane handed the validator. `null` candidate = the lane found nothing. */ export interface ValidatorCandidateInput { diff --git a/apps/api/test/chat/fake-do-state.ts b/apps/ai/test/chat/fake-do-state.ts similarity index 100% rename from apps/api/test/chat/fake-do-state.ts rename to apps/ai/test/chat/fake-do-state.ts diff --git a/apps/ai/tsconfig.json b/apps/ai/tsconfig.json new file mode 100644 index 000000000..62a376766 --- /dev/null +++ b/apps/ai/tsconfig.json @@ -0,0 +1,43 @@ +{ + "include": ["src/**/*.ts", "src/**/*.tsx"], + // Mirrors apps/api, whose sources this program also compiles: tests are + // excluded from tsc there, and `bun` is the resolution condition its imports + // were written against. + "exclude": ["src/**/*.test.ts"], + "compilerOptions": { + "customConditions": ["bun"], + "ignoreDeprecations": "6.0", + "target": "ES2022", + "module": "ESNext", + "jsx": "react-jsx", + // ES2023: this worker's layer graph reaches apps/api. Mirrors its tsconfig. + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node", "@cloudflare/workers-types"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + // `@/` is apps/api's source, NOT this app's — the same mapping alerting + // uses, and it is not a style choice. This worker's layer graph pulls + // api's own modules into its program, and those modules spell their + // internal imports `@/`. Point `@/` here and every one of them resolves + // into the wrong tree. + "@/*": ["../api/src/*"], + // This worker's own source. The asymmetry is the dependency direction + // made visible: apps/ai reaches into apps/api by source, apps/api reaches + // back only by binding. + "@ai/*": ["./src/*"] + }, + "plugins": [ + { + "name": "@effect/language-service", + "reportSuggestionsAsWarningsInTsc": true + } + ] + } +} diff --git a/apps/ai/vitest.config.ts b/apps/ai/vitest.config.ts new file mode 100644 index 000000000..56a1f6770 --- /dev/null +++ b/apps/ai/vitest.config.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + // Longest prefix first: "@ai" must not be swallowed by "@". The mapping + // mirrors tsconfig — `@` is apps/api's source, because this worker's + // graph pulls api modules in and they spell their own imports that way. + "@ai": fileURLToPath(new URL("./src", import.meta.url)), + "@": fileURLToPath(new URL("../api/src", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + // Threads over forked processes, for the reason apps/api's config records: + // process startup and the per-worker module registry dominate otherwise. + pool: "threads", + // The moved suites boot PGlite through api's `createTestDb`, so they need + // api's snapshot. Pointing at api's setup rather than copying it keeps one + // post-migration data directory instead of two racing builders. + globalSetup: ["../api/test/global-setup.ts"], + // Same headroom as apps/api, and for the same reasons: PGlite-per-test, real + // exponential backoff in the retry suites, and CPU starvation under a + // parallel `turbo test` stretching both past the 5s default. + testTimeout: 60_000, + hookTimeout: 60_000, + }, +}) diff --git a/apps/api/vitest.eval.config.ts b/apps/ai/vitest.eval.config.ts similarity index 76% rename from apps/api/vitest.eval.config.ts rename to apps/ai/vitest.eval.config.ts index 4ab9aca69..4016df515 100644 --- a/apps/api/vitest.eval.config.ts +++ b/apps/ai/vitest.eval.config.ts @@ -7,7 +7,9 @@ import { defineConfig } from "vitest/config" export default defineConfig({ resolve: { alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)), + // Longest prefix first, and `@` is apps/api's — see vitest.config.ts. + "@ai": fileURLToPath(new URL("./src", import.meta.url)), + "@": fileURLToPath(new URL("../api/src", import.meta.url)), }, }, test: { diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index f397658eb..54fd10e92 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -48,7 +48,7 @@ import { HttpServerResponse } from "effect/unstable/http" * so `InferEnv` can derive `AlertingWorkerEnv` below. */ const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ - // Cross-script binding to the investigation fan-out Workflow the api Worker + // Cross-script binding to the investigation fan-out Workflow the AI Worker // hosts as an alchemy class. Alert, error, and anomaly ticks start // investigations when incidents open. Bound under the CLASS name because the // api services shared with these ticks read it there @@ -59,7 +59,7 @@ const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ INVESTIGATION_FANOUT_BINDING, { className: INVESTIGATION_FANOUT_BINDING, - scriptName: resolveWorkerName("api", stage), + scriptName: resolveWorkerName("ai", stage), }, ), ...emailBinding(stage), diff --git a/apps/api/package.json b/apps/api/package.json index 17330614b..fc519a2e4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,11 +10,6 @@ "db:generate": "bun run --cwd ../../packages/db db:generate", "test": "vitest run", "test:integration": "vitest run --config vitest.integration.config.ts", - "eval": "vitest run --config vitest.eval.config.ts", - "eval:check": "bun run scripts/eval-runtime-check.ts", - "measure-tokens": "bun run scripts/measure-token-cost.ts", - "mcp:docs": "bun run scripts/generate-dashboard-skill.ts", - "eval:widgets": "bun run scripts/grade-widget-eval.ts", "typecheck": "tsc --noEmit && bun run bench:typecheck", "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "tinybird:dev": "tinybird dev", @@ -32,12 +27,7 @@ "@clerk/backend": "^3.16.12", "@distilled.cloud/cloudflare": "1.0.0-rc.6", "@distilled.cloud/core": "1.0.0-rc.6", - "@effect-agent/capabilities": "0.1.0-beta.74", - "@effect-agent/core": "0.1.0-beta.74", - "@effect-agent/engine": "0.1.0-beta.74", "@effect-agent/sandbox": "0.1.0-beta.74", - "@effect/ai-openai-compat": "catalog:effect", - "@effect/ai-openrouter": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@maple-dev/effect-clickhouse": "0.1.0", "@maple-dev/effect-clickhouse-http": "workspace:*", @@ -60,18 +50,13 @@ "effect": "catalog:effect" }, "devDependencies": { - "@ai-sdk/openai-compatible": "^2.0.48", "@cloudflare/workers-types": "catalog:alchemy", - "@effect-agent/testing": "0.1.0-beta.74", "@effect/language-service": "catalog:effect", "@electric-sql/pglite": "^0.5.2", "@types/node": "catalog:tooling", - "ai": "^6.0.196", "atmn": "^1.1.17", - "gpt-tokenizer": "^3.0.1", "typescript": "catalog:tooling", "vitest": "catalog:", - "vitest-evals": "^0.4.0", "wrangler": "^4.118.0" } } diff --git a/apps/api/src/internal-rpc.test.ts b/apps/api/src/internal-rpc.test.ts deleted file mode 100644 index 984856677..000000000 --- a/apps/api/src/internal-rpc.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" -import { callMcpToolRpc, submitDiagnosisRpc } from "./internal-rpc" -import { McpToolExecutor, type McpToolExecutorApi } from "./mcp/dispatcher" -import { InvestigationService, type InvestigationServiceApi } from "./services/errors/InvestigationService" - -const investigationId = "00000000-0000-4000-8000-000000000001" -const report = { - summary: "Checkout latency doubled after deploy.", - suspectedCause: "Connection pool regression", - severityAssessment: "high", - affectedScope: "checkout-api", - evidence: [ - { - traceIds: ["trace-1"], - logPatterns: ["pool exhausted"], - relatedServices: ["payments"], - note: "The failing traces share the same pool exhaustion event.", - }, - ], - suggestedActions: ["Roll back the deploy"], - confidence: "high", -} as const - -const unusedInvestigationService: InvestigationServiceApi = { - listInvestigations: () => Effect.die("unused"), - getInvestigation: () => Effect.die("unused"), - createInvestigation: () => Effect.die("unused"), - createAndStartInvestigation: () => Effect.die("unused"), - restartInvestigation: () => Effect.die("unused"), - updateStatus: () => Effect.die("unused"), - submitDiagnosis: () => Effect.die("unused"), -} - -const unusedMcpToolExecutor: McpToolExecutorApi = { - execute: () => Effect.die("unused"), -} - -describe("internal RPC boundary", () => { - it.effect("rejects invalid org IDs before MCP dispatch", () => - Effect.gen(function* () { - const error = yield* Effect.flip( - callMcpToolRpc({ orgId: " ", name: "inspect_trace", input: {} }).pipe( - Effect.provideService(McpToolExecutor, unusedMcpToolExecutor), - ), - ) - expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError") - if (error._tag !== "@maple/internal-rpc/InvalidInputError") { - throw new Error(`Expected invalid input, received ${error._tag}`) - } - expect(error.method).toBe("callMcpTool") - }), - ) - - it.effect("rejects invalid investigation IDs and model-produced reports", () => - Effect.gen(function* () { - for (const input of [ - { orgId: "org_1", investigationId: "not-a-uuid", report }, - { orgId: "org_1", investigationId, report: { summary: "incomplete" } }, - ]) { - const error = yield* Effect.flip( - submitDiagnosisRpc(input).pipe( - Effect.provideService(InvestigationService, unusedInvestigationService), - ), - ) - expect(error._tag).toBe("@maple/internal-rpc/InvalidInputError") - if (error._tag !== "@maple/internal-rpc/InvalidInputError") { - throw new Error(`Expected invalid input, received ${error._tag}`) - } - expect(error.method).toBe("submitDiagnosis") - } - }), - ) - - it.effect("submits a decoded diagnosis to the org-scoped service", () => - Effect.gen(function* () { - const calls: Array<{ orgId: string; investigationId: string; summary: string }> = [] - const expected = { id: investigationId, status: "diagnosed" } as never - const service: InvestigationServiceApi = { - ...unusedInvestigationService, - submitDiagnosis: (orgId, id, request) => - Effect.sync(() => { - calls.push({ orgId, investigationId: id, summary: request.report.summary }) - return expected - }), - } - - const result = yield* submitDiagnosisRpc({ orgId: "org_1", investigationId, report }).pipe( - Effect.provideService(InvestigationService, service), - ) - expect(result).toBe(expected) - expect(calls).toEqual([ - { orgId: "org_1", investigationId, summary: "Checkout latency doubled after deploy." }, - ]) - }), - ) -}) diff --git a/apps/api/src/internal-rpc.ts b/apps/api/src/internal-rpc.ts deleted file mode 100644 index 2498933a3..000000000 --- a/apps/api/src/internal-rpc.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - CallMcpToolRpcRequest, - InternalRpcInvalidInputError, - SubmitDiagnosisRpcRequest, -} from "@maple/domain/internal-rpc" -import { SubmitDiagnosisRequest } from "@maple/domain/http" -import { UserId } from "@maple/domain/primitives" -import { Effect, Schema } from "effect" -import type { TenantContext } from "@/services/auth/tenant-context" -import { McpToolExecutor, listMcpTools } from "./mcp/dispatcher" -import { InvestigationService } from "./services/errors/InvestigationService" - -const internalServiceUserId = Schema.decodeSync(UserId)("internal-service") - -const invalidInput = (method: "callMcpTool" | "submitDiagnosis") => (error: { message: string }) => - new InternalRpcInvalidInputError({ method, message: error.message }) - -const decodeCallMcpTool = (input: unknown) => - Schema.decodeUnknownEffect(CallMcpToolRpcRequest)(input).pipe( - Effect.mapError(invalidInput("callMcpTool")), - ) - -const decodeSubmitDiagnosis = (input: unknown) => - Schema.decodeUnknownEffect(SubmitDiagnosisRpcRequest)(input).pipe( - Effect.mapError(invalidInput("submitDiagnosis")), - ) - -const makeInternalTenant = (orgId: CallMcpToolRpcRequest["orgId"]): TenantContext => ({ - orgId, - userId: internalServiceUserId, - roles: [], - authMode: "self_hosted", -}) - -export const listMcpToolsRpc = listMcpTools.pipe(Effect.withSpan("InternalRpc.listMcpTools")) - -export const callMcpToolRpc = (input: unknown) => - decodeCallMcpTool(input).pipe( - Effect.flatMap((request) => - McpToolExecutor.pipe( - Effect.flatMap((executor) => - executor.execute(makeInternalTenant(request.orgId), request.name, request.input, "rpc"), - ), - ), - ), - Effect.withSpan("InternalRpc.callMcpTool"), - ) - -export const submitDiagnosisRpc = Effect.fn("InternalRpc.submitDiagnosis")(function* (input: unknown) { - const request = yield* decodeSubmitDiagnosis(input) - yield* Effect.annotateCurrentSpan({ - orgId: request.orgId, - "maple.investigation.id": request.investigationId, - }) - const investigations = yield* InvestigationService - return yield* investigations.submitDiagnosis( - request.orgId, - request.investigationId, - new SubmitDiagnosisRequest({ report: request.report }), - ) -}) diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 334d79d77..6414f1a3e 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -70,13 +70,6 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => plainWithDefault("EDGE_CACHE_READ_TIMEOUT_MS", "40"), // MAPLE_ENDPOINT / MAPLE_ENVIRONMENT / COMMIT_SHA / MAPLE_INGEST_KEY. selfObservabilityEnv(stage), - // Agent LLM path. `MAPLE_LLM_PROVIDER` flips between OpenRouter (default) and - // Workers AI; both stay wired, so a switch is this one var plus a redeploy. - // See `@/platform/Llm` for the provider-scoped model overrides. - optionalPlain("MAPLE_LLM_PROVIDER"), - optionalPlain("MAPLE_TRIAGE_MODEL_OPENROUTER"), - optionalPlain("MAPLE_TRIAGE_MODEL_WORKERS_AI"), - optionalSecret("OPENROUTER_API_KEY"), // Svix signing secrets for the public webhook receivers (`/webhooks/clerk`, // `/webhooks/autumn`); each route answers 503 until its secret is set. optionalSecret("CLERK_WEBHOOK_SECRET"), diff --git a/apps/api/src/runtime/graph-boundaries.test.ts b/apps/api/src/runtime/graph-boundaries.test.ts index caf25872a..2591a5144 100644 --- a/apps/api/src/runtime/graph-boundaries.test.ts +++ b/apps/api/src/runtime/graph-boundaries.test.ts @@ -24,83 +24,12 @@ describe("API runtime graph boundaries", () => { expect(imports.filter((specifier) => specifier.startsWith("effect/unstable/http"))).toEqual([]) }) - it("keeps runtime entrypoints off the compatibility facade", () => { - const runtimeEntrypoints: ReadonlyArray< - readonly [ - source: string, - expectedImports: ReadonlyArray, - expectedRoots: ReadonlyArray, - ] - > = [ - [ - readModule("../chat/turn-runner.ts"), - ["../runtime/mcp-service-graph"], - ["InvestigationServicesLive"], - ], - [ - readModule("../mcp/__evals__/eval-runtime.ts"), - ["@/runtime/mcp-service-graph"], - ["McpServicesLive"], - ], - [readModule("../worker/http.ts"), ["../runtime/service-graph"], ["HttpServicesLive"]], - [readModule("../worker/rpc.ts"), ["../runtime/mcp-service-graph"], ["InvestigationServicesLive"]], - [ - readModule("../workflows/InvestigationFanoutWorkflow.run.ts"), - ["../runtime/mcp-service-graph"], - ["McpServicesLive"], - ], - ] + it("keeps the HTTP entrypoint off the compatibility facade", () => { + const source = readModule("../worker/http.ts") - for (const [source, expectedImports, expectedRoots] of runtimeEntrypoints) { - for (const expectedImport of expectedImports) - expect(importSpecifiers(source)).toContain(expectedImport) - expect(importSpecifiers(source).some((specifier) => /(?:^|\/)app$/.test(specifier))).toBe(false) - for (const root of expectedRoots) expect(source).toContain(root) - expect(source).not.toMatch(/\{\s*MainLive\s*\}/) - } - }) - - it("keeps the headless MCP root limited to registered tool requirements", () => { - const source = readModule("./mcp-service-graph.ts") - const imports = importSpecifiers(source) - - expect(layerMembers(source, "McpRuntimeServicesLive")).toEqual([ - "AlertReadModelsServiceLive", - "AlertRulesServiceLive", - "AlertsServiceLive", - // Lets `register_agent` (and issue-workflow mutations) write org audit entries. - "AuditLogServiceLive", - "DashboardPersistenceService.layer", - "ErrorActorsServiceLive", - "ErrorIssueReadModelsServiceLive", - "ErrorIssueWorkflowServiceLive", - "ErrorPolicyServiceLive", - "ErrorsServiceLive", - // Backs `link_pull_request`, and is what lets `propose_fix` turn its - // `pr_url` into a durable link rather than an event-payload string. - "IssueFixVerificationServiceLive", - "QueryEngineServiceLive", - "RecommendationIssueServiceLive", - // The agents' repository sandbox tools. - "RepoSandboxServiceLive", - "SetupAuditServiceLive", - "VcsSourceServiceLive", - "WarehouseQueryServiceLive", - ]) - expect(source).toContain( - "export const InvestigationServicesLive = Layer.mergeAll(McpServicesLive, InvestigationServiceLive)", - ) - expect(imports).not.toContain("@/runtime/service-graph") - for (const routeOnlyService of [ - "DailySpendService", - "CloudflareAnalyticsService", - "AnomalyDetectionService", - "AiTriageService", - "DigestService", - "DemoService", - "SlackIntegrationService", - ]) { - expect(imports.some((specifier) => specifier.endsWith(`/${routeOnlyService}`))).toBe(false) - } + expect(importSpecifiers(source)).toContain("../runtime/service-graph") + expect(importSpecifiers(source).some((specifier) => /(?:^|\/)app$/.test(specifier))).toBe(false) + expect(source).toContain("HttpServicesLive") + expect(source).not.toMatch(/\{\s*MainLive\s*\}/) }) }) diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 29a470b46..5ab5d8eed 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -4,7 +4,6 @@ import { Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi" import { API_CORS_OPTIONS } from "@/http/api-cors" -import { McpLive } from "@/mcp/app" import { Env } from "@/platform/Env" import { HttpAiModelsInternalLive } from "@/routes/internal/ai-models.http" import { HttpAiSessionsInternalLive } from "@/routes/internal/ai-sessions.http" @@ -13,8 +12,6 @@ import { HttpAuthLive, HttpAuthPublicLive } from "@/routes/v1/auth.http" import { HttpBillingLive } from "@/routes/internal/billing.http" import { HttpBillingPublicLive } from "@/routes/v1/billing-public.http" import { HttpV2SharePublicLive } from "@/routes/v2/share.http" -import { ChatSessionsRouter } from "@/routes/v1/chat-sessions.http" -import { HttpChatLive } from "@/routes/internal/chat.http" import { V1ErrorBoundaryLive } from "@/routes/v1/error-boundary" import { HttpDemoLive } from "@/routes/internal/demo.http" import { DiscoveryRouter, NotFoundRouter } from "@/routes/discovery.http" @@ -119,9 +116,7 @@ const ApiInternalRoutes = HttpApiBuilder.layer(MapleInternalApi).pipe( HttpAiModelsInternalLive, ), ), - Layer.provide( - Layer.mergeAll(HttpAiTriageLive, HttpBillingLive, HttpChatLive, HttpDemoLive, HttpDigestLive), - ), + Layer.provide(Layer.mergeAll(HttpAiTriageLive, HttpBillingLive, HttpDemoLive, HttpDigestLive)), Layer.provide(V1ErrorBoundaryLive), ) @@ -191,7 +186,6 @@ const rawRoutes = ( const RawRoutes = rawRoutes( Layer.mergeAll( - ChatSessionsRouter, IntegrationsCallbackRouter, SlackCallbackRouter, SlackInternalRouter, @@ -201,7 +195,6 @@ const RawRoutes = rawRoutes( VcsWebhookRouter, ClerkWebhookRouter, AutumnWebhookRouter, - McpLive, HealthRouter, DocsRoute, DocsV2Route, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 1a355360e..e2640cf93 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -1,6 +1,5 @@ import { BucketCacheService } from "@maple/query-engine/caching" import { Layer } from "effect" -import { McpToolExecutor } from "@/mcp/dispatcher" import { EdgeCacheServiceLive } from "@/platform/CacheBackendLive" import { EmailService } from "@/platform/EmailService" import { Env } from "@/platform/Env" @@ -349,4 +348,4 @@ const MainServicesLive = Layer.mergeAll( * `mcp-service-graph.ts` instead of importing or acquiring route-only services * such as billing, demo, digest, OAuth, anomaly detection, and Slack integration. */ -export const HttpServicesLive = McpToolExecutor.layer.pipe(Layer.provideMerge(MainServicesLive)) +export const HttpServicesLive = MainServicesLive diff --git a/apps/api/src/services/alerts/AlertReadModelsService.boundary.test.ts b/apps/api/src/services/alerts/AlertReadModelsService.boundary.test.ts index 091fdabe2..a362114da 100644 --- a/apps/api/src/services/alerts/AlertReadModelsService.boundary.test.ts +++ b/apps/api/src/services/alerts/AlertReadModelsService.boundary.test.ts @@ -25,13 +25,10 @@ describe("AlertReadModelsService boundary", () => { } }) - it("is the capability consumed by incident, delivery, and check read handlers", () => { + it("is the capability consumed by the incident and delivery read handlers", () => { for (const path of [ "../../routes/v2/alert-incidents.http.ts", "../../routes/v2/alert-deliveries.http.ts", - "../../mcp/tools/list-alert-incidents.ts", - "../../mcp/tools/get-incident-timeline.ts", - "../../mcp/tools/list-alert-checks.ts", ]) { const imports = importSpecifiers(readModule(path)) expect(imports).toContain("@/services/alerts/AlertReadModelsService") diff --git a/apps/api/src/services/audit/audit-access.ts b/apps/api/src/services/audit/audit-access.ts index 0eac00e86..4b4e6f90a 100644 --- a/apps/api/src/services/audit/audit-access.ts +++ b/apps/api/src/services/audit/audit-access.ts @@ -3,7 +3,7 @@ import type { HttpServerRequest, HttpServerResponse } from "effect/unstable/http import type { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { AuditedRead, type AuditLogSource } from "@maple/domain/http" import type { ActorId, OrgId, UserId } from "@maple/domain/primitives" -import type { McpToolSurface } from "@/mcp/dispatcher" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" import type { AuditActorInfo } from "@/services/auth/audit-actor" import { CurrentAuditActor } from "@/services/auth/audit-actor" import type { TenantContext } from "@/services/auth/tenant-context" diff --git a/apps/api/src/services/auth/McpOAuthService.test.ts b/apps/api/src/services/auth/McpOAuthService.test.ts index 90a6db824..0226a11ef 100644 --- a/apps/api/src/services/auth/McpOAuthService.test.ts +++ b/apps/api/src/services/auth/McpOAuthService.test.ts @@ -7,7 +7,6 @@ import { cleanupTestDbs, createTestDb, queryFirstRow, type TestDb } from "@/plat import { ApiKeysService } from "@/services/org/ApiKeysService" import { AuthService } from "./AuthService" import { matchesMcpOAuthRedirectUri, McpOAuthService, validateMcpOAuthRedirectUri } from "./McpOAuthService" -import { resolveMcpTenantContext } from "@/mcp/lib/resolve-tenant" const createdDbs: TestDb[] = [] afterEach(() => cleanupTestDbs(createdDbs)) @@ -215,21 +214,9 @@ describe("McpOAuthService", () => { expect(resolved.value.scopes).toEqual(["mcp:tools"]) expect(resolved.value.mcpOAuthResource).toBe(resource) } - const tenant = yield* resolveMcpTenantContext( - new Request(resource, { headers: { authorization: `Bearer ${tokens.access_token}` } }), - ) - expect(tenant.orgId).toBe(orgId) - expect(tenant.roles).toEqual([memberRole]) - const wrongAudience = yield* resolveMcpTenantContext( - new Request("https://other.example.com/mcp", { - headers: { authorization: `Bearer ${tokens.access_token}` }, - }), - ).pipe(Effect.flip) - expect(wrongAudience._tag).toBe("@maple/mcp/errors/McpAuthInvalidError") - if (wrongAudience._tag === "@maple/mcp/errors/McpAuthInvalidError") { - expect(wrongAudience.reason).toBe("invalid_target") - } - + // The other half of this flow — that MCP accepts this token and rejects it + // for a different resource — now runs in apps/ai, where the code that + // resolves it lives: `mcp/lib/resolve-tenant.oauth.test.ts`. const reused = yield* oauth .exchangeAuthorizationCode( { diff --git a/apps/api/src/services/errors/AiTriageService.ts b/apps/api/src/services/errors/AiTriageService.ts index f2e79fefb..2d6a25452 100644 --- a/apps/api/src/services/errors/AiTriageService.ts +++ b/apps/api/src/services/errors/AiTriageService.ts @@ -13,7 +13,7 @@ import { aiTriageSettings, type AiTriageSettingsRow } from "@maple/db" import { eq } from "drizzle-orm" import { Clock, Context, Effect, Layer, Schema } from "effect" import { Database } from "@/platform/DatabaseLive" -import { widthFor } from "@/workflows/plan-normalize" +import { widthFor } from "@maple/domain/investigation-fanout" import { makeDbExecute, makePersistenceErrorMapper } from "@/platform/db-execute" import { DEFAULT_MAX_PASSES_PER_DAY, diff --git a/apps/api/src/services/errors/InvestigationService.ts b/apps/api/src/services/errors/InvestigationService.ts index f6ded5ab2..fd8c4ab13 100644 --- a/apps/api/src/services/errors/InvestigationService.ts +++ b/apps/api/src/services/errors/InvestigationService.ts @@ -26,7 +26,7 @@ import { import { ErrorIssueId, InvestigationId, UserId as UserIdSchema } from "@maple/domain/primitives" import { wrapChatContext } from "@maple/domain/chat-preamble" import { encodeChatTurnTenant } from "@maple/domain/chat-session" -import { chatSessionStub } from "@/chat/session" +import { chatSessionStub } from "@maple/domain/chat-session-stub" import type { TenantContext } from "@/services/auth/tenant-context" import { investigationLensRuns, @@ -38,7 +38,7 @@ import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { and, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm" import { Clock, Context, Duration, Effect, Exit, Layer, Option, Redacted, Schema } from "effect" import { applyDiagnosisWrites, subjectTypeOf } from "@/services/errors/apply-diagnosis" -import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@/workflows/incident-context" +import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@maple/domain/incident-context" import { routeInvestigation, type InvestigationRoute } from "@/services/errors/investigation-route" import { FanoutStartError } from "@/services/errors/investigation-fanout-error" import { diff --git a/apps/api/src/services/errors/ai-triage-enqueue.ts b/apps/api/src/services/errors/ai-triage-enqueue.ts index fde778d52..645a1c9be 100644 --- a/apps/api/src/services/errors/ai-triage-enqueue.ts +++ b/apps/api/src/services/errors/ai-triage-enqueue.ts @@ -14,10 +14,10 @@ import { aiTriageSettings, investigations } from "@maple/db" import { and, eq, lt } from "drizzle-orm" import { Clock, Duration, Effect, Exit, Option, Redacted, Schema } from "effect" import { encodeChatTurnTenant } from "@maple/domain/chat-session" +import { isChatSessionNamespace } from "@maple/domain/chat-session-stub" import { Database } from "@/platform/DatabaseLive" -import { isChatSessionNamespace } from "@/chat/session" -import { widthFor } from "@/workflows/plan-normalize" -import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@/workflows/incident-context" +import { widthFor } from "@maple/domain/investigation-fanout" +import { AUTONOMOUS_KICKOFF_LEAD, buildIncidentContextMessage } from "@maple/domain/incident-context" import { evaluateInvestigationQuota, selectInvestigationUsage } from "@/services/errors/investigation-quota" import { startInvestigationFanout } from "@/services/errors/investigation-fanout-start" import { diff --git a/apps/api/src/services/errors/investigation-route.ts b/apps/api/src/services/errors/investigation-route.ts index f38d5716c..64038afd3 100644 --- a/apps/api/src/services/errors/investigation-route.ts +++ b/apps/api/src/services/errors/investigation-route.ts @@ -4,7 +4,7 @@ * a continuing conversation. */ import type { InvestigationSubject, InvestigationSubjectSnapshot } from "@maple/domain/http" -import { widthFor } from "@/workflows/plan-normalize" +import { widthFor } from "@maple/domain/investigation-fanout" export type InvestigationRoute = /** One chat-session turn, and then a conversation. Free-form questions only. */ diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts index b171dab0a..019a838d6 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.test.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.test.ts @@ -319,10 +319,12 @@ describe("WarehouseQueryService raw-SQL provider routing", () => { }) it.effect("preserves missing Tinybird signing configuration as its own tag", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs), {}, false) return Effect.gen(function* () { @@ -370,8 +372,13 @@ describe("bounded Tinybird response body", () => { it.effect("accepts an exact-boundary response and refuses one byte over", () => Effect.gen(function* () { - const exact = makeTinybirdTestClient(tbConfig, async () => new Response(bodyOf(MAX_RAW_SQL_RESULT_BYTES))) - const result = yield* exact.sql(parseStatement("SELECT 1 FORMAT JSON"), { responseLimits: limits }) + const exact = makeTinybirdTestClient( + tbConfig, + async () => new Response(bodyOf(MAX_RAW_SQL_RESULT_BYTES)), + ) + const result = yield* exact.sql(parseStatement("SELECT 1 FORMAT JSON"), { + responseLimits: limits, + }) assert.deepStrictEqual(result.data, []) const over = makeTinybirdTestClient( @@ -393,18 +400,20 @@ describe("WarehouseQueryService.compiledQuery retry on transient upstream failur // delays, so the default TestClock would stall the retries. it.live("recovers after two 503s on the third attempt", () => { let attempts = 0 - __testables.setClientFactory(() => Effect.succeed({ - sql: () => - Effect.try({ - try: () => { - attempts++ - if (attempts < 3) throw transient503() - return { data: [{ ok: 1 }] } - }, - catch: warehouseDriverFailure, - }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => + Effect.try({ + try: () => { + attempts++ + if (attempts < 3) throw transient503() + return { data: [{ ok: 1 }] } + }, + catch: warehouseDriverFailure, + }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -421,17 +430,19 @@ describe("WarehouseQueryService.compiledQuery retry on transient upstream failur it.effect("does not retry non-transient errors (auth)", () => { let attempts = 0 - __testables.setClientFactory(() => Effect.succeed({ - sql: () => - Effect.try({ - try: () => { - attempts++ - throw new Error("HTTP status 401 authentication failed") - }, - catch: warehouseDriverFailure, - }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => + Effect.try({ + try: () => { + attempts++ + throw new Error("HTTP status 401 authentication failed") + }, + catch: warehouseDriverFailure, + }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -451,17 +462,19 @@ describe("WarehouseQueryService.compiledQuery retry on transient upstream failur // Runs under it.live: exhausts the real backoff schedule before giving up. it.live("gives up after the configured retry budget when all attempts fail", () => { let attempts = 0 - __testables.setClientFactory(() => Effect.succeed({ - sql: () => - Effect.try({ - try: () => { - attempts++ - throw transient503() - }, - catch: warehouseDriverFailure, - }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => + Effect.try({ + try: () => { + attempts++ + throw transient503() + }, + catch: warehouseDriverFailure, + }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -488,10 +501,12 @@ describe("WarehouseQueryService.compiledQuery", () => { const RowNumber = Schema.Union([Schema.Finite, Schema.FiniteFromString]) it.effect("executes compiled SQL and decodes rows with the compiled row schema", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [{ serviceName: "api", count: "42" }] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [{ serviceName: "api", count: "42" }] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -513,10 +528,12 @@ describe("WarehouseQueryService.compiledQuery", () => { }) it.effect("maps row decode failures to WarehouseResultDecodeError", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [{ count: "not-a-number" }] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [{ count: "not-a-number" }] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -540,10 +557,12 @@ describe("WarehouseQueryService.compiledQuery", () => { }) it.effect("still enforces OrgId scoping for compiled SQL", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [{ count: 1 }] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [{ count: 1 }] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -579,16 +598,18 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { const RowNumber = Schema.Union([Schema.Finite, Schema.FiniteFromString]) it.effect("returns Some with the decoded first row", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => - Effect.succeed({ - data: [ - { serviceName: "api", count: "42" }, - { serviceName: "worker", count: "9" }, - ], - }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => + Effect.succeed({ + data: [ + { serviceName: "api", count: "42" }, + { serviceName: "worker", count: "9" }, + ], + }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -613,10 +634,12 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { }) it.effect("returns None when the compiled SQL returns no rows", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -638,10 +661,12 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { }) it.effect("maps first-row decode failures to WarehouseResultDecodeError", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [{ count: "not-a-number" }] }), - insert: () => Effect.void, - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [{ count: "not-a-number" }] }), + insert: () => Effect.void, + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -668,16 +693,18 @@ describe("WarehouseQueryService.compiledQueryFirst", () => { describe("WarehouseQueryService.ingest writes through the SQL client", () => { it.effect("forwards datasource + rows to the client's insert", () => { const calls: Array<{ datasource: string; rows: ReadonlyArray }> = [] - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [] }), - insert: (datasource, rows) => - Effect.try({ - try: () => { - calls.push({ datasource, rows }) - }, - catch: WarehouseDriverError.fromUnknown, - }), - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [] }), + insert: (datasource, rows) => + Effect.try({ + try: () => { + calls.push({ datasource, rows }) + }, + catch: WarehouseDriverError.fromUnknown, + }), + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -694,16 +721,18 @@ describe("WarehouseQueryService.ingest writes through the SQL client", () => { it.effect("short-circuits without calling insert when there are no rows", () => { let inserts = 0 - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [] }), - insert: () => - Effect.try({ - try: () => { - inserts++ - }, - catch: WarehouseDriverError.fromUnknown, - }), - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [] }), + insert: () => + Effect.try({ + try: () => { + inserts++ + }, + catch: WarehouseDriverError.fromUnknown, + }), + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -718,16 +747,18 @@ describe("WarehouseQueryService.ingest writes through the SQL client", () => { // rows, not Maple's SQL, are what usually earned the rejection), so a // syntax-shaped complaint takes the caller-authored invalid-SQL tag. it.effect("maps a failed insert through the classifier", () => { - __testables.setClientFactory(() => Effect.succeed({ - sql: () => Effect.succeed({ data: [] }), - insert: () => - Effect.try({ - try: () => { - throw new Error("HTTP 400 Bad Request: DB::Exception: Syntax error") - }, - catch: WarehouseDriverError.fromUnknown, - }), - })) + __testables.setClientFactory(() => + Effect.succeed({ + sql: () => Effect.succeed({ data: [] }), + insert: () => + Effect.try({ + try: () => { + throw new Error("HTTP 400 Bad Request: DB::Exception: Syntax error") + }, + catch: WarehouseDriverError.fromUnknown, + }), + }), + ) const layer = buildLayer(createTestDb(trackedDbs)) const tenant = makeTenant() @@ -915,23 +946,24 @@ describe("ingest routes writes to the managed pipeline, not a per-org read overr const used: Array<{ op: "sql" | "insert"; kind: string }> = [] const purposes: Array = [] const executor = makeWarehouseExecutor({ - createClient: (config) => Effect.succeed({ - sql: () => - Effect.try({ - try: () => { - used.push({ op: "sql", kind: config.kind }) - return { data: [] } - }, - catch: warehouseDriverFailure, - }), - insert: () => - Effect.try({ - try: () => { - used.push({ op: "insert", kind: config.kind }) - }, - catch: WarehouseDriverError.fromUnknown, - }), - }), + createClient: (config) => + Effect.succeed({ + sql: () => + Effect.try({ + try: () => { + used.push({ op: "sql", kind: config.kind }) + return { data: [] } + }, + catch: warehouseDriverFailure, + }), + insert: () => + Effect.try({ + try: () => { + used.push({ op: "insert", kind: config.kind }) + }, + catch: WarehouseDriverError.fromUnknown, + }), + }), resolveRoute: (_tenant, purpose) => { purposes.push(purpose) return Effect.succeed( @@ -964,23 +996,25 @@ describe("ingest pins writes to Tinybird even when CLICKHOUSE_URL makes managed // resolver (which prefers ClickHouse) is what kept demo-seed onboarding broken. it.effect("reads resolve to managed ClickHouse, but ingest resolves to Tinybird", () => { const used: Array<{ op: "sql" | "insert"; kind: string }> = [] - __testables.setClientFactory((config) => Effect.succeed({ - sql: () => - Effect.try({ - try: () => { - used.push({ op: "sql", kind: config.kind }) - return { data: [] } - }, - catch: warehouseDriverFailure, - }), - insert: () => - Effect.try({ - try: () => { - used.push({ op: "insert", kind: config.kind }) - }, - catch: WarehouseDriverError.fromUnknown, - }), - })) + __testables.setClientFactory((config) => + Effect.succeed({ + sql: () => + Effect.try({ + try: () => { + used.push({ op: "sql", kind: config.kind }) + return { data: [] } + }, + catch: warehouseDriverFailure, + }), + insert: () => + Effect.try({ + try: () => { + used.push({ op: "insert", kind: config.kind }) + }, + catch: WarehouseDriverError.fromUnknown, + }), + }), + ) const layer = buildLayer(createTestDb(trackedDbs), { CLICKHOUSE_URL: "https://readonly-ch.example.com", @@ -1126,7 +1160,10 @@ describe("BYO ClickHouse redirect refusal", () => { assert.match(driver.message, /redirect responses are not allowed \(307\)/) // The Location is kept as context, so a refusal is diagnosable. assert.instanceOf(driver.cause, ClickHouseHttp.ClickHouseRedirectError) - assert.strictEqual((driver.cause as ClickHouseHttp.ClickHouseRedirectError).location, "http://169.254.169.254/") + assert.strictEqual( + (driver.cause as ClickHouseHttp.ClickHouseRedirectError).location, + "http://169.254.169.254/", + ) // Exactly one request, and it opted out of automatic redirect following. assert.strictEqual(seen.length, 1) assert.strictEqual(seen[0]?.redirect, "manual") @@ -1259,7 +1296,8 @@ describe("warehouse driver Effect boundaries", () => { it.effect("ClickHouse reports a single oversized row as a row limit, not the total", () => Effect.gen(function* () { // No response limits: the native client's 16 MiB per-row default applies. - const request: typeof fetch = async () => new Response(`{"value":"${"x".repeat(16 * 1024 * 1024)}"}\n`) + const request: typeof fetch = async () => + new Response(`{"value":"${"x".repeat(16 * 1024 * 1024)}"}\n`) const error = yield* Effect.flip( makeClickHouseTestClient(chConfig, request).sql(parseStatement("SELECT 1 FORMAT JSON")), ) @@ -1306,7 +1344,8 @@ it.effect("the executor's query budget aborts the adapter request without retryi }) const config = { kind: "tinybird" as const, host: "https://api.tinybird.co", token: "token" } const executor = makeWarehouseExecutor({ - createClient: () => __testables.createTinybirdSqlClient(config).pipe(Effect.provide(httpWith(request))), + createClient: () => + __testables.createTinybirdSqlClient(config).pipe(Effect.provide(httpWith(request))), resolveRoute: () => Effect.succeed({ source: "managed" as const, config, clientCacheKey: "test" }), }) @@ -1381,7 +1420,8 @@ const httpWith = (request: typeof fetch) => const makeClickHouseTestClient = ( config: Parameters[0], requestFetch: typeof fetch = fetch, -) => Effect.runSync(__testables.createClickHouseSqlClient(config).pipe(Effect.provide(httpWith(requestFetch)))) +) => + Effect.runSync(__testables.createClickHouseSqlClient(config).pipe(Effect.provide(httpWith(requestFetch)))) const makeTinybirdTestClient = ( config: Parameters[0], diff --git a/apps/api/src/services/warehouse/WarehouseQueryService.ts b/apps/api/src/services/warehouse/WarehouseQueryService.ts index fcfb408c2..67785f24a 100644 --- a/apps/api/src/services/warehouse/WarehouseQueryService.ts +++ b/apps/api/src/services/warehouse/WarehouseQueryService.ts @@ -128,7 +128,10 @@ const createClickHouseSqlClient = ( // take and is far above anything a Maple query returns. sql: (statement, options) => client - .query({ sql: statement.text, ...(options?.responseLimits ? { limits: options.responseLimits } : undefined) }) + .query({ + sql: statement.text, + ...(options?.responseLimits ? { limits: options.responseLimits } : undefined), + }) .pipe( Effect.map(({ data }) => ({ data })), Effect.mapError(clickHouseDriverError), @@ -235,10 +238,14 @@ const createTinybirdSqlClient = ( Effect.map(HttpClient.HttpClient, (http): WarehouseSqlClient => { const base = config.host.replace(/\/$/, "") const token = Redacted.make(config.token) - const bodyOf = (response: { readonly stream: Stream.Stream }) => + const bodyOf = (response: { + readonly stream: Stream.Stream + }) => response.stream.pipe( Stream.catchTag("HttpClientError", (error) => - error.reason._tag === "EmptyBodyError" ? Stream.empty : Stream.fail(tinybirdTransportError(error)), + error.reason._tag === "EmptyBodyError" + ? Stream.empty + : Stream.fail(tinybirdTransportError(error)), ), ) // Mirrors the SDK's rendering: the JSON `error` field when there is one, @@ -249,7 +256,10 @@ const createTinybirdSqlClient = ( status, message: Option.getOrElse( Option.map(decodeTinybirdErrorBody(body), (decoded) => decoded.error), - () => (body ? `Request failed with status ${status}: ${body.slice(0, 500)}` : `Request failed with status ${status}`), + () => + body + ? `Request failed with status ${status}: ${body.slice(0, 500)}` + : `Request failed with status ${status}`, ), cause: body, }) diff --git a/apps/api/src/services/warehouse/ai-tools.clickhouse.e2e.test.ts b/apps/api/src/services/warehouse/ai-tools.clickhouse.e2e.test.ts index 76e5b7486..4f1053970 100644 --- a/apps/api/src/services/warehouse/ai-tools.clickhouse.e2e.test.ts +++ b/apps/api/src/services/warehouse/ai-tools.clickhouse.e2e.test.ts @@ -462,10 +462,7 @@ describe.skipIf(!clickhouseE2eEnabled)("agent tools reads", () => { bucketSeconds: 3_600, }) const modelRows = Effect.runSync(perModel.decodeRows(await runJson(perModel.sql))) - assert.deepStrictEqual( - [...modelRows].map((row) => row.seriesKey).sort(), - ["", CLAUDE, GPT], - ) + assert.deepStrictEqual([...modelRows].map((row) => row.seriesKey).sort(), ["", CLAUDE, GPT]) }) it("measures the window and the one before it in one read", async () => { @@ -523,7 +520,6 @@ describe.skipIf(!clickhouseE2eEnabled)("agent tools reads", () => { const totalRows = Effect.runSync(totals.decodeRows(await runJson(totals.sql))) const current = totalRows.find((row) => row.period === "current") assert.deepStrictEqual({ calls: current?.calls, errors: current?.errors }, { calls: 1, errors: 1 }) - }) it("selects by the model a tool call was attributed to, not by a column", async () => { diff --git a/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts b/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts index a5ef417cd..06f750bef 100644 --- a/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts +++ b/apps/api/src/services/warehouse/ai-trace-index-materialization.clickhouse.e2e.test.ts @@ -677,12 +677,23 @@ describe.skipIf(!clickhouseE2eEnabled)("ai_trace_index materialization", () => { // span's `5` on the same trace — its three traces, its seven agent spans // (the plain child is not in the index), and the agent spans' services. assert.deepStrictEqual( - [eve?.vendorId, eve?.vendorVersion, eve?.traceCount, eve?.spanCount, [...(eve?.serviceNames ?? [])].sort()], + [ + eve?.vendorId, + eve?.vendorVersion, + eve?.traceCount, + eve?.spanCount, + [...(eve?.serviceNames ?? [])].sort(), + ], ["eve", "1", 3, 7, ["agent-service", "openrouter"]], ) const sessionless = page.find((row) => row.sessionId !== SESSION_ID) assert.deepStrictEqual( - [sessionless?.vendorId, sessionless?.vendorVersion, sessionless?.traceCount, sessionless?.spanCount], + [ + sessionless?.vendorId, + sessionless?.vendorVersion, + sessionless?.traceCount, + sessionless?.spanCount, + ], ["vercel_ai_sdk", "", 1, 1], ) // The buckets off the index, deepest reporter counted like the total and diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 3ab8854c5..c20624a3a 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -18,25 +18,26 @@ import { CLOUDFLARE_WORKER_PLACEMENT, emailBinding, MapleStack, + AiWorker, SandboxWorker, type MapleStage, resolveWorkerName, } from "@maple/infra/cloudflare" import { WorkerTelemetry } from "@maple/infra/worker-telemetry" +import { + INVESTIGATION_FANOUT_BINDING, + type InvestigationFanoutWorkflowPayload, +} from "@maple/domain/investigation-fanout" import * as Cloudflare from "alchemy/Cloudflare" import * as AlchemyTelemetry from "alchemy/Telemetry" import { Context, Effect, Layer, Option } from "effect" -import ChatSessionObject from "./chat/ChatSession" import { ApiObservabilityLive } from "./http/api-observability" -import { MCP_ANTICIPATED_ERROR_IDENTIFIERS } from "./mcp/expected-failures" import { apiConfiguredEnv } from "./resources/env" import { ApiBindingLayers, apiPorts, bindApiClients } from "./worker/bindings" import { registerQueueConsumers } from "./worker/consumers" import { registerCrons } from "./worker/crons" import { buildApp, makeFetch } from "./worker/http" -import { buildRpcServices, makeInternalRpc } from "./worker/rpc" import ClickHouseSchemaApplyWorkflow from "./workflows/ClickHouseSchemaApplyWorkflow" -import InvestigationFanoutWorkflow from "./workflows/InvestigationFanoutWorkflow" /** * The bindings that stay declared on `env`. Everything the services reach at @@ -50,8 +51,23 @@ const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ // for this resource. Deployed stages only: the gateway has no local emulation, // so declaring it under `alchemy dev` diffs it against Cloudflare and demands // an `alchemy login`; without the binding the Llm shim is a no-op. - ...(stage.kind === "dev" ? undefined : { AI: Cloudflare.AI.Gateway("maple-api-ai") }), ...emailBinding(stage), + // The two classes maple-ai now hosts, bound cross-script under their CLASS + // names — which is what `chatSessionStub` and `INVESTIGATION_FANOUT_BINDING` + // read off `env`. `resolveWorkerName` rather than the yielded Worker's output + // on purpose: consuming the output would make api's deploy wait on ai's, and + // these are reference-only bindings that need no such ordering. + ChatSession: Cloudflare.DurableObject("ChatSession", { + className: "ChatSession", + scriptName: resolveWorkerName("ai", stage), + }), + [INVESTIGATION_FANOUT_BINDING]: Cloudflare.Workflow( + INVESTIGATION_FANOUT_BINDING, + { + className: INVESTIGATION_FANOUT_BINDING, + scriptName: resolveWorkerName("ai", stage), + }, + ), }) /** @@ -67,6 +83,9 @@ const props = Effect.gen(function* () { // the stages that do not deploy it, where `SandboxClient` reports the tools // as unavailable rather than failing. const sandbox = yield* Effect.serviceOption(SandboxWorker) + // maple-ai, which serves `/mcp` and the chat surface. api keeps the hostname + // and forwards, so the public address and the OAuth identity do not move. + const ai = yield* AiWorker // Resolved before any resource is created, so a misconfigured deploy fails // with the full list of missing vars rather than part-way through applying. const configuredEnv = yield* apiConfiguredEnv(stage, domains) @@ -98,6 +117,7 @@ const props = Effect.gen(function* () { env: { ...makeWorkerBindings({ stage }), ...(Option.isSome(sandbox) ? { SANDBOX: sandbox.value } : undefined), + AI_WORKER: ai, ...configuredEnv, ...devEnv, }, @@ -111,9 +131,7 @@ export default class MapleApi extends Cloudflare.Worker()( // The Durable Object and the Workflows this Worker hosts: yielding each // binds it under the class name, registers it at plan time and exports // the class from the generated entry. - yield* ChatSessionObject yield* ClickHouseSchemaApplyWorkflow - yield* InvestigationFanoutWorkflow const clients = yield* bindApiClients const ports = apiPorts(clients, yield* Cloudflare.WorkerEnvironment) // The service graphs are built on the first event, not here: init also @@ -128,10 +146,9 @@ export default class MapleApi extends Cloudflare.Worker()( Layer.CurrentMemoMap, )(yield* Effect.context()) const app = yield* cachedRecoverable(buildApp(isolate, ports)) - const rpcServices = yield* cachedRecoverable(buildRpcServices(isolate, ports)) yield* registerCrons(ports) yield* registerQueueConsumers(ports) - return { fetch: makeFetch(app, ports), ...makeInternalRpc(rpcServices, ports) } + return { fetch: makeFetch(app, ports) } }).pipe( // The init IS the entry point: the cron and queue sources need the host // Worker, which exists only here. @@ -144,7 +161,6 @@ export default class MapleApi extends Cloudflare.Worker()( WorkerTelemetry({ serviceName: "maple-api", 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. diff --git a/apps/api/src/worker/bindings.ts b/apps/api/src/worker/bindings.ts index bec396c06..f520f1c9d 100644 --- a/apps/api/src/worker/bindings.ts +++ b/apps/api/src/worker/bindings.ts @@ -16,7 +16,6 @@ import { AuditEventsQueueProducer, CliAuthRateLimit, McpOAuthRateLimit, - McpToolsRateLimit, type ObjectStore, ObjectStoreError, PlanetScaleWebhookQueueProducer, @@ -32,10 +31,6 @@ import { API_V2_RATE_LIMIT_PERIOD_SECONDS, API_V2_RATE_LIMIT_REQUESTS, } from "../services/auth/ApiV2RateLimiter" -import { - MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS, - MCP_TOOLS_RATE_LIMIT_REQUESTS, -} from "../services/auth/McpToolRateLimiter" import { AuditEventsQueue, PlanetScaleWebhookQueue, VcsSyncQueue } from "../resources/queues" import { ReplayBlobs } from "../resources/replay-blobs" @@ -64,12 +59,6 @@ export const bindApiClients = Effect.gen(function* () { namespaceId: 2026072102, simple: { limit: 60, period: 60 }, }), - // Authenticated POST /mcp, per credential. A short window so a runaway - // agent loop is cut off in seconds, at twice the v2 API's throughput. - mcpToolsRateLimit: yield* Cloudflare.RateLimit("MCP_TOOLS_RATE_LIMITER", { - namespaceId: 2026082901, - simple: { limit: MCP_TOOLS_RATE_LIMIT_REQUESTS, period: MCP_TOOLS_RATE_LIMIT_PERIOD_SECONDS }, - }), } }) @@ -149,7 +138,6 @@ export const apiPorts = (clients: ApiBindingClients, env: Record) => isolate)) -/** The route graph as the bridge's handler, built for the isolate. */ -export const buildIsolateHandler = ( +/** + * The route graph as the bridge's handler, built for the isolate. + * + * The load-bearing parameter is the third: the graph may require nothing from + * the request context beyond the router and its own markers, so a service a + * handler reads per request fails the build naming itself instead of failing + * every request with "Service not found". + * + * The output parameter is deliberately open. The composed graph surfaces the + * service layers it was provided, and pinning it to `never` only ever appeared + * to hold: until the MCP routes moved out, `McpLive` widened the whole + * composition to `any` and the constraint was satisfied vacuously. + */ +export const buildIsolateHandler = ( isolate: Context.Context, routes: Layer.Layer< - never, + ROut, E, HttpRouter.HttpRouter | HttpRouter.Request<"Error" | "GlobalError" | "Requires", unknown> >, @@ -109,6 +121,16 @@ const bridgeHandler = ( >, ): HttpEffect => handler as HttpEffect +/** + * 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. + */ +const forwardsToAi = (path: string): boolean => + path === "/mcp" || + path.startsWith("/mcp/") || + path.startsWith("/api/chat/") || + path.startsWith("/internal/chat/") + const pathOf = (url: string): string => { const query = url.indexOf("?") return query === -1 ? url : url.slice(0, query) @@ -189,7 +211,31 @@ export const makeFetch = (app: Effect.Effect, ports: Layer. } if (request.method === "OPTIONS") return HttpServerResponse.fromWeb(apiCorsPreflightResponse()) - const isMcp = request.method === "POST" && path === "/mcp" + // The agent surfaces moved to maple-ai; this origin keeps serving them. + // Ahead of the route graph on purpose — that is the whole point of the + // split, so a `/mcp` call no longer builds `AllRoutes` and `ApiAuthLive`. + // + // The forward must stay byte-transparent: the same method, the original + // `Host` (which is what keeps `/mcp`'s OAuth `resource_metadata` pointing + // at this origin's well-known), every 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. + if (forwardsToAi(path)) { + const aiWorker = (yield* Cloudflare.WorkerEnvironment).AI_WORKER + if (aiWorker === undefined) { + yield* Effect.logError("AI worker binding is missing").pipe( + Effect.annotateLogs({ method: request.method, path }), + ) + return HttpServerResponse.text("maple-ai is unavailable", { + status: 503, + headers: API_CORS_RESPONSE_HEADERS, + }) + } + return yield* Cloudflare.fromCloudflareFetcher( + aiWorker as Parameters[0], + ).fetch(request) + } + const startedAt = yield* Clock.currentTimeMillis firstRequestAt ??= startedAt const ordinal = ++served @@ -210,17 +256,6 @@ export const makeFetch = (app: Effect.Effect, ports: Layer. yield* recordIsolateAge({ ageMs: startedAt - firstRequestAt, ordinal }) } - if (isMcp) { - // The transport is stateless, so there is no session to carry across - // requests and nothing to write back — see `mcp/transport/stateless-http.ts`. - const now = yield* Clock.currentTimeMillis - yield* Effect.logInfo("MCP request handled").pipe( - Effect.annotateLogs({ - "http.response.status_code": response.status, - duration_ms: now - startedAt, - }), - ) - } return response }).pipe( // oxlint-disable-next-line effecttsgo/strict-effect-provide -- the request IS the boundary the ports belong to. diff --git a/apps/api/src/worker/modules.ts b/apps/api/src/worker/modules.ts index 445ff2803..efc803ef8 100644 --- a/apps/api/src/worker/modules.ts +++ b/apps/api/src/worker/modules.ts @@ -11,7 +11,6 @@ */ import { Effect } from "effect" -export const rpcModule = Effect.promise(() => import("../internal-rpc")) export const vcsSyncModule = Effect.promise(() => import("../vcs-sync-runtime")) export const planetScaleWebhookModule = Effect.promise(() => import("../planetscale-webhook-runtime")) export const auditEventsModule = Effect.promise(() => import("../audit-events-runtime")) diff --git a/apps/api/src/worker/rpc.ts b/apps/api/src/worker/rpc.ts deleted file mode 100644 index dd1a0a211..000000000 --- a/apps/api/src/worker/rpc.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * The api Worker's internal RPC surface, over a service binding: the MCP tool - * catalog and executor for the alerting Worker, and diagnosis submission. RPC - * has no HttpApi request to construct the application services for it, so it - * gets a sibling isolate-wide service graph — the headless one the MCP tools - * run on. The bridge envelopes a typed failure for the caller's `toRpcAsync` - * and throws a defect as-is; one Postgres socket per call, released with it. - */ -import type { MapleApiRpcContract } from "@maple/domain/internal-rpc" -import { type Context, Effect, Layer } from "effect" -import type { MapleDbConnection } from "../platform/bindings" -import { layerPg } from "../platform/DatabasePgLive" -import { withPgConnectionScope } from "../platform/pg-connection-scope" -import type { ApiPortsLayer } from "./bindings" -import { forIsolate } from "./http" -import { rpcModule } from "./modules" - -/** The headless service graph, built once per isolate on the first RPC call. */ -export const buildRpcServices = (isolate: Context.Context, ports: ApiPortsLayer) => - Effect.gen(function* () { - const { InvestigationServicesLive } = yield* Effect.promise( - () => import("../runtime/mcp-service-graph"), - ) - return yield* forIsolate(isolate)( - Layer.build(InvestigationServicesLive.pipe(Layer.provideMerge(layerPg), Layer.provide(ports))), - ) - }) - -type RpcServices = Effect.Success> - -/** The RPC methods over the cached service graph, as the init returns them beside `fetch`. */ -export const makeInternalRpc = ( - rpcServices: Effect.Effect, - ports: Layer.Layer, -) => { - const runRpc = (program: Effect.Effect) => - Effect.flatMap(rpcServices.pipe(Effect.orDie), (services) => - withPgConnectionScope(program).pipe( - Effect.provideContext(services), - // oxlint-disable-next-line effecttsgo/strict-effect-provide -- the call IS the boundary the ports belong to. - Effect.provide(ports), - ), - ) - return { - listMcpTools: () => Effect.flatMap(rpcModule, ({ listMcpToolsRpc }) => runRpc(listMcpToolsRpc)), - callMcpTool: (input: unknown) => - Effect.flatMap(rpcModule, ({ callMcpToolRpc }) => runRpc(callMcpToolRpc(input))), - submitDiagnosis: (input: unknown) => - Effect.flatMap(rpcModule, ({ submitDiagnosisRpc }) => runRpc(submitDiagnosisRpc(input))), - } satisfies MapleApiRpcContract -} diff --git a/apps/api/src/workflows/durable-step.test.ts b/apps/api/src/workflows/durable-step.test.ts index 0f0d85811..585090b96 100644 --- a/apps/api/src/workflows/durable-step.test.ts +++ b/apps/api/src/workflows/durable-step.test.ts @@ -36,9 +36,7 @@ describe("durableStep", () => { Effect.provideService(Cloudflare.WorkflowStep, recordingStep(recorded)), ) assert.strictEqual(value, 42) - assert.deepStrictEqual(recorded, [ - { name: "claim", retries: undefined, timeout: "10 minutes" }, - ]) + assert.deepStrictEqual(recorded, [{ name: "claim", retries: undefined, timeout: "10 minutes" }]) }), ) diff --git a/apps/web/src/components/chat/chat-conversation.tsx b/apps/web/src/components/chat/chat-conversation.tsx index f9e6ecfd8..96be95d28 100644 --- a/apps/web/src/components/chat/chat-conversation.tsx +++ b/apps/web/src/components/chat/chat-conversation.tsx @@ -3,7 +3,7 @@ import { Exit } from "effect" import { useMountEffect } from "@/hooks/use-mount-effect" import { toastManager } from "@maple/ui/components/ui/toast" import { useAtomSet } from "@/lib/effect-atom" -import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" +import { MapleAiAtomClient } from "@/lib/services/common/ai-atom-client" import { useMapleChat, type FailedSend } from "@/hooks/use-maple-chat" import { useTypeAnywhereFocus } from "@/hooks/use-type-anywhere-focus" import { @@ -169,7 +169,7 @@ export function ChatConversation({ const diagnosisMessageId = useMemo(() => findDiagnosisMessageId(messages), [messages]) // Apply an approved proposal via Maple's authenticated API (propose-then-apply). - const applyProposal = useAtomSet(MapleInternalAtomClient.mutation("chat", "apply"), { + const applyProposal = useAtomSet(MapleAiAtomClient.mutation("chat", "apply"), { mode: "promiseExit", }) const [resolvedApprovals, setResolvedApprovals] = useState>( diff --git a/apps/web/src/lib/agent-sessions/session-transcript.test.ts b/apps/web/src/lib/agent-sessions/session-transcript.test.ts index 9a7bbea32..968421dff 100644 --- a/apps/web/src/lib/agent-sessions/session-transcript.test.ts +++ b/apps/web/src/lib/agent-sessions/session-transcript.test.ts @@ -1988,8 +1988,8 @@ describe("prepare / assemble", () => { buildTranscript({ ...read, collapsedTurns, hasMore: false }), ) } - expect(assembleTranscript(prepared, { collapsedTurns: collapsed, hasMore: false }).length).toBeLessThan( - assembleTranscript(prepared, { collapsedTurns: open, hasMore: false }).length, - ) + expect( + assembleTranscript(prepared, { collapsedTurns: collapsed, hasMore: false }).length, + ).toBeLessThan(assembleTranscript(prepared, { collapsedTurns: open, hasMore: false }).length) }) }) diff --git a/apps/web/src/lib/agent-sessions/session-window.test.ts b/apps/web/src/lib/agent-sessions/session-window.test.ts index 2047bbb8d..ade61fdb9 100644 --- a/apps/web/src/lib/agent-sessions/session-window.test.ts +++ b/apps/web/src/lib/agent-sessions/session-window.test.ts @@ -51,7 +51,10 @@ describe("sessionLinkWindow", () => { }) it("passes the true extent through once the row's details landed", () => { - expect(sessionLinkWindow({ ...row, hasDetails: true })).toEqual({ t: row.startTime, end: row.endTime }) + expect(sessionLinkWindow({ ...row, hasDetails: true })).toEqual({ + t: row.startTime, + end: row.endTime, + }) }) }) diff --git a/apps/web/src/lib/agent-sessions/tool-analytics.test.ts b/apps/web/src/lib/agent-sessions/tool-analytics.test.ts index 4760cd27c..2eb2d0a43 100644 --- a/apps/web/src/lib/agent-sessions/tool-analytics.test.ts +++ b/apps/web/src/lib/agent-sessions/tool-analytics.test.ts @@ -219,10 +219,7 @@ describe("scopeSummary", () => { describe("metricSpark", () => { it("reads the selected metric off each bucket, in bucket order", () => { - const points = [ - point(2, "a", { calls: 10, errors: 5 }), - point(1, "a", { calls: 20, errors: 2 }), - ] + const points = [point(2, "a", { calls: 10, errors: 5 }), point(1, "a", { calls: 20, errors: 2 })] expect(metricSpark(points, "calls", "p90")).toEqual([20, 10]) expect(metricSpark(points, "error_rate", "p90")).toEqual([0.1, 0.5]) }) diff --git a/apps/web/src/lib/agent-sessions/tool-analytics.ts b/apps/web/src/lib/agent-sessions/tool-analytics.ts index aa5d618c0..4528b84fe 100644 --- a/apps/web/src/lib/agent-sessions/tool-analytics.ts +++ b/apps/web/src/lib/agent-sessions/tool-analytics.ts @@ -166,11 +166,7 @@ export function toolSeriesMode(tool: string | undefined, model: string | undefin * a different statement from "nothing ran" — so it reads 0 and the formatters * are what decide how that prints. */ -export function metricValue( - measures: ToolMeasures, - metric: ToolMetric, - percentile: ToolPercentile, -): number { +export function metricValue(measures: ToolMeasures, metric: ToolMetric, percentile: ToolPercentile): number { switch (metric) { case "calls": return measures.calls @@ -239,8 +235,7 @@ export function toolMetricLabel(metric: ToolMetric, percentile: ToolPercentile): /** True when a rise in this metric is bad news — every metric here but the * counts. Read only by {@link toolDelta}, which is the one thing that grades a * move on this page. */ -const metricRiseIsBad = (metric: ToolMetric): boolean => - metric === "error_rate" || metric === "duration" +const metricRiseIsBad = (metric: ToolMetric): boolean => metric === "error_rate" || metric === "duration" /* ------------------------------------------------------------------------------------------------- * Series colours @@ -310,9 +305,7 @@ export function rankSeriesKeys(points: ReadonlyArray): Readonly for (const point of points) { calls.set(point.seriesKey, (calls.get(point.seriesKey) ?? 0) + point.calls) } - return [...calls.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .map(([key]) => key) + return [...calls.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key]) => key) } /* ------------------------------------------------------------------------------------------------- diff --git a/apps/web/src/lib/agent-sessions/use-tool-analytics.ts b/apps/web/src/lib/agent-sessions/use-tool-analytics.ts index 0e35ecd39..629b81f85 100644 --- a/apps/web/src/lib/agent-sessions/use-tool-analytics.ts +++ b/apps/web/src/lib/agent-sessions/use-tool-analytics.ts @@ -54,10 +54,7 @@ export interface ToolAnalyticsResults { }, QueryAtomFailure > - readonly breakdowns: Result.Result< - { tools: ReadonlyArray }, - QueryAtomFailure - > + readonly breakdowns: Result.Result<{ tools: ReadonlyArray }, QueryAtomFailure> } export interface ToolAnalyticsWindow { @@ -99,9 +96,7 @@ export function useToolAnalytics( const selection = useMemo(() => toolAnalyticsSelection(search, window), [search, window]) const bucketSeconds = chartBucketSeconds(window.startTime, window.endTime) - const series = useRefreshableAtomValue( - aiToolSeriesResultAtom({ data: { ...selection, bucketSeconds } }), - ) + const series = useRefreshableAtomValue(aiToolSeriesResultAtom({ data: { ...selection, bucketSeconds } })) const scopeSeries = useRefreshableAtomValue( aiToolSeriesResultAtom({ data: { ...selection, bucketSeconds, split: "none" as const } }), ) @@ -128,10 +123,7 @@ export function useAgentSessionsTabCounts(window: ToolAnalyticsWindow): { tools?: number } { const { startTime, endTime } = window - const selection = useMemo( - () => toolAnalyticsSelection({}, { startTime, endTime }), - [startTime, endTime], - ) + const selection = useMemo(() => toolAnalyticsSelection({}, { startTime, endTime }), [startTime, endTime]) const totals = useAtomValue(aiToolTotalsResultAtom({ data: selection })) const breakdowns = useAtomValue(aiToolBreakdownsResultAtom({ data: selection })) return { diff --git a/apps/web/src/lib/registry.ts b/apps/web/src/lib/registry.ts index d2f4c4328..8237a223a 100644 --- a/apps/web/src/lib/registry.ts +++ b/apps/web/src/lib/registry.ts @@ -4,6 +4,7 @@ import { AtomRegistry } from "effect/unstable/reactivity" import { MapleApiAtomClient } from "./services/common/atom-client" import { MapleFetchHttpClientLive } from "./services/common/http-client" import { mapleOtelLayer } from "./services/common/otel-layer" +import { MapleAiAtomClient } from "./services/common/ai-atom-client" import { MapleInternalAtomClient } from "./services/common/internal-atom-client" import { MapleApiV2AtomClient } from "./services/common/v2-atom-client" import { makeAppRuntime } from "./make-app-runtime" @@ -26,6 +27,7 @@ export const sharedAtomRuntime = MapleApiAtomClient.runtime appRegistry.mount(sharedAtomRuntime) appRegistry.mount(MapleApiV2AtomClient.runtime) appRegistry.mount(MapleInternalAtomClient.runtime) +appRegistry.mount(MapleAiAtomClient.runtime) // Extract the typed layer from the AtomRuntime for imperative Effect.provide() usage export const mapleApiClientLayer: Layer.Layer = appRegistry.get( @@ -40,6 +42,10 @@ export const mapleInternalClientLayer: Layer.Layer = ap MapleInternalAtomClient.runtime.layer, ) +export const mapleAiClientLayer: Layer.Layer = appRegistry.get( + MapleAiAtomClient.runtime.layer, +) + // One persistent ManagedRuntime built from both typed API layers, shared by every // imperative (non-React) Effect run, including `runMapleApiV2` collection writes. // Building it once avoids rebuilding the client layers on every call and gives the diff --git a/apps/web/src/lib/services/common/ai-atom-client.ts b/apps/web/src/lib/services/common/ai-atom-client.ts new file mode 100644 index 000000000..cb8fa03c6 --- /dev/null +++ b/apps/web/src/lib/services/common/ai-atom-client.ts @@ -0,0 +1,29 @@ +import { AtomHttpApi } from "@/lib/effect-atom" +import { MapleAiApi } from "@maple/domain/http" +import { apiBaseUrl } from "./api-base-url" +import { transformMapleApiClient } from "./api-client-transform" +import { MapleFetchHttpClientLive } from "./http-client" + +/** + * Client for the agent Worker's private transport. + * + * Same origin and same `apiBaseUrl` as the internal client, because the api + * still owns the hostname and forwards these paths to `maple-ai` over a service + * binding — the split is which Worker answers, not which host the dashboard + * calls. That also means `mapleFetch`'s URL scoping still attaches the Clerk + * JWT, and `MapleFetchHttpClientLive` is passed through untouched for the reason + * spelled out in `internal-atom-client.ts`. + * + * Separate from `MapleInternalAtomClient` because an `HttpApi` must be + * implemented in full by whoever builds it, so the chat group could not stay in + * `MapleInternalApi` once its handlers moved Workers. + */ +export class MapleAiAtomClient extends AtomHttpApi.Service()( + "@maple/web/services/common/MapleAiAtomClient", + { + api: MapleAiApi, + httpClient: MapleFetchHttpClientLive, + baseUrl: apiBaseUrl, + transformClient: transformMapleApiClient, + }, +) {} diff --git a/bun.lock b/bun.lock index b19587e21..ce8e637f3 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,39 @@ "typescript": "catalog:tooling", }, }, + "apps/ai": { + "name": "@maple/ai", + "dependencies": { + "@effect-agent/capabilities": "0.1.0-beta.74", + "@effect-agent/core": "0.1.0-beta.74", + "@effect-agent/engine": "0.1.0-beta.74", + "@effect-agent/sandbox": "0.1.0-beta.74", + "@effect/ai-openai-compat": "catalog:effect", + "@effect/ai-openrouter": "catalog:effect", + "@maple/db": "workspace:*", + "@maple/domain": "workspace:*", + "@maple/infra": "workspace:*", + "@maple/query-engine": "workspace:*", + "@maple/query-model": "workspace:*", + "@maple/widgets": "workspace:*", + "drizzle-orm": "^0.45.1", + "effect": "catalog:effect", + }, + "devDependencies": { + "@ai-sdk/openai-compatible": "^2.0.48", + "@cloudflare/workers-types": "catalog:alchemy", + "@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", + "typescript": "catalog:tooling", + "vitest": "catalog:", + "vitest-evals": "^0.4.0", + }, + }, "apps/alerting": { "name": "@maple/alerting", "dependencies": { @@ -49,12 +82,7 @@ "@clerk/backend": "^3.16.12", "@distilled.cloud/cloudflare": "1.0.0-rc.6", "@distilled.cloud/core": "1.0.0-rc.6", - "@effect-agent/capabilities": "0.1.0-beta.74", - "@effect-agent/core": "0.1.0-beta.74", - "@effect-agent/engine": "0.1.0-beta.74", "@effect-agent/sandbox": "0.1.0-beta.74", - "@effect/ai-openai-compat": "catalog:effect", - "@effect/ai-openrouter": "catalog:effect", "@effect/platform-bun": "catalog:effect", "@maple-dev/effect-clickhouse": "0.1.0", "@maple-dev/effect-clickhouse-http": "workspace:*", @@ -77,18 +105,13 @@ "effect": "catalog:effect", }, "devDependencies": { - "@ai-sdk/openai-compatible": "^2.0.48", "@cloudflare/workers-types": "catalog:alchemy", - "@effect-agent/testing": "0.1.0-beta.74", "@effect/language-service": "catalog:effect", "@electric-sql/pglite": "^0.5.2", "@types/node": "catalog:tooling", - "ai": "^6.0.196", "atmn": "^1.1.17", - "gpt-tokenizer": "^3.0.1", "typescript": "catalog:tooling", "vitest": "catalog:", - "vitest-evals": "^0.4.0", "wrangler": "^4.118.0", }, }, @@ -1416,6 +1439,8 @@ "@maple-examples/effect-todo": ["@maple-examples/effect-todo@workspace:examples/effect-todo"], + "@maple/ai": ["@maple/ai@workspace:apps/ai"], + "@maple/ai-model-catalog": ["@maple/ai-model-catalog@workspace:lib/ai-model-catalog"], "@maple/alchemy-portless": ["@maple/alchemy-portless@workspace:lib/alchemy-portless"], diff --git a/docs/infra.md b/docs/infra.md index 2c33ee57e..2985732bf 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -8,6 +8,38 @@ readable and the incidents stay findable. If you are about to delete a comment in a stack file because "the history is in git" — put it here instead. Git blame does not survive a refactor of the line it annotates. +## The AI Worker (`maple-ai`) + +`apps/ai` hosts every agent surface: the MCP transport and its tools, the chat +`ChatSession` Durable Object, and the `InvestigationFanoutWorkflow`. `apps/api` +keeps the hostname and forwards `/mcp`, `/api/chat/*` and `/internal/chat/*` to +it over a service binding, so the OAuth issuer and the RFC 8707 resource +identifiers never move off api's origin. + +Measured before committing to the split (rolldown, unminified, same tree), +dropping the MCP registry, the chat routes and the two hosted classes from api: + +| | with AI | without | +| --- | --- | --- | +| worker bundle | 11.74 MB | 9.34 MB | +| bundle chunks | 85 | 50 | +| module evaluation | ~336 ms | ~278 ms | + +The per-request half is not in that table: a `/mcp` call no longer builds +`AllRoutes` and `ApiAuthLive`, and a `/v2` call no longer builds 47 tool schemas. +A 2026-09-08 attempt that moved only the transport measured 1.0%, which is what +moving the registry too is worth avoiding. + +Two things a future change here needs to know: + +- **The `ChatSession` class carries `transferredFrom: "api"`.** Dropping a + locally hosted Durable Object class while keeping a cross-script reference is + the shape that destroys a namespace, and alchemy refuses it before uploading. + The property is inert once a stage has transferred, so it stays. +- **The Workflow has no equivalent.** Moving `InvestigationFanoutWorkflow` to a + new script mints a new physical workflow and orphans in-flight runs, which sit + in `status='running'` until the stale watchdog or a manual sweep clears them. + ## Layout - `alchemy.run.ts` — the root stack. Provides `MapleStack` (stage, domains, public URLs, diff --git a/knip.json b/knip.json index ab40accc7..6d4ed260b 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,9 @@ }, // Single-module Workers: the root alchemy.run.ts imports src/worker.ts, and // alchemy bundles it, so it is the app's one entry. + "apps/ai": { + "entry": ["src/worker.ts"] + }, "apps/alerting": { "entry": ["src/worker.ts"] }, diff --git a/packages/domain/package.json b/packages/domain/package.json index 360560260..c12b48026 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -12,12 +12,13 @@ "./gen-ai": "./src/gen-ai.ts", "./glob": "./src/glob.ts", "./http": "./src/http/index.ts", + "./incident-context": "./src/incident-context.ts", "./http/v2": "./src/http/v2/index.ts", "./http/v2-worker-unavailable": "./src/http/v2/worker-unavailable.ts", - "./internal-rpc": "./src/internal-rpc.ts", "./investigation-fanout": "./src/investigation-fanout.ts", "./llm": "./src/llm.ts", "./mcp-manifest": "./src/mcp-manifest.ts", + "./mcp-tool-contract": "./src/mcp-tool-contract.ts", "./permission": "./src/permission.ts", "./primitives": "./src/primitives.ts", "./query-engine": "./src/query-engine.ts", @@ -39,7 +40,8 @@ "./where-clause": "./src/where-clause.ts", "./http/warehouse-errors": "./src/http/warehouse-errors.ts", "./chat-preamble": "./src/chat-preamble.ts", - "./chat-session": "./src/chat-session.ts" + "./chat-session": "./src/chat-session.ts", + "./chat-session-stub": "./src/chat-session-stub.ts" }, "scripts": { "gen:anticipated-errors": "bun scripts/gen-anticipated-errors.ts", diff --git a/apps/api/src/chat/session.ts b/packages/domain/src/chat-session-stub.ts similarity index 66% rename from apps/api/src/chat/session.ts rename to packages/domain/src/chat-session-stub.ts index 1cd9109dd..77c777d09 100644 --- a/apps/api/src/chat/session.ts +++ b/packages/domain/src/chat-session-stub.ts @@ -2,25 +2,23 @@ /** * Reaching a chat session's Durable Object. * - * This module is deliberately tiny and dependency-free: it is imported by `ai-triage-enqueue` and - * `InvestigationService`, which are themselves reachable from the MCP tool registry, so anything - * heavy here would close an import cycle back through the chat run graph. + * Separate from the wire contract because this is the one place the object is + * addressed rather than described, and addressing it means handling a namespace + * handle off a Worker env — a value nothing has parsed yet. * - * Starting a turn is now a single `beginTurn` call. Under Flue there were two very different paths - * into the same conversation — the browser POSTed to `/agents/maple-chat/:id` on the chat-flue - * Worker, and `InvestigationService` POSTed the *same* URL back over the `CHAT_FLUE` service - * binding with an internal service token, purely because the agent lived in another Worker. The - * turn now runs inside the Durable Object itself (see `ChatSession.beginTurn`), so both paths are - * one method call and neither has to keep the turn alive. + * Both Workers need it: the one that hosts the class, and `apps/api`, which holds + * a cross-script reference to it. Keeping the shape in one place is what makes + * that reference structurally safe. */ -import type { - ChatEvent, - ChatEventInput, - ChatMessage, - ChatTurnTenantEncoded, -} from "@maple/domain/chat-session" +import type { ChatEvent, ChatEventInput, ChatMessage, ChatTurnTenantEncoded } from "./chat-session" -/** The `ChatSession` Durable Object's RPC surface. Mirrors `./ChatSession.ts`. */ +/** + * The `ChatSession` Durable Object's RPC surface, and how to reach it off a Worker env. + * + * This lives in the domain rather than beside the object because both Workers need it: the one + * that hosts the class, and `apps/api`, which holds a cross-script reference to it. Keeping the + * shape in one place is what makes that reference structurally safe. + */ export interface ChatSessionStub { readonly cursor: () => Promise readonly running: () => Promise diff --git a/packages/domain/src/gen-ai.ts b/packages/domain/src/gen-ai.ts index 7a8b79ebc..727d929ac 100644 --- a/packages/domain/src/gen-ai.ts +++ b/packages/domain/src/gen-ai.ts @@ -86,11 +86,22 @@ export const MAPLE_NATIVE_TURN_ID_ATTR = "maple_ai.turn.id" // production data carries — into the four readings the product distinguishes. // Shared between the session summary query and the web's span classifier so an // "llm call" is the same span on the server and on the page. -export const AI_INFERENCE_OPERATIONS = ["chat", "generate_content", "text_completion", "fetch_response"] as const +export const AI_INFERENCE_OPERATIONS = [ + "chat", + "generate_content", + "text_completion", + "fetch_response", +] as const /** Inference-shaped work that is not a model turn: an embedding is never an "llm call". */ export const AI_RETRIEVAL_OPERATIONS = ["embeddings", "retrieval"] as const export const AI_TOOL_OPERATIONS = ["execute_tool"] as const -export const AI_AGENT_OPERATIONS = ["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"] as const +export const AI_AGENT_OPERATIONS = [ + "invoke_agent", + "create_agent", + "invoke_workflow", + "plan", + "agent_step", +] as const /** * Count of whole oldest messages dropped from `gen_ai.input.messages` to fit * the emitter's attribute budget. Write-only diagnostics: nothing decodes it, diff --git a/packages/domain/src/http/ai-api.ts b/packages/domain/src/http/ai-api.ts new file mode 100644 index 000000000..abbbf2cbf --- /dev/null +++ b/packages/domain/src/http/ai-api.ts @@ -0,0 +1,34 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { ChatApiGroup } from "./chat" +import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" + +/** + * What the AI Worker serves over HTTP. + * + * Separate from `MapleInternalApi` for one mechanical reason and one real one. + * The mechanical one: an `HttpApi` must have every declared group implemented by + * whoever builds it, so a group cannot straddle two Workers. The real one: this + * is now a different deployable with its own release cadence, and a contract + * that says so is easier to reason about than one that silently expects two + * scripts to stay in step. + * + * The paths are unchanged — `apps/api` forwards `/internal/chat/*` here over a + * service binding — so this is a change of which Worker answers, not of what the + * dashboard calls. The error envelope stays v1's for the same reason + * `MapleInternalApi` keeps it: `apps/web` already decodes it. + * + * The chat SSE routes are deliberately NOT here. They are a raw `HttpRouter`, + * because `HttpApi` cannot model an open `text/event-stream`. + */ +export class MapleAiApi extends HttpApi.make("MapleAiApi") + .add(ChatApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) + .annotateMerge( + OpenApi.annotations({ + title: "Maple AI API", + version: "1.0.0", + description: + "Private dashboard transport for the agent surfaces. Not public API, not documented, not stable — do not build against it.", + }), + ) {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index 0a065da22..2a2a7fff0 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -225,7 +225,8 @@ export class ListAiSessionDetailsRequest extends Schema.Class AI_SESSION_DETAILS_MAX_EXTENT_MS) return "the window is wider than any page's extent" + if (extentMs > AI_SESSION_DETAILS_MAX_EXTENT_MS) + return "the window is wider than any page's extent" return true }, { identifier: "DetailsWindowBounded" }, @@ -352,54 +353,63 @@ export class GetAiSessionSpansRequest extends Schema.Class` id Maple synthesizes for a GenAI trace that carries none - * (`MAPLE_AI_TRACE_SESSION_PREFIX`). The handler routes on the prefix and - * validates the trace id behind it; a prefixed id that is not one reads as a - * session nothing carries, which answers empty like any unknown id. - */ - sessionId: Schema.String.check(Schema.isMinLength(1)), - // Optional, and the two halves are read as a pair — supply both or neither. - // - // With a window the read is partition-pruned on both levels (detection and - // fan-out), which is the fast path every link from the list page takes: the - // row already knows the session's own bounds, so it hands them over. - // - // Without one the handler resolves the session's bounds from the id first and - // then runs the same pruned read. That resolve step is viable rather than - // reckless where the fan-out would not be: `traces` carries a - // `bloom_filter(0.01)` skip index over `mapValues(SpanAttributes)` for the id - // to prune with, and its TTL caps any scan at 30 days. It still costs an - // extra round trip and still degrades as an org's volume grows, so this is - // the exception path for hint-less deep links — a pasted id, an MCP answer — - // and not the default. The client is expected to write the bounds it got back - // into its URL, which makes the second load of any such link the direct one. - startTime: Schema.optionalKey(TinybirdDateTime), - endTime: Schema.optionalKey(TinybirdDateTime), - /** Defaults to `all`. */ - scope: Schema.optionalKey(AiSessionSpanScope), - /** Spans strictly after this position; absent for the first page. */ - after: Schema.optionalKey(AiSessionSpanCursor), - /** - * Read these traces of the session instead of resolving the session's - * traces — the per-turn read the detail page makes for a turn's `app` - * spans, where the turn already knows which traces it spans. Requires the - * window, which is what bounds the read; the session id is then only the - * span the request is annotated with. - */ - traceIds: Schema.optionalKey(Schema.Array(TraceIdHex).check(Schema.isMaxLength(AI_SESSION_SPANS_MAX_TRACE_IDS))), - /** Page size, at most `AI_SESSION_SPANS_MAX_SPANS` (the default). */ - limit: Schema.optionalKey( - Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: AI_SESSION_SPANS_MAX_SPANS })), - ), + /** + * The framework's own session id, verbatim — `maple_ai.session.id` — or the + * `trace:` id Maple synthesizes for a GenAI trace that carries none + * (`MAPLE_AI_TRACE_SESSION_PREFIX`). The handler routes on the prefix and + * validates the trace id behind it; a prefixed id that is not one reads as a + * session nothing carries, which answers empty like any unknown id. + */ + sessionId: Schema.String.check(Schema.isMinLength(1)), + // Optional, and the two halves are read as a pair — supply both or neither. + // + // With a window the read is partition-pruned on both levels (detection and + // fan-out), which is the fast path every link from the list page takes: the + // row already knows the session's own bounds, so it hands them over. + // + // Without one the handler resolves the session's bounds from the id first and + // then runs the same pruned read. That resolve step is viable rather than + // reckless where the fan-out would not be: `traces` carries a + // `bloom_filter(0.01)` skip index over `mapValues(SpanAttributes)` for the id + // to prune with, and its TTL caps any scan at 30 days. It still costs an + // extra round trip and still degrades as an org's volume grows, so this is + // the exception path for hint-less deep links — a pasted id, an MCP answer — + // and not the default. The client is expected to write the bounds it got back + // into its URL, which makes the second load of any such link the direct one. + startTime: Schema.optionalKey(TinybirdDateTime), + endTime: Schema.optionalKey(TinybirdDateTime), + /** Defaults to `all`. */ + scope: Schema.optionalKey(AiSessionSpanScope), + /** Spans strictly after this position; absent for the first page. */ + after: Schema.optionalKey(AiSessionSpanCursor), + /** + * Read these traces of the session instead of resolving the session's + * traces — the per-turn read the detail page makes for a turn's `app` + * spans, where the turn already knows which traces it spans. Requires the + * window, which is what bounds the read; the session id is then only the + * span the request is annotated with. + */ + traceIds: Schema.optionalKey( + Schema.Array(TraceIdHex).check(Schema.isMaxLength(AI_SESSION_SPANS_MAX_TRACE_IDS)), + ), + /** Page size, at most `AI_SESSION_SPANS_MAX_SPANS` (the default). */ + limit: Schema.optionalKey( + Schema.Number.check( + Schema.isInt(), + Schema.isBetween({ minimum: 1, maximum: AI_SESSION_SPANS_MAX_SPANS }), + ), + ), }).check( // The window is what bounds a trace-pinned read, and the session id // cannot stand in for it: resolving the SESSION's bounds for traces named // outright is a round trip that answers empty for a session nothing // carries. Checked here so the miss is a 400 rather than an empty page. Schema.makeFilter( - (request: { readonly traceIds?: readonly string[]; readonly startTime?: string; readonly endTime?: string }) => + (request: { + readonly traceIds?: readonly string[] + readonly startTime?: string + readonly endTime?: string + }) => request.traceIds === undefined || (request.startTime !== undefined && request.endTime !== undefined) || "traceIds requires startTime and endTime", diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index 0f06918a0..74fc65513 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -1,4 +1,5 @@ export * from "./api" +export * from "./ai-api" export * from "./internal-api" export * from "./ai-models" export * from "./ai-sessions" diff --git a/packages/domain/src/http/internal-api.ts b/packages/domain/src/http/internal-api.ts index 34e9ca61f..1785413d7 100644 --- a/packages/domain/src/http/internal-api.ts +++ b/packages/domain/src/http/internal-api.ts @@ -3,7 +3,6 @@ import { AiModelsInternalApiGroup } from "./ai-models" import { AiSessionsInternalApiGroup } from "./ai-sessions" import { AiTriageApiGroup } from "./ai-triage" import { BillingApiGroup } from "./billing" -import { ChatApiGroup } from "./chat" import { DemoApiGroup } from "./demo" import { DigestApiGroup } from "./digest" import { QueryEngineApiGroup } from "./query-engine" @@ -23,7 +22,7 @@ import { V1SchemaErrors, V1UnexpectedErrors } from "./v1-boundary" * to change with the UI — raw SQL, generic query documents, dashboard-builder * facet discovery, infrastructure drill-downs — plus the dashboard-only product * workflows (checkout and billing controls, digest subscriptions, demo seeding, - * AI-triage settings, applying an approval-gated chat proposal) that were never + * AI-triage settings) that were never * public API and only ever lived under `/api` because that was the one HttpApi * at the time. Nothing here is a stable * public contract, and nothing here should be promoted to `/v2` without a @@ -41,7 +40,6 @@ export class MapleInternalApi extends HttpApi.make("MapleInternalApi") .add(AiSessionsInternalApiGroup) .add(AiTriageApiGroup) .add(BillingApiGroup) - .add(ChatApiGroup) .add(DemoApiGroup) .add(DigestApiGroup) .add(QueryEngineApiGroup) diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 75ab8723e..d0425de8c 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -134,7 +134,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/ingest_keys", "GET /v2/instrumentation/audit", "GET /v2/instrumentation/recommendations", - "GET /v2/instrumentation/signals", + "GET /v2/instrumentation/signals", "GET /v2/integrations/planetscale", "GET /v2/integrations/planetscale/databases", "GET /v2/integrations/planetscale/organizations", diff --git a/apps/api/src/workflows/incident-context.ts b/packages/domain/src/incident-context.ts similarity index 99% rename from apps/api/src/workflows/incident-context.ts rename to packages/domain/src/incident-context.ts index 2eb859e1c..8dc37108b 100644 --- a/apps/api/src/workflows/incident-context.ts +++ b/packages/domain/src/incident-context.ts @@ -17,7 +17,7 @@ * and every hypothesis lane — because a lane that saw a different framing of the * same incident than the planner did is a very expensive way to disagree. */ -import type { InvestigationSubject, InvestigationSubjectSnapshot } from "@maple/domain/http" +import type { InvestigationSubject, InvestigationSubjectSnapshot } from "./http" /** * The instruction on a single-pass investigation's opening turn. diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index a4b206f01..640cc1b33 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -1,6 +1,6 @@ export * from "./glob" export * from "./http" -export * from "./internal-rpc" +export * from "./mcp-tool-contract" export * from "./mcp-structured-types" export * from "./primitives" export * from "./query-engine" diff --git a/packages/domain/src/internal-rpc.ts b/packages/domain/src/internal-rpc.ts deleted file mode 100644 index 33af149f6..000000000 --- a/packages/domain/src/internal-rpc.ts +++ /dev/null @@ -1,79 +0,0 @@ -// BOUNDARY: This module owns unparsed external values and narrows them before domain use. -import type * as Effect from "effect/Effect" -import { Schema } from "effect" -import type { - InvestigationDataCorruptionError, - InvestigationDocument, - InvestigationNotFoundError, - InvestigationPersistenceError, -} from "./http/investigations" -import { AiTriageResult } from "./http/ai-triage" -import { InvestigationId, OrgId } from "./primitives" - -const NonEmptyString = Schema.String.pipe(Schema.check(Schema.isMinLength(1), Schema.isTrimmed())) - -/** Runtime-validated arguments for an internal MCP tool call. */ -export class CallMcpToolRpcRequest extends Schema.Class("CallMcpToolRpcRequest")({ - orgId: OrgId, - name: NonEmptyString, - input: Schema.Unknown, -}) {} - -/** Runtime-validated structured diagnosis submitted by an investigation agent's `submit_diagnosis`. */ -export class SubmitDiagnosisRpcRequest extends Schema.Class( - "SubmitDiagnosisRpcRequest", -)({ - orgId: OrgId, - investigationId: InvestigationId, - report: AiTriageResult, -}) {} - -export interface InternalMcpToolDescriptor { - readonly name: string - readonly description: string - readonly inputSchema: Record -} - -export interface InternalMcpToolResult { - readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }> - readonly isError?: boolean -} - -export class InternalRpcInvalidInputError extends Schema.TaggedError()( - "@maple/internal-rpc/InvalidInputError", - { - method: Schema.Literals(["callMcpTool", "submitDiagnosis"]), - message: Schema.String, - }, -) {} - -export class InternalRpcToolNotFoundError extends Schema.TaggedError()( - "@maple/internal-rpc/ToolNotFoundError", - { - name: Schema.String, - message: Schema.String, - }, -) {} - -/** - * Alchemy schemaless RPC shape exposed by the Maple API Worker. - * - * The method parameters intentionally remain `unknown`: Cloudflare RPC does - * structured cloning, not validation, so the implementation must decode each - * request with the schemas above before using it. - */ -export interface MapleApiRpcContract { - readonly listMcpTools: () => Effect.Effect> - readonly callMcpTool: ( - request: unknown, - ) => Effect.Effect - readonly submitDiagnosis: ( - request: unknown, - ) => Effect.Effect< - InvestigationDocument, - | InternalRpcInvalidInputError - | InvestigationNotFoundError - | InvestigationPersistenceError - | InvestigationDataCorruptionError - > -} diff --git a/packages/domain/src/investigation-fanout.test.ts b/packages/domain/src/investigation-fanout.test.ts new file mode 100644 index 000000000..c8f44bcd1 --- /dev/null +++ b/packages/domain/src/investigation-fanout.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest" +import { widthFor } from "./investigation-fanout" + +describe("widthFor", () => { + /** + * A null severity is unclassified, not unimportant. Error incidents carry no + * severity until someone triages them, so treating null as the floor would give + * the highest-volume incident kind the thinnest investigations. + */ + it("treats an unclassified incident as medium, not as the minimum", () => { + expect(widthFor(null, "error")).toBe(4) + expect(widthFor("medium", "error")).toBe(4) + }) + + it("scales with severity", () => { + expect(widthFor("critical", "error")).toBe(5) + expect(widthFor("high", "error")).toBe(4) + expect(widthFor("low", "error")).toBe(3) + }) + + /** An anomaly is already a narrow claim about one signal. */ + it("caps anomalies below the others regardless of severity", () => { + expect(widthFor("critical", "anomaly")).toBe(3) + }) +}) diff --git a/packages/domain/src/investigation-fanout.ts b/packages/domain/src/investigation-fanout.ts index 216ab3589..12ec3b0f1 100644 --- a/packages/domain/src/investigation-fanout.ts +++ b/packages/domain/src/investigation-fanout.ts @@ -7,6 +7,8 @@ * investigations (`AlertsService`, `ErrorsService`, …) run in both Workers and * read the binding by this one name. */ +import type { IssueSeverity } from "./http/errors" + export const INVESTIGATION_FANOUT_BINDING = "InvestigationFanoutWorkflow" export interface InvestigationFanoutWorkflowPayload { @@ -29,3 +31,39 @@ export interface InvestigationFanoutWorkflowPayload { export interface InvestigationFanoutWorkflowResult { readonly status: "ranked" | "inconclusive" | "skipped" | "failed" } + +/** + * How many hypotheses a subject of this shape deserves. + * + * This is the surviving half of the old `fanoutSize` table. The half that is + * gone decided *whether* to fan out at all — that question no longer exists, and + * conflating the two is what let a medium-severity alert compute a width of five + * and dispatch zero. + * + * An anomaly is capped below the others because an anomaly is already a narrow + * claim about one signal; five angles on it mostly produces four polite + * negatives. A null severity reads as medium rather than as "minimum": an + * unclassified incident is unclassified, not unimportant, and treating it as the + * floor is how error incidents — which carry no severity until someone triages + * them — would get the thinnest investigations. + * + * It sits beside the payload rather than beside the planner because the callers + * that compute a width are the ones that *start* an investigation, and they do + * not otherwise know anything about how the workflow plans. + */ +export const widthFor = ( + severity: IssueSeverity | null | undefined, + incidentKind: string | undefined, +): number => { + if (incidentKind === "anomaly") return 3 + switch (severity) { + case "critical": + return 5 + case "high": + return 4 + case "low": + return 3 + default: + return 4 + } +} diff --git a/packages/domain/src/mcp-manifest.ts b/packages/domain/src/mcp-manifest.ts index cfb2d8c9b..e96817515 100644 --- a/packages/domain/src/mcp-manifest.ts +++ b/packages/domain/src/mcp-manifest.ts @@ -83,3 +83,25 @@ export const mapleMcpServerManifest = ({ ], } as const } + +/** + * Which entry point drove this tool call. + * + * The surfaces share one dispatcher, and until this existed none of them were + * distinguishable in telemetry: the public-vs-internal traffic split had to be + * inferred from the ratio of `tools/call` spans to executor spans. Required + * rather than defaulted, for the same reason `tenant` is — a caller that forgets + * it should not silently be counted as somebody else. + * + * It lives in the domain because the audit log records it, and the audit log is + * read by a Worker that does not itself run any of these surfaces. + */ +export type McpToolSurface = + /** The public MCP transport (`mcp/server.ts`). */ + | "mcp" + /** The in-process AI chat agent (`chat/turn-runner.ts`). */ + | "chat" + /** Agent workflow passes (`workflows/agent-pass.ts`). */ + | "workflow" + /** Retired worker-to-worker internal RPC. Kept so already-audited rows stay readable. */ + | "rpc" diff --git a/packages/domain/src/mcp-tool-contract.ts b/packages/domain/src/mcp-tool-contract.ts new file mode 100644 index 000000000..294738b69 --- /dev/null +++ b/packages/domain/src/mcp-tool-contract.ts @@ -0,0 +1,23 @@ +/** + * The two shapes a caller of Maple's MCP tool registry sees from outside it. + * + * These lived in an internal Worker-to-Worker RPC contract until that contract + * was deleted unused. What survived is the part that was never about RPC: a + * tool's advertised shape, and the one failure any surface can provoke by + * naming a tool that does not exist. + */ +import { Schema } from "effect" + +export interface McpToolDescriptor { + readonly name: string + readonly description: string + readonly inputSchema: Record +} + +export class McpToolNotFoundError extends Schema.TaggedError()( + "@maple/mcp/ToolNotFoundError", + { + name: Schema.String, + message: Schema.String, + }, +) {} diff --git a/packages/infra/src/cloudflare/stack.ts b/packages/infra/src/cloudflare/stack.ts index bcb765e5d..408475d60 100644 --- a/packages/infra/src/cloudflare/stack.ts +++ b/packages/infra/src/cloudflare/stack.ts @@ -55,6 +55,15 @@ export class SandboxWorker extends Context.Service()("@maple/infra/AiWorker") {} + /** * Props for a resource declared at module scope whose physical name is * stage-derived (`resolveWorkerName(base, stage)`): `make` receives that name diff --git a/packages/infra/src/cloudflare/stage.ts b/packages/infra/src/cloudflare/stage.ts index d9dce19d6..c96ed065f 100644 --- a/packages/infra/src/cloudflare/stage.ts +++ b/packages/infra/src/cloudflare/stage.ts @@ -177,7 +177,7 @@ export function stageDeploysSandbox(stage: MapleStage): boolean { } /** Which worker is binding `MAPLE_DB`. prd gives each its own Hyperdrive config — see docs/infra.md. */ -export type MapleDbConsumer = "api" | "alerting" +export type MapleDbConsumer = "api" | "ai" | "alerting" /** * Dashboard-managed Hyperdrive configs, bound by ID; deploys never see the @@ -190,6 +190,11 @@ export function resolveHyperdriveRefId(stage: MapleStage, consumer: MapleDbConsu case "prd": // Both target the PlanetScale `main` branch; their `origin_connection_limit`s // SUM against its `max_connections`. + // TODO(ai-worker): `ai` shares `maple-prd` until a dedicated + // `maple-ai-prd` config exists in the dashboard. It must be created + // before maple-ai serves prd traffic — the agents are the heaviest + // Postgres readers after alerting, and sharing api's pool is how the + // api's connections got starved before alerting got its own. return consumer === "alerting" ? "f473167201af4d2cae494f9989f1d742" // `maple-alerting-prd` : "ad4c487838594b89810b23e5fb14e129" // `maple-prd` diff --git a/packages/infra/src/dev-urls.ts b/packages/infra/src/dev-urls.ts index 9e9e120c8..337e7c647 100644 --- a/packages/infra/src/dev-urls.ts +++ b/packages/infra/src/dev-urls.ts @@ -1,6 +1,7 @@ /** Every app `bun dev` can run. */ export const DEV_APPS = [ "api", + "ai", "alerting", "electric-sync", "web", diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index 9ab6b72b1..bc304563a 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -369,6 +369,7 @@ describe("the prd revision lockstep the skew alert depends on", () => { // that does. expect([...PRD_LOCKSTEP_REVISION_SERVICES]).toStrictEqual([ "alerting", + "maple-ai", "electric-sync", "ingest", "maple-api", diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index cfb489283..ce683d3d5 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -240,11 +240,17 @@ export const selfObservabilityEnv = (stage: MapleStage): Config.Config