Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8708d28
feat(status-pages): page config document, applied atomically by the s…
Makisuo Oct 29, 2026
57d82c3
feat(status-pages): GET and PUT /v2/status_pages/{id}/config
Makisuo Oct 29, 2026
f3b5384
feat(status-pages): get_status_page_config and apply_status_page_conf…
Makisuo Oct 29, 2026
0dfbe3a
feat(web): status page JSON tab with a validated, atomic apply
Makisuo Oct 29, 2026
c8a5434
fix(web): pad the JSON tab's change summary
Makisuo Oct 29, 2026
2767b51
fix(status): config apply locks the page, takes if_revision, refuses …
Makisuo Oct 29, 2026
8068d8c
fix(web): JSON tab reads GET /config and applies against its revision
Makisuo Oct 29, 2026
ade9839
fix(status): config apply precondition is a content version, not the …
Makisuo Oct 29, 2026
d7958b7
fix(web): JSON draft scanner walks members in a loop and caps nesting…
Makisuo Oct 29, 2026
133969a
fix(status-pages): a config apply that changes nothing neither republ…
Makisuo Oct 29, 2026
3452e3d
fix(web): JSON apply sends the version the draft was opened from
Makisuo Oct 29, 2026
f6c2b68
fix(web): after a JSON apply conflict the draft rebases onto the re-r…
Makisuo Oct 29, 2026
7e359f8
fix(web): a cancelled or new JSON draft ends any pending rebase
Makisuo Oct 29, 2026
7b5673f
fix(web): only an accepted config read rebases the JSON draft
Makisuo Oct 29, 2026
04c481f
fix(web): a failed config re-read keeps the JSON rebase pending
Makisuo Oct 29, 2026
34baa79
fix(web): a rebased JSON draft clears the conflict banner; Apply uses…
Makisuo Oct 30, 2026
98b1124
test(status-pages): the config apply audits a change once and a no-op…
Makisuo Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ai/src/mcp/resources/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\`
- Deploy verification: service_deployments (versions per service, newest vs previous) -> compare_periods around the rollout. Route usage over up to 90 days: route_usage. Silence check before calling an outage: ingest_freshness. Volume per service: ingest_usage. Database query shapes by volume: db_query_volume
- Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> preview_alert_rule (dry-run before saving; a window with no data is a blind rule, not a healthy one) -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents
- Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor.
- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text.
- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. Admins edit a whole page (settings, theme, components) with get_status_page_config -> apply_status_page_config, one atomic write.
- AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session (verdict and checks) -> get_agent_session_transcript (what it did step by step; \`failed_only\`, \`turn\`) -> inspect_span (one span in full)
- Agent tool health: get_agent_tools_overview -> get_agent_tools_overview tool="<tool>" (its failure groups) -> get_agent_tool_error
- Product analytics / conversion: list_product_events -> query_funnel (steps over page views, \`track()\` events and server events, stitched per person; \`breakdown_by\` a UTM/referrer dimension or an event attribute) -> add_dashboard_widget with \`panel_type: "funnel"\` and \`display_json.funnel.steps\` to pin it (\`display_json.funnel.variant: "dropoff"\` for the step-by-step drop-off view), or \`panel_type: "paths"\` with \`display_json.paths.anchor\` for what people do after / before one event
Expand Down
175 changes: 175 additions & 0 deletions apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { Schema } from "effect"
import { RoleName } from "@maple/domain/http"
import {
ApplyStatusPageConfigOutput,
GetStatusPageConfigOutput,
GetStatusPageOutput,
ListStatusPagesOutput,
StatusPageIncidentMutationOutput,
Expand Down Expand Up @@ -224,4 +227,176 @@ describe("status page tools", () => {
)
expect((await freshness()).state).toBe("live")
})

describe("config", () => {
const PAGE = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e01"
const admin = () => ({
...rt,
tenant: { ...rt.tenant, roles: [Schema.decodeUnknownSync(RoleName)("org:admin")] },
})
beforeAll(async () => {
const now = Date.now()
await executeSql(
rt.testDb,
`INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at)
VALUES ($1, $2, 'Config Page', 'config-page', $3, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`,
[PAGE, rt.tenant.orgId, rt.tenant.userId, now],
)
await executeSql(
rt.testDb,
`INSERT INTO status_page_components (id, org_id, page_id, name, position, created_at, updated_at)
VALUES ($1, $2, $3, 'Web', 0, now(), now()), ($4, $2, $3, 'Docs', 1, now(), now())`,
[
"7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e02",
rt.tenant.orgId,
PAGE,
"7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1e03",
],
)
})

it("reads the page as a JSON document with public ids, fenced for the model", async () => {
const result = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const { config } = result.structuredContent
expect(config).toMatchObject({
name: "Config Page",
slug: "config-page",
components: [{ name: "Web" }, { name: "Docs" }],
})
expect(config.components[0].id).toMatch(/^stc_/)
expect(decode(GetStatusPageConfigOutput, result).config.components).toHaveLength(2)
expect(markdown(result)).toContain("```json\n")
expect(markdown(result)).toContain(`"id": "${config.components[0].id}"`)
})

