Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
the per-user clients (including Codex) and skip it.

**Tool catalog (v0.91)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
**Tool catalog (v0.92)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:

| Tool | Actions |
|---|---|
Expand All @@ -426,7 +426,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
and `pad://_meta/version`.

**Stability contract.** The catalog is versioned
(`tool_surface_version: "0.91"`) and advertised in the initialize handshake and at
(`tool_surface_version: "0.92"`) and advertised in the initialize handshake and at
`pad://_meta/version`, so external agents can pin against it; the per-version
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
Expand Down
9 changes: 8 additions & 1 deletion cmd/pad/cmd_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -3131,7 +3131,7 @@ Use - to read the artifact from stdin.`,
// --- comments ---

func commentCmd() *cobra.Command {
var replyTo string
var replyTo, commentOpKey string

cmd := &cobra.Command{
Use: "comment <ref> <message>",
Expand All @@ -3142,9 +3142,15 @@ func commentCmd() *cobra.Command {
client, _ := getClient()
ws := getWorkspace()

// TASK-3585: one idempotency key per invocation, so a retry of a
// comment whose answer was lost returns it instead of a twin.
if commentOpKey == "" {
commentOpKey = uuid.NewString()
}
input := models.CommentCreate{
Body: args[1],
ParentID: replyTo,
OpKey: commentOpKey,
}

comment, err := client.CreateComment(ws, args[0], input)
Expand All @@ -3162,6 +3168,7 @@ func commentCmd() *cobra.Command {
}

cmd.Flags().StringVar(&replyTo, "reply-to", "", "reply to a specific comment ID")
cmd.Flags().StringVar(&commentOpKey, "op-key", "", "idempotency key: retrying the SAME comment with the same key returns the comment it made instead of a second one (default: a new key per invocation)")
return cmd
}

Expand Down
17 changes: 15 additions & 2 deletions cmd/pad/notes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"errors"
"fmt"
"github.com/google/uuid"
"io"
"os"
"strings"
Expand All @@ -16,7 +17,7 @@ import (
)

func noteCmd() *cobra.Command {
var details string
var details, noteOpKey string
var readStdin bool

cmd := &cobra.Command{
Expand Down Expand Up @@ -47,9 +48,15 @@ func noteCmd() *cobra.Command {
// BUG-3056: the server appends under its write lock, so a
// concurrent write to another key cannot be reverted by this
// one. It mints the id, timestamp and attribution.
// TASK-3585: one idempotency key per invocation, so a retry
// of a note whose answer was lost does not append it twice.
if noteOpKey == "" {
noteOpKey = uuid.NewString()
}
updated, err = client.UpdateItem(ws, args[0], models.ItemUpdate{
AppendImplementationNote: &models.ItemImplementationNoteAppend{Summary: summary, Details: body},
Source: "cli",
OpKey: noteOpKey,
})
if err != nil {
return structuredAppendRefusal(err, "note", models.ItemFieldImplementationNotes)
Expand Down Expand Up @@ -81,11 +88,12 @@ func noteCmd() *cobra.Command {

cmd.Flags().StringVar(&details, "details", "", "implementation details")
cmd.Flags().BoolVar(&readStdin, "stdin", false, "read note details from stdin")
cmd.Flags().StringVar(&noteOpKey, "op-key", "", "idempotency key: retrying the SAME note with the same key returns the note it appended instead of a second one (default: a new key per invocation)")
return cmd
}

func decideCmd() *cobra.Command {
var rationale string
var rationale, decideOpKey string
var readStdin bool

cmd := &cobra.Command{
Expand Down Expand Up @@ -114,9 +122,13 @@ func decideCmd() *cobra.Command {
)
if client.ServerSupportsItemFieldAppend() {
// BUG-3056 — see `pad item note`.
if decideOpKey == "" {
decideOpKey = uuid.NewString()
}
updated, err = client.UpdateItem(ws, args[0], models.ItemUpdate{
AppendDecision: &models.ItemDecisionLogAppend{Decision: decision, Rationale: body},
Source: "cli",
OpKey: decideOpKey,
})
if err != nil {
return structuredAppendRefusal(err, "decision", models.ItemFieldDecisionLog)
Expand Down Expand Up @@ -148,6 +160,7 @@ func decideCmd() *cobra.Command {

cmd.Flags().StringVar(&rationale, "rationale", "", "decision rationale")
cmd.Flags().BoolVar(&readStdin, "stdin", false, "read decision rationale from stdin")
cmd.Flags().StringVar(&decideOpKey, "op-key", "", "idempotency key: retrying the SAME decision with the same key returns the decision it appended instead of a second one (default: a new key per invocation)")
return cmd
}

Expand Down
81 changes: 81 additions & 0 deletions cmd/pad/task3585_append_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package main

import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)

// appendServer answers capabilities and scripts the statuses of the item
// PATCHes (appends) it receives. A success echoes the appended entry, as the
// server does, so the command's confirmation check passes.
func appendServer(t *testing.T, honorsKeys bool, statuses ...int) *[]map[string]any {
t.Helper()
var bodies []map[string]any
setupFormatRoutingTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/capabilities") {
_ = json.NewEncoder(w).Encode(map[string]any{"item_field_append": true, "idempotency_keys": honorsKeys})
return
}
if r.Method != http.MethodPatch {
_ = json.NewEncoder(w).Encode(map[string]any{"slug": "s", "ref": "TASK-5", "fields": "{}"})
return
}
raw, _ := io.ReadAll(r.Body)
var b map[string]any
_ = json.Unmarshal(raw, &b)
bodies = append(bodies, b)
status := http.StatusOK
if n := len(bodies) - 1; n < len(statuses) {
status = statuses[n]
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status >= 400 {
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "x", "message": "m"}})
return
}
note := map[string]any{"id": "note_1", "summary": "s"}
fields, _ := json.Marshal(map[string]any{"implementation_notes": []any{note}})
_ = json.NewEncoder(w).Encode(map[string]any{"slug": "s", "ref": "TASK-5", "fields": string(fields),
"appended": map[string]any{"implementation_note": note}})
}))
formatFlag = "table"
return &bodies
}

func runNote(t *testing.T, args ...string) error {
t.Helper()
cmd := noteCmd()
cmd.SetArgs(append([]string{"TASK-5", "s"}, args...))
var err error
_ = captureStdout(t, func() { err = cmd.Execute() })
return err
}

func TestTASK3585_CLINoteRetriesOnceWithTheSameKey(t *testing.T) {
bodies := appendServer(t, true, http.StatusBadGateway)
if err := runNote(t); err != nil {
t.Fatalf("a 502 then a 200 must succeed: %v", err)
}
if len(*bodies) != 2 {
t.Fatalf("want exactly one retry, got %d attempts", len(*bodies))
}
k1, _ := (*bodies)[0]["op_key"].(string)
k2, _ := (*bodies)[1]["op_key"].(string)
if k1 == "" || k1 != k2 {
t.Fatalf("the retry must reuse the key: %q vs %q", k1, k2)
}
}

func TestTASK3585_CLINoteNoRetryWithoutTheCapability(t *testing.T) {
bodies := appendServer(t, false, http.StatusBadGateway)
if err := runNote(t); err == nil {
t.Fatal("a 502 with no retry must fail")
}
if len(*bodies) != 1 {
t.Fatalf("retried a note against a server that ignores keys: %d attempts", len(*bodies))
}
}
92 changes: 92 additions & 0 deletions cmd/pad/task3585_comment_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)

// commentServer answers capabilities (idempotency_keys = honorsKeys) and
// scripts the statuses of the comment POSTs it receives.
func commentServer(t *testing.T, honorsKeys bool, statuses ...int) *[]map[string]any {
t.Helper()
var bodies []map[string]any
setupFormatRoutingTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/capabilities") {
_ = json.NewEncoder(w).Encode(map[string]any{"idempotency_keys": honorsKeys})
return
}
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/comments") {
_ = json.NewEncoder(w).Encode(map[string]any{})
return
}
raw, _ := io.ReadAll(r.Body)
var b map[string]any
_ = json.Unmarshal(raw, &b)
bodies = append(bodies, b)
status := http.StatusCreated
if n := len(bodies) - 1; n < len(statuses) {
status = statuses[n]
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if status >= 400 {
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": "x", "message": "m"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": "c1", "body": "hi"})
}))
formatFlag = "table"
return &bodies
}

func runComment(t *testing.T, args ...string) error {
t.Helper()
cmd := commentCmd()
cmd.SetArgs(append([]string{"TASK-5", "hi"}, args...))
var err error
_ = captureStdout(t, func() { err = cmd.Execute() })
return err
}

func TestTASK3585_CLICommentRetriesOnceWithTheSameKey(t *testing.T) {
bodies := commentServer(t, true, http.StatusBadGateway)
if err := runComment(t); err != nil {
t.Fatalf("a 502 then a 201 must succeed: %v", err)
}
if len(*bodies) != 2 {
t.Fatalf("want exactly one retry, got %d attempts", len(*bodies))
}
k1, _ := (*bodies)[0]["op_key"].(string)
k2, _ := (*bodies)[1]["op_key"].(string)
if k1 == "" || k1 != k2 {
t.Fatalf("the retry must reuse the key: %q vs %q", k1, k2)
}
}

func TestTASK3585_CLICommentNoRetryWithoutTheCapability(t *testing.T) {
bodies := commentServer(t, false, http.StatusBadGateway)
if err := runComment(t); err == nil {
t.Fatal("a 502 with no retry must fail")
}
if len(*bodies) != 1 {
t.Fatalf("retried a comment against a server that ignores keys: %d attempts", len(*bodies))
}
}

func TestTASK3585_CLICommentKeyIsGivenOrFresh(t *testing.T) {
bodies := commentServer(t, true)
_ = runComment(t)
_ = runComment(t)
_ = runComment(t, "--op-key", "mine")
if len(*bodies) != 3 {
t.Fatalf("attempts = %d", len(*bodies))
}
k0, _ := (*bodies)[0]["op_key"].(string)
k1, _ := (*bodies)[1]["op_key"].(string)
if k0 == "" || k0 == k1 || (*bodies)[2]["op_key"] != "mine" {
t.Fatalf("keys = %q %q %v", k0, k1, (*bodies)[2]["op_key"])
}
}
4 changes: 2 additions & 2 deletions cmd/pad/testdata/mcp_wire/remote_initialize.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/pad/testdata/mcp_wire/remote_tools_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@
"type": "number"
},
"op_key": {
"description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"description": "patch, create, comment, note, decide: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"type": "string"
},
"overwrite_pending_edits": {
Expand Down
4 changes: 2 additions & 2 deletions cmd/pad/testdata/mcp_wire/stdio_initialize.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/pad/testdata/mcp_wire/stdio_tools_list.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@
"type": "number"
},
"op_key": {
"description": "patch, create: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"description": "patch, create, comment, note, decide: an idempotency key you choose (e.g. a UUID). Retrying the SAME request with the same key returns the first answer instead of applying it again; use it whenever a retry could follow an unclear outcome. A key reused for a different request is refused (idempotency_key_reused).",
"type": "string"
},
"overwrite_pending_edits": {
Expand Down
Loading
Loading