Files
pad/internal/mcp/dispatch_http_advanced.go
T
xarmian bed933d7fd feat(items): field-level PATCH + conflict envelope + read-only version history (TASK-2022) (#876)
* feat(items): field-level PATCH + conflict envelope + version history

Adds three related item-update primitives (TASK-2022 / IDEA-1480):

- Field-level merge: PATCH `fields_patch` shallow-merges onto the item's
  current fields INSIDE the write transaction (null deletes a key), so
  concurrent single-field updates no longer clobber each other via the
  full-blob read-modify-write. `pad item update` and the MCP `pad_item.update`
  action now send only the changed keys.
- Optimistic concurrency: optional `expected_updated_at` on update; on
  mismatch the store returns *UpdateConflictError and the handler emits the
  pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict).
  Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`).
- Read-only version history: `pad item history <ref>` (alias `versions`) and
  MCP `pad_item.history`, reusing the existing item_versions store + versions
  endpoint (no new store, no schema change).

MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update
behavior change). No migration required.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS

* fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards

Round 1+2 review fixes for TASK-2022:
- HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only
  changed keys) instead of a client-side merged full fields blob, and forwards
  expected_updated_at — remote MCP callers get the same race-free merge +
  optimistic concurrency the CLI/HTTP paths do.
- ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field
  (would otherwise persist a blob the full-update validator rejects).
- Open-children guard on the fields_patch path merges the patch onto the IN-TX
  locked row inside the precheck (not a stale pre-lock preview), so a
  priority-only patch can't false-fire the guard.
- Optimistic-concurrency check now runs BEFORE the open-children precheck in the
  store, so a stale expected_updated_at yields update_conflict (not
  open_children) — single in-tx re-read shared by both.
- Date auto-population on the patch path only fills an EMPTY current date; an
  existing end_date the caller isn't touching is preserved.

Tests added for each fix.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:47:34 -04:00

483 lines
18 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/PerpetualSoftware/pad/internal/models"
)
// resolveAssignName rewrites a `--assign <name|email>` input into
// `assigned_user_id <uuid>` by hitting the workspace-members
// endpoint and finding a matching user. Mirrors the CLI's behaviour
// in cmd/pad/main.go's itemCreateCmd / itemUpdateCmd / itemListCmd —
// without this resolution, agents passing human-friendly assignee
// values would silently get empty results (the store filters by
// `i.assigned_user_id = ?` UUID, no name fallback).
//
// Returns the input map with `assign` replaced by `assigned_user_id`
// when a match is found, or unchanged when `assign` is missing /
// empty. Mismatches return a clear error so agents know to pass a
// different name.
//
// The returned map is always a fresh map — the caller's reference
// isn't mutated, matching the no-mutation contract of the rest of
// the dispatcher.
func (d *HTTPHandlerDispatcher) resolveAssignName(
ctx context.Context,
user *models.User,
input map[string]any,
) (map[string]any, error) {
rawAssign, present := input["assign"]
if !present {
return input, nil
}
assign, _ := rawAssign.(string)
if assign == "" {
return input, nil
}
// Already-resolved? If the caller used `--field assigned_user_id=<uuid>`
// that's a separate input key — we don't touch it. If the caller
// passed both `assign` and `assigned_user_id`, the explicit ID
// wins; drop the assign value to avoid the resolution lookup.
out := cloneStringMap(input)
if existingID, _ := out["assigned_user_id"].(string); existingID != "" {
delete(out, "assign")
return out, nil
}
workspace, _ := input["workspace"].(string)
if workspace == "" {
return nil, fmt.Errorf("workspace is required to resolve --assign")
}
userID, err := d.lookupAssigneeID(ctx, user, workspace, assign)
if err != nil {
return nil, err
}
out["assigned_user_id"] = userID
delete(out, "assign")
return out, nil
}
// resolveRoleSlug rewrites a `--role <slug>` input into
// `agent_role_id <uuid>` by hitting the agent-roles endpoint and
// finding a matching role. Mirrors the CLI's behaviour in
// itemCreateCmd / itemUpdateCmd which treats `--role` as a slug or
// ID and resolves to the column UUID before sending the create/
// update — without resolution, agents passing slugs would silently
// get empty results (the store filters by `i.agent_role_id = ?`
// UUID, with slug accepted only on the LIST endpoint, not the
// item-mutation handlers).
//
// Symmetric to resolveAssignName: returns the input map with `role`
// replaced by `agent_role_id` when a match is found, or unchanged
// when `role` is missing / empty. Mismatches return a clear error.
//
// The handleGetAgentRole endpoint at /agent-roles/{roleID} accepts
// either a UUID or a slug as roleID, so this single GET resolves
// both. If the caller passed an explicit `agent_role_id` alongside
// `--role`, the explicit ID wins (matches the --assign precedence
// in resolveAssignName).
//
// The returned map is always a fresh copy — the caller's reference
// isn't mutated.
func (d *HTTPHandlerDispatcher) resolveRoleSlug(
ctx context.Context,
user *models.User,
input map[string]any,
) (map[string]any, error) {
rawRole, present := input["role"]
if !present {
return input, nil
}
role, _ := rawRole.(string)
if role == "" {
return input, nil
}
out := cloneStringMap(input)
if existingID, _ := out["agent_role_id"].(string); existingID != "" {
// Explicit ID wins over slug; drop the role key to avoid the
// resolution lookup below.
delete(out, "role")
return out, nil
}
workspace, _ := input["workspace"].(string)
if workspace == "" {
return nil, fmt.Errorf("workspace is required to resolve --role")
}
roleID, err := d.lookupRoleID(ctx, user, workspace, role)
if err != nil {
return nil, err
}
out["agent_role_id"] = roleID
delete(out, "role")
return out, nil
}
// lookupRoleID issues an in-handler GET against
// /api/v1/workspaces/{ws}/agent-roles/{slug} and returns the role's
// canonical id. The handler accepts either UUID or slug for roleID
// (see handleGetAgentRole), so callers can pass a slug like
// "implementer" or a pre-resolved UUID interchangeably.
//
// Goes through buildAuthedRequest so d.Apply (the OAuth-scope hook)
// sees this prefetch the same as a top-level dispatch — no scope
// bypass during role resolution.
//
// Errors:
//
// - underlying handler returns 404 → "no agent role matches --role %q"
// (clearer than the raw 404 body for agents).
// - other non-2xx → wrapped error with body.
// - response shape doesn't include id → error.
func (d *HTTPHandlerDispatcher) lookupRoleID(
ctx context.Context,
user *models.User,
workspace string,
role string,
) (string, error) {
path := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/agent-roles/" + url.PathEscape(role)
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
if err != nil {
return "", fmt.Errorf("build agent-role request: %w", err)
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code == http.StatusNotFound {
return "", fmt.Errorf("no agent role matches --role %q", role)
}
if rec.Code >= 400 {
body := strings.TrimSpace(rec.Body.String())
if body == "" {
body = http.StatusText(rec.Code)
}
return "", fmt.Errorf("look up agent role: %d %s", rec.Code, body)
}
var resp struct {
ID string `json:"id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
return "", fmt.Errorf("parse agent-role response: %w", err)
}
if resp.ID == "" {
return "", fmt.Errorf("agent-role response missing id for %q", role)
}
return resp.ID, nil
}
// lookupAssigneeID issues an in-handler GET against
// /api/v1/workspaces/{ws}/members and returns the user_id whose
// name OR email matches `assign`. Case-insensitive on both fields.
//
// Errors:
//
// - underlying handler returns non-2xx → wrapped error with body.
// - response shape doesn't match expected {members:[...]} → error.
// - no member matches → "no workspace member matches --assign %q".
func (d *HTTPHandlerDispatcher) lookupAssigneeID(
ctx context.Context,
user *models.User,
workspace string,
assign string,
) (string, error) {
path := "/api/v1/workspaces/" + url.PathEscape(workspace) + "/members"
// Goes through buildAuthedRequest so d.Apply (the OAuth-scope
// hook) sees this prefetch the same as a top-level dispatch —
// no scope bypass during assignee resolution.
req, err := d.buildAuthedRequest(ctx, http.MethodGet, path, nil, user)
if err != nil {
return "", fmt.Errorf("build members request: %w", err)
}
rec := httptest.NewRecorder()
d.Handler.ServeHTTP(rec, req)
if rec.Code >= 400 {
body := strings.TrimSpace(rec.Body.String())
if body == "" {
body = http.StatusText(rec.Code)
}
return "", fmt.Errorf("list workspace members: %d %s", rec.Code, body)
}
// Response shape: {"members":[{user_id, user_name, user_email, ...}, ...], "invitations":[...]}
var resp struct {
Members []struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
UserEmail string `json:"user_email"`
} `json:"members"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
return "", fmt.Errorf("parse members response: %w", err)
}
for _, m := range resp.Members {
if strings.EqualFold(m.UserName, assign) || strings.EqualFold(m.UserEmail, assign) {
return m.UserID, nil
}
}
return "", fmt.Errorf("no workspace member matches --assign %q", assign)
}
// dispatchItemUpdate handles `pad item update <ref>` with full CLI
// parity, including the read-modify-write merge of the fields JSON.
//
// The handler at handleUpdateItem treats input.Fields as a complete
// replacement (json_extract-friendly), but the CLI does a GET first
// to read existing fields, merges in new --status / --priority /
// --field overrides, then PATCHes the merged result. Without this
// dispatch path, an MCP `item.update --status done` would erase
// every other field the schema set — Codex caught the equivalent
// shape regression on item.create in PR #343.
//
// Sequence:
//
// 1. GET /api/v1/workspaces/{ws}/items/{ref} — read current state.
// 2. Merge: existing.fields + input.{status, priority, category,
// parent} + parsed --field key=value pairs. Last-write-wins per
// key (matches CLI; --field can override --status).
// 3. PATCH /api/v1/workspaces/{ws}/items/{ref} with the merged
// payload.
//
// Returns the PATCH response packaged like any other dispatch result
// (structured JSON if 2xx + JSON body, IsError-flagged if non-2xx).
func (d *HTTPHandlerDispatcher) dispatchItemUpdate(
ctx context.Context,
input map[string]any,
user *models.User,
) (*mcp.CallToolResult, error) {
const cmdKey = "item update"
workspace, _ := input["workspace"].(string)
ref, _ := input["ref"].(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 to update)."), nil
}
itemPath := "/api/v1/workspaces/" + url.PathEscape(workspace) +
"/items/" + url.PathEscape(ref)
// `--role` is now resolved at the dispatcher level (TASK-968):
// Dispatch's preprocess step rewrites it to `agent_role_id`
// before reaching this method, so by the time we get here a slug
// has already been resolved to a UUID. The `--field
// agent_role_id=<uuid>` workaround that the older rejection
// pointed at still works (lifted via liftFieldsToColumns below)
// and is preserved as the explicit-ID escape hatch when an agent
// already knows the UUID and wants to skip the slug lookup.
// Step 1: GET the existing item first so a not-found / permission
// error surfaces cleanly (mirrors the CLI's "not found" UX) before we
// attempt the PATCH. We NO LONGER read fields here for a client-side
// read-modify-write merge — TASK-2022 moved field merging server-side
// via `fields_patch` (see Step 2), which closes the lost-write race the
// old GET-merge-PATCH suffered (IDEA-1480). The prefetch remains only
// as an existence/permission pre-check.
//
// Goes through buildAuthedRequest so d.Apply (the OAuth-scope
// hook) sees this prefetch the same as a top-level dispatch.
prefetchReq, err := d.buildAuthedRequest(ctx, http.MethodGet, itemPath, nil, user)
if err != nil {
return dispatcherErrorResult(cmdKey, "build prefetch request", err), nil
}
prefetchRec := httptest.NewRecorder()
d.Handler.ServeHTTP(prefetchRec, prefetchReq)
if prefetchRec.Code >= 400 {
// Mirror the CLI's "not found" UX — the handler's 404 body
// already contains a clear message; package it the same way
// any other tool error would be packaged. Pass d.Lister so
// the workspace-not-found envelope's available_workspaces
// list is filtered by the OAuth allow-list (TASK-977). Use
// prefetchReq.Context() so the lister sees the same
// auth/token state buildHTTPRequest + d.Apply attached
// (Codex review #379 round 1 — same fix as executeRequest).
return packageHTTPResponse(prefetchReq.Context(), cmdKey, prefetchRec.Result(), d.Lister)
}
// Step 2: Build the PATCH payload.
payload := map[string]any{}
// String-shaped fields: copy through when non-empty.
for _, key := range []string{"title", "content", "comment"} {
if v, ok := input[key].(string); ok && v != "" {
payload[key] = v
}
}
// `tags` is array<string> on the MCP schema as of BUG-1432; the
// dispatcher forwards it verbatim (array, JSON-encoded string, or
// CLI back-compat string) and lets ItemUpdate.UnmarshalJSON's
// flex parser (BUG-1144) normalize. Pre-BUG-1432 this loop
// filtered on `string` only, so a schema-conforming
// `tags: ["a"]` was silently dropped — Codex review #547
// round 1 [P1] caught that.
//
// Empty string is a no-op (matches the pre-fix behaviour for
// non-tags string fields): ItemUpdate treats `tags: ""` as an
// explicit empty-string write, which would corrupt the JSONB
// column on Postgres (500) and the TEXT column on SQLite. Empty
// array `[]` is intentionally NOT filtered — that's a legitimate
// "clear all tags" update. Codex review #547 round 3 [P2].
if v, ok := input["tags"]; ok && v != nil {
if s, isString := v.(string); !isString || s != "" {
payload["tags"] = v
}
}
if v, ok := input["assigned_user_id"].(string); ok && v != "" {
payload["assigned_user_id"] = v
}
if v, ok := input["agent_role_id"].(string); ok && v != "" {
payload["agent_role_id"] = v
}
if b, ok := input["pinned"].(bool); ok {
payload["pinned"] = b
}
// IDEA-1494: forward the open-children guard override. When set,
// the server-side handler skips the guard and still records the
// status transition. Same wire shape the CLI uses (`force: true`
// on the ItemUpdate body), so the HTTP dispatcher and ExecDispatcher
// paths share one contract.
if b, ok := input["force"].(bool); ok && b {
payload["force"] = true
}
// TASK-2022: forward the optimistic-concurrency token so remote MCP
// callers get the same 409 update_conflict guard the CLI/HTTP paths do.
if v, ok := input["expected_updated_at"].(string); ok && v != "" {
payload["expected_updated_at"] = v
}
// Field-level PATCH (TASK-2022). Send ONLY the changed keys as
// `fields_patch`; the server shallow-merges them onto the item's current
// fields inside the write transaction. This replaces the old client-side
// GET-merge-PATCH of a full `fields` blob, which lost concurrent
// single-field changes (the IDEA-1480 lost-write race). Named flags
// (status / priority / category / parent) then --field entries.
if hasFieldChanges(input) {
patch := map[string]any{}
for _, key := range []string{"status", "priority", "category", "parent"} {
if v, ok := input[key].(string); ok && v != "" {
patch[key] = v
}
}
if rawFields, ok := input["field"]; ok {
extra, err := parseFieldKVP(rawFields)
if err != nil {
return validationFailedResult(cmdKey, "parse --field: "+err.Error(),
"--field expects key=value entries (string array or single string)."), nil
}
for k, v := range extra {
patch[k] = v
}
}
// Lift recognized column keys (agent_role_id, assigned_user_id)
// out of the patch onto the top-level payload so the handler writes
// the column instead of stuffing the value inert in the JSON. Same
// shape mapItemCreate uses; matches the workaround the --role
// rejection points at.
liftFieldsToColumns(patch, payload)
// Only emit fields_patch when it still carries schema fields after
// the column lift — otherwise a role-only update would send an empty
// patch object (harmless, but avoids a needless fields write).
if len(patch) > 0 {
payload["fields_patch"] = patch
}
}
body, err := json.Marshal(payload)
if err != nil {
return dispatcherErrorResult(cmdKey, "encode body", err), nil
}
// Step 3: PATCH.
return d.executeRequest(ctx, cmdKey, user, http.MethodPatch, itemPath, body)
}
// hasFieldChanges reports whether the input has any value that
// should trigger field-merging on update. Mirrors the CLI's check
// at cmd/pad/main.go itemUpdateCmd around the `hasFieldChanges`
// boolean — without this guard, dispatching `item update TASK-1
// --content "x"` would do an unnecessary GET-merge-PATCH of
// fields, churning the audit log entry for no reason.
func hasFieldChanges(input map[string]any) bool {
for _, key := range []string{"status", "priority", "category", "parent"} {
if v, ok := input[key].(string); ok && v != "" {
return true
}
}
if rawFields, ok := input["field"]; ok && rawFields != nil {
switch x := rawFields.(type) {
case string:
return x != ""
case []any:
return len(x) > 0
case []string:
return len(x) > 0
}
}
return false
}
// maybeInjectWorkspace defaults the `workspace` input from the
// dispatcher's WorkspaceLister when the caller didn't pass one
// explicitly (TASK-1076).
//
// The lister already encodes the right policy via the OAuth-token
// allow-list (internal/mcp/dispatch_http_lister.go):
//
// - PAT auth (no allow-list) → all of the user's workspaces
// - Wildcard token (`["*"]`) → all of the user's workspaces
// - Specific allow-list → intersection with the user's
// memberships
//
// Inject ONLY when exactly one workspace results — that's the case
// where defaulting is unambiguous. Zero (no memberships, or
// allow-list disjoint from memberships) → leave alone; the route
// mapper's "missing required input" error is the agent's signal to
// pass workspace= explicitly. Multiple → also leave alone; agents
// should pick which workspace they mean rather than the dispatcher
// silently choosing one (the latter would be a real audience-confusion
// hazard for write operations).
//
// Caller-passed workspace ALWAYS wins (the early-return on the
// existing-value branch). Lister == nil paths (tests + non-OAuth
// transports) skip injection entirely so behavior stays unchanged
// for them — no Lister means no defaulting policy to apply.
//
// Mutations are applied to a copy; the caller's input map is not
// modified in place.
func (d *HTTPHandlerDispatcher) maybeInjectWorkspace(
ctx context.Context,
input map[string]any,
) map[string]any {
if d.Lister == nil {
return input
}
if existing, ok := input["workspace"].(string); ok && existing != "" {
return input
}
workspaces, err := d.Lister.ListWorkspaces(ctx)
if err != nil || len(workspaces) != 1 {
return input
}
out := make(map[string]any, len(input)+1)
for k, v := range input {
out[k] = v
}
out["workspace"] = workspaces[0].Slug
return out
}