mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +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
90 lines
3.3 KiB
Go
90 lines
3.3 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TASK-3585: idempotency keys on comment create.
|
|
func commentKeyFixture(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
srv := testServer(t)
|
|
slug := createWSWithCollections(t, srv)
|
|
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]any{"title": "Commented on", "fields": `{"status":"open"}`})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("item: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
var it models.Item
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &it)
|
|
return srv, "/api/v1/workspaces/" + slug + "/items/" + it.Slug + "/comments"
|
|
}
|
|
|
|
func countComments(t *testing.T, srv *Server, path, body string) int {
|
|
t.Helper()
|
|
rr := doRequest(srv, "GET", path, nil)
|
|
var cs []models.Comment
|
|
_ = json.Unmarshal(rr.Body.Bytes(), &cs)
|
|
n := 0
|
|
for _, c := range cs {
|
|
if c.Body == body {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func TestTASK3585_CommentReplaysTheCommentItMade(t *testing.T) {
|
|
srv, path := commentKeyFixture(t)
|
|
body := map[string]any{"body": "Keyed comment", "op_key": "k-1"}
|
|
first := doRequest(srv, "POST", path, body)
|
|
again := doRequest(srv, "POST", path, body)
|
|
var a, b models.Comment
|
|
_ = json.Unmarshal(first.Body.Bytes(), &a)
|
|
_ = json.Unmarshal(again.Body.Bytes(), &b)
|
|
if first.Code != http.StatusCreated || again.Code != http.StatusCreated || a.ID == "" || a.ID != b.ID || again.Header().Get("Idempotent-Replayed") != "true" {
|
|
t.Fatalf("replay: %d/%d %q vs %q", first.Code, again.Code, a.ID, b.ID)
|
|
}
|
|
if n := countComments(t, srv, path, "Keyed comment"); n != 1 {
|
|
t.Fatalf("%d comments; the retry made a twin", n)
|
|
}
|
|
if rr := doRequest(srv, "POST", path, map[string]any{"body": "Different", "op_key": "k-1"}); rr.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("a reused key: %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestTASK3585_RefusedCommentReleasesItsKey(t *testing.T) {
|
|
srv, path := commentKeyFixture(t)
|
|
bad := doRequestWithHeaders(srv, "POST", path, map[string]any{"body": "Reply", "parent_id": "no-such-comment"}, map[string]string{"Idempotency-Key": "k-2"})
|
|
if bad.Code < 400 {
|
|
t.Fatalf("premise: a reply to a missing parent must be refused; got %d", bad.Code)
|
|
}
|
|
ok := doRequestWithHeaders(srv, "POST", path, map[string]any{"body": "Reply"}, map[string]string{"Idempotency-Key": "k-2"})
|
|
if ok.Code != http.StatusCreated || ok.Header().Get("Idempotent-Replayed") != "" {
|
|
t.Fatalf("corrected retry with the same key: %d %s", ok.Code, ok.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTASK3585_CommentReplayOfADeletedComment(t *testing.T) {
|
|
srv, path := commentKeyFixture(t)
|
|
body := map[string]any{"body": "Then deleted", "op_key": "k-3"}
|
|
first := doRequest(srv, "POST", path, body)
|
|
var c models.Comment
|
|
_ = json.Unmarshal(first.Body.Bytes(), &c)
|
|
if first.Code != http.StatusCreated || c.ID == "" {
|
|
t.Fatalf("create: %d", first.Code)
|
|
}
|
|
if rr := doRequest(srv, "DELETE", path+"/"+c.ID, nil); rr.Code >= 300 {
|
|
t.Fatalf("delete: %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
again := doRequest(srv, "POST", path, body)
|
|
if again.Code != http.StatusGone || !json.Valid(again.Body.Bytes()) {
|
|
t.Fatalf("replay of a deleted comment: %d %s", again.Code, again.Body.String())
|
|
}
|
|
if n := countComments(t, srv, path, "Then deleted"); n != 0 {
|
|
t.Fatalf("the replay re-created the comment: %d", n)
|
|
}
|
|
}
|