From d4e8df7e7bcae448c5939e42e6f2b7ccbf12e6ad Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Thu, 9 Jul 2026 01:50:45 +0000 Subject: [PATCH 1/3] feat: attribute tasks to users or named automations and stop stamping fallback user ids --- .../architecture/cloud-job-execution.md | 3 +- .../github/__tests__/handlePrComment.test.ts | 2 +- .../handleWorkflowRunCompleted.test.ts | 7 +- .../src/handlers/github/handlePrComment.ts | 6 +- .../github/handleWorkflowRunCompleted.ts | 15 +- .../handlers/github/reviewTaskRelayPayload.ts | 8 +- .../handlers/mcp/__tests__/asana-auth.test.ts | 2 +- .../mcp/__tests__/grafana-auth.test.ts | 2 +- .../mcp/__tests__/snowflake-auth.test.ts | 2 +- .../mcp/__tests__/vercel-auth.test.ts | 2 +- apps/api/src/handlers/mcp/asana/index.ts | 4 +- apps/api/src/handlers/mcp/grafana/index.ts | 4 +- apps/api/src/handlers/mcp/proxy-utils.ts | 6 +- apps/api/src/handlers/mcp/slack.ts | 44 +++- apps/api/src/handlers/mcp/snowflake/index.ts | 4 +- apps/api/src/handlers/mcp/vercel/index.ts | 4 +- .../handlers/teams/__tests__/index.test.ts | 12 +- apps/api/src/handlers/teams/index.ts | 4 +- .../suggester-route-dispatch.test.ts | 3 +- .../scheduled-jobs/merged-pr-audit-runner.ts | 29 +-- .../scheduled-jobs/scheduled-triage-runner.ts | 23 +- .../suggester-route-dispatch.ts | 7 +- apps/bullmq/src/scheduled-jobs/suggester.ts | 19 +- apps/controller/src/BaseController.ts | 4 +- .../src/__tests__/BaseController.test.ts | 2 +- .../analytics-task-user-dimension.test.ts | 143 ++++++++++++ .../server/analytics-task-user-dimension.ts | 161 +++++++++++++ apps/web/src/lib/server/analytics.ts | 132 +---------- .../server/task-creator-filter-condition.ts | 82 +++++++ apps/web/src/lib/server/tasks.ts | 49 +--- apps/web/src/lib/task-creator-filter.test.ts | 48 ++-- apps/web/src/lib/task-creator-filter.ts | 74 +++--- apps/web/src/trpc/commands/filters/index.ts | 221 +++++------------- .../src/server/cloud-job-queue.ts | 22 +- packages/cloud-agents/src/server/index.ts | 2 +- ...esolve-credential-user-id-for-cloud-job.ts | 36 +++ .../server/resolve-user-id-for-cloud-job.ts | 29 --- .../src/server/workflows/utils.ts | 9 +- packages/db/src/lib/task-attribution.test.ts | 62 +++++ packages/db/src/lib/task-attribution.ts | 45 +++- .../src/server/lib/auth/create-job-token.ts | 4 +- .../__tests__/task-automation-display.test.ts | 112 +++++++++ packages/types/src/cloud-jobs.ts | 2 + packages/types/src/index.ts | 1 + packages/types/src/task-automation-display.ts | 79 +++++++ 45 files changed, 983 insertions(+), 548 deletions(-) create mode 100644 apps/web/src/lib/server/analytics-task-user-dimension.test.ts create mode 100644 apps/web/src/lib/server/analytics-task-user-dimension.ts create mode 100644 apps/web/src/lib/server/task-creator-filter-condition.ts create mode 100644 packages/cloud-agents/src/server/resolve-credential-user-id-for-cloud-job.ts delete mode 100644 packages/cloud-agents/src/server/resolve-user-id-for-cloud-job.ts create mode 100644 packages/types/src/__tests__/task-automation-display.test.ts create mode 100644 packages/types/src/task-automation-display.ts diff --git a/.agent-guidance/architecture/cloud-job-execution.md b/.agent-guidance/architecture/cloud-job-execution.md index f0f2461df..bb06671b3 100644 --- a/.agent-guidance/architecture/cloud-job-execution.md +++ b/.agent-guidance/architecture/cloud-job-execution.md @@ -112,7 +112,8 @@ Key behavior: - The internal Slack `!eval` launcher (gated by `SlackEvalLauncher`) is the one launch surface that maps caller-provided harness/model overrides into the launch. The shared [`resolveEvalHarnessSelection`](../../packages/types/src/eval-harness-selection.ts) helper resolves `--harness` / `--model` into an optional `task.harness` pin and `payload.harnessModelOverrides`; both the Slack command parser (to return usage errors) and [`startSlackAppMentionTask`](../../packages/slack/src/start-slack-app-mention.ts) (to build the payload) call it. `--harness` may only select `opencode-server`. When omitted, a recognized OpenCode catalog model (`getOpenCodeRequestedModelCatalogEntry`) infers and pins `task.harness = 'opencode-server'` and sets `harnessModelOverrides['opencode-server']`. `resolveEffectiveHarnessModelState` resolves the persisted `tasks.model` from the OpenCode override so eval launches record their actual model rather than the default. - `SnapshotResume` jobs inherit the source job's persisted `payload.harnessModelOverrides` instead of re-evaluating deployment feature-flag state, so resumed harness runs keep the same runtime model as the original launch. - Persists an initial `requestedWorkKind` decision on every new `cloud_jobs` row. StandardTask launches honor explicit bootstrap overrides first, Slack/Linear/Web/API launches fall back to a dedicated prompt classifier when needed, and `SnapshotResume` jobs inherit the source job's persisted value. -- Persists a task-attribution snapshot onto each new `cloud_jobs` row and any newly created `tasks` row. Enqueue stays conservative by default: automation and setup flows can stamp an explicit automatic override, user-facing StandardTask launches backfill as web-created work, and `SnapshotResume` only upgrades to human attribution when the initiating surface carries a concrete Roomote user identity. For resumed follow-ups, attribution prefers `payload.resumePromptUserId` when present so the persisted creator follows the current resumer even if the job owner stays tied to the source task for runtime continuity; Slack and Linear resumes still preserve those source kinds when their routing metadata is present. +- Persists a task-attribution snapshot onto each new `cloud_jobs` row and any newly created `tasks` row. Every task resolves to a single initiator: a matched product user, an unlinked external identity (displayed by its external name), or a named automation. Automation-initiated work (`buildTaskAttributionSnapshot` + `resolveTaskAutomationDisplayName` in `packages/types/src/task-automation-display.ts`) stamps `attributionSourceKind: 'automation'` and the automation's registry label (for example "PR Reviewer", "Suggest Ideas") into `attributionSourceDisplayName`; unnamed automatic work lands in the shared "Automations" bucket. Enqueue stays conservative by default: automation and setup flows can stamp an explicit automatic override (optionally with a `displayName`), user-facing StandardTask launches backfill as web-created work, and `SnapshotResume` only upgrades to human attribution when the initiating surface carries a concrete Roomote user identity. For resumed follow-ups, attribution prefers `payload.resumePromptUserId` when present so the persisted creator follows the current resumer even if the job owner stays tied to the source task for runtime continuity; Slack and Linear resumes still preserve those source kinds when their routing metadata is present. +- Automation-initiated tasks (known automation task types or explicit automatic overrides) enqueue with a null `userId`; the "must have a userId or githubUserId" guard only applies to human entry points. `tasks.userId` / `cloud_jobs.userId` are credential plumbing, never attribution or analytics identity: job tokens and MCP credential lookups resolve lazily through `resolveCredentialUserIdForCloudJob()` (job owner first, then the stable first-active-user fallback), and live human follow-ups override via `cloud_jobs.actingUserId`. Do not stamp a fallback human user id onto automation launches to satisfy the guard. - Creates or reuses `tasks` records (`taskId`) and links the execution attempt to org, user, and task. Newly created task rows persist the effective OpenCode runtime model from any validated override so task history and admin surfaces match the harness configuration that actually runs. - Supports an internal `beforeEnqueue` hook for producers that must persist external launch linkage after the cloud job row exists but before the job reaches the controller queue. If that hook throws, or if the Redis queue push fails afterward, enqueue cancels the new cloud job and reports the failure without leaving a recoverable pending job behind. - Resolves a launch class (`human`, `automation`, or `maintenance`) only to choose the persisted `keepaliveMs` runtime policy for new cloud jobs. Launch classes are not a chore queue concept, are not persisted as their own cloud-job field, and are not exposed to authorship rules; authorship should match durable task attribution fields such as `sourceKinds`, `taskTypes`, repository, and `humanCreated`. diff --git a/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts b/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts index 6e942f38e..f51c597ed 100644 --- a/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts +++ b/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts @@ -10,7 +10,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ enqueueCloudTask: vi.fn(), getTaskUrl: vi.fn(), routeGitHubTask: vi.fn(), - resolveUserIdForCloudJob: vi.fn(), + resolveCredentialUserIdForCloudJob: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ diff --git a/apps/api/src/handlers/github/__tests__/handleWorkflowRunCompleted.test.ts b/apps/api/src/handlers/github/__tests__/handleWorkflowRunCompleted.test.ts index 601cc247c..48b4ffb65 100644 --- a/apps/api/src/handlers/github/__tests__/handleWorkflowRunCompleted.test.ts +++ b/apps/api/src/handlers/github/__tests__/handleWorkflowRunCompleted.test.ts @@ -2,7 +2,6 @@ const { mockDbSelect, mockEnqueueCloudTask, mockBuildRepositoryCoverage, - mockResolveUserIdForCloudJob, mockGetBackgroundAgentSettingsForOrg, mockEvaluateFeatureFlag, mockStartBackgroundAutomationRun, @@ -16,7 +15,6 @@ const { mockDbSelect: vi.fn(), mockEnqueueCloudTask: vi.fn(), mockBuildRepositoryCoverage: vi.fn(), - mockResolveUserIdForCloudJob: vi.fn(), mockGetBackgroundAgentSettingsForOrg: vi.fn(), mockEvaluateFeatureFlag: vi.fn(), mockStartBackgroundAutomationRun: vi.fn(), @@ -57,8 +55,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ enqueueCloudTask: (...args: unknown[]) => mockEnqueueCloudTask(...args), getTaskUrl: ({ taskId }: { taskId: string }) => `https://app.example.com/task/${taskId}?utm_source=slack&utm_medium=link&utm_campaign=slack.thread_reply`, - resolveUserIdForCloudJob: (...args: unknown[]) => - mockResolveUserIdForCloudJob(...args), })); vi.mock('@roomote/feature-flags/server', () => ({ @@ -197,7 +193,6 @@ describe('handleWorkflowRunCompleted', () => { undefined as never, ); mockUpsertBackgroundAutomationSlackThread.mockResolvedValue(undefined); - mockResolveUserIdForCloudJob.mockResolvedValue('admin-1'); mockStartBackgroundAutomationRun.mockResolvedValue({ id: 'run-1' }); mockCompleteBackgroundAutomationRun.mockResolvedValue(undefined); mockEnqueueCloudTask.mockResolvedValue({ @@ -220,7 +215,7 @@ describe('handleWorkflowRunCompleted', () => { ); expect(mockEnqueueCloudTask).toHaveBeenCalledWith( expect.objectContaining({ - userId: 'admin-1', + userId: null, type: CloudTaskType.SuggestedTasks, payload: expect.objectContaining({ repo: 'acme/api', diff --git a/apps/api/src/handlers/github/handlePrComment.ts b/apps/api/src/handlers/github/handlePrComment.ts index d600f5a5d..14cbd6729 100644 --- a/apps/api/src/handlers/github/handlePrComment.ts +++ b/apps/api/src/handlers/github/handlePrComment.ts @@ -4,7 +4,7 @@ import { enqueueCloudTask, getTaskUrl, routeGitHubTask, - resolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob, } from '@roomote/cloud-agents/server'; import { findActiveGitHubPrReviewTask, @@ -841,7 +841,7 @@ async function resolveReusableTaskSenderUserId({ return ( latestJob.actingUserId ?? - (await resolveUserIdForCloudJob({ + (await resolveCredentialUserIdForCloudJob({ id: latestJob.id, userId: latestJob.userId, })) @@ -1030,7 +1030,7 @@ async function resumeExistingTaskAndDeliverFollowUp({ const senderUserId = userId ?? sourceJob.actingUserId ?? - (await resolveUserIdForCloudJob({ + (await resolveCredentialUserIdForCloudJob({ id: sourceJob.id, userId: sourceJob.userId, })); diff --git a/apps/api/src/handlers/github/handleWorkflowRunCompleted.ts b/apps/api/src/handlers/github/handleWorkflowRunCompleted.ts index f08250bc6..4245df643 100644 --- a/apps/api/src/handlers/github/handleWorkflowRunCompleted.ts +++ b/apps/api/src/handlers/github/handleWorkflowRunCompleted.ts @@ -6,7 +6,6 @@ import { buildRepositoryCoverage, enqueueCloudTask, getTaskUrl, - resolveUserIdForCloudJob, } from '@roomote/cloud-agents/server'; import { and, @@ -249,16 +248,6 @@ export async function handleWorkflowRunCompleted( }; } - const adminUserId = await resolveUserIdForCloudJob({ - id: 0, - userId: null, - }); - - if (!adminUserId) { - console.warn(`${LOG_PREFIX} No user available for CI failure triage`); - return { status: 'ok', message: 'No user available' }; - } - const workflowName = run.name ?? payload.workflow?.name ?? 'unknown'; const announcementText = buildAnnouncementText({ repositoryFullName: match.repositoryFullName, @@ -328,7 +317,9 @@ export async function handleWorkflowRunCompleted( const launchResult = await enqueueCloudTask( { - userId: adminUserId, + // Automation-initiated: no stamped user id. Attribution comes from + // the suggestion source, and credentials resolve at token-mint time. + userId: null, type: CloudTaskType.SuggestedTasks, payload: { repo: match.repositoryFullName, diff --git a/apps/api/src/handlers/github/reviewTaskRelayPayload.ts b/apps/api/src/handlers/github/reviewTaskRelayPayload.ts index ce1ded771..be7bde168 100644 --- a/apps/api/src/handlers/github/reviewTaskRelayPayload.ts +++ b/apps/api/src/handlers/github/reviewTaskRelayPayload.ts @@ -12,10 +12,12 @@ function hasRoomoteTaskAttribution(prBody: string | null | undefined): boolean { } const hasTaskLink = /\[[^\]]+\]\([^)]*\/task\/[^)]*\)/.test(prBody); + // The attribution line names either a human ("Opened on behalf of ...") or + // the initiating automation ("Created by PR Reviewer.", "Created by + // Roomote."). Combined with the task-link requirement above this stays + // specific to Roomote-authored PR bodies. const hasRoomoteAttributionLine = - /Opened on behalf of|Created by Roomote(?: from an unlinked [^.]+)?\./.test( - prBody, - ); + /Opened on behalf of|Created by [^.\n]{1,100}\./.test(prBody); return hasTaskLink && hasRoomoteAttributionLine; } diff --git a/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts index bdfd13bbf..562393f28 100644 --- a/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts @@ -45,7 +45,7 @@ vi.mock('@roomote/db/encryption', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ - resolveUserIdForCloudJob: mockResolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob: mockResolveUserIdForCloudJob, })); import { db } from '@roomote/db/server'; diff --git a/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts index 344212259..7f9eb649a 100644 --- a/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts @@ -45,7 +45,7 @@ vi.mock('@roomote/db/encryption', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ - resolveUserIdForCloudJob: mockResolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob: mockResolveUserIdForCloudJob, })); import { db } from '@roomote/db/server'; diff --git a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts index 742781600..796f2aea5 100644 --- a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts @@ -66,7 +66,7 @@ vi.mock('@roomote/db/encryption', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ - resolveUserIdForCloudJob: mockResolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob: mockResolveUserIdForCloudJob, })); vi.mock('snowflake-sdk', () => ({ diff --git a/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts index e91f8c54c..6f83b2177 100644 --- a/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts @@ -45,7 +45,7 @@ vi.mock('@roomote/db/encryption', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ - resolveUserIdForCloudJob: mockResolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob: mockResolveUserIdForCloudJob, })); import { db } from '@roomote/db/server'; diff --git a/apps/api/src/handlers/mcp/asana/index.ts b/apps/api/src/handlers/mcp/asana/index.ts index 8b50ee07a..f524bd7fb 100644 --- a/apps/api/src/handlers/mcp/asana/index.ts +++ b/apps/api/src/handlers/mcp/asana/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { and, cloudJobs, @@ -52,7 +52,7 @@ async function resolveAsanaMcpAuth( throw new McpProxyError(404, 'Cloud job not found for this MCP token'); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!resolvedUserId) { throw new McpProxyError( 403, diff --git a/apps/api/src/handlers/mcp/grafana/index.ts b/apps/api/src/handlers/mcp/grafana/index.ts index d10701ef3..af8ac8dca 100644 --- a/apps/api/src/handlers/mcp/grafana/index.ts +++ b/apps/api/src/handlers/mcp/grafana/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { and, cloudJobs, @@ -52,7 +52,7 @@ async function resolveGrafanaMcpAuth( throw new McpProxyError(404, 'Cloud job not found for this MCP token'); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!resolvedUserId) { throw new McpProxyError( 403, diff --git a/apps/api/src/handlers/mcp/proxy-utils.ts b/apps/api/src/handlers/mcp/proxy-utils.ts index 58e43c6eb..57cacfcda 100644 --- a/apps/api/src/handlers/mcp/proxy-utils.ts +++ b/apps/api/src/handlers/mcp/proxy-utils.ts @@ -9,7 +9,7 @@ import { isUserToken, parseMcpJsonRpcPayload, } from '@roomote/types'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { cloudJobs, db, eq } from '@roomote/db/server'; import type { Variables } from '../../types'; @@ -102,7 +102,7 @@ export async function resolveActingUserId( throw new McpProxyError(404, 'Cloud job not found for this MCP token'); } - const resolvedUserId = await resolveUserIdForCloudJob({ + const resolvedUserId = await resolveCredentialUserIdForCloudJob({ id: auth.cloudJobId, userId: cloudJob.userId, }); @@ -133,7 +133,7 @@ async function verifyCloudJobHasRealUser( ); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!hasRealCloudJobUser(resolvedUserId)) { return jsonRpcErrorResponse( diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index 2e2d3fdeb..f7619a1b9 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -2,7 +2,7 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'; import { Hono } from 'hono'; import { Env } from '@roomote/env'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { and, backgroundAutomationSlackThreads, @@ -885,7 +885,7 @@ slackMcp.post('/thread_reply', async (c) => { return c.json({ error: 'Cloud job not found for this MCP token' }, 404); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!hasRealCloudJobUser(resolvedUserId)) { return c.json( @@ -1505,14 +1505,20 @@ slackMcp.post('/thread_lookup', async (c) => { return c.json({ error: 'Cloud job not found for this MCP token' }, 404); } - if (!hasRealCloudJobUser(cloudJob.userId)) { + // Automation-initiated jobs carry a null userId; their tokens are minted + // from the lazily resolved credential user, so validate against that same + // resolution instead of the raw job owner column. + const tokenCredentialUserId = + await resolveCredentialUserIdForCloudJob(cloudJob); + + if (!hasRealCloudJobUser(tokenCredentialUserId)) { return c.json( { error: 'MCP proxy requires a cloud job associated with a real user' }, 403, ); } - if (cloudJob.userId !== authContext.userId) { + if (tokenCredentialUserId !== authContext.userId) { return c.json( { error: 'MCP token user does not match cloud job user' }, 403, @@ -1577,14 +1583,20 @@ slackMcp.post('/reaction_add', async (c) => { return c.json({ error: 'Cloud job not found for this MCP token' }, 404); } - if (!hasRealCloudJobUser(cloudJob.userId)) { + // Automation-initiated jobs carry a null userId; their tokens are minted + // from the lazily resolved credential user, so validate against that same + // resolution instead of the raw job owner column. + const tokenCredentialUserId = + await resolveCredentialUserIdForCloudJob(cloudJob); + + if (!hasRealCloudJobUser(tokenCredentialUserId)) { return c.json( { error: 'MCP proxy requires a cloud job associated with a real user' }, 403, ); } - if (cloudJob.userId !== authContext.userId) { + if (tokenCredentialUserId !== authContext.userId) { return c.json( { error: 'MCP token user does not match cloud job user' }, 403, @@ -1697,14 +1709,20 @@ slackMcp.post('/channel_messages', async (c) => { return c.json({ error: 'Cloud job not found for this MCP token' }, 404); } - if (!hasRealCloudJobUser(cloudJob.userId)) { + // Automation-initiated jobs carry a null userId; their tokens are minted + // from the lazily resolved credential user, so validate against that same + // resolution instead of the raw job owner column. + const tokenCredentialUserId = + await resolveCredentialUserIdForCloudJob(cloudJob); + + if (!hasRealCloudJobUser(tokenCredentialUserId)) { return c.json( { error: 'MCP proxy requires a cloud job associated with a real user' }, 403, ); } - if (cloudJob.userId !== authContext.userId) { + if (tokenCredentialUserId !== authContext.userId) { return c.json( { error: 'MCP token user does not match cloud job user' }, 403, @@ -1777,14 +1795,20 @@ slackMcp.post('/channel_post', async (c) => { return c.json({ error: 'Cloud job not found for this MCP token' }, 404); } - if (!hasRealCloudJobUser(cloudJob.userId)) { + // Automation-initiated jobs carry a null userId; their tokens are minted + // from the lazily resolved credential user, so validate against that same + // resolution instead of the raw job owner column. + const tokenCredentialUserId = + await resolveCredentialUserIdForCloudJob(cloudJob); + + if (!hasRealCloudJobUser(tokenCredentialUserId)) { return c.json( { error: 'MCP proxy requires a cloud job associated with a real user' }, 403, ); } - if (cloudJob.userId !== authContext.userId) { + if (tokenCredentialUserId !== authContext.userId) { return c.json( { error: 'MCP token user does not match cloud job user' }, 403, diff --git a/apps/api/src/handlers/mcp/snowflake/index.ts b/apps/api/src/handlers/mcp/snowflake/index.ts index 57c05faab..1f7747d26 100644 --- a/apps/api/src/handlers/mcp/snowflake/index.ts +++ b/apps/api/src/handlers/mcp/snowflake/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { and, cloudJobs, @@ -52,7 +52,7 @@ async function resolveSnowflakeMcpAuth( throw new McpProxyError(404, 'Cloud job not found for this MCP token'); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!resolvedUserId) { throw new McpProxyError( 403, diff --git a/apps/api/src/handlers/mcp/vercel/index.ts b/apps/api/src/handlers/mcp/vercel/index.ts index 4f5219e86..827384299 100644 --- a/apps/api/src/handlers/mcp/vercel/index.ts +++ b/apps/api/src/handlers/mcp/vercel/index.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; import { and, cloudJobs, @@ -52,7 +52,7 @@ async function resolveVercelMcpAuth( throw new McpProxyError(404, 'Cloud job not found for this MCP token'); } - const resolvedUserId = await resolveUserIdForCloudJob(cloudJob); + const resolvedUserId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!resolvedUserId) { throw new McpProxyError( 403, diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 17e357382..aa8929056 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -24,7 +24,7 @@ const { redisGetMock, queueCommunicationMessageMock, redisSetMock, - resolveUserIdForCloudJobMock, + resolveCredentialUserIdForCloudJobMock, routeTaskMock, shouldRouteUnmentionedReplyMock, teamsInstallationsTable, @@ -76,7 +76,7 @@ const { redisGetMock: vi.fn(), queueCommunicationMessageMock: vi.fn(), redisSetMock: vi.fn(), - resolveUserIdForCloudJobMock: vi.fn(), + resolveCredentialUserIdForCloudJobMock: vi.fn(), routeTaskMock: vi.fn(), shouldRouteUnmentionedReplyMock: vi.fn(), teamsInstallationsTable: { @@ -211,7 +211,7 @@ vi.mock('@roomote/cloud-agents/server', () => ({ buildTeamsRoutingContext: buildTeamsRoutingContextMock, enqueueCloudTask: enqueueCloudTaskMock, getTaskUrl: getTaskUrlMock, - resolveUserIdForCloudJob: resolveUserIdForCloudJobMock, + resolveCredentialUserIdForCloudJob: resolveCredentialUserIdForCloudJobMock, routeTask: routeTaskMock, })); @@ -311,7 +311,7 @@ describe('Teams webhook handler', () => { processImageAttachmentsMock.mockResolvedValue([ 'data:image/png;base64,abc123', ]); - resolveUserIdForCloudJobMock.mockResolvedValue('user-1'); + resolveCredentialUserIdForCloudJobMock.mockResolvedValue('user-1'); redisEvalMock.mockResolvedValue(null); redisGetMock.mockResolvedValue(null); redisSetMock.mockResolvedValue('OK'); @@ -832,7 +832,7 @@ describe('Teams webhook handler', () => { cloudJobId: 88, }); expect(response.status).toBe(200); - expect(resolveUserIdForCloudJobMock).not.toHaveBeenCalled(); + expect(resolveCredentialUserIdForCloudJobMock).not.toHaveBeenCalled(); expect(buildTeamsRoutingContextMock).toHaveBeenCalledWith( expect.objectContaining({ userId: 'mapped-user-1', @@ -988,7 +988,7 @@ describe('Teams webhook handler', () => { reason: 'account_link_required', }); expect(response.status).toBe(200); - expect(resolveUserIdForCloudJobMock).not.toHaveBeenCalled(); + expect(resolveCredentialUserIdForCloudJobMock).not.toHaveBeenCalled(); expect(buildTeamsRoutingContextMock).not.toHaveBeenCalled(); expect(routeTaskMock).not.toHaveBeenCalled(); expect(enqueueCloudTaskMock).not.toHaveBeenCalled(); diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 4b9b7c5d1..65db68874 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -58,7 +58,7 @@ import { buildTeamsRoutingContext, enqueueCloudTask, getTaskUrl, - resolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob, routeTask, type RoutingWorkspace, } from '@roomote/cloud-agents/server'; @@ -985,7 +985,7 @@ async function resumeTeamsTaskFromSnapshot(input: { const resumeUserId = queuedMessage.userId ?? completedJob.userId ?? - (await resolveUserIdForCloudJob(completedJob)); + (await resolveCredentialUserIdForCloudJob(completedJob)); if (!resumeUserId) { throw new Error( 'No active user is available for Teams snapshot resume.', diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/suggester-route-dispatch.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/suggester-route-dispatch.test.ts index 55650e249..9b5e61cd1 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/suggester-route-dispatch.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/suggester-route-dispatch.test.ts @@ -35,7 +35,6 @@ import { dispatchSuggestionRoutes } from '../suggester-route-dispatch'; function buildParams() { return { - adminUserId: 'admin-1', deployment: { slackBotToken: 'xoxb-test', slackTeamId: 'T-1', @@ -118,7 +117,7 @@ describe('dispatchSuggestionRoutes', () => { ); expect(mockEnqueueCloudTask).toHaveBeenCalledWith( { - userId: 'admin-1', + userId: null, type: CloudTaskType.SuggestedTasks, payload: { repo: ALL_REPOSITORIES, diff --git a/apps/bullmq/src/scheduled-jobs/merged-pr-audit-runner.ts b/apps/bullmq/src/scheduled-jobs/merged-pr-audit-runner.ts index 260653a6e..2f9ad8a83 100644 --- a/apps/bullmq/src/scheduled-jobs/merged-pr-audit-runner.ts +++ b/apps/bullmq/src/scheduled-jobs/merged-pr-audit-runner.ts @@ -2,7 +2,6 @@ import { buildRepositoryCoverage, enqueueCloudTask, formatRepositoryEnvironmentLines, - resolveUserIdForCloudJob, type RepositoryCoverage, } from '@roomote/cloud-agents/server'; import { @@ -438,30 +437,6 @@ async function processDeployment( return { kind: 'processed' }; } - const adminUserId = await resolveUserIdForCloudJob({ - id: 0, - userId: null, - }); - - if (!adminUserId) { - console.warn( - `${logPrefix} Skipping deployment: no user available for ${config.automationKey.replaceAll('_', ' ')} task`, - ); - await completeBackgroundAutomationRun(db, { - runId, - automationKey: config.automationKey, - status: 'failed', - finishedAt: new Date(), - error: `No user available for ${config.automationKey.replaceAll('_', ' ')} cloud task.`, - lastRunAt: 'skip', - metadata: { - reason: 'no_user', - scanUpperBound: scanUpperBound.toISOString(), - }, - }); - return { kind: 'skipped' }; - } - const recentThreadFeedback = await loadAutomationThreadFeedbackReport({ automationKey: config.automationKey, slackChannelId: channelId, @@ -475,7 +450,9 @@ async function processDeployment( const launchResult = await enqueueCloudTask( { - userId: adminUserId, + // Automation-initiated: no stamped user id. Attribution comes from + // the suggestion source, and credentials resolve at token-mint time. + userId: null, type: CloudTaskType.SuggestedTasks, payload: { repo: ALL_REPOSITORIES, diff --git a/apps/bullmq/src/scheduled-jobs/scheduled-triage-runner.ts b/apps/bullmq/src/scheduled-jobs/scheduled-triage-runner.ts index 02512df13..fa7a50b63 100644 --- a/apps/bullmq/src/scheduled-jobs/scheduled-triage-runner.ts +++ b/apps/bullmq/src/scheduled-jobs/scheduled-triage-runner.ts @@ -1,7 +1,4 @@ -import { - enqueueCloudTask, - resolveUserIdForCloudJob, -} from '@roomote/cloud-agents/server'; +import { enqueueCloudTask } from '@roomote/cloud-agents/server'; import { completeBackgroundAutomationRun, completeBackgroundAutomationRunByJobId, @@ -157,19 +154,6 @@ export function createScheduledTriageJob( continue; } - const adminUserId = await resolveUserIdForCloudJob({ - id: 0, - userId: null, - }); - - if (!adminUserId) { - console.warn( - `${logPrefix} Skipping deployment: no active user available for scheduled ${config.automationKey.replaceAll('_', ' ')} task`, - ); - skipped++; - continue; - } - const scanTask = await config.buildScanTask({ deployment, channelId, @@ -195,7 +179,10 @@ export function createScheduledTriageJob( const launchResult = await enqueueCloudTask( { - userId: adminUserId, + // Automation-initiated: no stamped user id. Attribution comes + // from the suggestion source, and credentials resolve at + // token-mint time. + userId: null, type: CloudTaskType.SuggestedTasks, payload: scanTask.payload, }, diff --git a/apps/bullmq/src/scheduled-jobs/suggester-route-dispatch.ts b/apps/bullmq/src/scheduled-jobs/suggester-route-dispatch.ts index 96ebec557..29d9b199b 100644 --- a/apps/bullmq/src/scheduled-jobs/suggester-route-dispatch.ts +++ b/apps/bullmq/src/scheduled-jobs/suggester-route-dispatch.ts @@ -38,7 +38,6 @@ function getSuggestionRouteKind(route: SuggestionDispatchRoute) { } async function enqueueSuggestionRoute(params: { - adminUserId: string; deployment: SuggesterDeploymentContext; previousSuggestions: Array<{ title: string; @@ -70,7 +69,9 @@ async function enqueueSuggestionRoute(params: { const launchResult = await enqueueCloudTask( { - userId: params.adminUserId, + // Automation-initiated: no stamped user id. Attribution comes from + // the suggestion source, and credentials resolve at token-mint time. + userId: null, type: CloudTaskType.SuggestedTasks, payload: { repo: ALL_REPOSITORIES, @@ -153,7 +154,6 @@ async function enqueueSuggestionRoute(params: { } export async function dispatchSuggestionRoutes(params: { - adminUserId: string; deployment: SuggesterDeploymentContext; previousSuggestions: Array<{ title: string; @@ -170,7 +170,6 @@ export async function dispatchSuggestionRoutes(params: { for (const route of params.routePlan.routes) { const result = await enqueueSuggestionRoute({ - adminUserId: params.adminUserId, deployment: params.deployment, previousSuggestions: params.previousSuggestions, repositoryCoverage: params.repositoryCoverage, diff --git a/apps/bullmq/src/scheduled-jobs/suggester.ts b/apps/bullmq/src/scheduled-jobs/suggester.ts index ec9c4d57b..77b9b12cd 100644 --- a/apps/bullmq/src/scheduled-jobs/suggester.ts +++ b/apps/bullmq/src/scheduled-jobs/suggester.ts @@ -1,7 +1,4 @@ -import { - findEnvironmentForRepo, - resolveUserIdForCloudJob, -} from '@roomote/cloud-agents/server'; +import { findEnvironmentForRepo } from '@roomote/cloud-agents/server'; import { db, slackInstallations, @@ -168,19 +165,6 @@ export async function suggesterJob( continue; } - const adminUserId = await resolveUserIdForCloudJob({ - id: 0, - userId: null, - }); - - if (!adminUserId) { - console.warn( - `${LOG_PREFIX} Skipping deployment: no user available for scheduled suggester task`, - ); - skipped++; - continue; - } - const openSuggestionCount = await countOpenSuggestions(); if ( @@ -255,7 +239,6 @@ export async function suggesterJob( }, }); const dispatchResult = await dispatchSuggestionRoutes({ - adminUserId, deployment, previousSuggestions, repositoryCoverage: environmentBackedRepositoryCoverage, diff --git a/apps/controller/src/BaseController.ts b/apps/controller/src/BaseController.ts index 3067b4d65..12c9ca9ff 100644 --- a/apps/controller/src/BaseController.ts +++ b/apps/controller/src/BaseController.ts @@ -27,7 +27,7 @@ import { } from '@roomote/db/server'; import { dequeueCloudTask, - resolveUserIdForCloudJob, + resolveCredentialUserIdForCloudJob, } from '@roomote/cloud-agents/server'; import { finishCloudJob } from '@roomote/sdk/server'; @@ -411,7 +411,7 @@ export abstract class BaseController { protected async dequeueCloudJob( cloudJob: CloudJob, ): Promise<{ cloudJob: CloudJob; authToken: string } | null> { - const userId = await resolveUserIdForCloudJob(cloudJob); + const userId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!userId) { throw new Error( diff --git a/apps/controller/src/__tests__/BaseController.test.ts b/apps/controller/src/__tests__/BaseController.test.ts index 53412e3f3..a1a7e2d99 100644 --- a/apps/controller/src/__tests__/BaseController.test.ts +++ b/apps/controller/src/__tests__/BaseController.test.ts @@ -89,7 +89,7 @@ vi.mock('@roomote/redis', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ dequeueCloudTask: (...args: unknown[]) => mockDequeueCloudTask(...args), - resolveUserIdForCloudJob: (...args: unknown[]) => + resolveCredentialUserIdForCloudJob: (...args: unknown[]) => mockResolveUserIdForCloudJob(...args), })); diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.test.ts b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts new file mode 100644 index 000000000..3e0af9ce3 --- /dev/null +++ b/apps/web/src/lib/server/analytics-task-user-dimension.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { + CloudTaskType, + PRODUCT_NAME, + resolveTaskAutomationDisplayName, +} from '@roomote/types'; + +import { + AUTOMATIONS_USER_DIMENSION_KEY, + AUTOMATIONS_USER_DIMENSION_LABEL, + getCanonicalTaskAttributionDimensionValue, +} from './analytics-task-user-dimension'; + +describe('getCanonicalTaskAttributionDimensionValue', () => { + it('keeps matched users as their own series', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'matched_user', + attributedUserId: 'user-1', + attributionSourceKind: 'web', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: 'human', + effectiveAuthorUserId: 'user-1', + effectiveAuthorDisplayName: 'Matt Rubens', + effectiveAuthorGithubLogin: 'mrubens', + userName: 'Matt Rubens', + userEmail: 'matt@example.com', + }), + ).toEqual({ + key: 'user:user-1', + label: 'Matt Rubens', + disambiguationLabel: 'Matt Rubens (matt@example.com)', + }); + }); + + it('uses named automation series for automatic attribution', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: null, + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }), + ).toEqual({ + key: 'automation:PR Reviewer', + label: 'PR Reviewer', + }); + }); + + it('falls back to Automations when automatic has no specific name', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'system', + attributionSourceDisplayName: null, + attributionSourceExternalId: null, + attributedGithubLogin: null, + effectiveAuthorKind: 'roomote', + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }), + ).toEqual({ + key: AUTOMATIONS_USER_DIMENSION_KEY, + label: AUTOMATIONS_USER_DIMENSION_LABEL, + }); + }); + + it('keeps unlinked external identities distinct', () => { + expect( + getCanonicalTaskAttributionDimensionValue({ + attributionKind: 'unlinked_user', + attributedUserId: null, + attributionSourceKind: 'github', + attributionSourceDisplayName: 'octocat', + attributionSourceExternalId: 'octocat', + attributedGithubLogin: 'octocat', + effectiveAuthorKind: null, + effectiveAuthorUserId: null, + effectiveAuthorDisplayName: null, + effectiveAuthorGithubLogin: null, + userName: null, + userEmail: null, + }), + ).toEqual({ + key: 'unlinked:github:octocat', + label: 'octocat', + }); + }); +}); + +describe('resolveTaskAutomationDisplayName', () => { + it('names PR review and Conflict resolver automations', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.GithubPrReview, + }), + ).toBe('PR Reviewer'); + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.GithubPrConflictResolve, + }), + ).toBe('Resolve PR Conflicts'); + }); + + it('names scheduled suggestion automations from suggestionSource', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.SuggestedTasks, + payload: { suggestionSource: 'suggest_ideas' }, + }), + ).toBe('Suggest Ideas'); + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.SuggestedTasks, + payload: { suggestionSource: 'dependabot_triage' }, + }), + ).toBe('Triage Dependabot Alerts'); + }); + + it('returns null when no automation identity is available', () => { + expect( + resolveTaskAutomationDisplayName({ + type: CloudTaskType.StandardTask, + payload: { repo: 'owner/repo' } as never, + }), + ).toBeNull(); + expect(PRODUCT_NAME).toBeTruthy(); + }); +}); diff --git a/apps/web/src/lib/server/analytics-task-user-dimension.ts b/apps/web/src/lib/server/analytics-task-user-dimension.ts new file mode 100644 index 000000000..6e5574a01 --- /dev/null +++ b/apps/web/src/lib/server/analytics-task-user-dimension.ts @@ -0,0 +1,161 @@ +import { + type EffectiveAuthorKind, + PRODUCT_NAME, + type TaskAttributionKind, + type TaskAttributionSourceKind, +} from '@roomote/types'; +import { resolveTaskAttributionDisplay } from '@roomote/db/server'; +import { getUserDisplayName } from '@/lib/user-display-name'; + +export const AUTOMATIONS_USER_DIMENSION_KEY = 'automations'; +export const AUTOMATIONS_USER_DIMENSION_LABEL = 'Automations'; + +type AnalyticsUserDimensionValue = { + key: string; + label: string; + disambiguationLabel?: string; +}; + +function formatUserLabel( + user: { name: string | null; email: string | null } | null, +) { + return getUserDisplayName(user) || PRODUCT_NAME; +} + +function formatDisambiguatedUserLabel( + user: { name: string | null; email: string | null } | null, +) { + const displayName = getUserDisplayName(user); + + if (displayName && user?.email) { + return `${displayName} (${user.email})`; + } + + return formatUserLabel(user); +} + +function createDimensionValue( + key: string, + label: string, + disambiguationLabel?: string, +): AnalyticsUserDimensionValue { + return { + key, + label, + ...(disambiguationLabel && + disambiguationLabel !== label && + disambiguationLabel.trim() + ? { disambiguationLabel } + : {}), + }; +} + +function getCanonicalUserDimensionValue(user: { + id: string | null; + name: string | null; + email: string | null; +}): AnalyticsUserDimensionValue { + const label = formatUserLabel(user); + const normalizedEmail = user.email?.trim().toLowerCase() ?? null; + const key = user.id + ? `user:${user.id}` + : normalizedEmail + ? `email:${normalizedEmail}` + : PRODUCT_NAME; + + return createDimensionValue(key, label, formatDisambiguatedUserLabel(user)); +} + +/** + * Canonical "By User" dimension for task analytics. + * Matched product users keep their own series. Named automations each get + * their series (e.g. "PR Reviewer", "Suggest Ideas"). Other automatic work + * falls back to a shared Automations series. Residual unlinked identities + * keep their own label so they are not mislabeled as an automation. + */ +export function getCanonicalTaskAttributionDimensionValue(input: { + attributionKind: TaskAttributionKind | null; + attributedUserId: string | null; + attributionSourceKind: TaskAttributionSourceKind | null; + attributionSourceDisplayName: string | null; + attributionSourceExternalId: string | null; + attributedGithubLogin: string | null; + effectiveAuthorKind: EffectiveAuthorKind | null; + effectiveAuthorUserId: string | null; + effectiveAuthorDisplayName: string | null; + effectiveAuthorGithubLogin: string | null; + userName: string | null; + userEmail: string | null; +}): AnalyticsUserDimensionValue { + const attribution = resolveTaskAttributionDisplay( + { + attributionKind: input.attributionKind, + attributedUserId: input.attributedUserId, + attributionSourceKind: input.attributionSourceKind, + attributionSourceDisplayName: input.attributionSourceDisplayName, + attributionSourceExternalId: input.attributionSourceExternalId, + attributedGithubLogin: input.attributedGithubLogin, + effectiveAuthorKind: input.effectiveAuthorKind, + effectiveAuthorUserId: input.effectiveAuthorUserId, + effectiveAuthorDisplayName: input.effectiveAuthorDisplayName, + effectiveAuthorGithubLogin: input.effectiveAuthorGithubLogin, + }, + { + attributedUser: { + id: input.attributedUserId, + name: input.userName, + email: input.userEmail, + }, + }, + ); + + if (attribution.kind === 'matched_user') { + const matchedUserId = + input.effectiveAuthorKind === 'human' && input.effectiveAuthorUserId + ? input.effectiveAuthorUserId + : input.attributedUserId; + const matchedUserName = + input.effectiveAuthorKind === 'human' && + input.effectiveAuthorDisplayName && + input.effectiveAuthorUserId !== input.attributedUserId + ? input.effectiveAuthorDisplayName + : input.userName; + const matchedUserEmail = + matchedUserId && matchedUserId === input.attributedUserId + ? input.userEmail + : null; + + return getCanonicalUserDimensionValue({ + id: matchedUserId, + name: matchedUserName, + email: matchedUserEmail, + }); + } + + if (attribution.kind === 'automatic') { + const label = attribution.analyticsDisplay?.trim() || PRODUCT_NAME; + if ( + !label || + label === PRODUCT_NAME || + label === AUTOMATIONS_USER_DIMENSION_LABEL + ) { + return createDimensionValue( + AUTOMATIONS_USER_DIMENSION_KEY, + AUTOMATIONS_USER_DIMENSION_LABEL, + ); + } + + return createDimensionValue(`automation:${label}`, label); + } + + // Unlinked external identities still show their own label. + const unlinkedLabel = + attribution.analyticsDisplay?.trim() || AUTOMATIONS_USER_DIMENSION_LABEL; + const unlinkedKey = + input.attributionSourceExternalId != null && + input.attributionSourceExternalId.trim() + ? `unlinked:${input.attributionSourceKind}:${input.attributionSourceExternalId}` + : `unlinked:${unlinkedLabel}`; + + return createDimensionValue(unlinkedKey, unlinkedLabel); +} diff --git a/apps/web/src/lib/server/analytics.ts b/apps/web/src/lib/server/analytics.ts index 9a263bd95..07b9ef71f 100644 --- a/apps/web/src/lib/server/analytics.ts +++ b/apps/web/src/lib/server/analytics.ts @@ -17,10 +17,8 @@ import { syncGitHubPullRequestFactsForOrg } from '@roomote/sdk/server'; import { ALL_REPOSITORIES, CloudTaskType, - type EffectiveAuthorKind, type PullRequestStatus, PRODUCT_NAME, - type TaskAttributionKind, type TaskAttributionSourceKind, } from '@roomote/types'; import { @@ -33,7 +31,6 @@ import { desc, eq, inArray, - resolveTaskAttributionDisplay, } from '@roomote/db/server'; import { @@ -61,8 +58,8 @@ import { } from '@/types'; import type { UserAuthSuccess } from '@/types'; -import { getUserDisplayName } from '@/lib/user-display-name'; +import { getCanonicalTaskAttributionDimensionValue } from './analytics-task-user-dimension'; import { getLatestCloudJobsByTaskId } from './cloud-jobs'; import { getRepositories } from './source-control'; import { @@ -71,6 +68,7 @@ import { } from './pull-request-facts'; const SYSTEM_SOURCE = 'System'; +const AUTOMATION_SOURCE = 'Automation'; const UNKNOWN_REPO_LABEL = 'Unknown Repo'; const NO_PROJECT_LABEL = 'No Environment'; const NO_VALUE_LABEL = '—'; @@ -87,7 +85,7 @@ const SOURCE_ORDER = [ 'GitHub', 'Linear', 'Web', - 'Automation', + AUTOMATION_SOURCE, SYSTEM_SOURCE, ]; const PR_STATUS_ORDER = ['Closed', 'Draft', 'Open', 'Merged'] as const; @@ -291,24 +289,6 @@ function getSummaryPeriodCount( } } -function formatUserLabel( - user: { name: string | null; email: string | null } | null, -) { - return getUserDisplayName(user) || PRODUCT_NAME; -} - -function formatDisambiguatedUserLabel( - user: { name: string | null; email: string | null } | null, -) { - const displayName = getUserDisplayName(user); - - if (displayName && user?.email) { - return `${displayName} (${user.email})`; - } - - return formatUserLabel(user); -} - function createDimensionValue( key: string, label: string, @@ -329,97 +309,6 @@ function createLabelBackedDimensionValue(label: string) { return createDimensionValue(label, label); } -function getCanonicalUserDimensionValue(user: { - id: string | null; - name: string | null; - email: string | null; -}) { - const label = formatUserLabel(user); - const normalizedEmail = user.email?.trim().toLowerCase() ?? null; - const key = user.id - ? `user:${user.id}` - : normalizedEmail - ? `email:${normalizedEmail}` - : PRODUCT_NAME; - - return createDimensionValue(key, label, formatDisambiguatedUserLabel(user)); -} - -function getCanonicalTaskAttributionDimensionValue(input: { - attributionKind: TaskAttributionKind | null; - attributedUserId: string | null; - attributionSourceKind: TaskAttributionSourceKind | null; - attributionSourceDisplayName: string | null; - attributionSourceExternalId: string | null; - attributedGithubLogin: string | null; - effectiveAuthorKind: EffectiveAuthorKind | null; - effectiveAuthorUserId: string | null; - effectiveAuthorDisplayName: string | null; - effectiveAuthorGithubLogin: string | null; - userName: string | null; - userEmail: string | null; -}) { - const attribution = resolveTaskAttributionDisplay( - { - attributionKind: input.attributionKind, - attributedUserId: input.attributedUserId, - attributionSourceKind: input.attributionSourceKind, - attributionSourceDisplayName: input.attributionSourceDisplayName, - attributionSourceExternalId: input.attributionSourceExternalId, - attributedGithubLogin: input.attributedGithubLogin, - effectiveAuthorKind: input.effectiveAuthorKind, - effectiveAuthorUserId: input.effectiveAuthorUserId, - effectiveAuthorDisplayName: input.effectiveAuthorDisplayName, - effectiveAuthorGithubLogin: input.effectiveAuthorGithubLogin, - }, - { - attributedUser: { - id: input.attributedUserId, - name: input.userName, - email: input.userEmail, - }, - }, - ); - - if (attribution.kind === 'matched_user') { - const matchedUserId = - input.effectiveAuthorKind === 'human' && input.effectiveAuthorUserId - ? input.effectiveAuthorUserId - : input.attributedUserId; - const matchedUserName = - input.effectiveAuthorKind === 'human' && - input.effectiveAuthorDisplayName && - input.effectiveAuthorUserId !== input.attributedUserId - ? input.effectiveAuthorDisplayName - : input.userName; - const matchedUserEmail = - matchedUserId && matchedUserId === input.attributedUserId - ? input.userEmail - : null; - - return getCanonicalUserDimensionValue({ - id: matchedUserId, - name: matchedUserName, - email: matchedUserEmail, - }); - } - - if (attribution.kind === 'unlinked_user') { - const key = - input.effectiveAuthorKind === 'human' && - !input.effectiveAuthorUserId && - attribution.analyticsDisplay - ? `unlinked:${attribution.sourceKind}:${attribution.analyticsDisplay}` - : input.attributionSourceExternalId - ? `unlinked:${input.attributionSourceKind}:${input.attributionSourceExternalId}` - : `unlinked:${attribution.analyticsDisplay}`; - - return createDimensionValue(key, attribution.analyticsDisplay); - } - - return createLabelBackedDimensionValue(attribution.analyticsDisplay); -} - function formatRepositoryLabel(repositoryName: string) { return repositoryName === ALL_REPOSITORIES ? ALL_REPOS_LABEL : repositoryName; } @@ -441,14 +330,12 @@ function mapTaskSource(taskType: CloudTaskType | string | null | undefined) { return 'Linear'; } - if ( - taskType === CloudTaskType.StandardTask || - taskType === CloudTaskType.SuggestedTasks || - taskType === CloudTaskType.LegacyOnboardingSuggestions - ) { + if (taskType === CloudTaskType.StandardTask) { return 'Web'; } + // Suggestion-generated and onboarding tasks are automation-initiated and + // are labeled via the attribution source kind before this fallback runs. return SYSTEM_SOURCE; } @@ -1073,7 +960,10 @@ async function getTaskAnalyticsRows( const taskRows = await getTaskAnalyticsBaseRows(auth, timePeriod, now); return taskRows.map((task) => { - const sourceLabel = mapTaskSource(task.latestJobType); + const sourceLabel = + task.attributionSourceKind === 'automation' + ? AUTOMATION_SOURCE + : mapTaskSource(task.latestJobType); return { id: task.id, @@ -1107,6 +997,7 @@ type TaskAnalyticsBaseRow = { title: string; timestamp: Date; latestJobType: CloudTaskType | null; + attributionSourceKind: TaskAttributionSourceKind | null; projectLabel: string; userDimension: AnalyticsDimensionValue; }; @@ -1237,6 +1128,7 @@ async function getTaskAnalyticsBaseRows( title: task.title, timestamp: task.timestamp, latestJobType: latestJob?.type ?? null, + attributionSourceKind: task.attributionSourceKind, projectLabel: getProjectLabel(task.repositoryName, environmentId), userDimension, } satisfies TaskAnalyticsBaseRow; diff --git a/apps/web/src/lib/server/task-creator-filter-condition.ts b/apps/web/src/lib/server/task-creator-filter-condition.ts new file mode 100644 index 000000000..40de99e69 --- /dev/null +++ b/apps/web/src/lib/server/task-creator-filter-condition.ts @@ -0,0 +1,82 @@ +import { PRODUCT_NAME } from '@roomote/types'; +import { and, eq, isNull, or, tasks, type SQL } from '@roomote/db/server'; + +import { + AUTOMATIONS_CREATOR_FILTER_LABEL, + parseCreatorFilterValue, +} from '@/lib/task-creator-filter'; + +/** + * Tasks initiated by an automation rather than a human: either the authorship + * rules resolved the effective author to the product, or no authorship rules + * ran and the attribution snapshot is `automatic`. + */ +export function automationInitiatedTaskCondition(): SQL { + return or( + eq(tasks.effectiveAuthorKind, 'roomote'), + and( + isNull(tasks.effectiveAuthorKind), + eq(tasks.attributionKind, 'automatic'), + ), + )!; +} + +/** + * Automation-initiated tasks that carry no specific automation name and land + * in the shared "Automations" bucket. Mirrors the analytics user-dimension + * bucketing in `analytics-task-user-dimension.ts`. + */ +function unnamedAutomationCondition(): SQL { + return or( + isNull(tasks.attributionSourceDisplayName), + eq(tasks.attributionSourceDisplayName, ''), + eq(tasks.attributionSourceDisplayName, PRODUCT_NAME), + eq(tasks.attributionSourceDisplayName, AUTOMATIONS_CREATOR_FILTER_LABEL), + )!; +} + +/** + * Canonical SQL predicate for a task "creator" filter value: a matched + * product user, an unlinked external identity, a named automation, or the + * shared Automations bucket. + */ +export function buildTaskCreatorFilterCondition(value: string): SQL { + const creatorFilter = parseCreatorFilterValue(value); + + switch (creatorFilter.kind) { + case 'automations': + return and( + automationInitiatedTaskCondition(), + unnamedAutomationCondition(), + )!; + case 'automation': + return and( + automationInitiatedTaskCondition(), + eq(tasks.attributionSourceDisplayName, creatorFilter.label), + )!; + case 'unlinked_user': + return and( + eq(tasks.attributionKind, 'unlinked_user'), + or( + isNull(tasks.effectiveAuthorKind), + and( + eq(tasks.effectiveAuthorKind, 'human'), + isNull(tasks.effectiveAuthorUserId), + ), + ), + eq(tasks.attributionSourceKind, creatorFilter.sourceKind), + eq(tasks.attributionSourceExternalId, creatorFilter.sourceExternalId), + )!; + case 'matched_user': + return or( + and( + eq(tasks.effectiveAuthorKind, 'human'), + eq(tasks.effectiveAuthorUserId, creatorFilter.userId), + ), + and( + isNull(tasks.effectiveAuthorKind), + eq(tasks.attributedUserId, creatorFilter.userId), + ), + )!; + } +} diff --git a/apps/web/src/lib/server/tasks.ts b/apps/web/src/lib/server/tasks.ts index 5e82cddd5..4194dff7d 100644 --- a/apps/web/src/lib/server/tasks.ts +++ b/apps/web/src/lib/server/tasks.ts @@ -15,8 +15,6 @@ import { desc, inArray, lt, - isNull, - or, resolveTaskAttributionDisplay, sql, isVisibleTask, @@ -30,11 +28,11 @@ import { HAS_PULL_REQUEST_FILTER_VALUE, } from '@/types'; import { getTaskCategoryById } from '@/lib'; -import { parseCreatorFilterValue } from '@/lib/task-creator-filter'; import { type SimpleUser, getUsersById } from './users'; import { type SimpleCloudJob, getLatestCloudJobsByTaskId } from './cloud-jobs'; import { getTaskModelDisplayNameMap } from './task-models'; +import { buildTaskCreatorFilterCondition } from './task-creator-filter-condition'; /** * Task Filter Conditions @@ -94,50 +92,7 @@ const getTaskFilterConditions = async ({ filters }: { filters: Filter[] }) => { for (const filter of filters) { switch (filter.type) { case 'userId': { - const creatorFilter = parseCreatorFilterValue(filter.value); - - if (creatorFilter.kind === 'roomote') { - conditions.push( - or( - eq(tasks.effectiveAuthorKind, 'roomote'), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributionKind, 'automatic'), - ), - )!, - ); - } else if (creatorFilter.kind === 'unlinked_user') { - conditions.push( - and( - eq(tasks.attributionKind, 'unlinked_user'), - or( - isNull(tasks.effectiveAuthorKind), - and( - eq(tasks.effectiveAuthorKind, 'human'), - isNull(tasks.effectiveAuthorUserId), - ), - ), - eq(tasks.attributionSourceKind, creatorFilter.sourceKind), - eq( - tasks.attributionSourceExternalId, - creatorFilter.sourceExternalId, - ), - )!, - ); - } else { - conditions.push( - or( - and( - eq(tasks.effectiveAuthorKind, 'human'), - eq(tasks.effectiveAuthorUserId, creatorFilter.userId), - ), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributedUserId, creatorFilter.userId), - ), - )!, - ); - } + conditions.push(buildTaskCreatorFilterCondition(filter.value)); break; } diff --git a/apps/web/src/lib/task-creator-filter.test.ts b/apps/web/src/lib/task-creator-filter.test.ts index 578b04b60..bae58d4b6 100644 --- a/apps/web/src/lib/task-creator-filter.test.ts +++ b/apps/web/src/lib/task-creator-filter.test.ts @@ -1,32 +1,38 @@ import { describe, expect, it } from 'vitest'; import { - ROOMOTE_CREATOR_FILTER_VALUE, - buildCreatorFilterValue, + AUTOMATIONS_CREATOR_FILTER_VALUE, + buildAutomationCreatorFilterValue, + buildMatchedUserCreatorFilterValue, + buildUnlinkedCreatorFilterValue, parseCreatorFilterValue, } from './task-creator-filter'; describe('task creator filter helpers', () => { - it('round-trips the Roomote creator filter value', () => { - const value = buildCreatorFilterValue({ - effectiveAuthorKind: 'roomote', - userId: null, - attributionKind: 'automatic', - attributionSourceKind: null, - attributionSourceExternalId: null, + it('round-trips the shared Automations bucket value', () => { + expect(parseCreatorFilterValue(AUTOMATIONS_CREATOR_FILTER_VALUE)).toEqual({ + kind: 'automations', }); + }); - expect(value).toBe(ROOMOTE_CREATOR_FILTER_VALUE); - expect(parseCreatorFilterValue(value ?? '')).toEqual({ - kind: 'roomote', + it('round-trips named automation filter values', () => { + const value = buildAutomationCreatorFilterValue('PR Reviewer'); + + expect(value).toBe('automation:PR%20Reviewer'); + expect(parseCreatorFilterValue(value)).toEqual({ + kind: 'automation', + label: 'PR Reviewer', + }); + }); + + it('falls back to the Automations bucket for empty automation labels', () => { + expect(parseCreatorFilterValue('automation:')).toEqual({ + kind: 'automations', }); }); it('round-trips unlinked creator filter values', () => { - const value = buildCreatorFilterValue({ - effectiveAuthorKind: 'human', - userId: null, - attributionKind: 'unlinked_user', + const value = buildUnlinkedCreatorFilterValue({ attributionSourceKind: 'slack', attributionSourceExternalId: 'U123', }); @@ -39,7 +45,17 @@ describe('task creator filter helpers', () => { }); }); + it('returns null unlinked values when the identity is incomplete', () => { + expect( + buildUnlinkedCreatorFilterValue({ + attributionSourceKind: 'slack', + attributionSourceExternalId: null, + }), + ).toBeNull(); + }); + it('treats matched user ids as opaque filter values', () => { + expect(buildMatchedUserCreatorFilterValue('user_123')).toBe('user_123'); expect(parseCreatorFilterValue('user_123')).toEqual({ kind: 'matched_user', userId: 'user_123', diff --git a/apps/web/src/lib/task-creator-filter.ts b/apps/web/src/lib/task-creator-filter.ts index 37e75d952..c867a8c56 100644 --- a/apps/web/src/lib/task-creator-filter.ts +++ b/apps/web/src/lib/task-creator-filter.ts @@ -1,16 +1,22 @@ import { - type EffectiveAuthorKind, TASK_ATTRIBUTION_SOURCE_KINDS, - type TaskAttributionKind, type TaskAttributionSourceKind, } from '@roomote/types'; const UNLINKED_CREATOR_FILTER_PREFIX = 'unlinked:'; -export const ROOMOTE_CREATOR_FILTER_VALUE = 'author:roomote'; +const AUTOMATION_CREATOR_FILTER_PREFIX = 'automation:'; + +/** Shared bucket for automation-initiated tasks without a specific name. */ +export const AUTOMATIONS_CREATOR_FILTER_VALUE = 'automations'; +export const AUTOMATIONS_CREATOR_FILTER_LABEL = 'Automations'; type ParsedCreatorFilterValue = | { - kind: 'roomote'; + kind: 'automations'; + } + | { + kind: 'automation'; + label: string; } | { kind: 'matched_user'; @@ -22,40 +28,56 @@ type ParsedCreatorFilterValue = sourceExternalId: string; }; -export function buildCreatorFilterValue(input: { - effectiveAuthorKind?: EffectiveAuthorKind | null | undefined; - userId?: string | null | undefined; - attributionKind: TaskAttributionKind | null | undefined; +export function buildMatchedUserCreatorFilterValue(userId: string): string { + return userId; +} + +export function buildUnlinkedCreatorFilterValue(input: { attributionSourceKind: TaskAttributionSourceKind | null | undefined; attributionSourceExternalId: string | null | undefined; }): string | null { - if (input.effectiveAuthorKind === 'roomote') { - return ROOMOTE_CREATOR_FILTER_VALUE; + if (!input.attributionSourceKind || !input.attributionSourceExternalId) { + return null; } - if (input.userId) { - return input.userId; - } - - if ( - input.attributionKind === 'unlinked_user' && - input.attributionSourceKind && - input.attributionSourceExternalId - ) { - return `${UNLINKED_CREATOR_FILTER_PREFIX}${input.attributionSourceKind}:${encodeURIComponent( - input.attributionSourceExternalId, - )}`; - } + return `${UNLINKED_CREATOR_FILTER_PREFIX}${input.attributionSourceKind}:${encodeURIComponent( + input.attributionSourceExternalId, + )}`; +} - return null; +export function buildAutomationCreatorFilterValue(label: string): string { + return `${AUTOMATION_CREATOR_FILTER_PREFIX}${encodeURIComponent(label)}`; } export function parseCreatorFilterValue( value: string, ): ParsedCreatorFilterValue { - if (value === ROOMOTE_CREATOR_FILTER_VALUE) { + if (value === AUTOMATIONS_CREATOR_FILTER_VALUE) { + return { + kind: 'automations', + }; + } + + if (value.startsWith(AUTOMATION_CREATOR_FILTER_PREFIX)) { + let label: string; + + try { + label = decodeURIComponent( + value.slice(AUTOMATION_CREATOR_FILTER_PREFIX.length), + ); + } catch { + label = ''; + } + + if (label) { + return { + kind: 'automation', + label, + }; + } + return { - kind: 'roomote', + kind: 'automations', }; } diff --git a/apps/web/src/trpc/commands/filters/index.ts b/apps/web/src/trpc/commands/filters/index.ts index 468c09194..7c833293a 100644 --- a/apps/web/src/trpc/commands/filters/index.ts +++ b/apps/web/src/trpc/commands/filters/index.ts @@ -27,10 +27,16 @@ import { import type { TimePeriodFilter, UserAuthSuccess } from '@/types'; import { getTaskCategoryById, getUserDisplayName } from '@/lib'; import { - ROOMOTE_CREATOR_FILTER_VALUE, - buildCreatorFilterValue, - parseCreatorFilterValue, + AUTOMATIONS_CREATOR_FILTER_LABEL, + AUTOMATIONS_CREATOR_FILTER_VALUE, + buildAutomationCreatorFilterValue, + buildMatchedUserCreatorFilterValue, + buildUnlinkedCreatorFilterValue, } from '@/lib/task-creator-filter'; +import { + automationInitiatedTaskCondition, + buildTaskCreatorFilterCondition, +} from '@/lib/server/task-creator-filter-condition'; type FilterOption = { value: string; label: string; subLabel?: string }; @@ -183,40 +189,27 @@ export async function getUsersOnlyForFilterCommand( tasks.attributionSourceExternalId, ); - const roomoteExistsQuery = db.select({ id: tasks.id }).from(tasks); + const automationsQuery = db + .select({ + sourceDisplayName: tasks.attributionSourceDisplayName, + }) + .from(tasks); - const roomoteResult = await ( + const automationResults = await ( taskCategory - ? roomoteExistsQuery.leftJoin(cloudJobs, eq(cloudJobs.taskId, tasks.id)) - : roomoteExistsQuery + ? automationsQuery.leftJoin(cloudJobs, eq(cloudJobs.taskId, tasks.id)) + : automationsQuery ) - .where( - and( - ...whereConditions, - or( - eq(tasks.effectiveAuthorKind, 'roomote'), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributionKind, 'automatic'), - ), - ), - ), - ) - .limit(1); + .where(and(...whereConditions, automationInitiatedTaskCondition())) + .groupBy(tasks.attributionSourceDisplayName) + .orderBy(tasks.attributionSourceDisplayName); const matchedUserOptions: FilterOption[] = [ ...matchedUserResults, ...legacyMatchedUserResults, ] .map((result) => ({ - value: - buildCreatorFilterValue({ - effectiveAuthorKind: 'human', - userId: result.userId, - attributionKind: 'matched_user', - attributionSourceKind: null, - attributionSourceExternalId: null, - }) ?? result.userId!, + value: buildMatchedUserCreatorFilterValue(result.userId!), label: getUserDisplayName({ name: result.username, @@ -231,10 +224,7 @@ export async function getUsersOnlyForFilterCommand( const unlinkedUserOptions: FilterOption[] = unlinkedUserResults.flatMap( (result) => { - const value = buildCreatorFilterValue({ - effectiveAuthorKind: 'human', - userId: null, - attributionKind: result.attributionKind, + const value = buildUnlinkedCreatorFilterValue({ attributionSourceKind: result.sourceKind, attributionSourceExternalId: result.sourceExternalId, }); @@ -253,20 +243,43 @@ export async function getUsersOnlyForFilterCommand( }, ); - const roomoteOptions: FilterOption[] = - roomoteResult.length > 0 + const namedAutomationLabels = new Set(); + let hasUnnamedAutomations = false; + + for (const result of automationResults) { + const label = result.sourceDisplayName?.trim(); + + if ( + !label || + label === PRODUCT_NAME || + label === AUTOMATIONS_CREATOR_FILTER_LABEL + ) { + hasUnnamedAutomations = true; + } else { + namedAutomationLabels.add(label); + } + } + + const automationOptions: FilterOption[] = [ + ...[...namedAutomationLabels].map((label) => ({ + value: buildAutomationCreatorFilterValue(label), + label, + subLabel: getAttributionSourceLabel('automation'), + })), + ...(hasUnnamedAutomations ? [ { - value: ROOMOTE_CREATOR_FILTER_VALUE, - label: PRODUCT_NAME, + value: AUTOMATIONS_CREATOR_FILTER_VALUE, + label: AUTOMATIONS_CREATOR_FILTER_LABEL, }, ] - : []; + : []), + ]; return [ ...matchedUserOptions, ...unlinkedUserOptions, - ...roomoteOptions, + ...automationOptions, ].sort((left, right) => left.label.localeCompare(right.label)); } @@ -297,49 +310,7 @@ export async function getRepositoriesForFilterCommand( const taskCategory = getTaskCategoryById(input.category); if (input.userId) { - const creatorFilter = parseCreatorFilterValue(input.userId); - - if (creatorFilter.kind === 'roomote') { - conditions.push( - or( - eq(tasks.effectiveAuthorKind, 'roomote'), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributionKind, 'automatic'), - ), - )!, - ); - } else if (creatorFilter.kind === 'unlinked_user') { - conditions.push(eq(tasks.attributionKind, 'unlinked_user')); - conditions.push( - or( - isNull(tasks.effectiveAuthorKind), - and( - eq(tasks.effectiveAuthorKind, 'human'), - isNull(tasks.effectiveAuthorUserId), - ), - )!, - ); - conditions.push( - eq(tasks.attributionSourceKind, creatorFilter.sourceKind), - ); - conditions.push( - eq(tasks.attributionSourceExternalId, creatorFilter.sourceExternalId), - ); - } else { - conditions.push( - or( - and( - eq(tasks.effectiveAuthorKind, 'human'), - eq(tasks.effectiveAuthorUserId, creatorFilter.userId), - ), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributedUserId, creatorFilter.userId), - ), - )!, - ); - } + conditions.push(buildTaskCreatorFilterCondition(input.userId)); } if (taskCategory) { @@ -408,49 +379,7 @@ export async function getPullRequestsForFilterCommand( } if (input.userId) { - const creatorFilter = parseCreatorFilterValue(input.userId); - - if (creatorFilter.kind === 'roomote') { - whereConditions.push( - or( - eq(tasks.effectiveAuthorKind, 'roomote'), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributionKind, 'automatic'), - ), - )!, - ); - } else if (creatorFilter.kind === 'unlinked_user') { - whereConditions.push(eq(tasks.attributionKind, 'unlinked_user')); - whereConditions.push( - or( - isNull(tasks.effectiveAuthorKind), - and( - eq(tasks.effectiveAuthorKind, 'human'), - isNull(tasks.effectiveAuthorUserId), - ), - )!, - ); - whereConditions.push( - eq(tasks.attributionSourceKind, creatorFilter.sourceKind), - ); - whereConditions.push( - eq(tasks.attributionSourceExternalId, creatorFilter.sourceExternalId), - ); - } else { - whereConditions.push( - or( - and( - eq(tasks.effectiveAuthorKind, 'human'), - eq(tasks.effectiveAuthorUserId, creatorFilter.userId), - ), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributedUserId, creatorFilter.userId), - ), - )!, - ); - } + whereConditions.push(buildTaskCreatorFilterCondition(input.userId)); } if (input.timePeriod && input.timePeriod !== 'all') { @@ -545,49 +474,7 @@ export async function getModelsForFilterCommand( } if (input.userId) { - const creatorFilter = parseCreatorFilterValue(input.userId); - - if (creatorFilter.kind === 'roomote') { - conditions.push( - or( - eq(tasks.effectiveAuthorKind, 'roomote'), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributionKind, 'automatic'), - ), - )!, - ); - } else if (creatorFilter.kind === 'unlinked_user') { - conditions.push(eq(tasks.attributionKind, 'unlinked_user')); - conditions.push( - or( - isNull(tasks.effectiveAuthorKind), - and( - eq(tasks.effectiveAuthorKind, 'human'), - isNull(tasks.effectiveAuthorUserId), - ), - )!, - ); - conditions.push( - eq(tasks.attributionSourceKind, creatorFilter.sourceKind), - ); - conditions.push( - eq(tasks.attributionSourceExternalId, creatorFilter.sourceExternalId), - ); - } else { - conditions.push( - or( - and( - eq(tasks.effectiveAuthorKind, 'human'), - eq(tasks.effectiveAuthorUserId, creatorFilter.userId), - ), - and( - isNull(tasks.effectiveAuthorKind), - eq(tasks.attributedUserId, creatorFilter.userId), - ), - )!, - ); - } + conditions.push(buildTaskCreatorFilterCondition(input.userId)); } if (taskCategory) { diff --git a/packages/cloud-agents/src/server/cloud-job-queue.ts b/packages/cloud-agents/src/server/cloud-job-queue.ts index cfea38db0..dd5b67887 100644 --- a/packages/cloud-agents/src/server/cloud-job-queue.ts +++ b/packages/cloud-agents/src/server/cloud-job-queue.ts @@ -28,6 +28,8 @@ import { resolveSourceControlProviderFromPayload, TASK_TIMEOUT_MS, type SourceControlProvider, + isKnownAutomationTaskType, + resolveTaskAutomationDisplayName, } from '@roomote/types'; import { Env } from '@roomote/env'; import { @@ -127,12 +129,17 @@ async function resolvePersistedTaskAttributionSnapshot( task: CloudTask, ): Promise { if (task.attributionOverride?.kind === 'automatic') { + const overrideDisplayName = + typeof task.attributionOverride.displayName === 'string' + ? task.attributionOverride.displayName.trim() + : ''; return { attributionKind: 'automatic', attributedUserId: null, attributionSourceKind: task.attributionOverride.sourceKind ?? 'automation', - attributionSourceDisplayName: null, + attributionSourceDisplayName: + overrideDisplayName || resolveTaskAutomationDisplayName(task) || null, attributionSourceExternalId: null, attributedGithubLogin: null, attributedGithubUserId: null, @@ -713,8 +720,17 @@ export async function enqueueCloudTask( await assertDeploymentIsActive(); - if (!userId && !githubUserId) { - throw new Error('A cloud task must have a userId or a githubUserId.'); + // Automation-initiated tasks carry no stamped user id. Their attribution is + // the automation name, and credential resolution happens lazily at job-token + // mint time via `resolveCredentialUserIdForCloudJob`. + const isAutomationInitiated = + task.attributionOverride?.kind === 'automatic' || + isKnownAutomationTaskType(task.type); + + if (!userId && !githubUserId && !isAutomationInitiated) { + throw new Error( + 'A cloud task must have a userId or a githubUserId, or be initiated by a known automation.', + ); } if (userId) { diff --git a/packages/cloud-agents/src/server/index.ts b/packages/cloud-agents/src/server/index.ts index a70833ce8..84e58c19e 100644 --- a/packages/cloud-agents/src/server/index.ts +++ b/packages/cloud-agents/src/server/index.ts @@ -25,7 +25,7 @@ export * from './llm-task-title'; export * from './mcp-self-setup'; export * from './mcp-tool-client'; export * from './non-task-provider-usage'; -export * from './resolve-user-id-for-cloud-job'; +export * from './resolve-credential-user-id-for-cloud-job'; export * from './router'; export * from './slack-question-channel-suggestions'; export * from './suggestion-routing'; diff --git a/packages/cloud-agents/src/server/resolve-credential-user-id-for-cloud-job.ts b/packages/cloud-agents/src/server/resolve-credential-user-id-for-cloud-job.ts new file mode 100644 index 000000000..1e7d0a281 --- /dev/null +++ b/packages/cloud-agents/src/server/resolve-credential-user-id-for-cloud-job.ts @@ -0,0 +1,36 @@ +import type { CloudJob } from '@roomote/db'; +import { db, asc, isNull, users } from '@roomote/db/server'; + +/** + * Resolves the user whose credentials a cloud job should use for + * authentication purposes (e.g. job tokens, GitHub tokens, MCP OAuth + * lookups). + * + * This is credential plumbing, never attribution: task/analytics attribution + * comes from the attribution snapshot columns (`attributionKind`, + * `attributedUserId`, `attributionSourceDisplayName`), and automation-initiated + * jobs intentionally carry a null `userId`. + * + * Prefers `userId` (the human who created the job) and falls back to the + * first active deployment user for automation-initiated jobs and + * integration-triggered jobs that predate a user mapping. Callers that track a + * live human actor should prefer `cloudJobs.actingUserId` before calling this. + * + * @returns The resolved user ID, or `null` if no user could be determined. + */ +export async function resolveCredentialUserIdForCloudJob( + cloudJob: Pick, +): Promise { + if (cloudJob.userId) { + return cloudJob.userId; + } + + const user = await db.query.users.findFirst({ + where: isNull(users.deletedAt), + // Keep the fallback stable so tokens minted earlier in the job lifecycle resolve + // to the same user during later auth checks. + orderBy: [asc(users.id)], + }); + + return user?.id ?? null; +} diff --git a/packages/cloud-agents/src/server/resolve-user-id-for-cloud-job.ts b/packages/cloud-agents/src/server/resolve-user-id-for-cloud-job.ts deleted file mode 100644 index 79a0d5de5..000000000 --- a/packages/cloud-agents/src/server/resolve-user-id-for-cloud-job.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CloudJob } from '@roomote/db'; -import { db, asc, isNull, users } from '@roomote/db/server'; - -/** - * Resolves the user ID that should be associated with a cloud job for - * authentication purposes (e.g., job tokens, GitHub tokens). - * - * Prefers `userId` (the user who created the job), then falls back to the - * first active deployment user for integration-triggered jobs that predate a - * user mapping. - * - * @returns The resolved user ID, or `null` if no user could be determined. - */ -export async function resolveUserIdForCloudJob( - cloudJob: Pick, -): Promise { - if (cloudJob.userId) { - return cloudJob.userId; - } - - const user = await db.query.users.findFirst({ - where: isNull(users.deletedAt), - // Keep the fallback stable so tokens minted earlier in the job lifecycle resolve - // to the same user during later auth checks. - orderBy: [asc(users.id)], - }); - - return user?.id ?? null; -} diff --git a/packages/cloud-agents/src/server/workflows/utils.ts b/packages/cloud-agents/src/server/workflows/utils.ts index 6f908f2c8..fec95d02f 100644 --- a/packages/cloud-agents/src/server/workflows/utils.ts +++ b/packages/cloud-agents/src/server/workflows/utils.ts @@ -236,7 +236,14 @@ function buildPrBodyAttributionLine({ : defaultFollowUpInstruction; if (attribution.authorKind === 'roomote') { - return `> Created by Roomote. ${instruction}`; + // `productDisplay` carries the initiating automation's name (for example + // "PR Reviewer") and falls back to the product name for unnamed + // automation work. + const safeAutomationName = escapeValue( + attribution.productDisplay?.trim() || PRODUCT_NAME, + ); + + return `> Created by ${safeAutomationName}. ${instruction}`; } const safeUserName = escapeValue( diff --git a/packages/db/src/lib/task-attribution.test.ts b/packages/db/src/lib/task-attribution.test.ts index 7baea96b0..47284e225 100644 --- a/packages/db/src/lib/task-attribution.test.ts +++ b/packages/db/src/lib/task-attribution.test.ts @@ -222,4 +222,66 @@ describe('buildTaskAttributionSnapshot', () => { attributedGithubUserId: 12345, }); }); + + it('attributes GitHub PR review background tasks to the PR Reviewer automation', async () => { + const snapshot = await buildTaskAttributionSnapshot(db, { + userId: null, + type: CloudTaskType.GithubPrReview, + githubLogin: 'openmote[bot]', + githubUserId: 1, + payload: { + repo: 'owner/repo', + prNumber: 1, + prTitle: 'Test', + prUrl: 'https://example.com/pr/1', + headSha: 'abc', + branchName: 'feature', + }, + } satisfies CloudTask); + + expect(snapshot).toMatchObject({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + }); + }); + + it('attributes scheduled suggestion tasks to the matching automation label', async () => { + const snapshot = await buildTaskAttributionSnapshot(db, { + userId: null, + type: CloudTaskType.SuggestedTasks, + payload: { + repo: 'owner/repo', + description: 'suggest', + suggestionSource: 'suggest_ideas', + }, + } satisfies CloudTask); + + expect(snapshot).toMatchObject({ + attributionKind: 'automatic', + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'Suggest Ideas', + }); + }); +}); + +describe('resolveTaskAttributionDisplay with automation names', () => { + it('surfaces automatic display names instead of the product brand', () => { + expect( + resolveTaskAttributionDisplay({ + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: 'PR Reviewer', + attributionSourceExternalId: null, + attributedGithubLogin: null, + attributedGithubUserId: null, + }), + ).toMatchObject({ + kind: 'automatic', + productDisplay: 'PR Reviewer', + analyticsDisplay: 'PR Reviewer', + }); + }); }); diff --git a/packages/db/src/lib/task-attribution.ts b/packages/db/src/lib/task-attribution.ts index 51f5bbc73..51cbff118 100644 --- a/packages/db/src/lib/task-attribution.ts +++ b/packages/db/src/lib/task-attribution.ts @@ -10,6 +10,8 @@ import { PRODUCT_NAME, getCommunicationProviderFromTaskPayload, getUserDisplayName, + isKnownAutomationTaskType, + resolveTaskAutomationDisplayName, } from '@roomote/types'; import { and, desc, eq } from 'drizzle-orm'; @@ -152,13 +154,16 @@ export function resolveTaskAttributionDisplay( ); if (effectiveAuthorKind === 'roomote') { + const automationDisplay = + normalizeNullableString(snapshot.attributionSourceDisplayName) || + PRODUCT_NAME; return { authorKind: 'roomote', kind: 'automatic', sourceKind: snapshot.attributionSourceKind ?? 'system', githubDisplay: null, - productDisplay: PRODUCT_NAME, - analyticsDisplay: PRODUCT_NAME, + productDisplay: automationDisplay, + analyticsDisplay: automationDisplay, assigneeGithubLogin: effectivePrOwnerKind === 'specific_user' ? effectivePrOwnerGithubLogin @@ -222,13 +227,17 @@ export function resolveTaskAttributionDisplay( }; } + const automationDisplay = + normalizeNullableString(snapshot.attributionSourceDisplayName) || + PRODUCT_NAME; + return { authorKind: 'roomote', kind: 'automatic', sourceKind, githubDisplay: null, - productDisplay: PRODUCT_NAME, - analyticsDisplay: PRODUCT_NAME, + productDisplay: automationDisplay, + analyticsDisplay: automationDisplay, assigneeGithubLogin: null, }; } @@ -512,11 +521,32 @@ export async function buildTaskAttributionSnapshot( const sourceKind = inferSourceKind(task); const sourceDisplayName = inferSourceDisplayName(task); const sourceExternalId = inferSourceExternalId(task); + const automationDisplayName = resolveTaskAutomationDisplayName(task); const githubSourceIdentity = sourceKind === 'github' ? getTaskGithubIdentity(task) : { githubLogin: null, githubUserId: null }; + // Webhook/background automation task types are product-owned, even when a + // GitHub/GitLab actor presence is available on the collate path. + if ( + task.type === CloudTaskType.GithubPrReview || + task.type === CloudTaskType.GithubPrReviewSync || + task.type === CloudTaskType.GithubPrReviewFollowUp || + task.type === CloudTaskType.GithubPrConflictResolve + ) { + return { + attributionKind: 'automatic', + attributedUserId: null, + attributionSourceKind: 'automation', + attributionSourceDisplayName: + automationDisplayName ?? sourceDisplayName ?? 'Automations', + attributionSourceExternalId: null, + attributedGithubLogin: null, + attributedGithubUserId: null, + }; + } + let attributedUserId: string | null = null; if ( @@ -607,8 +637,11 @@ export async function buildTaskAttributionSnapshot( return { attributionKind: 'automatic', attributedUserId: null, - attributionSourceKind: sourceKind, - attributionSourceDisplayName: sourceDisplayName, + attributionSourceKind: + isKnownAutomationTaskType(task.type) || automationDisplayName + ? 'automation' + : sourceKind, + attributionSourceDisplayName: automationDisplayName ?? sourceDisplayName, attributionSourceExternalId: sourceExternalId, attributedGithubLogin: null, attributedGithubUserId: null, diff --git a/packages/sdk/src/server/lib/auth/create-job-token.ts b/packages/sdk/src/server/lib/auth/create-job-token.ts index 717cd447e..4221c4563 100644 --- a/packages/sdk/src/server/lib/auth/create-job-token.ts +++ b/packages/sdk/src/server/lib/auth/create-job-token.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import type { AuthTokenContext } from '@roomote/types'; import * as Auth from '@roomote/auth'; import { db, cloudJobs, eq } from '@roomote/db/server'; -import { resolveUserIdForCloudJob } from '@roomote/cloud-agents/server'; +import { resolveCredentialUserIdForCloudJob } from '@roomote/cloud-agents/server'; export const createJobTokenInputSchema = Auth.createJobTokenOptionsSchema.omit({ userId: true, @@ -22,7 +22,7 @@ export const createJobToken = async ( throw new Error(`Cloud job ${input.cloudJobId} not found`); } - const userId = await resolveUserIdForCloudJob(cloudJob); + const userId = await resolveCredentialUserIdForCloudJob(cloudJob); if (!userId) { console.error( diff --git a/packages/types/src/__tests__/task-automation-display.test.ts b/packages/types/src/__tests__/task-automation-display.test.ts new file mode 100644 index 000000000..4395d6af4 --- /dev/null +++ b/packages/types/src/__tests__/task-automation-display.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { + BACKGROUND_AUTOMATION_KEYS, + CloudTaskType, + TASK_SUGGESTION_SOURCES, + getTriggerableBackgroundAutomationDescriptorByKey, + isKnownAutomationTaskType, + resolveTaskAutomationDisplayName, +} from '../index'; + +describe('resolveTaskAutomationDisplayName', () => { + it('names every scheduled suggestion source distinctly', () => { + const labels = TASK_SUGGESTION_SOURCES.map((suggestionSource) => + resolveTaskAutomationDisplayName({ + type: CloudTaskType.SuggestedTasks, + payload: { suggestionSource }, + }), + ); + + for (const label of labels) { + expect(label).toBeTruthy(); + } + + // Every suggestion source must resolve to its own automation series so a + // newly added automation cannot silently reuse another automation's name. + expect(new Set(labels).size).toBe(TASK_SUGGESTION_SOURCES.length); + + // Labels are embedded in the PR-body attribution line ("> Created by + //