feat(attribution): Phase 2.1 — surface run attribution on the task API (MUL-4302 §9)

First Phase 2 (visibility) increment: the agent-task API now returns the resolved
accountable-human provenance so the UI can render an "on behalf of" badge.

- AgentTaskResponse gains an `attribution` object: source label (never blank —
  pre-migration NULL renders "unattributed") + `precise` flag (false for the degraded
  owner_fallback / backfill / unattributed sources), the initiator (accountable) and
  originator (authorization) user refs, the evidence {kind, ref_id} pointer, and the
  rule_version / delegated / retry / rerun lineage ids.
- The label + evidence + raw ids are built in the PURE taskToResponse (no DB), so
  every task response carries them. Names are hydrated separately, only on the
  user-facing surfaces (ListAgentTasks, ListWorkspaceAgentTaskSnapshot, RerunIssue,
  CancelTaskByUser) — daemon-claim paths stay lean.
- Hydration resolves initiator/originator from the GLOBAL user table (departed-member
  safe) via a new batch GetUsersByIDs query (no N+1); best-effort, so a lookup hiccup
  leaves the raw ids intact.

Tests: pure taskAttributionBase (direct_human / rule_owner NULL-originator /
owner_fallback degraded / pre-migration→unattributed) + DB hydration (fills known
ref, leaves unknown id un-filled, skips nil). Full handler/service/attribution/
migration/scheduler/cmd suites pass on a DB migrated through 161; build/vet/gofmt
clean. The field is additive — the frontend's parseWithFallback ignores unknown keys,
so nothing breaks until the UI increment consumes it.

