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 f0f2461d..bb06671b 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 6e942f38..f51c597e 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 601cc247..48b4ffb6 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 d600f5a5..14cbd672 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 f08250bc..4245df64 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 ce1ded77..be7bde16 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 bdfd13bb..562393f2 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 34421225..7f9eb649 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 74278160..796f2aea 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 e91f8c54..6f83b217 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 8b50ee07..f524bd7f 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 d10701ef..af8ac8dc 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 58e43c6e..57cacfcd 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 2e2d3fde..f7619a1b 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 57c05faa..1f7747d2 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 4f5219e8..82738429 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 17e35738..aa892905 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 4b9b7c5d..65db6887 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 55650e24..9b5e61cd 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 260653a6..2f9ad8a8 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 02512df1..fa7a50b6 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 96ebec55..29d9b199 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 ec9c4d57..77b9b12c 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 3067b4d6..12c9ca9f 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 53412e3f..a1a7e2d9 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 00000000..3e0af9ce --- /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 00000000..6e5574a0 --- /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 9a263bd9..07b9ef71 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 00000000..40de99e6 --- /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 5e82cddd..4194dff7 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 578b04b6..bae58d4b 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 37e75d95..c867a8c5 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 468c0919..7c833293 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 cfea38db..dd5b6788 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 a70833ce..84e58c19 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 00000000..1e7d0a28 --- /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 79a0d5de..00000000 --- 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 6f908f2c..fec95d02 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 7baea96b..47284e22 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 51f5bbc7..51cbff11 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 717cd447..4221c456 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 00000000..4395d6af --- /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 + //