From a10ec3a30d1c2b7866448376ec0cf95bd67f4f5e Mon Sep 17 00:00:00 2001 From: J Date: Fri, 10 Jul 2026 00:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(attribution):=20Phase=202.1=20=E2=80=94=20?= =?UTF-8?q?surface=20run=20attribution=20on=20the=20task=20API=20(MUL-4302?= =?UTF-8?q?=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/internal/handler/agent.go | 209 +++++++++++++++--- .../handler/attribution_response_test.go | 105 +++++++++ server/internal/handler/chat.go | 1 + server/internal/handler/task_lifecycle.go | 4 +- server/pkg/db/generated/user.sql.go | 40 ++++ server/pkg/db/queries/user.sql | 7 + 6 files changed, 335 insertions(+), 31 deletions(-) create mode 100644 server/internal/handler/attribution_response_test.go diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index e1e51665f0..32a6120ee9 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -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 ` 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 ` 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 ` 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) } diff --git a/server/internal/handler/attribution_response_test.go b/server/internal/handler/attribution_response_test.go new file mode 100644 index 0000000000..4ab67b8e82 --- /dev/null +++ b/server/internal/handler/attribution_response_test.go @@ -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) + } +} diff --git a/server/internal/handler/chat.go b/server/internal/handler/chat.go index cece329869..b4d1c24782 100644 --- a/server/internal/handler/chat.go +++ b/server/internal/handler/chat.go @@ -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 { diff --git a/server/internal/handler/task_lifecycle.go b/server/internal/handler/task_lifecycle.go index 8287a25334..1f6fb1b3af 100644 --- a/server/internal/handler/task_lifecycle.go +++ b/server/internal/handler/task_lifecycle.go @@ -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) } diff --git a/server/pkg/db/generated/user.sql.go b/server/pkg/db/generated/user.sql.go index d355a7a7e5..1f9a35a26b 100644 --- a/server/pkg/db/generated/user.sql.go +++ b/server/pkg/db/generated/user.sql.go @@ -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, diff --git a/server/pkg/db/queries/user.sql b/server/pkg/db/queries/user.sql index 7d24d58a64..eef17ad013 100644 --- a/server/pkg/db/queries/user.sql +++ b/server/pkg/db/queries/user.sql @@ -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)