mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
42f6ce96e1
Three independent improvements bundled as one PR because they all touch
the same dispatcher error-emission surface; landing them piecemeal
would churn the same lines repeatedly.
## TASK-1077 — uniform envelope shape
Pre-fix some dispatchers emitted plain-string errors via
`mcp.NewToolResultErrorf("%s: %s failed: %s", ...)`. Same underlying
404 surfaced in three different shapes across the surface (item
lookup → structured envelope; note/decide → "item note: prefetch:
404 ..."; bulk-update per-row → bare error string). Inconsistent
shape made it hard for agents to reason about errors uniformly.
Three new helpers in errors.go:
- validationFailedResult(cmdKey, msg, fixHint) — replaces the
"X is required" / "invalid Y" chain across every dispatcher.
- dispatcherErrorResult(cmdKey, op, err) — replaces the internal
"build request: %s" / "encode body: %s" / "parse current: %s"
chain. Always emits ErrServerError with a programmer-readable
Hint.
- upstreamHTTPErrorResult(...) — wraps every in-handler prefetch /
sub-call HTTP failure through classifyHTTPStatusKind so the shape
matches the main pipeline's responses exactly.
Every NewToolResultErrorf call site in internal/mcp/dispatch_http*.go
+ catalog.go retrofitted. bulk-update's per-row `Error string` field
flipped to `Error *ErrorPayload` so every row failure carries the
same {code, message, hint} shape as a top-level failure.
## TASK-1078 — resource-kind-aware error codes
Pre-fix every 4xx 404 collapsed to ErrItemNotFound regardless of
what was being read; pad_workspace list returning 404 (route
missing) reported `code: "item_not_found"` despite the call having
nothing to do with items. Pre-fix every 5xx collapsed to
ErrServerError, indistinguishable from dispatcher internal failures.
Three new codes in errors.go:
- ErrNotFound — resource-shaped 404s that AREN'T item lookups
(collection, listing endpoint, link target, attachment).
- ErrUpstreamError — 5xx with a structured body (transient backend
failure). Distinct from ErrServerError (catch-all for dispatcher
internal + un-mapped 4xx).
- ErrBackendUnreachable — reserved for transport-level failures
(DNS / connection refused / 5xx with no body); not yet emitted
by classifyHTTPStatus but available for future transport-aware
classification.
- ErrWorkspaceRequired — reserved for the multi-workspace-token
"ambiguous default" case (TASK-1076's deferred sister error;
constant available even though dispatcher doesn't emit it yet).
New ResourceKind enum (item/workspace/collection/listing/link/
attachment/unknown) lets callers tell the classifier what they
were reading. classifyHTTPStatusKind is the new entry point;
classifyHTTPStatus preserved as a legacy adapter for callers that
haven't been retrofitted (pass ResourceUnknown → falls back to
pre-TASK-1078 behaviour).
Every retrofit call site passes its known kind + ref/slug, so 404s
now route through the right code with a contextual message
("Item TASK-7 not found.", "Workspace foo not visible.",
"Collection tasks not found.", etc.).
## TASK-1079 — actionable hints
Pre-fix `hint` was usually `"404 page not found"` (chi's default
NotFound body verbatim) or the upstream JSON envelope re-stringified.
Either way: zero diagnostic value, sometimes outright misleading
(double-stringified JSON in a hint field is hostile).
Per-code hint generators in errors.go:
- itemMissingHint — names the ref + route + suggests pad_item
search / list as recovery.
- workspaceMissingHint — names the slug + route + composes with
the existing available_workspaces enrichment.
- notFoundHintFor — kind-aware: collection 404 → "use pad_collection
list to enumerate"; listing 404 → "verify the route matches the
server's API surface (build version may be stale)"; etc.
- authHintFor / permissionHintFor — point at re-auth / scope check.
- upstreamHintFor — flags 5xx as "usually transient — retry once or
check pad logs."
extractUpstreamMessage parses pad's own structured `{error:{message}}`
envelope when the upstream backend returned one, so hints lift the
inner human-readable message out instead of dumping the literal JSON.
Falls back to the raw body when the JSON shape doesn't match (no
parse failure noise).
## Tests
- TestDispatcher_AllErrorsUseStructuredEnvelope walks every
special-case + link dispatcher's missing-required-input error
path; pins the shape (code, message, hint all set; hint never
just "404 page not found"). Adding a new dispatcher that uses
NewToolResultErrorf will fail this test — it's the regression
gate the DOD wants.
- TestClassifyHTTPStatus_KindAware pins each ResourceKind →
expected ErrorCode mapping for 404s.
- TestClassifyHTTPStatus_HintsAreActionable pins that hints
reference the actual route + ref + recovery tools, AND forbids
the bare "404 page not found" passthrough that triggered Bug 17.
- TestExtractUpstreamMessage covers the 7 input shapes the helper
can see (structured envelope, empty inner, missing inner field,
unparseable, wrong shape, empty, with extra fields).
- Two existing tests updated to reflect the new shapes:
TestClassifyHTTPStatus 5xx cases now expect ErrUpstreamError;
TestMakeFanOutHandler_UnknownAction + TestActionEnv_Dispatch_
UnknownCmdPath substring searches updated for JSON-encoded
quotes.
## Behavior diff agents will observe
Same underlying 404, three example error envelopes:
pad_item show TASK-MISSING:
code: "item_not_found"
message: "Item not found."
hint: "Item \"TASK-MISSING\" not found. Route: /api/v1/.../items/TASK-MISSING. Try `pad_item search` or `pad_item list` to find the right ref."
pad_workspace list (route 404):
code: "unknown_workspace"
message: "Workspace not visible to this session."
hint: "Route: /api/v1/workspaces. Available workspaces: docapp, pad-web."
pad_project dashboard (workspace doesn't exist):
code: "unknown_workspace"
message: "Workspace \"missing\" is not visible to this session."
hint: "Workspace \"missing\" not visible. Route: /api/v1/workspaces/missing/dashboard. Available workspaces: docapp."
Backend 500:
code: "upstream_error"
message: "pad item show failed: backend returned 500"
hint: "Backend returned 500. Usually transient — retry once or check pad logs for the underlying error. Route: ..."
639 lines
22 KiB
Go
639 lines
22 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// dispatchProjectReady reproduces `pad project ready --format json` —
|
|
// the CLI returns `{count, results}` extracted from the dashboard's
|
|
// SuggestedNext slice, NOT the full dashboard payload. (Compare with
|
|
// `pad project next` which returns the raw dashboard JSON; both surface
|
|
// the same suggestions but with different framing.)
|
|
//
|
|
// Aliasing to /dashboard would be a behavioural divergence: the
|
|
// agent would see an unexpected wrapper shape and have to know to dig
|
|
// into `suggested_next`. Mirroring the CLI's `{count, results}` shape
|
|
// keeps the MCP transport equivalent to ExecDispatcher.
|
|
func (d *HTTPHandlerDispatcher) dispatchProjectReady(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "project ready"
|
|
dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey)
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
suggestions := dashboardArrayField(dash, "suggested_next")
|
|
return packageStructuredResponse(cmdKey, map[string]any{
|
|
"count": len(suggestions),
|
|
"results": suggestions,
|
|
})
|
|
}
|
|
|
|
// dispatchProjectStale reproduces `pad project stale --format json` —
|
|
// CLI filters the dashboard's Attention slice to "interesting" types
|
|
// (stalled / blocked / overdue / orphaned_task) before returning
|
|
// `{count, results}`. Sorting matches cmd/pad/query.go's
|
|
// filterAgentAttention: type, ItemRef, ItemTitle.
|
|
//
|
|
// Operates on the raw map[string]any decoded from the dashboard JSON
|
|
// so any field server.DashboardAttention adds in future versions
|
|
// (collection, plus anything not yet wired) flows through unchanged.
|
|
// Codex review on PR #348 round 1 caught the previous typed-struct
|
|
// approach dropping `collection` from the response.
|
|
func (d *HTTPHandlerDispatcher) dispatchProjectStale(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "project stale"
|
|
dash, errRes := d.fetchDashboardJSON(ctx, input, user, cmdKey)
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
attention := filterAgentAttention(dashboardArrayField(dash, "attention"))
|
|
return packageStructuredResponse(cmdKey, map[string]any{
|
|
"count": len(attention),
|
|
"results": attention,
|
|
})
|
|
}
|
|
|
|
// fetchDashboardJSON hits the workspace dashboard endpoint and decodes
|
|
// the response into a generic map[string]any so the dispatcher
|
|
// preserves every field the server emits — no maintenance burden when
|
|
// new fields land on DashboardAttention / DashboardSuggestion.
|
|
//
|
|
// Returns the raw object so callers can pull specific fields
|
|
// (suggested_next, attention) via dashboardArrayField without the
|
|
// typed-struct round-trip.
|
|
func (d *HTTPHandlerDispatcher) fetchDashboardJSON(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
cmdKey string,
|
|
) (map[string]any, *mcp.CallToolResult) {
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return nil, validationFailedResult(cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace.")
|
|
}
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/dashboard"
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
|
|
if err != nil {
|
|
return nil, dispatcherErrorResult(cmdKey, "build dashboard request", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
return nil, upstreamHTTPErrorResult(ctx, cmdKey, "fetch dashboard", path,
|
|
rec.Code, rec.Body.Bytes(), d.Lister, ResourceWorkspace, workspace)
|
|
}
|
|
var dash map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &dash); err != nil {
|
|
return nil, dispatcherErrorResult(cmdKey, "parse dashboard", err)
|
|
}
|
|
return dash, nil
|
|
}
|
|
|
|
// dashboardArrayField pulls a named array out of a decoded dashboard
|
|
// payload, returning a typed []map[string]any so the callers can
|
|
// filter / sort by string fields without the json.Number / interface{}
|
|
// dance per element. Missing/empty/non-array values normalize to an
|
|
// empty slice so the {count, results} responses always emit a usable
|
|
// shape.
|
|
func dashboardArrayField(dash map[string]any, key string) []map[string]any {
|
|
raw, ok := dash[key].([]any)
|
|
if !ok {
|
|
return []map[string]any{}
|
|
}
|
|
out := make([]map[string]any, 0, len(raw))
|
|
for _, e := range raw {
|
|
if m, ok := e.(map[string]any); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// filterAgentAttention mirrors cmd/pad/query.go's helper of the same
|
|
// name — keeps only the attention types agents care about (stalled,
|
|
// blocked, overdue, orphaned_task) and sorts deterministically by
|
|
// (type, item_ref, item_title). Same stable ordering as the CLI so
|
|
// `--format json` outputs match between transports.
|
|
//
|
|
// Operates on map[string]any (not a typed struct) so attention
|
|
// entries pass through to the response with EVERY field the server
|
|
// emitted, not just the ones we knew to declare. Codex review on PR
|
|
// #348 caught the previous typed approach dropping `collection`.
|
|
func filterAgentAttention(attention []map[string]any) []map[string]any {
|
|
interesting := map[string]bool{
|
|
"stalled": true,
|
|
"blocked": true,
|
|
"overdue": true,
|
|
"orphaned_task": true,
|
|
}
|
|
results := make([]map[string]any, 0, len(attention))
|
|
for _, item := range attention {
|
|
typ, _ := item["type"].(string)
|
|
if interesting[typ] {
|
|
results = append(results, item)
|
|
}
|
|
}
|
|
sort.SliceStable(results, func(i, j int) bool {
|
|
ti, _ := results[i]["type"].(string)
|
|
tj, _ := results[j]["type"].(string)
|
|
if ti != tj {
|
|
return ti < tj
|
|
}
|
|
ri, _ := results[i]["item_ref"].(string)
|
|
rj, _ := results[j]["item_ref"].(string)
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
titI, _ := results[i]["item_title"].(string)
|
|
titJ, _ := results[j]["item_title"].(string)
|
|
return titI < titJ
|
|
})
|
|
return results
|
|
}
|
|
|
|
// --- item bulk-update ---
|
|
|
|
// dispatchItemBulkUpdate iterates the input's `ref` array and applies
|
|
// --status / --priority via the same read-modify-write semantics the
|
|
// item.update path uses (so existing fields survive). Mirrors the
|
|
// CLI's bulkUpdateCmd: at-least-one-of-status-or-priority gating, per-
|
|
// item GET → field merge → PATCH, and a per-item success/error report.
|
|
//
|
|
// The cmdhelp surface marks `ref` as required AND repeatable — agents
|
|
// pass it as either []any (typical JSON array) or []string. Anything
|
|
// else is rejected so the dispatcher doesn't silently iterate over
|
|
// nothing.
|
|
//
|
|
// Per-item failures don't abort the bulk operation; they get
|
|
// individually reported in the response so an agent can inspect what
|
|
// succeeded vs. failed without having to retry the whole batch.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemBulkUpdate(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item bulk-update"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return validationFailedResult(cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
|
|
}
|
|
|
|
refs, err := bulkUpdateRefs(input["ref"])
|
|
if err != nil {
|
|
return validationFailedResult(cmdKey, err.Error(),
|
|
"Pass `ref` as a string or array of TASK-N / BUG-N / etc. refs."), nil
|
|
}
|
|
if len(refs) == 0 {
|
|
return validationFailedResult(cmdKey, "at least one ref is required",
|
|
"Pass at least one item ref (e.g. ref=[\"TASK-7\",\"TASK-8\"])."), nil
|
|
}
|
|
|
|
status, _ := input["status"].(string)
|
|
priority, _ := input["priority"].(string)
|
|
if status == "" && priority == "" {
|
|
return validationFailedResult(cmdKey, "at least one of status or priority is required",
|
|
"Pass `status=<value>` and/or `priority=<value>` to specify what to update."), nil
|
|
}
|
|
|
|
type bulkResult struct {
|
|
Ref string `json:"ref"`
|
|
Updated bool `json:"updated"`
|
|
Error *ErrorPayload `json:"error,omitempty"`
|
|
}
|
|
results := make([]bulkResult, 0, len(refs))
|
|
successes := 0
|
|
|
|
// rowError builds a per-row ErrorPayload. Reuses the same shape
|
|
// the top-level error envelope uses — agents see consistent
|
|
// {code, message, hint} per failed row instead of bare strings
|
|
// (BUG-1077 / Bug 15: bulk-update was the partially-fixed case
|
|
// that surfaced this requirement).
|
|
rowError := func(code ErrorCode, msg, hint string) *ErrorPayload {
|
|
return &ErrorPayload{Code: code, Message: msg, Hint: hint}
|
|
}
|
|
rowDispatcherError := func(op string, err error) *ErrorPayload {
|
|
return rowError(ErrServerError, fmt.Sprintf("%s failed", op),
|
|
fmt.Sprintf("Internal: %s — %s", op, err.Error()))
|
|
}
|
|
rowUpstreamError := func(op, route string, status int, body []byte, ref string) *ErrorPayload {
|
|
res := classifyHTTPStatusKind(ctx, cmdKey, route, status, body, d.Lister, ResourceItem, ref)
|
|
env := envelopeFrom(res)
|
|
if op != "" && env.Error.Message != "" {
|
|
env.Error.Message = fmt.Sprintf("%s (%s)", env.Error.Message, op)
|
|
}
|
|
return &env.Error
|
|
}
|
|
|
|
for _, ref := range refs {
|
|
// Per-item RMW: GET, merge fields, PATCH. Same shape
|
|
// dispatchItemUpdate uses, but inlined here so a per-item
|
|
// failure produces a {ref, error} entry instead of aborting.
|
|
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
|
|
getReq, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user)
|
|
if err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("build request", err)})
|
|
continue
|
|
}
|
|
getRec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(getRec, getReq)
|
|
if getRec.Code >= 400 {
|
|
results = append(results, bulkResult{
|
|
Ref: ref,
|
|
Error: rowUpstreamError("read item", itemPath, getRec.Code, getRec.Body.Bytes(), ref),
|
|
})
|
|
continue
|
|
}
|
|
var existing struct {
|
|
Fields string `json:"fields"`
|
|
}
|
|
if err := json.Unmarshal(getRec.Body.Bytes(), &existing); err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("parse item", err)})
|
|
continue
|
|
}
|
|
|
|
merged := map[string]any{}
|
|
if existing.Fields != "" && existing.Fields != "{}" {
|
|
if err := json.Unmarshal([]byte(existing.Fields), &merged); err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("parse existing fields", err)})
|
|
continue
|
|
}
|
|
}
|
|
if status != "" {
|
|
merged["status"] = status
|
|
}
|
|
if priority != "" {
|
|
merged["priority"] = priority
|
|
}
|
|
fieldsJSON, err := json.Marshal(merged)
|
|
if err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("encode fields", err)})
|
|
continue
|
|
}
|
|
fieldsStr := string(fieldsJSON)
|
|
patchBody, err := json.Marshal(map[string]any{"fields": fieldsStr})
|
|
if err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("encode body", err)})
|
|
continue
|
|
}
|
|
|
|
patchReq, err := d.buildAuthedRequest(ctx, http.MethodPatch, itemPath, patchBody, user)
|
|
if err != nil {
|
|
results = append(results, bulkResult{Ref: ref, Error: rowDispatcherError("build PATCH", err)})
|
|
continue
|
|
}
|
|
patchRec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(patchRec, patchReq)
|
|
if patchRec.Code >= 400 {
|
|
results = append(results, bulkResult{
|
|
Ref: ref,
|
|
Error: rowUpstreamError("update item", itemPath, patchRec.Code, patchRec.Body.Bytes(), ref),
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, bulkResult{Ref: ref, Updated: true})
|
|
successes++
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"updated": successes,
|
|
"total": len(refs),
|
|
"results": results,
|
|
}
|
|
return packageStructuredResponse(cmdKey, payload)
|
|
}
|
|
|
|
// bulkUpdateRefs canonicalizes the `ref` input — accepts repeatable
|
|
// shapes the cmdhelp registry generates (string for a single value,
|
|
// []any from JSON arrays, []string from typed callers) into a clean
|
|
// []string. Empty / non-string entries are rejected so we don't
|
|
// silently skip elements an agent expected to be processed.
|
|
func bulkUpdateRefs(raw any) ([]string, error) {
|
|
switch v := raw.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case string:
|
|
if v == "" {
|
|
return nil, nil
|
|
}
|
|
return []string{v}, nil
|
|
case []string:
|
|
out := make([]string, 0, len(v))
|
|
for i, s := range v {
|
|
if s == "" {
|
|
return nil, fmt.Errorf("ref[%d] is empty", i)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for i, e := range v {
|
|
s, ok := e.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("ref[%d] must be a string, got %T", i, e)
|
|
}
|
|
if s == "" {
|
|
return nil, fmt.Errorf("ref[%d] is empty", i)
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
default:
|
|
return nil, fmt.Errorf("ref must be a string or array of strings, got %T", raw)
|
|
}
|
|
}
|
|
|
|
// --- item note + decide (RMW append) ---
|
|
|
|
// dispatchItemNote handles `pad item note <ref> <summary>
|
|
// [--details ...]` — appends an implementation-note entry to the
|
|
// item's structured-fields blob, then PATCHes.
|
|
//
|
|
// Same RMW shape as dispatchItemUpdate but using
|
|
// models.AppendImplementationNote so the entry gets the right shape
|
|
// + ID + timestamp the CLI applies.
|
|
//
|
|
// Emits the updated item (the PATCH response) like every other
|
|
// dispatcher — agents see the same shape they'd get from a follow-up
|
|
// `item show`.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemNote(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item note"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
ref, _ := input["ref"].(string)
|
|
summary, _ := input["summary"].(string)
|
|
if workspace == "" {
|
|
return validationFailedResult(cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
|
|
}
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required",
|
|
"Pass `ref=<TASK-N>` (or whichever item ref the note targets)."), nil
|
|
}
|
|
if summary == "" {
|
|
return validationFailedResult(cmdKey, "summary is required",
|
|
"Pass `summary=<short text>` describing the note."), nil
|
|
}
|
|
details, _ := input["details"].(string)
|
|
details = strings.TrimSpace(details)
|
|
|
|
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath, ref)
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
|
|
updated, err := models.AppendImplementationNote(currentFields, models.ItemImplementationNote{
|
|
ID: newStructuredEntryID("note"),
|
|
Summary: strings.TrimSpace(summary),
|
|
Details: details,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
CreatedBy: userActorLabel(user),
|
|
})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "append note", err), nil
|
|
}
|
|
|
|
body, err := json.Marshal(map[string]any{"fields": updated})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode body", err), nil
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
|
|
}
|
|
|
|
// dispatchItemDecide is the decision-log analogue of
|
|
// dispatchItemNote — same RMW shape, just using
|
|
// AppendDecisionLogEntry on a different fields slot.
|
|
func (d *HTTPHandlerDispatcher) dispatchItemDecide(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "item decide"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
ref, _ := input["ref"].(string)
|
|
decision, _ := input["decision"].(string)
|
|
if workspace == "" {
|
|
return validationFailedResult(cmdKey, "workspace is required",
|
|
"Pass `workspace=<slug>` or set a session default via pad_set_workspace."), nil
|
|
}
|
|
if ref == "" {
|
|
return validationFailedResult(cmdKey, "ref is required",
|
|
"Pass `ref=<TASK-N>` (or whichever item ref the decision targets)."), nil
|
|
}
|
|
if decision == "" {
|
|
return validationFailedResult(cmdKey, "decision is required",
|
|
"Pass `decision=<short text>` describing the decision."), nil
|
|
}
|
|
rationale, _ := input["rationale"].(string)
|
|
rationale = strings.TrimSpace(rationale)
|
|
|
|
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
currentFields, errRes := d.prefetchItemFields(ctx, user, cmdKey, itemPath, ref)
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
|
|
updated, err := models.AppendDecisionLogEntry(currentFields, models.ItemDecisionLogEntry{
|
|
ID: newStructuredEntryID("decision"),
|
|
Decision: strings.TrimSpace(decision),
|
|
Rationale: rationale,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
CreatedBy: userActorLabel(user),
|
|
})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "append decision", err), nil
|
|
}
|
|
|
|
body, err := json.Marshal(map[string]any{"fields": updated})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode body", err), nil
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
|
|
}
|
|
|
|
// prefetchItemFields GETs the item at itemPath and returns its
|
|
// `fields` JSON string. Surfaces 404s and parse errors as
|
|
// IsError-flagged tool results so the dispatcher's caller can return
|
|
// them directly without further wrapping.
|
|
//
|
|
// Used by note/decide which append into the existing fields blob —
|
|
// they need the current value so AppendImplementationNote /
|
|
// AppendDecisionLogEntry can preserve other entries.
|
|
//
|
|
// ref is the item ref the caller was looking up; threaded through to
|
|
// the error envelope so agents see e.g. "Item TASK-7 not found"
|
|
// rather than a bare 404 (TASK-1078 / TASK-1079).
|
|
func (d *HTTPHandlerDispatcher) prefetchItemFields(
|
|
ctx context.Context,
|
|
user *models.User,
|
|
cmdKey, itemPath, ref string,
|
|
) (string, *mcp.CallToolResult) {
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user)
|
|
if err != nil {
|
|
return "", dispatcherErrorResult(cmdKey, "build prefetch", err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
return "", upstreamHTTPErrorResult(ctx, cmdKey, "prefetch item", itemPath,
|
|
rec.Code, rec.Body.Bytes(), d.Lister, ResourceItem, ref)
|
|
}
|
|
var existing struct {
|
|
Fields string `json:"fields"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &existing); err != nil {
|
|
return "", dispatcherErrorResult(cmdKey, "parse current item", err)
|
|
}
|
|
return existing.Fields, nil
|
|
}
|
|
|
|
// newStructuredEntryID mirrors the CLI's helper for note/decision
|
|
// IDs (cmd/pad/notes.go). The actual collision-avoidance is handled
|
|
// by combining the prefix + a unix-nano timestamp — same shape so
|
|
// CLI-created and MCP-created entries are indistinguishable in
|
|
// downstream consumers.
|
|
func newStructuredEntryID(prefix string) string {
|
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UTC().UnixNano())
|
|
}
|
|
|
|
// userActorLabel produces a stable string label for the actor that
|
|
// created a structured entry. Mirrors the CLI's "user" label for
|
|
// CLI-driven entries; for MCP we use the requesting user's name (or
|
|
// email fallback) so audit-log review can tell who appended what
|
|
// when multiple users share the same MCP server.
|
|
func userActorLabel(user *models.User) string {
|
|
if user == nil {
|
|
return "user"
|
|
}
|
|
if user.Name != "" {
|
|
return user.Name
|
|
}
|
|
if user.Email != "" {
|
|
return user.Email
|
|
}
|
|
return "user"
|
|
}
|
|
|
|
// dispatchLibraryList composes the /convention-library and
|
|
// /playbook-library endpoints to mirror `pad library list --format
|
|
// json`. The CLI's JSON output shape varies on --type:
|
|
//
|
|
// - --type conventions → returns the convention library (lib).
|
|
// - --type playbooks → returns the playbook library (plib).
|
|
// - (no --type) → returns {conventions: lib, playbooks: plib}.
|
|
//
|
|
// `--category` is intentionally not applied here — the CLI also
|
|
// doesn't filter the JSON output by category (it's purely a
|
|
// human-readable rendering filter). Agents that want category
|
|
// filtering can apply it client-side over the returned categories[].
|
|
//
|
|
// The endpoints are global (no workspace), so we don't read
|
|
// `workspace` from input. Both endpoints require an authenticated
|
|
// user; the route table-level Apply hook handles that uniformly.
|
|
func (d *HTTPHandlerDispatcher) dispatchLibraryList(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*mcp.CallToolResult, error) {
|
|
const cmdKey = "library list"
|
|
typ, _ := input["type"].(string)
|
|
typ = strings.ToLower(strings.TrimSpace(typ))
|
|
|
|
wantConventions := typ == "" || typ == "conventions"
|
|
wantPlaybooks := typ == "" || typ == "playbooks"
|
|
if !wantConventions && !wantPlaybooks {
|
|
return validationFailedResult(cmdKey,
|
|
fmt.Sprintf("unknown --type %q", typ),
|
|
"Pass `type=conventions`, `type=playbooks`, or omit for both."), nil
|
|
}
|
|
|
|
var conventions any
|
|
var playbooks any
|
|
|
|
if wantConventions {
|
|
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/convention-library")
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
conventions = v
|
|
}
|
|
if wantPlaybooks {
|
|
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, "/api/v1/playbook-library")
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
playbooks = v
|
|
}
|
|
|
|
// Single-type mode returns the library payload directly (matches
|
|
// the CLI). Both-types mode wraps in {conventions, playbooks}.
|
|
switch {
|
|
case wantConventions && wantPlaybooks:
|
|
return packageStructuredResponse(cmdKey, map[string]any{
|
|
"conventions": conventions,
|
|
"playbooks": playbooks,
|
|
})
|
|
case wantConventions:
|
|
return packageStructuredResponse(cmdKey, conventions)
|
|
default:
|
|
return packageStructuredResponse(cmdKey, playbooks)
|
|
}
|
|
}
|
|
|
|
// fetchLibraryEndpoint GETs one of the library endpoints and decodes
|
|
// the JSON body into a generic any so the caller can stuff it into
|
|
// the composed response without losing the wire shape.
|
|
func (d *HTTPHandlerDispatcher) fetchLibraryEndpoint(
|
|
ctx context.Context,
|
|
user *models.User,
|
|
cmdKey, path string,
|
|
) (any, *mcp.CallToolResult) {
|
|
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
|
|
if err != nil {
|
|
return nil, dispatcherErrorResult(cmdKey, "build "+path, err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
d.Handler.ServeHTTP(rec, req)
|
|
if rec.Code >= 400 {
|
|
return nil, upstreamHTTPErrorResult(ctx, cmdKey, "fetch "+path, path,
|
|
rec.Code, rec.Body.Bytes(), d.Lister, ResourceListing, "")
|
|
}
|
|
var decoded any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil {
|
|
return nil, dispatcherErrorResult(cmdKey, "parse "+path, err)
|
|
}
|
|
return decoded, nil
|
|
}
|