diff --git a/docs/content/investigations.md b/docs/content/investigations.md index 3861fb54..992fac50 100644 --- a/docs/content/investigations.md +++ b/docs/content/investigations.md @@ -74,6 +74,7 @@ the token falls out of the address bar. The launcher stays alive in the terminal 3. **Spawn the agent.** Claude is launched with that `mcp.json` plus a system prompt that points the agent at the `investigation` playbook. The agent is told nothing product-specific in prose; the playbooks carry the procedural knowledge. + The form's optional **Playbook** picker overrides this: when the operator selects a playbook (a release-verification runbook, say), the system prompt points the agent at that playbook instead, and the guided flow, including the closing `capture_offer` step, is skipped. The session header shows which playbook was selected. 4. **Walk the playbook.** The agent calls `list_playbooks`, picks a matching domain playbook, and walks it: read step description, make suggested calls, call `step_complete` with findings and the matching goto. The activity panel renders every tool call live. diff --git a/frontend/app/(main)/investigations/new/page.tsx b/frontend/app/(main)/investigations/new/page.tsx index 97b8a91d..e3e7f355 100644 --- a/frontend/app/(main)/investigations/new/page.tsx +++ b/frontend/app/(main)/investigations/new/page.tsx @@ -58,6 +58,7 @@ function InvestigationsHomeInner() { inputs: sub.inputs, prom: sub.prom, auto: sub.auto || undefined, + playbook: sub.playbook, }); router.push(`/investigations/?id=${encodeURIComponent(inv.id)}`); } catch (e) { diff --git a/frontend/components/investigations/InvestigationForm.test.tsx b/frontend/components/investigations/InvestigationForm.test.tsx index dc727423..aacbc549 100644 --- a/frontend/components/investigations/InvestigationForm.test.tsx +++ b/frontend/components/investigations/InvestigationForm.test.tsx @@ -144,4 +144,42 @@ describe("InvestigationForm", () => { expect.objectContaining({ auto: false }), ); }); + + describe("playbook selection", () => { + const synced = { status: "synced", reason: "" } as const; + function setup() { + vi.spyOn(api, "getProfileInputs").mockResolvedValue([ + { id: "notes", label: "Notes", type: "textarea", optional: true, placeholder: "enter notes" }, + ]); + vi.spyOn(api, "getConnections").mockResolvedValue({ slack: false, incidentio: false, slack_channel_prefix: "" }); + vi.spyOn(api, "listPlaybooks").mockResolvedValue([ + { id: "investigation", source: "system", locked: true, nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + { id: "release_verification", symptom: "Verify a release", source: "user", nodeCount: 1, yaml: "", syncState: synced, type: "general" }, + ]); + } + + it("submits without a playbook by default", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByPlaceholderText("enter notes"); + await screen.findByRole("option", { name: /release_verification/ }); + + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: undefined })); + }); + + it("submits the chosen playbook id and hides locked entries", async () => { + setup(); + const onSubmit = vi.fn(); + render(); + await screen.findByPlaceholderText("enter notes"); + await screen.findByRole("option", { name: /release_verification/ }); + expect(screen.queryByRole("option", { name: /^investigation/ })).toBeNull(); + + fireEvent.change(screen.getByLabelText(/Playbook/i), { target: { value: "release_verification" } }); + fireEvent.click(screen.getByRole("button", { name: /run preflight/i })); + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ playbook: "release_verification" })); + }); + }); }); diff --git a/frontend/components/investigations/InvestigationForm.tsx b/frontend/components/investigations/InvestigationForm.tsx index 46f23f8c..9f64b3a6 100644 --- a/frontend/components/investigations/InvestigationForm.tsx +++ b/frontend/components/investigations/InvestigationForm.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { api, type InputSchema, type PromOverride } from "@/lib/api"; +import { api, type InputSchema, type PlaybookListItem, type PromOverride } from "@/lib/api"; +import { selectablePlaybooks } from "@/lib/playbook-select"; import { ArrowRightIcon } from "@/components/shared/Icons"; import { Spinner } from "@/components/shared/Spinner"; import { TextInput } from "@/components/inputs/TextInput"; @@ -14,6 +15,9 @@ export type FormSubmission = { inputs: Record>; prom: PromOverride; auto: boolean; + // playbook: id of the playbook the operator picked, or undefined for + // the profile's guided investigation flow. + playbook?: string; }; type Props = { @@ -24,6 +28,8 @@ export function InvestigationForm({ onSubmit }: Props) { const [schema, setSchema] = useState(null); const [values, setValues] = useState>>({}); const [auto, setAuto] = useState(false); + const [playbooks, setPlaybooks] = useState([]); + const [playbook, setPlaybook] = useState(""); // Prom override panel state — unchanged from today. const [showPromOverrides, setShowPromOverrides] = useState(false); @@ -34,6 +40,12 @@ export function InvestigationForm({ onSubmit }: Props) { useEffect(() => { api.getProfileInputs().then(setSchema).catch(() => setSchema([])); + // The picker degrades to "default flow only" when the catalog + // can't be fetched; the operator can still start a session. + api + .listPlaybooks() + .then((items) => setPlaybooks(selectablePlaybooks(items))) + .catch(() => setPlaybooks([])); // Pre-populate prom override fields with the profile's defaults so the // operator sees what's currently configured. Functional setters so a // late-arriving fetch can't overwrite values the operator typed while @@ -64,7 +76,7 @@ export function InvestigationForm({ onSubmit }: Props) { const portNum = parseInt(promPort, 10); if (!Number.isNaN(portNum) && portNum > 0) prom.port = portNum; } - onSubmit({ inputs: values, prom, auto }); + onSubmit({ inputs: values, prom, auto, playbook: playbook || undefined }); } function setValue(id: string, next: Record) { @@ -125,6 +137,28 @@ export function InvestigationForm({ onSubmit }: Props) { } })} + {playbooks.length > 0 && ( + + )} +