feat(server): library endpoints gain ?category, ?summary, /library/entry (TASK-1561) (#612)

Extends the convention + playbook library HTTP layer to support the shape
the upcoming `pad_library` MCP tool and the updated `pad library` CLI need:

- `GET /api/v1/convention-library?category=X` — server-side filter,
  case-sensitive exact match. Unknown categories return an empty slice,
  not 404.
- `GET /api/v1/playbook-library?category=X&summary=true` — same filter
  plus a new summary mode that strips Content and injects Summary
  (first non-heading paragraph, ~240 char cap). Web UI and existing
  consumers omit the flag and see the legacy full-body shape. Summary
  mode deep-copies category slices so a request never mutates the
  package-level library data — TestPlaybookLibrary_SummaryDoesNotMutate
  Global pins this.
- `GET /api/v1/library/entry?title=X` — NEW. Returns one matched entry
  in a `{type, convention|playbook}` envelope. Conventions-first
  precedence mirrors the dispatcher's `library activate` so a title
  resolves to the same kind in both surfaces. 400 on missing title,
  404 on no match.

Hoisted `playbookSummary` to `collections.PlaybookSummary` so the
bootstrap handler and the new library endpoints share one algorithm.
Bootstrap continues to call it for every playbook entry it returns.

Adds 12 handler tests + the existing bootstrap-summary test stays
green after the move. Lint clean on touched packages; `make check`
gate is blocked by a pre-existing gofmt issue in
internal/store/workspace_members.go captured as BUG-1565.

Parent: PLAN-1560. Unblocks TASK-1562 (CLI) and TASK-1563 (MCP catalog).
This commit is contained in:
xarmian
2026-05-21 13:08:02 -04:00
committed by GitHub
parent ef7eabaddc
commit 2df6edeaab
9 changed files with 486 additions and 60 deletions
+2 -1
View File
@@ -8,7 +8,8 @@ package collections
// both unset for trigger-only checklist playbooks (legacy shape).
type LibraryPlaybook struct {
Title string `json:"title"`
Content string `json:"content"`
Content string `json:"content,omitempty"` // omitted when summary mode is on (?summary=true)
Summary string `json:"summary,omitempty"` // injected when summary mode is on; first non-heading paragraph, ~240 char cap
Category string `json:"category"` // workflow, planning, quality, operations
Trigger string `json:"trigger"` // on-implement, on-triage, on-release, on-plan, on-review, on-deploy, manual
Scope string `json:"scope"` // all, backend, frontend, etc.
+56
View File
@@ -0,0 +1,56 @@
package collections
// PlaybookSummary extracts a short prose hint from a playbook body. Picks the
// first non-heading non-empty paragraph and caps at ~240 chars so summary
// payloads stay compact.
//
// Lives here (rather than in internal/server/) so the bootstrap handler, the
// library HTTP endpoints, and any future surface that wants a playbook
// summary all share one algorithm. The bootstrap handler previously owned
// this helper (handlers_bootstrap.go) — TASK-1561 hoisted it to the
// collections package alongside the library data it summarizes.
func PlaybookSummary(body string) string {
const maxLen = 240
const ellipsis = "…"
for _, line := range splitLines(body) {
trimmed := trimLeadingSpaces(line)
if trimmed == "" {
continue
}
// Skip markdown headings — they're labels, not summaries.
if len(trimmed) > 0 && trimmed[0] == '#' {
continue
}
if len(trimmed) > maxLen {
return trimmed[:maxLen-len(ellipsis)] + ellipsis
}
return trimmed
}
return ""
}
// splitLines is a small dependency-free helper. We avoid bufio.Scanner here
// because the typical body is small (under 50KB) and allocating a scanner
// per playbook is wasteful at this scale.
func splitLines(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
func trimLeadingSpaces(s string) string {
i := 0
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
return s[i:]
}
+2 -50
View File
@@ -6,6 +6,7 @@ import (
"sort"
"strings"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
)
@@ -686,7 +687,7 @@ func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, i
Scope: strField("scope"),
Status: strField("status"),
HasArguments: hasArgs,
Summary: playbookSummary(it.Content),
Summary: collections.PlaybookSummary(it.Content),
})
}
// Stable order: invocation_slug-bearing first (the user-facing,
@@ -702,55 +703,6 @@ func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, i
return out, nil
}
// playbookSummary extracts a short prose hint from a playbook body. Picks
// the first non-heading non-empty paragraph and caps at ~240 chars so the
// bootstrap stays compact.
func playbookSummary(body string) string {
const maxLen = 240
const ellipsis = "…"
for _, line := range splitLines(body) {
trimmed := trimLeadingSpaces(line)
if trimmed == "" {
continue
}
// Skip markdown headings — they're labels, not summaries.
if len(trimmed) > 0 && trimmed[0] == '#' {
continue
}
if len(trimmed) > maxLen {
return trimmed[:maxLen-len(ellipsis)] + ellipsis
}
return trimmed
}
return ""
}
// splitLines is a small dependency-free helper. We avoid bufio.Scanner
// here because the typical body is small (under 50KB) and allocating a
// scanner per playbook is wasteful at this scale.
func splitLines(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
func trimLeadingSpaces(s string) string {
i := 0
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
return s[i:]
}
// capBootstrapDashboard wraps a DashboardResponse with the bootstrap's
// per-section caps. The underlying *DashboardResponse is shallow-copied
// before the slice headers are reslized so the caller's pointer (used by
+4 -3
View File
@@ -7,6 +7,7 @@ import (
"reflect"
"testing"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
@@ -853,9 +854,9 @@ func TestPlaybookSummaryPrefersFirstParagraph(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := playbookSummary(tc.body)
got := collections.PlaybookSummary(tc.body)
if got != tc.want {
t.Errorf("playbookSummary() = %q, want %q", got, tc.want)
t.Errorf("collections.PlaybookSummary() = %q, want %q", got, tc.want)
}
})
}
@@ -866,7 +867,7 @@ func TestPlaybookSummaryPrefersFirstParagraph(t *testing.T) {
for i := 0; i < 100; i++ {
long += "abcdefghij"
}
got := playbookSummary(long)
got := collections.PlaybookSummary(long)
if len(got) > 240 {
t.Errorf("long summary not capped at 240 chars; got %d", len(got))
}
+22 -3
View File
@@ -6,11 +6,30 @@ import (
"github.com/PerpetualSoftware/pad/internal/collections"
)
// handleConventionLibrary returns the global convention library.
//
// Query params (all optional):
// - category=<name> — return only the matching category. Case-sensitive
// exact match against LibraryCategory.Name. Unknown categories return
// an empty Categories slice (NOT 404 — the library itself exists; the
// filter just produced no rows). TASK-1561 / PLAN-1560.
//
// No workspace context — the library is global content.
func (s *Server) handleConventionLibrary(w http.ResponseWriter, r *http.Request) {
type response struct {
Categories []collections.LibraryCategory `json:"categories"`
}
writeJSON(w, http.StatusOK, response{
Categories: collections.ConventionLibrary(),
})
cats := collections.ConventionLibrary()
if category := r.URL.Query().Get("category"); category != "" {
filtered := make([]collections.LibraryCategory, 0, 1)
for _, cat := range cats {
if cat.Name == category {
filtered = append(filtered, cat)
}
}
cats = filtered
}
writeJSON(w, http.StatusOK, response{Categories: cats})
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"net/http"
"github.com/PerpetualSoftware/pad/internal/collections"
)
// libraryEntryResponse is the envelope returned by the /library/entry
// endpoint — type is "convention" or "playbook" so callers can deserialize
// the entry into the right shape without inspecting fields. Only one of
// Convention or Playbook is set per response.
type libraryEntryResponse struct {
Type string `json:"type"`
Convention *collections.LibraryConvention `json:"convention,omitempty"`
Playbook *collections.LibraryPlaybook `json:"playbook,omitempty"`
}
// handleLibraryEntry returns a single library entry by exact title match.
//
// Lookup precedence — conventions first, then playbooks — mirrors the
// dispatcher's `library activate` so the two stay in lockstep: if a title
// resolves to a convention for activate, it resolves to a convention here
// too.
//
// Required query param:
// - title=<exact-title>
//
// Returns 400 if title is missing, 404 if not found in either library.
// Full body is included; this endpoint is the canonical "get one entry's
// full content" path complementing the list endpoints' summary mode.
//
// TASK-1561 / PLAN-1560. No workspace context — the library is global.
func (s *Server) handleLibraryEntry(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
if title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "title query parameter is required",
})
return
}
if conv := collections.GetLibraryConvention(title); conv != nil {
writeJSON(w, http.StatusOK, libraryEntryResponse{
Type: "convention",
Convention: conv,
})
return
}
if pb := collections.GetLibraryPlaybook(title); pb != nil {
writeJSON(w, http.StatusOK, libraryEntryResponse{
Type: "playbook",
Playbook: pb,
})
return
}
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "not found in convention or playbook library: " + title,
})
}
+290
View File
@@ -0,0 +1,290 @@
package server
import (
"net/http"
"net/url"
"testing"
"github.com/PerpetualSoftware/pad/internal/collections"
)
// TestConventionLibrary_NoParams verifies the default endpoint shape stays
// stable — the web UI library page and the MCP dispatcher both consume this
// without query params and expect full bodies.
func TestConventionLibrary_NoParams(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/convention-library", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Categories []collections.LibraryCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) == 0 {
t.Fatal("expected at least one category in the library")
}
// Spot-check: a known category and a known full-content body.
foundGit := false
for _, cat := range resp.Categories {
if cat.Name == "git" {
foundGit = true
if len(cat.Conventions) == 0 {
t.Errorf("git category has no conventions")
}
for _, conv := range cat.Conventions {
if conv.Content == "" {
t.Errorf("expected full content for convention %q, got empty", conv.Title)
}
}
}
}
if !foundGit {
t.Errorf("expected git category in convention library")
}
}
// TestConventionLibrary_CategoryFilter — ?category=git returns only git.
func TestConventionLibrary_CategoryFilter(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/convention-library?category=git", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.LibraryCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) != 1 {
t.Fatalf("expected exactly 1 category, got %d", len(resp.Categories))
}
if resp.Categories[0].Name != "git" {
t.Errorf("expected category name 'git', got %q", resp.Categories[0].Name)
}
}
// TestConventionLibrary_UnknownCategory — empty categories slice, NOT 404.
// The library itself exists; the filter just produced no rows.
func TestConventionLibrary_UnknownCategory(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/convention-library?category=nonexistent-zzz", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.LibraryCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) != 0 {
t.Errorf("expected 0 categories for unknown filter, got %d", len(resp.Categories))
}
}
// TestPlaybookLibrary_NoParams — legacy shape: full Content, no Summary.
func TestPlaybookLibrary_NoParams(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/playbook-library", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) == 0 {
t.Fatal("expected at least one playbook category")
}
for _, cat := range resp.Categories {
for _, pb := range cat.Playbooks {
if pb.Content == "" {
t.Errorf("expected full content for playbook %q (no summary mode), got empty", pb.Title)
}
if pb.Summary != "" {
t.Errorf("expected empty summary for playbook %q without ?summary=true, got %q", pb.Title, pb.Summary)
}
}
}
}
// TestPlaybookLibrary_CategoryFilter — ?category=agent-workflows.
func TestPlaybookLibrary_CategoryFilter(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/playbook-library?category=agent-workflows", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) != 1 {
t.Fatalf("expected exactly 1 category, got %d", len(resp.Categories))
}
if resp.Categories[0].Name != "agent-workflows" {
t.Errorf("expected agent-workflows, got %q", resp.Categories[0].Name)
}
}
// TestPlaybookLibrary_SummaryMode — Content stripped, Summary populated.
func TestPlaybookLibrary_SummaryMode(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/playbook-library?summary=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) == 0 {
t.Fatal("expected at least one playbook category")
}
checked := 0
for _, cat := range resp.Categories {
for _, pb := range cat.Playbooks {
if pb.Content != "" {
t.Errorf("expected empty content in summary mode for %q, got %d chars", pb.Title, len(pb.Content))
}
if pb.Summary == "" {
t.Errorf("expected non-empty summary for %q", pb.Title)
}
checked++
}
}
if checked == 0 {
t.Fatal("no playbooks found to assert against")
}
}
// TestPlaybookLibrary_SummaryDoesNotMutateGlobal — guards against the
// summary-mode handler clobbering the package-level library data via
// shared slice backing. A second non-summary request must still see full
// bodies after a summary request.
func TestPlaybookLibrary_SummaryDoesNotMutateGlobal(t *testing.T) {
srv := testServer(t)
// Request with summary=true first.
rr := doRequest(srv, "GET", "/api/v1/playbook-library?summary=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("summary req: expected 200, got %d", rr.Code)
}
// Now request without summary — bodies must be back to full.
rr = doRequest(srv, "GET", "/api/v1/playbook-library", nil)
if rr.Code != http.StatusOK {
t.Fatalf("plain req: expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
for _, cat := range resp.Categories {
for _, pb := range cat.Playbooks {
if pb.Content == "" {
t.Errorf("library data was mutated by a prior summary request: playbook %q has empty Content", pb.Title)
}
}
}
// And the global library accessor still has full bodies.
for _, cat := range collections.PlaybookLibrary() {
for _, pb := range cat.Playbooks {
if pb.Content == "" {
t.Errorf("global PlaybookLibrary() mutated: %q has empty Content", pb.Title)
}
}
}
}
// TestPlaybookLibrary_CategoryAndSummary — both flags together.
func TestPlaybookLibrary_CategoryAndSummary(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/playbook-library?category=agent-workflows&summary=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
parseJSON(t, rr, &resp)
if len(resp.Categories) != 1 {
t.Fatalf("expected 1 category, got %d", len(resp.Categories))
}
for _, pb := range resp.Categories[0].Playbooks {
if pb.Content != "" {
t.Errorf("expected empty content in combined mode for %q", pb.Title)
}
if pb.Summary == "" {
t.Errorf("expected non-empty summary for %q", pb.Title)
}
}
}
// TestLibraryEntry_Convention — title resolves to a convention, full body.
func TestLibraryEntry_Convention(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("Commit after task completion"), nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp libraryEntryResponse
parseJSON(t, rr, &resp)
if resp.Type != "convention" {
t.Errorf("expected type=convention, got %q", resp.Type)
}
if resp.Convention == nil {
t.Fatal("expected Convention to be set")
}
if resp.Convention.Content == "" {
t.Error("expected full content on convention entry")
}
if resp.Playbook != nil {
t.Error("expected Playbook to be nil for convention entry")
}
}
// TestLibraryEntry_Playbook — title resolves to a playbook, full body.
func TestLibraryEntry_Playbook(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("Ship tasks"), nil)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp libraryEntryResponse
parseJSON(t, rr, &resp)
if resp.Type != "playbook" {
t.Errorf("expected type=playbook, got %q", resp.Type)
}
if resp.Playbook == nil {
t.Fatal("expected Playbook to be set")
}
if resp.Playbook.Content == "" {
t.Error("expected full content on playbook entry")
}
if resp.Playbook.InvocationSlug != "ship" {
t.Errorf("expected invocation_slug=ship, got %q", resp.Playbook.InvocationSlug)
}
if resp.Convention != nil {
t.Error("expected Convention to be nil for playbook entry")
}
}
// TestLibraryEntry_MissingTitle — 400 when title is missing.
func TestLibraryEntry_MissingTitle(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/library/entry", nil)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing title, got %d", rr.Code)
}
}
// TestLibraryEntry_NotFound — 404 when title doesn't match anything.
func TestLibraryEntry_NotFound(t *testing.T) {
srv := testServer(t)
rr := doRequest(srv, "GET", "/api/v1/library/entry?title="+url.QueryEscape("definitely not a real library entry"), nil)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rr.Code)
}
}
+44 -3
View File
@@ -6,11 +6,52 @@ import (
"github.com/PerpetualSoftware/pad/internal/collections"
)
// handlePlaybookLibrary returns the global playbook library.
//
// Query params (all optional):
// - category=<name> — return only the matching category. Case-sensitive
// exact match against PlaybookCategory.Name. Unknown categories return
// an empty Categories slice.
// - summary=true — strip Content from each LibraryPlaybook and inject
// a Summary field via collections.PlaybookSummary. Keeps payloads
// compact for browsing surfaces (CLI default, MCP). Web UI and other
// consumers that want the full body omit the flag.
//
// TASK-1561 / PLAN-1560. No workspace context — the library is global.
func (s *Server) handlePlaybookLibrary(w http.ResponseWriter, r *http.Request) {
type response struct {
Categories []collections.PlaybookCategory `json:"categories"`
}
writeJSON(w, http.StatusOK, response{
Categories: collections.PlaybookLibrary(),
})
cats := collections.PlaybookLibrary()
if category := r.URL.Query().Get("category"); category != "" {
filtered := make([]collections.PlaybookCategory, 0, 1)
for _, cat := range cats {
if cat.Name == category {
filtered = append(filtered, cat)
}
}
cats = filtered
}
if r.URL.Query().Get("summary") == "true" {
// Materialize a summary-mode copy. Categories carry value-type
// playbook slices, so we can mutate freely after copying without
// touching the package-level library data.
summarized := make([]collections.PlaybookCategory, len(cats))
for i, cat := range cats {
pbs := make([]collections.LibraryPlaybook, len(cat.Playbooks))
for j, pb := range cat.Playbooks {
pb.Summary = collections.PlaybookSummary(pb.Content)
pb.Content = ""
pbs[j] = pb
}
cat.Playbooks = pbs
summarized[i] = cat
}
cats = summarized
}
writeJSON(w, http.StatusOK, response{Categories: cats})
}
+4
View File
@@ -961,6 +961,10 @@ func (s *Server) setupRouter() {
// Playbook Library
r.Get("/playbook-library", s.handlePlaybookLibrary)
// Single library entry by title (conventions first, then playbooks).
// TASK-1561 / PLAN-1560.
r.Get("/library/entry", s.handleLibraryEntry)
// URL import — fetch a remote page and return markdown.
// Side-effect-free; the client decides what to do with the
// markdown. See PLAN-1467 / TASK-1472 / internal/urlimport.