Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions docs/content/investigations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions frontend/app/(main)/investigations/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions frontend/components/investigations/InvestigationForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<InvestigationForm onSubmit={onSubmit} />);
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(<InvestigationForm onSubmit={onSubmit} />);
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" }));
});
});
});
38 changes: 36 additions & 2 deletions frontend/components/investigations/InvestigationForm.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,6 +15,9 @@ export type FormSubmission = {
inputs: Record<string, Record<string, unknown>>;
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 = {
Expand All @@ -24,6 +28,8 @@ export function InvestigationForm({ onSubmit }: Props) {
const [schema, setSchema] = useState<InputSchema[] | null>(null);
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
const [auto, setAuto] = useState(false);
const [playbooks, setPlaybooks] = useState<PlaybookListItem[]>([]);
const [playbook, setPlaybook] = useState("");

// Prom override panel state — unchanged from today.
const [showPromOverrides, setShowPromOverrides] = useState(false);
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>) {
Expand Down Expand Up @@ -125,6 +137,28 @@ export function InvestigationForm({ onSubmit }: Props) {
}
})}

{playbooks.length > 0 && (
<label className="block">
<span className="font-medium">Playbook</span>
<span className="block text-sm text-zinc-500">
Walk a specific playbook instead of the guided investigation flow.
</span>
<select
value={playbook}
onChange={(e) => setPlaybook(e.target.value)}
className={inputClass + " mt-2"}
>
<option value="">Guided investigation (default)</option>
{playbooks.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
{p.symptom ? ` — ${p.symptom}` : ""}
</option>
))}
</select>
</label>
)}

<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
Expand Down
8 changes: 8 additions & 0 deletions frontend/components/investigations/SessionView.header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ function HeaderTitle({ investigation }: { investigation: Investigation }) {
{investigation.namespace}
</p>
)}
{investigation.playbook && (
<p
className="truncate text-xs text-zinc-500"
title="Playbook selected at session start"
>
playbook: <span className="font-mono">{investigation.playbook}</span>
</p>
)}
</>
);
}
Expand Down
7 changes: 7 additions & 0 deletions frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ export type PreflightRequest = {
// Omitted on the wire when false thanks to `auto,omitempty` on the
// server-side struct.
auto?: boolean;
// playbook: optional playbook id to walk instead of the profile's
// guided investigation flow. Omitted when the operator keeps the
// default.
playbook?: string;
};

