diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index 35902985..193f3872 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -193,6 +193,13 @@ export const toolRenderers: ToolRendererRegistry = { summary: (o) => o.exists ? `${o.status} · drafted by ${o.draftedBy}` : "no postmortem", }, + draft_postmortem: { + renderDraft: (input) => [ + { field: "incidentId", after: input.id }, + { field: "content", after: input.content }, + ], + summary: (o) => `${o.status} · incident ${o.incidentId}`, + }, approve_postmortem: { renderDraft: (input) => [ { field: "incidentId", after: input.id }, diff --git a/apps/server/src/routes/mcp/handler.test.ts b/apps/server/src/routes/mcp/handler.test.ts index 2bb07619..121f4b4b 100644 --- a/apps/server/src/routes/mcp/handler.test.ts +++ b/apps/server/src/routes/mcp/handler.test.ts @@ -125,6 +125,7 @@ describe("MCP transport", () => { "create_maintenance", "create_status_report", "declare_incident", + "draft_postmortem", "get_audit_log", "get_content_page", "get_doc_page", diff --git a/apps/server/src/routes/mcp/tools/incident.ts b/apps/server/src/routes/mcp/tools/incident.ts index d783f473..8846876e 100644 --- a/apps/server/src/routes/mcp/tools/incident.ts +++ b/apps/server/src/routes/mcp/tools/incident.ts @@ -7,6 +7,7 @@ import { addIncidentNoteTool, approvePostmortemTool, declareIncidentTool, + draftPostmortemTool, getIncidentTool, getPostmortemTool, listIncidentsTool, @@ -30,6 +31,7 @@ export function registerIncidentTools( setIncidentStatusTool, addIncidentNoteTool, getPostmortemTool, + draftPostmortemTool, approvePostmortemTool, ]); } diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index e18c695a..5a3425d3 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -5,17 +5,11 @@ import type { ModelMessage, Tool } from "ai"; import { tb } from "@/libs/clients"; +import { SLACK_AGENT_MODEL } from "./model"; import { buildSlackTools } from "./registry-runner"; import type { SlackActor } from "./require-slack-member"; import { buildSystemPrompt } from "./system-prompt"; -// Vercel AI Gateway model id (`anthropic/`). Override via -// SLACK_AGENT_MODEL when rolling out a new model version. -const DEFAULT_MODEL = "anthropic/claude-sonnet-5"; -// `||` (not `??`) so empty / whitespace-only env values fall back to the -// default rather than being passed through to `generateText`. -const MODEL = process.env.SLACK_AGENT_MODEL?.trim() || DEFAULT_MODEL; - interface SlackThreadMessage { user?: string; bot_id?: string; @@ -110,7 +104,7 @@ export async function runAgent( const { events, signal, contextNote } = options ?? {}; const result = streamText({ - model: MODEL, + model: SLACK_AGENT_MODEL, system: buildSystemPrompt(workspace.name ?? "Unknown", contextNote, { incidentManagement: isFeatureEnabled(workspace, "incident-management"), }), diff --git a/apps/server/src/routes/slack/incident-analytics.ts b/apps/server/src/routes/slack/incident-analytics.ts index 6f5bd75a..6312eb80 100644 --- a/apps/server/src/routes/slack/incident-analytics.ts +++ b/apps/server/src/routes/slack/incident-analytics.ts @@ -5,6 +5,9 @@ const EVENTS = { declare: Events.DeclareManagedIncident, status: Events.ChangeManagedIncidentStatus, note: Events.AddManagedIncidentNote, + postmortem: Events.DraftManagedPostmortem, + approved: Events.ApproveManagedPostmortem, + closed: Events.CloseManagedIncident, } as const; /** Fire-and-forget: analytics never fails a Slack action. */ diff --git a/apps/server/src/routes/slack/incident-commands.ts b/apps/server/src/routes/slack/incident-commands.ts index 1e34dcb2..9559d468 100644 --- a/apps/server/src/routes/slack/incident-commands.ts +++ b/apps/server/src/routes/slack/incident-commands.ts @@ -8,6 +8,7 @@ import { import { addIncidentNote, displayName, + generatePostmortemDraft, getIncident, getIncidentBySlackChannel, listIncidents, @@ -18,6 +19,7 @@ import type { SlackConfig } from "./config"; import { postConfirmationCard } from "./confirmation-card"; import { trackSlackIncident } from "./incident-analytics"; import { getIncidentDashboardUrl } from "./page-urls"; +import { generateWithSlackModel } from "./postmortem-generator"; import type { SlackActor } from "./require-slack-member"; import type { SlackWorkspace } from "./workspace-resolver"; @@ -29,6 +31,7 @@ export const INCIDENT_HELP = [ "• `/openstatus incident note ` — add to the timeline (in an incident channel)", "• `/openstatus incident mitigate|resolve|cancel|reopen [#id] [note]` — change its status (approval card)", "• `/openstatus incident status [#id]` — where it stands", + "• `/openstatus incident postmortem [#id]` — draft the postmortem from the channel (approval card to approve and close)", "• `/openstatus incident list` — open incidents", ].join("\n"); @@ -174,6 +177,30 @@ export async function runIncidentCommand(args: { }, }); } + case "postmortem": { + const { id } = parseTarget(rest); + const incidentId = id ?? bound?.id; + if (!incidentId) { + return "Run this in an incident's channel, or name it: `/openstatus incident postmortem #12`."; + } + await generatePostmortemDraft({ + ctx, + incidentId, + generate: generateWithSlackModel, + slackFor: (token) => new WebClient(token), + }); + trackSlackIncident(ctx, "postmortem", { draftedBy: "agent" }); + const card = await postCard({ + slack, + ctx, + teamId, + channelId, + slackUserId: actor.slackUserId, + toolName: "approve_postmortem", + input: { id: incidentId, close: true }, + }); + return `Postmortem drafted: <${getIncidentDashboardUrl(incidentId)}|read and edit it in openstatus>. ${card}`; + } case "status": { const { id } = parseTarget(rest); const target = diff --git a/apps/server/src/routes/slack/incident-slack.ts b/apps/server/src/routes/slack/incident-slack.ts index 74e206e2..fb8a76cc 100644 --- a/apps/server/src/routes/slack/incident-slack.ts +++ b/apps/server/src/routes/slack/incident-slack.ts @@ -44,6 +44,12 @@ const INCIDENT_TOOLS = new Set([ "set_incident_status", ]); +const postmortemOutput = z.object({ + incidentId: z.number().int(), + status: z.string(), +}); +const approveInput = z.object({ close: z.boolean().optional() }); + /** Slack side effects of an approved incident tool call. Best effort. */ export async function afterIncidentTool(args: { ctx: ServiceContext; @@ -57,6 +63,28 @@ export async function afterIncidentTool(args: { threadTs: string; }): Promise { const { ctx, toolName, config } = args; + if (toolName === "approve_postmortem") { + const out = postmortemOutput.safeParse(args.output); + if (!out.success) return; + const close = approveInput.safeParse(args.input).data?.close !== false; + trackSlackIncident(ctx, "approved"); + if (close) trackSlackIncident(ctx, "closed"); + await announceIncidentChange({ + ctx, + incidentId: out.data.incidentId, + text: close + ? `${who(ctx)} approved the postmortem and closed the incident.` + : `${who(ctx)} approved the postmortem.`, + clientFor: slackClientFor, + dashboardUrl: config.dashboardUrl, + archive: close, + }).catch(() => undefined); + return; + } + if (toolName === "draft_postmortem") { + trackSlackIncident(ctx, "postmortem", { draftedBy: "agent" }); + return; + } if (!INCIDENT_TOOLS.has(toolName)) return; const output = incidentOutput.safeParse(args.output); if (!output.success) return; diff --git a/apps/server/src/routes/slack/model.ts b/apps/server/src/routes/slack/model.ts new file mode 100644 index 00000000..a3db8ac8 --- /dev/null +++ b/apps/server/src/routes/slack/model.ts @@ -0,0 +1,8 @@ +// Vercel AI Gateway model id (`anthropic/`). Override via +// SLACK_AGENT_MODEL when rolling out a new model version. +const DEFAULT_MODEL = "anthropic/claude-sonnet-5"; + +// `||` (not `??`) so empty / whitespace-only env values fall back to the +// default rather than being passed through to `generateText`. +export const SLACK_AGENT_MODEL = + process.env.SLACK_AGENT_MODEL?.trim() || DEFAULT_MODEL; diff --git a/apps/server/src/routes/slack/postmortem-generator.ts b/apps/server/src/routes/slack/postmortem-generator.ts new file mode 100644 index 00000000..f5ce98b9 --- /dev/null +++ b/apps/server/src/routes/slack/postmortem-generator.ts @@ -0,0 +1,16 @@ +import type { GenerateText } from "@openstatus/services/incident"; +import { generateText } from "ai"; + +import { SLACK_AGENT_MODEL } from "./model"; + +export const generateWithSlackModel: GenerateText = async ({ + system, + prompt, +}) => { + const result = await generateText({ + model: SLACK_AGENT_MODEL, + system, + prompt, + }); + return result.text; +}; diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts index a003d3bb..b8b51c97 100644 --- a/apps/server/src/routes/slack/system-prompt.ts +++ b/apps/server/src/routes/slack/system-prompt.ts @@ -10,7 +10,7 @@ Managed incidents (internal, never published): - "the incident is resolved" with a managed incident in play -> resolve_incident; if its linked status report is still open, draft resolve_status_report too. - "mitigated", "the bleeding stopped" -> set_incident_status mitigated; "false alarm", "declared by mistake" -> set_incident_status canceled (closes it for good). - In an incident channel (see the note below when there is one), notes and updates belong to that incident: use its id without asking. -- Postmortems: get_postmortem reads one; approve_postmortem signs it off (approval card) and by default closes the incident. +- Postmortems: get_postmortem reads one; draft_postmortem saves a draft you wrote from get_incident and the channel (resolved incidents only, never invent facts); approve_postmortem signs it off (approval card) and by default closes the incident. For a full draft from the whole channel, suggest \`/openstatus incident postmortem\`. - Before referencing a managed incident, call list_incidents. Severity: critical = major outage or data loss, major = significant degradation, minor = limited impact.`; export function buildSystemPrompt( diff --git a/packages/api/package.json b/packages/api/package.json index a0d52342..4393ecb9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@libsql/client": "catalog:", + "@openstatus/ai": "workspace:*", "@openstatus/analytics": "workspace:*", "@openstatus/assertions": "workspace:*", "@openstatus/db": "workspace:*", @@ -50,6 +51,7 @@ "@trpc/tanstack-react-query": "catalog:", "@unkey/api": "catalog:", "@vercel/blob": "catalog:", + "ai": "catalog:", "date-fns": "catalog:", "drizzle-orm": "catalog:", "isomorphic-dompurify": "catalog:", diff --git a/packages/api/src/router/incident.ts b/packages/api/src/router/incident.ts index 7b6e8c5a..50768251 100644 --- a/packages/api/src/router/incident.ts +++ b/packages/api/src/router/incident.ts @@ -1,3 +1,4 @@ +import { resolveChatModel } from "@openstatus/ai"; import { Events } from "@openstatus/analytics"; import { incidentStatus } from "@openstatus/db/src/schema/incidents/constants"; import { sendIncidentCommander } from "@openstatus/emails"; @@ -16,6 +17,7 @@ import { allowedTransitions, approvePostmortem, draftPostmortem, + generatePostmortemDraft, getPostmortem, announceIncidentChange, announceInChannel, @@ -39,6 +41,7 @@ import { } from "@openstatus/services/incident"; import { WebClient } from "@slack/web-api"; import { TRPCError } from "@trpc/server"; +import { generateText } from "ai"; import { after } from "next/server.js"; import { z } from "zod"; @@ -352,7 +355,32 @@ export const incidentRouter = createTRPCRouter({ } }), + draftPostmortemWithAgent: protectedProcedure + .meta({ track: Events.DraftManagedPostmortem }) + .input(IncidentIdInput) + .mutation(async ({ ctx, input }) => { + const model = resolveChatModel({ plan: ctx.workspace.plan }); + if (!model) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "The agent is not configured on this server", + }); + } + try { + return await generatePostmortemDraft({ + ctx: toServiceCtx(ctx), + incidentId: input.id, + generate: async ({ system, prompt }) => + (await generateText({ model, system, prompt })).text, + slackFor: (token) => new WebClient(token), + }); + } catch (err) { + toTRPCError(err); + } + }), + draftPostmortem: protectedProcedure + .meta({ track: Events.DraftManagedPostmortem }) .input(DraftPostmortemInput) .mutation(async ({ ctx, input }) => { try { diff --git a/packages/services/src/agent-tools/__tests__/shape-equivalence.test.ts b/packages/services/src/agent-tools/__tests__/shape-equivalence.test.ts index 947a0a46..d7408eec 100644 --- a/packages/services/src/agent-tools/__tests__/shape-equivalence.test.ts +++ b/packages/services/src/agent-tools/__tests__/shape-equivalence.test.ts @@ -181,6 +181,7 @@ describe("approval metadata contract", () => { from: new Date().toISOString(), to: new Date(Date.now() + 60000).toISOString(), pageComponentIds: [], + content: "c", }; const summary = t.approval.summarize(placeholder); expect(summary.title.length, `${t.name} title empty`).toBeGreaterThan(0); diff --git a/packages/services/src/agent-tools/incident.ts b/packages/services/src/agent-tools/incident.ts index a4a72812..d84916b4 100644 --- a/packages/services/src/agent-tools/incident.ts +++ b/packages/services/src/agent-tools/incident.ts @@ -10,6 +10,7 @@ import { addIncidentNote, approvePostmortem, declareIncident, + draftPostmortem, displayName, getIncident, getPostmortem, @@ -467,6 +468,57 @@ export const getPostmortemTool: AgentTool< }, }; +const DraftPostmortemToolInput = PostmortemInput.extend({ + content: z + .string() + .min(1) + .max(100_000) + .describe( + "The postmortem in markdown with sections Summary, Impact, Timeline, Root cause, What went well, What went wrong, Action items.", + ), +}); + +const DraftPostmortemOutput = z.object({ + incidentId: z.number().int(), + status: z.enum(["draft", "approved"]), +}); + +export const draftPostmortemTool: AgentTool< + z.infer, + z.infer +> = { + name: "draft_postmortem", + description: + "Save a postmortem draft for a resolved managed incident. Build it from get_incident (timeline, linked status report) and the conversation; never invent facts. Replaces an existing draft; an approved postmortem can't be redrafted.", + scope: "write", + destructive: true, + feature: FEATURE, + inputSchema: DraftPostmortemToolInput, + outputSchema: DraftPostmortemOutput, + approval: { + summarize: (input) => ({ + title: `Save the postmortem draft of incident #${input.id}`, + lines: [ + { + label: "Draft", + value: + input.content.length > 600 + ? `${input.content.slice(0, 600)}…` + : input.content, + }, + ], + }), + verb: "saved", + }, + async run({ ctx, input }) { + const row = await draftPostmortem({ + ctx, + input: { id: input.id, content: input.content, draftedBy: "agent" }, + }); + return { incidentId: input.id, status: row.status }; + }, +}; + const ApprovePostmortemToolInput = PostmortemInput.extend({ close: z .boolean() diff --git a/packages/services/src/agent-tools/index.ts b/packages/services/src/agent-tools/index.ts index ba733382..89503aaf 100644 --- a/packages/services/src/agent-tools/index.ts +++ b/packages/services/src/agent-tools/index.ts @@ -9,6 +9,7 @@ import { addIncidentNoteTool, approvePostmortemTool, declareIncidentTool, + draftPostmortemTool, getIncidentTool, getPostmortemTool, listIncidentsTool, @@ -45,6 +46,7 @@ export { addIncidentNoteTool, approvePostmortemTool, declareIncidentTool, + draftPostmortemTool, getIncidentTool, getPostmortemTool, listIncidentsTool, @@ -114,6 +116,7 @@ export const agentTools = { set_incident_status: setIncidentStatusTool, add_incident_note: addIncidentNoteTool, get_postmortem: getPostmortemTool, + draft_postmortem: draftPostmortemTool, approve_postmortem: approvePostmortemTool, list_maintenances: listMaintenancesTool, create_maintenance: createMaintenanceTool, diff --git a/packages/services/src/agent-tools/prompt.ts b/packages/services/src/agent-tools/prompt.ts index df2e0cf0..f0e73a5b 100644 --- a/packages/services/src/agent-tools/prompt.ts +++ b/packages/services/src/agent-tools/prompt.ts @@ -51,7 +51,7 @@ Managed incidents (internal): - "note that…", "add to the timeline", "log that…" → add_incident_note (internal, runs without confirmation). - "the incident is fixed/resolved" with a managed incident in play → resolve_incident; if its linked status report is still open, ask whether to resolve that too (resolve_status_report). - "mitigated", "the bleeding stopped" → set_incident_status mitigated; "false alarm", "declared by mistake" → set_incident_status canceled (this closes it). -- Postmortems: get_postmortem reads it; approve_postmortem signs it off and by default closes the incident. +- Postmortems: get_postmortem reads it; draft_postmortem saves a draft you wrote from get_incident and the conversation (resolved incidents only, never invent facts); approve_postmortem signs it off and by default closes the incident. - severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. Ask when unclear.` : ""; diff --git a/packages/services/src/incident/__tests__/postmortem-draft.test.ts b/packages/services/src/incident/__tests__/postmortem-draft.test.ts new file mode 100644 index 00000000..51af7422 --- /dev/null +++ b/packages/services/src/incident/__tests__/postmortem-draft.test.ts @@ -0,0 +1,174 @@ +import { integration } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { DB, ServiceContext } from "../../context"; +import { ForbiddenError } from "../../errors"; +import { SLACK_BOT_SCOPES } from "../../integration/slack-scopes"; +import type { Workspace } from "../../types"; +import { + bindIncidentSlackChannel, + collectChannelTranscript, + declareIncident, + generatePostmortemDraft, + getPostmortem, + setIncidentStatus, + type SlackHistoryClient, +} from "../index"; + +let workspace: Workspace; +let freeWorkspace: Workspace; +let userId: number; + +beforeAll(async () => { + const team = await createWorkspaceFixture("team"); + workspace = team.workspace; + userId = team.userId; + freeWorkspace = (await createWorkspaceFixture("free")).workspace; +}); + +function history(pages: { + top: { + ts: string; + text: string; + user?: string; + bot_id?: string; + reply_count?: number; + }[]; + threads?: Record; +}): SlackHistoryClient { + return { + conversations: { + history: async () => ({ ok: true, messages: [...pages.top].reverse() }), + replies: async ({ ts }) => ({ + ok: true, + messages: [{ ts, text: "parent" }, ...(pages.threads?.[ts] ?? [])], + }), + }, + }; +} + +describe("collectChannelTranscript", () => { + test("oldest first, bots out, threads inlined under their parent", async () => { + const client = history({ + top: [ + { ts: "100.0", text: "API is down", user: "U1", reply_count: 1 }, + { ts: "101.0", text: "Incident card", bot_id: "B1" }, + { ts: "102.0", text: "Rolled back", user: "U2" }, + ], + threads: { "100.0": [{ ts: "100.5", text: "on it", user: "U3" }] }, + }); + const { text, truncated } = await collectChannelTranscript(client, "C1"); + const lines = text.split("\n"); + expect(lines[0]).toContain("API is down"); + expect(lines[1]).toMatch(/^ {4}.*on it/); + expect(lines[2]).toContain("Rolled back"); + expect(text).not.toContain("Incident card"); + expect(truncated).toBe(false); + }); +}); + +async function resolvedIncident(tx: DB, ctx: ServiceContext, bound: boolean) { + const row = await declareIncident({ + ctx, + input: { title: "Checkout down", severity: "critical" }, + }); + if (bound) { + await tx.insert(integration).values({ + name: "slack-agent", + workspaceId: workspace.id, + externalId: "T_PM", + credential: { botToken: "xoxb-test", botUserId: "UBOT" }, + data: { teamId: "T_PM", scopes: SLACK_BOT_SCOPES.join(",") }, + }); + await bindIncidentSlackChannel({ + ctx, + input: { id: row.id, teamId: "T_PM", channelId: "C_PM" }, + }); + } + await setIncidentStatus({ ctx, input: { id: row.id, status: "resolved" } }); + return row; +} + +describe("generatePostmortemDraft", () => { + test("builds the prompt from the incident and channel, keeps the transcript", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(workspace, { userId }), db: tx }; + const row = await resolvedIncident(tx, ctx, true); + const prompts: string[] = []; + const draft = await generatePostmortemDraft({ + ctx, + incidentId: row.id, + generate: async ({ prompt }) => { + prompts.push(prompt); + return "## Summary\n\nIt broke."; + }, + slackFor: () => + history({ top: [{ ts: "200.0", text: "DB failover", user: "U1" }] }), + }); + expect(prompts).toHaveLength(1); + expect(prompts[0]).toContain("Checkout down"); + expect(prompts[0]).toContain("DB failover"); + expect(draft.draftedBy).toBe("agent"); + expect(draft.sourceTranscript).toContain("DB failover"); + expect(draft.content).toContain("Drafted by the openstatus agent"); + expect((await getPostmortem({ ctx, input: { id: row.id } }))?.id).toBe( + draft.id, + ); + }); + }); + + test("a long channel is summarized in windows first and says so", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(workspace, { userId }), db: tx }; + const row = await resolvedIncident(tx, ctx, true); + const long = "x".repeat(300); + const top = Array.from({ length: 400 }, (_, i) => ({ + ts: `${300 + i}.0`, + text: `${long} ${i}`, + user: "U1", + })); + let calls = 0; + const draft = await generatePostmortemDraft({ + ctx, + incidentId: row.id, + generate: async () => { + calls++; + return "summary"; + }, + slackFor: () => history({ top }), + }); + expect(calls).toBeGreaterThan(2); + expect(draft.content).toContain("summarized in parts"); + }); + }); + + test("without a channel it drafts from the timeline and says so", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeUserCtx(workspace, { userId }), db: tx }; + const row = await resolvedIncident(tx, ctx, false); + const draft = await generatePostmortemDraft({ + ctx, + incidentId: row.id, + generate: async () => "## Summary", + }); + expect(draft.sourceTranscript).toBeNull(); + expect(draft.content).toContain("had no Slack channel"); + }); + }); + + test("a plan without the agent is refused", async () => { + await expect( + generatePostmortemDraft({ + ctx: makeUserCtx(freeWorkspace, { userId }), + incidentId: 1, + generate: async () => "", + }), + ).rejects.toThrow(ForbiddenError); + }); +}); diff --git a/packages/services/src/incident/index.ts b/packages/services/src/incident/index.ts index c3c2df84..007941e6 100644 --- a/packages/services/src/incident/index.ts +++ b/packages/services/src/incident/index.ts @@ -28,6 +28,12 @@ export { draftPostmortem, getPostmortem, } from "./postmortem"; +export { + collectChannelTranscript, + type GenerateText, + generatePostmortemDraft, + type SlackHistoryClient, +} from "./postmortem-draft"; export { AddIncidentNoteInput, ApprovePostmortemInput, diff --git a/packages/services/src/incident/postmortem-draft.ts b/packages/services/src/incident/postmortem-draft.ts new file mode 100644 index 00000000..071e1783 --- /dev/null +++ b/packages/services/src/incident/postmortem-draft.ts @@ -0,0 +1,243 @@ +import { asc, eq } from "@openstatus/db"; +import { + type IncidentPostmortem, + statusReportUpdate, +} from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { ForbiddenError, NotFoundError } from "../errors"; +import { getSlackConnection } from "../integration/slack-connection"; +import { displayName } from "./internal"; +import { getIncident } from "./list"; +import { listIncidentEvents } from "./list-events"; +import { draftPostmortem } from "./postmortem"; + +type SlackPage = { + ok?: boolean; + messages?: { + ts?: string; + text?: string; + user?: string; + bot_id?: string; + reply_count?: number; + }[]; + has_more?: boolean; + response_metadata?: { next_cursor?: string }; +}; + +/** The Slack history calls the postmortem draft makes. `WebClient` fits. */ +export type SlackHistoryClient = { + conversations: { + history(args: { + channel: string; + cursor?: string; + limit?: number; + }): Promise; + replies(args: { + channel: string; + ts: string; + cursor?: string; + limit?: number; + }): Promise; + }; +}; + +export type GenerateText = (args: { + system: string; + prompt: string; +}) => Promise; + +const MAX_MESSAGES = 500; +/** Beyond this the transcript is summarized in windows before drafting. */ +const TRANSCRIPT_BUDGET = 60_000; +const WINDOW = 40_000; + +function line(message: { ts?: string; user?: string; text?: string }): string { + const at = message.ts + ? new Date(Number(message.ts.split(".")[0]) * 1000).toISOString() + : ""; + return `[${at}] <@${message.user ?? "unknown"}>: ${message.text ?? ""}`; +} + +/** + * The channel, oldest first, bot messages left out and threads inlined under + * their parent, capped at ~500 messages. + */ +export async function collectChannelTranscript( + client: SlackHistoryClient, + channel: string, +): Promise<{ text: string; truncated: boolean }> { + const top: NonNullable = []; + let cursor: string | undefined; + let truncated = false; + do { + const page = await client.conversations.history({ + channel, + cursor, + limit: 200, + }); + top.push(...(page.messages ?? [])); + cursor = page.has_more ? page.response_metadata?.next_cursor : undefined; + if (top.length >= MAX_MESSAGES) { + truncated = Boolean(cursor); + break; + } + } while (cursor); + + const lines: string[] = []; + let count = 0; + for (const message of top.reverse()) { + if (message.bot_id || !message.text) continue; + if (count >= MAX_MESSAGES) { + truncated = true; + break; + } + lines.push(line(message)); + count++; + if (message.reply_count && message.ts) { + const thread = await client.conversations.replies({ + channel, + ts: message.ts, + limit: 200, + }); + for (const reply of (thread.messages ?? []).slice(1)) { + if (reply.bot_id || !reply.text) continue; + lines.push(` ${line(reply)}`); + count++; + } + } + } + return { text: lines.join("\n"), truncated }; +} + +const SYSTEM = `You write blameless incident postmortems for an engineering team. +Write GitHub-flavored markdown with exactly these sections, in order: +## Summary +## Impact +## Timeline +## Root cause +## What went well +## What went wrong +## Action items +Use only the facts given. Where a section has no facts, say what is unknown instead of guessing. Timeline entries are UTC timestamps with one line each. Action items are a checklist ("- [ ] ...") with an owner placeholder when none is known. No preamble, no closing remarks.`; + +async function fitTranscript( + transcript: string, + generate: GenerateText, +): Promise<{ text: string; summarized: boolean }> { + if (transcript.length <= TRANSCRIPT_BUDGET) { + return { text: transcript, summarized: false }; + } + const summaries: string[] = []; + for (let i = 0; i < transcript.length; i += WINDOW) { + summaries.push( + await generate({ + system: + "Summarize this excerpt of an incident channel as a list of timestamped facts: what was observed, decided and changed, and by whom. Keep every timestamp.", + prompt: transcript.slice(i, i + WINDOW), + }), + ); + } + return { text: summaries.join("\n"), summarized: true }; +} + +/** + * The agent's postmortem: incident fields, timeline, public status-report + * updates and, when the incident has a channel, its transcript. Stored as a + * draft together with the transcript it was built from. + */ +export async function generatePostmortemDraft(args: { + ctx: ServiceContext; + incidentId: number; + generate: GenerateText; + slackFor?: (botToken: string) => SlackHistoryClient; +}): Promise { + const { ctx, incidentId, generate } = args; + if (!ctx.workspace.limits["slack-agent"]) { + throw new ForbiddenError("Agent drafting is not included in your plan"); + } + const row = await getIncident({ ctx, input: { id: incidentId } }); + if (!row) throw new NotFoundError("incident", incidentId); + const events = await listIncidentEvents({ ctx, input: { id: incidentId } }); + const updates = row.statusReportId + ? await getReadDb(ctx) + .select() + .from(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, row.statusReportId)) + .orderBy(asc(statusReportUpdate.date)) + .all() + : []; + + let transcript: string | null = null; + let transcriptNote = "The incident had no Slack channel."; + if (row.slackChannelId && args.slackFor) { + const connection = await getSlackConnection({ ctx }); + if (connection && connection.teamId === row.slackTeamId) { + try { + const collected = await collectChannelTranscript( + args.slackFor(connection.botToken), + row.slackChannelId, + ); + transcript = collected.text; + transcriptNote = collected.truncated + ? "The channel history was truncated to its first 500 messages." + : "The full channel history was available."; + } catch { + transcriptNote = "The channel history could not be read."; + } + } else { + transcriptNote = + "Slack is no longer connected; the channel was not read."; + } + } + + let channelSection = ""; + if (transcript) { + const fitted = await fitTranscript(transcript, generate); + if (fitted.summarized) { + transcriptNote += " It was too long and was summarized in parts first."; + } + channelSection = `\n\nChannel ${fitted.summarized ? "summary" : "transcript"}:\n${fitted.text}`; + } + + const timeline = [...events] + .reverse() + .map( + (e) => + `[${e.createdAt.toISOString()}] ${e.type}${e.createdByUser ? ` by ${displayName(e.createdByUser)}` : ""}: ${e.message ?? ""}`, + ) + .join("\n"); + const publicUpdates = updates + .map((u) => `[${u.date.toISOString()}] ${u.status}: ${u.message}`) + .join("\n"); + + const prompt = `Incident: ${row.title} +Severity: ${row.severity} +Status: ${row.status} +Impact started: ${row.startedAt.toISOString()} +Declared: ${row.declaredAt.toISOString()} +Mitigated: ${row.mitigatedAt?.toISOString() ?? "never"} +Resolved: ${row.resolvedAt?.toISOString() ?? "not yet"} +Commander: ${row.commander ? displayName(row.commander) : "none"} +Summary: ${row.summary ?? "none"} + +Timeline recorded in openstatus: +${timeline || "(empty)"} + +Public status-report updates: +${publicUpdates || "(none)"} + +Source note: ${transcriptNote}${channelSection}`; + + const body = await generate({ system: SYSTEM, prompt }); + const content = `${body.trim()}\n\n---\n_Drafted by the openstatus agent. ${transcriptNote}_`; + return draftPostmortem({ + ctx, + input: { + id: incidentId, + content, + draftedBy: "agent", + sourceTranscript: transcript, + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c15e2595..2753dcee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1977,6 +1977,9 @@ importers: '@libsql/client': specifier: 'catalog:' version: 0.17.4 + '@openstatus/ai': + specifier: workspace:* + version: link:../ai '@openstatus/analytics': specifier: workspace:* version: link:../analytics @@ -2076,6 +2079,9 @@ importers: '@vercel/blob': specifier: 'catalog:' version: 2.8.0 + ai: + specifier: 'catalog:' + version: 7.0.109(zod@4.6.5) date-fns: specifier: 'catalog:' version: 3.6.0