mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
493498c977
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585) A replayed create whose item has since been deleted answers 410 idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no stdio marker for it, so it reached agents as a generic error. It is now allow-listed, mapped on the remote 410 arm, and marked on stdio, with a hint shared between internal/cli and internal/mcp. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for comment create (TASK-3585) POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key. The key's answer (the comment id) is recorded inside CreateCommentWithActivity's transaction, so a retry whose first answer was lost returns that comment instead of a twin. A refused comment releases its key; a replay of a comment deleted since answers 410 idempotency_target_gone. `pad item comment --op-key` (a fresh key per invocation by default) retries once behind the idempotency_keys capability; MCP comment forwards op_key on both transports. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(items): idempotency keys for note and decision appends (TASK-3585) PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or Idempotency-Key, but only on an append-only update (append_implementation_note and/or append_decision, plus source); a key with any other member is refused 400. The answer (the item id and the minted entries) is recorded in the update's own transaction, so a retry whose first answer was lost echoes the first entries in `appended` instead of appending twice. The built-in and patch callers, which reach this handler with their own key, are untouched. `pad item note|decide --op-key` (fresh per invocation by default) retry once behind the idempotency_keys capability; MCP note/decide forward op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410 now being structured). docs/api.md covers create, comment and append keys. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
695 lines
25 KiB
Go
695 lines
25 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/server"
|
|
)
|
|
|
|
// 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,
|
|
) (*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,
|
|
) (*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,
|
|
})
|
|
}
|
|
|
|
// dispatchProjectNext proxies `project next` to GET
|
|
// /workspaces/{ws}/next — handleGetProjectNext
|
|
// (internal/server/handlers_project_intel.go), the browser/REST
|
|
// surface PLAN-1888 / TASK-1894 added. Before TASK-1916 this method
|
|
// fetched the dashboard itself and sliced out suggested_next (there
|
|
// was no REST endpoint to route to yet — see BUG-987 bug 6: without
|
|
// that slicing, `project next` was indistinguishable from `project
|
|
// dashboard`); now the REST endpoint already returns exactly that
|
|
// slice, so this is a thin validate-then-forward.
|
|
//
|
|
// Kept as a hand-written method rather than a routeTable entry so the
|
|
// missing-workspace error keeps the pad_set_workspace hint every other
|
|
// workspace-required dispatcher uses (a plain routeSpec's missing
|
|
// {workspace} placeholder produces a more generic "check your input
|
|
// shape" hint instead — see expandPath in dispatch_http_routes.go).
|
|
//
|
|
// No re-marshaling needed for the BUG-985 array-wrap: executeRequest's
|
|
// packageHTTPResponse already wraps top-level JSON arrays as
|
|
// {items: [...]} for every proxied GET, and /next returns a bare
|
|
// array.
|
|
func (d *HTTPHandlerDispatcher) dispatchProjectNext(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "project next"
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), nil
|
|
}
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/next"
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
|
|
}
|
|
|
|
// dispatchProjectStandup proxies `project standup` to GET
|
|
// /workspaces/{ws}/standup?days=N — handleGetProjectStandup
|
|
// (internal/server/handlers_project_intel.go). Before TASK-1916 this
|
|
// method reproduced the CLI's standupCmd JSON branch itself (dashboard
|
|
// fetch + per-terminal-status ListItems loop + in-progress fetch);
|
|
// that reshaping, its days=1 default, and its per-status best-effort
|
|
// error handling (a failed status is skipped, not fatal) now all live
|
|
// server-side in handleGetProjectStandup / listTerminalItemsSince,
|
|
// so this method only validates workspace (same pad_set_workspace
|
|
// hint as dispatchProjectNext above, same routeTable-vs-custom-method
|
|
// reasoning) and forwards `days` as-is via the shared buildQuery
|
|
// helper (dispatch_http_routes.go) — parseDaysParam server-side
|
|
// applies the identical n<=0-falls-back-to-default gate the
|
|
// in-dispatcher `numericInput(...) && n > 0` check used to.
|
|
func (d *HTTPHandlerDispatcher) dispatchProjectStandup(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "project standup"
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), nil
|
|
}
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/standup"
|
|
if q := buildQuery(input, map[string]string{"days": "days"}); q != "" {
|
|
path += "?" + q
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
|
|
}
|
|
|
|
// dispatchProjectChangelog proxies `project changelog` to GET
|
|
// /workspaces/{ws}/changelog?days=N&since=YYYY-MM-DD&parent=REF —
|
|
// handleGetProjectChangelog (internal/server/handlers_project_intel.go).
|
|
// Same consolidation as dispatchProjectStandup above: the per-status
|
|
// listing loop, days=7 default, since-overrides-days precedence,
|
|
// parent filtering, and collection grouping all now live server-side.
|
|
//
|
|
// One observable behavior change from consolidation: a malformed
|
|
// `since` used to get a bespoke dispatcher-side hint ("Pass
|
|
// since=YYYY-MM-DD..."); now it flows through the REST endpoint's 400
|
|
// and the generic HTTP-error classifier (classifyHTTPStatusKind) like
|
|
// every other proxied validation error in this codebase — same
|
|
// ErrValidationFailed code, but the hint text becomes the generic
|
|
// "Backend: invalid 'since' date...Adjust the input shape and retry."
|
|
// rather than the old bespoke wording. Accepted as part of TASK-1916's
|
|
// consolidation (keeping the bespoke hint here would mean re-parsing
|
|
// `since` twice and defeats the point of proxying).
|
|
func (d *HTTPHandlerDispatcher) dispatchProjectChangelog(
|
|
ctx context.Context,
|
|
input map[string]any,
|
|
user *models.User,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "project changelog"
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), nil
|
|
}
|
|
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/changelog"
|
|
if q := buildQuery(input, map[string]string{"days": "days", "since": "since", "parent": "parent"}); q != "" {
|
|
path += "?" + q
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodGet, path, nil)
|
|
}
|
|
|
|
// 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, *CallToolResult) {
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return nil, noWorkspaceResult(ctx, d.Lister)
|
|
}
|
|
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 as a per-item `fields_patch` (so other fields are
|
|
// never written, BUG-3156). Mirrors the CLI's bulkUpdateCmd:
|
|
// at-least-one-of-status-or-priority gating, one PATCH per item, 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,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "item bulk-update"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), 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 {
|
|
// BUG-3156: ONE PATCH per row carrying `fields_patch` with only the
|
|
// keys this call sets. This used to be a read-modify-write: GET the
|
|
// item, set status/priority on the DECODED blob, PATCH the whole
|
|
// blob back, which reverted any field another client wrote between
|
|
// the two (BUG-3049's defect, on the transport that fix missed; the
|
|
// CLI has sent `fields_patch` since). The server merges the patch
|
|
// under its write lock. There is no prefetch: the PATCH does its own
|
|
// visibility and not-found checks, and a GET whose only job was the
|
|
// merge is the defect's shape (the same call BUG-3056 made for
|
|
// note/decide).
|
|
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
|
|
"/items/" + url.PathEscape(ref)
|
|
|
|
fieldsPatch := map[string]any{}
|
|
if status != "" {
|
|
fieldsPatch["status"] = status
|
|
}
|
|
if priority != "" {
|
|
fieldsPatch["priority"] = priority
|
|
}
|
|
// refuse_undeclared_fields (BUG-3156): status and priority are the
|
|
// operation's keys, so a collection declaring neither refuses the
|
|
// row with validation_error instead of storing an orphan field, the
|
|
// same answer stdio and POST /items/bulk (WebMCP) give.
|
|
patchPayload := map[string]any{"fields_patch": fieldsPatch, "refuse_undeclared_fields": true}
|
|
// IDEA-1494: forward the open-children guard override per-row.
|
|
// Same flag shape as `pad item bulk-update --force` so the
|
|
// override travels through both transports identically.
|
|
if b, ok := input["force"].(bool); ok && b {
|
|
patchPayload["force"] = true
|
|
}
|
|
patchBody, err := json.Marshal(patchPayload)
|
|
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,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "item note"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
ref, _ := input["ref"].(string)
|
|
summary, _ := input["summary"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), 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)
|
|
// BUG-3056: the server appends under its write lock (and mints the id and
|
|
// timestamp), so a concurrent write to another key cannot be reverted by
|
|
// this one. No capability check: this dispatcher runs in-process with the
|
|
// handler it calls, so the two cannot disagree. The entry keeps this door's
|
|
// own attribution, the user's display name.
|
|
ctx = server.WithStructuredEntryAuthor(ctx, userActorLabel(user))
|
|
body, err := json.Marshal(models.ItemUpdate{
|
|
AppendImplementationNote: &models.ItemImplementationNoteAppend{
|
|
Summary: strings.TrimSpace(summary),
|
|
Details: details,
|
|
},
|
|
// TASK-3585: the append's idempotency key, when the agent sent one.
|
|
OpKey: opKeyFrom(input),
|
|
})
|
|
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,
|
|
) (*CallToolResult, error) {
|
|
const cmdKey = "item decide"
|
|
|
|
workspace, _ := input["workspace"].(string)
|
|
ref, _ := input["ref"].(string)
|
|
decision, _ := input["decision"].(string)
|
|
if workspace == "" {
|
|
return noWorkspaceResult(ctx, d.Lister), 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)
|
|
// BUG-3056 — see dispatchItemNote.
|
|
ctx = server.WithStructuredEntryAuthor(ctx, userActorLabel(user))
|
|
body, err := json.Marshal(models.ItemUpdate{
|
|
AppendDecision: &models.ItemDecisionLogAppend{
|
|
Decision: strings.TrimSpace(decision),
|
|
Rationale: rationale,
|
|
},
|
|
OpKey: opKeyFrom(input),
|
|
})
|
|
if err != nil {
|
|
return dispatcherErrorResult(cmdKey, "encode body", err), nil
|
|
}
|
|
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
|
|
}
|
|
|
|
// userActorLabel is the created_by this door writes on note/decision entries:
|
|
// the requesting user's name, falling back to email, so audit-log review can
|
|
// tell who appended what when several users share one MCP server. (The CLI
|
|
// writes the user/agent kind instead; the two doors have always differed.)
|
|
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}.
|
|
//
|
|
// Inputs honored (PLAN-1560 / TASK-1561+TASK-1563):
|
|
// - type: restricts to conventions or playbooks (above).
|
|
// - category: server-side category filter; case-sensitive exact match.
|
|
// Forwarded to BOTH endpoints when set.
|
|
// - full: when true, playbooks come back with full bodies. Default
|
|
// (full=false) passes ?summary=true so MCP agents browse
|
|
// the catalog without blowing their context budget.
|
|
//
|
|
// 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,
|
|
) (*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
|
|
}
|
|
|
|
category, _ := input["category"].(string)
|
|
full, _ := input["full"].(bool)
|
|
|
|
// Build query strings per endpoint. Convention endpoint takes only
|
|
// category; playbook endpoint takes both category and summary.
|
|
conventionQuery := url.Values{}
|
|
playbookQuery := url.Values{}
|
|
if category != "" {
|
|
conventionQuery.Set("category", category)
|
|
playbookQuery.Set("category", category)
|
|
}
|
|
if !full {
|
|
// Default: summary mode for playbooks. MCP callers want compact
|
|
// payloads; CLI default already does this too. Opt back in via
|
|
// full=true.
|
|
playbookQuery.Set("summary", "true")
|
|
}
|
|
|
|
conventionPath := "/api/v1/convention-library"
|
|
if encoded := conventionQuery.Encode(); encoded != "" {
|
|
conventionPath += "?" + encoded
|
|
}
|
|
playbookPath := "/api/v1/playbook-library"
|
|
if encoded := playbookQuery.Encode(); encoded != "" {
|
|
playbookPath += "?" + encoded
|
|
}
|
|
|
|
var conventions any
|
|
var playbooks any
|
|
|
|
if wantConventions {
|
|
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, conventionPath)
|
|
if errRes != nil {
|
|
return errRes, nil
|
|
}
|
|
conventions = v
|
|
}
|
|
if wantPlaybooks {
|
|
v, errRes := d.fetchLibraryEndpoint(ctx, user, cmdKey, playbookPath)
|
|
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, *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
|
|
}
|
|
|
|
// opKeyFrom is the agent's op_key, if any (TASK-3585).
|
|
func opKeyFrom(input map[string]any) string {
|
|
k, _ := input["op_key"].(string)
|
|
return k
|
|
}
|