export type LinkedRepo = {
Expand Down Expand Up @@ -479,6 +483,9 @@ export type Investigation = {
slackChannelUrl?: string;
notes?: string;
label?: string;
// playbook: the id the operator selected at session start; absent
// when the session runs the guided investigation flow.
playbook?: string;
mcpConfigPath: string;
docsPrefix?: string;
sessionDir: string;
Expand Down
31 changes: 31 additions & 0 deletions frontend/lib/playbook-select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { selectablePlaybooks } from "./playbook-select";
import type { PlaybookListItem } from "./playbook";

const synced = { status: "synced", reason: "" } as PlaybookListItem["syncState"];

function item(over: Partial<PlaybookListItem> & { id: string }): PlaybookListItem {
return { source: "plugin", nodeCount: 1, yaml: "", syncState: synced, ...over };
}

describe("selectablePlaybooks", () => {
it("drops locked, disabled and broken entries", () => {
const out = selectablePlaybooks([
item({ id: "investigation", source: "system", locked: true }),
item({ id: "off", disabled: true }),
item({ id: "bad", source: "broken" }),
item({ id: "ok" }),
]);
expect(out.map((p) => p.id)).toEqual(["ok"]);
});

it("orders by type (investigation first) then id", () => {
const out = selectablePlaybooks([
item({ id: "z-general", type: "general" }),
item({ id: "b-inv", type: "investigation" }),
item({ id: "a-general", type: "general" }),
item({ id: "untyped" }),
]);
expect(out.map((p) => p.id)).toEqual(["b-inv", "untyped", "a-general", "z-general"]);
});
});
20 changes: 20 additions & 0 deletions frontend/lib/playbook-select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { DEFAULT_PLAYBOOK_TYPE, type PlaybookListItem } from "./playbook";

// selectablePlaybooks narrows the playbook catalog to entries an
// operator can start a session against: locked entries are the
// launcher's own metas (the guided entrypoint, closing offer, and
// sub-flows the walker delegates to), and disabled / broken entries
// can't be walked. Investigation-typed playbooks sort first, then
// other types, each group by id.
Comment on lines +7 to +8
export function selectablePlaybooks(items: PlaybookListItem[]): PlaybookListItem[] {
const typeOf = (p: PlaybookListItem) => p.type || DEFAULT_PLAYBOOK_TYPE;
const rank = (p: PlaybookListItem) => (typeOf(p) === DEFAULT_PLAYBOOK_TYPE ? 0 : 1);
return items
.filter((p) => !p.locked && !p.disabled && p.source !== "broken")
.sort(
(a, b) =>
rank(a) - rank(b) ||
typeOf(a).localeCompare(typeOf(b)) ||
a.id.localeCompare(b.id),
);
}
32 changes: 32 additions & 0 deletions internal/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,12 @@ func (a *apiHandlers) handlePreflight(w http.ResponseWriter, r *http.Request) {
return
}
}
if body.Playbook != "" {
if err := a.validateSelectedPlaybook(body.Playbook); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}

// Extract typed values from the generic inputs map.
// Canonical input IDs (same for any profile that
Expand Down Expand Up @@ -473,6 +479,7 @@ func (a *apiHandlers) handlePreflight(w http.ResponseWriter, r *http.Request) {
IncidentioMCPEnabled: ioMCPToken != "",
LinkedRepos: linked,
Profile: a.prof,
Playbook: body.Playbook,
PromTarget: promTarget,
PromDisabled: promDisabled,
ActiveContext: activeContext,
Expand Down Expand Up @@ -1031,6 +1038,31 @@ type preflightRequest struct {
// turn — EnableAuto runs in a goroutine. Operator-side failures are
// logged to stderr and the investigation continues in manual mode.
Auto bool `json:"auto,omitempty"`
// Playbook is an optional playbook id the session should walk
// instead of the profile's guided investigation flow. Must name a
// loadable, non-disabled playbook from GET /api/playbooks.
Playbook string `json:"playbook,omitempty"`
}

// validateSelectedPlaybook checks that id names a playbook the session
// can start on: present in the catalog, not disabled, and not one of
// the launcher's locked metas (the guided entrypoint, the closing
// offer, and the sub-flows the walker delegates to are internal, not
// standalone session entrypoints).
func (a *apiHandlers) validateSelectedPlaybook(id string) error {
for _, pb := range a.collectPlaybooks() {
if pb.ID != id {
continue
}
if pb.Disabled {
return fmt.Errorf("playbook %q is disabled", id)
}
if pb.Locked {
return fmt.Errorf("playbook %q is a launcher meta and cannot be selected", id)
}
return nil
}
return fmt.Errorf("playbook %q not found", id)
}
Comment thread
sourcehawk marked this conversation as resolved.

// promOverrideBody is the per-investigation Prometheus override received
Expand Down
83 changes: 83 additions & 0 deletions internal/server/handlers_start_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -10,6 +11,8 @@ import (
"github.com/sourcehawk/triagent/internal/connections"
"github.com/sourcehawk/triagent/internal/preflight"
"github.com/sourcehawk/triagent/internal/profile"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// newPreflightAPIWithInputsProfile returns an apiHandlers whose prof field is
Expand Down Expand Up @@ -124,3 +127,83 @@ func TestPreflight_InputsMap_EmptyIsOK(t *testing.T) {
t.Fatalf("status=%d, body=%s", rr.Code, rr.Body.String())
}
}

// seedPlaybooks populates the handler's meta cache with the given
// playbooks so preflight's playbook validation has a catalog to check.
func seedPlaybooks(a *apiHandlers, pbs map[string]MetaPlaybook) {
a.metaCache = &metaCache{}
a.metaCache.set(&Meta{Playbooks: pbs})
}

func postPreflight(t *testing.T, a *apiHandlers, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/preflight", strings.NewReader(body))
rr := httptest.NewRecorder()
a.handlePreflight(rr, req)
return rr
}

func TestPreflight_SelectedPlaybookIsRecordedOnInvestigation(t *testing.T) {
t.Parallel()
a := newPreflightAPIWithInputsProfile(t)
seedPlaybooks(a, map[string]MetaPlaybook{
"release_verification": {Source: "plugin", Type: "general", YAML: "id: release_verification\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"},
})

rr := postPreflight(t, a, `{"inputs": {}, "playbook": "release_verification"}`)
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())

var dto InvestigationDTO
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &dto))
assert.Equal(t, "release_verification", dto.Playbook)
inv := a.manager.Get(dto.ID)
require.NotNil(t, inv)
assert.Equal(t, "release_verification", inv.Playbook)
}

func TestPreflight_UnknownPlaybookRejected(t *testing.T) {
t.Parallel()
a := newPreflightAPIWithInputsProfile(t)
seedPlaybooks(a, map[string]MetaPlaybook{})

rr := postPreflight(t, a, `{"inputs": {}, "playbook": "nope"}`)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "nope", "error must name the offending playbook")
}

func TestPreflight_DisabledPlaybookRejected(t *testing.T) {
t.Parallel()
a := newPreflightAPIWithInputsProfile(t)
seedPlaybooks(a, map[string]MetaPlaybook{
"off": {Source: "plugin", Type: "general", YAML: "id: off\nactive: false\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"},
})

rr := postPreflight(t, a, `{"inputs": {}, "playbook": "off"}`)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "off")
}

func TestPreflight_LockedPlaybookRejected(t *testing.T) {
t.Parallel()
a := newPreflightAPIWithInputsProfile(t)
seedPlaybooks(a, map[string]MetaPlaybook{
"investigation": {Source: "system", Locked: true, Type: "general", YAML: "id: investigation\nsymptom: x\nentrypoint: n\nnodes:\n n: {description: d}\n"},
})

rr := postPreflight(t, a, `{"inputs": {}, "playbook": "investigation"}`)
assert.Equal(t, http.StatusBadRequest, rr.Code)
assert.Contains(t, rr.Body.String(), "investigation")
}

func TestPreflight_NoPlaybookLeavesInvestigationUnset(t *testing.T) {
t.Parallel()
a := newPreflightAPIWithInputsProfile(t)
// No meta cache seeded: the default path must not consult the catalog.

rr := postPreflight(t, a, `{"inputs": {}}`)
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
var dto InvestigationDTO
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &dto))
assert.Empty(t, dto.Playbook)
assert.NotContains(t, rr.Body.String(), `"playbook"`, "unset playbook must be omitted from the wire DTO")
}
Loading
Loading