From 263786beaebfc81f8906817f1a5fad44995598f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 11 Sep 2026 14:49:44 +0200 Subject: [PATCH 01/12] feat(empty-states): tell "not wired up" apart from "quiet window" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every empty list view in Maple said the same sentence — "No traces found" — whether the user had never instrumented the signal, had instrumented it and picked a quiet time range, or had filtered everything out. Those three need opposite next steps, and nothing in the product could tell them apart, so nothing gave advice. Adds the signal that separates them, and the empty state that uses it. API — GET /v2/instrumentation/signals reports, per signal (traces, logs, metrics, sessions, product events), whether the org is sending it and when it last arrived. Traces, logs and metrics come from the hourly service_usage rollup rather than the raw tables, so it is cheap enough to call from anywhere. Three invariants the UI leans on: - It never fails. A warehouse outage returns 200 with every signal "unknown", following SetupAuditService's catchCause precedent. An empty state that 500s the page it was added to help would be worse than the bare string. - Every signal is always in the response. The union's branches are group-less, so an unsent signal reports count 0 rather than dropping out — a caller indexing by signal can treat a missing entry as a bug. - The window is 30 days, and the wire contract says absence means "not sending now", not "never sent". Web — SignalEmptyState resolves four branches: filters active (wins over everything, because the user narrowed the view themselves), never received (setup route plus what actually produces the signal), received but quiet (says when the last event arrived), and unreadable (states the fact, offers nothing — advising setup to someone already set up is worse than silence). All copy lives in one table so adding this to a page is picking a signal, not writing a sentence. Split into SignalEmptyStateView, which takes presence as a prop, plus a hook wrapper: anti-slop(no-module-mocking) rules out vi.mock, so the tests inject presence and render in a real memory router. Wired into the three worst offenders, each previously a bare string in a table cell: traces, logs and services. Logs keeps its own search and trace-scoped copy — a narrow question deserves its narrow answer — and only the fallback changed. Services lists services but is built on traces, so the heading uses the page's noun while the timestamp uses the signal's. Also registers the new builder in the benchmark catalog. QUERY_MODULES is an explicit map, so a builder missing from it slips past the coverage gate unnoticed. A new v2 API group breaks every shared test harness that builds the whole graph, hence the three test-support changes. Co-Authored-By: Claude Opus 5 --- .../routes/v2/config-resources.http.test.ts | 2 + .../src/routes/v2/setup-audit.http.test.ts | 4 + .../routes/v2/telemetry-signals.http.test.ts | 284 ++++++++++++++++++ .../src/routes/v2/telemetry-signals.http.ts | 38 +++ apps/api/src/routes/v2/v2-test-support.ts | 9 + apps/api/src/runtime/http-graph.ts | 2 + apps/api/src/runtime/service-graph.ts | 6 + .../src/services/org/SignalPresenceService.ts | 177 +++++++++++ .../common/signal-empty-state.test.tsx | 74 +++++ .../components/common/signal-empty-state.tsx | 218 ++++++++++++++ apps/web/src/components/logs/logs-table.tsx | 87 +++--- .../components/services/services-table.tsx | 14 +- .../src/components/traces/traces-table.tsx | 35 +-- apps/web/src/hooks/use-signal-presence.ts | 35 +++ .../src/lib/services/atoms/signal-atoms.ts | 20 ++ packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/index.ts | 1 + packages/domain/src/http/v2/openapi.test.ts | 1 + .../domain/src/http/v2/telemetry-signals.ts | 133 ++++++++ .../src/__sql_baseline__/catalog.sql | 55 ++++ .../query-engine/src/benchmark/builders.ts | 9 + .../src/benchmark/catalog.test.ts | 2 + packages/query-engine/src/ch/index.ts | 7 + .../src/ch/queries/signal-presence.test.ts | 87 ++++++ .../src/ch/queries/signal-presence.ts | 133 ++++++++ 25 files changed, 1372 insertions(+), 63 deletions(-) create mode 100644 apps/api/src/routes/v2/telemetry-signals.http.test.ts create mode 100644 apps/api/src/routes/v2/telemetry-signals.http.ts create mode 100644 apps/api/src/services/org/SignalPresenceService.ts create mode 100644 apps/web/src/components/common/signal-empty-state.test.tsx create mode 100644 apps/web/src/components/common/signal-empty-state.tsx create mode 100644 apps/web/src/hooks/use-signal-presence.ts create mode 100644 apps/web/src/lib/services/atoms/signal-atoms.ts create mode 100644 packages/domain/src/http/v2/telemetry-signals.ts create mode 100644 packages/query-engine/src/ch/queries/signal-presence.test.ts create mode 100644 packages/query-engine/src/ch/queries/signal-presence.ts diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 1daca7a6c..035c5f400 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -30,6 +30,7 @@ import { PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, SetupAuditServiceStubLayer, + SignalPresenceServiceStubLayer, TelemetryServiceStubsLayer, } from "./v2-test-support" import { compiledQueryOf } from "@maple/query-engine/execution" @@ -112,6 +113,7 @@ const makeHarness = () => { Layer.provide(AlertsServiceStubLayer), Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SetupAuditServiceStubLayer), + Layer.provide(SignalPresenceServiceStubLayer), Layer.provide(TelemetryServiceStubsLayer), // session_replays (in AllV2GroupLayersLive) needs the warehouse at the routes level. Layer.provide(warehouseLive), diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index e3c6a7247..90e58024c 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -22,6 +22,7 @@ import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" import { SetupAuditService } from "@/services/org/SetupAuditService" +import { SignalPresenceService } from "@/services/org/SignalPresenceService" import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, @@ -131,6 +132,9 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi = warehouseStub()) => { RecommendationIssueService.layer.pipe(Layer.provide(warehouseLive)), ScrapeTargetsService.layer.pipe(Layer.provide(planetScaleStubs)), SetupAuditService.layer.pipe(Layer.provide(warehouseLive)), + // Sibling group in `AllV2GroupLayersLive`; the stub bundle is deliberately + // unused here, so it needs its own (warehouse-only) layer. + SignalPresenceService.layer.pipe(Layer.provide(warehouseLive)), ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) const routes = HttpApiBuilder.layer(MapleApiV2).pipe( diff --git a/apps/api/src/routes/v2/telemetry-signals.http.test.ts b/apps/api/src/routes/v2/telemetry-signals.http.test.ts new file mode 100644 index 000000000..549809354 --- /dev/null +++ b/apps/api/src/routes/v2/telemetry-signals.http.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +import { Env } from "@/platform/Env" +import { ApiAuthorizationV2Layer } from "@/services/auth/ApiAuthorizationV2Layer" +import { AuditLogService } from "@/services/audit/AuditLogService" +import { ApiKeysService } from "@/services/org/ApiKeysService" +import { AuthService } from "@/services/auth/AuthService" +import { DashboardPersistenceService } from "@/services/dashboards/DashboardPersistenceService" +import { IngestAttributeMappingService } from "@/services/org/IngestAttributeMappingService" +import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" +import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" +import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" +import { SetupAuditService } from "@/services/org/SetupAuditService" +import { PlanetScaleDiscoveryService } from "@/services/integrations/PlanetScaleDiscoveryService" +import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService" +import { SharedDashboardService } from "@/services/dashboards/SharedDashboardService" +import { SignalPresenceService } from "@/services/org/SignalPresenceService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + makeWarehouseServiceStub, + Phase1ResourceStubsLayer, + PlanetScaleServiceStubsLayer, + SlackIntegrationServiceStubLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" +import { compiledQueryOf } from "@maple/query-engine/execution" + +/** + * Wire-contract tests for `GET /v2/instrumentation/signals`. The SQL shape is covered in + * packages/query-engine; what matters here is the promise the empty states depend on — every signal + * always present in the response, and a warehouse outage degrading to `unknown` with a 200 rather + * than failing the view that asked. + */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_signals_e2e") +const USER = Schema.decodeUnknownSync(UserId)("user_signals_e2e") + +const testConfig = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3496", + MCP_PORT: "3497", + 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_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +/** + * Rows are written the way ClickHouse actually returns them — 64-bit counts as strings — so this + * also proves the derived row schema decodes the BYO-ClickHouse wire shape. + */ +const warehouseStub = (rows: ReadonlyArray>): WarehouseQueryServiceApi => + makeWarehouseServiceStub({ + compiledQuery: (_tenant, compiled) => compiledQueryOf(compiled).decodeRows(rows).pipe(Effect.orDie), + warmRoute: () => Effect.void, + }) + +const unavailableWarehouse: WarehouseQueryServiceApi = makeWarehouseServiceStub({ + compiledQuery: () => Effect.die(new Error("warehouse unreachable")), + warmRoute: () => Effect.void, +}) + +const die = () => Effect.die(new Error("not available in this test harness")) + +/** `ScrapeTargetsService` reaches these for `planetscale` targets; nothing here does. */ +const planetScaleStubs = Layer.mergeAll( + Layer.succeed(PlanetScaleDiscoveryService, { + discover: die, + lastError: () => Effect.succeed(null), + invalidate: () => Effect.void, + }), + Layer.succeed(PlanetScaleOAuthService, { + startConnect: die, + completeConnect: die, + getValidAccessToken: die, + listOrganizations: die, + hasConnection: die, + connectedByUserId: die, + disconnect: die, + }), +) + +const makeHarness = (warehouse: WarehouseQueryServiceApi) => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + const warehouseLive = Layer.succeed(WarehouseQueryService, warehouse) + + // Real config-resource services rather than `ConfigResourceServiceStubsLayer`: + // that bundle carries an inert SignalPresenceService, which would shadow the + // one under test. Same trap the setup-audit harness documents. + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + IngestAttributeMappingService.layer, + OrgIngestKeysService.layer, + RecommendationIssueService.layer.pipe(Layer.provide(warehouseLive)), + ScrapeTargetsService.layer.pipe(Layer.provide(planetScaleStubs)), + SetupAuditService.layer.pipe(Layer.provide(warehouseLive)), + SignalPresenceService.layer.pipe(Layer.provide(warehouseLive)), + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(Phase1ResourceStubsLayer), + Layer.provide(SlackIntegrationServiceStubLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provide(warehouseLive), + Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + + const request = async (token?: string) => { + const response = await handler( + new Request("http://maple.test/v2/instrumentation/signals", { + method: "GET", + headers: token !== undefined ? { authorization: `Bearer ${token}` } : {}, + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length > 0 ? JSON.parse(text) : null } + } + + const bootstrapKey = (scopes?: ReadonlyArray) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(ORG, USER, { name: "signals-test", scopes }) + }), + ) + + return { + request, + bootstrapKey, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +describe("GET /v2/instrumentation/signals", () => { + it("reports a signal the org is sending as present, with its window", async () => { + const harness = makeHarness( + warehouseStub([ + { + signal: "traces", + count: "1842013", + firstSeen: "2026-08-12 09:00:00", + lastSeen: "2026-09-11 11:00:00", + }, + ]), + ) + try { + const key = await harness.bootstrapKey() + const { status, body } = await harness.request(key.secret) + + expect(status).toBe(200) + expect(body.object).toBe("telemetry_signals") + expect(body.warehouse_available).toBe(true) + + const traces = body.signals.find((s: { signal: string }) => s.signal === "traces") + expect(traces).toMatchObject({ + object: "telemetry_signal", + status: "present", + count: 1842013, + }) + // Naive ClickHouse literals are UTC. Reading them as local time would shift + // every timestamp by the server's offset, silently. + expect(traces.last_seen).toBe("2026-09-11T11:00:00.000Z") + } finally { + await harness.dispose() + } + }) + + it("always returns every signal, reporting the unsent ones as absent", async () => { + const harness = makeHarness( + warehouseStub([ + { + signal: "traces", + count: "12", + firstSeen: "2026-09-11 10:00:00", + lastSeen: "2026-09-11 11:00:00", + }, + ]), + ) + try { + const key = await harness.bootstrapKey() + const { body } = await harness.request(key.secret) + + // The whole point: an empty state keys its copy on this array, so a signal + // dropping out would silently turn "wire up a log bridge" into no advice. + expect(body.signals.map((s: { signal: string }) => s.signal)).toEqual([ + "traces", + "logs", + "metrics", + "sessions", + "product_events", + ]) + const logs = body.signals.find((s: { signal: string }) => s.signal === "logs") + expect(logs).toMatchObject({ status: "absent", count: 0, first_seen: null, last_seen: null }) + } finally { + await harness.dispose() + } + }) + + it("treats a zero-count row as absent rather than present", async () => { + const harness = makeHarness( + warehouseStub([ + { + signal: "sessions", + count: "0", + firstSeen: "1970-01-01 00:00:00", + lastSeen: "1970-01-01 00:00:00", + }, + ]), + ) + try { + const key = await harness.bootstrapKey() + const { body } = await harness.request(key.secret) + + const sessions = body.signals.find((s: { signal: string }) => s.signal === "sessions") + expect(sessions).toMatchObject({ status: "absent", first_seen: null, last_seen: null }) + } finally { + await harness.dispose() + } + }) + + it("degrades to unknown on a warehouse outage instead of failing the caller", async () => { + const harness = makeHarness(unavailableWarehouse) + try { + const key = await harness.bootstrapKey() + const { status, body } = await harness.request(key.secret) + + // A 5xx here would break the very views this endpoint exists to repair. + expect(status).toBe(200) + expect(body.warehouse_available).toBe(false) + for (const signal of body.signals) { + expect(signal).toMatchObject({ status: "unknown", count: null }) + } + } finally { + await harness.dispose() + } + }) + + it("requires authentication", async () => { + const harness = makeHarness(warehouseStub([])) + try { + const { status } = await harness.request() + expect(status).toBe(401) + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/v2/telemetry-signals.http.ts b/apps/api/src/routes/v2/telemetry-signals.http.ts new file mode 100644 index 000000000..103ec904a --- /dev/null +++ b/apps/api/src/routes/v2/telemetry-signals.http.ts @@ -0,0 +1,38 @@ +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { CurrentTenant } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import type { V2TelemetrySignal, V2TelemetrySignals } from "@maple/domain/http/v2" +import { Effect } from "effect" +import type { SignalPresence, SignalPresenceReport } from "@/services/org/SignalPresenceService" +import { SignalPresenceService } from "@/services/org/SignalPresenceService" + +const toV2Signal = (presence: SignalPresence): V2TelemetrySignal => ({ + object: "telemetry_signal", + signal: presence.signal, + status: presence.status, + count: presence.count, + first_seen: presence.firstSeen === null ? null : new Date(presence.firstSeen).toISOString(), + last_seen: presence.lastSeen === null ? null : new Date(presence.lastSeen).toISOString(), +}) + +const toV2 = (report: SignalPresenceReport): V2TelemetrySignals => ({ + object: "telemetry_signals", + generated_at: new Date(report.generatedAt).toISOString(), + window_start: new Date(report.windowStart).toISOString(), + window_end: new Date(report.windowEnd).toISOString(), + warehouse_available: report.warehouseAvailable, + signals: report.signals.map(toV2Signal), +}) + +export const HttpV2TelemetrySignalsLive = HttpApiBuilder.group(MapleApiV2, "telemetrySignals", (handlers) => + Effect.gen(function* () { + const signals = yield* SignalPresenceService + + return handlers.handle("retrieve", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return toV2(yield* signals.read(tenant)) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 27c3fd826..3c2d53a1a 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -21,6 +21,7 @@ import { PlanetScaleService } from "@/services/integrations/PlanetScaleService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" import { SlackIntegrationService } from "@/services/integrations/SlackIntegrationService" import { SetupAuditService } from "@/services/org/SetupAuditService" +import { SignalPresenceService } from "@/services/org/SignalPresenceService" import { ApiV2RateLimiter } from "@/services/auth/ApiV2RateLimiter" import { WarehouseQueryService, @@ -48,6 +49,7 @@ import { OrgMembersService } from "@/services/org/OrgMembersService" import { HttpV2ScrapeTargetsLive } from "./scrape-targets.http" import { HttpV2SessionReplaysLive } from "./session-replays.http" import { HttpV2InstrumentationAuditLive } from "./setup-audit.http" +import { HttpV2TelemetrySignalsLive } from "./telemetry-signals.http" import { HttpV2SharePublicLive } from "./share.http" import { DashboardWidgetDataService } from "@/services/dashboards/DashboardWidgetDataService" import { @@ -98,6 +100,7 @@ export const AllV2GroupLayersLive = Layer.mergeAll( HttpV2ScrapeTargetsLive, HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, + HttpV2TelemetrySignalsLive, HttpV2InvestigationsLive, HttpV2AnomaliesLive, HttpV2OrganizationLive, @@ -251,6 +254,11 @@ export const SetupAuditServiceStubLayer = Layer.succeed(SetupAuditService, { run: die, }) +/** Inert SignalPresenceService, paired with the audit stub for the same reason. */ +export const SignalPresenceServiceStubLayer = Layer.succeed(SignalPresenceService, { + read: die, +}) + /** Inert config-resource services for harnesses that never touch those groups. */ export const ConfigResourceServiceStubsLayer = Layer.mergeAll( Layer.succeed(IngestAttributeMappingService, { @@ -271,6 +279,7 @@ export const ConfigResourceServiceStubsLayer = Layer.mergeAll( reopen: die, }), SetupAuditServiceStubLayer, + SignalPresenceServiceStubLayer, Layer.succeed(ScrapeTargetsService, { list: die, get: die, diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7d57123b8..29a470b46 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -53,6 +53,7 @@ import { HttpV2AuditLogLive } from "@/routes/v2/audit-log.http" import { AuditLogServiceLive } from "@/runtime/service-graph" import { HttpV2ScrapeTargetsLive } from "@/routes/v2/scrape-targets.http" import { HttpV2InstrumentationAuditLive } from "@/routes/v2/setup-audit.http" +import { HttpV2TelemetrySignalsLive } from "@/routes/v2/telemetry-signals.http" import { HttpV2SessionReplaysLive } from "@/routes/v2/session-replays.http" import { HttpV2EnvironmentsLive, @@ -142,6 +143,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2ScrapeTargetsLive, HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, + HttpV2TelemetrySignalsLive, HttpV2SharePublicLive, HttpV2InvestigationsLive, HttpV2AnomaliesLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index fbb789ea4..1a355360e 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -62,6 +62,7 @@ import { OrganizationService } from "@/services/org/OrganizationService" import { LiveActivitiesService } from "@/services/push/LiveActivitiesService" import { MobileDevicesService } from "@/services/push/MobileDevicesService" import { SetupAuditService } from "@/services/org/SetupAuditService" +import { SignalPresenceService } from "@/services/org/SignalPresenceService" import { ProductEventsService } from "@/services/product-events/ProductEventsService" import { QueryEngineService } from "@/services/warehouse/QueryEngineService" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" @@ -243,6 +244,10 @@ const RecommendationIssueServiceLive = RecommendationIssueService.layer.pipe( const SetupAuditServiceLive = SetupAuditService.layer.pipe(Layer.provideMerge(WarehouseQueryServiceLive)) +const SignalPresenceServiceLive = SignalPresenceService.layer.pipe( + Layer.provideMerge(WarehouseQueryServiceLive), +) + // The agents' repository sandbox tools, over the sandbox Worker's service // binding; `WorkerEnvironment` arrives at worker scope. const SandboxClientLive = SandboxClient.layer.pipe(Layer.provide(InfraLive)) @@ -329,6 +334,7 @@ const MainServicesLive = Layer.mergeAll( RecommendationIssueServiceLive, RepoSandboxServiceLive, SetupAuditServiceLive, + SignalPresenceServiceLive, DigestServiceLive, DemoServiceLive, VcsServicesLive, diff --git a/apps/api/src/services/org/SignalPresenceService.ts b/apps/api/src/services/org/SignalPresenceService.ts new file mode 100644 index 000000000..af3fc472d --- /dev/null +++ b/apps/api/src/services/org/SignalPresenceService.ts @@ -0,0 +1,177 @@ +// Which signals is this org actually sending? +// +// Every empty view in the product needs this before it can give advice. "No logs +// found" is the wrong thing to say to an org that has never wired a log bridge +// AND to one whose logs simply went quiet for ten minutes; the two need opposite +// next steps. This service is the input that separates them. +// +// Two deliberate properties: +// +// Cheap. Traces, logs and metrics come from `service_usage`, the hourly +// per-service rollup, not the raw signal tables. Sessions and product events +// have no equivalent rollup but are narrow tables to begin with. +// +// Never fails. A warehouse outage returns every signal as `unknown` rather than +// an error. The alternative is an empty state that breaks the page it was added +// to help — and this runs on views that are, by definition, already not working +// for the user. + +import { Clock, Context, Effect, Layer, Option } from "effect" +import { CH, formatWarehouseDateTime } from "@maple/query-engine" +import type { TenantContext } from "@/services/auth/AuthService" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" + +/** + * How far back presence is evaluated. + * + * Thirty days, not "ever". `service_usage` is small, but an unbounded scan still + * grows without limit and the extra reach buys nothing: a signal last sent two + * months ago is not wired up *now*, which is the only thing the advice turns on. + * The wire contract says so explicitly so callers do not read `absent` as `never`. + */ +const WINDOW_DAYS = 30 + +/** ClickHouse's zero date, which `min`/`max` return over an empty set. */ +const CH_EPOCH = "1970-01-01 00:00:00" + +export const TELEMETRY_SIGNALS = [ + "traces", + "logs", + "metrics", + "sessions", + "product_events", +] as const satisfies ReadonlyArray + +export interface SignalPresence { + readonly signal: CH.TelemetrySignal + readonly status: "present" | "absent" | "unknown" + readonly count: number | null + readonly firstSeen: number | null + readonly lastSeen: number | null +} + +export interface SignalPresenceReport { + readonly generatedAt: number + readonly windowStart: number + readonly windowEnd: number + readonly warehouseAvailable: boolean + readonly signals: ReadonlyArray +} + +export interface SignalPresenceServiceApi { + readonly read: (tenant: TenantContext) => Effect.Effect +} + +/** + * ClickHouse hands back a naive datetime literal in UTC. `Date.parse` reads a + * bare `YYYY-MM-DD HH:MM:SS` as *local* time, which silently shifts every + * timestamp by the server's offset — so pin the zone rather than trusting the + * default. + */ +const parseWarehouseTime = (value: string): number | null => { + if (value === "" || value.startsWith(CH_EPOCH)) return null + const ms = Date.parse(`${value.replace(" ", "T")}Z`) + return Number.isNaN(ms) ? null : ms +} + +const unknown = (signal: CH.TelemetrySignal): SignalPresence => ({ + signal, + status: "unknown", + count: null, + firstSeen: null, + lastSeen: null, +}) + +const absent = (signal: CH.TelemetrySignal): SignalPresence => ({ + signal, + status: "absent", + count: 0, + firstSeen: null, + lastSeen: null, +}) + +const make: Effect.Effect = Effect.gen(function* () { + const warehouse = yield* WarehouseQueryService + + const read = Effect.fn("SignalPresenceService.read")(function* (tenant: TenantContext) { + const now = yield* Clock.currentTimeMillis + const windowStart = now - WINDOW_DAYS * 24 * 60 * 60 * 1000 + + const compiled = CH.compileUnion(CH.signalPresenceQuery(), { + orgId: tenant.orgId, + startTime: formatWarehouseDateTime(windowStart), + endTime: formatWarehouseDateTime(now), + }) + + // `catchCause` rather than `Effect.option`, for the same reason the setup + // audit uses it: a driver-level defect should degrade this to "unknown", + // not 500 a view that is already failing the user. + const rows = yield* warehouse + .compiledQuery(tenant, compiled, { profile: "discovery", context: "signalPresence" }) + .pipe( + Effect.map(Option.some), + Effect.catchCause((cause) => + Effect.logWarning("Signal presence unavailable — warehouse read failed").pipe( + Effect.annotateLogs({ orgId: tenant.orgId, cause }), + Effect.as(Option.none()), + ), + ), + ) + + if (Option.isNone(rows)) { + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "signals.warehouseAvailable": false, + }) + return { + generatedAt: now, + windowStart, + windowEnd: now, + warehouseAvailable: false, + signals: TELEMETRY_SIGNALS.map(unknown), + } satisfies SignalPresenceReport + } + + const bySignal = new Map(rows.value.map((row) => [row.signal, row])) + + // The union always emits one group-less row per branch, so a signal that + // is missing here means the query changed shape — report it as `absent` + // rather than dropping the entry, because the wire contract promises the + // full set and a caller that indexes by signal would otherwise read + // `undefined` as "unknown" and silently stop advising. + const signals = TELEMETRY_SIGNALS.map((signal): SignalPresence => { + const row = bySignal.get(signal) + if (row === undefined || row.count === 0) return absent(signal) + return { + signal, + status: "present", + count: row.count, + firstSeen: parseWarehouseTime(row.firstSeen), + lastSeen: parseWarehouseTime(row.lastSeen), + } + }) + + yield* Effect.annotateCurrentSpan({ + orgId: tenant.orgId, + "signals.warehouseAvailable": true, + "signals.present": signals.filter((s) => s.status === "present").length, + }) + + return { + generatedAt: now, + windowStart, + windowEnd: now, + warehouseAvailable: true, + signals, + } satisfies SignalPresenceReport + }) + + return { read } satisfies SignalPresenceServiceApi +}) + +export class SignalPresenceService extends Context.Service()( + "@maple/api/services/SignalPresenceService", + { make }, +) { + static readonly layer = Layer.effect(this, make) +} diff --git a/apps/web/src/components/common/signal-empty-state.test.tsx b/apps/web/src/components/common/signal-empty-state.test.tsx new file mode 100644 index 000000000..4e1837525 --- /dev/null +++ b/apps/web/src/components/common/signal-empty-state.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react" +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router" +import { afterEach, expect, it } from "vitest" + +import type { SignalPresence } from "@/hooks/use-signal-presence" +import { SignalEmptyStateView, type SignalEmptyStateProps } from "./signal-empty-state" + +afterEach(cleanup) + +/** The setup CTA is a router link, so these render inside a memory router rather than a stub. */ +async function renderState(props: SignalEmptyStateProps & { readonly presence: SignalPresence }) { + const router = createRouter({ + routeTree: createRootRoute({ component: () => }), + history: createMemoryHistory({ initialEntries: ["/"] }), + }) + await router.load() + render() +} + +it("tells a user who has never sent the signal how to send it", async () => { + await renderState({ signal: "logs", presence: { status: "absent", lastSeen: null } }) + + expect(screen.getByText("No logs yet")).toBeTruthy() + expect(screen.getByText(/OTLP log bridge/)).toBeTruthy() + expect(screen.getByText("Set up logging")).toBeTruthy() +}) + +it("tells a user whose signal is wired up that the window is quiet, and when it last arrived", async () => { + const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000 + await renderState({ signal: "logs", presence: { status: "present", lastSeen: threeDaysAgo } }) + + expect(screen.getByText("No logs in this time range")).toBeTruthy() + // The distinction the whole feature exists for: never advise setup to someone already set up. + expect(screen.queryByText(/OTLP log bridge/)).toBeNull() + expect(screen.getByText(/Maple last received logs/)).toBeTruthy() +}) + +it("blames the filters when filters are active, whatever presence says", async () => { + // Even on an org that has never sent the signal: the user narrowed the view themselves, so that + // is the question they are asking. Setup advice would answer a different one. + await renderState({ + signal: "traces", + presence: { status: "absent", lastSeen: null }, + filtered: true, + onClearFilters: () => {}, + }) + + expect(screen.getByText("No traces match these filters")).toBeTruthy() + expect(screen.getByRole("button", { name: "Clear filters" })).toBeTruthy() + expect(screen.queryByText(/OpenTelemetry SDK/)).toBeNull() +}) + +it("offers no advice at all while presence is unreadable", async () => { + // Guessing here would flash "you haven't set this up" at someone who has. + await renderState({ signal: "metrics", presence: { status: "unknown", lastSeen: null } }) + + expect(screen.getByText("No metrics found")).toBeTruthy() + expect(screen.queryByText(/^Set up /)).toBeNull() + expect(screen.queryByText(/metric reader/)).toBeNull() +}) + +it("uses the page's noun for the heading but the signal's noun for the timestamp", async () => { + // Services are listed from trace data, so "your most recent service arrived" is nonsense. + await renderState({ + signal: "traces", + noun: "services", + presence: { status: "present", lastSeen: Date.now() - 60 * 60 * 1000 }, + }) + + expect(screen.getByText("No services in this time range")).toBeTruthy() + expect(screen.getByText(/Maple last received traces/)).toBeTruthy() +}) diff --git a/apps/web/src/components/common/signal-empty-state.tsx b/apps/web/src/components/common/signal-empty-state.tsx new file mode 100644 index 000000000..ea2a512d3 --- /dev/null +++ b/apps/web/src/components/common/signal-empty-state.tsx @@ -0,0 +1,218 @@ +import type React from "react" +import { Link } from "@tanstack/react-router" +import { Button } from "@maple/ui/components/ui/button" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@maple/ui/components/ui/empty" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { + ChartLineIcon, + ClockIcon, + ConnectionIcon, + EyeIcon, + FileIcon, + SlidersIcon, + NetworkNodesIcon, +} from "@/components/icons" +import { useSignalPresence, type SignalPresence, type TelemetrySignalKind } from "@/hooks/use-signal-presence" + +/** + * The one empty state every list view should use. + * + * "No traces found" is the wrong sentence in three different situations, and until now the product + * said it in all of them. This resolves which one the user is actually in and says the matching + * thing: + * + * filtered — their filters excluded everything. Offer to clear them. + * absent — they have never wired this signal up. Tell them how. + * present — it is wired up and this window is quiet. Say when the last one arrived. + * unknown — we could not read presence. Say nothing beyond the fact, and offer no advice: + * telling someone to install an SDK they already installed is worse than silence. + * + * Adding this to a page should be one line. Everything page-specific lives in SIGNAL_COPY below, so + * a caller picks a signal rather than writing copy — which is what keeps thirty empty states + * consistent instead of thirty people each inventing a sentence. + */ + +interface SignalCopy { + /** Plural noun for the thing the page lists — "traces", "log lines". */ + readonly noun: string + readonly icon: React.ComponentType<{ size?: number; className?: string }> + /** What the user has to do, phrased as the thing they are missing. */ + readonly source: string + /** Action label for the setup CTA. */ + readonly action: string +} + +const SIGNAL_COPY = { + traces: { + noun: "traces", + icon: NetworkNodesIcon, + source: "Traces come from an OpenTelemetry SDK in your app, exporting to Maple's endpoint.", + action: "Set up tracing", + }, + logs: { + noun: "logs", + icon: FileIcon, + source: "Logs come from an OTLP log bridge under your existing logger. Logging to stdout alone never reaches Maple.", + action: "Set up logging", + }, + metrics: { + noun: "metrics", + icon: ChartLineIcon, + source: "Metrics come from an OpenTelemetry metric reader exporting to Maple's endpoint.", + action: "Set up metrics", + }, + sessions: { + noun: "sessions", + icon: EyeIcon, + source: "Sessions come from the browser SDK — install @maple-dev/browser and call MapleBrowser.init().", + action: "Set up session replay", + }, + product_events: { + noun: "events", + icon: ConnectionIcon, + source: "Product events come from track() calls in the browser SDK.", + action: "Set up product analytics", + }, +} satisfies Record + +export interface SignalEmptyStateProps { + /** Which signal this view is built on. Drives every piece of copy. */ + readonly signal: TelemetrySignalKind + /** Override the plural noun when the page lists something narrower than the signal. */ + readonly noun?: string + /** True when filters or a search term are narrowing the view — the most specific reason wins. */ + readonly filtered?: boolean + readonly onClearFilters?: () => void + /** Offered alongside "this window is quiet", when the page can widen its own range. */ + readonly onWidenRange?: () => void + /** + * Page-specific detail rendered under the description — the excluded-value chips on Traces, for + * instance. Keep it to evidence the generic copy cannot carry; anything reusable belongs in + * SIGNAL_COPY instead, or thirty pages drift apart again. + */ + readonly detail?: React.ReactNode + readonly className?: string +} + +/** The whole component, minus the data fetch. Split out so it can be rendered against a known + * presence — by its own tests, and by the component lab — without standing up an API client. */ +export function SignalEmptyStateView({ + signal, + presence, + noun, + filtered = false, + onClearFilters, + onWidenRange, + detail, + className, +}: SignalEmptyStateProps & { readonly presence: SignalPresence }): React.ReactElement { + const copy = SIGNAL_COPY[signal] + const subject = noun ?? copy.noun + + // Filters first: the user narrowed this themselves, so that is the explanation they are looking + // for — even on an org that has never sent the signal, where the setup advice is also true but + // answers a question they did not ask. + if (filtered) { + return ( + + + + + + No {subject} match these filters + + Everything in this time range was excluded by the current filters. + + + {(detail !== undefined || onClearFilters !== undefined) && ( + + {detail} + {onClearFilters !== undefined && ( + + )} + + )} + + ) + } + + if (presence.status === "absent") { + const Icon = copy.icon + return ( + + + + + + No {subject} yet + {copy.source} + + + {detail} + + + + ) + } + + if (presence.status === "present") { + return ( + + + + + + No {subject} in this time range + + {/* Phrased against the signal's own noun, never the page's override: on + Services the page lists services but the timestamp describes traces, and + "your most recent service arrived" is nonsense. */} + {presence.lastSeen === null + ? `Maple is receiving ${copy.noun}, just none in the selected window.` + : `Maple last received ${copy.noun} ${formatRelativeTime(presence.lastSeen)}. Widen the range to see them.`} + + + {(detail !== undefined || onWidenRange !== undefined) && ( + + {detail} + {onWidenRange !== undefined && ( + + )} + + )} + + ) + } + + // Presence unreadable, or still loading. State the fact and stop. + return ( + + + No {subject} found + + {detail !== undefined && {detail}} + + ) +} + +export function SignalEmptyState(props: SignalEmptyStateProps): React.ReactElement { + return +} diff --git a/apps/web/src/components/logs/logs-table.tsx b/apps/web/src/components/logs/logs-table.tsx index 5c305577a..26308e843 100644 --- a/apps/web/src/components/logs/logs-table.tsx +++ b/apps/web/src/components/logs/logs-table.tsx @@ -2,6 +2,7 @@ import * as React from "react" import { useNavigate } from "@tanstack/react-router" import { Result } from "@/lib/effect-atom" import { ExcludedEmptyHint } from "@maple/ui/components/filters/excluded-empty-hint" +import { SignalEmptyState } from "@/components/common/signal-empty-state" import { logFilterChips } from "@/lib/logs/log-filter-chips" import { useVirtualizer } from "@tanstack/react-virtual" import { useHotkeys } from "@tanstack/react-hotkeys" @@ -556,44 +557,58 @@ export function LogsTableView({ return (
{!onLogClick && !embedded && } -
- {searchText || traceId ? ( - <> - - {traceId ? ( - <> - No logs on trace{" "} - {traceId} in this - time range - - ) : ( - <> - No log message contains{" "} - “{searchText}” - - )} - - {onClearSearch && ( - + {/* A search term or a trace scope explains the emptiness better than anything + presence can add — the user asked a narrow question and it had no answer. */} + {searchText || traceId ? ( +
+ + {traceId ? ( + <> + No logs on trace{" "} + {traceId} in this time + range + + ) : ( + <> + No log message contains{" "} + “{searchText}” + )} - - ) : ( - No logs found - )} - {clearExclusions && ( - + {onClearSearch && ( + + )} + {clearExclusions && ( + + )} +
+ ) : ( +
+ 0} + detail={ + clearExclusions && ( + + ) + } /> - )} -
+
+ )}
) } diff --git a/apps/web/src/components/services/services-table.tsx b/apps/web/src/components/services/services-table.tsx index 90d1570e6..b4a284b5d 100644 --- a/apps/web/src/components/services/services-table.tsx +++ b/apps/web/src/components/services/services-table.tsx @@ -26,6 +26,7 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Sparkline } from "@maple/ui/components/ui/gradient-chart" import { Tooltip, TooltipTrigger, TooltipContent } from "@maple/ui/components/ui/tooltip" import { cn } from "@maple/ui/lib/utils" +import { SignalEmptyState } from "@/components/common/signal-empty-state" import { formatErrorRate } from "@maple/ui/lib/format" import { CommitShaHoverCard, @@ -376,7 +377,10 @@ const DeployCell = React.memo(function DeployCell({ commits }: { commits: Commit } const stateLine = info.errorsSince ? ( - {info.firstSeen !== "" ? `${formatRelativeTimeOrDate(info.firstSeen, undefined, effectiveTimezone)} · ` : ""}errors ↑ since + {info.firstSeen !== "" + ? `${formatRelativeTimeOrDate(info.firstSeen, undefined, effectiveTimezone)} · ` + : ""} + errors ↑ since ) : info.rollout !== undefined ? ( @@ -802,8 +806,8 @@ export function ServicesTable({ filters }: ServicesTableProps) { {services.length === 0 ? ( - - No services found + + ) : ( @@ -865,9 +869,7 @@ export function ServicesTable({ filters }: ServicesTableProps) { match the desktop table; metrics collapse to a tight mono line. */}
{services.length === 0 ? ( -
- No services found -
+ ) : ( groups.map(([namespace, envGroups]) => (
diff --git a/apps/web/src/components/traces/traces-table.tsx b/apps/web/src/components/traces/traces-table.tsx index 2bd49e564..cfe4560c6 100644 --- a/apps/web/src/components/traces/traces-table.tsx +++ b/apps/web/src/components/traces/traces-table.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Result } from "@/lib/effect-atom" import { Link, useNavigate } from "@tanstack/react-router" import { ExcludedEmptyHint } from "@maple/ui/components/filters/excluded-empty-hint" +import { SignalEmptyState } from "@/components/common/signal-empty-state" import { traceFilterChips } from "@/lib/traces/trace-filter-chips" import { columnSizingFeature, @@ -424,27 +425,19 @@ function TracesTableView({ return (
- - - - - - - - - - - -
- Trace columns -
- No traces found - -
+ 0} + // No `onClearFilters`: the hint below carries its own clear action, and it + // names the excluded values, which a generic button cannot. + detail={ + + } + />
) diff --git a/apps/web/src/hooks/use-signal-presence.ts b/apps/web/src/hooks/use-signal-presence.ts new file mode 100644 index 000000000..a146c39fb --- /dev/null +++ b/apps/web/src/hooks/use-signal-presence.ts @@ -0,0 +1,35 @@ +import { Result, useAtomValue } from "@/lib/effect-atom" +import { telemetrySignalsAtom } from "@/lib/services/atoms/signal-atoms" + +/** The signal kinds a view can be empty for. Mirrors the v2 wire contract. */ +export type TelemetrySignalKind = "traces" | "logs" | "metrics" | "sessions" | "product_events" + +export interface SignalPresence { + /** + * `unknown` covers both "still loading" and "the warehouse could not be read". Callers must treat + * it as "do not advise" in both cases: guessing during the load flashes "you haven't set this up" + * at someone who has, which is the worst thing an empty state can say. + */ + readonly status: "present" | "absent" | "unknown" + /** Epoch ms of the most recent event, when the signal is present. */ + readonly lastSeen: number | null +} + +const UNKNOWN: SignalPresence = { status: "unknown", lastSeen: null } + +/** + * Whether this org has sent a given signal recently, for empty states that need to tell "nothing is + * wired up" apart from "nothing happened in this window". + */ +export function useSignalPresence(signal: TelemetrySignalKind): SignalPresence { + const result = useAtomValue(telemetrySignalsAtom) + if (!Result.isSuccess(result)) return UNKNOWN + + const entry = result.value.signals.find((candidate) => candidate.signal === signal) + if (entry === undefined || entry.status === "unknown") return UNKNOWN + + return { + status: entry.status, + lastSeen: entry.last_seen === null ? null : Date.parse(entry.last_seen), + } +} diff --git a/apps/web/src/lib/services/atoms/signal-atoms.ts b/apps/web/src/lib/services/atoms/signal-atoms.ts new file mode 100644 index 000000000..68d40600b --- /dev/null +++ b/apps/web/src/lib/services/atoms/signal-atoms.ts @@ -0,0 +1,20 @@ +import { Effect } from "effect" +import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" + +/** + * Which telemetry signals this org is actually sending. + * + * A module-level singleton on purpose: nearly every view can ask for this, and they must share one + * fetch. It is also the cheapest read in the app — traces, logs and metrics come from an hourly + * rollup — so the cost of it being everywhere is close to nothing. + * + * Not polled. Presence changes once, when a user finishes wiring a signal up, and the surfaces that + * care about that moment (the Connect panel, the setup checklist) run their own live poll. Everything + * else reads the answer that was true when the page loaded, which is the right answer for advice. + */ +export const telemetrySignalsAtom = MapleApiV2AtomClient.runtime.atom( + Effect.gen(function* () { + const client = yield* MapleApiV2AtomClient + return yield* client.telemetrySignals.retrieve() + }), +) diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index e0814e060..e81277a9c 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -19,6 +19,7 @@ import { V2InstrumentationRecommendationsApiGroup } from "./recommendations" import { V2ScrapeTargetsApiGroup } from "./scrape-targets" import { V2SessionReplaysApiGroup } from "./session-replays" import { V2InstrumentationAuditApiGroup } from "./setup-audit" +import { V2TelemetrySignalsApiGroup } from "./telemetry-signals" import { V2SharePublicApiGroup } from "./share" import { V2WidgetCredentialsApiGroup } from "./widget-credentials" import { V2WidgetSummaryApiGroup } from "./widget-summary" @@ -99,6 +100,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2ScrapeTargetsApiGroup) .add(V2InstrumentationRecommendationsApiGroup) .add(V2InstrumentationAuditApiGroup) + .add(V2TelemetrySignalsApiGroup) .add(V2InvestigationsApiGroup) .add(V2AnomaliesApiGroup) .add(V2OrganizationApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index b04facc3b..4bb717584 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -27,6 +27,7 @@ export * from "./route-not-found" export * from "./scrape-targets" export * from "./session-replays" export * from "./setup-audit" +export * from "./telemetry-signals" export * from "./share" export * from "./telemetry" export * from "./widget-credentials" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index e0ee0573c..75ab8723e 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -134,6 +134,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/ingest_keys", "GET /v2/instrumentation/audit", "GET /v2/instrumentation/recommendations", + "GET /v2/instrumentation/signals", "GET /v2/integrations/planetscale", "GET /v2/integrations/planetscale/databases", "GET /v2/integrations/planetscale/organizations", diff --git a/packages/domain/src/http/v2/telemetry-signals.ts b/packages/domain/src/http/v2/telemetry-signals.ts new file mode 100644 index 000000000..eb44b72e7 --- /dev/null +++ b/packages/domain/src/http/v2/telemetry-signals.ts @@ -0,0 +1,133 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { AuthorizationV2 } from "./auth" +import { wireExample, Timestamp } from "./envelopes" + +/** + * The signal kinds Maple distinguishes when telling a user what they have and have not wired up. + * Stable strings — the web app keys its empty-state copy and SDK snippets on them. + */ +export const V2TelemetrySignalKind = Schema.Literals([ + "traces", + "logs", + "metrics", + "sessions", + "product_events", +]) +export type V2TelemetrySignalKind = Schema.Schema.Type + +export const V2TelemetrySignal = Schema.Struct({ + object: Schema.Literal("telemetry_signal").annotate({ + description: 'The object type — always `"telemetry_signal"`.', + examples: ["telemetry_signal"], + }), + signal: V2TelemetrySignalKind.annotate({ + description: "Which signal this entry describes.", + examples: ["logs"], + }), + status: Schema.Literals(["present", "absent", "unknown"]).annotate({ + description: + "`present` when the signal arrived within the window, `absent` when it did not, and `unknown` when the warehouse could not be read. Treat `unknown` as 'do not advise' — it is not evidence of absence.", + examples: ["present"], + }), + count: Schema.NullOr(Schema.Number).annotate({ + description: + "Events seen in the window, or `null` when `status` is `unknown`. An estimate for rolled-up signals, exact for sessions and product events.", + examples: [1842], + }), + first_seen: Schema.NullOr(Timestamp).annotate({ + description: + "When the signal first arrived inside the window — not the all-time first, which may predate it. `null` unless `status` is `present`.", + }), + last_seen: Schema.NullOr(Timestamp).annotate({ + description: + "When the signal most recently arrived. `null` unless `status` is `present`. Hourly precision for traces, logs and metrics, which are read from an hourly rollup.", + }), +}).annotate({ + identifier: "TelemetrySignal", + title: "Telemetry signal", + description: "Whether one kind of telemetry is reaching Maple, and when it last did.", +}) +export type V2TelemetrySignal = Schema.Schema.Type + +export const V2TelemetrySignals = Schema.Struct({ + object: Schema.Literal("telemetry_signals").annotate({ + description: 'The object type — always `"telemetry_signals"`.', + examples: ["telemetry_signals"], + }), + generated_at: Timestamp.annotate({ + description: "When presence was computed. Never cached as an object.", + }), + window_start: Timestamp.annotate({ + description: + "Start of the window presence was evaluated over. A signal last sent before this reads as `absent`, so treat absence as 'not sending now', not 'never sent'.", + }), + window_end: Timestamp.annotate({ description: "End of the evaluated window." }), + warehouse_available: Schema.Boolean.annotate({ + description: + "Whether the warehouse could be read. When `false` every signal reports `unknown` and the response is still a 200 — callers use this to decide whether to show telemetry-dependent guidance at all.", + examples: [true], + }), + signals: Schema.Array(V2TelemetrySignal).annotate({ + description: + 'One entry per signal kind, always the full set. A signal never drops out of this array — absence is reported as `status: "absent"`, so a missing entry is a bug rather than a no-data answer.', + }), +}).annotate({ + identifier: "TelemetrySignals", + title: "Telemetry signals", + description: + "What kinds of telemetry the organization is actually sending. Answers 'is this page empty because nothing is wired up, or because nothing happened?' — the question every empty view in Maple has to resolve before it can give useful advice.", + examples: [ + wireExample({ + object: "telemetry_signals", + generated_at: "2026-07-27T12:00:00.000Z", + window_start: "2026-06-27T12:00:00.000Z", + window_end: "2026-07-27T12:00:00.000Z", + warehouse_available: true, + signals: [ + { + object: "telemetry_signal", + signal: "traces", + status: "present", + count: 1842013, + first_seen: "2026-06-27T12:00:00.000Z", + last_seen: "2026-07-27T11:00:00.000Z", + }, + { + object: "telemetry_signal", + signal: "logs", + status: "absent", + count: 0, + first_seen: null, + last_seen: null, + }, + ], + }), + ], +}) +export type V2TelemetrySignals = Schema.Schema.Type + +export class V2TelemetrySignalsApiGroup extends HttpApiGroup.make("telemetrySignals") + .add( + HttpApiEndpoint.get("retrieve", "/", { + success: V2TelemetrySignals, + }).annotateMerge( + OpenApi.annotations({ + identifier: "getTelemetrySignals", + summary: "Retrieve telemetry signal presence", + description: + "Reports which kinds of telemetry the organization is sending and when each last arrived, over a trailing window. " + + "Deliberately cheap: traces, logs and metrics are read from an hourly usage rollup rather than the raw signal tables. " + + "A warehouse outage returns `200` with every signal `unknown` rather than an error, so a caller that renders guidance from this can always render something. Requires the `instrumentation:read` scope.", + }), + ), + ) + .prefix("/v2/instrumentation/signals") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Telemetry Signals", + description: + "Which signals are reaching Maple. The input every empty state needs to tell 'nothing is wired up' apart from 'nothing happened'.", + }), + ) {} diff --git a/packages/query-engine/src/__sql_baseline__/catalog.sql b/packages/query-engine/src/__sql_baseline__/catalog.sql index 83656aeae..80d5a59a2 100644 --- a/packages/query-engine/src/__sql_baseline__/catalog.sql +++ b/packages/query-engine/src/__sql_baseline__/catalog.sql @@ -3745,6 +3745,61 @@ SELECT LIMIT 200 FORMAT JSON +-- builder:signal-presence:signalPresenceQuery:default [1e5ac049] +SELECT + 'traces' AS signal, + sum(TraceCount) AS count, + toString(min(Hour)) AS firstSeen, + toString(max(Hour)) AS lastSeen + FROM service_usage + WHERE OrgId = 'org_sql_catalog' + AND Hour >= toStartOfHour(toDateTime('2026-01-01 10:30:00')) + AND Hour <= toStartOfHour(toDateTime('2026-01-03 14:15:00')) + AND TraceCount > 0 +UNION ALL +SELECT + 'logs' AS signal, + sum(LogCount) AS count, + toString(min(Hour)) AS firstSeen, + toString(max(Hour)) AS lastSeen + FROM service_usage + WHERE OrgId = 'org_sql_catalog' + AND Hour >= toStartOfHour(toDateTime('2026-01-01 10:30:00')) + AND Hour <= toStartOfHour(toDateTime('2026-01-03 14:15:00')) + AND LogCount > 0 +UNION ALL +SELECT + 'metrics' AS signal, + sum(SumMetricCount) + sum(GaugeMetricCount) + sum(HistogramMetricCount) + sum(ExpHistogramMetricCount) AS count, + toString(min(Hour)) AS firstSeen, + toString(max(Hour)) AS lastSeen + FROM service_usage + WHERE OrgId = 'org_sql_catalog' + AND Hour >= toStartOfHour(toDateTime('2026-01-01 10:30:00')) + AND Hour <= toStartOfHour(toDateTime('2026-01-03 14:15:00')) + AND SumMetricCount + GaugeMetricCount + HistogramMetricCount + ExpHistogramMetricCount > 0 +UNION ALL +SELECT + 'sessions' AS signal, + count() AS count, + toString(min(StartTime)) AS firstSeen, + toString(max(StartTime)) AS lastSeen + FROM session_replays + WHERE OrgId = 'org_sql_catalog' + AND StartTime >= '2026-01-01 10:30:00' + AND StartTime <= '2026-01-03 14:15:00' +UNION ALL +SELECT + 'product_events' AS signal, + count() AS count, + toString(min(Timestamp)) AS firstSeen, + toString(max(Timestamp)) AS lastSeen + FROM product_events + WHERE OrgId = 'org_sql_catalog' + AND Timestamp >= '2026-01-01 10:30:00' + AND Timestamp <= '2026-01-03 14:15:00' +FORMAT JSON + -- builder:traces:traceServicesByTraceIdsQuery:page-enrichment [4e5e4b4b] SELECT TraceId AS traceId, diff --git a/packages/query-engine/src/benchmark/builders.ts b/packages/query-engine/src/benchmark/builders.ts index 82bc72d99..1602fb21f 100644 --- a/packages/query-engine/src/benchmark/builders.ts +++ b/packages/query-engine/src/benchmark/builders.ts @@ -338,6 +338,15 @@ const productEventsFixtures: ReadonlyArray = [ export const builderFixtures: ReadonlyArray = [ ...productEventsFixtures, + // Signal presence — the org-wide "have you ever sent this?" probe behind every + // empty state (apps/api/src/services/org/SignalPresenceService.ts). One fixture + // suffices: the builder takes no options, so there is only one SQL shape. + { + module: "signal-presence", + name: "signalPresenceQuery", + label: "default", + compile: () => CH.compileUnionUnsafe(CH.signalPresenceQuery(), window), + }, // Audit log listing (apps/api/src/services/audit/AuditLogService.ts `list`). { module: "audit-log", diff --git a/packages/query-engine/src/benchmark/catalog.test.ts b/packages/query-engine/src/benchmark/catalog.test.ts index 64d1f426d..9ddc5b3e4 100644 --- a/packages/query-engine/src/benchmark/catalog.test.ts +++ b/packages/query-engine/src/benchmark/catalog.test.ts @@ -30,6 +30,7 @@ import * as containerQueries from "../ch/queries/containers" import * as errorQueries from "../ch/queries/errors" import * as infraQueries from "../ch/queries/infra" import * as livenessQueries from "../ch/queries/liveness" +import * as signalPresenceQueries from "../ch/queries/signal-presence" import * as logQueries from "../ch/queries/logs" import * as metricQueries from "../ch/queries/metrics" import * as serviceInfraQueries from "../ch/queries/service-infra" @@ -270,6 +271,7 @@ const QUERY_MODULES: Record> = { "service-endpoints": serviceEndpointQueries, "service-operations": serviceOperationQueries, services: serviceQueries, + "signal-presence": signalPresenceQueries, releases: releaseQueries, "session-events": sessionEventQueries, "session-replays": sessionReplayQueries, diff --git a/packages/query-engine/src/ch/index.ts b/packages/query-engine/src/ch/index.ts index e858a12cf..addb0d405 100644 --- a/packages/query-engine/src/ch/index.ts +++ b/packages/query-engine/src/ch/index.ts @@ -470,6 +470,13 @@ export { type TelemetryPulseOutput, } from "./queries/liveness" +// Queries — Signal presence (what the org has ever sent, per signal; drives every empty state) +export { + signalPresenceQuery, + type SignalPresenceOutput, + type TelemetrySignal, +} from "./queries/signal-presence" + // Queries — Top Operations (per-service operation ranking by metric) export { topOperationsQuery, diff --git a/packages/query-engine/src/ch/queries/signal-presence.test.ts b/packages/query-engine/src/ch/queries/signal-presence.test.ts new file mode 100644 index 000000000..e294aa105 --- /dev/null +++ b/packages/query-engine/src/ch/queries/signal-presence.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" +import { Effect } from "effect" +import { compileUnionUnsafe } from "@maple-dev/effect-clickhouse" +import { signalPresenceQuery } from "./signal-presence" + +const params = { + orgId: "org_1", + // Deliberately sub-hour: `service_usage` is keyed on top-of-hour `Hour`, so + // the branches must floor both bounds or a partial hour vanishes. + startTime: "2024-01-01 00:23:00", + endTime: "2024-01-02 11:47:00", +} + +describe("signalPresenceQuery", () => { + it("reads the hourly usage rollup for traces, logs and metrics", () => { + const { sql } = compileUnionUnsafe(signalPresenceQuery(), params) + + expect(sql).toContain("FROM service_usage") + expect(sql).toContain("toStartOfHour(toDateTime('2024-01-01 00:23:00'))") + expect(sql).toContain("toStartOfHour(toDateTime('2024-01-02 11:47:00'))") + expect(sql).not.toContain("FROM traces") + expect(sql).not.toContain("FROM logs") + }) + + it("scopes every branch to the org", () => { + const { sql } = compileUnionUnsafe(signalPresenceQuery(), params) + const branches = sql.split("UNION ALL") + + expect(branches).toHaveLength(5) + for (const branch of branches) { + expect(branch).toContain("OrgId = 'org_1'") + } + }) + + it("gives each signal its own presence predicate", () => { + const { sql } = compileUnionUnsafe(signalPresenceQuery(), params) + + // Without these, `min(Hour)` for logs would report the hour the org first + // sent traces — a `service_usage` row exists for any signal that hour. + expect(sql).toContain("TraceCount > 0") + expect(sql).toContain("LogCount > 0") + expect(sql).toContain("FROM session_replays") + expect(sql).toContain("FROM product_events") + }) + + it("counts all four metric shapes as metrics", () => { + const { sql } = compileUnionUnsafe(signalPresenceQuery(), params) + + for (const column of [ + "SumMetricCount", + "GaugeMetricCount", + "HistogramMetricCount", + "ExpHistogramMetricCount", + ]) { + expect(sql).toContain(column) + } + }) + + it("decodes BYO-ClickHouse string-encoded counts", () => { + const compiled = compileUnionUnsafe(signalPresenceQuery(), params) + const rows = Effect.runSync( + compiled.decodeRows([ + { + signal: "logs", + count: "412", + firstSeen: "2024-01-01 01:00:00", + lastSeen: "2024-01-02 11:00:00", + }, + ]), + ) + + expect(rows[0]).toEqual({ + signal: "logs", + count: 412, + firstSeen: "2024-01-01 01:00:00", + lastSeen: "2024-01-02 11:00:00", + }) + }) + + it("keeps every branch group-less, so an absent signal still returns a row", () => { + // This is the whole zero-row guarantee: callers treat a missing signal as a + // bug rather than as "no data". A GROUP BY anywhere would break it silently. + const { sql } = compileUnionUnsafe(signalPresenceQuery(), params) + + expect(sql).not.toContain("GROUP BY") + }) +}) diff --git a/packages/query-engine/src/ch/queries/signal-presence.ts b/packages/query-engine/src/ch/queries/signal-presence.ts new file mode 100644 index 000000000..0dc7b984e --- /dev/null +++ b/packages/query-engine/src/ch/queries/signal-presence.ts @@ -0,0 +1,133 @@ +// Signal presence +// +// "Has this org ever sent this kind of telemetry, and when did it last arrive?" +// Every empty state in the product needs that answer before it can say anything +// useful: a Logs page with no rows means "wire up a log bridge" for an org that +// has never logged, and "widen the time range" for one that logged an hour ago. +// Without it both collapse into "No logs found", which helps nobody. +// +// Deliberately cheap. The trace/log/metric branches read `service_usage` — an +// hourly per-service rollup, so an org+window predicate touches a handful of +// rows rather than the raw signal tables. Sessions and product events have no +// equivalent rollup, so those branches aggregate their own (already narrow) +// tables group-lessly. +// +// Every branch is group-less, so the union always returns exactly one row per +// signal even when nothing matched — an absent signal reports `count: 0` and a +// zero `lastSeen` rather than dropping out of the result. Callers can therefore +// treat a missing row as a bug, never as "no data". +// +// `firstSeen`/`lastSeen` are stringified for the same reason +// `orgTelemetryPulseQuery` does it: the branches mix `DateTime` (`service_usage`) +// and `DateTime64` (sessions, product events), and emitting the raw columns +// would force a UNION supertype with inconsistent precision. + +import * as CH from "@maple-dev/effect-clickhouse/expr" +import { from, param, unionAll, type CHUnionQuery } from "@maple-dev/effect-clickhouse" +import { ProductEvents, ServiceUsage, SessionReplays } from "../tables" +import { hourFloor } from "./query-helpers" + +/** The signals an empty state can be missing. Stable — the UI keys copy on these. */ +export type TelemetrySignal = "traces" | "logs" | "metrics" | "sessions" | "product_events" + +export interface SignalPresenceOutput { + readonly signal: string + /** Rows (or rolled-up events) seen in the window. `0` means never received. */ + readonly count: number + /** ClickHouse datetime literal; '1970-01-01 00:00:00' when the signal is absent. */ + readonly firstSeen: string + readonly lastSeen: string +} + +/** + * One `service_usage` branch per signal. The window predicate snaps to the hour + * floor because `service_usage` is keyed on top-of-hour `Hour` — comparing to a + * raw sub-hour bound misses every partial hour, the same trap + * `serviceUsageQuery` documents. + * + * Each branch filters on its own count column, because `service_usage` carries a + * row for any service that sent *anything* that hour — without the predicate, + * `min(Hour)` for logs would report the hour the org first sent traces. Filtering + * does not cost the zero-row guarantee: these aggregates are group-less, so a + * branch that matches nothing still returns one row reading `count: 0`. + */ +export function signalPresenceQuery(): CHUnionQuery { + const traces = from(ServiceUsage) + .select(($) => ({ + signal: CH.lit("traces"), + count: CH.sum($.TraceCount), + firstSeen: CH.toString_(CH.min_($.Hour)), + lastSeen: CH.toString_(CH.max_($.Hour)), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Hour.gte(hourFloor("startTime")), + $.Hour.lte(hourFloor("endTime")), + $.TraceCount.gt(0), + ]) + + const logs = from(ServiceUsage) + .select(($) => ({ + signal: CH.lit("logs"), + count: CH.sum($.LogCount), + firstSeen: CH.toString_(CH.min_($.Hour)), + lastSeen: CH.toString_(CH.max_($.Hour)), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Hour.gte(hourFloor("startTime")), + $.Hour.lte(hourFloor("endTime")), + $.LogCount.gt(0), + ]) + + // All four metric shapes count as "metrics are wired" — a service that only + // exports counters is as instrumented as one exporting histograms, and the + // empty state's advice ("add a metric reader") is identical either way. + const metrics = from(ServiceUsage) + .select(($) => ({ + signal: CH.lit("metrics"), + count: CH.sum($.SumMetricCount) + .add(CH.sum($.GaugeMetricCount)) + .add(CH.sum($.HistogramMetricCount)) + .add(CH.sum($.ExpHistogramMetricCount)), + firstSeen: CH.toString_(CH.min_($.Hour)), + lastSeen: CH.toString_(CH.max_($.Hour)), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Hour.gte(hourFloor("startTime")), + $.Hour.lte(hourFloor("endTime")), + $.SumMetricCount.add($.GaugeMetricCount) + .add($.HistogramMetricCount) + .add($.ExpHistogramMetricCount) + .gt(0), + ]) + + const sessions = from(SessionReplays) + .select(($) => ({ + signal: CH.lit("sessions"), + count: CH.count(), + firstSeen: CH.toString_(CH.min_($.StartTime)), + lastSeen: CH.toString_(CH.max_($.StartTime)), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.StartTime.gte(param.dateTimeString("startTime")), + $.StartTime.lte(param.dateTimeString("endTime")), + ]) + + const productEvents = from(ProductEvents) + .select(($) => ({ + signal: CH.lit("product_events"), + count: CH.count(), + firstSeen: CH.toString_(CH.min_($.Timestamp)), + lastSeen: CH.toString_(CH.max_($.Timestamp)), + })) + .where(($) => [ + $.OrgId.eq(param.string("orgId")), + $.Timestamp.gte(param.dateTimeString("startTime")), + $.Timestamp.lte(param.dateTimeString("endTime")), + ]) + + return unionAll(traces, logs, metrics, sessions, productEvents).format("JSON") +} From 314d4b132e8ee1e9458e82e3b4eaaa0f7dad3cdc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 11 Sep 2026 18:29:30 +0200 Subject: [PATCH 02/12] feat(empty-states): link each signal's empty state to its docs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app snippet is the fast path, but it is one framework's worth of instructions. Anyone on a language or setup it does not cover had nowhere to go from an empty page except a support chat. Each signal now carries a docs path alongside its copy, rendered as a secondary link beside the setup button. Traces, logs and metrics point at the instrumentation guide; sessions and product events at their own pages. Paths are stored relative rather than as full URLs so a test can resolve each one against the landing content collection. Docs links rot silently — nothing in a build notices a 404 — and this makes a renamed doc fail in the PR that renames it, naming the signal that broke. The test asserts it found a plausible number of pages first, so a wrong content root fails loudly instead of passing vacuously. Co-Authored-By: Claude Opus 5 --- .../common/signal-empty-state.test.tsx | 23 +++++++- .../components/common/signal-empty-state.tsx | 55 ++++++++++++++++--- 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/common/signal-empty-state.test.tsx b/apps/web/src/components/common/signal-empty-state.test.tsx index 4e1837525..3b09e2382 100644 --- a/apps/web/src/components/common/signal-empty-state.test.tsx +++ b/apps/web/src/components/common/signal-empty-state.test.tsx @@ -5,7 +5,7 @@ import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } fr import { afterEach, expect, it } from "vitest" import type { SignalPresence } from "@/hooks/use-signal-presence" -import { SignalEmptyStateView, type SignalEmptyStateProps } from "./signal-empty-state" +import { SIGNAL_COPY, SignalEmptyStateView, type SignalEmptyStateProps } from "./signal-empty-state" afterEach(cleanup) @@ -72,3 +72,24 @@ it("uses the page's noun for the heading but the signal's noun for the timestamp expect(screen.getByText("No services in this time range")).toBeTruthy() expect(screen.getByText(/Maple last received traces/)).toBeTruthy() }) + +it("points every signal at a docs page that exists", async () => { + // Docs links rot silently — nothing in a build notices a 404. Resolving each path against the + // landing content collection makes a renamed doc fail in the PR that renames it. + const { readdir } = await import("node:fs/promises") + const { resolve } = await import("node:path") + + // Vitest runs with apps/web as the cwd. + const contentRoot = resolve(process.cwd(), "../landing/src/content/docs") + const files = await readdir(contentRoot, { recursive: true }) + const slugs = new Set( + files.filter((file) => /\.mdx?$/.test(file)).map((file) => `/docs/${file.replace(/\.mdx?$/, "")}`), + ) + + // Guards the guard: a wrong contentRoot would make every assertion below vacuously pass. + expect(slugs.size).toBeGreaterThan(5) + + for (const [signal, copy] of Object.entries(SIGNAL_COPY)) { + expect(slugs.has(copy.docs), `${signal} → ${copy.docs}`).toBe(true) + } +}) diff --git a/apps/web/src/components/common/signal-empty-state.tsx b/apps/web/src/components/common/signal-empty-state.tsx index ea2a512d3..644a64cf2 100644 --- a/apps/web/src/components/common/signal-empty-state.tsx +++ b/apps/web/src/components/common/signal-empty-state.tsx @@ -11,6 +11,7 @@ import { } from "@maple/ui/components/ui/empty" import { formatRelativeTime } from "@maple/ui/lib/time-format" import { + ExternalLinkIcon, ChartLineIcon, ClockIcon, ConnectionIcon, @@ -47,41 +48,74 @@ interface SignalCopy { readonly source: string /** Action label for the setup CTA. */ readonly action: string + /** + * Docs page for this signal, as a path under maple.dev. Kept as a path rather than a full URL so + * `signal-empty-state.test.tsx` can check each one against the landing content collection — a + * renamed doc then fails in the PR that renames it, instead of rotting into a 404. + */ + readonly docs: string } -const SIGNAL_COPY = { +/** Exported for the docs-link test, which resolves each path against the landing content. */ +export const SIGNAL_COPY = { traces: { noun: "traces", icon: NetworkNodesIcon, source: "Traces come from an OpenTelemetry SDK in your app, exporting to Maple's endpoint.", action: "Set up tracing", + docs: "/docs/instrumentation", }, logs: { noun: "logs", icon: FileIcon, source: "Logs come from an OTLP log bridge under your existing logger. Logging to stdout alone never reaches Maple.", action: "Set up logging", + docs: "/docs/instrumentation", }, metrics: { noun: "metrics", icon: ChartLineIcon, source: "Metrics come from an OpenTelemetry metric reader exporting to Maple's endpoint.", action: "Set up metrics", + docs: "/docs/instrumentation", }, sessions: { noun: "sessions", icon: EyeIcon, source: "Sessions come from the browser SDK — install @maple-dev/browser and call MapleBrowser.init().", action: "Set up session replay", + docs: "/docs/session-replay/browser-sdk", }, product_events: { noun: "events", icon: ConnectionIcon, source: "Product events come from track() calls in the browser SDK.", action: "Set up product analytics", + docs: "/docs/session-replay/product-events-api", }, } satisfies Record +const DOCS_ORIGIN = "https://maple.dev" + +/** + * Docs escape hatch. The in-app snippet is the fast path, but it is one framework's worth of + * instructions — anyone on a language or setup it does not cover needs somewhere to go that is not + * "open a support chat". + */ +function DocsLink({ signal }: { readonly signal: TelemetrySignalKind }): React.ReactElement { + return ( + + Read the docs + + + ) +} + export interface SignalEmptyStateProps { /** Which signal this view is built on. Drives every piece of copy. */ readonly signal: TelemetrySignalKind @@ -158,14 +192,17 @@ export function SignalEmptyStateView({ {detail} - +
+ + +
) From 5b38fa62ea293bcae452c59a5005a0ecf42a4918 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 11 Sep 2026 18:47:18 +0200 Subject: [PATCH 03/12] fix(empty-states): regenerate iOS spec, complete the example, respect service filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from CI and review. The iOS OpenAPI spec was stale — adding a v2 API group changes the generated tag list, and `ios:openapi:check` failed the quality job. Regenerated. Only the tag lands; the signals path is outside the curated mobile subset. The telemetry-signals wire example showed two signals while the contract promises all five, and that example is what OpenApi.fromApi renders into the public document. Added the missing three. The services table can be emptied by seven search params — three inclusion facets, three exclusion facets, and health — and both empty states omitted the filter flag, so a filtered-to-nothing table showed trace-presence guidance instead of offering to clear. Both now pass a derived flag and a reset that preserves the time range and grouping, neither of which is a filter. The clear action rebuilds search from the route's typed params rather than spreading `prev`: `prev` is the union of every route's params, so its `groupBy` widens to `string` and stops satisfying this route. Co-Authored-By: Claude Opus 5 --- .../MapleAPI/Sources/MapleAPI/openapi.json | 4 ++ .../components/services/services-table.tsx | 54 ++++++++++++++++++- .../domain/src/http/v2/telemetry-signals.ts | 24 +++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index c68b2076c..037d3a422 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7351,6 +7351,10 @@ "description": "A health check for your Maple setup — what you are ingesting, whether alerts can reach you, and whether your telemetry follows the conventions Maple's features depend on.", "name": "Setup Audit" }, + { + "description": "Which signals are reaching Maple. The input every empty state needs to tell 'nothing is wired up' apart from 'nothing happened'.", + "name": "Telemetry Signals" + }, { "description": "Durable investigation war-rooms — autonomous or human-opened diagnostic sessions over incidents and ad-hoc questions, each carrying its structured AI diagnosis.", "name": "Investigations" diff --git a/apps/web/src/components/services/services-table.tsx b/apps/web/src/components/services/services-table.tsx index b4a284b5d..06ce7db9a 100644 --- a/apps/web/src/components/services/services-table.tsx +++ b/apps/web/src/components/services/services-table.tsx @@ -581,6 +581,28 @@ interface ServicesTableProps { filters?: ServicesSearchParams } +/** + * The search params that can empty the table by themselves. Time range is NOT one of them: an empty + * window is what `SignalEmptyState`'s quiet-window branch exists to explain, and clearing it here + * would throw away the range the user chose. + */ +const SERVICE_FILTER_KEYS = [ + "environments", + "namespaces", + "commitShas", + "excludedEnvironments", + "excludedNamespaces", + "excludedCommitShas", + "health", +] as const satisfies ReadonlyArray + +const hasActiveServiceFilters = (filters: ServicesSearchParams | undefined): boolean => + filters !== undefined && + SERVICE_FILTER_KEYS.some((key) => { + const value = filters[key] + return Array.isArray(value) ? value.length > 0 : value !== undefined + }) + const SERVICES_SKELETON_COLUMNS = [ { header: "Service", skeleton: "w-32" }, { header: "P50", headClassName: "w-[6%]", skeleton: "w-12" }, @@ -657,6 +679,22 @@ export function ServicesTable({ filters }: ServicesTableProps) { }), ) + const filtersActive = hasActiveServiceFilters(filters) + const clearServiceFilters = () => { + navigate({ + to: "/services", + // Rebuilt from the typed search rather than spreading `prev`: `prev` is the union of every + // route's params, so its `groupBy` widens to `string` and no longer satisfies this route. + // Time range and grouping are carried over deliberately — neither is a filter. + search: { + startTime: filters?.startTime, + endTime: filters?.endTime, + timePreset: filters?.timePreset, + groupBy: filters?.groupBy, + }, + }) + } + const healthFilter = filters?.health // Kept in the blocking Result.all below so the health lane never flashes // from "healthy" to "unhealthy" after first paint; the derivation itself @@ -807,7 +845,14 @@ export function ServicesTable({ filters }: ServicesTableProps) { {services.length === 0 ? ( - + ) : ( @@ -869,7 +914,12 @@ export function ServicesTable({ filters }: ServicesTableProps) { match the desktop table; metrics collapse to a tight mono line. */}
{services.length === 0 ? ( - + ) : ( groups.map(([namespace, envGroups]) => (
diff --git a/packages/domain/src/http/v2/telemetry-signals.ts b/packages/domain/src/http/v2/telemetry-signals.ts index eb44b72e7..e57000569 100644 --- a/packages/domain/src/http/v2/telemetry-signals.ts +++ b/packages/domain/src/http/v2/telemetry-signals.ts @@ -101,6 +101,30 @@ export const V2TelemetrySignals = Schema.Struct({ first_seen: null, last_seen: null, }, + { + object: "telemetry_signal", + signal: "metrics", + status: "present", + count: 90400, + first_seen: "2026-06-27T12:00:00.000Z", + last_seen: "2026-07-27T11:00:00.000Z", + }, + { + object: "telemetry_signal", + signal: "sessions", + status: "absent", + count: 0, + first_seen: null, + last_seen: null, + }, + { + object: "telemetry_signal", + signal: "product_events", + status: "absent", + count: 0, + first_seen: null, + last_seen: null, + }, ], }), ], From 2befb4f6a7c7dc0582e2242cd9e981d52327289a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 11 Sep 2026 20:04:39 +0200 Subject: [PATCH 04/12] feat(settings): regroup the settings nav and rework the API Keys page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings nav had four uneven groups and thirteen rows, one group holding a single item and one row ("API Reference") that was half of the API Keys page filed as its own tab — it even passed a callback back to API Keys so it could link there. Nav: - Four balanced groups: Workspace, Connections, Data, Alerting. - Integrations leads Connections. It is a sibling page rather than a tab, and the shell kept tabs and links in separate arrays and painted every tab first, so a link could only ever land at the foot of its group no matter how it was declared. Both now share one ordered list; position is declared, not derived. - "API Reference" folded into the foot of the API Keys page. `?tab=developer` redirects rather than blanking. - The link id no longer widens the tab union, so /account keeps its narrow type, and tab resolution filters links out of the visible-item list so /integrations can never be picked as a fallback tab id. API Keys: - The tab counts were wrong. A key past its expiry is not revoked, so it counted as Active, cued only by a badge and a date column both hidden below `md`. Status is now one derived value and the list groups by it: Active, Expired, Revoked, covered by api-key-status.test.ts. - Keys inside their last week carry a badge in the name row and sort to the top. - The restricted-scope picker was seventeen families times three levels with no shortcuts. Adds All read / All write / Clear, a selected count, and a filter that hides rows without touching their levels. - The disabled Create button now names what is missing. - Drops the toolbar docs link, which competed with the reference block below it. KeyIcon is now Nucleo Arcade's pixel key, replacing the hairline key that read as an arrow at nav size. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/icons/key.tsx | 31 +- .../settings/api-key-status.test.ts | 55 +++ .../components/settings/api-keys-section.tsx | 334 +++++++++++++++--- .../settings/create-api-key-dialog.tsx | 70 +++- .../components/settings/developer-section.tsx | 163 --------- .../settings/settings-nav-shell.tsx | 60 ++-- .../components/settings/settings-nav.test.ts | 17 +- .../src/components/settings/settings-nav.tsx | 85 +++-- apps/web/src/routes/settings.tsx | 12 +- 9 files changed, 533 insertions(+), 294 deletions(-) create mode 100644 apps/web/src/components/settings/api-key-status.test.ts delete mode 100644 apps/web/src/components/settings/developer-section.tsx diff --git a/apps/web/src/components/icons/key.tsx b/apps/web/src/components/icons/key.tsx index bf7c02c52..549bf0339 100644 --- a/apps/web/src/components/icons/key.tsx +++ b/apps/web/src/components/icons/key.tsx @@ -1,12 +1,37 @@ import type { IconProps } from "./icon" -const paths: ReadonlyArray = ["M4 5H9V10H4Z", "M9 10L18 19", "M18 19L20 17", "M14 15L16 13"] +/** + * Nucleo Arcade `key`. Arcade is a pixel-art set: the glyph is a list of lit pixels on a 30×30 grid, + * each drawn as a zero-length stroke 4 units wide with a square cap. That is why the viewBox is 30 + * rather than the 24 the outline icons use, and why `strokeLinecap="square"` is load-bearing — round + * caps turn every pixel into a dot. Rows below run top to bottom, left to right across the grid. + */ +const paths: ReadonlyArray = [ + "M7 7H7.01", + "M11 7H11.01", + "M3 11H3.01", + "M15 11H15.01", + "M3 15H3.01", + "M7 15H7.01", + "M15 15H15.01", + "M19 15H19.01", + "M23 15H23.01", + "M27 15H27.01", + "M3 19H3.01", + "M7 19H7.01", + "M11 19H11.01", + "M15 19H15.01", + "M23 19H23.01", + "M27 19H27.01", + "M7 23H7.01", + "M11 23H11.01", +] function KeyIcon({ size = 24, className, ...props }: IconProps) { return ( {paths.map((d, i) => ( - + ))} ) diff --git a/apps/web/src/components/settings/api-key-status.test.ts b/apps/web/src/components/settings/api-key-status.test.ts new file mode 100644 index 000000000..7f46b9dbb --- /dev/null +++ b/apps/web/src/components/settings/api-key-status.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" +import type { V2ApiKey } from "@maple/domain/http/v2" +import { apiKeyStatus } from "./api-keys-section" + +const NOW = Date.parse("2026-09-11T12:00:00Z") +const inDays = (days: number) => new Date(NOW + days * 86_400_000).toISOString() + +const key = (overrides: Partial): V2ApiKey => + ({ + id: "ak_1", + name: "CI", + description: null, + key_prefix: "maple_ak_abc", + kind: "standard", + scopes: null, + revoked: false, + expires_at: null, + last_used_at: null, + created_at: inDays(-30), + created_by_email: null, + ...overrides, + }) as V2ApiKey + +describe("apiKeyStatus", () => { + it("is active when it never expires", () => { + expect(apiKeyStatus(key({ expires_at: null }), NOW)).toBe("active") + }) + + it("is active while the expiry is more than a week out", () => { + expect(apiKeyStatus(key({ expires_at: inDays(8) }), NOW)).toBe("active") + }) + + it("is expiring inside the last week", () => { + expect(apiKeyStatus(key({ expires_at: inDays(6) }), NOW)).toBe("expiring") + expect(apiKeyStatus(key({ expires_at: inDays(0.5) }), NOW)).toBe("expiring") + }) + + it("is expired once the moment passes", () => { + expect(apiKeyStatus(key({ expires_at: inDays(-0.1) }), NOW)).toBe("expired") + }) + + it("counts an expiry exactly at now as expired, not expiring", () => { + expect(apiKeyStatus(key({ expires_at: new Date(NOW).toISOString() }), NOW)).toBe("expired") + }) + + it("lets revoked win over every expiry state", () => { + expect(apiKeyStatus(key({ revoked: true, expires_at: inDays(30) }), NOW)).toBe("revoked") + expect(apiKeyStatus(key({ revoked: true, expires_at: inDays(-30) }), NOW)).toBe("revoked") + }) + + it("treats an unparseable expiry as active rather than expired", () => { + // A key that still works must never be filed under "Expired" because a timestamp was odd. + expect(apiKeyStatus(key({ expires_at: "not-a-date" }), NOW)).toBe("active") + }) +}) diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx index 61e3ac405..b93f7a0af 100644 --- a/apps/web/src/components/settings/api-keys-section.tsx +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -9,6 +9,8 @@ import { cn } from "@maple/ui/lib/utils" import { Button } from "@maple/ui/components/ui/button" import { Badge } from "@maple/ui/components/ui/badge" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" +import { CopyButton } from "@maple/ui/components/ui/copy-button" import { AlertDialog, AlertDialogAction, @@ -34,16 +36,19 @@ import { EmptyMedia, EmptyTitle, } from "@maple/ui/components/ui/empty" +import { SearchInput } from "@maple/ui/components/ui/search-input" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { AlertWarningIcon, ArrowPathIcon, + CodeIcon, DotsVerticalIcon, KeyIcon, PlusIcon, SquareTerminalIcon, TrashIcon, } from "@/components/icons" +import { apiBaseUrl } from "@/lib/services/common/api-base-url" import { useApiKeyMutationSync, useApiKeysList } from "@/hooks/use-api-keys" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { displayError } from "@/lib/error-messages" @@ -66,9 +71,40 @@ function formatDate(timestamp: string | null): string { } } +/** + * A key has exactly one status, and the list is grouped by it. "Expiring" is not a separate bucket — + * the key still works, so it belongs with the active ones — but it carries a badge and sorts to the + * top, because an expiry nobody noticed is this page's most common failure. + */ +export type ApiKeyStatus = "active" | "expiring" | "expired" | "revoked" +type ApiKeyView = "active" | "expired" | "revoked" + +const EXPIRING_WINDOW_MS = 7 * 86_400_000 + +/** `now` is passed in so every row in a render agrees on where the expiry boundary falls. */ +export function apiKeyStatus(apiKey: ApiKey, now: number): ApiKeyStatus { + if (apiKey.revoked) return "revoked" + const expiresAt = apiKey.expires_at === null ? null : Date.parse(apiKey.expires_at) + if (expiresAt === null || !Number.isFinite(expiresAt)) return "active" + if (expiresAt <= now) return "expired" + return expiresAt - now < EXPIRING_WINDOW_MS ? "expiring" : "active" +} + +function matchesSearch(apiKey: ApiKey, needle: string): boolean { + const haystack = [apiKey.name, apiKey.description ?? "", apiKey.key_prefix].join(" ").toLowerCase() + return haystack.includes(needle) +} + +const VIEW_LABELS = { + active: "Active", + expired: "Expired", + revoked: "Revoked", +} satisfies Record + export function ApiKeysSection() { const isAdmin = useIsOrgAdmin() - const [view, setView] = useState<"active" | "revoked">("active") + const [view, setView] = useState("active") + const [search, setSearch] = useState("") const [createOpen, setCreateOpen] = useState(false) const [revokeOpen, setRevokeOpen] = useState(false) const [revokingKey, setRevokingKey] = useState(null) @@ -110,13 +146,33 @@ export function ApiKeysSection() { // fallback while the close animation plays; the next open overwrites it. } - const activeKeys = keys.filter((k) => !k.revoked) - const revokedKeys = keys.filter((k) => k.revoked) - const mcpCount = activeKeys.filter((k) => k.kind === "mcp").length - const standardCount = activeKeys.length - mcpCount + // One pass, one clock. An expired key used to count as "Active" and sit in the active list behind + // a badge that only appeared on wide viewports, so the tab counts told you a key still worked + // when it did not. + const now = Date.now() + const statuses = new Map(keys.map((k) => [k.id, apiKeyStatus(k, now)] as const)) + const statusOf = (k: ApiKey): ApiKeyStatus => statuses.get(k.id) ?? "active" + + const buckets = { + active: keys.filter((k) => statusOf(k) === "active" || statusOf(k) === "expiring"), + expired: keys.filter((k) => statusOf(k) === "expired"), + revoked: keys.filter((k) => statusOf(k) === "revoked"), + } satisfies Record> + + // A tab can empty out under you — revoking the last expired key, say. Fall back rather than + // leaving the page on a tab that no longer exists. + const activeView: ApiKeyView = buckets[view].length > 0 ? view : "active" + + const mcpCount = buckets.active.filter((k) => k.kind === "mcp").length + const standardCount = buckets.active.length - mcpCount + + const needle = search.trim().toLowerCase() + const visibleKeys = [...buckets[activeView]] + .filter((k) => needle.length === 0 || matchesSearch(k, needle)) + // Keys about to stop working lead the list; everything else keeps collection order. + .sort((a, b) => Number(statusOf(b) === "expiring") - Number(statusOf(a) === "expiring")) - const showRevokedSection = view === "active" && revokedKeys.length > 0 - const visibleActive = view === "active" ? activeKeys : [] + const showSearch = buckets[activeView].length > 5 return (
@@ -125,14 +181,21 @@ export function ApiKeysSection() { {keys.length > 0 && ( <>
- setView("active")}> - Active · {activeKeys.length} - - setView("revoked")}> - Revoked · {revokedKeys.length} - + {(["active", "expired", "revoked"] as const).map((tab) => + // A tab for an empty bucket is a dead end. Active always shows, so + // there is something to fall back to. + tab === "active" || buckets[tab].length > 0 ? ( + setView(tab)} + > + {VIEW_LABELS[tab]} · {buckets[tab].length} + + ) : null, + )}
- {activeKeys.length > 0 && ( + {buckets.active.length > 0 && ( {standardCount} standard · @@ -142,14 +205,14 @@ export function ApiKeysSection() { )}
- - View API docs ↗ - + {showSearch && ( + + )} - ) : view === "revoked" && revokedKeys.length === 0 ? ( + ) : visibleKeys.length === 0 ? ( - No revoked keys - Revoked keys will show up here. + + {needle.length > 0 + ? "No keys match" + : `No ${VIEW_LABELS[activeView].toLowerCase()} keys`} + + + {needle.length > 0 + ? `Nothing in ${VIEW_LABELS[activeView]} matches "${search.trim()}".` + : "Keys show up here once they reach this state."} + ) : ( @@ -213,23 +284,15 @@ export function ApiKeysSection() { Expires
- {visibleActive.map((key) => ( + {visibleKeys.map((key) => ( openRollDialog(key)} - onRevoke={() => openRevokeDialog(key)} + status={statusOf(key)} + onRoll={key.revoked ? undefined : () => openRollDialog(key)} + onRevoke={key.revoked ? undefined : () => openRevokeDialog(key)} /> ))} - {showRevokedSection && ( -
- - Revoked · {revokedKeys.length} - -
- )} - {(showRevokedSection || view === "revoked") && - revokedKeys.map((key) => )}
)}
@@ -248,6 +311,8 @@ export function ApiKeysSection() {

) : null} + + @@ -286,6 +351,156 @@ export function ApiKeysSection() { ) } +/** + * Keep in sync with `SCOPE_FAMILIES` in create-api-key-dialog.tsx — one row per + * shipped v2 resource family. + */ +const SCOPE_FAMILY_ROWS = [ + { id: "api_keys", label: "API keys", description: "Create, roll, and revoke API keys" }, + { id: "dashboards", label: "Dashboards", description: "Dashboards, templates, and version history" }, + { + id: "alerts", + label: "Alerts", + description: "Alert rules (incl. test/preview/checks), destinations, and incidents", + }, + { id: "ingest_keys", label: "Ingest keys", description: "View and roll telemetry ingest keys" }, + { + id: "attribute_mappings", + label: "Attribute mappings", + description: "Ingest-time attribute rewrite rules", + }, + { + id: "scrape_targets", + label: "Scrape targets", + description: "Prometheus/PlanetScale scrape targets, probes, and checks", + }, + { id: "instrumentation", label: "Recommendations", description: "Instrumentation recommendations" }, + { + id: "investigations", + label: "Investigations", + description: "AI investigation war-rooms — list, open, and update status", + }, + { + id: "anomalies", + label: "Anomalies", + description: "Anomaly incidents (incl. timeseries/resolve/link-issue) and detector settings", + }, + { + id: "session_replays", + label: "Session replays", + description: "Search sessions, retrieve detail, events, and transcripts", + }, + { id: "traces", label: "Traces", description: "Search traces and retrieve spans" }, + { id: "logs", label: "Logs", description: "Search and retrieve log records" }, + { id: "metrics", label: "Metrics", description: "Metric catalog and timeseries reads" }, + { id: "services", label: "Services", description: "Service catalog and health summaries" }, + { id: "service_map", label: "Service map", description: "Service-to-service topology" }, + { id: "query", label: "Query", description: "Structured telemetry queries" }, + { id: "organization", label: "Organization", description: "Read the organization's identity" }, +] as const + +const docsUrl = `${apiBaseUrl}/v2/docs` + +const curlExample = `curl ${apiBaseUrl}/v2/alerts/rules \\ + -H "Authorization: Bearer maple_ak_..."` + +/** + * The reference for the keys listed above: where to point them, and what each scope in the create + * dialog actually grants. It used to be its own "API Reference" nav item, which split one job across + * two tabs — you cannot read the scope table and pick scopes at the same time. + */ +function ApiReference() { + return ( +
+ + +
+
+ API Reference + + The Maple v2 API is a resource-oriented REST interface — snake_case JSON, + prefixed object IDs, cursor-paginated lists, and scoped API keys. + +
+ +
+
+ +
+
+ Base URL +
+
+ {apiBaseUrl}/v2 + +
+
+
+
+ Quick start +
+
+
{curlExample}
+ +
+
+
+
+ + + + Scopes + + Restricted keys grant read or{" "} + write access per resource family ( + write implies{" "} + read). A key without scopes has full + access. + + + +
+ {SCOPE_FAMILY_ROWS.map((family) => ( +
+
+
{family.label}
+
+ {family.description} +
+
+
+ + {family.id}:read + + + {family.id}:write + +
+
+ ))} +
+
+
+
+ ) +} + // Shared column lanes so the header row and key rows stay aligned. Prefix/scopes/last-used // collapse on narrower viewports; the key cell always keeps name + created meta visible. const COL = { @@ -322,12 +537,21 @@ function FilterTab({ ) } +/** "in 3 days" / "today" — the urgency, not the date. The Expires column carries the date. */ +function expiresInLabel(expiresAt: number, now: number): string { + const days = Math.floor((expiresAt - now) / 86_400_000) + if (days < 1) return "Expires today" + return `Expires in ${days} ${days === 1 ? "day" : "days"}` +} + function ApiKeyRow({ apiKey, + status, onRoll, onRevoke, }: { apiKey: ApiKey + status: ApiKeyStatus onRoll?: () => void onRevoke?: () => void }) { @@ -335,20 +559,17 @@ function ApiKeyRow({ const Icon = isMcp ? SquareTerminalIcon : KeyIcon const relativeLastUsed = apiKey.last_used_at ? formatRelativeTime(apiKey.last_used_at) : null const expiresAt = apiKey.expires_at === null ? null : Date.parse(apiKey.expires_at) - const expiresInPast = expiresAt !== null && Number.isFinite(expiresAt) && expiresAt < Date.now() - const expiresSoon = - expiresAt !== null && - Number.isFinite(expiresAt) && - !expiresInPast && - expiresAt - Date.now() < 7 * 86_400_000 + const expiresInPast = status === "expired" + const expiresSoon = status === "expiring" // Type-coded icon tile: emerald for standard keys (live credential), blue for MCP - // (agent/machine type). Revoked keys desaturate to neutral so dead keys read as dead. - const tileClass = apiKey.revoked - ? "bg-muted/40 text-muted-foreground" - : isMcp - ? "bg-info/10 text-info" - : "bg-success/10 text-success" + // (agent/machine type). Dead keys — revoked or expired — desaturate to neutral. + const tileClass = + status === "revoked" || status === "expired" + ? "bg-muted/40 text-muted-foreground" + : isMcp + ? "bg-info/10 text-info" + : "bg-success/10 text-success" const createdMeta = [ apiKey.description, @@ -361,7 +582,7 @@ function ApiKeyRow({
@@ -378,16 +599,23 @@ function ApiKeyRow({ MCP )} - {apiKey.revoked && ( + {status === "revoked" && ( Revoked )} - {expiresInPast && !apiKey.revoked && ( + {expiresInPast && ( Expired )} + {/* The Expires column is hidden below `md`, so the one state that silently + breaks a running integration rides in the name row instead. */} + {expiresSoon && expiresAt !== null && ( + + {expiresInLabel(expiresAt, Date.now())} + + )}
{createdMeta} @@ -423,7 +651,7 @@ function ApiKeyRow({
- {!apiKey.revoked && onRevoke && ( + {onRevoke && ( } diff --git a/apps/web/src/components/settings/create-api-key-dialog.tsx b/apps/web/src/components/settings/create-api-key-dialog.tsx index 2742f6837..b0668bd68 100644 --- a/apps/web/src/components/settings/create-api-key-dialog.tsx +++ b/apps/web/src/components/settings/create-api-key-dialog.tsx @@ -8,6 +8,7 @@ import { toastManager } from "@maple/ui/components/ui/toast" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" import { Input } from "@maple/ui/components/ui/input" +import { SearchInput } from "@maple/ui/components/ui/search-input" import { Label } from "@maple/ui/components/ui/label" import { Dialog, @@ -76,6 +77,9 @@ type AccessMode = "full" | "restricted" const defaultScopeLevels = (): Record => Object.fromEntries(SCOPE_FAMILIES.map((f) => [f.id, "none"])) +const allScopeLevels = (level: ScopeLevel): Record => + Object.fromEntries(SCOPE_FAMILIES.map((f) => [f.id, level])) + const scopesFromLevels = (levels: Record): Array => SCOPE_FAMILIES.flatMap((f) => { const level = levels[f.id] @@ -90,6 +94,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea const [expiration, setExpiration] = useState("never") const [accessMode, setAccessMode] = useState("full") const [scopeLevels, setScopeLevels] = useState>(defaultScopeLevels) + const [scopeFilter, setScopeFilter] = useState("") const [isCreating, setIsCreating] = useState(false) const [createdKey, setCreatedKey] = useState(null) @@ -98,10 +103,25 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea mode: "promiseExit", }) + const selectedFamilyCount = SCOPE_FAMILIES.filter((f) => (scopeLevels[f.id] ?? "none") !== "none").length + const familyNeedle = scopeFilter.trim().toLowerCase() + // Filtering hides rows, never their levels: a family scoped then filtered out still ships. + const visibleFamilies = SCOPE_FAMILIES.filter( + (f) => familyNeedle.length === 0 || f.label.toLowerCase().includes(familyNeedle), + ) + const restrictedScopes = !isMcp && accessMode === "restricted" ? scopesFromLevels(scopeLevels) : undefined const missingScopes = !isMcp && accessMode === "restricted" && restrictedScopes?.length === 0 const canCreate = newName.trim().length > 0 && !missingScopes && !isCreating + // A disabled primary button with no stated reason is a dead end — say which field is missing. + const blockedReason = + newName.trim().length === 0 + ? "Name the key so you can tell it apart later." + : missingScopes + ? "Give at least one resource family read or write access." + : null + async function handleCreate() { if (!canCreate) return setIsCreating(true) @@ -135,6 +155,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea setExpiration("never") setAccessMode("full") setScopeLevels(defaultScopeLevels()) + setScopeFilter("") setCreatedKey(null) } @@ -247,7 +268,51 @@ export function CreateApiKeyDialog({ open, onOpenChange, onCreated, kind }: Crea {accessMode === "restricted" ? (
- {SCOPE_FAMILIES.map((family) => { + {/* Seventeen families times three levels is fifty-one clicks to + express "read-only", which is the common case. */} +
+ + + + + {selectedFamilyCount} of {SCOPE_FAMILIES.length} selected + +
+ {SCOPE_FAMILIES.length > 8 && ( + + )} + {visibleFamilies.length === 0 && ( +

+ No resource family matches "{scopeFilter.trim()}". +

+ )} + {visibleFamilies.map((family) => { const familyLabelId = `${accessLabelId}-${family.id}` return (
+ {blockedReason !== null && ( + {blockedReason} + )} diff --git a/apps/web/src/components/settings/developer-section.tsx b/apps/web/src/components/settings/developer-section.tsx deleted file mode 100644 index 2481476d8..000000000 --- a/apps/web/src/components/settings/developer-section.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { Button } from "@maple/ui/components/ui/button" -import { CopyButton } from "@maple/ui/components/ui/copy-button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@maple/ui/components/ui/card" -import { Badge } from "@maple/ui/components/ui/badge" -import { CodeIcon, KeyIcon } from "@/components/icons" -import { apiBaseUrl } from "@/lib/services/common/api-base-url" - -/** - * Keep in sync with `SCOPE_FAMILIES` in create-api-key-dialog.tsx — one row per - * shipped v2 resource family. - */ -const SCOPE_FAMILY_ROWS = [ - { id: "api_keys", label: "API keys", description: "Create, roll, and revoke API keys" }, - { id: "dashboards", label: "Dashboards", description: "Dashboards, templates, and version history" }, - { - id: "alerts", - label: "Alerts", - description: "Alert rules (incl. test/preview/checks), destinations, and incidents", - }, - { id: "ingest_keys", label: "Ingest keys", description: "View and roll telemetry ingest keys" }, - { - id: "attribute_mappings", - label: "Attribute mappings", - description: "Ingest-time attribute rewrite rules", - }, - { - id: "scrape_targets", - label: "Scrape targets", - description: "Prometheus/PlanetScale scrape targets, probes, and checks", - }, - { id: "instrumentation", label: "Recommendations", description: "Instrumentation recommendations" }, - { - id: "investigations", - label: "Investigations", - description: "AI investigation war-rooms — list, open, and update status", - }, - { - id: "anomalies", - label: "Anomalies", - description: "Anomaly incidents (incl. timeseries/resolve/link-issue) and detector settings", - }, - { - id: "session_replays", - label: "Session replays", - description: "Search sessions, retrieve detail, events, and transcripts", - }, - { id: "traces", label: "Traces", description: "Search traces and retrieve spans" }, - { id: "logs", label: "Logs", description: "Search and retrieve log records" }, - { id: "metrics", label: "Metrics", description: "Metric catalog and timeseries reads" }, - { id: "services", label: "Services", description: "Service catalog and health summaries" }, - { id: "service_map", label: "Service map", description: "Service-to-service topology" }, - { id: "query", label: "Query", description: "Structured telemetry queries" }, - { id: "organization", label: "Organization", description: "Read the organization's identity" }, -] as const - -const docsUrl = `${apiBaseUrl}/v2/docs` - -const curlExample = `curl ${apiBaseUrl}/v2/alerts/rules \\ - -H "Authorization: Bearer maple_ak_..."` - -export function DeveloperSection({ onNavigateToApiKeys }: { onNavigateToApiKeys: () => void }) { - return ( -
- - -
-
- API Reference - - The Maple v2 API is a resource-oriented REST interface — snake_case JSON, - prefixed object IDs, cursor-paginated lists, and scoped API keys. - -
- -
-
- -
-
- Base URL -
-
- {apiBaseUrl}/v2 - -
-
-
-
- Quick start -
-
-
{curlExample}
- -
-

- Authenticate with a Bearer API key. Create one under{" "} - - . -

-
-
-
- - - - Scopes - - Restricted keys grant read or{" "} - write access per resource family ( - write implies{" "} - read). A key without scopes has full - access. - - - -
- {SCOPE_FAMILY_ROWS.map((family) => ( -
-
-
{family.label}
-
- {family.description} -
-
-
- - {family.id}:read - - - {family.id}:write - -
-
- ))} -
-
-
-
- ) -} diff --git a/apps/web/src/components/settings/settings-nav-shell.tsx b/apps/web/src/components/settings/settings-nav-shell.tsx index b8fc4ceed..1d6f6eeae 100644 --- a/apps/web/src/components/settings/settings-nav-shell.tsx +++ b/apps/web/src/components/settings/settings-nav-shell.tsx @@ -10,18 +10,34 @@ export interface NavShellItem { } /** Sibling pages that share the shell (rendered as router Links rather than tab buttons). */ -export interface NavShellLink { - id: string +export interface NavShellLink { + id: TLinkId label: string icon: IconComponent to: string } -export interface NavShellSection { +/** + * Tabs and sibling-page links share one ordered list rather than sitting in separate buckets. + * Two buckets meant a link could only ever render after every tab in its group, which silently + * decided where Integrations sat in the settings nav — a layout rule masquerading as a data shape. + */ +export type NavShellRow = + | NavShellItem + | NavShellLink + +const isLink = ( + row: NavShellRow, +): row is NavShellLink => "to" in row + +/** + * `TLinkId` defaults to `never`, so a nav with no sibling-page links (`/account`) keeps `row.id` + * narrowed to its own tab union instead of widening to `string`. + */ +export interface NavShellSection { id: string title: string - items: ReadonlyArray> - links?: ReadonlyArray + items: ReadonlyArray> } /** @@ -48,12 +64,12 @@ function ActiveIndicator() { * entitlements, `/account` shows every tab to any signed-in user. Keeping the two unions apart * is what stops account tabs from leaking into the org page's search schema. */ -export function SettingsNavShell({ +export function SettingsNavShell({ sections, active, onSelectTab, }: { - sections: ReadonlyArray> + sections: ReadonlyArray> /** Active tab id, or a link id when a sibling page renders the nav. */ active: string onSelectTab: (tab: TId) => void @@ -66,31 +82,27 @@ export function SettingsNavShell({ {section.title}
- {section.items.map((item) => { - const isActive = item.id === active - return ( + {section.items.map((row) => { + const isActive = row.id === active + return isLink(row) ? ( + + {isActive && } + + {row.label} + + ) : ( ) })} - {section.links?.map((link) => { - const isActive = link.id === active - return ( - - {isActive && } - - {link.label} - - ) - })}
))} diff --git a/apps/web/src/components/settings/settings-nav.test.ts b/apps/web/src/components/settings/settings-nav.test.ts index acba47cd2..29e359151 100644 --- a/apps/web/src/components/settings/settings-nav.test.ts +++ b/apps/web/src/components/settings/settings-nav.test.ts @@ -9,22 +9,22 @@ import { const item = (id: SettingsTab, icon: IconComponent = GearIcon) => ({ id, label: id, icon }) -/** Workspace items other than `setup-audit` are Clerk-gated, so self-hosted keeps only that one. */ +/** Organization, Members, Billing and Notifications are Clerk-gated, so self-hosted drops them. */ const SELF_HOSTED = [ - item("setup-audit"), item("ingestion", ServerIcon), + item("data-platform"), + item("setup-audit"), + item("automation"), item("api-keys"), - item("developer"), item("mcp"), - item("automation"), ] const CLERK = [ item("organization"), item("members"), - item("setup-audit"), item("billing"), item("ingestion", ServerIcon), + item("setup-audit"), item("api-keys"), ] @@ -47,10 +47,9 @@ describe("resolveActiveSettingsTab", () => { }) it("lands on Ingestion when self-hosted, never on Setup Audit", () => { - // Regression: `setup-audit` is not Clerk-gated, so it is the FIRST visible item in - // self-hosted mode. A positional default would land here and run the audit's warehouse - // reads on every visit to Settings. - expect(SELF_HOSTED[0]?.id).toBe("setup-audit") + // `setup-audit` is not Clerk-gated, so it survives into self-hosted mode and a positional + // default could land on it — which would run the audit's warehouse reads on every visit + // to Settings. Ordering the nav must never be able to make that happen. expect(resolveActiveSettingsTab(undefined, SELF_HOSTED)).toBe("ingestion") }) diff --git a/apps/web/src/components/settings/settings-nav.tsx b/apps/web/src/components/settings/settings-nav.tsx index db51b7bf2..86b3ee14a 100644 --- a/apps/web/src/components/settings/settings-nav.tsx +++ b/apps/web/src/components/settings/settings-nav.tsx @@ -9,7 +9,6 @@ import { retainedQuery } from "@/lib/services/common/atom-client" import { BellIcon, CircleCheckIcon, - CodeIcon, CreditCardIcon, DatabaseIcon, GearIcon, @@ -27,32 +26,30 @@ import { SettingsNavShell } from "@/components/settings/settings-nav-shell" export const settingsTabValues = [ "organization", "members", + "billing", "audit-log", - "setup-audit", "ingestion", - "api-keys", - "developer", - "mcp", + "data-platform", + "setup-audit", "notifications", "automation", - "billing", - "data-platform", + "api-keys", + "mcp", ] as const export type SettingsTab = (typeof settingsTabValues)[number] export const settingsTabLabels: Record = { organization: "Organization", members: "Members", + billing: "Billing", "audit-log": "Audit Log", - "setup-audit": "Setup Audit", ingestion: "Ingestion", - "api-keys": "API Keys", - developer: "API Reference", - mcp: "MCP", + "data-platform": "Data Platform", + "setup-audit": "Setup Audit", notifications: "Notifications", automation: "Automation", - billing: "Billing", - "data-platform": "Data Platform", + "api-keys": "API Keys", + mcp: "MCP", } satisfies Record interface NavItem { @@ -98,12 +95,21 @@ interface NavLinkItem { } export interface SettingsNavSection { - id: "workspace" | "data" | "behavior" | "infra" + id: "workspace" | "connections" | "data" | "alerting" title: string - items: NavItem[] - links?: NavLinkItem[] + /** Tabs and sibling-page links in one ordered list — nav position is declared, not derived. */ + items: Array } +/** + * Four groups, each earning its header: who can use the workspace and what it costs; what talks to + * Maple from outside; where telemetry comes from and where it lands; and what Maple does when + * something breaks. + * + * Group order does not decide the landing tab; `DEFAULT_SETTINGS_TAB_ORDER` does, on purpose. + * + * Within a group, rows run most-visited first rather than alphabetically. + */ const navSections: SettingsNavSection[] = [ { id: "workspace", @@ -111,11 +117,19 @@ const navSections: SettingsNavSection[] = [ items: [ { id: "organization", label: "Organization", icon: GearIcon }, { id: "members", label: "Members", icon: UserIcon }, - { id: "audit-log", label: "Audit Log", icon: HistoryIcon }, - // Spans alerting, ingestion and integrations, so it sits at workspace level rather than - // under any one of them. - { id: "setup-audit", label: "Setup Audit", icon: CircleCheckIcon }, { id: "billing", label: "Billing", icon: CreditCardIcon }, + { id: "audit-log", label: "Audit Log", icon: HistoryIcon }, + ], + }, + { + id: "connections", + title: "Connections", + items: [ + // A sibling page rather than a tab, and the most-visited row in the nav, so it leads its + // group. Nothing about it being a route should push it down the list. + { id: "integrations", label: "Integrations", icon: GridIcon, to: "/integrations" }, + { id: "api-keys", label: "API Keys", icon: KeyIcon }, + { id: "mcp", label: "MCP", icon: SquareTerminalIcon }, ], }, { @@ -123,27 +137,28 @@ const navSections: SettingsNavSection[] = [ title: "Data", items: [ { id: "ingestion", label: "Ingestion", icon: ServerIcon }, - { id: "api-keys", label: "API Keys", icon: KeyIcon }, - { id: "developer", label: "API Reference", icon: CodeIcon }, - { id: "mcp", label: "MCP", icon: SquareTerminalIcon }, + { id: "data-platform", label: "Data Platform", icon: DatabaseIcon }, + // A diagnostic over everything that feeds Maple, so it closes the group it reports on. + { id: "setup-audit", label: "Setup Audit", icon: CircleCheckIcon }, ], - links: [{ id: "integrations", label: "Integrations", icon: GridIcon, to: "/integrations" }], }, { - id: "behavior", - title: "Behavior", + id: "alerting", + title: "Alerting", items: [ { id: "notifications", label: "Notifications", icon: BellIcon }, { id: "automation", label: "Automation", icon: ShieldIcon }, ], }, - { - id: "infra", - title: "Infrastructure", - items: [{ id: "data-platform", label: "Data Platform", icon: DatabaseIcon }], - }, ] +/** + * The tab rows of a section list, dropping sibling-page links. `visibleItems` feeds tab resolution, + * and `/integrations` is a route rather than a tab — it must never surface as a fallback tab id. + */ +const tabItems = (sections: ReadonlyArray): ReadonlyArray => + sections.flatMap((section) => section.items.filter((row): row is NavItem => !("to" in row))) + /** * Permission-filtered settings nav sections, shared by /settings and the * /integrations hub (which renders the same sidebar). @@ -176,12 +191,12 @@ export function useVisibleSettingsSections() { return true }), })) - .filter((section) => section.items.length > 0 || (section.links?.length ?? 0) > 0) + .filter((section) => section.items.length > 0) if (!isClerkAuthEnabled) { return { visibleSections, - visibleItems: visibleSections.flatMap((s) => s.items), + visibleItems: tabItems(visibleSections), isAdmin: true, canAccessDataPlatform: true, canAccessAi: true, @@ -204,11 +219,11 @@ export function useVisibleSettingsSections() { return true }), })) - .filter((section) => section.items.length > 0 || (section.links?.length ?? 0) > 0) + .filter((section) => section.items.length > 0) return { visibleSections: dataSections, - visibleItems: dataSections.flatMap((s) => s.items), + visibleItems: tabItems(dataSections), isAdmin, canAccessDataPlatform, canAccessAi, diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 2f77ffd81..b9c0f1f5e 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -9,7 +9,6 @@ import { MembersSection } from "@/components/settings/members-section" import { IngestionSection } from "@/components/settings/ingestion-section" import { ApiKeysSection } from "@/components/settings/api-keys-section" import { AuditLogSection } from "@/components/settings/audit-log-section" -import { DeveloperSection } from "@/components/settings/developer-section" import { McpSection } from "@/components/settings/mcp-section" import { NotificationsSection } from "@/components/settings/notifications-section" import { AutomationSection } from "@/components/settings/automation-section" @@ -25,8 +24,8 @@ import { type SettingsTab, } from "@/components/settings/settings-nav" -/** Pre-hub tabs that moved to /integrations — kept decodable so old deep links redirect. */ -const legacyTabValues = ["connectors", "integrations", "escalations", "ai"] as const +/** Retired tabs — kept decodable so old deep links redirect instead of landing on a blank page. */ +const legacyTabValues = ["connectors", "integrations", "escalations", "ai", "developer"] as const const SettingsSearch = Schema.Struct({ tab: Schema.optional(Schema.Literals([...settingsTabValues, ...legacyTabValues])), @@ -59,6 +58,10 @@ function SettingsPage() { if (search.tab === "escalations" || search.tab === "ai") { return } + // "API Reference" is now the reference block at the foot of the API Keys page. + if (search.tab === "developer") { + return + } const activeTab = resolveActiveSettingsTab(search.tab, visibleItems) @@ -137,9 +140,6 @@ function SettingsPage() { {activeTab === "setup-audit" && } {activeTab === "ingestion" && } {activeTab === "api-keys" && } - {activeTab === "developer" && ( - handleTabSelect("api-keys")} /> - )} {activeTab === "mcp" && } {activeTab === "notifications" && } {activeTab === "automation" && ( From 8e04dcbdf5be7f4ce5b4adfd7900b4ea708de48f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 11 Sep 2026 21:56:26 +0200 Subject: [PATCH 05/12] refactor(ai): move the api/AI shared seams into the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preparation for extracting MCP, chat and the investigation fan-out into their own Worker. Four modules sat inside the AI surface while services that will stay in `apps/api` imported them, so the split could not be a clean cut. Each one moves to where both sides can reach it: - `chatSessionStub` and the Durable Object's RPC surface become `@maple/domain/chat-session-stub`. Its own file rather than the wire contract, because addressing the object means handling an unparsed namespace handle off a Worker env — the boundary marker belongs on that, not on the schemas. - `widthFor` joins `@maple/domain/investigation-fanout`, beside the payload whose `maxWidth` it computes. Its tests come with it. - `incident-context.ts` moves whole. The staying services and the moving agents build the same message, and two copies is how the wording silently drifts. - `McpToolSurface` joins `@maple/domain/mcp-manifest`, because the audit log records it and the audit log is read by code that runs no MCP. Also deletes the internal Worker-to-Worker RPC surface, which has had no caller since it was written: `apps/api/src/internal-rpc.ts`, `worker/rpc.ts`, their wiring, and the `MapleApiRpcContract` half of the domain module. What survived 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 — so it becomes `@maple/domain/mcp-tool-contract`, with `McpToolNotFoundError` tagged `@maple/mcp/ToolNotFoundError`. Nothing persists that tag on a wire; the only readers are three `catchTag` call sites in this repo. The remaining edges from staying code into the moving set are now exactly the four files that themselves move later: the two runtime graphs and the two chat route modules. Co-Authored-By: Claude Opus 5 --- apps/api/src/chat/ChatSession.ts | 2 +- apps/api/src/chat/tools.ts | 3 +- apps/api/src/internal-rpc.test.ts | 97 ------------------- apps/api/src/internal-rpc.ts | 61 ------------ apps/api/src/mcp/dispatcher.test.ts | 6 +- apps/api/src/mcp/dispatcher.ts | 28 +----- apps/api/src/mcp/server.ts | 2 +- apps/api/src/mcp/tools/llm-tools.ts | 3 +- apps/api/src/mcp/tools/registry.ts | 4 +- apps/api/src/routes/internal/chat.http.ts | 4 +- .../src/routes/v1/chat-sessions.http.test.ts | 2 +- apps/api/src/routes/v1/chat-sessions.http.ts | 2 +- apps/api/src/runtime/graph-boundaries.test.ts | 1 - apps/api/src/services/audit/audit-access.ts | 2 +- .../src/services/errors/AiTriageService.ts | 2 +- .../services/errors/InvestigationService.ts | 4 +- .../src/services/errors/ai-triage-enqueue.ts | 6 +- .../services/errors/investigation-route.ts | 2 +- apps/api/src/worker.ts | 4 +- apps/api/src/worker/modules.ts | 1 - apps/api/src/worker/rpc.ts | 51 ---------- .../InvestigationFanoutWorkflow.run.ts | 25 +++-- apps/api/src/workflows/hypothesis-agent.ts | 2 +- apps/api/src/workflows/plan-normalize.test.ts | 25 +---- apps/api/src/workflows/plan-normalize.ts | 33 ------- apps/api/src/workflows/planner-agent.ts | 2 +- apps/api/src/workflows/validator-agent.ts | 2 +- packages/domain/package.json | 6 +- .../domain/src/chat-session-stub.ts | 30 +++--- .../domain/src}/incident-context.ts | 2 +- packages/domain/src/index.ts | 2 +- packages/domain/src/internal-rpc.ts | 79 --------------- .../domain/src/investigation-fanout.test.ts | 25 +++++ packages/domain/src/investigation-fanout.ts | 38 ++++++++ packages/domain/src/mcp-manifest.ts | 22 +++++ packages/domain/src/mcp-tool-contract.ts | 23 +++++ 36 files changed, 179 insertions(+), 424 deletions(-) delete mode 100644 apps/api/src/internal-rpc.test.ts delete mode 100644 apps/api/src/internal-rpc.ts delete mode 100644 apps/api/src/worker/rpc.ts rename apps/api/src/chat/session.ts => packages/domain/src/chat-session-stub.ts (66%) rename {apps/api/src/workflows => packages/domain/src}/incident-context.ts (99%) delete mode 100644 packages/domain/src/internal-rpc.ts create mode 100644 packages/domain/src/investigation-fanout.test.ts create mode 100644 packages/domain/src/mcp-tool-contract.ts diff --git a/apps/api/src/chat/ChatSession.ts b/apps/api/src/chat/ChatSession.ts index f7e90dc03..f9e42c2bf 100644 --- a/apps/api/src/chat/ChatSession.ts +++ b/apps/api/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 { diff --git a/apps/api/src/chat/tools.ts b/apps/api/src/chat/tools.ts index f78eaf750..483015039 100644 --- a/apps/api/src/chat/tools.ts +++ b/apps/api/src/chat/tools.ts @@ -18,7 +18,8 @@ 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 type { McpToolExecutorApi } from "@/mcp/dispatcher" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" import { buildMapleToolkit, MapleToolFailure, summarizeToolFailure } from "@/mcp/tools/llm-tools" import type { TenantContext } from "@/services/auth/tenant-context" 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/mcp/dispatcher.test.ts b/apps/api/src/mcp/dispatcher.test.ts index a13b2a14a..a3aa392ce 100644 --- a/apps/api/src/mcp/dispatcher.test.ts +++ b/apps/api/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/api/src/mcp/dispatcher.ts index e7e306730..f0edbe6ba 100644 --- a/apps/api/src/mcp/dispatcher.ts +++ b/apps/api/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/server.ts b/apps/api/src/mcp/server.ts index d9ac9d48d..c76ce750e 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/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/llm-tools.ts b/apps/api/src/mcp/tools/llm-tools.ts index 9e61e7d49..725ac3a81 100644 --- a/apps/api/src/mcp/tools/llm-tools.ts +++ b/apps/api/src/mcp/tools/llm-tools.ts @@ -14,7 +14,8 @@ */ import { Cause, Effect, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi, McpToolSurface } from "@/mcp/dispatcher" +import type { McpToolExecutorApi } from "@/mcp/dispatcher" +import type { McpToolSurface } from "@maple/domain/mcp-manifest" import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" import { truncateToolOutput } from "@/mcp/tools/tool-output" import type { TenantContext } from "@/services/auth/tenant-context" diff --git a/apps/api/src/mcp/tools/registry.ts b/apps/api/src/mcp/tools/registry.ts index 6272990ed..d648bbdf8 100644 --- a/apps/api/src/mcp/tools/registry.ts +++ b/apps/api/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/routes/internal/chat.http.ts b/apps/api/src/routes/internal/chat.http.ts index a45540bba..6f1d447af 100644 --- a/apps/api/src/routes/internal/chat.http.ts +++ b/apps/api/src/routes/internal/chat.http.ts @@ -11,7 +11,7 @@ import { 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 { chatSessionStub } from "@maple/domain/chat-session-stub" import { mapleToolCatalog } from "@/mcp/tools/registry" import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" import { McpToolExecutor } from "@/mcp/dispatcher" @@ -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/api/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/api/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/api/src/routes/v1/chat-sessions.http.ts index 84824ccad..a57a61ff3 100644 --- a/apps/api/src/routes/v1/chat-sessions.http.ts +++ b/apps/api/src/routes/v1/chat-sessions.http.ts @@ -30,10 +30,10 @@ 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" diff --git a/apps/api/src/runtime/graph-boundaries.test.ts b/apps/api/src/runtime/graph-boundaries.test.ts index caf25872a..9ca23c272 100644 --- a/apps/api/src/runtime/graph-boundaries.test.ts +++ b/apps/api/src/runtime/graph-boundaries.test.ts @@ -43,7 +43,6 @@ describe("API runtime graph boundaries", () => { ["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"], 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/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/worker.ts b/apps/api/src/worker.ts index 3ab8854c5..df2c662e7 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -34,7 +34,6 @@ 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" @@ -128,10 +127,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. 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/InvestigationFanoutWorkflow.run.ts b/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts index cd31ef848..31f6ac2a1 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts +++ b/apps/api/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" @@ -70,8 +71,8 @@ import { import { McpServicesLive } from "../runtime/mcp-service-graph" import { durableStep } from "./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/hypothesis-agent.ts b/apps/api/src/workflows/hypothesis-agent.ts index 277e239db..0a2f9dbef 100644 --- a/apps/api/src/workflows/hypothesis-agent.ts +++ b/apps/api/src/workflows/hypothesis-agent.ts @@ -21,7 +21,7 @@ import { hypothesisAgent } from "@/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/plan-normalize.test.ts b/apps/api/src/workflows/plan-normalize.test.ts index fd6e91566..22eb72388 100644 --- a/apps/api/src/workflows/plan-normalize.test.ts +++ b/apps/api/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/api/src/workflows/plan-normalize.ts index a91221715..aabc5e158 100644 --- a/apps/api/src/workflows/plan-normalize.ts +++ b/apps/api/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/api/src/workflows/planner-agent.ts index c20938dbb..e4d39ae58 100644 --- a/apps/api/src/workflows/planner-agent.ts +++ b/apps/api/src/workflows/planner-agent.ts @@ -18,7 +18,7 @@ import { Effect, Option } from "effect" import { plannerAgent } from "@/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/validator-agent.ts b/apps/api/src/workflows/validator-agent.ts index 0e3fe65e7..0414ac24f 100644 --- a/apps/api/src/workflows/validator-agent.ts +++ b/apps/api/src/workflows/validator-agent.ts @@ -22,7 +22,7 @@ import { AGENTS } from "@/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/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/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, + }, +) {} From 6942d000f2c0251eb41c588b7fb2687be3da0c81 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 00:08:33 +0200 Subject: [PATCH 06/12] feat(ai): add the maple-ai Worker, hosting nothing yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of moving every agent surface out of apps/api: the MCP server and its tools, the chat agent and its Durable Object, and the investigation fan-out. They move together because they are one thing wearing three hats — all three reach the same tool registry in-process, so extracting any one alone leaves the registry behind. That is what made the September attempt worth 1%. Measured on the api's module graph before committing to this (rolldown, unminified, same tree). 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 — ~40% of that on the http graph alone. The other half is per-request and not in those numbers: a `/mcp` call builds `AllRoutes` and `ApiAuthLive` today, and a `/v2` call builds 47 tool schemas. This commit is the skeleton only. It hosts nothing, serves `/health`, and changes no behaviour anywhere: apps/api still owns every AI route. Shaped after apps/alerting, which is the proven satellite-Worker form — single-module alchemy class, `__ALCHEMY_RUNTIME__`-guarded props, heavy graphs behind a dynamic import so module scope stays inside Cloudflare's upload-validation CPU budget. It carries the api's `strictExecutionOrder: false` override for the same reason the api does, because it will carry the same drizzle and Effect-Schema graph. Registration: yielded and dev-served from the root stack, `ai` added to DEV_APPS, to MapleDbConsumer so it gets its own Postgres connection budget, to knip's entries, and to the CI install filters. `workersDev: false` and no custom domain — it is reached only over a service binding, which is what will keep `/mcp` on api.maple.dev and its OAuth issuer and RFC 8707 resource identifiers unchanged. One prd prerequisite is recorded as a TODO rather than done, because it is not a code change: `ai` shares the `maple-prd` Hyperdrive config until a dedicated one is created in the dashboard. Their origin connection limits sum against PlanetScale's max_connections, and sharing api's pool is how api's connections got starved before alerting got its own. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 6 +- alchemy.run.ts | 7 ++ apps/ai/package.json | 20 ++++ apps/ai/src/app.test.ts | 11 +++ apps/ai/src/app.ts | 16 ++++ apps/ai/src/worker.ts | 127 +++++++++++++++++++++++++ apps/ai/tsconfig.json | 28 ++++++ apps/ai/vitest.config.ts | 14 +++ bun.lock | 16 ++++ knip.json | 3 + packages/infra/src/cloudflare/stage.ts | 7 +- packages/infra/src/dev-urls.ts | 1 + 12 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 apps/ai/package.json create mode 100644 apps/ai/src/app.test.ts create mode 100644 apps/ai/src/app.ts create mode 100644 apps/ai/src/worker.ts create mode 100644 apps/ai/tsconfig.json create mode 100644 apps/ai/vitest.config.ts 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/alchemy.run.ts b/alchemy.run.ts index 462dd19e2..0c4d33f26 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -38,6 +38,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 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" @@ -255,6 +256,12 @@ export default Alchemy.Stack( const localUi = isDevServer ? undefined : yield* LocalUi + // Every agent surface — the MCP server and its tools, the chat agent, the + // investigation fan-out. Standalone for now: the api still serves `/mcp` and + // the chat routes, and starts forwarding them here once they move. + const ai = yield* MapleAi + yield* serveWorker("ai", ai) + const alerting = yield* Alerting yield* serveWorker("alerting", alerting) diff --git a/apps/ai/package.json b/apps/ai/package.json new file mode 100644 index 000000000..485aa2515 --- /dev/null +++ b/apps/ai/package.json @@ -0,0 +1,20 @@ +{ + "name": "@maple/ai", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@maple/infra": "workspace:*", + "effect": "catalog:effect" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@effect/language-service": "catalog:effect", + "@types/node": "catalog:tooling", + "typescript": "catalog:tooling", + "vitest": "catalog:" + } +} diff --git a/apps/ai/src/app.test.ts b/apps/ai/src/app.test.ts new file mode 100644 index 000000000..c5aa76fa6 --- /dev/null +++ b/apps/ai/src/app.test.ts @@ -0,0 +1,11 @@ +import { Effect } from "effect" +import { expect, it } from "vitest" +import { fetch } from "./app" + +it("answers /health before any service graph exists", async () => { + // The point of the check: it must not depend on the layer graph, the database, + // or a binding. A health endpoint that builds the graph reports the graph's + // health, which is the thing most likely to be broken when you ask. + const response = await Effect.runPromise(fetch) + expect(response.status).toBe(200) +}) diff --git a/apps/ai/src/app.ts b/apps/ai/src/app.ts new file mode 100644 index 000000000..678fa5732 --- /dev/null +++ b/apps/ai/src/app.ts @@ -0,0 +1,16 @@ +/** + * The AI worker's request surface. + * + * Imported lazily from `worker.ts` so the graph it will carry — the MCP + * transport, the chat routes, and the service layers under both — stays off the + * startup path, where Cloudflare evaluates module scope under a fixed CPU budget + * and 47 tool schemas have already exhausted it once. + * + * Only `/health` lives here today. It answers before any graph exists, which is + * what makes it useful: it says the isolate booted and its bindings resolved, + * not that the database is reachable. + */ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" + +export const fetch = Effect.succeed(HttpServerResponse.text("ok")) diff --git a/apps/ai/src/worker.ts b/apps/ai/src/worker.ts new file mode 100644 index 000000000..a60beb583 --- /dev/null +++ b/apps/ai/src/worker.ts @@ -0,0 +1,127 @@ +/** + * 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, + MapleDb, + MapleStack, + type MapleStage, + resolveWorkerName, +} from "@maple/infra/cloudflare" +import { appUrlsEnv, authEnv, merge, selfObservabilityEnv, tinybirdEnv } from "@maple/infra/env" +import { WorkerTelemetry } from "@maple/infra/worker-telemetry" +import * as Cloudflare from "alchemy/Cloudflare" +import { Effect, Layer } from "effect" + +/** + * 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: MapleStage }) => ({}) + +/** + * 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(tinybirdEnv, authEnv, appUrlsEnv, selfObservabilityEnv(stage)) + +/** + * 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 default class MapleAi extends Cloudflare.Worker()( + "ai", + props, + Effect.gen(function* () { + // `MAPLE_DB` in the stage's flavor. The agents read and write the same + // application database the api does — investigations, error issues, alert + // rules — so this is a connection budget of its own, not a share of api's. + yield* MapleDb("ai") + // The routes arrive here in the next phase, behind this import: the MCP + // transport and the chat routes both pull the service graph, which has no + // business in startup validation or in the deploy process. + const app = yield* cachedRecoverable(Effect.promise(() => import("./app"))) + return { fetch: (yield* app).fetch } + }).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( + Cloudflare.Hyperdrive.ConnectBinding, + WorkerTelemetry({ serviceName: "maple-ai" }), + ), + ), + ), +) {} diff --git a/apps/ai/tsconfig.json b/apps/ai/tsconfig.json new file mode 100644 index 000000000..90d8c4a63 --- /dev/null +++ b/apps/ai/tsconfig.json @@ -0,0 +1,28 @@ +{ + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "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": { + "@/*": ["../api/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..41ec85e89 --- /dev/null +++ b/apps/ai/vitest.config.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("../api/src", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}) diff --git a/bun.lock b/bun.lock index b19587e21..70ac0f6c1 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,20 @@ "typescript": "catalog:tooling", }, }, + "apps/ai": { + "name": "@maple/ai", + "dependencies": { + "@maple/infra": "workspace:*", + "effect": "catalog:effect", + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:alchemy", + "@effect/language-service": "catalog:effect", + "@types/node": "catalog:tooling", + "typescript": "catalog:tooling", + "vitest": "catalog:", + }, + }, "apps/alerting": { "name": "@maple/alerting", "dependencies": { @@ -1416,6 +1430,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/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/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", From 6e60ffbb8c4869a8b2f3b93070ad2d856fd48f99 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 00:17:13 +0200 Subject: [PATCH 07/12] fix(ai): serve /health only, and 404 the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skeleton's fetch handler returned 200 "ok" for every path while its own comment and test said `/health`. Harmless today, since nothing routes here yet, but the point of landing the skeleton first is that the next phase moves real routes onto it — and a worker that 200s everything turns a mistyped path into a silent success instead of a visible 404. Matches the api's liveness check: GET `/health` only, answered without touching the layer graph, the database, or a binding, because a check that builds the graph reports the graph's health rather than the isolate's. Co-Authored-By: Claude Opus 5 --- apps/ai/src/app.test.ts | 34 +++++++++++++++++++++++++++------- apps/ai/src/app.ts | 24 +++++++++++++++++++----- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/ai/src/app.test.ts b/apps/ai/src/app.test.ts index c5aa76fa6..9be37d799 100644 --- a/apps/ai/src/app.test.ts +++ b/apps/ai/src/app.test.ts @@ -1,11 +1,31 @@ import { Effect } from "effect" -import { expect, it } from "vitest" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { describe, expect, it } from "vitest" import { fetch } from "./app" -it("answers /health before any service graph exists", async () => { - // The point of the check: it must not depend on the layer graph, the database, - // or a binding. A health endpoint that builds the graph reports the graph's - // health, which is the thing most likely to be broken when you ask. - const response = await Effect.runPromise(fetch) - expect(response.status).toBe(200) +const respondTo = (method: string, url: string) => + Effect.runPromise( + fetch.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(new Request(`https://maple-ai.internal${url}`, { method })), + ), + ), + ) + +describe("the AI worker's request surface", () => { + it("answers /health without building a service graph", async () => { + const response = await respondTo("GET", "/health") + expect(response.status).toBe(200) + }) + + it("404s every other path, so a route that should be served and is not stays visible", async () => { + for (const path of ["/", "/mcp", "/api/chat/sessions/o:t/events"]) { + expect((await respondTo("GET", path)).status).toBe(404) + } + }) + + it("does not answer /health for a non-GET", async () => { + expect((await respondTo("POST", "/health")).status).toBe(404) + }) }) diff --git a/apps/ai/src/app.ts b/apps/ai/src/app.ts index 678fa5732..714c5e62d 100644 --- a/apps/ai/src/app.ts +++ b/apps/ai/src/app.ts @@ -6,11 +6,25 @@ * startup path, where Cloudflare evaluates module scope under a fixed CPU budget * and 47 tool schemas have already exhausted it once. * - * Only `/health` lives here today. It answers before any graph exists, which is - * what makes it useful: it says the isolate booted and its bindings resolved, - * not that the database is reachable. + * Only `/health` exists today, and it answers the way the api's does: without + * touching the layer graph, the database, or a binding. That is what makes it + * worth having — a liveness check that builds the graph reports the graph's + * health, which is the thing most likely to be broken when you ask. Everything + * else 404s until the real routes land, so a path that should be served and + * isn't is visible rather than silently 200. */ import { Effect } from "effect" -import { HttpServerResponse } from "effect/unstable/http" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -export const fetch = Effect.succeed(HttpServerResponse.text("ok")) +const pathOf = (url: string): string => { + const query = url.indexOf("?") + return query === -1 ? url : url.slice(0, query) +} + +export const fetch = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.method === "GET" && pathOf(request.url) === "/health") { + return HttpServerResponse.text("OK") + } + return HttpServerResponse.text("maple-ai: no routes yet", { status: 404 }) +}) From 067595b0b88ae97bbc036130a35a35dce1808816 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 00:40:41 +0200 Subject: [PATCH 08/12] refactor(ai): move MCP, chat and the investigation fan-out into apps/ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP — the code has moved but nothing serves it yet. apps/ai still answers only /health, and apps/api no longer mounts the routes, so /mcp and the chat surface are dark until the worker wiring and the api forward land. Not pushable. The plan split this into three phases and that was wrong: chat and the investigation workflow reach the MCP tool registry in-process, so separating them would mean a cross-worker call per tool invocation. They move together. Alias convention, which took a wrong turn first: in apps/ai, `@/` is apps/api's source and `@ai/` is its own. That looks backwards until you notice this program compiles api's modules too, and those spell their internal imports `@/`. Pointing `@/` at apps/ai resolved every one of them into the wrong tree — 1015 errors. apps/alerting already had it right. The chat HTTP group moved out of MapleInternalApi into its own MapleAiApi: an HttpApi must be implemented in full by whoever builds it, so a group cannot straddle two Workers. Paths are unchanged, since api will forward /internal/chat/* here, so this is a change of which Worker answers rather than of what the dashboard calls. apps/web gains a matching atom client and its one call site moves across. Two things surfaced that were not part of the plan: `buildIsolateHandler`'s guard was vacuous. It exists to reject services a raw route reads from the request context — the bug that took chat down in September — but `McpLive` widened the whole route composition to `any`, so the constraint held over nothing. With MCP gone the `any` went, the guard bound for real, and it immediately caught the chat group. Its output parameter is now open, with the requirement side left exactly as strict as it was meant to be. apps/api excludes tests from tsc. Mirrored here rather than fixing the 47 type errors that exclusion has been hiding, because that is a decision about api's config too, not something to change while relocating files. Boundary tests followed their subjects: the MCP and chat entrypoint assertions now live in apps/ai, and AlertReadModelsService keeps its route half in api while the tool half moves. Co-Authored-By: Claude Opus 5 --- apps/ai/package.json | 21 +++- apps/{api => ai}/src/chat/ChatSession.test.ts | 0 apps/{api => ai}/src/chat/ChatSession.ts | 0 .../src/chat/ChatSessionObject.test.ts | 0 apps/{api => ai}/src/chat/agents.test.ts | 2 +- apps/{api => ai}/src/chat/agents.ts | 6 +- apps/{api => ai}/src/chat/budgets.ts | 0 apps/{api => ai}/src/chat/delegation.test.ts | 6 +- apps/{api => ai}/src/chat/delegation.ts | 4 +- apps/{api => ai}/src/chat/events.test.ts | 7 +- apps/{api => ai}/src/chat/events.ts | 0 apps/{api => ai}/src/chat/permissions.ts | 4 +- apps/{api => ai}/src/chat/prompts.ts | 0 apps/{api => ai}/src/chat/run.test.ts | 0 apps/{api => ai}/src/chat/run.ts | 4 +- apps/{api => ai}/src/chat/tools.test.ts | 0 apps/{api => ai}/src/chat/tools.ts | 4 +- .../src/chat/turn-metering.test.ts | 0 apps/{api => ai}/src/chat/turn-runner.ts | 6 +- .../{api => ai}/src/mcp/__evals__/BASELINE.md | 0 apps/{api => ai}/src/mcp/__evals__/README.md | 0 .../src/mcp/__evals__/cli-scenarios.eval.ts | 0 .../src/mcp/__evals__/disambiguation.eval.ts | 0 .../src/mcp/__evals__/eval-runtime.ts | 4 +- .../src/mcp/__evals__/execution.eval.ts | 0 .../src/mcp/__evals__/fake-warehouse.ts | 0 .../{api => ai}/src/mcp/__evals__/fixtures.ts | 0 .../src/mcp/__evals__/issue-workflow.eval.ts | 0 apps/{api => ai}/src/mcp/__evals__/model.ts | 0 .../src/mcp/__evals__/observability.eval.ts | 0 .../src/mcp/__evals__/regression.test.ts | 0 apps/{api => ai}/src/mcp/__evals__/scorers.ts | 0 apps/{api => ai}/src/mcp/__evals__/tools.ts | 4 +- apps/{api => ai}/src/mcp/__evals__/utils.ts | 0 apps/{api => ai}/src/mcp/app.test.ts | 0 apps/{api => ai}/src/mcp/app.ts | 0 apps/{api => ai}/src/mcp/dispatcher.test.ts | 0 apps/{api => ai}/src/mcp/dispatcher.ts | 0 .../src/mcp/expected-failures.test.ts | 0 apps/{api => ai}/src/mcp/expected-failures.ts | 0 .../src/mcp/lib/chart-statistics.test.ts | 0 .../src/mcp/lib/chart-statistics.ts | 0 .../src/mcp/lib/dashboard-docs-drift.test.ts | 0 .../src/mcp/lib/dashboard-mutations.test.ts | 4 +- .../src/mcp/lib/dashboard-mutations.ts | 4 +- .../src/mcp/lib/dashboard-schema-doc.test.ts | 0 .../src/mcp/lib/dashboard-schema-doc.ts | 0 .../src/mcp/lib/format-query-result.ts | 2 +- apps/{api => ai}/src/mcp/lib/format.ts | 0 .../{api => ai}/src/mcp/lib/inspect-widget.ts | 0 apps/{api => ai}/src/mcp/lib/limits.test.ts | 0 apps/{api => ai}/src/mcp/lib/limits.ts | 0 .../{api => ai}/src/mcp/lib/map-http-error.ts | 2 +- .../src/mcp/lib/map-warehouse-error.test.ts | 0 .../src/mcp/lib/map-warehouse-error.ts | 2 +- apps/{api => ai}/src/mcp/lib/next-steps.ts | 0 .../src/mcp/lib/panel-type.test.ts | 0 apps/{api => ai}/src/mcp/lib/panel-type.ts | 0 .../src/mcp/lib/query-spec-tokens.test.ts | 0 .../src/mcp/lib/query-spec-tokens.ts | 0 .../src/mcp/lib/query-warehouse.ts | 6 +- .../src/mcp/lib/raw-sql-widget.test.ts | 0 .../{api => ai}/src/mcp/lib/raw-sql-widget.ts | 0 .../src/mcp/lib/render-trace.test.ts | 0 apps/{api => ai}/src/mcp/lib/render-trace.ts | 0 .../src/mcp/lib/resolve-actor.test.ts | 0 apps/{api => ai}/src/mcp/lib/resolve-actor.ts | 2 +- .../lib/resolve-dashboard-time-range.test.ts | 0 .../mcp/lib/resolve-dashboard-time-range.ts | 0 .../{api => ai}/src/mcp/lib/resolve-tenant.ts | 4 +- .../src/mcp/lib/run-raw-sql.test.ts | 0 apps/{api => ai}/src/mcp/lib/run-raw-sql.ts | 0 .../{api => ai}/src/mcp/lib/span-tree.test.ts | 0 apps/{api => ai}/src/mcp/lib/span-tree.ts | 0 .../src/mcp/lib/structured-output.ts | 0 apps/{api => ai}/src/mcp/lib/time.test.ts | 0 apps/{api => ai}/src/mcp/lib/time.ts | 0 .../lib/validate-widget-renderability.test.ts | 0 .../mcp/lib/validate-widget-renderability.ts | 0 .../src/mcp/prompts/debug-errors.ts | 0 .../src/mcp/prompts/incident-triage.ts | 0 .../src/mcp/prompts/latency-analysis.ts | 0 .../src/mcp/resources/instructions.ts | 0 apps/{api => ai}/src/mcp/server.ts | 0 .../mcp/tools/__tests__/audit-setup.test.ts | 2 +- .../__tests__/dashboard-concurrency.test.ts | 0 ...et-instrumentation-recommendations.test.ts | 2 +- .../mcp/tools/__tests__/query-funnel.test.ts | 10 +- .../__tests__/run-sql-unknown-column.test.ts | 0 .../__tests__/run-sql-unknown-table.test.ts | 0 .../src/mcp/tools/add-dashboard-widget.ts | 14 +-- .../tools/alert-read-models.boundary.test.ts | 27 ++++++ apps/{api => ai}/src/mcp/tools/audit-setup.ts | 8 +- .../src/mcp/tools/claim-error-issue.ts | 6 +- .../src/mcp/tools/comment-on-error-issue.ts | 6 +- .../src/mcp/tools/compare-periods.ts | 10 +- .../src/mcp/tools/create-alert-rule.ts | 6 +- .../src/mcp/tools/create-dashboard.ts | 10 +- .../src/mcp/tools/delete-alert-rule.ts | 4 +- .../mcp/tools/describe-dashboard-schema.ts | 2 +- .../mcp/tools/describe-warehouse-tables.ts | 0 .../src/mcp/tools/diagnose-service.ts | 12 +-- .../{api => ai}/src/mcp/tools/error-detail.ts | 12 +-- .../src/mcp/tools/explore-attributes.ts | 16 ++-- apps/{api => ai}/src/mcp/tools/find-errors.ts | 12 +-- .../src/mcp/tools/find-slow-traces.ts | 14 +-- .../src/mcp/tools/get-alert-rule.ts | 8 +- .../src/mcp/tools/get-dashboard.ts | 4 +- .../src/mcp/tools/get-incident-timeline.ts | 6 +- .../get-instrumentation-recommendations.ts | 12 +-- .../mcp/tools/get-service-top-operations.ts | 16 ++-- .../src/mcp/tools/get-session-traces.ts | 12 +-- .../src/mcp/tools/get-session-transcript.ts | 12 +-- .../src/mcp/tools/inspect-chart-data.ts | 12 +-- .../{api => ai}/src/mcp/tools/inspect-span.ts | 10 +- .../src/mcp/tools/inspect-trace.ts | 12 +-- .../src/mcp/tools/link-pull-request.ts | 6 +- .../src/mcp/tools/list-alert-checks.ts | 8 +- .../src/mcp/tools/list-alert-incidents.ts | 8 +- .../src/mcp/tools/list-alert-rules.ts | 10 +- .../src/mcp/tools/list-dashboards.ts | 8 +- .../src/mcp/tools/list-error-incidents.ts | 6 +- .../src/mcp/tools/list-error-issue-events.ts | 6 +- .../src/mcp/tools/list-error-issues.ts | 8 +- .../{api => ai}/src/mcp/tools/list-metrics.ts | 12 +-- .../src/mcp/tools/list-product-events.ts | 14 +-- .../src/mcp/tools/list-services.ts | 12 +-- .../src/mcp/tools/llm-tools.test.ts | 2 +- apps/{api => ai}/src/mcp/tools/llm-tools.ts | 8 +- .../src/mcp/tools/mine-log-patterns.ts | 12 +-- .../src/mcp/tools/mutating.test.ts | 2 +- apps/{api => ai}/src/mcp/tools/mutating.ts | 0 apps/{api => ai}/src/mcp/tools/propose-fix.ts | 6 +- apps/{api => ai}/src/mcp/tools/query-data.ts | 10 +- .../{api => ai}/src/mcp/tools/query-funnel.ts | 14 +-- .../src/mcp/tools/register-agent.ts | 4 +- .../src/mcp/tools/registry.test.ts | 0 apps/{api => ai}/src/mcp/tools/registry.ts | 0 .../src/mcp/tools/release-error-issue.ts | 6 +- .../src/mcp/tools/remove-dashboard-widget.ts | 4 +- .../mcp/tools/reorder-dashboard-widgets.ts | 4 +- .../mcp/tools/replace-dashboard-widgets.ts | 14 +-- apps/{api => ai}/src/mcp/tools/run-sql.ts | 12 +-- .../src/mcp/tools/runtime-requirements.ts | 0 .../{api => ai}/src/mcp/tools/sandbox.test.ts | 2 +- apps/{api => ai}/src/mcp/tools/sandbox.ts | 2 +- apps/{api => ai}/src/mcp/tools/search-logs.ts | 14 +-- .../src/mcp/tools/search-sessions.ts | 14 +-- .../src/mcp/tools/search-traces.ts | 16 ++-- apps/{api => ai}/src/mcp/tools/service-map.ts | 10 +- .../src/mcp/tools/set-issue-severity.ts | 6 +- apps/{api => ai}/src/mcp/tools/source-code.ts | 2 +- .../src/mcp/tools/tool-output.test.ts | 0 apps/{api => ai}/src/mcp/tools/tool-output.ts | 0 .../src/mcp/tools/transition-error-issue.ts | 6 +- apps/{api => ai}/src/mcp/tools/types.ts | 0 .../src/mcp/tools/update-alert-rule.ts | 6 +- .../src/mcp/tools/update-dashboard-widget.ts | 14 +-- .../src/mcp/tools/update-dashboard.ts | 8 +- .../tools/update-error-notification-policy.ts | 4 +- .../src/mcp/transport/stateless-http.ts | 0 apps/{api => ai}/src/platform/Llm.test.ts | 0 apps/{api => ai}/src/platform/Llm.ts | 0 .../src/platform/WorkersAiHttpClient.test.ts | 0 .../src/platform/WorkersAiHttpClient.ts | 0 .../src/platform/genai-spans.test.ts | 0 apps/{api => ai}/src/platform/genai-spans.ts | 0 .../src/platform/model-call-span.test.ts | 8 +- .../src/routes/internal/chat.http.test.ts | 6 +- .../src/routes/internal/chat.http.ts | 10 +- .../src/routes/v1/chat-sessions.http.test.ts | 0 .../src/routes/v1/chat-sessions.http.ts | 2 +- apps/ai/src/runtime/graph-boundaries.test.ts | 96 +++++++++++++++++++ .../src/runtime/mcp-service-graph.ts | 6 +- .../InvestigationFanoutWorkflow.run.test.ts | 2 +- .../InvestigationFanoutWorkflow.run.ts | 8 +- .../workflows/InvestigationFanoutWorkflow.ts | 4 +- .../workflows/__evals__/diagnosis-fixtures.ts | 0 .../__evals__/diagnosis-scorers.test.ts | 0 .../workflows/__evals__/diagnosis-scorers.ts | 0 .../src/workflows/__evals__/diagnosis.eval.ts | 6 +- .../src/workflows/agent-pass.test.ts | 14 ++- apps/{api => ai}/src/workflows/agent-pass.ts | 12 +-- .../src/workflows/hypothesis-agent.ts | 4 +- .../src/workflows/hypothesis-catalogue.ts | 0 .../src/workflows/plan-normalize.test.ts | 0 .../src/workflows/plan-normalize.ts | 0 .../src/workflows/planner-agent.ts | 4 +- .../src/workflows/planner-prompt.ts | 0 .../src/workflows/submit-tools.test.ts | 6 +- .../{api => ai}/src/workflows/submit-tools.ts | 2 +- .../src/workflows/validator-agent.ts | 4 +- apps/{api => ai}/test/chat/fake-do-state.ts | 0 apps/ai/tsconfig.json | 17 +++- apps/ai/vitest.config.ts | 16 ++++ apps/api/package.json | 10 -- apps/api/src/runtime/graph-boundaries.test.ts | 82 ++-------------- apps/api/src/runtime/http-graph.ts | 9 +- apps/api/src/runtime/service-graph.ts | 3 +- .../AlertReadModelsService.boundary.test.ts | 5 +- .../src/services/auth/McpOAuthService.test.ts | 19 +--- apps/api/src/worker.ts | 6 -- apps/api/src/worker/bindings.ts | 12 --- apps/api/src/worker/http.ts | 30 +++--- .../src/components/chat/chat-conversation.tsx | 4 +- .../agent-sessions/session-transcript.test.ts | 6 +- .../lib/agent-sessions/session-window.test.ts | 5 +- .../lib/agent-sessions/tool-analytics.test.ts | 5 +- .../src/lib/agent-sessions/tool-analytics.ts | 13 +-- .../lib/agent-sessions/use-tool-analytics.ts | 14 +-- apps/web/src/lib/registry.ts | 6 ++ .../src/lib/services/common/ai-atom-client.ts | 29 ++++++ bun.lock | 29 ++++-- packages/domain/src/http/ai-api.ts | 34 +++++++ packages/domain/src/http/ai-sessions.ts | 96 ++++++++++--------- packages/domain/src/http/index.ts | 1 + packages/domain/src/http/internal-api.ts | 4 +- packages/domain/src/http/v2/openapi.test.ts | 2 +- 218 files changed, 728 insertions(+), 598 deletions(-) rename apps/{api => ai}/src/chat/ChatSession.test.ts (100%) rename apps/{api => ai}/src/chat/ChatSession.ts (100%) rename apps/{api => ai}/src/chat/ChatSessionObject.test.ts (100%) rename apps/{api => ai}/src/chat/agents.test.ts (98%) rename apps/{api => ai}/src/chat/agents.ts (98%) rename apps/{api => ai}/src/chat/budgets.ts (100%) rename apps/{api => ai}/src/chat/delegation.test.ts (97%) rename apps/{api => ai}/src/chat/delegation.ts (98%) rename apps/{api => ai}/src/chat/events.test.ts (97%) rename apps/{api => ai}/src/chat/events.ts (100%) rename apps/{api => ai}/src/chat/permissions.ts (93%) rename apps/{api => ai}/src/chat/prompts.ts (100%) rename apps/{api => ai}/src/chat/run.test.ts (100%) rename apps/{api => ai}/src/chat/run.ts (98%) rename apps/{api => ai}/src/chat/tools.test.ts (100%) rename apps/{api => ai}/src/chat/tools.ts (98%) rename apps/{api => ai}/src/chat/turn-metering.test.ts (100%) rename apps/{api => ai}/src/chat/turn-runner.ts (98%) rename apps/{api => ai}/src/mcp/__evals__/BASELINE.md (100%) rename apps/{api => ai}/src/mcp/__evals__/README.md (100%) rename apps/{api => ai}/src/mcp/__evals__/cli-scenarios.eval.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/disambiguation.eval.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/eval-runtime.ts (96%) rename apps/{api => ai}/src/mcp/__evals__/execution.eval.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/fake-warehouse.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/fixtures.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/issue-workflow.eval.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/model.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/observability.eval.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/regression.test.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/scorers.ts (100%) rename apps/{api => ai}/src/mcp/__evals__/tools.ts (93%) rename apps/{api => ai}/src/mcp/__evals__/utils.ts (100%) rename apps/{api => ai}/src/mcp/app.test.ts (100%) rename apps/{api => ai}/src/mcp/app.ts (100%) rename apps/{api => ai}/src/mcp/dispatcher.test.ts (100%) rename apps/{api => ai}/src/mcp/dispatcher.ts (100%) rename apps/{api => ai}/src/mcp/expected-failures.test.ts (100%) rename apps/{api => ai}/src/mcp/expected-failures.ts (100%) rename apps/{api => ai}/src/mcp/lib/chart-statistics.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/chart-statistics.ts (100%) rename apps/{api => ai}/src/mcp/lib/dashboard-docs-drift.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/dashboard-mutations.test.ts (99%) rename apps/{api => ai}/src/mcp/lib/dashboard-mutations.ts (98%) rename apps/{api => ai}/src/mcp/lib/dashboard-schema-doc.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/dashboard-schema-doc.ts (100%) rename apps/{api => ai}/src/mcp/lib/format-query-result.ts (98%) rename apps/{api => ai}/src/mcp/lib/format.ts (100%) rename apps/{api => ai}/src/mcp/lib/inspect-widget.ts (100%) rename apps/{api => ai}/src/mcp/lib/limits.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/limits.ts (100%) rename apps/{api => ai}/src/mcp/lib/map-http-error.ts (87%) rename apps/{api => ai}/src/mcp/lib/map-warehouse-error.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/map-warehouse-error.ts (97%) rename apps/{api => ai}/src/mcp/lib/next-steps.ts (100%) rename apps/{api => ai}/src/mcp/lib/panel-type.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/panel-type.ts (100%) rename apps/{api => ai}/src/mcp/lib/query-spec-tokens.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/query-spec-tokens.ts (100%) rename apps/{api => ai}/src/mcp/lib/query-warehouse.ts (92%) rename apps/{api => ai}/src/mcp/lib/raw-sql-widget.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/raw-sql-widget.ts (100%) rename apps/{api => ai}/src/mcp/lib/render-trace.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/render-trace.ts (100%) rename apps/{api => ai}/src/mcp/lib/resolve-actor.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/resolve-actor.ts (97%) rename apps/{api => ai}/src/mcp/lib/resolve-dashboard-time-range.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/resolve-dashboard-time-range.ts (100%) rename apps/{api => ai}/src/mcp/lib/resolve-tenant.ts (98%) rename apps/{api => ai}/src/mcp/lib/run-raw-sql.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/run-raw-sql.ts (100%) rename apps/{api => ai}/src/mcp/lib/span-tree.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/span-tree.ts (100%) rename apps/{api => ai}/src/mcp/lib/structured-output.ts (100%) rename apps/{api => ai}/src/mcp/lib/time.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/time.ts (100%) rename apps/{api => ai}/src/mcp/lib/validate-widget-renderability.test.ts (100%) rename apps/{api => ai}/src/mcp/lib/validate-widget-renderability.ts (100%) rename apps/{api => ai}/src/mcp/prompts/debug-errors.ts (100%) rename apps/{api => ai}/src/mcp/prompts/incident-triage.ts (100%) rename apps/{api => ai}/src/mcp/prompts/latency-analysis.ts (100%) rename apps/{api => ai}/src/mcp/resources/instructions.ts (100%) rename apps/{api => ai}/src/mcp/server.ts (100%) rename apps/{api => ai}/src/mcp/tools/__tests__/audit-setup.test.ts (97%) rename apps/{api => ai}/src/mcp/tools/__tests__/dashboard-concurrency.test.ts (100%) rename apps/{api => ai}/src/mcp/tools/__tests__/get-instrumentation-recommendations.test.ts (95%) rename apps/{api => ai}/src/mcp/tools/__tests__/query-funnel.test.ts (94%) rename apps/{api => ai}/src/mcp/tools/__tests__/run-sql-unknown-column.test.ts (100%) rename apps/{api => ai}/src/mcp/tools/__tests__/run-sql-unknown-table.test.ts (100%) rename apps/{api => ai}/src/mcp/tools/add-dashboard-widget.ts (97%) create mode 100644 apps/ai/src/mcp/tools/alert-read-models.boundary.test.ts rename apps/{api => ai}/src/mcp/tools/audit-setup.ts (96%) rename apps/{api => ai}/src/mcp/tools/claim-error-issue.ts (93%) rename apps/{api => ai}/src/mcp/tools/comment-on-error-issue.ts (93%) rename apps/{api => ai}/src/mcp/tools/compare-periods.ts (97%) rename apps/{api => ai}/src/mcp/tools/create-alert-rule.ts (98%) rename apps/{api => ai}/src/mcp/tools/create-dashboard.ts (98%) rename apps/{api => ai}/src/mcp/tools/delete-alert-rule.ts (95%) rename apps/{api => ai}/src/mcp/tools/describe-dashboard-schema.ts (97%) rename apps/{api => ai}/src/mcp/tools/describe-warehouse-tables.ts (100%) rename apps/{api => ai}/src/mcp/tools/diagnose-service.ts (92%) rename apps/{api => ai}/src/mcp/tools/error-detail.ts (94%) rename apps/{api => ai}/src/mcp/tools/explore-attributes.ts (93%) rename apps/{api => ai}/src/mcp/tools/find-errors.ts (92%) rename apps/{api => ai}/src/mcp/tools/find-slow-traces.ts (88%) rename apps/{api => ai}/src/mcp/tools/get-alert-rule.ts (95%) rename apps/{api => ai}/src/mcp/tools/get-dashboard.ts (95%) rename apps/{api => ai}/src/mcp/tools/get-incident-timeline.ts (96%) rename apps/{api => ai}/src/mcp/tools/get-instrumentation-recommendations.ts (96%) rename apps/{api => ai}/src/mcp/tools/get-service-top-operations.ts (90%) rename apps/{api => ai}/src/mcp/tools/get-session-traces.ts (93%) rename apps/{api => ai}/src/mcp/tools/get-session-transcript.ts (94%) rename apps/{api => ai}/src/mcp/tools/inspect-chart-data.ts (97%) rename apps/{api => ai}/src/mcp/tools/inspect-span.ts (91%) rename apps/{api => ai}/src/mcp/tools/inspect-trace.ts (93%) rename apps/{api => ai}/src/mcp/tools/link-pull-request.ts (93%) rename apps/{api => ai}/src/mcp/tools/list-alert-checks.ts (95%) rename apps/{api => ai}/src/mcp/tools/list-alert-incidents.ts (94%) rename apps/{api => ai}/src/mcp/tools/list-alert-rules.ts (92%) rename apps/{api => ai}/src/mcp/tools/list-dashboards.ts (92%) rename apps/{api => ai}/src/mcp/tools/list-error-incidents.ts (94%) rename apps/{api => ai}/src/mcp/tools/list-error-issue-events.ts (93%) rename apps/{api => ai}/src/mcp/tools/list-error-issues.ts (97%) rename apps/{api => ai}/src/mcp/tools/list-metrics.ts (93%) rename apps/{api => ai}/src/mcp/tools/list-product-events.ts (93%) rename apps/{api => ai}/src/mcp/tools/list-services.ts (89%) rename apps/{api => ai}/src/mcp/tools/llm-tools.test.ts (98%) rename apps/{api => ai}/src/mcp/tools/llm-tools.ts (97%) rename apps/{api => ai}/src/mcp/tools/mine-log-patterns.ts (93%) rename apps/{api => ai}/src/mcp/tools/mutating.test.ts (97%) rename apps/{api => ai}/src/mcp/tools/mutating.ts (100%) rename apps/{api => ai}/src/mcp/tools/propose-fix.ts (95%) rename apps/{api => ai}/src/mcp/tools/query-data.ts (97%) rename apps/{api => ai}/src/mcp/tools/query-funnel.ts (97%) rename apps/{api => ai}/src/mcp/tools/register-agent.ts (95%) rename apps/{api => ai}/src/mcp/tools/registry.test.ts (100%) rename apps/{api => ai}/src/mcp/tools/registry.ts (100%) rename apps/{api => ai}/src/mcp/tools/release-error-issue.ts (92%) rename apps/{api => ai}/src/mcp/tools/remove-dashboard-widget.ts (93%) rename apps/{api => ai}/src/mcp/tools/reorder-dashboard-widgets.ts (97%) rename apps/{api => ai}/src/mcp/tools/replace-dashboard-widgets.ts (95%) rename apps/{api => ai}/src/mcp/tools/run-sql.ts (95%) rename apps/{api => ai}/src/mcp/tools/runtime-requirements.ts (100%) rename apps/{api => ai}/src/mcp/tools/sandbox.test.ts (99%) rename apps/{api => ai}/src/mcp/tools/sandbox.ts (99%) rename apps/{api => ai}/src/mcp/tools/search-logs.ts (92%) rename apps/{api => ai}/src/mcp/tools/search-sessions.ts (95%) rename apps/{api => ai}/src/mcp/tools/search-traces.ts (92%) rename apps/{api => ai}/src/mcp/tools/service-map.ts (94%) rename apps/{api => ai}/src/mcp/tools/set-issue-severity.ts (94%) rename apps/{api => ai}/src/mcp/tools/source-code.ts (99%) rename apps/{api => ai}/src/mcp/tools/tool-output.test.ts (100%) rename apps/{api => ai}/src/mcp/tools/tool-output.ts (100%) rename apps/{api => ai}/src/mcp/tools/transition-error-issue.ts (95%) rename apps/{api => ai}/src/mcp/tools/types.ts (100%) rename apps/{api => ai}/src/mcp/tools/update-alert-rule.ts (98%) rename apps/{api => ai}/src/mcp/tools/update-dashboard-widget.ts (93%) rename apps/{api => ai}/src/mcp/tools/update-dashboard.ts (95%) rename apps/{api => ai}/src/mcp/tools/update-error-notification-policy.ts (97%) rename apps/{api => ai}/src/mcp/transport/stateless-http.ts (100%) rename apps/{api => ai}/src/platform/Llm.test.ts (100%) rename apps/{api => ai}/src/platform/Llm.ts (100%) rename apps/{api => ai}/src/platform/WorkersAiHttpClient.test.ts (100%) rename apps/{api => ai}/src/platform/WorkersAiHttpClient.ts (100%) rename apps/{api => ai}/src/platform/genai-spans.test.ts (100%) rename apps/{api => ai}/src/platform/genai-spans.ts (100%) rename apps/{api => ai}/src/platform/model-call-span.test.ts (98%) rename apps/{api => ai}/src/routes/internal/chat.http.test.ts (94%) rename apps/{api => ai}/src/routes/internal/chat.http.ts (95%) rename apps/{api => ai}/src/routes/v1/chat-sessions.http.test.ts (100%) rename apps/{api => ai}/src/routes/v1/chat-sessions.http.ts (99%) create mode 100644 apps/ai/src/runtime/graph-boundaries.test.ts rename apps/{api => ai}/src/runtime/mcp-service-graph.ts (97%) rename apps/{api => ai}/src/workflows/InvestigationFanoutWorkflow.run.test.ts (99%) rename apps/{api => ai}/src/workflows/InvestigationFanoutWorkflow.run.ts (99%) rename apps/{api => ai}/src/workflows/InvestigationFanoutWorkflow.ts (94%) rename apps/{api => ai}/src/workflows/__evals__/diagnosis-fixtures.ts (100%) rename apps/{api => ai}/src/workflows/__evals__/diagnosis-scorers.test.ts (100%) rename apps/{api => ai}/src/workflows/__evals__/diagnosis-scorers.ts (100%) rename apps/{api => ai}/src/workflows/__evals__/diagnosis.eval.ts (97%) rename apps/{api => ai}/src/workflows/agent-pass.test.ts (96%) rename apps/{api => ai}/src/workflows/agent-pass.ts (97%) rename apps/{api => ai}/src/workflows/hypothesis-agent.ts (98%) rename apps/{api => ai}/src/workflows/hypothesis-catalogue.ts (100%) rename apps/{api => ai}/src/workflows/plan-normalize.test.ts (100%) rename apps/{api => ai}/src/workflows/plan-normalize.ts (100%) rename apps/{api => ai}/src/workflows/planner-agent.ts (96%) rename apps/{api => ai}/src/workflows/planner-prompt.ts (100%) rename apps/{api => ai}/src/workflows/submit-tools.test.ts (97%) rename apps/{api => ai}/src/workflows/submit-tools.ts (98%) rename apps/{api => ai}/src/workflows/validator-agent.ts (98%) rename apps/{api => ai}/test/chat/fake-do-state.ts (100%) create mode 100644 apps/web/src/lib/services/common/ai-atom-client.ts create mode 100644 packages/domain/src/http/ai-api.ts diff --git a/apps/ai/package.json b/apps/ai/package.json index 485aa2515..abd7aca23 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -7,14 +7,33 @@ "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": "catalog:", + "vitest-evals": "^0.4.0" } } 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 100% rename from apps/api/src/chat/ChatSession.ts rename to apps/ai/src/chat/ChatSession.ts 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 98% rename from apps/api/src/chat/tools.ts rename to apps/ai/src/chat/tools.ts index 483015039..617e7fa7d 100644 --- a/apps/api/src/chat/tools.ts +++ b/apps/ai/src/chat/tools.ts @@ -18,9 +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 } from "@/mcp/dispatcher" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" import type { McpToolSurface } from "@maple/domain/mcp-manifest" -import { buildMapleToolkit, MapleToolFailure, summarizeToolFailure } from "@/mcp/tools/llm-tools" +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 100% rename from apps/api/src/mcp/dispatcher.test.ts rename to apps/ai/src/mcp/dispatcher.test.ts diff --git a/apps/api/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts similarity index 100% rename from apps/api/src/mcp/dispatcher.ts rename to apps/ai/src/mcp/dispatcher.ts 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/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 100% rename from apps/api/src/mcp/server.ts rename to apps/ai/src/mcp/server.ts 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 97% rename from apps/api/src/mcp/tools/llm-tools.ts rename to apps/ai/src/mcp/tools/llm-tools.ts index f13d8731b..a3bbb2581 100644 --- a/apps/api/src/mcp/tools/llm-tools.ts +++ b/apps/ai/src/mcp/tools/llm-tools.ts @@ -14,11 +14,11 @@ */ import { Cause, Effect, Schema } from "effect" import { Tool, Toolkit } from "effect/unstable/ai" -import type { McpToolExecutorApi } from "@/mcp/dispatcher" +import type { McpToolExecutorApi } from "@ai/mcp/dispatcher" import type { McpToolSurface } from "@maple/domain/mcp-manifest" -import { mapleToolCatalog, toInputSchema } from "@/mcp/tools/registry" -import { truncateToolOutput } from "@/mcp/tools/tool-output" -import { withToolCallContent } from "@/platform/genai-spans" +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 100% rename from apps/api/src/mcp/tools/registry.ts rename to apps/ai/src/mcp/tools/registry.ts 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/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 95% rename from apps/api/src/routes/internal/chat.http.ts rename to apps/ai/src/routes/internal/chat.http.ts index 6f1d447af..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 "@maple/domain/chat-session-stub" -import { mapleToolCatalog } from "@/mcp/tools/registry" -import { MUTATING_TOOL_NAMES } from "@/mcp/tools/mutating" -import { McpToolExecutor } from "@/mcp/dispatcher" +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 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 100% rename from apps/api/src/routes/v1/chat-sessions.http.test.ts rename to apps/ai/src/routes/v1/chat-sessions.http.test.ts diff --git a/apps/api/src/routes/v1/chat-sessions.http.ts b/apps/ai/src/routes/v1/chat-sessions.http.ts similarity index 99% rename from apps/api/src/routes/v1/chat-sessions.http.ts rename to apps/ai/src/routes/v1/chat-sessions.http.ts index a57a61ff3..96194021b 100644 --- a/apps/api/src/routes/v1/chat-sessions.http.ts +++ b/apps/ai/src/routes/v1/chat-sessions.http.ts @@ -38,7 +38,7 @@ 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/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/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 99% rename from apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts rename to apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts index 31f6ac2a1..0230d2a7d 100644 --- a/apps/api/src/workflows/InvestigationFanoutWorkflow.run.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts @@ -49,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, @@ -59,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" @@ -69,7 +69,7 @@ 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 "@maple/domain/incident-context" import { normalizePlan, type NormalizedPlan, type PlannedHypothesis } from "./plan-normalize" 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..3aee2b624 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 98% rename from apps/api/src/workflows/hypothesis-agent.ts rename to apps/ai/src/workflows/hypothesis-agent.ts index 0a2f9dbef..65997fba5 100644 --- a/apps/api/src/workflows/hypothesis-agent.ts +++ b/apps/ai/src/workflows/hypothesis-agent.ts @@ -15,9 +15,9 @@ 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" 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 100% rename from apps/api/src/workflows/plan-normalize.test.ts rename to apps/ai/src/workflows/plan-normalize.test.ts diff --git a/apps/api/src/workflows/plan-normalize.ts b/apps/ai/src/workflows/plan-normalize.ts similarity index 100% rename from apps/api/src/workflows/plan-normalize.ts rename to apps/ai/src/workflows/plan-normalize.ts diff --git a/apps/api/src/workflows/planner-agent.ts b/apps/ai/src/workflows/planner-agent.ts similarity index 96% rename from apps/api/src/workflows/planner-agent.ts rename to apps/ai/src/workflows/planner-agent.ts index e4d39ae58..f16d12c1a 100644 --- a/apps/api/src/workflows/planner-agent.ts +++ b/apps/ai/src/workflows/planner-agent.ts @@ -13,9 +13,9 @@ 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 "@maple/domain/incident-context" 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 98% rename from apps/api/src/workflows/validator-agent.ts rename to apps/ai/src/workflows/validator-agent.ts index 0414ac24f..595f506c6 100644 --- a/apps/api/src/workflows/validator-agent.ts +++ b/apps/ai/src/workflows/validator-agent.ts @@ -16,9 +16,9 @@ */ 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" 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 index 90d8c4a63..62a376766 100644 --- a/apps/ai/tsconfig.json +++ b/apps/ai/tsconfig.json @@ -1,6 +1,12 @@ { "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", @@ -16,7 +22,16 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "paths": { - "@/*": ["../api/src/*"] + // `@/` 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": [ { diff --git a/apps/ai/vitest.config.ts b/apps/ai/vitest.config.ts index 41ec85e89..56a1f6770 100644 --- a/apps/ai/vitest.config.ts +++ b/apps/ai/vitest.config.ts @@ -4,11 +4,27 @@ 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/package.json b/apps/api/package.json index 17330614b..744c22f0a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -32,12 +32,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 +55,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/runtime/graph-boundaries.test.ts b/apps/api/src/runtime/graph-boundaries.test.ts index 9ca23c272..2591a5144 100644 --- a/apps/api/src/runtime/graph-boundaries.test.ts +++ b/apps/api/src/runtime/graph-boundaries.test.ts @@ -24,82 +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("../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/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/worker.ts b/apps/api/src/worker.ts index df2c662e7..d5f7e491a 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -26,16 +26,13 @@ import { WorkerTelemetry } from "@maple/infra/worker-telemetry" 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 ClickHouseSchemaApplyWorkflow from "./workflows/ClickHouseSchemaApplyWorkflow" -import InvestigationFanoutWorkflow from "./workflows/InvestigationFanoutWorkflow" /** * The bindings that stay declared on `env`. Everything the services reach at @@ -110,9 +107,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 @@ -142,7 +137,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> >, @@ -189,7 +201,6 @@ export const makeFetch = (app: Effect.Effect, ports: Layer. } if (request.method === "OPTIONS") return HttpServerResponse.fromWeb(apiCorsPreflightResponse()) - const isMcp = request.method === "POST" && path === "/mcp" const startedAt = yield* Clock.currentTimeMillis firstRequestAt ??= startedAt const ordinal = ++served @@ -210,17 +221,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/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 abd6508a2..e238d5c04 100644 --- a/apps/web/src/lib/agent-sessions/tool-analytics.ts +++ b/apps/web/src/lib/agent-sessions/tool-analytics.ts @@ -137,11 +137,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 @@ -210,8 +206,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 @@ -281,9 +276,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 295d7b026..6e223887d 100644 --- a/apps/web/src/lib/agent-sessions/use-tool-analytics.ts +++ b/apps/web/src/lib/agent-sessions/use-tool-analytics.ts @@ -50,10 +50,7 @@ export interface ToolAnalyticsResults { }, QueryAtomFailure > - readonly breakdowns: Result.Result< - { tools: ReadonlyArray }, - QueryAtomFailure - > + readonly breakdowns: Result.Result<{ tools: ReadonlyArray }, QueryAtomFailure> } export interface ToolAnalyticsWindow { @@ -95,9 +92,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 } }), ) @@ -124,10 +119,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 70ac0f6c1..ce8e637f3 100644 --- a/bun.lock +++ b/bun.lock @@ -28,15 +28,34 @@ "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": { @@ -63,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:*", @@ -91,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", }, }, 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 517c3957f..10795f0b2 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", From 0cac66702904e2ac66f676b23306c43209e872fc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 01:00:58 +0200 Subject: [PATCH 09/12] feat(ai): serve MCP, chat and investigations from maple-ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the move. maple-ai now hosts what it was given: the MCP transport and its tools, the chat Durable Object and its routes, and the investigation fan-out Workflow. apps/api forwards `/mcp`, `/api/chat/*` and `/internal/chat/*` over a service binding, ahead of building its route graph — which is the point, since a `/mcp` call no longer builds `AllRoutes` and `ApiAuthLive`, and a `/v2` call no longer builds 47 tool schemas. The public address does not move. `api.maple.dev/mcp` still answers, so the OAuth issuer and the RFC 8707 resource identifiers stay on api's origin and no registered MCP client is invalidated. The forward is byte-transparent through alchemy's Fetcher: same method, original Host, every header, 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. api answers OPTIONS before forwarding and maple-ai emits no CORS headers of its own, because two `access-control-allow-origin` headers is a hard browser failure rather than a merge. Details worth knowing: - The MCP tool rate limiter moved with its `namespaceId` unchanged. It is the Cloudflare-side identity of the bucket, so a new one would silently reset every client's budget at the cutover. The OAuth limiter stayed with OAuth. - The AI Gateway keeps the resource name `maple-api-ai`; only its alchemy logical id moved. Renaming mints a new gateway and abandons its analytics. - The raw-route guard is ported verbatim. It is worth more here than in api: this Worker is almost entirely raw routers, and it is what turns "reads a service per request" into a build failure instead of a runtime 500 on every request — the September chat outage. - The OAuth-to-MCP seam is tested again, on the side that would break. api mints a token, maple-ai resolves it, and refuses it for a resource it was not bound to. That contract now crosses Workers and nothing else pinned it. CI follows the code: token-cost watches apps/ai/src/mcp, evals run against @maple/ai, and the Slack approval-list canary triggers on apps/ai. Co-Authored-By: Claude Opus 5 --- .github/workflows/eval.yml | 4 +- .github/workflows/token-cost.yml | 12 +- alchemy.run.ts | 18 +- apps/ai/package.json | 5 + .../{api => ai}/scripts/eval-runtime-check.ts | 8 +- .../scripts/generate-dashboard-skill.ts | 10 +- apps/{api => ai}/scripts/grade-widget-eval.ts | 2 +- .../{api => ai}/scripts/measure-token-cost.ts | 2 +- apps/{api => ai}/scripts/widget-eval-tasks.ts | 0 apps/ai/src/app.test.ts | 31 -- apps/ai/src/app.ts | 30 -- .../src/mcp/lib/resolve-tenant.oauth.test.ts | 126 +++++++ apps/ai/src/routes/health.ts | 24 ++ apps/ai/src/runtime/http-graph.ts | 97 ++++++ apps/ai/src/worker.ts | 79 +++-- apps/ai/src/worker/bindings.ts | 80 +++++ apps/ai/src/worker/http.ts | 158 +++++++++ apps/{api => ai}/vitest.eval.config.ts | 4 +- apps/alerting/src/worker.ts | 4 +- apps/api/package.json | 5 - apps/api/src/resources/env.ts | 7 - .../routes/internal/ai-sessions.http.test.ts | 62 +++- .../src/routes/internal/ai-sessions.http.ts | 48 ++- .../warehouse/WarehouseQueryService.test.ts | 316 ++++++++++-------- .../warehouse/WarehouseQueryService.ts | 18 +- .../warehouse/ai-tools.clickhouse.e2e.test.ts | 6 +- ...dex-materialization.clickhouse.e2e.test.ts | 15 +- apps/api/src/worker.ts | 26 +- apps/api/src/worker/http.ts | 35 ++ apps/api/src/workflows/durable-step.test.ts | 4 +- packages/domain/src/gen-ai.ts | 15 +- packages/infra/src/cloudflare/stack.ts | 9 + skills/maple-dashboard-widgets/SKILL.md | 2 +- tsconfig.alchemy.json | 3 + 34 files changed, 956 insertions(+), 309 deletions(-) rename apps/{api => ai}/scripts/eval-runtime-check.ts (84%) rename apps/{api => ai}/scripts/generate-dashboard-skill.ts (92%) rename apps/{api => ai}/scripts/grade-widget-eval.ts (97%) rename apps/{api => ai}/scripts/measure-token-cost.ts (96%) rename apps/{api => ai}/scripts/widget-eval-tasks.ts (100%) delete mode 100644 apps/ai/src/app.test.ts delete mode 100644 apps/ai/src/app.ts create mode 100644 apps/ai/src/mcp/lib/resolve-tenant.oauth.test.ts create mode 100644 apps/ai/src/routes/health.ts create mode 100644 apps/ai/src/runtime/http-graph.ts create mode 100644 apps/ai/src/worker/bindings.ts create mode 100644 apps/ai/src/worker/http.ts rename apps/{api => ai}/vitest.eval.config.ts (76%) 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..3807edc69 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" @@ -36,7 +36,7 @@ jobs: # 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-dev/effect-sdk @maple-dev/browser" - name: Restore turbo cache uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -52,16 +52,16 @@ 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 + git checkout "${{ github.event.pull_request.base.sha }}" -- apps/ai/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 packages/domain/src || true - name: Summary run: | diff --git a/alchemy.run.ts b/alchemy.run.ts index 0c4d33f26..4a38aa219 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -24,6 +24,7 @@ import { } from "@maple/infra/aws" import { ApiWorker, + AiWorker, SandboxWorker, stageDeploysSandbox, formatMapleStage, @@ -219,9 +220,14 @@ 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. + const ai = yield* MapleAi + 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 @@ -256,12 +262,6 @@ export default Alchemy.Stack( const localUi = isDevServer ? undefined : yield* LocalUi - // Every agent surface — the MCP server and its tools, the chat agent, the - // investigation fan-out. Standalone for now: the api still serves `/mcp` and - // the chat routes, and starts forwarding them here once they move. - const ai = yield* MapleAi - yield* serveWorker("ai", ai) - const alerting = yield* Alerting yield* serveWorker("alerting", alerting) diff --git a/apps/ai/package.json b/apps/ai/package.json index abd7aca23..8aec98c71 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -3,6 +3,11 @@ "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" }, 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/ai/src/app.test.ts b/apps/ai/src/app.test.ts deleted file mode 100644 index 9be37d799..000000000 --- a/apps/ai/src/app.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Effect } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { describe, expect, it } from "vitest" -import { fetch } from "./app" - -const respondTo = (method: string, url: string) => - Effect.runPromise( - fetch.pipe( - Effect.provideService( - HttpServerRequest.HttpServerRequest, - HttpServerRequest.fromWeb(new Request(`https://maple-ai.internal${url}`, { method })), - ), - ), - ) - -describe("the AI worker's request surface", () => { - it("answers /health without building a service graph", async () => { - const response = await respondTo("GET", "/health") - expect(response.status).toBe(200) - }) - - it("404s every other path, so a route that should be served and is not stays visible", async () => { - for (const path of ["/", "/mcp", "/api/chat/sessions/o:t/events"]) { - expect((await respondTo("GET", path)).status).toBe(404) - } - }) - - it("does not answer /health for a non-GET", async () => { - expect((await respondTo("POST", "/health")).status).toBe(404) - }) -}) diff --git a/apps/ai/src/app.ts b/apps/ai/src/app.ts deleted file mode 100644 index 714c5e62d..000000000 --- a/apps/ai/src/app.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * The AI worker's request surface. - * - * Imported lazily from `worker.ts` so the graph it will carry — the MCP - * transport, the chat routes, and the service layers under both — stays off the - * startup path, where Cloudflare evaluates module scope under a fixed CPU budget - * and 47 tool schemas have already exhausted it once. - * - * Only `/health` exists today, and it answers the way the api's does: without - * touching the layer graph, the database, or a binding. That is what makes it - * worth having — a liveness check that builds the graph reports the graph's - * health, which is the thing most likely to be broken when you ask. Everything - * else 404s until the real routes land, so a path that should be served and - * isn't is visible rather than silently 200. - */ -import { Effect } from "effect" -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" - -const pathOf = (url: string): string => { - const query = url.indexOf("?") - return query === -1 ? url : url.slice(0, query) -} - -export const fetch = Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - if (request.method === "GET" && pathOf(request.url) === "/health") { - return HttpServerResponse.text("OK") - } - return HttpServerResponse.text("maple-ai: no routes yet", { status: 404 }) -}) 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/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/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/ai/src/worker.ts b/apps/ai/src/worker.ts index a60beb583..e8fb5e83f 100644 --- a/apps/ai/src/worker.ts +++ b/apps/ai/src/worker.ts @@ -30,15 +30,27 @@ import { cachedRecoverable, CLOUDFLARE_WORKER_PLACEMENT, - MapleDb, MapleStack, type MapleStage, resolveWorkerName, } from "@maple/infra/cloudflare" -import { appUrlsEnv, authEnv, merge, selfObservabilityEnv, tinybirdEnv } from "@maple/infra/env" +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 { Effect, Layer } from "effect" +import { Context, Effect, Layer } from "effect" +import 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 @@ -49,7 +61,12 @@ import { Effect, Layer } from "effect" * them, and the two hosted classes are yielded in the init rather than declared * here. */ -const makeWorkerBindings = (_: { stage: MapleStage }) => ({}) +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. @@ -68,7 +85,26 @@ export type AiWorkerEnv = Partial - merge(tinybirdEnv, authEnv, appUrlsEnv, selfObservabilityEnv(stage)) + 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 @@ -104,24 +140,27 @@ export default class MapleAi extends Cloudflare.Worker()( "ai", props, Effect.gen(function* () { - // `MAPLE_DB` in the stage's flavor. The agents read and write the same - // application database the api does — investigations, error issues, alert - // rules — so this is a connection budget of its own, not a share of api's. - yield* MapleDb("ai") - // The routes arrive here in the next phase, behind this import: the MCP - // transport and the chat routes both pull the service graph, which has no - // business in startup validation or in the deploy process. - const app = yield* cachedRecoverable(Effect.promise(() => import("./app"))) - return { fetch: (yield* app).fetch } + // 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( - Cloudflare.Hyperdrive.ConnectBinding, - WorkerTelemetry({ serviceName: "maple-ai" }), - ), - ), + Effect.provide(Layer.mergeAll(AiBindingLayers, 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/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 744c22f0a..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", 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/routes/internal/ai-sessions.http.test.ts b/apps/api/src/routes/internal/ai-sessions.http.test.ts index 2bec3a1c3..66db7e422 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.test.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.test.ts @@ -455,7 +455,8 @@ describe("POST /internal/ai-sessions/list", () => { it("carries the index's row through in the page's order, its agent-span extent as the bounds", async () => { const harness = makeHarness({ - compiledQuery: (_tenant, compiled) => compiledQueryOf(compiled).decodeRows(PAGE).pipe(Effect.orDie), + compiledQuery: (_tenant, compiled) => + compiledQueryOf(compiled).decodeRows(PAGE).pipe(Effect.orDie), }) try { @@ -596,7 +597,9 @@ describe("POST /internal/ai-sessions/details", () => { const harness = makeHarness({ compiledQuery: (_tenant, compiled) => { const { sql } = compiledQueryOf(compiled) - const start = /Timestamp >= '([^']+)'\n\s+AND Timestamp <= '([^']+)'\n\s+AND TraceId IN/.exec(sql) + const start = /Timestamp >= '([^']+)'\n\s+AND Timestamp <= '([^']+)'\n\s+AND TraceId IN/.exec( + sql, + ) spansBounds.push([start?.[1] ?? "", start?.[2] ?? ""]) // The first read (the earlier day) sees the session's first spans, the // second its last; only one of them sees the `trace:` session at all. @@ -940,7 +943,12 @@ describe("POST /internal/ai-sessions/summary", () => { /** The session's own row, in the wire shape `aiSessionTotalsRowSchema` decodes. */ const totalsRow = (overrides: Record) => { - const { turnKey: _turnKey, conversationId: _conversationId, traceIds: _traceIds, ...measures } = turnRow({}) + const { + turnKey: _turnKey, + conversationId: _conversationId, + traceIds: _traceIds, + ...measures + } = turnRow({}) return { traceCount: "1", ...measures, ...overrides } } @@ -965,7 +973,12 @@ describe("POST /internal/ai-sessions/summary", () => { it("reports the session's own row as the totals, and the turn rows beside it", async () => { const { harness, sqls } = summaryHarness( [ - turnRow({ inputTokens: "300", llmInputTokens: "150", outputTokens: "60", llmOutputTokens: "30" }), + turnRow({ + inputTokens: "300", + llmInputTokens: "150", + outputTokens: "60", + llmOutputTokens: "30", + }), turnRow({ turnKey: "turn_1", conversationId: "turn_1", @@ -1024,10 +1037,14 @@ describe("POST /internal/ai-sessions/summary", () => { }) const turns = response.body.turns as Array> expect(turns).toHaveLength(2) - expect(turns[1]).toMatchObject({ turnKey: "turn_1", tokens: { input: 100, output: 0, cacheRead: 0 } }) + expect(turns[1]).toMatchObject({ + turnKey: "turn_1", + tokens: { input: 100, output: 0, cacheRead: 0 }, + }) expect(turns[1]).not.toHaveProperty("cost") expect(sqls).toHaveLength(2) - for (const sql of sqls) expect(sql).toContain(`SpanAttributes['maple_ai.session.id'] = '${SESSION_ID}'`) + for (const sql of sqls) + expect(sql).toContain(`SpanAttributes['maple_ai.session.id'] = '${SESSION_ID}'`) } finally { await harness.dispose() } @@ -1045,7 +1062,10 @@ describe("POST /internal/ai-sessions/summary", () => { ) try { const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) - expect(response.body).toMatchObject({ tokens: { input: 300, output: 0, cacheRead: 0 }, tokenReporting: "per-call" }) + expect(response.body).toMatchObject({ + tokens: { input: 300, output: 0, cacheRead: 0 }, + tokenReporting: "per-call", + }) } finally { await harness.dispose() } @@ -1072,10 +1092,15 @@ describe("POST /internal/ai-sessions/summary", () => { const rows = Array.from({ length: AI_SESSION_SUMMARY_MAX_TURNS + 1 }, (_, index) => turnRow({ turnKey: `turn_${index}`, conversationId: `turn_${index}`, spanCount: "1" }), ) - const { harness } = summaryHarness(rows, [totalsRow({ spanCount: String(AI_SESSION_SUMMARY_MAX_TURNS + 1) })]) + const { harness } = summaryHarness(rows, [ + totalsRow({ spanCount: String(AI_SESSION_SUMMARY_MAX_TURNS + 1) }), + ]) try { const response = await harness.post("/internal/ai-sessions/summary", SPANS_BODY) - expect(response.body).toMatchObject({ spanCount: AI_SESSION_SUMMARY_MAX_TURNS + 1, turnsTruncated: true }) + expect(response.body).toMatchObject({ + spanCount: AI_SESSION_SUMMARY_MAX_TURNS + 1, + turnsTruncated: true, + }) expect(response.body.turns).toHaveLength(AI_SESSION_SUMMARY_MAX_TURNS) } finally { await harness.dispose() @@ -1119,7 +1144,9 @@ describe("POST /internal/ai-sessions/tools/series", () => { const harness = makeHarness({ compiledQuery: (_tenant, compiled) => compiledQueryOf(compiled) - .decodeRows([{ bucket: "2026-08-19T09:00:00.000Z", seriesKey: "gpt-5", ...toolsMeasures }]) + .decodeRows([ + { bucket: "2026-08-19T09:00:00.000Z", seriesKey: "gpt-5", ...toolsMeasures }, + ]) .pipe(Effect.orDie), }) @@ -1215,7 +1242,14 @@ describe("POST /internal/ai-sessions/tools/totals", () => { try { const response = await harness.post("/internal/ai-sessions/tools/totals", TOOLS_WINDOW) - expect(response.body.previous).toEqual({ calls: 0, sessions: 0, errors: 0, p50: 0, p90: 0, p95: 0 }) + expect(response.body.previous).toEqual({ + calls: 0, + sessions: 0, + errors: 0, + p50: 0, + p90: 0, + p95: 0, + }) } finally { await harness.dispose() } @@ -1324,9 +1358,9 @@ describe("POST /internal/ai-sessions/tools/errors", () => { // The span read is pruned by the (trace, span) ids the index answered — // without that subquery it is a whole-window scan of every span in the org. expect(seen[0]).toContain("(trace_detail_spans.TraceId, trace_detail_spans.SpanId) IN") - expect( - (response.body.data as ReadonlyArray<{ errorType: string }>)[0]?.errorType, - ).toBe("TimeoutError") + expect((response.body.data as ReadonlyArray<{ errorType: string }>)[0]?.errorType).toBe( + "TimeoutError", + ) } finally { await harness.dispose() } diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index 2772aaa5e..406480ea0 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -172,7 +172,11 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( sortBy: payload.sortBy, sortDir: payload.sortDir, }), - { orgId: tenant.orgId, startTime: payload.startTime, endTime: payload.endTime }, + { + orgId: tenant.orgId, + startTime: payload.startTime, + endTime: payload.endTime, + }, ), { profile: "list", context: "aiSessionsPage" }, ) @@ -340,7 +344,10 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( const compiled = payload.traceIds !== undefined ? CH.compile( - Integrations.aiTraceSpansQuery({ ...opts, traceIds: payload.traceIds }), + Integrations.aiTraceSpansQuery({ + ...opts, + traceIds: payload.traceIds, + }), { orgId: tenant.orgId, ...window }, rowSchema, ) @@ -419,18 +426,24 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( ? Integrations.aiSessionSummaryQuery() : Integrations.aiTraceSummaryQuery() const totalsQuery = - traceId === undefined ? Integrations.aiSessionTotalsQuery() : Integrations.aiTraceTotalsQuery() + traceId === undefined + ? Integrations.aiSessionTotalsQuery() + : Integrations.aiTraceTotalsQuery() const kind = traceId === undefined ? "aiSession" : "aiTrace" const [rows, totals] = yield* Effect.all( [ warehouse.compiledQuery( tenant, - CH.compile(turnsQuery, params, { rowSchema: Integrations.aiSessionSummaryRowSchema }), + CH.compile(turnsQuery, params, { + rowSchema: Integrations.aiSessionSummaryRowSchema, + }), { context: `${kind}Summary` }, ), warehouse.compiledQuery( tenant, - CH.compile(totalsQuery, params, { rowSchema: Integrations.aiSessionTotalsRowSchema }), + CH.compile(totalsQuery, params, { + rowSchema: Integrations.aiSessionTotalsRowSchema, + }), { context: `${kind}Totals` }, ), ], @@ -481,12 +494,15 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( [ warehouse.compiledQuery( tenant, - CH.compileUnion(Integrations.aiToolsTotalsQuery(toolsSelection(payload)), { - orgId: tenant.orgId, - startTime: payload.startTime, - endTime: payload.endTime, - ...previous, - }), + CH.compileUnion( + Integrations.aiToolsTotalsQuery(toolsSelection(payload)), + { + orgId: tenant.orgId, + startTime: payload.startTime, + endTime: payload.endTime, + ...previous, + }, + ), { context: "aiToolsTotals" }, ), // The detail page's header names the tool, so only a selected @@ -742,16 +758,18 @@ const emptySummary = () => */ const usageOf = (row: Integrations.AiSessionTotalsOutput | Integrations.AiSessionSummaryOutput) => { const perCall = row.llmInputTokens + row.llmOutputTokens + row.llmCacheReadTokens > 0 - const reporting: AiSessionTokenReporting = - perCall ? "per-call" : row.inputTokens + row.outputTokens + row.cacheReadTokens > 0 ? "roll-up" : "none" + const reporting: AiSessionTokenReporting = perCall + ? "per-call" + : row.inputTokens + row.outputTokens + row.cacheReadTokens > 0 + ? "roll-up" + : "none" const tokens: AiSessionTokenTotals = perCall ? { input: row.llmInputTokens, output: row.llmOutputTokens, cacheRead: row.llmCacheReadTokens } : { input: row.inputTokens, output: row.outputTokens, cacheRead: row.cacheReadTokens } // Cost follows the same rule, but only once something reported one: a // per-call session whose calls carry no price still has a session cost if // the wrapper stamped one. - const cost = - row.costReporters === 0 ? undefined : perCall && row.llmCost > 0 ? row.llmCost : row.cost + const cost = row.costReporters === 0 ? undefined : perCall && row.llmCost > 0 ? row.llmCost : row.cost return { reporting, tokens, cost } } 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 3f37d7fb1..bade8c400 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 @@ -307,10 +307,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 () => { @@ -368,7 +365,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 beb84526f..c32bd784d 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 @@ -539,12 +539,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 d5f7e491a..c20624a3a 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -18,11 +18,16 @@ 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" @@ -46,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), + }, + ), }) /** @@ -63,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) @@ -94,6 +117,7 @@ const props = Effect.gen(function* () { env: { ...makeWorkerBindings({ stage }), ...(Option.isSome(sandbox) ? { SANDBOX: sandbox.value } : undefined), + AI_WORKER: ai, ...configuredEnv, ...devEnv, }, diff --git a/apps/api/src/worker/http.ts b/apps/api/src/worker/http.ts index 77575c941..0f92085c9 100644 --- a/apps/api/src/worker/http.ts +++ b/apps/api/src/worker/http.ts @@ -121,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) @@ -201,6 +211,31 @@ export const makeFetch = (app: Effect.Effect, ports: Layer. } if (request.method === "OPTIONS") return HttpServerResponse.fromWeb(apiCorsPreflightResponse()) + // 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 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/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/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/skills/maple-dashboard-widgets/SKILL.md b/skills/maple-dashboard-widgets/SKILL.md index 682641115..b59c8ed2c 100644 --- a/skills/maple-dashboard-widgets/SKILL.md +++ b/skills/maple-dashboard-widgets/SKILL.md @@ -7,7 +7,7 @@ description: "Build, repair, or review Maple dashboard widgets via the MCP. Trig # 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. diff --git a/tsconfig.alchemy.json b/tsconfig.alchemy.json index 4fee41b33..0b07cc92a 100644 --- a/tsconfig.alchemy.json +++ b/tsconfig.alchemy.json @@ -22,6 +22,9 @@ // them let the rest resolve through node_modules instead, so the root // typecheck and turbo's disagreed about which sources they were checking. "paths": { + // apps/ai's own source. Its `@/` is apps/api's, matching its own + // tsconfig — see the comment there for why the asymmetry is load-bearing. + "@ai/*": ["./apps/ai/src/*"], "@maple/infra": ["./packages/infra/src/index.ts"], "@maple/infra/acm": ["./packages/infra/src/aws/acm-dns-validation.ts"], "@maple/infra/aws": ["./packages/infra/src/aws/index.ts"], From 10235810032fafd6f82648fb956b6f848588c9ff Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 01:07:41 +0200 Subject: [PATCH 10/12] fix(ai): transfer the ChatSession Durable Object instead of recreating it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this the prd deploy fails before it uploads anything. Dropping a locally hosted Durable Object class while keeping a cross-script reference to it is the shape that silently destroys a namespace, so alchemy refuses it outright with `DurableObjectTransferRequired` — a safe failure, but a red deploy, and the two ways out are a transfer or a downtime window with the binding removed entirely. `transferredFrom: "api"` takes the first: alchemy runs a `transferred_classes` migration and live chat transcripts follow the class to maple-ai. One merge, no downtime, nothing stranded. The property is inert once every stage has moved, so it stays rather than being cleaned up later and stranding whichever stage lagged. That needs the props-carrying class form, since the single-argument overload takes an implementation and no props — hence `ChatSessionLive` beside the class, and `MapleAi` declaring the class in its contract so the host provides it. Two inference details worth keeping, because both fail far from their cause: - `.make(…)`. The activation only needs `DurableObjectServices`, which `.make` already discharges, but inference otherwise widens them into the layer's own requirements and `DurableObjectState` surfaces in alchemy.run.ts. - The root provides the Live layer where it yields the Worker. That is a genuine entry point, so the lint rule about `Effect.provide` is suppressed there rather than worked around. Co-Authored-By: Claude Opus 5 --- alchemy.run.ts | 8 +++-- apps/ai/src/chat/ChatSession.ts | 32 ++++++++++++++++--- apps/ai/src/worker.ts | 20 +++++++++--- .../InvestigationFanoutWorkflow.run.ts | 2 +- .../workflows/InvestigationFanoutWorkflow.ts | 2 +- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/alchemy.run.ts b/alchemy.run.ts index 4a38aa219..9d5ae792a 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -39,7 +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 MapleAi from "./apps/ai/src/worker.ts" +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" @@ -223,7 +223,11 @@ export default Alchemy.Stack( // 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. - const ai = yield* MapleAi + // 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), diff --git a/apps/ai/src/chat/ChatSession.ts b/apps/ai/src/chat/ChatSession.ts index f9e42c2bf..435740dc6 100644 --- a/apps/ai/src/chat/ChatSession.ts +++ b/apps/ai/src/chat/ChatSession.ts @@ -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/ai/src/worker.ts b/apps/ai/src/worker.ts index e8fb5e83f..c74d3358d 100644 --- a/apps/ai/src/worker.ts +++ b/apps/ai/src/worker.ts @@ -47,7 +47,7 @@ import { import { WorkerTelemetry } from "@maple/infra/worker-telemetry" import * as Cloudflare from "alchemy/Cloudflare" import { Context, Effect, Layer } from "effect" -import ChatSessionObject from "@ai/chat/ChatSession" +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" @@ -136,8 +136,9 @@ const props = Effect.gen(function* () { } }) -export default class MapleAi extends Cloudflare.Worker()( - "ai", +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, @@ -161,6 +162,15 @@ export default class MapleAi extends Cloudflare.Worker()( // 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, WorkerTelemetry({ serviceName: "maple-ai" }))), + 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/workflows/InvestigationFanoutWorkflow.run.ts b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts index 0230d2a7d..60aa7d1e0 100644 --- a/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.run.ts @@ -49,7 +49,7 @@ 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 "@ai/chat/ChatSession" +import type { ChatSessionObject } from "@ai/chat/ChatSession" import type { McpToolExecutor } from "@ai/mcp/dispatcher" import { Database } from "@/platform/DatabaseLive" import { diff --git a/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts b/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts index 3aee2b624..f622786bc 100644 --- a/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts +++ b/apps/ai/src/workflows/InvestigationFanoutWorkflow.ts @@ -5,7 +5,7 @@ * agents run in parallel, then one validator promotes a single cause and * records why each rival lost. */ -import ChatSessionObject from "@ai/chat/ChatSession" +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" From 66d67a41653ede717261c7a80e61e840515489f8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 12 Sep 2026 01:09:26 +0200 Subject: [PATCH 11/12] docs(ai): record the split, and add maple-ai to the prd lockstep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md gains a section on the AI Worker and loses a stale one: the LLM core paragraph still named `@opencode-ai/ai`, removed by 520af877ee and replaced with Effect AI and `@effect-agent/*`. The sandbox tool paths and the `bun dev` app list move with the code. The alias convention gets called out explicitly, because it reads backwards and cost a thousand type errors to learn: in apps/ai, `@/` is apps/api's source and `@ai/` is its own. That program compiles api's modules, and those spell their internal imports `@/`. docs/infra.md records the measurements the decision rested on, so a third pass starts from numbers instead of re-deriving them, plus the two migration facts that bite at deploy time rather than at compile time — the Durable Object carries a transfer, and the Workflow cannot. `maple-ai` joins PRD_LOCKSTEP_REVISION_SERVICES, since it now deploys with the rest. NOTE FOR WHOEVER MERGES: the skew alert's SQL lives in the production database, not this repo, and has to list `maple-ai` too. Until it does, a maple-ai that misses a deploy goes unnoticed — the constant and the rule are coupled by nothing but this note and `env.test.ts`. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 33 +++++++++++++++++++++++++-------- docs/infra.md | 32 ++++++++++++++++++++++++++++++++ packages/infra/src/env.test.ts | 1 + packages/infra/src/env.ts | 10 ++++++++-- 4 files changed, 66 insertions(+), 10 deletions(-) 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/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/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 Date: Sat, 12 Sep 2026 01:15:23 +0200 Subject: [PATCH 12/12] ci(token-cost): install apps/api alongside the agent Worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job measures the MCP tool definitions, which moved to apps/ai, so it now runs `--filter @maple/ai`. But the tools reach api's services through a path alias, and those modules resolve `@maple/domain/*` out of apps/api's own node_modules — which a scoped install that omits `@maple/api` never creates. Head measurement died on the first such import, leaving no head.json for the summary step to read. The base measurement also swaps in the base commit's sources, and on a base older than the split there is no apps/ai at all. It reads 0 either way and the delta is the whole cost, once; the pathspec now includes apps/api/src so the swap is complete rather than half-applied when the base does have both. Co-Authored-By: Claude Opus 5 --- .github/workflows/token-cost.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/token-cost.yml b/.github/workflows/token-cost.yml index 3807edc69..e6f822cad 100644 --- a/.github/workflows/token-cost.yml +++ b/.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/ai @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 @@ -59,9 +62,12 @@ jobs: - name: Measure (base) continue-on-error: true run: | - git checkout "${{ github.event.pull_request.base.sha }}" -- apps/ai/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 packages/domain/src || true + git checkout HEAD -- apps/ai/src apps/api/src packages/domain/src || true - name: Summary run: |