Files
pad/internal/models/comment.go
T
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* 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
2026-10-11 14:38:15 -04:00

126 lines
5.6 KiB
Go

package models
import (
"database/sql"
"encoding/json"
"time"
)
// Comment represents a comment on an item.
type Comment struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
WorkspaceID string `json:"workspace_id"`
Author string `json:"author"`
// UserID is the authenticated user who authored the comment. Empty for
// pre-identity comments (created before TASK-1663) and agent/system
// comments; the comment-edit permission check treats empty as
// "no provable author" → admin-only.
UserID string `json:"user_id,omitempty"`
Body string `json:"body"`
CreatedBy string `json:"created_by"`
Source string `json:"source"`
ActivityID string `json:"activity_id,omitempty"`
ParentID string `json:"parent_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Deleted marks a tombstone: a comment deleted while it still had
// replies, kept so they keep their parent (BUG-3252). Its body is empty;
// author and timestamps are the original ones.
Deleted bool `json:"deleted,omitempty"`
// Imported marks a comment a workspace import wrote (BUG-3379): its
// author string came from the export and was never verified against an
// account here. Set only by the import door.
Imported bool `json:"imported,omitempty"`
// Populated by joins (not stored)
//
// LinkedActivityAction is the action of the activity ActivityID points at,
// read by the same list join as AgentName (BUG-3437): "commented" for a
// comment or reply on its own, "updated" (or another change) for a comment
// an item update carried. Never serialized: the item timeline turns it into
// TimelineEntry.CommentOnUpdate, and nothing else needs it, so the
// comments API and MCP keep their shape. Empty on a comment read any other
// way, and on one with no linked activity.
LinkedActivityAction string `json:"-"`
ItemTitle string `json:"item_title,omitempty"`
ItemSlug string `json:"item_slug,omitempty"`
// AgentName is the display name the writing agent declared (the
// X-Pad-Agent header), read off the activity this comment's ActivityID
// points at — the `commented` row a comment or reply logs, or the
// `updated` row of an item update that carried the comment. Comments
// themselves never store it; workspace activity rows are the only
// carrier (TASK-2759 / TASK-2760). Its ONLY
// writer is the LEFT JOIN in the store's list queries (scanComments in
// comments.go), so it is empty on a comment read any other way
// (GetComment, the create/update read-back) and on a comment whose
// activity carries no stamp — a human's write, a pre-BUG-2542 row, an
// agent that sent no header. It is populated whatever CreatedBy says;
// the client decides whether the actor kind makes it meaningful.
// Self-declared, so it records what the client said, not who acted.
AgentName string `json:"agent_name,omitempty"`
// Populated by handlers for threaded views
Replies []Comment `json:"replies,omitempty"`
Reactions []Reaction `json:"reactions,omitempty"`
// ViaApp and ViaAppName name the installed app that wrote the comment,
// absent for a person's or agent's (SPEC-6 U9c, TASK-3413). The name
// is the app's bot display name, else its origin, and survives
// uninstall. The server fills both on read; no scan sets them.
ViaApp string `json:"via_app,omitempty"`
ViaAppName string `json:"via_app_name,omitempty"`
}
// IsEdited reports whether the body changed after creation: create stamps
// created_at and updated_at with one value, and only an edit moves updated_at
// (reactions live in their own table). Timestamps are second-precision, so an
// edit inside the creation second is not seen. Same rule as the web timeline's
// isEdited (TimelineCommentCard.svelte); TASK-2695 brought it to the CLI.
func (c Comment) IsEdited() bool {
return !c.CreatedAt.IsZero() && c.UpdatedAt.After(c.CreatedAt)
}
// MarshalJSON adds a derived, read-only `edited` (IsEdited) to every
// serialised comment, so an agent reading JSON on either MCP transport sees
// the same marker the CLI table and the web timeline show without comparing
// timestamps itself (TASK-2695). It is not stored and not read back: Comment
// has no field for it, so a decode ignores it.
func (c Comment) MarshalJSON() ([]byte, error) {
type plain Comment
return json.Marshal(struct {
plain
Edited bool `json:"edited"`
}{plain(c), c.IsEdited()})
}
// CommentCreate is the input for creating a new comment.
type CommentCreate struct {
Author string `json:"author,omitempty"`
Body string `json:"body"`
CreatedBy string `json:"created_by,omitempty"`
Source string `json:"source,omitempty"`
ParentID string `json:"parent_id,omitempty"`
ActivityID string `json:"activity_id,omitempty"`
// OpKey is the idempotency key in the body (TASK-3585), for callers that
// cannot set the Idempotency-Key header. Not part of the request hash;
// the store ignores it.
OpKey string `json:"op_key,omitempty"`
// AfterWriteTx, when set, runs inside CreateCommentWithActivity's
// transaction after the comment is inserted and read back, before COMMIT
// (TASK-3585: the idempotency key's answer). An error rolls the comment
// and its activity back. Internal.
AfterWriteTx func(tx *sql.Tx, created *Comment) error `json:"-"`
}
// Reaction represents an emoji reaction on a comment.
type Reaction struct {
ID string `json:"id"`
CommentID string `json:"comment_id"`
UserID string `json:"user_id,omitempty"`
Actor string `json:"actor"`
Emoji string `json:"emoji"`
CreatedAt time.Time `json:"created_at"`
ActorName string `json:"actor_name,omitempty"`
}