Files
pad/internal/server/handlers_documents.go
T
xarmian 6f8d43e2eb feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926) (#2227)
* feat(metrics): signup and first-write funnel metrics; TouchUserWrite reports the first write (TASK-3926)

pad_signups_total{method=email,client=app|web} counts a self-serve or invited
registration once it is kept and signed in; admin-created accounts and the
bootstrap admin are not signups, and OAuth signups are pad-cloud's
(pad_cloud_oauth_signups_total, TASK-3925).

TouchUserWrite now reports first: a conditional UPDATE on last_write_at IS
NULL lets exactly one of any number of concurrent first writes see a row
count of 1, then the old throttled update runs. s.touchUserWrite(r, kind)
replaces the four call sites and counts pad_first_writes_total{kind,client}
plus pad_first_write_age_seconds{client} for a human account created within
30 days. client is agent, then app (PadShell UA), then cli, else web.

PLAN-3908 phase 1, unit 2. No migrations.

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF

* fix(metrics): a workspace restore is not a first item write; every call site tested through its handler (TASK-3926 r1)

Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
2026-10-18 23:25:22 -04:00

695 lines
26 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"sort"
"strings"
"unicode"
"github.com/go-chi/chi/v5"
"github.com/PerpetualSoftware/pad/internal/events"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
func (s *Server) handleListDocuments(w http.ResponseWriter, r *http.Request) {
// Legacy documents are not covered by the grants model — block guests.
if !requireMinRole(w, r, "viewer") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
params := models.DocumentListParams{
Type: r.URL.Query().Get("type"),
Status: r.URL.Query().Get("status"),
Tag: r.URL.Query().Get("tag"),
Query: r.URL.Query().Get("q"),
Sort: r.URL.Query().Get("sort"),
Order: r.URL.Query().Get("order"),
}
if pinnedStr := r.URL.Query().Get("pinned"); pinnedStr != "" {
pinned := pinnedStr == "true"
params.Pinned = &pinned
}
docs, err := s.store.ListDocuments(workspaceID, params)
if err != nil {
writeInternalError(w, err)
return
}
if docs == nil {
docs = []models.Document{}
}
writeJSON(w, http.StatusOK, docs)
}
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
var input models.DocumentCreate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if msg := models.ValidateDocumentTitle(input.Title); msg != "" {
writeError(w, http.StatusBadRequest, "bad_request", msg)
return
}
if input.DocType != "" && !models.IsValidDocType(input.DocType) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid doc_type")
return
}
if input.Status != "" && !models.IsValidStatus(input.Status) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid status")
return
}
// BUG-3372: attribution is the request's, never the body's.
input.CreatedBy, input.Source = actorFromRequest(r)
doc, err := s.store.CreateDocument(workspaceID, input)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
return
}
writeInternalError(w, err)
return
}
// Log activity and publish event
actor, source := actorFromRequest(r)
s.logActivity(workspaceID, doc.ID, "created", r)
s.publishEventWithName(events.DocumentCreated, workspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
writeJSON(w, http.StatusCreated, doc)
}
func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "viewer") {
return
}
_, doc, ok := s.getWorkspaceDocument(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, doc)
}
// isRetryableLockError reports whether a store error is Postgres telling the
// caller that a LOCK was not available, rather than that the request was
// wrong. 55P03 is the lock_timeout this codebase sets on the document-rename
// lock; 40P01 is a deadlock Postgres broke by aborting this transaction.
// Matched on the SQLSTATE text because that is what pgx puts in the error
// string, the same way the UNIQUE-constraint mapping above works.
func isRetryableLockError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "SQLSTATE 55P03") || strings.Contains(msg, "SQLSTATE 40P01")
}
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, doc, ok := s.getWorkspaceDocument(w, r)
if !ok {
return
}
var input models.DocumentUpdate
if err := decodeJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// A title write here is the RENAME door — the one that cascades into every
// linking document — so it carries the same validation as create. Update
// had none at all before BUG-2798/BUG-2796: doc_type and status were
// checked and the field that drives the cascade was not.
//
// Only when the title actually CHANGES, though. Grandfathering existing
// over-limit titles is not a thing you get by validating at write time —
// it is a thing you get by not validating a write that is not a rename
// (codex round 10). A client PATCHing content with the full object, title
// included, is the ordinary shape of an edit; validating the echoed-back
// value would make every document with a legacy title uneditable rather
// than merely un-renameable, which is the opposite of the promise this
// fix's own comments make. The store applies the same test before
// cascading (documents.go, `*input.Title != existing.Title`), so this
// matches where the work actually happens.
if input.Title != nil && *input.Title != doc.Title {
if msg := models.ValidateDocumentTitle(*input.Title); msg != "" {
writeError(w, http.StatusBadRequest, "bad_request", msg)
return
}
}
if input.DocType != nil && !models.IsValidDocType(*input.DocType) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid doc_type")
return
}
if input.Status != nil && !models.IsValidStatus(*input.Status) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid status")
return
}
// BUG-3372: attribution is the request's, never the body's. The body's
// source survives only as the autosave hint below, which decides whether
// an activity row is written and never what any row says.
autosaveHint := input.Source
input.LastModifiedBy, input.Source = actorFromRequest(r)
updated, err := s.store.UpdateDocument(doc.ID, input)
if err != nil {
// TYPED checks first, prose matching last. The UNIQUE-constraint arm
// below identifies its error by SUBSTRING, and the refusal error
// carries the caller's own title verbatim — so a document renamed to a
// title containing the words "UNIQUE constraint" came back as a 409
// title collision, telling the caller to pick a different name for a
// rename that was refused for size and would fail identically under
// any name (codex round 3 P2). Any sentinel this handler knows by
// identity is tested before an error is classified by what its text
// happens to contain.
//
// A size refusal is also PERMANENT-shaped, and must not join the
// retryable family below: retrying it unchanged fails identically
// until the workspace's content changes, so it gets a 4xx with no
// Retry-After, carrying the projection because that is the only
// actionable information. 413 follows this codebase's own precedent
// for refusing work whose COST is not in the request body
// (`image_too_large` in handlers_attachments_transform.go, where the
// request is likewise small and what is refused is what handling it
// would take). The precedent's quantity is output size and this
// guard's is retained content; what carries across is the shape —
// a small request refused for what it would cost, not for its size.
// The store re-checks the title under the rename lock, which is the
// authoritative point; this arm surfaces that check for the case the
// handler's pre-lock comparison could not see — a concurrent rename
// turning an echoed legacy title into a real one (codex round 11).
var badTitle *store.InvalidDocumentTitleError
if errors.As(err, &badTitle) {
writeError(w, http.StatusBadRequest, "bad_request", badTitle.Reason)
return
}
var tooLarge *store.RenameCascadeTooLargeError
if errors.As(err, &tooLarge) {
// Composed from TYPED fields, never by splicing err.Error(). The
// two figures are meant to reach the caller — the refusal has to
// be actionable — but the internal call path wrapped around them
// is not, and appending the error text published whatever any
// layer had prefixed (codex round 5).
writeError(w, http.StatusRequestEntityTooLarge, "rename_cascade_too_large",
fmt.Sprintf("This rename would rewrite more linked content than the server will process in one "+
"operation: at least %d bytes, and the limit is %d. Reduce the number of documents linking "+
"this title, or shorten the new title, and try again.", tooLarge.Retained, tooLarge.Max))
return
}
if strings.Contains(err.Error(), "UNIQUE constraint") {
writeError(w, http.StatusConflict, "conflict", "A document with this title already exists in this workspace")
return
}
// Lock contention is RETRYABLE and must not look like a bug
// (BUG-2778, codex round 6). A rename serializes per workspace with a
// 5s lock timeout, so a burst produces SQLSTATE 55P03
// (lock_not_available); 40P01 is a deadlock Postgres chose to abort.
// Both mean "try again", and a generic 500 tells the caller the
// opposite — that the request will never succeed. Retry-After is
// deliberately short: the holder is one transaction, not a queue.
// Same disposition for a cascade that exhausted its compare-and-set
// retries (BUG-2785): the rename rolled back cleanly because a linking
// document kept being edited underneath it. Retrying can succeed, so a
// 500 would be actively misleading — it says the request will never
// work. Distinguished by sentinel rather than message text, because
// this one is ours to name (codex round 2).
if isRetryableLockError(err) || errors.Is(err, store.ErrLinkCascadeContention) {
w.Header().Set("Retry-After", "1")
// Deliberately does NOT name the holder. The previous wording said
// "another rename", but 55P03 here is just as likely to be an
// ordinary content edit holding the row — and after BUG-2785 the
// cascade-contention arm is definitely a content edit. Claiming a
// cause the server has not established sends the reader looking in
// the wrong place.
writeError(w, http.StatusServiceUnavailable, "lock_contention",
"The workspace is busy with a concurrent edit; retry in a moment")
return
}
writeInternalError(w, err)
return
}
if updated == nil {
writeError(w, http.StatusNotFound, "not_found", "Document not found")
return
}
// Only log activity for meaningful changes (not every auto-save keystroke).
// Content-only updates from the web editor are too frequent to log individually.
isContentOnly := input.Content != nil &&
input.Title == nil && input.DocType == nil && input.Status == nil &&
input.Tags == nil && input.Pinned == nil && input.SortOrder == nil
// The hint is the body's, so it also has to match the request: a PAT or
// agent sending "source":"web" on a content-only PATCH used to skip the
// activity row, writing no audit record at all (BUG-3372 review).
actor, source := actorFromRequest(r)
isWebAutoSave := isContentOnly && autosaveHint == "web" && source == "web"
if !isWebAutoSave {
s.logActivity(updated.WorkspaceID, updated.ID, "updated", r)
}
s.publishEventWithName(events.DocumentUpdated, updated.WorkspaceID, updated.ID, updated.Title, updated.DocType, actor, actorNameFromRequest(r), source)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
_, doc, ok := s.getWorkspaceDocument(w, r)
if !ok {
return
}
if err := s.store.DeleteDocument(doc.ID); err != nil {
writeInternalError(w, err)
return
}
actor, source := actorFromRequest(r)
s.logActivity(doc.WorkspaceID, doc.ID, "archived", r)
s.publishEventWithName(events.DocumentArchived, doc.WorkspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRestoreDocument(w http.ResponseWriter, r *http.Request) {
if !requireMinRole(w, r, "editor") {
return
}
// Restore needs special handling — doc is soft-deleted so getWorkspaceDocument won't find it.
// Verify workspace exists, then restore by ID.
workspaceID, ok := s.getWorkspaceID(w, r)
if !ok {
return
}
// The store restores only a document of this workspace (BUG-3335): one of
// another workspace is not found and is never written, so there is
// nothing to compensate.
docID := chi.URLParam(r, "docID")
doc, err := s.store.RestoreDocument(workspaceID, docID)
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "not_found", "Document not found or not archived")
return
}
if err != nil {
writeInternalError(w, err)
return
}
actor, source := actorFromRequest(r)
s.logActivity(doc.WorkspaceID, doc.ID, "restored", r)
s.publishEventWithName(events.DocumentRestored, doc.WorkspaceID, doc.ID, doc.Title, doc.DocType, actor, actorNameFromRequest(r), source)
writeJSON(w, http.StatusOK, doc)
}
// publishEvent publishes a real-time event if the event bus is available (best-effort).
func (s *Server) publishEvent(eventType, workspaceID, documentID, title, docType, actor, source string) {
s.publishEventWithName(eventType, workspaceID, documentID, title, docType, actor, "", source)
}
// publishEventWithName publishes a real-time event with actor name.
func (s *Server) publishEventWithName(eventType, workspaceID, documentID, title, docType, actor, actorName, source string) {
if s.events == nil {
return
}
s.publishActivityEvent(events.Event{
Type: eventType,
WorkspaceID: workspaceID,
DocumentID: documentID,
Title: title,
DocType: docType,
Actor: actor,
ActorName: actorName,
Source: source,
})
}
// actorNameFromRequest returns the authenticated user's display name, or empty string.
func actorNameFromRequest(r *http.Request) string {
if u := currentUser(r); u != nil {
return u.Name
}
return ""
}
// actorFromRequest derives actor and source from the request's auth context.
// Actor is "user" or "agent" (determined by X-Pad-Agent header).
// Source is "web" (cookie session), "cli" (Bearer token), or falls back to "web".
func actorFromRequest(r *http.Request) (actor, source string) {
actor = "user"
source = "web"
// If an agent name header is present, mark as agent. A remote MCP call
// is an agent write whether or not its client declared a name
// (BUG-2772): an app acting for the user, never the user typing.
if agentNameFromRequest(r) != "" {
actor = "agent"
} else if _, ok := remoteMCPCaller(r); ok {
actor = "agent"
}
// Determine source from auth method. Two signals — both mean
// "non-cookie authenticated", which we attribute as source="cli"
// regardless of whether the request rode the wire with an
// Authorization header or was synthesized in-process by the MCP
// HTTPHandlerDispatcher (server.WithAPITokenAuth in
// internal/mcp/dispatch_http.go). Without honoring the context
// flag, dispatcher-driven calls would persist source="web",
// regressing dashboard/source attribution vs. ExecDispatcher.
if auth := r.Header.Get("Authorization"); auth != "" {
source = "cli"
} else if isAPITokenAuth(r) {
source = "cli"
}
return actor, source
}
// maxAgentNameRunes bounds the self-declared agent name (BUG-3372).
const maxAgentNameRunes = 64
// agentNameFromRequest is the X-Pad-Agent header as stored: trimmed, control
// characters removed, at most maxAgentNameRunes. The header is a client's
// self-description and cannot be verified; it only ever labels the caller's
// OWN writes (user_id is still the authenticated account), so it is kept as
// the agent signal. What it may not do is carry an unbounded or
// control-laden string into every activity row and timeline chip
// (BUG-3372).
func agentNameFromRequest(r *http.Request) string {
raw := r.Header.Get("X-Pad-Agent")
if raw == "" {
return ""
}
var b strings.Builder
n := 0
for _, c := range strings.TrimSpace(raw) {
if unicode.IsControl(c) {
continue
}
if n == maxAgentNameRunes {
break
}
b.WriteRune(c)
n++
}
return strings.TrimSpace(b.String())
}
// stampCommentAttribution sets a new comment's author, created_by and source
// from the request, whatever the body said (BUG-3372). The body could name any
// author: a member posting as another member, or as "Support team", in the
// trail the conventions treat as the audit record. The author is the signed-in
// account's display name; with no account (a fresh install, a legacy workspace
// token) it is left empty and the store labels it by kind. created_by and
// source are what actorFromRequest derives.
func stampCommentAttribution(r *http.Request, input *models.CommentCreate) {
input.Author = ""
if u := currentUser(r); u != nil {
input.Author = u.Name
}
input.CreatedBy, input.Source = actorFromRequest(r)
}
// agentMeta returns metadata JSON with the agent name if X-Pad-Agent is set,
// merged with any existing metadata. Returns empty string if no agent.
func agentMeta(r *http.Request, existingMeta string) string {
agentName := agentNameFromRequest(r)
if agentName == "" {
return existingMeta
}
if existingMeta == "" || existingMeta == "{}" {
return auditMeta(map[string]string{"agent": agentName})
}
// Merge: insert agent field into existing JSON object
if strings.HasPrefix(existingMeta, "{") {
agentJSON, _ := json.Marshal(agentName)
return fmt.Sprintf(`{"agent":%s,%s`, agentJSON, existingMeta[1:])
}
return existingMeta
}
// logActivity is a helper that logs activity, ignoring errors (best-effort).
func (s *Server) logActivity(workspaceID, documentID, action string, r *http.Request) {
s.logActivityWithMeta(workspaceID, documentID, action, r, "")
}
// logActivityWithMeta logs activity with optional JSON metadata.
// For "updated" actions, uses debouncing to coalesce rapid successive saves
// (e.g., autosave) into a single activity entry within a cooldown window.
func (s *Server) logActivityWithMeta(workspaceID, documentID, action string, r *http.Request, metadata string) {
_, _ = s.logActivityWithMetaReturningID(workspaceID, documentID, action, r, metadata)
}
// logActivityWithMetaReturningID is like logActivityWithMeta but returns the activity ID.
// The ID is either newly created or the coalesced existing activity's ID (for debounced updates).
// activityForRequest builds the activity row a handler would log for this
// request — actor, source, agent metadata, user, ip, user agent — without
// writing it. logActivityWithMetaReturningID writes it debounced; the two
// "commented" sites hand it to CreateCommentWithActivity so it commits in the
// same transaction as the comment that links to it (BUG-2716).
func (s *Server) activityForRequest(workspaceID, documentID, action string, r *http.Request, metadata string) models.Activity {
actor, source := actorFromRequest(r)
return models.Activity{
WorkspaceID: workspaceID,
DocumentID: documentID,
Action: action,
Actor: actor,
Source: source,
Metadata: agentMeta(r, metadata),
UserID: currentUserID(r),
IPAddress: clientIP(r),
UserAgent: requestUserAgent(r),
}
}
func (s *Server) logActivityWithMetaReturningID(workspaceID, documentID, action string, r *http.Request, metadata string) (string, error) {
id, err := s.store.CreateActivityDebounced(s.activityForRequest(workspaceID, documentID, action, r, metadata))
// Bump last_write_at on the actor. Every action that flows through this
// helper (created/updated/archived/restored/moved/reordered) is a write.
// PLAN-1542 / TASK-1543. TouchUserWrite is throttled + no-ops on empty
// userID, so we can call unconditionally. On the funnel (TASK-3926) an
// item action is kind=item (comments and attachments touch from their own
// handlers). The one caller with no item, a workspace restore, still
// records the write but is never counted as an item: if it is an
// account's first write, that first write goes uncounted (r1, Codex and
// the hunter).
if documentID == "" {
s.store.TouchUserWrite(r.Context(), currentUserID(r))
} else {
s.touchUserWrite(r, writeKindItem)
}
return id, err
}
// logAuditEvent logs a non-workspace audit event (e.g. login, logout).
// Best-effort: errors are silently ignored.
func (s *Server) logAuditEvent(action string, r *http.Request, metadata string) {
s.logAuditEventForUser(action, r, currentUserID(r), metadata)
}
// logAuditEventForUser logs an audit event with an explicit user ID.
// Use this when the user isn't (yet) in the request context, e.g. after
// a successful login/register/bootstrap where the session was just created.
func (s *Server) logAuditEventForUser(action string, r *http.Request, userID string, metadata string) {
actor, source := actorFromRequest(r)
if metadata == "" {
metadata = "{}"
}
_, _ = s.store.CreateActivity(models.Activity{
Action: action,
Actor: actor,
Source: source,
Metadata: metadata,
UserID: userID,
IPAddress: clientIP(r),
UserAgent: requestUserAgent(r),
})
}
// auditMeta safely marshals a map to a JSON string for audit log metadata.
// Falls back to "{}" on marshal error so audit calls never break.
func auditMeta(kv map[string]string) string {
data, err := json.Marshal(kv)
if err != nil {
return "{}"
}
return string(data)
}
// logWorkspaceAuditEvent logs a workspace-scoped audit event (e.g. member invited).
// Best-effort: errors are silently ignored.
func (s *Server) logWorkspaceAuditEvent(workspaceID, action string, r *http.Request, metadata string) {
actor, source := actorFromRequest(r)
if metadata == "" {
metadata = "{}"
}
_, _ = s.store.CreateActivity(models.Activity{
WorkspaceID: workspaceID,
Action: action,
Actor: actor,
Source: source,
Metadata: metadata,
UserID: currentUserID(r),
IPAddress: clientIP(r),
UserAgent: requestUserAgent(r),
})
}
// diffFields compares old and new field JSON strings and returns a human-readable
// summary of changes (e.g. "status: open → done, priority: medium → high").
// valueOrEmpty returns the string or "(none)" if empty.
func valueOrEmpty(s string) string {
if s == "" {
return "(none)"
}
return s
}
// withActivityMetaKey sets one string key in an activity's JSON metadata,
// keeping every other key (PLAN-2348 U2). Metadata that does not parse is
// replaced by an object holding only the key, as appendChange does.
func withActivityMetaKey(meta, key, value string) string {
m := map[string]string{}
if meta != "" {
_ = json.Unmarshal([]byte(meta), &m)
}
m[key] = value
data, err := json.Marshal(m)
if err != nil {
return meta
}
return string(data)
}
// appendChange adds a change description to existing metadata JSON.
func appendChange(meta, change string) string {
if meta == "" {
return fmt.Sprintf(`{"changes":%q}`, change)
}
// Parse existing changes and append
var m map[string]string
if err := json.Unmarshal([]byte(meta), &m); err != nil {
return fmt.Sprintf(`{"changes":%q}`, change)
}
if existing, ok := m["changes"]; ok {
m["changes"] = existing + "; " + change
} else {
m["changes"] = change
}
b, _ := json.Marshal(m)
return string(b)
}
func diffFields(oldFields, newFields string) string {
// Decoded keeping number literals (BUG-3202), so the comparison below
// is on exact values and a large integer is displayed with its digits.
oldMap, err := models.DecodeFieldsJSON([]byte(oldFields))
if err != nil {
return ""
}
newMap, err := models.DecodeFieldsJSON([]byte(newFields))
if err != nil {
return ""
}
var changes []string
for key, newVal := range newMap {
oldVal, exists := oldMap[key]
newStr := formatChangeValue(key, newVal)
if !exists {
changes = append(changes, fmt.Sprintf("%s: → %s", key, newStr))
continue
}
// Compare on the raw decoded values rather than the display strings.
// formatChangeValue() collapses structured values to fixed labels
// (e.g. `(1 note)`, `(object)`), so two semantically distinct edits
// of the same cardinality would otherwise produce equal display
// strings and the change would be silently dropped from the activity
// metadata. Numbers are compared in one exact canonical spelling
// (BUG-3202): a stored 1e3 and a resent 1000 are the same value and
// no change, while two integers above 2^53 that share a float64 are
// different values and a change.
if reflect.DeepEqual(models.CanonicalJSONNumbers(oldVal), models.CanonicalJSONNumbers(newVal)) {
continue
}
oldStr := formatChangeValue(key, oldVal)
changes = append(changes, fmt.Sprintf("%s: %s → %s", key, oldStr, newStr))
}
// Sort for deterministic output.
sort.Strings(changes)
// "; " matches mergeActivityMeta's joiner and the web timeline parser's
// split delimiter (TimelineActivityCard.svelte splits on ';'), so a
// multi-field PATCH renders as individual change pills instead of one
// unparseable blob. Same delimiter for single-PATCH and merged outputs
// lets collapseChanges treat the whole string uniformly.
return strings.Join(changes, "; ")
}
// formatChangeValue renders a JSON-decoded field value as a human-readable
// string for the activity-feed `changes` summary. Primitives use Go's default
// formatting; structured values (slices, maps) are summarised as a count or
// short label so the activity card never surfaces Go's `[map[k:v ...]]`
// repr to end users (BUG-748).
//
// Known structured fields (`implementation_notes`, `decision_log`) get
// domain-specific phrasing; unknown structured fields fall back to a generic
// `(N items)` / `(object)` label.
func formatChangeValue(field string, val any) string {
if val == nil {
return ""
}
switch v := val.(type) {
case []any:
switch field {
case models.ItemFieldImplementationNotes:
if len(v) == 1 {
return "(1 note)"
}
return fmt.Sprintf("(%d notes)", len(v))
case models.ItemFieldDecisionLog:
if len(v) == 1 {
return "(1 entry)"
}
return fmt.Sprintf("(%d entries)", len(v))
}
if len(v) == 1 {
return "(1 item)"
}
return fmt.Sprintf("(%d items)", len(v))
case map[string]any:
return "(object)"
default:
return fmt.Sprintf("%v", v)
}
}