Also merges origin/main (unrelated editor feature #5090).

Remaining Phase 2 (next increments, same PR): frontend zod schema + "on behalf of"
badge + evidence-chain jump; append-only correction events (write + display).

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-07-10 00:14:10 +08:00
parent 75a962e3f5
commit a10ec3a30d
6 changed files with 335 additions and 31 deletions

View File

@@ -17,10 +17,12 @@ import (
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/attribution"
"github.com/multica-ai/multica/server/internal/logger"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/runtimeapps"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/agent"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
@@ -308,36 +310,36 @@ type AgentTaskResponse struct {
// when WorkDir is empty, or when stripping leaves nothing. See
// relativeWorkDir() for the full rules. Older clients can still read
// WorkDir directly; newer UIs should prefer RelativeWorkDir.
RelativeWorkDir string `json:"relative_work_dir,omitempty"`
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
CoalescedCommentIDs []string `json:"coalesced_comment_ids,omitempty"` // MUL-4195: earlier comments folded into this run when it had not yet started, so a single run still covers every deliberate comment; trigger_comment_id is the newest. Surfaced so the UI can show which comments a run covered. omitempty so old clients ignore it
CoalescedComments []CoalescedCommentData `json:"coalesced_comments,omitempty"` // MUL-4195: full detail (thread_id/author/created_at/content) of the folded comments, so the daemon prompt can address each without assuming they share the triggering thread. omitempty so old clients ignore it
TriggerThreadID string `json:"trigger_thread_id,omitempty"` // root comment ID for the triggering thread
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
NewCommentCount int `json:"new_comment_count,omitempty"` // trigger-thread comments since last run; excludes injected trigger + own comments; omitempty so old daemons ignore it
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; omitempty so old daemons ignore it
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Makes the agent channel-aware (read history from the channel, not Multica)
ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; tells the agent to start with `multica chat thread` vs `multica chat history`
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download <id>` per entry
ChatIntro bool `json:"chat_intro,omitempty"` // true for the agent's proactive self-introduction chat (is_agent_intro session, no user message); the daemon builds an intro prompt instead of a reply prompt
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
QuickCreateAttachmentIDs []string `json:"quick_create_attachment_ids,omitempty"` // attachment ids uploaded in the quick-create prompt and bound on issue create
HandoffNote string `json:"handoff_note,omitempty"` // assignment handoff instruction; rendered into the run's opening prompt + issue_context.md (omitempty so old daemons ignore it)
SquadID string `json:"squad_id,omitempty"` // for quick-create tasks where the picker was a squad; Agent is still the resolved leader
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad
ParentIssueID string `json:"parent_issue_id,omitempty"` // for quick-create tasks opened from "Add sub issue" — UUID of the parent issue the new issue should be filed under
ParentIssueIdentifier string `json:"parent_issue_identifier,omitempty"` // human-readable identifier (e.g. MUL-123) of the quick-create parent issue, resolved on claim for prompt context
RelativeWorkDir string `json:"relative_work_dir,omitempty"`
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
CoalescedCommentIDs []string `json:"coalesced_comment_ids,omitempty"` // MUL-4195: earlier comments folded into this run when it had not yet started, so a single run still covers every deliberate comment; trigger_comment_id is the newest. Surfaced so the UI can show which comments a run covered. omitempty so old clients ignore it
CoalescedComments []CoalescedCommentData `json:"coalesced_comments,omitempty"` // MUL-4195: full detail (thread_id/author/created_at/content) of the folded comments, so the daemon prompt can address each without assuming they share the triggering thread. omitempty so old clients ignore it
TriggerThreadID string `json:"trigger_thread_id,omitempty"` // root comment ID for the triggering thread
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
NewCommentCount int `json:"new_comment_count,omitempty"` // trigger-thread comments since last run; excludes injected trigger + own comments; omitempty so old daemons ignore it
NewCommentsSince string `json:"new_comments_since,omitempty"` // RFC3339 anchor (last run's started_at) the count is measured from; omitempty so old daemons ignore it
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatChannelType string `json:"chat_channel_type,omitempty"` // "slack" when the chat session is backed by an IM channel; empty for a web-only chat. Makes the agent channel-aware (read history from the channel, not Multica)
ChatInThread bool `json:"chat_in_thread,omitempty"` // true when the latest @mention was a thread reply; tells the agent to start with `multica chat thread` vs `multica chat history`
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments on the user message — agent calls `multica attachment download <id>` per entry
ChatIntro bool `json:"chat_intro,omitempty"` // true for the agent's proactive self-introduction chat (is_agent_intro session, no user message); the daemon builds an intro prompt instead of a reply prompt
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
QuickCreateAttachmentIDs []string `json:"quick_create_attachment_ids,omitempty"` // attachment ids uploaded in the quick-create prompt and bound on issue create
HandoffNote string `json:"handoff_note,omitempty"` // assignment handoff instruction; rendered into the run's opening prompt + issue_context.md (omitempty so old daemons ignore it)
SquadID string `json:"squad_id,omitempty"` // for quick-create tasks where the picker was a squad; Agent is still the resolved leader
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad
ParentIssueID string `json:"parent_issue_id,omitempty"` // for quick-create tasks opened from "Add sub issue" — UUID of the parent issue the new issue should be filed under
ParentIssueIdentifier string `json:"parent_issue_identifier,omitempty"` // human-readable identifier (e.g. MUL-123) of the quick-create parent issue, resolved on claim for prompt context
// RequestingUserName + RequestingUserProfileDescription mirror the user
// the agent is acting on behalf of (see daemon/types.go). v1 sources them
// from the runtime owner so they're populated for daemon runtimes and
@@ -364,6 +366,12 @@ type AgentTaskResponse struct {
InitiatorName string `json:"initiator_name,omitempty"` // display name of the initiator
InitiatorEmail string `json:"initiator_email,omitempty"` // member email; empty for agent initiators
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
// Attribution is the resolved accountable-human provenance for this run
// (MUL-4302 §9): the source label + precise flag, the initiator (accountable)
// and originator refs, the evidence pointer, and lineage. Always present (the
// pure taskToResponse builds the labels + raw ids); initiator/originator names
// are hydrated from the global user table only on user-facing surfaces.
Attribution *TaskAttribution `json:"attribution,omitempty"`
// AuthToken is the task-scoped `mat_` token the daemon must inject as
// MULTICA_TOKEN in the agent process environment. The server binds it to
// this (agent_id, task_id) pair at claim time and treats any request
@@ -375,6 +383,142 @@ type AgentTaskResponse struct {
AuthToken string `json:"auth_token,omitempty"`
}
// TaskAttribution is the wire shape of a run's accountable-human provenance
// (MUL-4302 §9). Source/Precise/Evidence/lineage come straight from the row (pure);
// Initiator/Originator carry the raw user id always and the display name/email/avatar
// only after hydration on a user-facing surface.
type TaskAttribution struct {
// Source is the waterfall level that resolved the accountable human:
// direct_human | delegation | comment_source | rule_owner | owner_fallback |
// backfill | unattributed. Never blank (a pre-migration NULL renders "unattributed").
Source string `json:"source"`
// Precise is false for degraded sources (owner_fallback / backfill / unattributed);
// the UI marks these distinctly and they count against the coverage metric.
Precise bool `json:"precise"`
// Initiator is the accountable human (accountable_user_id). Nil when unattributed.
Initiator *AttributionUser `json:"initiator,omitempty"`
// Originator is the authorization human (originator_user_id); nil for autopilot
// (rule_owner / owner_fallback), where no human authorized the run.
Originator *AttributionUser `json:"originator,omitempty"`
// Evidence points at the direct cause of the run so the UI can jump to it.
Evidence *TaskEvidence `json:"evidence,omitempty"`
RuleVersionID string `json:"rule_version_id,omitempty"`
DelegatedFromTaskID string `json:"delegated_from_task_id,omitempty"`
RetryOfTaskID string `json:"retry_of_task_id,omitempty"`
RerunOfTaskID string `json:"rerun_of_task_id,omitempty"`
}
// AttributionUser is a departed-member-safe user ref, resolved from the global user
// table. Name/Email/AvatarURL are empty until hydrated.
type AttributionUser struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// TaskEvidence is the kind-tagged handle to a run's direct cause (a comment,
// autopilot run, rule version, source task, ...).
type TaskEvidence struct {
Kind string `json:"kind"`
RefID string `json:"ref_id"`
}
// taskAttributionBase builds the pure (no-DB) part of a run's attribution from the
// row: the source label + precise flag, evidence, lineage, and the raw initiator /
// originator user ids. Names are filled later by hydrateTaskAttributions.
func taskAttributionBase(t db.AgentTaskQueue) *TaskAttribution {
src := attribution.Source(t.OriginatorSource.String)
attr := &TaskAttribution{
Source: src.String(), // empty (pre-migration) → "unattributed"
Precise: src.Precise(),
RuleVersionID: uuidToString(t.RuleVersionID),
DelegatedFromTaskID: uuidToString(t.DelegatedFromTaskID),
RetryOfTaskID: uuidToString(t.RetryOfTaskID),
RerunOfTaskID: uuidToString(t.RerunOfTaskID),
}
if t.AccountableUserID.Valid {
attr.Initiator = &AttributionUser{ID: uuidToString(t.AccountableUserID)}
}
if t.OriginatorUserID.Valid {
attr.Originator = &AttributionUser{ID: uuidToString(t.OriginatorUserID)}
}
if t.TriggerEvidenceKind.Valid && t.TriggerEvidenceKind.String != "" {
attr.Evidence = &TaskEvidence{Kind: t.TriggerEvidenceKind.String, RefID: uuidToString(t.TriggerEvidenceRefID)}
}
return attr
}
// hydrateTaskAttributions fills the display name / email / avatar on the initiator
// and originator refs of the given attributions, resolving from the GLOBAL user
// table in one batch (departed-safe, N+1-free). Best-effort: on a lookup failure the
// raw user ids already present are left as-is — names are display sugar, not gating.
func (h *Handler) hydrateTaskAttributions(ctx context.Context, attrs []*TaskAttribution) {
seen := make(map[string]struct{})
var ids []pgtype.UUID
add := func(ref *AttributionUser) {
if ref == nil || ref.ID == "" {
return
}
if _, ok := seen[ref.ID]; ok {
return
}
if u, err := util.ParseUUID(ref.ID); err == nil {
seen[ref.ID] = struct{}{}
ids = append(ids, u)
}
}
for _, a := range attrs {
if a == nil {
continue
}
add(a.Initiator)
add(a.Originator)
}
if len(ids) == 0 {
return
}
users, err := h.Queries.GetUsersByIDs(ctx, ids)
if err != nil {
return
}
byID := make(map[string]db.GetUsersByIDsRow, len(users))
for _, u := range users {
byID[uuidToString(u.ID)] = u
}
fill := func(ref *AttributionUser) {
if ref == nil {
return
}
if u, ok := byID[ref.ID]; ok {
ref.Name = u.Name
ref.Email = u.Email
if u.AvatarUrl.Valid {
ref.AvatarURL = u.AvatarUrl.String
}
}
}
for _, a := range attrs {
if a == nil {
continue
}
fill(a.Initiator)
fill(a.Originator)
}
}
// attributionsOf collects the (non-nil) TaskAttribution pointers from a slice of
// task responses so a list endpoint can hydrate them all in one batch.
func attributionsOf(resps []AgentTaskResponse) []*TaskAttribution {
out := make([]*TaskAttribution, 0, len(resps))
for i := range resps {
if resps[i].Attribution != nil {
out = append(out, resps[i].Attribution)
}
}
return out
}
// ChatAttachmentMeta is the structured attachment metadata embedded in
// claim responses for chat tasks. The agent uses these to run
// `multica attachment download <id>` rather than guessing from the
@@ -481,6 +625,9 @@ func taskToResponse(t db.AgentTaskQueue, workspaceID string) AgentTaskResponse {
ChatSessionID: uuidToString(t.ChatSessionID),
AutopilotRunID: uuidToString(t.AutopilotRunID),
Kind: computeTaskKind(t),
// Attribution labels + evidence + lineage + raw user ids (pure). Names are
// hydrated separately on user-facing surfaces (MUL-4302 §9).
Attribution: taskAttributionBase(t),
}
}
@@ -1801,6 +1948,7 @@ func (h *Handler) ListAgentTasks(w http.ResponseWriter, r *http.Request) {
for i, t := range tasks {
resp[i] = taskToResponse(t, workspaceID)
}
h.hydrateTaskAttributions(r.Context(), attributionsOf(resp))
writeJSON(w, http.StatusOK, resp)
}
@@ -1938,6 +2086,7 @@ func (h *Handler) ListWorkspaceAgentTaskSnapshot(w http.ResponseWriter, r *http.
}
resp = append(resp, taskToResponse(t, workspaceID))
}
h.hydrateTaskAttributions(r.Context(), attributionsOf(resp))
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,105 @@
package handler
import (
"context"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestTaskAttributionBase covers the pure row→attribution mapping (MUL-4302 §9):
// source label + precise flag, initiator/originator raw refs, evidence, lineage —
// no DB, no name hydration.
func TestTaskAttributionBase(t *testing.T) {
member := parseUUID("11111111-1111-1111-1111-111111111111")
comment := parseUUID("22222222-2222-2222-2222-222222222222")
ruleVer := parseUUID("33333333-3333-3333-3333-333333333333")
t.Run("direct_human sets both refs + evidence", func(t *testing.T) {
got := taskAttributionBase(db.AgentTaskQueue{
OriginatorSource: pgtype.Text{String: "direct_human", Valid: true},
OriginatorUserID: member,
AccountableUserID: member,
TriggerEvidenceKind: pgtype.Text{String: "comment", Valid: true},
TriggerEvidenceRefID: comment,
})
if got.Source != "direct_human" || !got.Precise {
t.Fatalf("source/precise = %q/%v, want direct_human/true", got.Source, got.Precise)
}
if got.Initiator == nil || got.Initiator.ID != uuidToString(member) {
t.Errorf("initiator = %+v, want member", got.Initiator)
}
if got.Originator == nil || got.Originator.ID != uuidToString(member) {
t.Errorf("originator = %+v, want member", got.Originator)
}
if got.Evidence == nil || got.Evidence.Kind != "comment" || got.Evidence.RefID != uuidToString(comment) {
t.Errorf("evidence = %+v, want comment/%s", got.Evidence, uuidToString(comment))
}
if got.Initiator.Name != "" {
t.Errorf("base must not carry a name (hydration is separate), got %q", got.Initiator.Name)
}
})
t.Run("rule_owner: accountable set, originator NULL, rule_version present", func(t *testing.T) {
got := taskAttributionBase(db.AgentTaskQueue{
OriginatorSource: pgtype.Text{String: "rule_owner", Valid: true},
AccountableUserID: member, // originator left invalid (autopilot)
RuleVersionID: ruleVer,
})
if got.Source != "rule_owner" || !got.Precise {
t.Fatalf("source/precise = %q/%v, want rule_owner/true", got.Source, got.Precise)
}
if got.Initiator == nil || got.Initiator.ID != uuidToString(member) {
t.Errorf("initiator = %+v, want member (rule publisher)", got.Initiator)
}
if got.Originator != nil {
t.Errorf("rule_owner must have NULL originator, got %+v", got.Originator)
}
if got.RuleVersionID != uuidToString(ruleVer) {
t.Errorf("rule_version_id = %q, want %s", got.RuleVersionID, uuidToString(ruleVer))
}
})
t.Run("owner_fallback is degraded (not precise)", func(t *testing.T) {
got := taskAttributionBase(db.AgentTaskQueue{
OriginatorSource: pgtype.Text{String: "owner_fallback", Valid: true},
AccountableUserID: member,
})
if got.Source != "owner_fallback" || got.Precise {
t.Errorf("source/precise = %q/%v, want owner_fallback/false", got.Source, got.Precise)
}
})
t.Run("empty (pre-migration) source renders unattributed", func(t *testing.T) {
got := taskAttributionBase(db.AgentTaskQueue{}) // no attribution columns set
if got.Source != "unattributed" || got.Precise {
t.Errorf("source/precise = %q/%v, want unattributed/false", got.Source, got.Precise)
}
if got.Initiator != nil || got.Originator != nil || got.Evidence != nil {
t.Errorf("no ids/evidence should be set for an empty row, got %+v", got)
}
})
}
// TestHydrateTaskAttributionsFillsUserRef verifies the batch name hydration resolves
// initiator/originator refs from the GLOBAL user table (departed-safe) and leaves an
// unknown id un-filled without erroring (MUL-4302 §9).
func TestHydrateTaskAttributionsFillsUserRef(t *testing.T) {
known := &AttributionUser{ID: testUserID}
unknown := &AttributionUser{ID: "44444444-4444-4444-4444-444444444444"}
attrs := []*TaskAttribution{
{Initiator: known, Originator: known}, // same id in both refs
{Initiator: unknown},
nil, // must be skipped without panic
}
testHandler.hydrateTaskAttributions(context.Background(), attrs)
if known.Name != handlerTestName || known.Email != handlerTestEmail {
t.Errorf("known ref = %q/%q, want %q/%q", known.Name, known.Email, handlerTestName, handlerTestEmail)
}
if unknown.Name != "" {
t.Errorf("unknown user id must stay un-filled, got name %q", unknown.Name)
}
}

View File

@@ -1147,6 +1147,7 @@ func (h *Handler) CancelTaskByUser(w http.ResponseWriter, r *http.Request) {
resp := CancelTaskByUserResponse{
AgentTaskResponse: taskToResponse(cancelled.Task, workspaceID),
}
h.hydrateTaskAttributions(r.Context(), []*TaskAttribution{resp.AgentTaskResponse.Attribution})
if cancelled.CancelledChatMessage != nil {
attachments := make([]AttachmentResponse, 0, len(cancelled.CancelledChatMessage.Attachments))
for _, a := range cancelled.CancelledChatMessage.Attachments {

View File

@@ -158,5 +158,7 @@ func (h *Handler) RerunIssue(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, taskToResponse(*task, uuidToString(issue.WorkspaceID)))
resp := taskToResponse(*task, uuidToString(issue.WorkspaceID))
h.hydrateTaskAttributions(r.Context(), []*TaskAttribution{resp.Attribution})
writeJSON(w, http.StatusAccepted, resp)
}

View File

@@ -99,6 +99,46 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, name, email, avatar_url FROM "user"
WHERE id = ANY($1::uuid[])
`
type GetUsersByIDsRow struct {
ID pgtype.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
AvatarUrl pgtype.Text `json:"avatar_url"`
}
// Batch lookup from the GLOBAL user table (not gated on membership, so departed
// members still render). Used to enrich attribution initiator / originator refs on
// task responses without an N+1 (MUL-4302 §9). Returns only the display fields.
func (q *Queries) GetUsersByIDs(ctx context.Context, ids []pgtype.UUID) ([]GetUsersByIDsRow, error) {
rows, err := q.db.Query(ctx, getUsersByIDs, ids)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetUsersByIDsRow{}
for rows.Next() {
var i GetUsersByIDsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Email,
&i.AvatarUrl,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const joinCloudWaitlist = `-- name: JoinCloudWaitlist :one
UPDATE "user" SET
cloud_waitlist_email = $2,

View File

@@ -6,6 +6,13 @@ WHERE id = $1;
SELECT * FROM "user"
WHERE email = $1;
-- name: GetUsersByIDs :many
-- Batch lookup from the GLOBAL user table (not gated on membership, so departed
-- members still render). Used to enrich attribution initiator / originator refs on
-- task responses without an N+1 (MUL-4302 §9). Returns only the display fields.
SELECT id, name, email, avatar_url FROM "user"
WHERE id = ANY(@ids::uuid[]);
-- name: CreateUser :one
INSERT INTO "user" (name, email, avatar_url)
VALUES ($1, $2, $3)