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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/api/src/chat/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { investigationIdFromChatSessionId } from "@maple/domain/chat-session"
import { evaluatePermission, type PermissionRuleset } from "@maple/domain/permission"
import {
AiTriageResult,
InvestigationDataCorruptionError,
InvestigationNotFoundError,
InvestigationPersistenceError,
SubmitDiagnosisRequest,
Expand Down Expand Up @@ -42,7 +43,10 @@ export type SubmitDiagnosis = (
orgId: TenantContext["orgId"],
investigationId: InvestigationId,
request: SubmitDiagnosisRequest,
) => Effect.Effect<unknown, InvestigationPersistenceError | InvestigationNotFoundError>
) => Effect.Effect<
unknown,
InvestigationPersistenceError | InvestigationNotFoundError | InvestigationDataCorruptionError
>

export const buildSubmitDiagnosisTool = (
sessionId: string,
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/http/v2-worker-unavailable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "@effect/vitest"
import { Schema } from "effect"
import { V2WorkerUnavailable } from "@maple/domain/http/v2"
import { v2WorkerUnavailableResponse } from "./v2-worker-unavailable"

describe("v2 worker fallback", () => {
it("uses the declared 504 tag and canonical body", async () => {
const response = v2WorkerUnavailableResponse()
const body = await response.json()

expect(response.status).toBe(504)
expect(response.headers.get("retry-after")).toBe("1")
expect(body).toEqual({ error: V2WorkerUnavailable.make().error })
expect(() => Schema.decodeUnknownSync(V2WorkerUnavailable.schema)(body)).not.toThrow()
})
})
16 changes: 16 additions & 0 deletions apps/api/src/http/v2-worker-unavailable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
v2WorkerUnavailableBody,
v2WorkerUnavailableDefinition,
} from "@maple/domain/http/v2-worker-unavailable"

/** Canonical v2 fallback used when the route graph could not finish bootstrapping. */
export const v2WorkerUnavailableResponse = (): Response => {
const definition = v2WorkerUnavailableDefinition
return Response.json(
{ error: v2WorkerUnavailableBody() },
{
status: definition.status,
headers: { "retry-after": String(definition.retryAfterSeconds) },
},
)
}
2 changes: 2 additions & 0 deletions apps/api/src/mcp/lib/dashboard-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ export const withDashboardMutation = Effect.fn("withDashboardMutation")(function
Effect.catchTags({
"@maple/http/errors/DashboardPersistenceError": (error) =>
Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })),
"@maple/http/errors/DashboardStoredConfigInvalidError": (error) =>
Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })),
"@maple/http/errors/DashboardConcurrencyError": (error) =>
Effect.fail(new McpQueryError({ message: error.message, pipeName: tool, cause: error })),
"@maple/http/errors/DashboardValidationError": (error) =>
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/mcp/lib/map-http-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { SelfDescribingHttpError } from "@maple/domain/http"
import { McpQueryError } from "@/mcp/tools/types"

/** Adapt an HTTP-domain failure at the MCP protocol boundary without reclassifying its tag. */
export const toMcpHttpError =
(pipeName: string) =>
(error: SelfDescribingHttpError): McpQueryError =>
new McpQueryError({
message: `${error._tag}: ${error.error.message}`,
pipeName,
cause: error,
})
12 changes: 11 additions & 1 deletion apps/api/src/mcp/lib/map-warehouse-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
import { WarehouseQueryError, WarehouseSchemaDriftError } from "@maple/domain"
import { WarehouseQueryError, WarehouseResultDecodeError, WarehouseSchemaDriftError } from "@maple/domain"
import { toMcpQueryError } from "./map-warehouse-error"

describe("toMcpQueryError", () => {
Expand Down Expand Up @@ -27,4 +27,14 @@ describe("toMcpQueryError", () => {
const mcp = toMcpQueryError("service_overview")(err)
expect(mcp.message).toBe("boom")
})

it("does not tell users to apply schema for a result decode failure", () => {
const err = new WarehouseResultDecodeError({
message: "row did not decode",
pipeName: "service_overview",
})
const mcp = toMcpQueryError("service_overview")(err)
expect(mcp.message).toBe("row did not decode")
expect(mcp.message).not.toContain("schema apply")
})
})
11 changes: 4 additions & 7 deletions apps/api/src/mcp/lib/map-warehouse-error.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Effect } from "effect"
import { type WarehouseError, WarehouseSchemaDriftError } from "@maple/domain"
import { warehouseHandlers } from "@/services/warehouse/warehouse-error-handlers"
import { warehouseHandlers, warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers"
import { McpQueryError } from "@/mcp/tools/types"

export { warehouseHandlers }
export { warehouseHandlers, warehouseReadHandlers }

const SCHEMA_DRIFT_HINT =
" — your ClickHouse cluster's schema is out of sync with what Maple expects. " +
Expand All @@ -17,13 +17,10 @@ const SCHEMA_DRIFT_HINT =
* column). Every MCP surface that renders a warehouse error should go through
* this, not `error.message`.
*
* `kind: "decode"` drift means the cluster answered fine but the rows failed
* Maple's own row schema — schema apply cannot fix that, so no hint.
* Row-decoding failures have their own tag and never receive this hint.
*/
export const warehouseErrorText = (error: WarehouseError): string =>
error instanceof WarehouseSchemaDriftError && error.kind !== "decode"
? `${error.message}${SCHEMA_DRIFT_HINT}`
: error.message
error instanceof WarehouseSchemaDriftError ? `${error.message}${SCHEMA_DRIFT_HINT}` : error.message

