From d16bf7a38c7c4dc5eaff64cb4dd3f2ca7b93ab6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:08:02 +0200 Subject: [PATCH 1/2] feat(server): let the operator select a playbook at session start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-investigation form gains an optional Playbook picker fed by GET /api/playbooks (locked launcher metas and disabled entries filtered out). When set, the id flows preflight → Investigation → metadata.json → sessions.Options → prompts.Env, and the opening prompt points the agent at that playbook instead of the profile's guided investigation flow, skipping the closing playbook as well. Preflight rejects unknown or disabled ids with 400. The session header shows the selected playbook, and it survives launcher restarts via persisted metadata. Signal-watch spawns are unchanged and keep the default flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sh4mHX8JUzvrdpWg56iZth --- docs/content/investigations.md | 1 + .../app/(main)/investigations/new/page.tsx | 1 + .../investigations/InvestigationForm.test.tsx | 38 ++++++++++ .../investigations/InvestigationForm.tsx | 38 +++++++++- .../investigations/SessionView.header.tsx | 8 +++ frontend/lib/api.ts | 7 ++ frontend/lib/playbook-select.test.ts | 31 ++++++++ frontend/lib/playbook-select.ts | 20 ++++++ internal/server/handlers.go | 26 +++++++ internal/server/handlers_start_test.go | 71 +++++++++++++++++++ internal/server/manager.go | 8 +++ internal/server/manager_test.go | 17 +++++ internal/server/persist.go | 3 + internal/sessions/session.go | 38 ++++++---- internal/sessions/session_test.go | 7 ++ prompts/prompts.go | 22 ++++-- prompts/prompts_test.go | 21 ++++++ 17 files changed, 338 insertions(+), 19 deletions(-) create mode 100644 frontend/lib/playbook-select.test.ts create mode 100644 frontend/lib/playbook-select.ts 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 && ( + + )} +