it("applies an edited document passed as a JSON string and summarises what changed", async () => {
const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const [web] = read.structuredContent.config.components
const edited = {
...read.structuredContent.config,
description: "Live status.",
components: [{ name: "API" }, { ...web, group: "Apps" }],
}
const result = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: JSON.stringify(edited),
})
expect(result.isError).not.toBe(true)
const output = decode(ApplyStatusPageConfigOutput, result)
expect(output.changes).toEqual({
fields: ["description"],
created: ["API"],
updated: ["Web"],
deleted: ["Docs"],
reordered: false,
})
expect(output.config.components.map((c) => c.name)).toEqual(["API", "Web"])
const text = markdown(result)
expect(text).toContain("Components created")
expect(text).toContain("Docs")

const again = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: result.structuredContent.config,
})
expect(markdown(again)).toContain("Nothing changed")
})

it("reports nothing changed for a re-sent document that differs only until stored", async () => {
const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const { config } = read.structuredContent
const result = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: {
...config,
name: ` ${config.name} `,
description: config.description === null ? " " : ` ${config.description} `,
components: config.components.map((c: { name: string }) => ({
...c,
name: ` ${c.name} `,
})),
},
})
expect(result.isError).not.toBe(true)
expect(decode(ApplyStatusPageConfigOutput, result).changes).toEqual({
fields: [],
created: [],
updated: [],
deleted: [],
reordered: false,
})
expect(markdown(result)).toContain("Nothing changed")
})

it("refuses a key the config does not define, as an object or as JSON text", async () => {
const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const { config } = read.structuredContent
const [first, ...rest] = config.components
const { show_uptime: _shown, ...withoutUptime } = first
const typo = { ...config, components: [{ ...withoutUptime, show_uptim: false }, ...rest] }
const asObject = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: typo,
})
expect(asObject.isError).toBe(true)
expect(markdown(asObject)).toContain("show_uptim")
const asText = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: JSON.stringify({ ...config, colour: "red" }),
})
expect(asText.isError).toBe(true)
expect(markdown(asText)).toContain("colour")
const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
expect(after.structuredContent.config).toEqual(config)
})

it("refuses a document read before the config changed, and says to read it again", async () => {
const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const { config } = read.structuredContent
await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: { ...config, description: "Changed meanwhile." },
})
const moved = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const stale = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: { ...config, components: [] },
})
expect(stale.isError).toBe(true)
expect(markdown(stale)).toContain("get_status_page_config again")
const explicit = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: moved.structuredContent.config,
if_version: config.version,
})
expect(explicit.isError).toBe(true)
const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
expect(after.structuredContent.config).toEqual(moved.structuredContent.config)
expect(after.structuredContent.config.version).not.toBe(config.version)
})

