diff --git a/apps/server/src/libs/test/doubles/page-urls.mock.ts b/apps/server/src/libs/test/doubles/page-urls.mock.ts index b4e18132..424e115b 100644 --- a/apps/server/src/libs/test/doubles/page-urls.mock.ts +++ b/apps/server/src/libs/test/doubles/page-urls.mock.ts @@ -8,3 +8,18 @@ export const getReportUrl = ( reportId: number, ): Promise => Promise.resolve(`https://example.openstatus.dev/events/report/${reportId}`); + +export const getPageDashboardLink = ( + _workspaceId: number, + pageId: number, +): Promise<{ title: string; url: string } | null> => + Promise.resolve({ + title: `Page ${pageId}`, + url: `https://app.openstatus.dev/status-pages/${pageId}`, + }); + +export const getComponentNames = ( + _workspaceId: number, + ids: number[], +): Promise> => + Promise.resolve(new Map(ids.map((id) => [id, `Component ${id}`]))); diff --git a/apps/server/src/routes/slack/blocks.test.ts b/apps/server/src/routes/slack/blocks.test.ts index ceac36c3..eeb1b2a3 100644 --- a/apps/server/src/routes/slack/blocks.test.ts +++ b/apps/server/src/routes/slack/blocks.test.ts @@ -6,12 +6,13 @@ import { buildConfirmationBlocks, getConfirmationText, parseActionId, + type RefResolvers, } from "./blocks"; describe("buildConfirmationBlocks", () => { - test("create_status_report has approve / approve_flag / cancel", () => { + test("create_status_report has approve / approve_flag / cancel", async () => { const tool = agentTools.create_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "abc123", tool, input: { @@ -39,9 +40,303 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements[2].action_id).toBe("cancel_abc123"); }); - test("create_status_report shows components when provided", () => { + const stubResolvers: RefResolvers = { + page: (pageId) => + Promise.resolve({ + title: "Acme Status", + url: `https://app.openstatus.dev/status-pages/${pageId}`, + }), + componentNames: (ids) => + Promise.resolve(new Map(ids.map((id) => [id, `Svc ${id}`]))), + }; + + test("create_status_report links the page name when resolvers resolve", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "link1", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 2705, + pageComponentIds: [], + }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain( + "*Page:* ", + ); + expect(text).not.toContain("Page ID"); + }); + + test("create_status_report falls back to page id when the page can't be resolved", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "link2", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 2705, + pageComponentIds: [], + }, + resolvers: { ...stubResolvers, page: () => Promise.resolve(null) }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Page ID:* 2705"); + }); + + test("create_status_report shows component names when resolvers resolve", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "cn1", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [101, 102], + componentImpacts: [{ pageComponentId: 101, impact: "major_outage" }], + }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* Svc 101, Svc 102"); + expect(text).toContain("*Impacts:* Svc 101 → major_outage"); + }); + + test("component line falls back to raw id when a name is missing", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "cn2", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [101, 999], + }, + resolvers: { + ...stubResolvers, + componentNames: () => Promise.resolve(new Map([[101, "Svc 101"]])), + }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* Svc 101, 999"); + }); + + test("degrades to raw page id (card intact) when the page resolver rejects", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "rej1", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 2705, + pageComponentIds: [], + }, + resolvers: { + ...stubResolvers, + page: () => Promise.reject(new Error("db down")), + }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Page ID:* 2705"); + // The rest of the card must still build — a flaky lookup degrades one line. + const actions = blocks.find((b) => b.type === "actions") as { + elements: unknown[]; + }; + expect(actions.elements).toHaveLength(3); + }); + + test("degrades to raw component ids when the component resolver rejects", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "rej2", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [101, 102], + componentImpacts: [{ pageComponentId: 101, impact: "major_outage" }], + }, + resolvers: { + ...stubResolvers, + componentNames: () => Promise.reject(new Error("db down")), + }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* 101, 102"); + expect(text).toContain("*Impacts:* 101 → major_outage"); + }); + + test("escapes mrkdwn-significant chars in the page link text", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "esc1", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [], + }, + resolvers: { + ...stubResolvers, + page: () => + Promise.resolve({ title: "A & B |d", url: "https://x/1" }), + }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain(""); + }); + + test("escapes mrkdwn-significant chars in component names", async () => { + const tool = agentTools.create_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "esc2", + tool, + input: { + title: "Outage", + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [101], + }, + resolvers: { + ...stubResolvers, + componentNames: () => Promise.resolve(new Map([[101, "API & "]])), + }, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* API & <Web>"); + }); + + test("escapes mrkdwn in the title header and un-refed line values", async () => { + const tool = agentTools.create_status_report; + const evil = ""; + const blocks = await buildConfirmationBlocks({ + actionId: "esc3", + tool, + input: { + title: evil, + status: "investigating", + message: "msg", + pageId: 1, + pageComponentIds: [], + }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + // Header ("Create Status Report: ") and the "Title" line both escape. + expect(text).not.toContain(evil); + expect(text).toContain("<http://evil.example|Click here>"); + }); + + test("add_status_report_update resolves impact component names", async () => { + const tool = agentTools.add_status_report_update; + const blocks = await buildConfirmationBlocks({ + actionId: "au1", + tool, + input: { + statusReportId: 42, + status: "monitoring", + message: "recovering", + componentImpacts: [{ pageComponentId: 7, impact: "partial_outage" }], + }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Impacts:* Svc 7 → partial_outage"); + expect(text).not.toContain("*Impacts:* 7 →"); + }); + + test("update_status_report resolves component names", async () => { + const tool = agentTools.update_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "up1", + tool, + input: { statusReportId: 10, pageComponentIds: [1, 2] }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* Svc 1, Svc 2"); + }); + + test("update_status_report keeps '(clear all)' literal even with resolvers", async () => { + const tool = agentTools.update_status_report; + const blocks = await buildConfirmationBlocks({ + actionId: "up2", + tool, + input: { statusReportId: 10, pageComponentIds: [] }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain("*Components:* (clear all)"); + }); + + test("create_maintenance resolves the page link and component names", async () => { + const tool = agentTools.create_maintenance; + const blocks = await buildConfirmationBlocks({ + actionId: "mn1", + tool, + input: { + title: "DB Upgrade", + message: "Restarting replicas", + from: "2026-06-01T00:00:00Z", + to: "2026-06-01T01:00:00Z", + pageId: 7, + pageComponentIds: [1, 2], + }, + resolvers: stubResolvers, + }); + const text = ( + blocks.find((b) => b.type === "section") as { text: { text: string } } + ).text.text; + expect(text).toContain( + "*Page:* ", + ); + expect(text).toContain("*Components:* Svc 1, Svc 2"); + expect(text).not.toContain("Page ID"); + }); + + test("create_status_report shows components when provided", async () => { const tool = agentTools.create_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "id1", tool, input: { @@ -58,9 +353,9 @@ describe("buildConfirmationBlocks", () => { expect(section.text.text).toContain("101, 102"); }); - test("create_status_report shows impacts when provided", () => { + test("create_status_report shows impacts when provided", async () => { const tool = agentTools.create_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "i1", tool, input: { @@ -83,9 +378,9 @@ describe("buildConfirmationBlocks", () => { expect(section.text.text).toContain("102 → degraded_performance"); }); - test("add_status_report_update shows impacts when provided", () => { + test("add_status_report_update shows impacts when provided", async () => { const tool = agentTools.add_status_report_update; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "i2", tool, input: { @@ -102,9 +397,9 @@ describe("buildConfirmationBlocks", () => { expect(section.text.text).toContain("7 → partial_outage"); }); - test("add_status_report_update has 3 buttons", () => { + test("add_status_report_update has 3 buttons", async () => { const tool = agentTools.add_status_report_update; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "abc", tool, input: { @@ -126,11 +421,11 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements).toHaveLength(3); }); - test("update_status_report distinguishes 'clear all' from 'no change' for components", () => { + test("update_status_report distinguishes 'clear all' from 'no change' for components", async () => { const tool = agentTools.update_status_report; // pageComponentIds undefined → no Components line at all - const noChange = buildConfirmationBlocks({ + const noChange = await buildConfirmationBlocks({ actionId: "u1", tool, input: { statusReportId: 10, title: "X" }, @@ -143,7 +438,7 @@ describe("buildConfirmationBlocks", () => { expect(noChangeText).not.toContain("Components"); // pageComponentIds: [] → "(clear all)" - const clearAll = buildConfirmationBlocks({ + const clearAll = await buildConfirmationBlocks({ actionId: "u2", tool, input: { statusReportId: 10, pageComponentIds: [] }, @@ -156,7 +451,7 @@ describe("buildConfirmationBlocks", () => { expect(clearAllText).toContain("(clear all)"); // pageComponentIds: [1,2] → list - const withIds = buildConfirmationBlocks({ + const withIds = await buildConfirmationBlocks({ actionId: "u3", tool, input: { statusReportId: 10, pageComponentIds: [1, 2] }, @@ -169,9 +464,9 @@ describe("buildConfirmationBlocks", () => { expect(withIdsText).toContain("1, 2"); }); - test("update_status_report has 2 buttons (no notify flag)", () => { + test("update_status_report has 2 buttons (no notify flag)", async () => { const tool = agentTools.update_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "xyz", tool, input: { statusReportId: 10, title: "Updated Title" }, @@ -190,9 +485,9 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements[1].action_id).toBe("cancel_xyz"); }); - test("create_maintenance card shows pageId", () => { + test("create_maintenance card shows pageId", async () => { const tool = agentTools.create_maintenance; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "m1", tool, input: { @@ -213,9 +508,9 @@ describe("buildConfirmationBlocks", () => { expect(text).toContain("7"); }); - test("resolve_status_report has 3 buttons", () => { + test("resolve_status_report has 3 buttons", async () => { const tool = agentTools.resolve_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "res1", tool, input: { statusReportId: 5, message: "Issue has been resolved" }, @@ -233,9 +528,9 @@ describe("buildConfirmationBlocks", () => { expect(actions.elements).toHaveLength(3); }); - test("all blocks include a divider", () => { + test("all blocks include a divider", async () => { const tool = agentTools.create_status_report; - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: "d1", tool, input: { @@ -283,14 +578,29 @@ describe("getConfirmationText", () => { }), ).toBe("Resolve Status Report"); }); + + test("escapes mrkdwn in the title (message text field)", () => { + expect( + getConfirmationText({ + tool: agentTools.create_status_report, + input: { + title: "", + status: "investigating", + message: "m", + pageId: 1, + pageComponentIds: [], + }, + }), + ).toBe("Create Status Report: <http://evil.example|Click here>"); + }); }); describe("buildConfirmationBlocks (error paths)", () => { - test("throws when the tool has no approval metadata", () => { + test("rejects when the tool has no approval metadata", async () => { const readTool = agentTools.list_status_pages; - expect(() => + await expect( buildConfirmationBlocks({ actionId: "x", tool: readTool, input: {} }), - ).toThrow(/no approval metadata/); + ).rejects.toThrow(/no approval metadata/); }); }); diff --git a/apps/server/src/routes/slack/blocks.ts b/apps/server/src/routes/slack/blocks.ts index 338ea8ac..3e8dd0a7 100644 --- a/apps/server/src/routes/slack/blocks.ts +++ b/apps/server/src/routes/slack/blocks.ts @@ -1,4 +1,8 @@ -import type { AnyAgentTool, ExtraFlag } from "@openstatus/services/agent-tools"; +import type { + AnyAgentTool, + ExtraFlag, + SummaryLine, +} from "@openstatus/services/agent-tools"; interface TextObject { type: "plain_text" | "mrkdwn"; @@ -87,16 +91,90 @@ export function parseActionId(actionId: string): ParsedActionId | undefined { return undefined; } +/** Escape `& < >` so Slack renders them as literal mrkdwn text. */ +function escapeText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** Escape a string for use as Slack mrkdwn link text (``). */ +function escapeLinkText(text: string): string { + return escapeText(text).replace(/\|/g, "❘"); +} + +/** + * Data resolvers the Slack surface injects so `buildConfirmationBlocks` can + * turn `SummaryLineRef` descriptors into names. Resolution needs DB access, + * which the edge-safe services layer that produces the refs must not do. + */ +export interface RefResolvers { + /** Page id → dashboard link, or null when the page no longer exists. */ + page: (pageId: number) => Promise<{ title: string; url: string } | null>; + /** Page-component ids → their names (missing ids simply absent). */ + componentNames: (ids: number[]) => Promise>; +} + +async function renderLine( + line: SummaryLine, + resolvers?: RefResolvers, +): Promise { + const ref = line.ref; + if (ref && resolvers) { + try { + switch (ref.kind) { + case "page": { + const link = await resolvers.page(ref.pageId); + if (link) { + return `*Page:* <${link.url}|${escapeLinkText(link.title)}>`; + } + break; + } + case "components": { + const names = await resolvers.componentNames(ref.componentIds); + const value = ref.componentIds + .map((id) => nameOrId(names, id)) + .join(", "); + return `*${line.label}:* ${value}`; + } + case "componentImpacts": { + const names = await resolvers.componentNames( + ref.impacts.map((i) => i.pageComponentId), + ); + const value = ref.impacts + .map((i) => `${nameOrId(names, i.pageComponentId)} → ${i.impact}`) + .join(", "); + return `*${line.label}:* ${value}`; + } + } + } catch { + // A transient name/link lookup failure degrades just this line to its + // raw id value below, rather than aborting the whole confirmation card. + } + } + return `*${line.label}:* ${escapeText(line.value)}`; +} + +function nameOrId(names: Map, id: number): string { + const name = names.get(id); + return name ? escapeText(name) : String(id); +} + /** * Build the Block Kit confirmation card from a tool's `approval.summarize()`. - * Two affirmative buttons when an extraFlag exists; one otherwise. + * Two affirmative buttons when an extraFlag exists; one otherwise. When a + * summary line carries a `ref` and `resolvers` are supplied, raw ids are + * replaced by entity names (a dashboard link for pages, component names for + * component ids). */ -export function buildConfirmationBlocks(args: { +export async function buildConfirmationBlocks(args: { actionId: string; tool: AnyAgentTool; input: unknown; -}): Block[] { - const { actionId, tool, input } = args; + resolvers?: RefResolvers; +}): Promise { + const { actionId, tool, input, resolvers } = args; if (!tool.approval) { throw new Error( `slack blocks: tool "${tool.name}" has no approval metadata`, @@ -105,7 +183,9 @@ export function buildConfirmationBlocks(args: { const summary = tool.approval.summarize(input); const flag: ExtraFlag | undefined = tool.approval.extraFlags?.[0]; - const lines = summary.lines.map((l) => `*${l.label}:* ${l.value}`).join("\n"); + const lines = ( + await Promise.all(summary.lines.map((l) => renderLine(l, resolvers))) + ).join("\n"); const buttons: ButtonElement[] = [ { @@ -137,7 +217,10 @@ export function buildConfirmationBlocks(args: { return [ { type: "section", - text: { type: "mrkdwn", text: `*${summary.title}*\n\n${lines}` }, + text: { + type: "mrkdwn", + text: `*${escapeText(summary.title)}*\n\n${lines}`, + }, }, { type: "divider" }, { type: "actions", elements: buttons }, @@ -149,5 +232,7 @@ export function getConfirmationText(args: { input: unknown; }): string { if (!args.tool.approval) return `Confirm ${args.tool.name}`; - return args.tool.approval.summarize(args.input).title; + // Rendered as the message `text` field, which Slack parses as mrkdwn — escape + // so an LLM/user-controlled title can't inject a link or other markup. + return escapeText(args.tool.approval.summarize(args.input).title); } diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index a075966c..2e2012f4 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -6,12 +6,24 @@ import type { Context } from "hono"; import { z } from "zod"; import { runAgent } from "./agent"; -import { buildConfirmationBlocks, getConfirmationText } from "./blocks"; +import { + buildConfirmationBlocks, + getConfirmationText, + type RefResolvers, +} from "./blocks"; import { findByThread, replace, store } from "./confirmation-store"; import type { PendingPayload } from "./confirmation-store"; +import { getComponentNames, getPageDashboardLink } from "./page-urls"; import { getRegistryTool, isSlackToolDraft } from "./registry-runner"; import { resolveWorkspace } from "./workspace-resolver"; +function makeRefResolvers(workspaceId: number): RefResolvers { + return { + page: (pageId) => getPageDashboardLink(workspaceId, pageId), + componentNames: (ids) => getComponentNames(workspaceId, ids), + }; +} + const logger = getLogger("api-server"); const processedEvents = new Map(); @@ -342,10 +354,11 @@ async function handleConfirmation( if (existing) { await replace(existing.id, payload); - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId: existing.id, tool, input: draft.displayInput, + resolvers: makeRefResolvers(workspaceId), }); await slack.chat.update({ channel, ts: thinkingTs, text, blocks }); await slack.chat.update({ @@ -365,10 +378,11 @@ async function handleConfirmation( payload, }); - const blocks = buildConfirmationBlocks({ + const blocks = await buildConfirmationBlocks({ actionId, tool, input: draft.displayInput, + resolvers: makeRefResolvers(workspaceId), }); await slack.chat.update({ channel, ts: thinkingTs, text, blocks }); } diff --git a/apps/server/src/routes/slack/page-urls.ts b/apps/server/src/routes/slack/page-urls.ts index 8aab57f4..8d210c91 100644 --- a/apps/server/src/routes/slack/page-urls.ts +++ b/apps/server/src/routes/slack/page-urls.ts @@ -1,5 +1,7 @@ -import { db, eq } from "@openstatus/db"; -import { page } from "@openstatus/db/src/schema"; +import { and, db, eq, inArray } from "@openstatus/db"; +import { page, pageComponent } from "@openstatus/db/src/schema"; + +import { env } from "@/env"; /** * Slack-message URL helpers. Pure transport-layer formatting — lives here @@ -21,6 +23,60 @@ export async function getPageUrl(pageId: number): Promise { : `https://${statusPage.slug}.openstatus.dev`; } +function getDashboardBaseUrl(): string { + return env.NODE_ENV === "production" + ? "https://app.openstatus.dev" + : "http://localhost:3000"; +} + +/** + * Resolve a page id to its dashboard link — the page title plus the internal + * dashboard URL (not the public status page). Scoped to the workspace so a + * spoofed id from another workspace never leaks its title into the preview + * card (pageId on the draft is only validated against the workspace at execute + * time). Returns null when the page doesn't exist in the workspace so callers + * can fall back to the raw id. + */ +export async function getPageDashboardLink( + workspaceId: number, + pageId: number, +): Promise<{ title: string; url: string } | null> { + const statusPage = await db + .select({ title: page.title }) + .from(page) + .where(and(eq(page.workspaceId, workspaceId), eq(page.id, pageId))) + .get(); + + if (!statusPage) return null; + return { + title: statusPage.title, + url: `${getDashboardBaseUrl()}/status-pages/${pageId}`, + }; +} + +/** + * Resolve page-component ids to their names, scoped to the workspace so a + * spoofed id from another workspace never leaks a name. Missing ids are + * simply absent from the map — callers fall back to the raw id. + */ +export async function getComponentNames( + workspaceId: number, + ids: number[], +): Promise> { + if (ids.length === 0) return new Map(); + const rows = await db + .select({ id: pageComponent.id, name: pageComponent.name }) + .from(pageComponent) + .where( + and( + eq(pageComponent.workspaceId, workspaceId), + inArray(pageComponent.id, ids), + ), + ) + .all(); + return new Map(rows.map((r: { id: number; name: string }) => [r.id, r.name])); +} + export async function getReportUrl( pageId: number, reportId: number, diff --git a/deno.lock b/deno.lock index 0fb0ec68..ce3a1683 100644 --- a/deno.lock +++ b/deno.lock @@ -54,6 +54,232 @@ "dependencies": [ "npm:turbo@2.9.14" ] + }, + "members": { + "apps/server": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "apps/status-page": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "apps/web": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/ai": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/api": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/db": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/emails": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/header-analysis": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/importers": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/base": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/bird-whatsapp": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/discord": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/google-chat": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/grafana-oncall": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/ms-teams": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/ntfy": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/opsgenie": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/pagerduty": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/slack": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/telegram": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/twillio-sms": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/notifications/webhook": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/services": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/status-fetcher": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/subscriptions": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/test-utils": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/tracker": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + }, + "packages/utils": { + "packageJson": { + "dependencies": [ + "npm:@jsr/std__expect@^1.0.19", + "npm:@jsr/std__testing@^1.0.19" + ] + } + } } } } diff --git a/packages/services/src/agent-tools/__tests__/summary-refs.test.ts b/packages/services/src/agent-tools/__tests__/summary-refs.test.ts new file mode 100644 index 00000000..1c47d822 --- /dev/null +++ b/packages/services/src/agent-tools/__tests__/summary-refs.test.ts @@ -0,0 +1,102 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { agentTools } from "../index"; +import type { AnyAgentTool, SummaryLine } from "../types"; + +function summarize( + toolName: keyof typeof agentTools, + input: unknown, +): SummaryLine[] { + const tool = agentTools[toolName] as AnyAgentTool; + if (!tool.approval) throw new Error(`${toolName} has no approval`); + return tool.approval.summarize(input).lines; +} + +function line(lines: SummaryLine[], label: string): SummaryLine { + const found = lines.find((l) => l.label === label); + if (!found) throw new Error(`no "${label}" line`); + return found; +} + +describe("summary-line refs", () => { + test("create_status_report tags the page line with a page ref", () => { + const lines = summarize("create_status_report", { + title: "Outage", + status: "investigating", + message: "m", + pageId: 2705, + pageComponentIds: [], + }); + expect(line(lines, "Page ID").ref).toEqual({ kind: "page", pageId: 2705 }); + }); + + test("create_status_report tags components and impacts with refs", () => { + const lines = summarize("create_status_report", { + title: "Outage", + status: "investigating", + message: "m", + pageId: 1, + pageComponentIds: [101, 102], + componentImpacts: [{ pageComponentId: 101, impact: "major_outage" }], + }); + expect(line(lines, "Components").ref).toEqual({ + kind: "components", + componentIds: [101, 102], + }); + expect(line(lines, "Impacts").ref).toEqual({ + kind: "componentImpacts", + impacts: [{ pageComponentId: 101, impact: "major_outage" }], + }); + }); + + test("add_status_report_update tags impacts with a componentImpacts ref", () => { + const lines = summarize("add_status_report_update", { + statusReportId: 42, + status: "monitoring", + message: "m", + componentImpacts: [{ pageComponentId: 7, impact: "partial_outage" }], + }); + expect(line(lines, "Impacts").ref).toEqual({ + kind: "componentImpacts", + impacts: [{ pageComponentId: 7, impact: "partial_outage" }], + }); + }); + + test("create_maintenance tags the page and components lines", () => { + const lines = summarize("create_maintenance", { + title: "DB Upgrade", + message: "m", + from: "2026-06-01T00:00:00Z", + to: "2026-06-01T01:00:00Z", + pageId: 7, + pageComponentIds: [1, 2], + }); + expect(line(lines, "Page ID").ref).toEqual({ kind: "page", pageId: 7 }); + expect(line(lines, "Components").ref).toEqual({ + kind: "components", + componentIds: [1, 2], + }); + }); + + test("update_status_report tags components when ids are present", () => { + const lines = summarize("update_status_report", { + statusReportId: 10, + pageComponentIds: [1, 2], + }); + expect(line(lines, "Components").ref).toEqual({ + kind: "components", + componentIds: [1, 2], + }); + }); + + test("update_status_report leaves the '(clear all)' line ref-less", () => { + const lines = summarize("update_status_report", { + statusReportId: 10, + pageComponentIds: [], + }); + const components = line(lines, "Components"); + expect(components.value).toBe("(clear all)"); + expect(components.ref).toBeUndefined(); + }); +}); diff --git a/packages/services/src/agent-tools/index.ts b/packages/services/src/agent-tools/index.ts index 64db2717..71a81abe 100644 --- a/packages/services/src/agent-tools/index.ts +++ b/packages/services/src/agent-tools/index.ts @@ -53,6 +53,7 @@ export type { InferAgentToolInput, InferAgentToolOutput, SummaryLine, + SummaryLineRef, } from "./types"; export { type AgentSystemPromptOptions, diff --git a/packages/services/src/agent-tools/maintenance.ts b/packages/services/src/agent-tools/maintenance.ts index 5833e350..8934efc5 100644 --- a/packages/services/src/agent-tools/maintenance.ts +++ b/packages/services/src/agent-tools/maintenance.ts @@ -167,7 +167,11 @@ export const createMaintenanceTool: AgentTool< title: `Schedule Maintenance: ${input.title}`, lines: [ { label: "Title", value: input.title }, - { label: "Page ID", value: String(input.pageId) }, + { + label: "Page ID", + value: String(input.pageId), + ref: { kind: "page", pageId: input.pageId }, + }, { label: "From", value: formatMaintenanceDate(input.from) }, { label: "To", value: formatMaintenanceDate(input.to) }, ...(input.pageComponentIds?.length @@ -175,6 +179,10 @@ export const createMaintenanceTool: AgentTool< { label: "Components", value: input.pageComponentIds.join(", "), + ref: { + kind: "components" as const, + componentIds: input.pageComponentIds, + }, }, ] : []), diff --git a/packages/services/src/agent-tools/status-report.ts b/packages/services/src/agent-tools/status-report.ts index a5e032f3..453fcaa2 100644 --- a/packages/services/src/agent-tools/status-report.ts +++ b/packages/services/src/agent-tools/status-report.ts @@ -20,7 +20,7 @@ import { type StatusReportStatus, } from "../status-report/schemas"; import { formatComponentImpacts } from "../status-report/utils"; -import type { AgentTool } from "./types"; +import type { AgentTool, SummaryLine } from "./types"; // Agent surfaces send only the impacts that CHANGED (see system prompt). Carry // the report's current non-operational impacts into the update so each update's @@ -243,12 +243,20 @@ export const createStatusReportTool: AgentTool< lines: [ { label: "Title", value: input.title }, { label: "Status", value: input.status }, - { label: "Page ID", value: String(input.pageId) }, + { + label: "Page ID", + value: String(input.pageId), + ref: { kind: "page", pageId: input.pageId }, + }, ...(input.pageComponentIds?.length ? [ { label: "Components", value: input.pageComponentIds.join(", "), + ref: { + kind: "components" as const, + componentIds: input.pageComponentIds, + }, }, ] : []), @@ -259,6 +267,10 @@ export const createStatusReportTool: AgentTool< value: formatComponentImpacts(input.componentImpacts).join( ", ", ), + ref: { + kind: "componentImpacts" as const, + impacts: input.componentImpacts, + }, }, ] : []), @@ -379,6 +391,10 @@ export const addStatusReportUpdateTool: AgentTool< value: formatComponentImpacts(input.componentImpacts).join( ", ", ), + ref: { + kind: "componentImpacts" as const, + impacts: input.componentImpacts, + }, }, ] : []), @@ -473,7 +489,7 @@ export const updateStatusReportTool: AgentTool< outputSchema: UpdateStatusReportOutput, approval: { summarize: (input) => { - const lines: { label: string; value: string }[] = [ + const lines: SummaryLine[] = [ { label: "Report ID", value: String(input.statusReportId) }, ]; if (input.title) lines.push({ label: "New Title", value: input.title }); @@ -488,6 +504,14 @@ export const updateStatusReportTool: AgentTool< value: input.pageComponentIds.length ? input.pageComponentIds.join(", ") : "(clear all)", + ...(input.pageComponentIds.length + ? { + ref: { + kind: "components" as const, + componentIds: input.pageComponentIds, + }, + } + : {}), }); } return { diff --git a/packages/services/src/agent-tools/types.ts b/packages/services/src/agent-tools/types.ts index d0338200..48d29cdf 100644 --- a/packages/services/src/agent-tools/types.ts +++ b/packages/services/src/agent-tools/types.ts @@ -47,7 +47,25 @@ export type ExtraFlag = { default?: boolean; }; -export type SummaryLine = { label: string; value: string }; +/** + * Structured reference a rendering surface can resolve into entity names + * (e.g. a dashboard link, or component names in place of ids), replacing the + * raw `value`. Kept as a descriptor — resolution needs DB access, which the + * edge-safe services layer must not do. + */ +export type SummaryLineRef = + | { kind: "page"; pageId: number } + | { kind: "components"; componentIds: number[] } + | { + kind: "componentImpacts"; + impacts: readonly { pageComponentId: number; impact: string }[]; + }; + +export type SummaryLine = { + label: string; + value: string; + ref?: SummaryLineRef; +}; export type ApprovalMeta = { /**