/**
* Curry the pipe label so call sites read as
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/mcp/lib/run-raw-sql.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import type { RawSqlValidationError } from "@maple/domain/http"
import type { WarehouseSqlError } from "@maple/query-engine/execution"
import type { WarehouseExecutionError } from "@maple/query-engine/execution"
import { makeExecuteRawSql } from "@maple/query-engine/runtime"
import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService"
import type { TenantContext } from "@/services/auth/tenant-context"
Expand Down Expand Up @@ -43,7 +43,7 @@ export interface RunRawSqlInput {
*/
export const runRawSql = Effect.fn("runRawSql")(function* (input: RunRawSqlInput) {
const warehouse = yield* WarehouseQueryService
const executeRawSql = makeExecuteRawSql<TenantContext, WarehouseSqlError | RawSqlValidationError>(
const executeRawSql = makeExecuteRawSql<TenantContext, WarehouseExecutionError | RawSqlValidationError>(
warehouse,
)
return yield* executeRawSql(input.tenant, {
Expand Down
41 changes: 4 additions & 37 deletions apps/api/src/mcp/tools/create-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} 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 { AlertRulesService } from "@/services/alerts/AlertRulesService"
import { AlertRuleUpsertRequest } from "@maple/domain/http"
Expand Down Expand Up @@ -330,43 +331,9 @@ export function registerCreateAlertRuleTool(server: McpToolRegistrar) {
const tenant = yield* CurrentMcpTenant
const alerts = yield* AlertRulesService

const rule = yield* alerts.createRule(tenant.orgId, tenant.userId, tenant.roles, decoded).pipe(
Effect.catchTag("@maple/http/errors/AlertValidationError", (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}\n${error.details.join("\n")}`,
pipeName: "create_alert_rule",
cause: error,
}),
),
),
Effect.catchTags({
"@maple/http/errors/AlertForbiddenError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "create_alert_rule",
cause: error,
}),
),
"@maple/http/errors/AlertPersistenceError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "create_alert_rule",
cause: error,
}),
),
"@maple/http/errors/AlertNotFoundError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "create_alert_rule",
cause: error,
}),
),
}),
)
const rule = yield* alerts
.createRule(tenant.orgId, tenant.userId, tenant.roles, decoded)
.pipe(Effect.mapError(toMcpHttpError("create_alert_rule")))

const lines: string[] = [
`## Alert Rule Created`,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/delete-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function registerDeleteAlertRuleTool(server: McpToolRegistrar) {
cause: error,
}),
),
"@maple/http/errors/AlertNotFoundError": (error) =>
"@maple/http/errors/AlertRuleNotFoundError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}. Use list_alert_rules to find available rule IDs.`,
Expand Down
16 changes: 5 additions & 11 deletions apps/api/src/mcp/tools/get-alert-rule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { McpQueryError, requiredStringParam, type McpToolRegistrar } from "./types"
import { requiredStringParam, type McpToolRegistrar } from "./types"
import { toMcpHttpError } from "@/mcp/lib/map-http-error"
import { formatNextSteps } from "@/mcp/lib/next-steps"
import { Effect, Schema } from "effect"
import { createDualContent } from "@/mcp/lib/structured-output"
Expand All @@ -23,16 +24,9 @@ export function registerGetAlertRuleTool(server: McpToolRegistrar) {
const tenant = yield* CurrentMcpTenant
const alerts = yield* AlertRulesService

const result = yield* alerts.listRules(tenant.orgId).pipe(
Effect.mapError(
(error) =>
new McpQueryError({
message: error.message,
pipeName: "get_alert_rule",
cause: error,
}),
),
)
const result = yield* alerts
.listRules(tenant.orgId)
.pipe(Effect.mapError(toMcpHttpError("get_alert_rule")))

const rule = result.rules.find((r) => r.id === rule_id)

Expand Down
16 changes: 5 additions & 11 deletions apps/api/src/mcp/tools/list-alert-rules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { McpQueryError, optionalBooleanParam, optionalStringParam, type McpToolRegistrar } from "./types"
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 { Effect, Schema } from "effect"
import { createDualContent } from "@/mcp/lib/structured-output"
Expand Down Expand Up @@ -34,16 +35,9 @@ export function registerListAlertRulesTool(server: McpToolRegistrar) {
const tenant = yield* CurrentMcpTenant
const alerts = yield* AlertRulesService

const result = yield* alerts.listRules(tenant.orgId).pipe(
Effect.mapError(
(error) =>
new McpQueryError({
message: error.message,
pipeName: "list_alert_rules",
cause: error,
}),
),
)
const result = yield* alerts
.listRules(tenant.orgId)
.pipe(Effect.mapError(toMcpHttpError("list_alert_rules")))

let rules = result.rules

Expand Down
8 changes: 3 additions & 5 deletions apps/api/src/mcp/tools/query-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
type MetricsBreakdownQuery,
} from "@maple/query-engine"
import { formatQueryResult } from "@/mcp/lib/format-query-result"
import { warehouseErrorText, warehouseHandlers } from "@/mcp/lib/map-warehouse-error"
import { warehouseErrorText, warehouseReadHandlers } from "@/mcp/lib/map-warehouse-error"
import {
CommitSha,
DeploymentEnvironment,
Expand Down Expand Up @@ -373,13 +373,11 @@ export function registerQueryDataTool(server: McpToolRegistrar) {
Effect.succeed(taggedErrorResult(error._tag, error.message, error.details)),
),
Effect.catchTags({
"@maple/http/errors/QueryEngineExecutionError": (error) =>
Effect.succeed(taggedErrorResult(error._tag, error.message)),
"@maple/http/errors/QueryEngineTimeoutError": (error) =>
Effect.succeed(taggedErrorResult(error._tag, error.message)),
// Shared 9-tag warehouse table; warehouseErrorText appends the
// Shared exact warehouse table; warehouseErrorText appends the
// schema-apply hint for schema drift, matching the other MCP tools.
...warehouseHandlers((error) =>
...warehouseReadHandlers((error) =>
Effect.succeed(taggedErrorResult(error._tag, warehouseErrorText(error))),
),
}),
Expand Down
52 changes: 5 additions & 47 deletions apps/api/src/mcp/tools/update-alert-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} 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 { AlertsService } from "@/services/alerts/AlertsService"
import { AlertRulesService } from "@/services/alerts/AlertRulesService"
Expand Down Expand Up @@ -195,16 +196,9 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) {
const alerts = yield* AlertsService
const rules = yield* AlertRulesService

const list = yield* rules.listRules(tenant.orgId).pipe(
Effect.mapError(
(error) =>
new McpQueryError({
message: error.message,
pipeName: "update_alert_rule",
cause: error,
}),
),
)
const list = yield* rules
.listRules(tenant.orgId)
.pipe(Effect.mapError(toMcpHttpError("update_alert_rule")))

const current = list.rules.find((r) => r.id === params.rule_id)
if (!current) {
Expand Down Expand Up @@ -240,43 +234,7 @@ export function registerUpdateAlertRuleTool(server: McpToolRegistrar) {

const rule = yield* alerts
.updateRule(tenant.orgId, tenant.userId, tenant.roles, current.id, decoded)
.pipe(
Effect.catchTag("@maple/http/errors/AlertValidationError", (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}\n${error.details.join("\n")}`,
pipeName: "update_alert_rule",
cause: error,
}),
),
),
Effect.catchTags({
"@maple/http/errors/AlertForbiddenError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "update_alert_rule",
cause: error,
}),
),
"@maple/http/errors/AlertPersistenceError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "update_alert_rule",
cause: error,
}),
),
"@maple/http/errors/AlertNotFoundError": (error) =>
Effect.fail(
new McpQueryError({
message: `${error._tag}: ${error.message}`,
pipeName: "update_alert_rule",
cause: error,
}),
),
}),
)
.pipe(Effect.mapError(toMcpHttpError("update_alert_rule")))

const lines: string[] = [
`## Alert Rule Updated`,
Expand Down
20 changes: 15 additions & 5 deletions apps/api/src/routes/v1/anomalies.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ import {
type OrgId,
} from "@maple/domain/http"
import { Effect } from "effect"
import { AnomalyDetectionService } from "@/services/alerts/AnomalyDetectionService"
import {
AnomalyDetectionService,
makePersistenceError as makeAnomalyPersistenceError,
} from "@/services/alerts/AnomalyDetectionService"
import { ErrorsService } from "@/services/errors/ErrorsService"
import { requireAdmin } from "@/services/auth/auth"
import { warehouseReadHandlers } from "@/services/warehouse/warehouse-error-handlers"

// Preserve v1's historical persistence envelope while v2 exposes warehouse tags directly.
const legacyPersistenceFailure = (error: { readonly message: string }) =>
Effect.fail(makeAnomalyPersistenceError(error))

export const HttpAnomaliesLive = HttpApiBuilder.group(MapleApi, "anomalies", (handlers) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -83,10 +91,12 @@ export const HttpAnomaliesLive = HttpApiBuilder.group(MapleApi, "anomalies", (ha
orgId: tenant.orgId,
incidentId: params.incidentId,
})
return yield* anomalies.getIncidentTimeseries(tenant, params.incidentId, {
startTime: query.startTime,
endTime: query.endTime,
})
return yield* anomalies
.getIncidentTimeseries(tenant, params.incidentId, {
startTime: query.startTime,
endTime: query.endTime,
})
.pipe(Effect.catchTags(warehouseReadHandlers(legacyPersistenceFailure)))
}).pipe(Effect.withSpan("HttpAnomalies.getIncidentTimeseries")),
)
.handle("resolveIncident", ({ params }) =>
Expand Down
Loading
Loading