mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
bed933d7fd
* 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
483 lines
18 KiB
Go
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
|
|
}
|