Files
pad/internal/mcp/dispatch_http_library.go
T
xarmian c17865462f feat: built-ins record their origin; an item made from one can be offered Pad's current text (TASK-3462 U1+U2) (#1852)
* feat: built-ins record their origin, and an item made from one can be offered Pad's current text (TASK-3462 U1+U2)

U1: every convention and playbook Pad ships has a stable key and a content
hash; template seeding and the new server-side POST /library/activate record
{key, seed hash, seed text} in item_builtin_origin (migration 128 / pg 102).
CLI, remote MCP and the web Library page activate through the endpoint;
seeded and activated copies of an entry store the same fields. Export/import
carry the origin; copies get none; a request body cannot claim one.

U2: GET /items/{ref}/builtin derives current / update_available / diverged /
unknown_origin / unknown_entry; POST /items/{ref}/builtin/update takes the
library text through the ordinary PATCH path (expected_seq required, status
and title untouched, a forced version row keeps a diverged item's edits);
GET /builtins lists states in one join, showing only fully visible
collections.

* fix: the origin's seed text is NUL-protected (trigger migration 129); the MCP activate test asserts the endpoint call; activation follows the declared kind server-side (TASK-3462)

* fix: the built-in seed moves in the update's own transaction; a NUL-bearing imported origin is skipped; the listing computes visibility once; numbers hash by value (TASK-3462, codex r1)

* fix: on the applier path the built-in seed is recorded only after the apply confirms; a library change that only drops a field is offered (TASK-3462, codex r2)

* fix: a field the library added that the user already set reads diverged; an update clears convention metadata the library dropped (TASK-3462, codex r3)

* test: the lifecycle test finds the imported item by slug (the copy shares its title); ScanNUL's cost figures count the two new columns (TASK-3462)

* test(e2e): Library activate posts the entry to /library/activate and the item records its built-in origin (TASK-3462)
2026-10-07 06:40:06 -04:00

55 lines
2.0 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/models"
)
// --- library activate ---
// dispatchLibraryActivate reproduces `pad library activate <title>`. The
// server's POST /workspaces/{ws}/library/activate does the work since
// TASK-3462: it builds the fields, picks the collection that declares the
// entry's artifact kind (BUG-2702), and records the item's built-in origin, so
// this door, the CLI and the web create the same item.
//
// The title is checked against the libraries in-process first, so an unknown
// one answers with the not-found payload this tool has always returned rather
// than the server's 404. The OAuth-scope hook (d.Apply) still runs on the
// POST, so this isn't a scope bypass.
func (d *HTTPHandlerDispatcher) dispatchLibraryActivate(
ctx context.Context,
input map[string]any,
user *models.User,
) (*CallToolResult, error) {
const cmdKey = "library activate"
workspace, _ := input["workspace"].(string)
if workspace == "" {
return noWorkspaceResult(ctx, d.Lister), nil
}
title, _ := input["title"].(string)
if title == "" {
return validationFailedResult(cmdKey, "title is required",
"Pass `title=<library-item-title>` matching an entry in the convention or playbook library."), nil
}
if collections.GetLibraryConvention(title) == nil && collections.GetLibraryPlaybook(title) == nil {
return NewErrorResult(ErrorPayload{
Code: ErrNotFound,
Message: fmt.Sprintf("%s: %q not found in convention or playbook library", cmdKey, title),
Hint: "Use `pad_library action=list` to enumerate available titles.",
}), nil
}
body, err := json.Marshal(map[string]string{"title": title})
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
urlPath := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/library/activate"
return d.executeRequest(ctx, cmdKey, user, http.MethodPost, urlPath, body)
}