it("refuses members and names a component id from another page", async () => {
const read = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
const refused = await runToolDirect(rt, "apply_status_page_config", {
page: "config-page",
config: read.structuredContent.config,
})
expect(refused.isError).toBe(true)
expect(markdown(refused)).toContain("only org admins")

const other = await runToolDirect(rt, "get_status_page_config", { page: "acme-status" })
const stolen = other.structuredContent.config.components[0].id
const foreign = await runToolDirect(admin(), "apply_status_page_config", {
page: "config-page",
config: {
...read.structuredContent.config,
components: [{ id: stolen, name: "Stolen" }],
},
})
expect(foreign.isError).toBe(true)
expect(markdown(foreign)).toContain(stolen)
const after = await runToolDirect(rt, "get_status_page_config", { page: "config-page" })
expect(after.structuredContent.config).toEqual(read.structuredContent.config)
})
})
})
1 change: 1 addition & 0 deletions apps/ai/src/mcp/tools/mutating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const MUTATING_TOOL_NAMES: ReadonlySet<string> = new Set([
"post_status_page_update",
"resolve_status_page_incident",
"schedule_status_page_maintenance",
"apply_status_page_config",
// feedback
"send_maple_feedback",
])
16 changes: 12 additions & 4 deletions apps/ai/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// BOUNDARY: This module owns unparsed external values and narrows them before domain use.
import { McpToolNotFoundError } from "@maple/domain/mcp-tool-contract"
import type { McpToolSurface } from "@maple/domain/mcp-manifest"
import { Effect, Schema } from "effect"
import { Effect, Schema, type SchemaAST } from "effect"
import { registerAddDashboardWidgetTool } from "./add-dashboard-widget"
import { registerDescribeWarehouseTablesTool } from "./describe-warehouse-tables"
import { registerComparePeriodsTool } from "./compare-periods"
Expand Down Expand Up @@ -122,6 +122,8 @@ export interface MapleToolCatalogEntry {
readonly title: string
readonly description: string
readonly schema: Schema.Codec<unknown, unknown, never, unknown>
/** How arguments decode; `rejectUnknownKeys` tools refuse nested keys the schema lacks. */
readonly parseOptions: SchemaAST.ParseOptions
readonly outputSchema: Schema.Codec<unknown, unknown, never, never>
readonly hints: McpToolHints
readonly aliases: Readonly<Record<string, string>>
Expand Down Expand Up @@ -343,6 +345,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray<MapleToolDefinition> => {
title: spec.title,
description: spec.description,
schema: spec.parameters,
parseOptions: { onExcessProperty: spec.rejectUnknownKeys === true ? "error" : "ignore" },
outputSchema: spec.output,
hints: spec.hints,
aliases: spec.aliases ?? {},
Expand Down Expand Up @@ -511,7 +514,9 @@ export const readToolCall = (
return {
args: isArgumentRecord(normalized.args) ? normalized.args : {},
unknown: normalized.unknown.map((entry) => entry.key),
decodes: Schema.decodeUnknownOption(definition.schema)(normalized.args)._tag === "Some",
decodes:
Schema.decodeUnknownOption(definition.schema)(normalized.args, definition.parseOptions)._tag ===
"Some",
}
}

Expand Down Expand Up @@ -550,7 +555,7 @@ const isValidNextCall = (call: NextCall, surface: McpToolSurface): boolean => {
const normalized = normalizeFor(target, call.args)
return (
normalized.unknown.length === 0 &&
Schema.decodeUnknownOption(target.schema)(normalized.args)._tag === "Some"
Schema.decodeUnknownOption(target.schema)(normalized.args, target.parseOptions)._tag === "Some"
)
}

Expand Down Expand Up @@ -619,7 +624,10 @@ export const executeRegisteredMcpToolUnscoped = Effect.fn("McpToolRegistry.execu
? undefined
: { "maple.mcp.tool.aliased_arguments": normalized.renamed.map(([from]) => from).join(",") }),
})
const decoded = yield* Schema.decodeUnknownEffect(definition.schema)(normalized.args).pipe(
const decoded = yield* Schema.decodeUnknownEffect(definition.schema)(
normalized.args,
definition.parseOptions,
).pipe(
Effect.mapError(
(error) =>
new McpDecodeError({
Expand Down
Loading
Loading