mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +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
126 lines
5.6 KiB
Go
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"`
|
|
}
|