From 371cdf7c4a63c088ae90fe7c66df1b69f70901c8 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:47:25 -0500 Subject: [PATCH 01/28] [Fix] PR reviews send duplicate action offers (#1161) --- .../__tests__/notifyPrReviewActivity.test.ts | 7 + .../handlers/github/notifyPrReviewActivity.ts | 3 + .../pr-review-notification-delivery.test.ts | 195 ++++++++++++++++++ .../pr-review-notification-delivery.ts | 53 ++++- .../lib/task-runs/pr-review-notification.ts | 2 + 5 files changed, 256 insertions(+), 4 deletions(-) diff --git a/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts index 1ce39b2cd..931bb991f 100644 --- a/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts +++ b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts @@ -81,6 +81,7 @@ const pullRequest = { number: 42, html_url: 'https://github.com/owner/repo/pull/42', }; +const reviewHeadSha = 'f0c89ce4'; function reviewPayload(review: { body?: string | null; @@ -92,6 +93,7 @@ function reviewPayload(review: { pull_request: pullRequest, review: { body: review.body ?? null, + commit_id: reviewHeadSha, state: review.state ?? 'approved', html_url: 'https://github.com/owner/repo/pull/42#pullrequestreview-1000', user: review.login === null ? null : { login: review.login ?? 'alice' }, @@ -109,6 +111,7 @@ function reviewCommentPayload(comment: { pull_request: pullRequest, comment: { body: comment.body ?? 'Looks off to me', + commit_id: reviewHeadSha, in_reply_to_id: comment.inReplyToId, html_url: 'https://github.com/owner/repo/pull/42#discussion_r2000', user: comment.login === null ? null : { login: comment.login ?? 'alice' }, @@ -130,6 +133,7 @@ describe('buildPrReviewActivityNotificationInput', () => { event: { kind: 'review', authorLogin: 'alice', + reviewHeadSha, reviewState: 'changes_requested', url: 'https://github.com/owner/repo/pull/42#pullrequestreview-1000', }, @@ -181,6 +185,7 @@ describe('buildPrReviewActivityNotificationInput', () => { event: { kind: 'review_comment', authorLogin: 'bob', + reviewHeadSha, url: 'https://github.com/owner/repo/pull/42#discussion_r2000', }, }); @@ -246,6 +251,7 @@ describe('queuePrReviewActivityNotification', () => { event: { kind: 'review', authorLogin: 'alice', + reviewHeadSha, reviewState: 'approved', url: 'https://github.com/owner/repo/pull/42#pullrequestreview-1000', }, @@ -366,6 +372,7 @@ describe('buildPrReviewSummaryNotification', () => { event: { kind: 'review_summary', authorLogin: 'roomote[bot]', + reviewHeadSha, summary: '1 minor doc note; no blocking issues.', url: 'https://github.com/owner/repo/pull/42#issuecomment-99', roomoteAuthored: true, diff --git a/apps/api/src/handlers/github/notifyPrReviewActivity.ts b/apps/api/src/handlers/github/notifyPrReviewActivity.ts index c3bce38fe..3e8951290 100644 --- a/apps/api/src/handlers/github/notifyPrReviewActivity.ts +++ b/apps/api/src/handlers/github/notifyPrReviewActivity.ts @@ -78,6 +78,7 @@ export function buildPrReviewActivityNotificationInput( event: { kind: 'review', authorLogin, + ...(review.commit_id ? { reviewHeadSha: review.commit_id } : {}), reviewState: review.state, ...(review.html_url ? { url: review.html_url } : {}), ...(GitHubSchemas.isRoomoteGitHubLogin(authorLogin) @@ -110,6 +111,7 @@ export function buildPrReviewActivityNotificationInput( event: { kind: 'review_comment', authorLogin, + ...(comment.commit_id ? { reviewHeadSha: comment.commit_id } : {}), ...(comment.html_url ? { url: comment.html_url } : {}), ...(GitHubSchemas.isRoomoteGitHubLogin(authorLogin) ? { roomoteAuthored: true } @@ -254,6 +256,7 @@ export function buildPrReviewSummaryNotification( event: { kind: 'review_summary', authorLogin, + ...(markerSha ? { reviewHeadSha: markerSha } : {}), summary, ...(comment.html_url ? { url: comment.html_url } : {}), roomoteAuthored: true, diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts index 1b8ecdf08..c37618e93 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/pr-review-notification-delivery.test.ts @@ -10,6 +10,8 @@ const { mockPullsGet, mockListCheckRunsForRef, mockGetCombinedStatusForRef, + mockIsRoomoteGitHubLogin, + mockResolveConfiguredGitHubAppSlug, } = vi.hoisted(() => ({ mockGenerateObject: vi.fn(), mockReadSourceControlPullRequest: vi.fn(), @@ -22,6 +24,8 @@ const { mockPullsGet: vi.fn(), mockListCheckRunsForRef: vi.fn(), mockGetCombinedStatusForRef: vi.fn(), + mockIsRoomoteGitHubLogin: vi.fn((login: string) => login === 'roomote[bot]'), + mockResolveConfiguredGitHubAppSlug: vi.fn(), })); vi.mock('@roomote/cloud-agents/server/non-task-provider-usage', () => ({ @@ -53,6 +57,8 @@ vi.mock('@roomote/cloud-agents/server', () => ({ return content.slice(start + startMarker.length, end); }, + isReviewInProgressStatusLine: (line: string) => + /^(Self-reviewing|Reviewing|Re-reviewing)/i.test(line.trim()), })); vi.mock('../../pull-requests/source-control-pull-request-reads', () => ({ @@ -81,6 +87,10 @@ vi.mock('@roomote/slack', () => ({ })); vi.mock('@roomote/github', () => ({ + Schemas: { + isRoomoteGitHubLogin: (login: string) => mockIsRoomoteGitHubLogin(login), + }, + resolveConfiguredGitHubAppSlug: () => mockResolveConfiguredGitHubAppSlug(), createTaskRunGitHubToken: (...args: unknown[]) => mockCreateTaskRunGitHubToken(...args), getOctokit: () => ({ @@ -134,6 +144,13 @@ const events: PrReviewActivityEvent[] = [ const eventsWithoutSelfReview: PrReviewActivityEvent[] = events.slice(0, 2); +beforeEach(() => { + mockIsRoomoteGitHubLogin.mockImplementation( + (login: string) => login === 'roomote[bot]', + ); + mockResolveConfiguredGitHubAppSlug.mockResolvedValue('roomote'); +}); + function mockGreenCiChecks() { mockCreateTaskRunGitHubToken.mockResolvedValue('github-token'); mockPullsGet.mockResolvedValue({ @@ -330,6 +347,180 @@ describe('preparePrReviewNotificationDelivery', () => { }), ).resolves.toEqual({ post: false, reason: 'not_worth_notifying' }); }); + + it('suppresses Roomote activity represented by a terminal summary for the same head', async () => { + await expect( + preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'roomote[bot]', + roomoteAuthored: true, + reviewHeadSha: 'abc', + }, + ], + }), + ).resolves.toEqual({ post: false, reason: 'not_worth_notifying' }); + + expect(mockGenerateObject).not.toHaveBeenCalled(); + expect(mockFormatMessage).not.toHaveBeenCalled(); + }); + + it('keeps human activity when matching Roomote activity is coalesced', async () => { + await preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'roomote[bot]', + roomoteAuthored: true, + reviewHeadSha: 'abc', + }, + { + kind: 'review_comment', + authorLogin: 'alice', + reviewHeadSha: 'abc', + }, + ], + }); + + const prompt = mockGenerateObject.mock.calls[0]?.[0]?.prompt as string; + expect(prompt).toContain('- alice left an inline review comment'); + expect(prompt).not.toContain('you (this is your own review)'); + }); + + it('keeps Roomote activity from a different reviewed head', async () => { + await preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'roomote[bot]', + roomoteAuthored: true, + reviewHeadSha: 'def', + }, + ], + }); + + const prompt = mockGenerateObject.mock.calls[0]?.[0]?.prompt as string; + expect(prompt).toContain('you (this is your own review)'); + }); + + it('keeps Roomote activity while the matching summary is still in progress', async () => { + mockReadSourceControlPullRequest.mockResolvedValue({ + success: true, + provider: 'github', + repositoryFullName: 'owner/repo', + number: 42, + threads: [], + issueComments: [ + { + id: 'c1', + author: 'roomote[bot]', + body: '\n\nReviewing the PR now.\n', + createdAt: null, + url: null, + }, + ], + warnings: [], + }); + + await preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'roomote[bot]', + roomoteAuthored: true, + reviewHeadSha: 'abc', + }, + ], + }); + + expect(mockGenerateObject).toHaveBeenCalled(); + }); + + it('keeps Roomote activity when a human posts a marker-shaped comment', async () => { + mockReadSourceControlPullRequest.mockResolvedValue({ + success: true, + provider: 'github', + repositoryFullName: 'owner/repo', + number: 42, + threads: [], + issueComments: [ + { + id: 'c1', + author: 'alice', + body: '\n\n1 issue outstanding.\n', + createdAt: null, + url: null, + }, + ], + warnings: [], + }); + + await preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'roomote[bot]', + roomoteAuthored: true, + reviewHeadSha: 'abc', + }, + ], + }); + + expect(mockGenerateObject).toHaveBeenCalled(); + }); + + it('resolves a custom GitHub App slug before classifying summary authors', async () => { + mockResolveConfiguredGitHubAppSlug.mockResolvedValue('acme'); + mockIsRoomoteGitHubLogin.mockImplementation((login: string) => { + expect(mockResolveConfiguredGitHubAppSlug).toHaveBeenCalled(); + return login === 'acme[bot]'; + }); + mockReadSourceControlPullRequest.mockResolvedValue({ + success: true, + provider: 'github', + repositoryFullName: 'owner/repo', + number: 42, + threads: [], + issueComments: [ + { + id: 'c1', + author: 'acme[bot]', + body: '\n\n1 issue outstanding.\n', + createdAt: null, + url: null, + }, + ], + warnings: [], + }); + + await expect( + preparePrReviewNotificationDelivery({ + taskRun, + request, + events: [ + { + kind: 'review_comment', + authorLogin: 'acme[bot]', + roomoteAuthored: true, + reviewHeadSha: 'abc', + }, + ], + }), + ).resolves.toEqual({ post: false, reason: 'not_worth_notifying' }); + + expect(mockGenerateObject).not.toHaveBeenCalled(); + }); }); describe('triagePrReviewActivity', () => { @@ -455,6 +646,7 @@ describe('triagePrReviewActivity', () => { latestReviewStatus: '2 issues outstanding.', latestReviewSummaryComment: '\n\n- [ ] `apps/api/src/foo.ts:10` - Handle null actor ids\n- [ ] `apps/api/src/bar.ts:20` - Rename the helper to match its return shape\n', + latestTerminalReviewSummaryHeadSha: 'abc', ciStatus: { checks: [ { name: 'CI / Lint', status: 'success' }, @@ -497,6 +689,7 @@ describe('triagePrReviewActivity', () => { unresolvedThreadCount: 0, latestReviewStatus: null, latestReviewSummaryComment: null, + latestTerminalReviewSummaryHeadSha: null, ciStatus: { checks: [{ name: 'CI / Tests', status: 'failure' }], }, @@ -603,6 +796,7 @@ describe('gatherPrReviewTriageContext', () => { latestReviewStatus: 'All 1 issue addressed. See task', latestReviewSummaryComment: '\n\n**All 1 issue addressed.** [See task](https://example.com)\n', + latestTerminalReviewSummaryHeadSha: 'abc', ciStatus: { checks: [ { name: 'CI / Lint', status: 'success' }, @@ -709,6 +903,7 @@ describe('gatherPrReviewTriageContext', () => { unresolvedThreadCount: null, latestReviewStatus: null, latestReviewSummaryComment: null, + latestTerminalReviewSummaryHeadSha: null, ciStatus: null, mergeable: null, }); diff --git a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts index 2030d34e0..c9448f219 100644 --- a/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts +++ b/packages/sdk/src/server/lib/task-runs/pr-review-notification-delivery.ts @@ -3,13 +3,19 @@ import { REVIEW_STATUS_START_MARKER, REVIEW_SUMMARY_MARKER, getMarkedSection, + isReviewInProgressStatusLine, } from '@roomote/cloud-agents/server'; import { generateTrackedNonTaskObject, NON_TASK_INFERENCE_SURFACES, } from '@roomote/cloud-agents/server/non-task-provider-usage'; import type { TaskRun } from '@roomote/db/server'; -import { createTaskRunGitHubToken, getOctokit } from '@roomote/github'; +import { + Schemas as GitHubSchemas, + createTaskRunGitHubToken, + getOctokit, + resolveConfiguredGitHubAppSlug, +} from '@roomote/github'; import { setLatestSlackBotReply, trackSlackBotReply } from '@roomote/slack'; import { ACP_ENVELOPE_EVENT_TYPES, @@ -65,6 +71,7 @@ export type PrReviewTriageContext = { unresolvedThreadCount: number | null; latestReviewStatus: string | null; latestReviewSummaryComment: string | null; + latestTerminalReviewSummaryHeadSha: string | null; /** * Per-check CI state for the PR head, when available. Fed into the * triage LLM so the chat message can mention CI naturally. @@ -527,6 +534,12 @@ function sanitizeReviewStatus(status: string): string { .slice(0, MAX_REVIEW_STATUS_LENGTH); } +function getReviewSummaryHeadSha(body: string): string | null { + return ( + body.match(/Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', @@ -269,7 +270,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -283,7 +284,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -299,7 +300,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -316,7 +317,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -333,7 +334,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/c/456789/7/42).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/c/456789/7/42).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -350,7 +351,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/roomote_bot).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/roomote_bot).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -366,7 +367,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -383,7 +384,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/message/19%3Achannel%40thread.v2/1647012345678?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/message/19%3Achannel%40thread.v2/1647012345678?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -401,7 +402,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/app/bot-app-id?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/app/bot-app-id?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -417,7 +418,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -431,7 +432,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -470,7 +471,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', + 'prepend `> Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', @@ -490,7 +491,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Created by Roomote. [View the task](https://example.com/task/123) or mention @', + 'prepend `> Created by Roomote. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', diff --git a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts index e4b3ef6ab..75154586b 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts @@ -24,6 +24,7 @@ const exactSlackPermalink = const matchedSlackAttribution: ResolvedTaskCommitAuthor = { kind: 'user', displayName: 'Jane Doe', + publicDisplayName: null, githubLogin: null, prAssigneeLogin: null, gitAuthor: { @@ -383,7 +384,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -409,7 +410,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${exactSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${exactSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -433,7 +434,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); diff --git a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts index e0daa161a..62a6aa2d4 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts @@ -16,6 +16,10 @@ import { setGitHubRoomoteMentionSettingCache, type Schemas, } from '@roomote/github'; +import { + PR_BODY_ATTRIBUTION_END_MARKER, + PR_BODY_ATTRIBUTION_START_MARKER, +} from '@roomote/types'; import { DEFAULT_ROOMOTE_COMMIT_AUTHOR } from '../../commit-author'; import { @@ -73,6 +77,8 @@ describe('getPrBodyAttributionLine', () => { expect(line).toContain('@roomote'); expect(line).not.toContain('@octomote'); + expect(line).toContain(PR_BODY_ATTRIBUTION_START_MARKER); + expect(line).toContain(PR_BODY_ATTRIBUTION_END_MARKER); }); it('mentions @roomote with a database-configured app slug', () => { diff --git a/packages/cloud-agents/src/server/workflows/standardTask.ts b/packages/cloud-agents/src/server/workflows/standardTask.ts index f03c9783a..a494b4863 100644 --- a/packages/cloud-agents/src/server/workflows/standardTask.ts +++ b/packages/cloud-agents/src/server/workflows/standardTask.ts @@ -24,6 +24,7 @@ import { buildGitHubMessageInstructions } from '../github-message-instructions'; const DEFAULT_ATTRIBUTION: ResolvedTaskCommitAuthor = { kind: 'roomote', displayName: PRODUCT_NAME, + publicDisplayName: null, githubLogin: null, prAssigneeLogin: null, gitAuthor: { diff --git a/packages/cloud-agents/src/server/workflows/utils.ts b/packages/cloud-agents/src/server/workflows/utils.ts index fae10326d..8e9bf2000 100644 --- a/packages/cloud-agents/src/server/workflows/utils.ts +++ b/packages/cloud-agents/src/server/workflows/utils.ts @@ -6,6 +6,7 @@ import { buildTelegramMessagePermalink, buildDiscordMessagePermalink, getGitHubFollowUpMention, + formatPrBodyAttribution, resolveTaskWorkspace, } from '@roomote/types'; import { @@ -273,12 +274,15 @@ function buildPrBodyAttributionLine({ : defaultFollowUpInstruction; if (attribution.kind === 'roomote') { - return `> Created by Roomote. ${instruction}`; + return formatPrBodyAttribution('Created by Roomote.', instruction); } const safeUserName = escapeValue(attribution.displayName || PRODUCT_NAME); - return `> Opened on behalf of ${safeUserName}. ${instruction}`; + return formatPrBodyAttribution( + `Opened on behalf of ${safeUserName}.`, + instruction, + ); } export function getWorkspaceInstructions( repoFullNames?: string[], diff --git a/packages/db/src/lib/__tests__/source-control-provider.test.ts b/packages/db/src/lib/__tests__/source-control-provider.test.ts index 3da2ceed5..487ad1d90 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -3,6 +3,8 @@ import type { DatabaseOrTransaction } from '../../db'; import { resolveWorkspaceRepositoryProviders, resolveWorkspaceSourceControlProvider, + workspaceAllowsPrivateAttribution, + workspaceUsesOnlySourceControlProvider, } from '../source-control-provider'; const mockWhere = vi.fn(); @@ -11,6 +13,7 @@ let mockRows: Array<{ fullName: string; host: string | null; isActive?: boolean; + private?: boolean; sourceControlProvider: 'github' | 'gitlab' | 'gitea' | 'ado' | 'bitbucket'; }> = []; let mockEnvironmentRepositories: string[] = []; @@ -228,6 +231,65 @@ describe('resolveWorkspaceSourceControlProvider', () => { ).resolves.toEqual({ 'group/project': 'gitea' }); }); + it('rejects a single repository row from a different host', async () => { + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.example.com', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'git.example.com', + }), + ).resolves.toEqual({}); + }); + + it('does not use a legacy null-host row for a stamped host', async () => { + mockRows = [ + { + fullName: 'group/project', + host: null, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'gitlab.example.com', + }), + ).resolves.toEqual({}); + }); + + it('prefers an exact host match over a legacy null-host row', async () => { + mockRows = [ + { + fullName: 'group/project', + host: null, + sourceControlProvider: 'github', + }, + { + fullName: 'group/project', + host: 'gitlab.example.com', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'gitlab.example.com', + }), + ).resolves.toEqual({ 'group/project': 'gitlab' }); + }); + it('prefers active rows over stale inactive rows with the same name', async () => { mockRows = [ { @@ -297,3 +359,158 @@ describe('resolveWorkspaceSourceControlProvider', () => { warn.mockRestore(); }); }); + +describe('workspaceAllowsPrivateAttribution', () => { + beforeEach(() => { + mockRows = []; + mockEnvironmentRepositories = []; + }); + + it('allows account names only when every selected repository is private', async () => { + mockRows = [ + { + fullName: 'octo/api', + host: 'github.com', + private: true, + sourceControlProvider: 'github', + }, + { + fullName: 'octo/web', + host: 'github.com', + private: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository_set', + repositories: ['octo/api', 'octo/web'], + }), + ).resolves.toBe(true); + }); + + it('uses public-safe attribution for mixed-visibility workspaces', async () => { + mockRows = [ + { + fullName: 'octo/private', + host: 'github.com', + private: true, + sourceControlProvider: 'github', + }, + { + fullName: 'octo/public', + host: 'github.com', + private: false, + sourceControlProvider: 'github', + }, + ]; + + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository_set', + repositories: ['octo/private', 'octo/public'], + }), + ).resolves.toBe(false); + }); + + it('uses public-safe attribution when a repository cannot be resolved', async () => { + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository', + repo: 'octo/missing', + }), + ).resolves.toBe(false); + }); + + it('uses public-safe attribution when the selected host does not match', async () => { + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.example.com', + private: true, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'git.example.com', + }), + ).resolves.toBe(false); + }); + + it('uses public-safe attribution for a legacy null-host row', async () => { + mockRows = [ + { + fullName: 'group/project', + host: null, + private: true, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'gitlab.example.com', + }), + ).resolves.toBe(false); + }); + + it('uses legacy null-host visibility only when no exact host exists', async () => { + mockRows = [ + { + fullName: 'group/project', + host: null, + private: true, + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/project', + host: 'gitlab.example.com', + private: false, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + workspaceAllowsPrivateAttribution(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'gitlab.example.com', + }), + ).resolves.toBe(false); + }); + + it('requires every repository to match before using a provider handle', async () => { + mockRows = [ + { + fullName: 'octo/api', + host: 'github.com', + private: true, + sourceControlProvider: 'github', + }, + { + fullName: 'group/web', + host: 'gitlab.com', + private: false, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + workspaceUsesOnlySourceControlProvider( + dbOrTx, + { + type: 'repository_set', + repositories: ['octo/api', 'group/web'], + }, + 'github', + ), + ).resolves.toBe(false); + }); +}); diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index 9a1e22eb1..9ad344892 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -25,9 +25,43 @@ type RepositoryProviderRow = { fullName: string; host: string | null; isActive?: boolean; + private?: boolean; sourceControlProvider: SourceControlProvider; }; +function selectRepositoryRows( + rows: RepositoryProviderRow[], + repositoryOrder: string[], + sourceControlHost?: string, +): RepositoryProviderRow[] | null { + const rowsByFullName = new Map(); + + for (const row of rows) { + const matches = rowsByFullName.get(row.fullName) ?? []; + matches.push(row); + rowsByFullName.set(row.fullName, matches); + } + + const selected: RepositoryProviderRow[] = []; + + for (const fullName of [...new Set(repositoryOrder)]) { + const matches = rowsByFullName.get(fullName) ?? []; + const activeMatches = matches.filter((row) => row.isActive === true); + const candidates = activeMatches.length > 0 ? activeMatches : matches; + const hostMatches = sourceControlHost + ? candidates.filter((row) => row.host === sourceControlHost) + : candidates; + + if (hostMatches.length !== 1) { + return null; + } + + selected.push(hostMatches[0]!); + } + + return selected.length > 0 ? selected : null; +} + function toRepositoryProviderMap( rows: RepositoryProviderRow[], repositoryOrder: string[], @@ -47,10 +81,9 @@ function toRepositoryProviderMap( const matches = rowsByFullName.get(fullName) ?? []; const activeMatches = matches.filter((row) => row.isActive === true); const candidates = activeMatches.length > 0 ? activeMatches : matches; - const hostMatches = - candidates.length > 1 && sourceControlHost - ? candidates.filter((row) => row.host === sourceControlHost) - : candidates; + const hostMatches = sourceControlHost + ? candidates.filter((row) => row.host === sourceControlHost) + : candidates; if (candidates.length > 1 && hostMatches.length !== 1) { console.warn( @@ -177,6 +210,110 @@ export async function resolveWorkspaceRepositoryProviders( } } +async function resolveWorkspaceRepositoryRows( + dbOrTx: DatabaseOrTransaction, + workspace: TaskWorkspace, +): Promise { + if (workspace.type === 'environment') { + const environment = await dbOrTx.query.environments.findFirst({ + where: eq(environments.id, workspace.environmentId), + columns: { config: true }, + }); + if (!environment) { + return null; + } + + const rows = await dbOrTx + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + private: repositories.private, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(environmentRepositoryMappings) + .innerJoin( + repositories, + eq(environmentRepositoryMappings.repositoryId, repositories.id), + ) + .where( + and( + eq( + environmentRepositoryMappings.environmentId, + workspace.environmentId, + ), + eq(repositories.isActive, true), + ), + ); + const selected = selectRepositoryRows( + rows, + environment.config.repositories.map( + (repository) => repository.repository, + ), + ); + return selected; + } + + if (workspace.type === 'all_repositories') { + const rows = await dbOrTx + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + private: repositories.private, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(repositories) + .where(eq(repositories.isActive, true)); + return rows.length > 0 ? rows : null; + } + + const fullNames = + workspace.type === 'repository' ? [workspace.repo] : workspace.repositories; + const rows = await dbOrTx + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + private: repositories.private, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(repositories) + .where(inArray(repositories.fullName, fullNames)); + const selected = selectRepositoryRows( + rows, + fullNames, + workspace.sourceControlHost, + ); + return selected; +} + +/** + * Whether every repository in a workspace is known private. Missing or + * ambiguous repository rows return false so attribution fails toward privacy. + */ +export async function workspaceAllowsPrivateAttribution( + dbOrTx: DatabaseOrTransaction, + workspace: TaskWorkspace, +): Promise { + const rows = await resolveWorkspaceRepositoryRows(dbOrTx, workspace); + return rows?.every((repository) => repository.private === true) ?? false; +} + +/** Whether every known repository can use a handle from the same provider. */ +export async function workspaceUsesOnlySourceControlProvider( + dbOrTx: DatabaseOrTransaction, + workspace: TaskWorkspace, + provider: SourceControlProvider, +): Promise { + const rows = await resolveWorkspaceRepositoryRows(dbOrTx, workspace); + return ( + rows?.every( + (repository) => repository.sourceControlProvider === provider, + ) ?? false + ); +} + /** * Resolve the single source-control provider a launch's workspace belongs to, * so the task payload can carry an explicit `sourceControlProvider`. Handles diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.host.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.host.test.ts index 33e9d3473..d6eb3a63b 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.host.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.host.test.ts @@ -76,11 +76,10 @@ describe('resolveRepositoryRow host scoping', () => { ).resolves.toEqual(exactRow); }); - it('falls back to a legacy null-host row when no row matches the host exactly', async () => { - const legacyRow = repositoryRow({ id: 'repo-legacy', host: null }); + it('rejects a legacy null-host row when no row matches the host exactly', async () => { mockRepositoriesFindMany.mockResolvedValue([ repositoryRow({ id: 'repo-other-host', host: 'gitlab.other.example' }), - legacyRow, + repositoryRow({ id: 'repo-legacy', host: null }), ]); await expect( @@ -89,7 +88,9 @@ describe('resolveRepositoryRow host scoping', () => { repositoryFullName: 'acme/backend', host: 'gitlab.example.com', }), - ).resolves.toEqual(legacyRow); + ).rejects.toThrow( + 'GitLab repository not found or inactive on gitlab.example.com: acme/backend', + ); }); it('reports the host in the not-found error when no row qualifies for it', async () => { @@ -108,10 +109,10 @@ describe('resolveRepositoryRow host scoping', () => { ); }); - it('rejects multiple candidates within the chosen host tier', async () => { + it('rejects multiple exact matches within the chosen host', async () => { mockRepositoriesFindMany.mockResolvedValue([ - repositoryRow({ id: 'repo-legacy-1', host: null }), - repositoryRow({ id: 'repo-legacy-2', host: null }), + repositoryRow({ id: 'repo-exact-1', host: 'gitlab.example.com' }), + repositoryRow({ id: 'repo-exact-2', host: 'gitlab.example.com' }), ]); await expect( diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index 765008932..04ee59e82 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { RunStatus, TaskPayloadKind } from '@roomote/types'; +import { + RunStatus, + TaskPayloadKind, + formatPrBodyAttribution, +} from '@roomote/types'; import type { TaskRun } from '@roomote/db/server'; const { @@ -167,16 +171,28 @@ function jsonResponse(body: unknown, status = 200): Response { }); } +function attributionBody( + provenance: string, + instruction = 'Follow up by mentioning @roomote.', +): string { + return formatPrBodyAttribution(provenance, instruction); +} + describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { beforeEach(() => { vi.clearAllMocks(); mockGetDeploymentGitHubRoomoteMentionEnabled.mockResolvedValue(true); mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'roomote', displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockResolveLaunchTaskCommitAuthor.mockResolvedValue({ + kind: 'roomote', + displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockGetDeploymentPrAction.mockResolvedValue('draft'); @@ -199,6 +215,13 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { externalRepoId: '101', fullName: 'acme/backend', htmlUrl: 'https://gitlab.com/acme/backend', + private: false, + }); + mockResolveLaunchTaskCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@github-login', + prAssigneeLogin: null, }); const fetchImpl = vi .fn() @@ -225,7 +248,7 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { sourceBranch: 'codex/provider-neutral', targetBranch: 'develop', title: '[Feature] Provider neutral PRs', - body: 'Body', + body: attributionBody('Opened on behalf of Private Name.'), labels: ['roomote'], assignees: [], }, @@ -264,7 +287,7 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { target_branch: 'develop', remove_source_branch: false, title: '[Feature] Provider neutral PRs', - description: 'Body', + description: attributionBody('Created by Roomote.'), labels: 'roomote', }), }), @@ -342,10 +365,15 @@ describe('platform-managed draft state', () => { vi.clearAllMocks(); mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'roomote', displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockResolveLaunchTaskCommitAuthor.mockResolvedValue({ + kind: 'roomote', + displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockGetDeploymentPrAction.mockResolvedValue('draft'); @@ -388,6 +416,7 @@ describe('platform-managed draft state', () => { externalRepoId: null, fullName: 'acme/web', htmlUrl: 'https://github.com/acme/web', + private: true, }); return octokit; } @@ -446,13 +475,19 @@ describe('platform-managed draft state', () => { taskRun: makeTaskRun({ repo: 'acme/web' }), input: { ...githubInput, - body: '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', + body: `${attributionBody( + 'Created by Roomote.', + 'Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', + )}\n\n## What changed\n\nDone.`, }, }); expect(octokit.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ - body: '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', + body: `${attributionBody( + 'Created by Roomote.', + 'Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', + )}\n\n## What changed\n\nDone.`, }), ); }); @@ -476,13 +511,19 @@ describe('platform-managed draft state', () => { taskRun: makeTaskRun({ repo: 'acme/web' }), input: { ...githubInput, - body: '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', + body: attributionBody( + 'Created by Roomote.', + 'Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', + ), }, }); expect(octokit.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ - body: '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).', + body: attributionBody( + 'Created by Roomote.', + 'Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).', + ), }), ); }); @@ -705,10 +746,15 @@ describe('optional targetBranch', () => { vi.clearAllMocks(); mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'roomote', displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockResolveLaunchTaskCommitAuthor.mockResolvedValue({ + kind: 'roomote', + displayName: 'Roomote', + publicDisplayName: null, prAssigneeLogin: null, }); mockGetDeploymentPrAction.mockResolvedValue('draft'); @@ -766,6 +812,7 @@ describe('optional targetBranch', () => { externalRepoId: null, fullName: 'acme/web', htmlUrl: 'https://github.com/acme/web', + private: true, }); return octokit; } @@ -937,18 +984,186 @@ describe('optional targetBranch', () => { input: { ...baseInput, targetBranch: 'develop', - body: '> Opened on behalf of Launch Owner. Follow up by mentioning @roomote.', + body: attributionBody('Opened on behalf of Launch Owner.'), + }, + }); + + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: attributionBody('Opened on behalf of Participant.'), + }), + ); + }); + + it('uses only the linked handle in a public GitHub pull request body', async () => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: attributionBody('Opened on behalf of Private Name.'), + }, + }); + + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: attributionBody('Opened on behalf of @participant.'), + }), + ); + }); + + it('scrubs duplicated unmarked attribution in an otherwise marked public body', async () => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: `${attributionBody('Opened on behalf of Private Name.')}\n\n> Opened on behalf of Duplicated Private Name.`, + }, + }); + + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: `${attributionBody('Opened on behalf of @participant.')}\n\n> Opened on behalf of @participant.`, + }), + ); + }); + + it('uses generic provenance in a public GitHub pull request without a linked handle', async () => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: null, + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: attributionBody('Opened on behalf of Private Name.'), }, }); expect(octokit.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ - body: '> Opened on behalf of Participant. Follow up by mentioning @roomote.', + body: attributionBody('Created by Roomote.'), }), ); }); - it('preserves the original opener line when updating a pull request', async () => { + it('scrubs an unmarked public attribution line without parsing the name', async () => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Jane R. Doe', + publicDisplayName: null, + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: 'Preamble\n> Opened on behalf of Jane R. Doe. Follow up by mentioning @roomote.\n\nDone.', + }, + }); + + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'Preamble\n> Created by Roomote.\n\nDone.', + }), + ); + }); + + it('preserves replacement tokens literally in a private marked opener', async () => { const existing = { number: 11, node_id: 'node-11', @@ -956,7 +1171,7 @@ describe('optional targetBranch', () => { title: 'Old title', draft: false, base: { ref: 'develop' }, - body: '> Opened on behalf of Launch Owner. Follow up by mentioning @roomote.', + body: attributionBody('Opened on behalf of Launch $& Owner.'), }; const octokit = makeOctokit({ list: [existing], @@ -971,13 +1186,142 @@ describe('optional targetBranch', () => { taskRun: makeTaskRun({ repo: 'acme/web' }), input: { ...baseInput, - body: '> Opened on behalf of Participant. Follow up by mentioning @roomote.', + body: attributionBody('Opened on behalf of Participant.'), + }, + }); + + expect(octokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: attributionBody('Opened on behalf of Launch $& Owner.'), + }), + ); + }); + + it('does not preserve a private marked name when a public pull request is updated', async () => { + const existing = { + number: 11, + node_id: 'node-11', + html_url: 'https://github.com/acme/web/pull/11', + title: 'Old title', + draft: false, + base: { ref: 'develop' }, + body: attributionBody('Opened on behalf of Private Name.'), + }; + const octokit = makeOctokit({ + list: [existing], + updated: { ...existing, title: '[Feature] X' }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + body: attributionBody('Opened on behalf of Private Name.'), + }, + }); + + expect(octokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: attributionBody('Opened on behalf of @participant.'), + }), + ); + }); + + it('preserves a valid marked handle in existing public attribution', async () => { + const existing = { + number: 11, + node_id: 'node-11', + html_url: 'https://github.com/acme/web/pull/11', + title: 'Old title', + draft: false, + base: { ref: 'develop' }, + body: attributionBody('Opened on behalf of @launch-owner.'), + }; + const octokit = makeOctokit({ + list: [existing], + updated: { ...existing, title: '[Feature] X' }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + body: attributionBody('Opened on behalf of Private Name.'), + }, + }); + + expect(octokit.rest.pulls.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: attributionBody('Opened on behalf of @launch-owner.'), + }), + ); + }); + + it('ignores attribution in an old unmarked public PR', async () => { + const existing = { + number: 11, + node_id: 'node-11', + html_url: 'https://github.com/acme/web/pull/11', + title: 'Old title', + draft: false, + base: { ref: 'develop' }, + body: '> Opened on behalf of @octocat. Private Name. Follow up by mentioning @roomote.', + }; + const octokit = makeOctokit({ + list: [existing], + updated: { ...existing, title: '[Feature] X' }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + body: attributionBody('Opened on behalf of @participant.'), }, }); expect(octokit.rest.pulls.update).toHaveBeenCalledWith( expect.objectContaining({ - body: '> Opened on behalf of Launch Owner. Follow up by mentioning @roomote.', + body: attributionBody('Opened on behalf of @participant.'), }), ); }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts index 3d17ee81a..b6c48f62d 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-shared.ts @@ -37,6 +37,7 @@ export type RepositoryRow = { externalRepoId: string | null; fullName: string; htmlUrl: string; + private?: boolean; }; export function resolveSourceControlProviderForRepositoryFromPayload( @@ -96,10 +97,7 @@ export function resolveSourceControlHostForRepositoryFromPayload( * optionally narrowed by source-control instance host. * * When `host` is provided (typically from the task payload's - * `sourceControlHost`), rows whose `host` matches exactly are preferred; - * when none match, rows with a NULL host still qualify so legacy rows - * written before the host backfill keep resolving (mirroring the scoping in - * `upsertSourceControlPullRequestFactFromWebhook`). + * `sourceControlHost`), only rows whose `host` matches exactly qualify. * * Without a `host`, a (provider, fullName) identity active on more than one * row is an error rather than an arbitrary pick: same-name repositories on @@ -130,15 +128,12 @@ export async function resolveRepositoryRow({ externalRepoId: true, fullName: true, htmlUrl: true, + private: true, }, }); if (host !== undefined) { - const exactMatches = rows.filter((row) => row.host === host); - const candidates = - exactMatches.length > 0 - ? exactMatches - : rows.filter((row) => row.host === null); + const candidates = rows.filter((row) => row.host === host); if (candidates.length === 0) { throw new Error( diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index 09f16d51b..b9a48a530 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -20,7 +20,10 @@ import { import { buildPullRequestUrl, getSourceControlProviderLabel, + findPrBodyAttributionLine, normalizePrBodyAttributionAppMention, + preservePrBodyAttribution, + rewritePrBodyAttribution, prActions, sourceControlProviderSchema, type PrAction, @@ -218,22 +221,42 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ resolveConfiguredGitHubAppSlugIfConfigured(), getDeploymentGitHubRoomoteMentionEnabled(), ]); - const inputWithNormalizedAttribution: SourceControlPullRequestMutationInput = - configuredGitHubAppSlug - ? { - ...input, - body: normalizePrBodyAttributionAppMention( - input.body, - configuredGitHubAppSlug, - roomoteMentionEnabled, - ), - } - : input; - - const liveGitHubAttribution = + const attribution = provider === 'github' ? await resolveRunCommitAuthor(db, taskRun) - : undefined; + : await resolveLaunchTaskCommitAuthor(db, taskRun.taskId); + const normalizedMentionBody = configuredGitHubAppSlug + ? normalizePrBodyAttributionAppMention( + input.body, + configuredGitHubAppSlug, + roomoteMentionEnabled, + ) + : input.body; + const displayName = + attribution.kind === 'roomote' + ? null + : repository.private === true + ? attribution.displayName + : provider === 'github' + ? attribution.publicDisplayName + : null; + const rewrittenAttributionBody = rewritePrBodyAttribution( + normalizedMentionBody, + displayName, + ); + const inputWithNormalizedAttribution: SourceControlPullRequestMutationInput = + { + ...input, + body: + repository.private !== true + ? scrubUnmarkedPublicAttribution( + rewrittenAttributionBody, + displayName, + ) + : rewrittenAttributionBody, + }; + + const liveGitHubAttribution = provider === 'github' ? attribution : undefined; const liveGitHubAssigneePlan = liveGitHubAttribution ? await resolveLiveGitHubAssigneePlan({ taskRun, @@ -256,7 +279,6 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ repository, provider, createDraft, - attribution: liveGitHubAttribution, staleLaunchAssignee: liveGitHubAssigneePlan?.staleLaunchAssignee, }); case 'gitlab': @@ -415,14 +437,12 @@ async function createOrUpdateGitHubPullRequest({ repository, provider, createDraft, - attribution, staleLaunchAssignee, }: { input: SourceControlPullRequestMutationInput; repository: RepositoryRow; provider: 'github'; createDraft: boolean; - attribution?: ResolvedTaskCommitAuthor; staleLaunchAssignee?: string; }): Promise { if (!repository.installationId) { @@ -474,6 +494,7 @@ async function createOrUpdateGitHubPullRequest({ body: preserveExistingPullRequestAttribution( input.body, pullRequest.body, + repository.private === true, ), }); pullRequest = data; @@ -484,7 +505,7 @@ async function createOrUpdateGitHubPullRequest({ owner, repo, title: input.title, - body: replaceCreatedPullRequestAttribution(input.body, attribution), + body: input.body, head: input.sourceBranch, base: targetBranch, draft: createDraft, @@ -541,28 +562,44 @@ async function createOrUpdateGitHubPullRequest({ }; } -function replaceCreatedPullRequestAttribution( +function preserveExistingPullRequestAttribution( body: string, - attribution: ResolvedTaskCommitAuthor | undefined, + existingBody: string | null | undefined, + repositoryIsPrivate: boolean, ): string { - if (!attribution) { + const openerLine = existingBody + ? findPrBodyAttributionLine(existingBody) + : null; + const publicHandle = openerLine?.match( + /^> Opened on behalf of @([^\s.]+)\.(?: |$)/u, + )?.[1]; + const safePublicOpener = + publicHandle !== undefined && + /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/u.test(publicHandle); + if (!openerLine) { return body; } - return body.replace( - /^(> Opened on behalf of ).+?(\. (?:Follow up by|\[View the task\]))/mu, - `$1${attribution.displayName}$2`, - ); + if (repositoryIsPrivate) { + return preservePrBodyAttribution(body, existingBody ?? ''); + } + + return safePublicOpener + ? rewritePrBodyAttribution(body, `@${publicHandle}`) + : body; } -function preserveExistingPullRequestAttribution( +function scrubUnmarkedPublicAttribution( body: string, - existingBody: string | null | undefined, + displayName: string | null, ): string { - const openerLine = existingBody?.match(/^> Opened on behalf of .+$/mu)?.[0]; - return openerLine - ? body.replace(/^> Opened on behalf of .+$/mu, openerLine) - : body; + const provenance = displayName + ? `> Opened on behalf of ${displayName}.` + : '> Created by Roomote.'; + return body.replace( + /^[ \t]*>[ \t]*(?:Opened on behalf of|Created by Roomote).*$/gmu, + () => provenance, + ); } async function createOrUpdateGitLabMergeRequest({ diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts index c431ce6e2..28f4e75e1 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts @@ -1,10 +1,12 @@ import { db, githubUserMappings, + repositoryFactory, taskFactory, + type TaskRun, userFactory, } from '@roomote/db/server'; -import { PRODUCT_NAME } from '@roomote/types'; +import { ALL_REPOSITORIES, PRODUCT_NAME } from '@roomote/types'; import { resolveGitAuthor } from '../dequeue-helpers'; @@ -14,6 +16,22 @@ function uniqueGitHubUserId(): number { return githubUserIdSeed; } +function runContext( + taskId: string, + actingUserId: string | null, + repo = 'Roomote/example-app', +) { + return { + id: 1, + taskId, + actingUserId, + payload: { + repo, + sourceControlProvider: 'github', + } as TaskRun['payload'], + }; +} + /** * resolveGitAuthor resolves a linked live acting user and falls back to * Roomote when a run has no current actor. @@ -23,7 +41,7 @@ describe('resolveGitAuthor', () => { const task = await taskFactory.create({}); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: null }), + resolveGitAuthor(tx, runContext(task.id, null)), ); expect(result).toEqual({ @@ -38,7 +56,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: null }), + resolveGitAuthor(tx, runContext(task.id, null)), ); expect(result).toEqual({ @@ -47,7 +65,7 @@ describe('resolveGitAuthor', () => { }); }); - it('resolves a user commit author to their noreply email via the GitHub mapping', async () => { + it('uses the linked handle for an unknown-visibility GitHub workspace', async () => { const user = await userFactory.create({ name: 'Mona Lisa' }); const githubUserId = uniqueGitHubUserId(); @@ -64,7 +82,43 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: user.id }), + resolveGitAuthor(tx, runContext(task.id, user.id)), + ); + + expect(result).toEqual({ + name: '@octocat', + email: `${githubUserId}+octocat@users.noreply.github.com`, + }); + }); + + it('keeps the account name for a known private workspace', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const githubUserId = uniqueGitHubUserId(); + const repository = await repositoryFactory.create({ + fullName: `octo/private-${githubUserId}`, + linkedByUserId: user.id, + private: true, + sourceControlProvider: 'gitlab', + }); + await db.insert(githubUserMappings).values({ + userId: user.id, + githubLogin: 'octocat', + githubUserId, + }); + const task = await taskFactory.create({ + initiatorUserId: user.id, + commitAuthorKind: 'user', + commitAuthorUserId: user.id, + }); + + const result = await db.transaction(async (tx) => + resolveGitAuthor(tx, { + ...runContext(task.id, user.id, repository.fullName), + payload: { + repo: repository.fullName, + sourceControlProvider: 'gitlab', + } as TaskRun['payload'], + }), ); expect(result).toEqual({ @@ -73,6 +127,52 @@ describe('resolveGitAuthor', () => { }); }); + it('uses Roomote for a public mixed-provider workspace', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const githubUserId = uniqueGitHubUserId(); + const privateRepository = await repositoryFactory.create({ + fullName: `group/private-${githubUserId}`, + linkedByUserId: user.id, + private: true, + sourceControlProvider: 'gitea', + }); + const publicRepository = await repositoryFactory.create({ + fullName: `group/public-${githubUserId}`, + linkedByUserId: user.id, + private: false, + sourceControlProvider: 'gitlab', + }); + await db.insert(githubUserMappings).values({ + userId: user.id, + githubLogin: 'octocat', + githubUserId, + }); + const task = await taskFactory.create({ + initiatorUserId: user.id, + commitAuthorKind: 'user', + commitAuthorUserId: user.id, + }); + + const result = await db.transaction(async (tx) => + resolveGitAuthor(tx, { + ...runContext(task.id, user.id), + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: [ + privateRepository.fullName, + publicRepository.fullName, + ], + sourceControlProvider: 'github', + } as TaskRun['payload'], + }), + ); + + expect(result).toEqual({ + name: PRODUCT_NAME, + email: 'roomote@roomote.dev', + }); + }); + it('falls back to Roomote when the user commit author has no GitHub mapping', async () => { const user = await userFactory.create({ name: 'Unmapped User' }); @@ -83,7 +183,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: user.id }), + resolveGitAuthor(tx, runContext(task.id, user.id)), ); expect(result).toEqual({ @@ -101,7 +201,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: null }), + resolveGitAuthor(tx, runContext(task.id, null)), ); expect(result).toEqual({ @@ -118,7 +218,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: null }), + resolveGitAuthor(tx, runContext(task.id, null)), ); expect(result).toEqual({ @@ -135,7 +235,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { id: 1, taskId: task.id, actingUserId: null }), + resolveGitAuthor(tx, runContext(task.id, null)), ); expect(result).toEqual({ @@ -146,11 +246,7 @@ describe('resolveGitAuthor', () => { it('does not require a task lookup when the run has no acting user', async () => { const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { - id: 1, - taskId: 'missing-task-id', - actingUserId: null, - }), + resolveGitAuthor(tx, runContext('missing-task-id', null)), ); expect(result).toEqual({ @@ -174,15 +270,11 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { - id: 1, - taskId: task.id, - actingUserId: participant.id, - }), + resolveGitAuthor(tx, runContext(task.id, participant.id)), ); expect(result).toEqual({ - name: 'Participant', + name: '@participant', email: `${githubUserId}+participant@users.noreply.github.com`, }); }); @@ -198,11 +290,7 @@ describe('resolveGitAuthor', () => { }); const result = await db.transaction(async (tx) => - resolveGitAuthor(tx, { - id: 1, - taskId: task.id, - actingUserId: participant.id, - }), + resolveGitAuthor(tx, runContext(task.id, participant.id)), ); expect(result).toEqual({ diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index 4a027dc80..bd8346894 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -22,6 +22,8 @@ import { markTaskStartParallelCountEndedAt, resolveSandboxModelRuntimeEnv, resolveWorkspaceSourceControlProvider, + workspaceAllowsPrivateAttribution, + workspaceUsesOnlySourceControlProvider, stringifyDecryptedEnvVarValue, syncTaskStateFromRuns, eq, @@ -35,7 +37,9 @@ import { createTaskRunBitbucketCredentials } from '@roomote/bitbucket'; import { createTaskRunGiteaCredentials } from '@roomote/gitea'; import { createTaskRunAdoCredentials } from '@roomote/ado'; import { + DEFAULT_ROOMOTE_COMMIT_AUTHOR, releaseTaskRun, + resolvePublicGitAuthor, resolveRunCommitAuthor, } from '@roomote/cloud-agents/server'; @@ -758,9 +762,32 @@ export function reportBootstrapFailure({ export async function resolveGitAuthor( tx: DbTx, - taskRun: Pick, + taskRun: Pick, ): Promise { const commitAuthor = await resolveRunCommitAuthor(tx, taskRun); - return commitAuthor.gitAuthor; + if (commitAuthor.kind === 'roomote') { + return commitAuthor.gitAuthor; + } + + const workspace = resolveTaskWorkspace(taskRun.payload); + if (await workspaceAllowsPrivateAttribution(tx, workspace)) { + return commitAuthor.gitAuthor; + } + + const usesOnlyGitHub = await workspaceUsesOnlySourceControlProvider( + tx, + workspace, + 'github', + ); + const provider = await resolveWorkspaceSourceControlProvider(tx, workspace); + const singleUnknownGitHubRepository = + workspace.type === 'repository' && + !provider && + resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'; + if (!usesOnlyGitHub && !singleUnknownGitHubRepository) { + return DEFAULT_ROOMOTE_COMMIT_AUTHOR.gitAuthor; + } + + return resolvePublicGitAuthor(commitAuthor); } diff --git a/packages/types/src/__tests__/github-bot-identity.test.ts b/packages/types/src/__tests__/github-bot-identity.test.ts index 88457d08e..6650453d9 100644 --- a/packages/types/src/__tests__/github-bot-identity.test.ts +++ b/packages/types/src/__tests__/github-bot-identity.test.ts @@ -1,8 +1,10 @@ import { getRoomoteGitHubAppSlugs, getRoomoteManagedGitHubLogins, + formatPrBodyAttribution, matchesRoomoteGitHubLogin, normalizePrBodyAttributionAppMention, + rewritePrBodyAttribution, } from '../constants'; describe('Roomote GitHub bot identity helpers', () => { @@ -67,31 +69,48 @@ describe('Roomote GitHub bot identity helpers', () => { describe('normalizePrBodyAttributionAppMention', () => { it('rewrites a hardcoded @roomote mention to the configured app slug', () => { - const body = - '> Created by Roomote. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.'; + const body = `${formatPrBodyAttribution( + 'Created by Roomote.', + 'Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', + )}\n\n## What changed\n\nDone.`; expect( normalizePrBodyAttributionAppMention(body, 'roomote-roomote'), ).toBe( - '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.', + `${formatPrBodyAttribution( + 'Created by Roomote.', + 'Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).', + )}\n\n## What changed\n\nDone.`, ); }); it('keeps the shorthand when it is enabled', () => { - const body = - '> Created by Roomote. Follow up by mentioning @roomote-roomote.'; + const body = formatPrBodyAttribution( + 'Created by Roomote.', + 'Follow up by mentioning @roomote-roomote.', + ); expect( normalizePrBodyAttributionAppMention(body, 'roomote-roomote', true), - ).toBe('> Created by Roomote. Follow up by mentioning @roomote.'); + ).toBe( + formatPrBodyAttribution( + 'Created by Roomote.', + 'Follow up by mentioning @roomote.', + ), + ); }); it('rewrites Opened on behalf of attribution mentions', () => { - const body = - '> Opened on behalf of Matt Rubens. [View the task](https://example.com/task/1) or mention @roomote for follow-up asks.'; + const body = formatPrBodyAttribution( + 'Opened on behalf of Matt Rubens.', + '[View the task](https://example.com/task/1) or mention @roomote for follow-up asks.', + ); expect(normalizePrBodyAttributionAppMention(body, 'openmote')).toBe( - '> Opened on behalf of Matt Rubens. [View the task](https://example.com/task/1) or mention @openmote for follow-up asks.', + formatPrBodyAttribution( + 'Opened on behalf of Matt Rubens.', + '[View the task](https://example.com/task/1) or mention @openmote for follow-up asks.', + ), ); }); @@ -110,15 +129,70 @@ describe('Roomote GitHub bot identity helpers', () => { expect(normalizePrBodyAttributionAppMention(body, 'openmote')).toBe(body); }); - it('rewrites historical unlinked-attribution mentions', () => { + it('leaves unmarked historical attribution alone', () => { const body = '> Created by Roomote from an unlinked Slack user. Follow up by mentioning @roomote or in the web UI.'; expect( normalizePrBodyAttributionAppMention(body, 'roomote-roomote'), - ).toBe( - '> Created by Roomote from an unlinked Slack user. Follow up by mentioning @roomote-roomote or in the web UI.', + ).toBe(body); + }); + }); + + describe('rewritePrBodyAttribution', () => { + const instruction = + '[View the task](https://example.com/task/1) or mention @roomote for follow-up asks.'; + const body = `${formatPrBodyAttribution( + 'Opened on behalf of Private Name.', + instruction, + )}\n\n## What changed\n\nDone.`; + + it('uses a public handle without changing the attribution tail', () => { + expect(rewritePrBodyAttribution(body, '@octocat')).toBe( + `${formatPrBodyAttribution('Opened on behalf of @octocat.', instruction)}\n\n## What changed\n\nDone.`, + ); + }); + + it('uses generic Roomote provenance when no public identity exists', () => { + expect(rewritePrBodyAttribution(body, null)).toBe( + `${formatPrBodyAttribution('Created by Roomote.', instruction)}\n\n## What changed\n\nDone.`, + ); + }); + + it('handles periods in marked display names without parsing them', () => { + const marked = formatPrBodyAttribution( + 'Opened on behalf of Jane R. Doe.', + instruction, + ); + + expect(rewritePrBodyAttribution(marked, null)).toBe( + formatPrBodyAttribution('Created by Roomote.', instruction), ); }); + + it('rewrites a marked attribution line after a preamble', () => { + const marked = `Preamble\n${formatPrBodyAttribution( + 'Opened on behalf of Private Name.', + instruction, + )}`; + + expect(rewritePrBodyAttribution(marked, null)).toBe( + `Preamble\n${formatPrBodyAttribution('Created by Roomote.', instruction)}`, + ); + }); + + it('ignores markers outside an attribution blockquote', () => { + const unquoted = formatPrBodyAttribution( + 'Opened on behalf of Private Name.', + instruction, + ).slice(2); + expect(rewritePrBodyAttribution(unquoted, null)).toBe(unquoted); + }); + + it('does not parse or upgrade unmarked attribution', () => { + const legacy = + '> Opened on behalf of Jane R. Doe. Follow up by mentioning @roomote.'; + expect(rewritePrBodyAttribution(legacy, null)).toBe(legacy); + }); }); }); diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts index eae431b5f..ac2d71346 100644 --- a/packages/types/src/constants.ts +++ b/packages/types/src/constants.ts @@ -85,96 +85,69 @@ export function getGitHubFollowUpMention( return roomoteMentionEnabled ? '@roomote' : getGitHubAppMention(slug); } -/** - * Leading Roomote PR provenance blockquote: - * `> Created by Roomote. ...` or `> Opened on behalf of . ...` - * (including the historical "from an unlinked ..." attribution form). - * - * Parsed with linear string scans so untrusted PR bodies cannot trigger - * polynomial regular-expression matching. - */ -function matchPrBodyAttributionLine( - firstLine: string, -): { prefix: string; instruction: string } | null { - if (!firstLine.startsWith('>')) { +export const PR_BODY_ATTRIBUTION_START_MARKER = + ''; +export const PR_BODY_ATTRIBUTION_END_MARKER = + ''; + +type PrBodyAttributionMarkerMatch = { + start: number; + end: number; + lineStart: number; + lineEnd: number; +}; + +function findPrBodyAttributionMarkers( + body: string, +): PrBodyAttributionMarkerMatch | null { + const startMarker = body.indexOf(PR_BODY_ATTRIBUTION_START_MARKER); + if (startMarker === -1) { return null; } - let index = 1; - while ( - index < firstLine.length && - (firstLine.charCodeAt(index) === 32 /* space */ || - firstLine.charCodeAt(index) === 9) /* tab */ - ) { - index += 1; + const start = startMarker + PR_BODY_ATTRIBUTION_START_MARKER.length; + const end = body.indexOf(PR_BODY_ATTRIBUTION_END_MARKER, start); + if (end === -1 || body.slice(start, end).includes('\n')) { + return null; } - const contentStart = index; - const content = firstLine.slice(contentStart); - - const createdByPrefix = 'Created by Roomote'; - if (content.startsWith(createdByPrefix)) { - let sentenceEnd = createdByPrefix.length; - - if (content.startsWith(' from an unlinked ', sentenceEnd)) { - sentenceEnd += ' from an unlinked '.length; - while ( - sentenceEnd < content.length && - content.charCodeAt(sentenceEnd) !== 46 /* . */ - ) { - sentenceEnd += 1; - } - } - - if (content.charCodeAt(sentenceEnd) !== 46 /* . */) { - return null; - } - - sentenceEnd += 1; - while ( - sentenceEnd < content.length && - (content.charCodeAt(sentenceEnd) === 32 || - content.charCodeAt(sentenceEnd) === 9) - ) { - sentenceEnd += 1; - } - - return { - prefix: firstLine.slice(0, contentStart + sentenceEnd), - instruction: content.slice(sentenceEnd), - }; + const lineStart = body.lastIndexOf('\n', startMarker - 1) + 1; + if (!/^[ \t]*>[ \t]*$/u.test(body.slice(lineStart, startMarker))) { + return null; } - const openedPrefix = 'Opened on behalf of '; - if (content.startsWith(openedPrefix)) { - let sentenceEnd = openedPrefix.length; - while ( - sentenceEnd < content.length && - content.charCodeAt(sentenceEnd) !== 46 /* . */ - ) { - sentenceEnd += 1; - } + return { + start, + end, + lineStart, + lineEnd: + body.indexOf('\n', end) === -1 ? body.length : body.indexOf('\n', end), + }; +} - if (content.charCodeAt(sentenceEnd) !== 46 /* . */) { - return null; - } +export function formatPrBodyAttribution( + provenance: string, + instruction: string, +): string { + return `> ${PR_BODY_ATTRIBUTION_START_MARKER}${provenance}${PR_BODY_ATTRIBUTION_END_MARKER} ${instruction}`; +} - sentenceEnd += 1; - while ( - sentenceEnd < content.length && - (content.charCodeAt(sentenceEnd) === 32 || - content.charCodeAt(sentenceEnd) === 9) - ) { - sentenceEnd += 1; - } +export function findPrBodyAttributionLine(body: string): string | null { + const markers = findPrBodyAttributionMarkers(body); + return markers ? `> ${body.slice(markers.start, markers.end)}` : null; +} - return { - prefix: firstLine.slice(0, contentStart + sentenceEnd), - instruction: content.slice(sentenceEnd), - }; +export function preservePrBodyAttribution( + body: string, + existingBody: string, +): string { + const current = findPrBodyAttributionMarkers(body); + const existing = findPrBodyAttributionMarkers(existingBody); + if (!current || !existing) { + return body; } - return null; + return `${body.slice(0, current.start)}${existingBody.slice(existing.start, existing.end)}${body.slice(current.end)}`; } /** @@ -182,8 +155,8 @@ function matchPrBodyAttributionLine( * the deployment's current follow-up handle: the configured GitHub App slug, * or the shorter `@roomote` alias when that setting is enabled. * - * Only the leading attribution blockquote is rewritten; other body text that - * happens to mention `@roomote` is left unchanged. + * Only the marker-containing line is rewritten; other body text that happens + * to mention `@roomote` is left unchanged. */ export function normalizePrBodyAttributionAppMention( body: string, @@ -200,26 +173,43 @@ export function normalizePrBodyAttributionAppMention( normalizedSlug, roomoteMentionEnabled, ); - const firstNewline = body.indexOf('\n'); - const firstLine = firstNewline === -1 ? body : body.slice(0, firstNewline); - const remainder = firstNewline === -1 ? '' : body.slice(firstNewline); - const match = matchPrBodyAttributionLine(firstLine); - - if (!match) { + const markers = findPrBodyAttributionMarkers(body); + if (!markers) { return body; } - const { prefix, instruction } = match; - const rewrittenInstruction = instruction.replace( + const line = body.slice(markers.lineStart, markers.lineEnd); + const rewrittenLine = line.replace( /(mention(?:ing)?\s+)@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)/g, `$1${mention}`, ); - if (rewrittenInstruction === instruction) { + if (rewrittenLine === line) { + return body; + } + + return `${body.slice(0, markers.lineStart)}${rewrittenLine}${body.slice(markers.lineEnd)}`; +} + +/** + * Rewrite only the server-owned provenance text between attribution markers. + * Unmarked bodies are deliberately left alone. + */ +export function rewritePrBodyAttribution( + body: string, + displayName: string | null, +): string { + const markers = findPrBodyAttributionMarkers(body); + if (!markers) { return body; } - return `${prefix}${rewrittenInstruction}${remainder}`; + const normalizedDisplayName = displayName?.trim().replace(/[\r\n]+/g, ' '); + const provenance = normalizedDisplayName + ? `Opened on behalf of ${normalizedDisplayName}.` + : 'Created by Roomote.'; + + return `${body.slice(0, markers.start)}${provenance}${body.slice(markers.end)}`; } /** From 9da9f7baafeafb645ef8442db4f3469ac792533e Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:25:54 -0500 Subject: [PATCH 16/28] fix: make harness completion idempotent (#1183) --- .../src/jobs/pr-review-notification.test.ts | 58 +++++++++++++++++++ .../lib/__tests__/harness-manager.test.ts | 57 ++++++++++++++++++ .../src/sandbox-server/lib/harness-manager.ts | 10 ++++ ...ctive-review-follow-up-paired-idle.test.ts | 29 ++++++++-- 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index d49cf3ba9..ed6a9a6ba 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -577,6 +577,64 @@ describe('prReviewNotificationJob', () => { expect(mockStickyFooterPost).toHaveBeenCalled(); }); + it('releases deferred feedback exactly once after a live worker heartbeat becomes stale', async () => { + const liveRun = { + id: 1, + payload: { channel: 'C123' }, + slackThreadTs: '111.222', + sourceRunId: null, + status: RunStatus.Idle, + taskPhase: 'running', + workerHeartbeatAt: new Date(), + }; + const deadRun = { + ...liveRun, + workerHeartbeatAt: new Date(Date.now() - WORKER_HEARTBEAT_STALE_MS - 1), + }; + mockFindFirstTaskRun.mockResolvedValue(deadRun); + mockFindFirstTaskRun.mockResolvedValueOnce(liveRun); + mockConsumePending.mockResolvedValueOnce(events).mockResolvedValueOnce([]); + + await prReviewNotificationJob(makeJob() as never); + await prReviewNotificationJob(makeJob({ deferrals: 1 }) as never); + await prReviewNotificationJob(makeJob({ deferrals: 1 }) as never); + + expect(mockSchedule).toHaveBeenCalledTimes(1); + expect(mockConsumePending).toHaveBeenCalledTimes(2); + expect(mockPrepareDelivery).toHaveBeenCalledTimes(1); + expect(mockStickyFooterPost).toHaveBeenCalledTimes(1); + }); + + it('keeps feedback deferred across a worker restart until the replacement run settles', async () => { + const replacementRun = { + id: 2, + payload: { channel: 'C123' }, + slackThreadTs: '111.222', + sourceRunId: 1, + status: RunStatus.Running, + taskPhase: 'running', + workerHeartbeatAt: new Date(), + }; + mockFindFirstTaskRun + .mockResolvedValueOnce(replacementRun) + .mockResolvedValueOnce({ + ...replacementRun, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + }); + + await prReviewNotificationJob(makeJob() as never); + await prReviewNotificationJob(makeJob({ deferrals: 1 }) as never); + + expect(mockSchedule).toHaveBeenCalledTimes(1); + expect(mockConsumePending).toHaveBeenCalledTimes(1); + expect(mockPrepareDelivery).toHaveBeenCalledTimes(1); + expect(mockStickyFooterPost).toHaveBeenCalledTimes(1); + expect(mockRecordDelivery).toHaveBeenCalledWith( + expect.objectContaining({ runId: 2, taskId: 'task-1' }), + ); + }); + it('drops at the deferral cap when an idle running phase has a fresh heartbeat', async () => { mockFindFirstTaskRun.mockResolvedValue({ id: 1, diff --git a/apps/worker/src/sandbox-server/lib/__tests__/harness-manager.test.ts b/apps/worker/src/sandbox-server/lib/__tests__/harness-manager.test.ts index 113af6d97..044130d6b 100644 --- a/apps/worker/src/sandbox-server/lib/__tests__/harness-manager.test.ts +++ b/apps/worker/src/sandbox-server/lib/__tests__/harness-manager.test.ts @@ -2051,6 +2051,63 @@ describe('HarnessManager touchKeepalive', () => { } }); + it('finalizes each completed turn once when duplicate terminal events arrive', () => { + const onExit = vi.fn(); + const onTaskUpdate = vi.fn(); + const { harness, manager } = createManager({ onExit, onTaskUpdate }); + const completionEvent = { + eventName: TaskEventName.TaskCompleted, + payload: [ + 'task-duplicate-completion', + { + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + contextTokens: 0, + }, + {}, + { isSubtask: false }, + ], + } as TaskEvent; + + try { + manager.initializeWithoutPrompt(); + manager.startNewTaskFromPrompt({ prompt: 'hello' }); + harness.emitTaskEvent({ + eventName: TaskEventName.TaskStarted, + payload: ['task-duplicate-completion'], + } as TaskEvent); + + harness.emitTaskEvent(completionEvent); + harness.emitTaskEvent(completionEvent); + + expect(onExit).toHaveBeenCalledTimes(1); + expect( + onTaskUpdate.mock.calls.filter( + ([update]) => update.status === 'completed', + ), + ).toHaveLength(1); + + expect( + manager.sendFollowUpPrompt({ prompt: 'run a real follow-up turn' }), + ).toBe(true); + expect(manager.getStatus().phase).toBe('running'); + + harness.emitTaskEvent(completionEvent); + harness.emitTaskEvent(completionEvent); + + expect(onExit).toHaveBeenCalledTimes(2); + expect( + onTaskUpdate.mock.calls.filter( + ([update]) => update.status === 'completed', + ), + ).toHaveLength(2); + } finally { + manager.dispose(); + harness.dispose(); + } + }); + it('is a no-op when in running phase', () => { const { harness, manager } = createManager(); diff --git a/apps/worker/src/sandbox-server/lib/harness-manager.ts b/apps/worker/src/sandbox-server/lib/harness-manager.ts index 84225f383..5b6e9f310 100644 --- a/apps/worker/src/sandbox-server/lib/harness-manager.ts +++ b/apps/worker/src/sandbox-server/lib/harness-manager.ts @@ -1392,6 +1392,16 @@ export class HarnessManager extends EventEmitter { private onTaskCompleted(payload: TaskEventCompletedPayload): void { if (payload[0] === this.state.sessionId) { + if ( + this.state.taskFinishedAt !== undefined && + !isActiveTaskPhase(this.phase) + ) { + this.logger.info( + `[HarnessManager] Ignoring duplicate task completion for settled task ${payload[0]} (phase=${this.phase})`, + ); + return; + } + this.logger.info( `[HarnessManager] Task completed: ${payload[0]} (phase=${this.phase}, queuedRuntimePrompts=${this.runtimeQueuedMessagesCount}, deferredSettlement=${this.deferredTurnSettlement ?? 'none'})`, ); diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/active-review-follow-up-paired-idle.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/active-review-follow-up-paired-idle.test.ts index 4f23a9a11..46c228777 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/active-review-follow-up-paired-idle.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/active-review-follow-up-paired-idle.test.ts @@ -191,26 +191,42 @@ function createFixture() { }); const onExit = vi.fn(); + const onTaskUpdate = vi.fn(); const manager = new HarnessManager({ harness, keepaliveMs: 60_000, runId: 100, taskId: 'task-100', logger: { ...createLogger(), log: vi.fn() }, - callbacks: { onExit }, + callbacks: { onExit, onTaskUpdate }, }); const taskEvents: TaskEvent[] = []; harness.subscribe((event) => taskEvents.push(event)); - return { client, harness, manager, onExit, submittedPrompts, taskEvents }; + return { + client, + harness, + manager, + onExit, + onTaskUpdate, + submittedPrompts, + taskEvents, + }; } describe('active PR review follow-up lifecycle (paired session.status idle + session.idle)', () => { it('defers run completion across the paired idle until the drained re-review turn has run', async () => { const fixture = createFixture(); - const { client, harness, manager, onExit, submittedPrompts, taskEvents } = - fixture; + const { + client, + harness, + manager, + onExit, + onTaskUpdate, + submittedPrompts, + taskEvents, + } = fixture; try { await connectHarness(harness, client); @@ -283,6 +299,11 @@ describe('active PR review follow-up lifecycle (paired session.status idle + ses (event) => event.eventName === TaskEventName.TaskCompleted, ), ).toHaveLength(2); + expect( + onTaskUpdate.mock.calls.filter( + ([update]) => update.status === 'completed', + ), + ).toHaveLength(1); } finally { manager.dispose(); harness.dispose(); From 245eafa73a3a42b35054ecb53a8278bef3e74474 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:11:16 -0400 Subject: [PATCH 17/28] [Feat] Add linked identities for source-control providers (#1189) * feat: add source-control account identities * fix: preserve source-control host ports --------- Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- apps/docs/source-control.mdx | 13 + .../settings/LinkedAccounts.test.tsx | 15 + .../components/settings/LinkedAccounts.tsx | 1 + apps/web/src/lib/server/auth.test.ts | 189 +- apps/web/src/lib/server/auth.ts | 233 +- .../trpc/commands/linked-accounts/index.ts | 56 +- .../cloud-agents/src/server/commit-author.ts | 61 + packages/db/drizzle/0031_lumpy_cerebro.sql | 18 + packages/db/drizzle/meta/0031_snapshot.json | 10322 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../__tests__/source-control-provider.test.ts | 45 + .../db/src/lib/source-control-provider.ts | 16 + packages/db/src/schema.ts | 56 + packages/db/src/server.ts | 2 + .../source-control-pull-requests.test.ts | 21 +- .../source-control-pull-requests.ts | 12 +- .../__tests__/resolve-git-author.test.ts | 195 +- .../server/lib/task-runs/dequeue-helpers.ts | 24 +- 18 files changed, 11248 insertions(+), 38 deletions(-) create mode 100644 packages/db/drizzle/0031_lumpy_cerebro.sql create mode 100644 packages/db/drizzle/meta/0031_snapshot.json diff --git a/apps/docs/source-control.mdx b/apps/docs/source-control.mdx index e722e4e24..c54654c6b 100644 --- a/apps/docs/source-control.mdx +++ b/apps/docs/source-control.mdx @@ -53,6 +53,19 @@ the Roomote identity otherwise. A workspace containing any public or unresolved repository uses the public-safe identity for all new commits because Git author configuration applies across the workspace. +Linked GitLab and Gitea accounts retain the username verified by that provider, +scoped to the configured source-control host. Bitbucket does the same only when +its profile API returns a username; a nickname is not treated as a public +handle. Azure DevOps +accounts retain their verified display name for Settings and private-repository +context, but public attribution remains generic because Azure DevOps does not +provide a stable non-email public handle. GitLab.com can also provide a verified +`noreply` commit identity; other non-GitHub providers use Roomote as the Git +author for public work. + +Existing account links pick up verified profile attribution after their OAuth +token refreshes or after the account is linked again. + Changing a repository from private to public does not rewrite existing Git history. Roomote sanitizes a legacy named attribution line the next time it updates an open public pull request. diff --git a/apps/web/src/components/settings/LinkedAccounts.test.tsx b/apps/web/src/components/settings/LinkedAccounts.test.tsx index 24c67bae5..00980cc92 100644 --- a/apps/web/src/components/settings/LinkedAccounts.test.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.test.tsx @@ -789,6 +789,21 @@ describe('LinkedAccounts settings', () => { expect(document.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(6); }); + it('keeps the loading state while only the Bitbucket account is pending', () => { + state.deploymentEnablements = []; + state.gitHubInstallations = []; + state.linearInstallation = null; + state.linearAccount = null; + state.bitbucketAccountIsPending = true; + + render(); + + expect( + screen.queryByText(/No personal linked accounts/), + ).not.toBeInTheDocument(); + expect(document.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(6); + }); + it('does not render org-scoped MCPs in linked accounts', () => { state.user.isAdmin = false; state.deploymentEnablements = createMcpEnablements( diff --git a/apps/web/src/components/settings/LinkedAccounts.tsx b/apps/web/src/components/settings/LinkedAccounts.tsx index c9ca24139..390f20c6f 100644 --- a/apps/web/src/components/settings/LinkedAccounts.tsx +++ b/apps/web/src/components/settings/LinkedAccounts.tsx @@ -815,6 +815,7 @@ export function LinkedAccounts() { githubInstallations.isPending || gitlabAccount.isPending || giteaAccount.isPending || + bitbucketAccount.isPending || adoAccount.isPending || slackInstallation.isPending || linearInstallation.isPending || diff --git a/apps/web/src/lib/server/auth.test.ts b/apps/web/src/lib/server/auth.test.ts index 3194f45c5..8e9c27874 100644 --- a/apps/web/src/lib/server/auth.test.ts +++ b/apps/web/src/lib/server/auth.test.ts @@ -3,6 +3,8 @@ const { mockBetterAuth, mockGenericOAuth, mockResolveAuthProviderConfig, + mockSourceControlMappingValues, + mockSourceControlMappingUpsert, } = vi.hoisted(() => { const calls: Array<{ config: Array<{ @@ -25,6 +27,8 @@ const { return { id: 'generic-oauth-plugin', options }; }), mockResolveAuthProviderConfig: vi.fn(), + mockSourceControlMappingValues: vi.fn(), + mockSourceControlMappingUpsert: vi.fn(), }; }); @@ -55,10 +59,20 @@ vi.mock('@better-auth/drizzle-adapter', () => ({ vi.mock('@roomote/db/server', () => ({ and: vi.fn(), authUsers: {}, - db: {}, + db: { + insert: vi.fn(() => ({ + values: (values: unknown) => { + mockSourceControlMappingValues(values); + return { onConflictDoUpdate: mockSourceControlMappingUpsert }; + }, + })), + }, eq: vi.fn(), inArray: vi.fn(), microsoftAuthUserMappings: {}, + sourceControlUserMappings: { + authAccountId: 'source_control_user_mappings.authAccountId', + }, teamsUserMappings: {}, })); @@ -111,6 +125,17 @@ function getAdoOAuthProvider() { return provider; } +function getOAuthProvider(providerId: string) { + const config = genericOAuthCalls.at(-1)?.config; + const provider = config?.find((item) => item.providerId === providerId); + + if (!provider?.getUserInfo) { + throw new Error(`${providerId} OAuth provider was not configured`); + } + + return provider; +} + describe('getAuth', () => { beforeEach(() => { vi.clearAllMocks(); @@ -209,5 +234,167 @@ describe('getAuth', () => { id: 'ada@roomote.onmicrosoft.com', name: 'Ada Lovelace', }); + + const options = mockBetterAuth.mock.calls.at(-1)?.[0] as { + databaseHooks?: { + account?: { + create?: { after?: (account: unknown) => Promise }; + }; + }; + }; + await options.databaseHooks?.account?.create?.after?.({ + id: 'ado-auth-account-id', + userId: 'roomote-user-id', + accountId: 'ada@roomote.onmicrosoft.com', + providerId: 'ado', + accessToken: 'azure-devops-token', + }); + + expect(mockSourceControlMappingValues).toHaveBeenCalledWith( + expect.objectContaining({ + authAccountId: 'ado-auth-account-id', + sourceControlProvider: 'ado', + host: 'dev.azure.com', + externalAccountId: 'connection-user-guid', + username: null, + displayName: 'Ada Lovelace', + }), + ); + }); + + it('persists a host-scoped GitLab identity after account linking', async () => { + mockResolveAuthProviderConfig.mockResolvedValue({ + adoBaseUrl: undefined, + adoClientId: undefined, + adoClientSecret: undefined, + adoOrganization: undefined, + adoTenantId: undefined, + gitlabBaseUrl: 'https://gitlab.example.com:8443', + gitlabClientId: 'gitlab-client-id', + gitlabClientSecret: 'gitlab-client-secret', + microsoftClientId: undefined, + microsoftClientSecret: undefined, + microsoftTenantId: undefined, + signature: crypto.randomUUID(), + slackClientId: undefined, + slackClientSecret: undefined, + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + Response.json({ + id: 42, + username: 'octocat', + name: 'Octo Cat', + }), + ), + ), + ); + + await getAuth(); + expect(getOAuthProvider('gitlab')).toBeDefined(); + + const options = mockBetterAuth.mock.calls.at(-1)?.[0] as { + databaseHooks?: { + account?: { + create?: { after?: (account: unknown) => Promise }; + }; + }; + }; + await options.databaseHooks?.account?.create?.after?.({ + id: 'auth-account-id', + userId: 'roomote-user-id', + accountId: '42', + providerId: 'gitlab', + accessToken: 'gitlab-access-token', + }); + + expect(mockSourceControlMappingValues).toHaveBeenCalledWith( + expect.objectContaining({ + authAccountId: 'auth-account-id', + userId: 'roomote-user-id', + sourceControlProvider: 'gitlab', + host: 'gitlab.example.com:8443', + externalAccountId: '42', + username: 'octocat', + displayName: 'Octo Cat', + }), + ); + expect(mockSourceControlMappingUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + target: 'source_control_user_mappings.authAccountId', + }), + ); + }); + + it('links Bitbucket accounts without treating a nickname as a public handle', async () => { + mockResolveAuthProviderConfig.mockResolvedValue({ + adoBaseUrl: undefined, + adoClientId: undefined, + adoClientSecret: undefined, + adoOrganization: undefined, + adoTenantId: undefined, + bitbucketBaseUrl: 'https://bitbucket.org', + bitbucketClientId: 'bitbucket-client-id', + bitbucketClientSecret: 'bitbucket-client-secret', + gitlabBaseUrl: undefined, + gitlabClientId: undefined, + gitlabClientSecret: undefined, + microsoftClientId: undefined, + microsoftClientSecret: undefined, + microsoftTenantId: undefined, + signature: crypto.randomUUID(), + slackClientId: undefined, + slackClientSecret: undefined, + }); + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + Response.json({ + account_id: 'bitbucket-account-id', + nickname: 'Octo Cat', + display_name: 'Octo Cat', + }), + ), + ), + ); + + await getAuth(); + const provider = getOAuthProvider('bitbucket'); + await expect( + provider.getUserInfo?.({ accessToken: 'bitbucket-access-token' }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'bitbucket-account-id', + name: 'Octo Cat', + }), + ); + + const options = mockBetterAuth.mock.calls.at(-1)?.[0] as { + databaseHooks?: { + account?: { + create?: { after?: (account: unknown) => Promise }; + }; + }; + }; + await options.databaseHooks?.account?.create?.after?.({ + id: 'bitbucket-auth-account-id', + userId: 'roomote-user-id', + accountId: 'bitbucket-account-id', + providerId: 'bitbucket', + accessToken: 'bitbucket-access-token', + }); + + expect(mockSourceControlMappingValues).toHaveBeenCalledWith( + expect.objectContaining({ + sourceControlProvider: 'bitbucket', + host: 'bitbucket.org', + externalAccountId: 'bitbucket-account-id', + username: null, + displayName: 'Octo Cat', + }), + ); }); }); diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts index b0e2e5c1e..6382408f9 100644 --- a/apps/web/src/lib/server/auth.ts +++ b/apps/web/src/lib/server/auth.ts @@ -6,6 +6,7 @@ import { nextCookies } from 'better-auth/next-js'; import { genericOAuth, microsoftEntraId, slack } from 'better-auth/plugins'; import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { normalizeAdoLinkedAccountKey } from '@roomote/ado'; +import type { SourceControlTokenBackedProvider } from '@roomote/types'; import { authUsers, @@ -14,6 +15,7 @@ import { eq, inArray, microsoftAuthUserMappings, + sourceControlUserMappings, teamsUserMappings, } from '@roomote/db/server'; import * as dbSchema from '@roomote/db/server'; @@ -91,6 +93,22 @@ type MicrosoftAuthAccountHookRow = { idToken?: unknown; }; +type SourceControlAuthAccountHookRow = { + id?: unknown; + userId?: unknown; + accountId?: unknown; + providerId?: unknown; + accessToken?: unknown; +}; + +type SourceControlIdentityProfile = { + provider: SourceControlTokenBackedProvider; + host: string; + externalAccountId: string; + username: string | null; + displayName: string | null; +}; + type MicrosoftEntraIdTokenClaims = { oid?: unknown; sub?: unknown; @@ -124,6 +142,7 @@ type BitbucketOAuthProfile = { }; }; username?: unknown; + nickname?: unknown; uuid?: unknown; }; @@ -489,6 +508,191 @@ function getAdoProfileEmail({ : buildAdoPlaceholderEmail(accountId); } +function getSourceControlHost(baseUrl: string): string { + return new URL(baseUrl).host.toLowerCase(); +} + +async function resolveSourceControlIdentityProfile({ + provider, + accessToken, + gitlabBaseUrl, + giteaBaseUrl, + bitbucketBaseUrl, + adoBaseUrl, +}: { + provider: SourceControlTokenBackedProvider; + accessToken: string; + gitlabBaseUrl: string; + giteaBaseUrl: string | null; + bitbucketBaseUrl: string; + adoBaseUrl: string; +}): Promise { + const headers = { Authorization: `Bearer ${accessToken}` }; + + if (provider === 'gitlab') { + const response = await fetch(`${gitlabBaseUrl}/api/v4/user`, { headers }); + if (!response.ok) return null; + + const profile = (await response.json()) as GitLabOAuthProfile; + const externalAccountId = readGitLabProfileId(profile); + const username = readGitLabProfileString(profile, 'username'); + if (!externalAccountId || !username) return null; + + return { + provider, + host: getSourceControlHost(gitlabBaseUrl), + externalAccountId, + username, + displayName: readGitLabProfileString(profile, 'name'), + }; + } + + if (provider === 'gitea') { + if (!giteaBaseUrl) return null; + const response = await fetch(`${giteaBaseUrl}/api/v1/user`, { headers }); + if (!response.ok) return null; + + const profile = (await response.json()) as GiteaOAuthProfile; + const externalAccountId = readGiteaProfileId(profile); + const username = readGiteaProfileString(profile, 'login'); + if (!externalAccountId || !username) return null; + + return { + provider, + host: getSourceControlHost(giteaBaseUrl), + externalAccountId, + username, + displayName: readGiteaProfileString(profile, 'full_name'), + }; + } + + if (provider === 'bitbucket') { + const response = await fetch('https://api.bitbucket.org/2.0/user', { + headers, + }); + if (!response.ok) return null; + + const profile = (await response.json()) as BitbucketOAuthProfile; + const externalAccountId = readBitbucketProfileId(profile); + if (!externalAccountId) return null; + + return { + provider, + host: getSourceControlHost(bitbucketBaseUrl), + externalAccountId, + username: readBitbucketProfileString(profile, 'username'), + displayName: + readBitbucketProfileString(profile, 'display_name') ?? + readBitbucketProfileString(profile, 'nickname'), + }; + } + + const [connectionDataResponse, profileResponse] = await Promise.all([ + fetch( + buildAdoGlobalApiUrl( + '_apis/connectionData', + ADO_CONNECTION_DATA_API_VERSION, + ), + { + headers, + }, + ), + fetch(buildAdoGlobalApiUrl('_apis/profile/profiles/me', ADO_API_VERSION), { + headers, + }).catch(() => null), + ]); + if (!connectionDataResponse.ok) return null; + + const connectionData = + (await connectionDataResponse.json()) as AdoConnectionData; + const user = connectionData.authenticatedUser; + const externalAccountId = user && readAdoConnectionDataUserString(user, 'id'); + if (!user || !externalAccountId) return null; + + const profile = + profileResponse?.ok === true + ? ((await profileResponse.json()) as AdoProfile) + : null; + + return { + provider, + host: getSourceControlHost(adoBaseUrl), + externalAccountId, + // Azure DevOps exposes uniqueName as an email/UPN, not a public handle. + username: null, + displayName: + (profile && readAdoProfileString(profile, 'displayName')) ?? + readAdoConnectionDataUserString(user, 'displayName') ?? + readAdoConnectionDataUserString(user, 'providerDisplayName'), + }; +} + +async function syncSourceControlAuthUserMapping( + account: unknown, + config: { + gitlabBaseUrl: string; + giteaBaseUrl: string | null; + bitbucketBaseUrl: string; + adoBaseUrl: string; + }, +) { + const row = account as SourceControlAuthAccountHookRow | null; + const provider = readNonEmptyString(row?.providerId); + if ( + provider !== 'gitlab' && + provider !== 'gitea' && + provider !== 'bitbucket' && + provider !== 'ado' + ) { + return; + } + + const authAccountId = readNonEmptyString(row?.id); + const userId = readNonEmptyString(row?.userId); + const accessToken = readNonEmptyString(row?.accessToken); + if (!authAccountId || !userId || !accessToken) return; + + try { + const identity = await resolveSourceControlIdentityProfile({ + provider, + accessToken, + ...config, + }); + if (!identity) return; + + const now = new Date(); + await db + .insert(sourceControlUserMappings) + .values({ + authAccountId, + userId, + sourceControlProvider: identity.provider, + host: identity.host, + externalAccountId: identity.externalAccountId, + username: identity.username, + displayName: identity.displayName, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: sourceControlUserMappings.authAccountId, + set: { + userId, + sourceControlProvider: identity.provider, + host: identity.host, + externalAccountId: identity.externalAccountId, + username: identity.username, + displayName: identity.displayName, + updatedAt: now, + }, + }); + } catch (error) { + console.error( + `[auth] Failed to sync ${provider} linked-account identity:`, + error, + ); + } +} + async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { const { slackClientId, @@ -697,7 +901,7 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { const accountId = readBitbucketProfileId(profile); const username = readBitbucketProfileString(profile, 'username'); - if (!accountId || !username) { + if (!accountId) { return null; } @@ -708,12 +912,13 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { return { id: accountId, - email: buildBitbucketPlaceholderEmail(username), + email: buildBitbucketPlaceholderEmail(username ?? accountId), emailVerified: false, image: avatarHref, name: readBitbucketProfileString(profile, 'display_name') ?? - `@${username}`, + readBitbucketProfileString(profile, 'nickname') ?? + (username ? `@${username}` : `Bitbucket user ${accountId}`), }; }, }, @@ -873,12 +1078,30 @@ async function createAuth(authProviderConfig: ResolvedAuthProviderConfig) { account: { create: { after: async (account) => { - await syncMicrosoftAuthUserMapping(account); + await Promise.all([ + syncMicrosoftAuthUserMapping(account), + syncSourceControlAuthUserMapping(account, { + gitlabBaseUrl: normalizedGitLabBaseUrl, + giteaBaseUrl: normalizedGiteaBaseUrl, + bitbucketBaseUrl: + normalizedBitbucketBaseUrl ?? 'https://bitbucket.org', + adoBaseUrl: normalizedAdoBaseUrl, + }), + ]); }, }, update: { after: async (account) => { - await syncMicrosoftAuthUserMapping(account); + await Promise.all([ + syncMicrosoftAuthUserMapping(account), + syncSourceControlAuthUserMapping(account, { + gitlabBaseUrl: normalizedGitLabBaseUrl, + giteaBaseUrl: normalizedGiteaBaseUrl, + bitbucketBaseUrl: + normalizedBitbucketBaseUrl ?? 'https://bitbucket.org', + adoBaseUrl: normalizedAdoBaseUrl, + }), + ]); }, }, delete: { diff --git a/apps/web/src/trpc/commands/linked-accounts/index.ts b/apps/web/src/trpc/commands/linked-accounts/index.ts index eca8bb943..1da4e072b 100644 --- a/apps/web/src/trpc/commands/linked-accounts/index.ts +++ b/apps/web/src/trpc/commands/linked-accounts/index.ts @@ -4,6 +4,7 @@ import { githubUserMappings, slackInstallations, slackUserMappings, + sourceControlUserMappings, telegramUserMappings, discordUserMappings, resolveDiscordRuntimeCredentials, @@ -44,6 +45,25 @@ function formatAdoLinkedAccountDisplayName(accountId: string) { return `Azure DevOps user ${accountId}`; } +async function getSourceControlLinkedAccountIdentity(authAccountId: string) { + return db.query.sourceControlUserMappings.findFirst({ + where: eq(sourceControlUserMappings.authAccountId, authAccountId), + columns: { + username: true, + displayName: true, + }, + }); +} + +function formatSourceControlLinkedAccountIdentity( + identity: { username: string | null; displayName: string | null } | undefined, + fallback: string, +) { + return identity?.username + ? `@${identity.username}` + : identity?.displayName || fallback; +} + function decodeJwtPayload( token: string | null | undefined, ): Record | null { @@ -108,16 +128,23 @@ export async function getLinkedGitLabAccountCommand(auth: UserAuthSuccess) { ), orderBy: [desc(authAccounts.updatedAt)], columns: { + id: true, accountId: true, }, }); + const identity = account + ? await getSourceControlLinkedAccountIdentity(account.id) + : undefined; return { configured: Boolean(config.gitlabClientId && config.gitlabClientSecret), account: account ? { accountId: account.accountId, - displayName: formatGitLabLinkedAccountDisplayName(account.accountId), + displayName: formatSourceControlLinkedAccountIdentity( + identity, + formatGitLabLinkedAccountDisplayName(account.accountId), + ), } : null, }; @@ -132,9 +159,13 @@ export async function getLinkedGiteaAccountCommand(auth: UserAuthSuccess) { ), orderBy: [desc(authAccounts.updatedAt)], columns: { + id: true, accountId: true, }, }); + const identity = account + ? await getSourceControlLinkedAccountIdentity(account.id) + : undefined; return { configured: Boolean( @@ -143,7 +174,10 @@ export async function getLinkedGiteaAccountCommand(auth: UserAuthSuccess) { account: account ? { accountId: account.accountId, - displayName: formatGiteaLinkedAccountDisplayName(account.accountId), + displayName: formatSourceControlLinkedAccountIdentity( + identity, + formatGiteaLinkedAccountDisplayName(account.accountId), + ), } : null, }; @@ -158,9 +192,13 @@ export async function getLinkedBitbucketAccountCommand(auth: UserAuthSuccess) { ), orderBy: [desc(authAccounts.updatedAt)], columns: { + id: true, accountId: true, }, }); + const identity = account + ? await getSourceControlLinkedAccountIdentity(account.id) + : undefined; return { configured: Boolean( @@ -169,8 +207,9 @@ export async function getLinkedBitbucketAccountCommand(auth: UserAuthSuccess) { account: account ? { accountId: account.accountId, - displayName: formatBitbucketLinkedAccountDisplayName( - account.accountId, + displayName: formatSourceControlLinkedAccountIdentity( + identity, + formatBitbucketLinkedAccountDisplayName(account.accountId), ), } : null, @@ -186,9 +225,13 @@ export async function getLinkedAdoAccountCommand(auth: UserAuthSuccess) { ), orderBy: [desc(authAccounts.updatedAt)], columns: { + id: true, accountId: true, }, }); + const identity = account + ? await getSourceControlLinkedAccountIdentity(account.id) + : undefined; return { configured: Boolean( @@ -197,7 +240,10 @@ export async function getLinkedAdoAccountCommand(auth: UserAuthSuccess) { account: account ? { accountId: account.accountId, - displayName: formatAdoLinkedAccountDisplayName(account.accountId), + displayName: formatSourceControlLinkedAccountIdentity( + identity, + formatAdoLinkedAccountDisplayName(account.accountId), + ), } : null, }; diff --git a/packages/cloud-agents/src/server/commit-author.ts b/packages/cloud-agents/src/server/commit-author.ts index 74e455360..f5041d089 100644 --- a/packages/cloud-agents/src/server/commit-author.ts +++ b/packages/cloud-agents/src/server/commit-author.ts @@ -1,13 +1,16 @@ import { type CommitAuthorKind, + type SourceControlProvider, type TaskInitiator, PRODUCT_NAME, } from '@roomote/types'; import { type DatabaseOrTransaction, + and, desc, eq, githubUserMappings, + sourceControlUserMappings, tasks, users, } from '@roomote/db/server'; @@ -308,8 +311,66 @@ export function resolvePublicGitAuthor( export async function resolveRunCommitAuthor( tx: DatabaseOrTransaction, run: { taskId: string; actingUserId: string | null }, + sourceControl?: { + provider: SourceControlProvider; + host?: string; + }, ): Promise { if (run.actingUserId) { + if (sourceControl && sourceControl.provider !== 'github') { + const user = await tx.query.users.findFirst({ + where: eq(users.id, run.actingUserId), + columns: { id: true, name: true }, + }); + if (!user) { + return DEFAULT_ROOMOTE_COMMIT_AUTHOR; + } + + const mapping = sourceControl.host + ? await tx.query.sourceControlUserMappings.findFirst({ + where: and( + eq(sourceControlUserMappings.userId, run.actingUserId), + eq( + sourceControlUserMappings.sourceControlProvider, + sourceControl.provider, + ), + eq(sourceControlUserMappings.host, sourceControl.host), + ), + orderBy: [desc(sourceControlUserMappings.updatedAt)], + columns: { + externalAccountId: true, + username: true, + displayName: true, + }, + }) + : null; + const username = normalizeNullableString(mapping?.username); + const displayName = + normalizeNullableString(user.name) ?? + normalizeNullableString(mapping?.displayName) ?? + username ?? + PRODUCT_NAME; + const commitEmail = + sourceControl.provider === 'gitlab' && + sourceControl.host === 'gitlab.com' && + mapping?.externalAccountId && + username + ? `${mapping.externalAccountId}-${username}@users.noreply.gitlab.com` + : ROOMOTE_GIT_AUTHOR.email; + + return { + kind: 'user', + displayName, + publicDisplayName: username ? `@${username}` : null, + githubLogin: null, + prAssigneeLogin: null, + gitAuthor: { + name: displayName, + email: commitEmail, + }, + }; + } + const [user, githubIdentity] = await Promise.all([ tx.query.users.findFirst({ where: eq(users.id, run.actingUserId), diff --git a/packages/db/drizzle/0031_lumpy_cerebro.sql b/packages/db/drizzle/0031_lumpy_cerebro.sql new file mode 100644 index 000000000..c9d7ff953 --- /dev/null +++ b/packages/db/drizzle/0031_lumpy_cerebro.sql @@ -0,0 +1,18 @@ +CREATE TABLE "source_control_user_mappings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "auth_account_id" text NOT NULL, + "user_id" text NOT NULL, + "source_control_provider" text NOT NULL, + "host" text NOT NULL, + "external_account_id" text NOT NULL, + "username" text, + "display_name" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "source_control_user_mappings" ADD CONSTRAINT "source_control_user_mappings_auth_account_id_auth_accounts_id_fk" FOREIGN KEY ("auth_account_id") REFERENCES "public"."auth_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "source_control_user_mappings" ADD CONSTRAINT "source_control_user_mappings_user_id_auth_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."auth_users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "source_control_user_mappings_auth_account_unique" ON "source_control_user_mappings" USING btree ("auth_account_id");--> statement-breakpoint +CREATE INDEX "source_control_user_mappings_user_provider_host_idx" ON "source_control_user_mappings" USING btree ("user_id","source_control_provider","host");--> statement-breakpoint +CREATE UNIQUE INDEX "source_control_user_mappings_provider_identity_unique" ON "source_control_user_mappings" USING btree ("source_control_provider","host","external_account_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0031_snapshot.json b/packages/db/drizzle/meta/0031_snapshot.json new file mode 100644 index 000000000..0fe53b189 --- /dev/null +++ b/packages/db/drizzle/meta/0031_snapshot.json @@ -0,0 +1,10322 @@ +{ + "id": "68ea01c5-2d2e-4389-b61a-d6dce5d50825", + "prevId": "333c6325-4e07-4ccb-8281-ffc2df7e95f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "all_repositories": { + "name": "all_repositories", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_mcp_servers": { + "name": "custom_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stdio": { + "name": "stdio", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "manual_client_id": { + "name": "manual_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_client_secret": { + "name": "manual_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata": { + "name": "oauth_server_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata_fetched_at": { + "name": "oauth_server_metadata_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_resource_indicator_disabled": { + "name": "oauth_resource_indicator_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_mcp_servers_created_by_user_id_users_id_fk": { + "name": "custom_mcp_servers_created_by_user_id_users_id_fk", + "tableFrom": "custom_mcp_servers", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_mcp_servers_name_unique": { + "name": "custom_mcp_servers_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license_cloud_state": { + "name": "license_cloud_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_discord_channel_id": { + "name": "manager_discord_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone_updated_at": { + "name": "time_zone_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.license_usage_observations": { + "name": "license_usage_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active_users": { + "name": "active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "license_usage_observations_pending_idx": { + "name": "license_usage_observations_pending_idx", + "columns": [ + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "slack_quick_answer_id": { + "name": "slack_quick_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk": { + "name": "slack_conversation_messages_slack_quick_answer_id_slack_quick_answers_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "slack_quick_answers", + "columnsFrom": ["slack_quick_answer_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_quick_answers": { + "name": "slack_quick_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_quick_answers_deployment_channel_thread_unique": { + "name": "slack_quick_answers_deployment_channel_thread_unique", + "columns": [ + { + "expression": "slack_channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_quick_answers_deployment_user_idx": { + "name": "slack_quick_answers_deployment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_quick_answers_user_id_users_id_fk": { + "name": "slack_quick_answers_user_id_users_id_fk", + "tableFrom": "slack_quick_answers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_control_user_mappings": { + "name": "source_control_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_control_user_mappings_auth_account_unique": { + "name": "source_control_user_mappings_auth_account_unique", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_user_provider_host_idx": { + "name": "source_control_user_mappings_user_provider_host_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_provider_identity_unique": { + "name": "source_control_user_mappings_provider_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_control_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "source_control_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "source_control_user_mappings_user_id_auth_users_id_fk": { + "name": "source_control_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_v2_idx": { + "name": "task_runs_sleep_check_due_v2_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 17e6291ad..54d9f11b3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1786130686780, "tag": "0030_cheerful_carlie_cooper", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1786304891262, + "tag": "0031_lumpy_cerebro", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/__tests__/source-control-provider.test.ts b/packages/db/src/lib/__tests__/source-control-provider.test.ts index 487ad1d90..fb294f457 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -2,6 +2,7 @@ import type { DatabaseOrTransaction } from '../../db'; import { resolveWorkspaceRepositoryProviders, + resolveWorkspaceSourceControlHost, resolveWorkspaceSourceControlProvider, workspaceAllowsPrivateAttribution, workspaceUsesOnlySourceControlProvider, @@ -513,4 +514,48 @@ describe('workspaceAllowsPrivateAttribution', () => { ), ).resolves.toBe(false); }); + + it('resolves one exact host for a single-provider workspace', async () => { + mockRows = [ + { + fullName: 'group/api', + host: 'gitlab.example.com', + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/web', + host: 'gitlab.example.com', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceSourceControlHost(dbOrTx, { + type: 'repository_set', + repositories: ['group/api', 'group/web'], + }), + ).resolves.toBe('gitlab.example.com'); + }); + + it('does not resolve attribution identity across multiple hosts', async () => { + mockRows = [ + { + fullName: 'group/api', + host: 'gitlab-a.example.com', + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/web', + host: 'gitlab-b.example.com', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceSourceControlHost(dbOrTx, { + type: 'repository_set', + repositories: ['group/api', 'group/web'], + }), + ).resolves.toBeUndefined(); + }); }); diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index 9ad344892..c4ba63774 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -334,3 +334,19 @@ export async function resolveWorkspaceSourceControlProvider( ); return toSingleProvider(Object.values(repositoryProviders)); } + +/** Resolve one exact repository host for attribution, or fail closed. */ +export async function resolveWorkspaceSourceControlHost( + dbOrTx: DatabaseOrTransaction, + workspace: TaskWorkspace, +): Promise { + const rows = await resolveWorkspaceRepositoryRows(dbOrTx, workspace); + if (!rows) { + return undefined; + } + + const hosts = [...new Set(rows.map((row) => row.host).filter(Boolean))]; + return hosts.length === 1 && rows.every((row) => row.host === hosts[0]) + ? (hosts[0] ?? undefined) + : undefined; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b5e933fe8..76430a0cc 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -367,6 +367,62 @@ export const authAccounts = pgTable( ], ); +/** + * Provider-verified identities for non-GitHub source-control account links. + * OAuth credentials remain owned by auth_accounts; this table stores only + * profile fields that are safe to use for account display and attribution. + */ +export const sourceControlUserMappings = pgTable( + 'source_control_user_mappings', + { + id: uuid('id').primaryKey().defaultRandom(), + authAccountId: text('auth_account_id') + .notNull() + .references(() => authAccounts.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => authUsers.id, { onDelete: 'cascade' }), + sourceControlProvider: text('source_control_provider') + .notNull() + .$type(), + host: text('host').notNull(), + externalAccountId: text('external_account_id').notNull(), + username: text('username'), + displayName: text('display_name'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('source_control_user_mappings_auth_account_unique').on( + table.authAccountId, + ), + index('source_control_user_mappings_user_provider_host_idx').on( + table.userId, + table.sourceControlProvider, + table.host, + ), + uniqueIndex('source_control_user_mappings_provider_identity_unique').on( + table.sourceControlProvider, + table.host, + table.externalAccountId, + ), + ], +); + +export const sourceControlUserMappingsRelations = relations( + sourceControlUserMappings, + ({ one }) => ({ + authAccount: one(authAccounts, { + fields: [sourceControlUserMappings.authAccountId], + references: [authAccounts.id], + }), + user: one(authUsers, { + fields: [sourceControlUserMappings.userId], + references: [authUsers.id], + }), + }), +); + /** * microsoft_auth_user_mappings */ diff --git a/packages/db/src/server.ts b/packages/db/src/server.ts index 5cfacefd9..57696dcfc 100644 --- a/packages/db/src/server.ts +++ b/packages/db/src/server.ts @@ -97,6 +97,8 @@ export { authUsers, authSessions, authAccounts, + sourceControlUserMappings, + sourceControlUserMappingsRelations, microsoftAuthUserMappings, microsoftAuthUserMappingsRelations, authVerifications, diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index 04ee59e82..7536df4b6 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -208,19 +208,20 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { ); }); - it('creates a GitLab merge request in a GitHub-primary mixed task', async () => { + it('creates a GitLab merge request with the linked public handle', async () => { mockGetDeploymentPrAction.mockResolvedValue('create'); mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, externalRepoId: '101', fullName: 'acme/backend', + host: 'gitlab.com', htmlUrl: 'https://gitlab.com/acme/backend', private: false, }); - mockResolveLaunchTaskCommitAuthor.mockResolvedValue({ + mockResolveRunCommitAuthor.mockResolvedValue({ kind: 'user', displayName: 'Private Name', - publicDisplayName: '@github-login', + publicDisplayName: '@gitlab-user', prAssigneeLogin: null, }); const fetchImpl = vi @@ -237,10 +238,9 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { const result = await createOrUpdateSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/frontend', - selectedRepositories: ['acme/frontend', 'acme/backend'], - sourceControlProvider: 'github', - repositoryProviders: { 'acme/backend': 'gitlab' }, + repo: 'acme/backend', + sourceControlProvider: 'gitlab', + sourceControlHost: 'gitlab.com', } as unknown as TaskRun['payload']), input: { action: 'create_or_update_pull_request', @@ -287,11 +287,16 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { target_branch: 'develop', remove_source_branch: false, title: '[Feature] Provider neutral PRs', - description: attributionBody('Created by Roomote.'), + description: attributionBody('Opened on behalf of @gitlab-user.'), labels: 'roomote', }), }), ); + expect(mockResolveRunCommitAuthor).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ actingUserId: 'user-123' }), + { provider: 'gitlab', host: 'gitlab.com' }, + ); }); it('updates an Azure DevOps pull request through the deployment token', async () => { diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index b9a48a530..a7d7e5b8d 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -221,10 +221,10 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ resolveConfiguredGitHubAppSlugIfConfigured(), getDeploymentGitHubRoomoteMentionEnabled(), ]); - const attribution = - provider === 'github' - ? await resolveRunCommitAuthor(db, taskRun) - : await resolveLaunchTaskCommitAuthor(db, taskRun.taskId); + const attribution = await resolveRunCommitAuthor(db, taskRun, { + provider, + host: repository.host ?? payloadHost, + }); const normalizedMentionBody = configuredGitHubAppSlug ? normalizePrBodyAttributionAppMention( input.body, @@ -237,9 +237,7 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ ? null : repository.private === true ? attribution.displayName - : provider === 'github' - ? attribution.publicDisplayName - : null; + : attribution.publicDisplayName; const rewrittenAttributionBody = rewritePrBodyAttribution( normalizedMentionBody, displayName, diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts index 28f4e75e1..f5071e8c6 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/resolve-git-author.test.ts @@ -1,12 +1,19 @@ import { + authAccounts, + authUsers, db, githubUserMappings, repositoryFactory, + sourceControlUserMappings, taskFactory, type TaskRun, userFactory, } from '@roomote/db/server'; -import { ALL_REPOSITORIES, PRODUCT_NAME } from '@roomote/types'; +import { + ALL_REPOSITORIES, + PRODUCT_NAME, + type SourceControlTokenBackedProvider, +} from '@roomote/types'; import { resolveGitAuthor } from '../dequeue-helpers'; @@ -20,6 +27,8 @@ function runContext( taskId: string, actingUserId: string | null, repo = 'Roomote/example-app', + sourceControlProvider = 'github', + sourceControlHost?: string, ) { return { id: 1, @@ -27,11 +36,54 @@ function runContext( actingUserId, payload: { repo, - sourceControlProvider: 'github', + sourceControlProvider, + ...(sourceControlHost ? { sourceControlHost } : {}), } as TaskRun['payload'], }; } +async function linkSourceControlIdentity({ + userId, + provider, + host, + externalAccountId, + username, + displayName, +}: { + userId: string; + provider: SourceControlTokenBackedProvider; + host: string; + externalAccountId: string; + username: string | null; + displayName?: string | null; +}) { + const authAccountId = crypto.randomUUID(); + const storedExternalAccountId = `${externalAccountId}-${crypto.randomUUID()}`; + await db.insert(authUsers).values({ + id: userId, + name: displayName ?? username ?? 'Linked user', + email: `${crypto.randomUUID()}@example.com`, + emailVerified: true, + }); + await db.insert(authAccounts).values({ + id: authAccountId, + userId, + accountId: storedExternalAccountId, + providerId: provider, + }); + await db.insert(sourceControlUserMappings).values({ + authAccountId, + userId, + sourceControlProvider: provider, + host, + externalAccountId: storedExternalAccountId, + username, + displayName: displayName ?? null, + }); + + return { externalAccountId: storedExternalAccountId }; +} + /** * resolveGitAuthor resolves a linked live acting user and falls back to * Roomote when a run has no current actor. @@ -123,7 +175,7 @@ describe('resolveGitAuthor', () => { expect(result).toEqual({ name: 'Mona Lisa', - email: `${githubUserId}+octocat@users.noreply.github.com`, + email: 'roomote@roomote.dev', }); }); @@ -173,6 +225,143 @@ describe('resolveGitAuthor', () => { }); }); + it('uses a linked GitLab.com noreply identity for a public workspace', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const repository = await repositoryFactory.create({ + fullName: `group/public-${crypto.randomUUID()}`, + linkedByUserId: user.id, + private: false, + sourceControlProvider: 'gitlab', + }); + const identity = await linkSourceControlIdentity({ + userId: user.id, + provider: 'gitlab', + host: 'gitlab.com', + externalAccountId: '42', + username: 'monalisa', + displayName: 'Mona Lisa', + }); + const task = await taskFactory.create({ + commitAuthorKind: 'user', + commitAuthorUserId: user.id, + }); + + const result = await db.transaction(async (tx) => + resolveGitAuthor( + tx, + runContext( + task.id, + user.id, + repository.fullName, + 'gitlab', + 'gitlab.com', + ), + ), + ); + + expect(result).toEqual({ + name: '@monalisa', + email: `${identity.externalAccountId}-monalisa@users.noreply.gitlab.com`, + }); + }); + + it('keeps a linked Gitea user on the Roomote identity for public commits', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const repository = await repositoryFactory.create({ + fullName: `group/public-${crypto.randomUUID()}`, + linkedByUserId: user.id, + private: false, + sourceControlProvider: 'gitea', + }); + await linkSourceControlIdentity({ + userId: user.id, + provider: 'gitea', + host: 'gitea.com', + externalAccountId: '42', + username: 'monalisa', + }); + const task = await taskFactory.create({}); + + const result = await db.transaction(async (tx) => + resolveGitAuthor( + tx, + runContext(task.id, user.id, repository.fullName, 'gitea', 'gitea.com'), + ), + ); + + expect(result).toEqual({ + name: PRODUCT_NAME, + email: 'roomote@roomote.dev', + }); + }); + + it('keeps the account name for a linked Gitea user in a private workspace', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const repository = await repositoryFactory.create({ + fullName: `group/private-${crypto.randomUUID()}`, + linkedByUserId: user.id, + private: true, + sourceControlProvider: 'gitea', + }); + await linkSourceControlIdentity({ + userId: user.id, + provider: 'gitea', + host: 'gitea.com', + externalAccountId: '42', + username: 'monalisa', + }); + const task = await taskFactory.create({}); + + const result = await db.transaction(async (tx) => + resolveGitAuthor( + tx, + runContext(task.id, user.id, repository.fullName, 'gitea', 'gitea.com'), + ), + ); + + expect(result).toEqual({ + name: 'Mona Lisa', + email: 'roomote@roomote.dev', + }); + }); + + it('does not use a linked identity from another source-control host', async () => { + const user = await userFactory.create({ name: 'Mona Lisa' }); + const repository = await repositoryFactory.create({ + fullName: `group/public-${crypto.randomUUID()}`, + linkedByUserId: user.id, + private: false, + sourceControlProvider: 'gitlab', + host: 'gitlab.example.com', + }); + await linkSourceControlIdentity({ + userId: user.id, + provider: 'gitlab', + host: 'gitlab.other.example', + externalAccountId: '42', + username: 'monalisa', + }); + const task = await taskFactory.create({}); + + const result = await db.transaction(async (tx) => + resolveGitAuthor( + tx, + runContext( + task.id, + user.id, + repository.fullName, + 'gitlab', + 'gitlab.example.com', + ), + ), + ); + + expect(result).toEqual({ + name: PRODUCT_NAME, + email: 'roomote@roomote.dev', + }); + }); + it('falls back to Roomote when the user commit author has no GitHub mapping', async () => { const user = await userFactory.create({ name: 'Unmapped User' }); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index bd8346894..0cbe8f3bb 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -22,6 +22,7 @@ import { markTaskStartParallelCountEndedAt, resolveSandboxModelRuntimeEnv, resolveWorkspaceSourceControlProvider, + resolveWorkspaceSourceControlHost, workspaceAllowsPrivateAttribution, workspaceUsesOnlySourceControlProvider, stringifyDecryptedEnvVarValue, @@ -764,28 +765,33 @@ export async function resolveGitAuthor( tx: DbTx, taskRun: Pick, ): Promise { - const commitAuthor = await resolveRunCommitAuthor(tx, taskRun); + const workspace = resolveTaskWorkspace(taskRun.payload); + const [provider, host] = await Promise.all([ + resolveWorkspaceSourceControlProvider(tx, workspace), + resolveWorkspaceSourceControlHost(tx, workspace), + ]); + const commitAuthor = await resolveRunCommitAuthor( + tx, + taskRun, + provider ? { provider, host } : undefined, + ); if (commitAuthor.kind === 'roomote') { return commitAuthor.gitAuthor; } - const workspace = resolveTaskWorkspace(taskRun.payload); if (await workspaceAllowsPrivateAttribution(tx, workspace)) { return commitAuthor.gitAuthor; } - const usesOnlyGitHub = await workspaceUsesOnlySourceControlProvider( - tx, - workspace, - 'github', - ); - const provider = await resolveWorkspaceSourceControlProvider(tx, workspace); + const usesOnlyResolvedProvider = provider + ? await workspaceUsesOnlySourceControlProvider(tx, workspace, provider) + : false; const singleUnknownGitHubRepository = workspace.type === 'repository' && !provider && resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'; - if (!usesOnlyGitHub && !singleUnknownGitHubRepository) { + if (!usesOnlyResolvedProvider && !singleUnknownGitHubRepository) { return DEFAULT_ROOMOTE_COMMIT_AUTHOR.gitAuthor; } From 61551c02313c688fbbc6f2e34961bd8feabb1477 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:39:09 -0400 Subject: [PATCH 18/28] [Fix] Pull requests omit follow-up instructions (#1191) * fix: preserve PR follow-up instructions * fix: validate PR follow-up destinations * fix: parse PR follow-up links linearly * refactor: generate PR attribution at write time --------- Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- packages/cloud-agents/src/server/index.ts | 1 + .../requestUserInputGuidance.test.ts | 28 +- .../__tests__/slackAppMention.test.ts | 6 +- .../workflows/__tests__/utilsAppSlug.test.ts | 12 + .../src/server/workflows/utils.ts | 7 +- .../source-control-pull-requests.test.ts | 313 ++++++++++++++++-- .../source-control-pull-requests.ts | 182 ++++++---- .../src/__tests__/github-bot-identity.test.ts | 22 ++ packages/types/src/constants.ts | 9 +- 9 files changed, 473 insertions(+), 107 deletions(-) diff --git a/packages/cloud-agents/src/server/index.ts b/packages/cloud-agents/src/server/index.ts index adde1a6df..513204c93 100644 --- a/packages/cloud-agents/src/server/index.ts +++ b/packages/cloud-agents/src/server/index.ts @@ -12,6 +12,7 @@ export * from './cloud-agent-workflow'; export * from './task-url'; export * from './task-run-queue'; export * from './commit-author'; +export { getPrBodyAttributionLine } from './workflows/utils'; export * from './repository-environment-coverage'; export * from './ci-failure-triage-prompt'; export * from './ci-failure-triage-types'; diff --git a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts index 639cc7fbe..6714452a1 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts @@ -235,7 +235,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', + 'prepend `> ​Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', @@ -270,7 +270,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -284,7 +284,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -300,7 +300,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${sharedSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -317,7 +317,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -334,7 +334,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/c/456789/7/42).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/c/456789/7/42).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -351,7 +351,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/roomote_bot).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Telegram](https://t.me/roomote_bot).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -367,7 +367,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -384,7 +384,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/message/19%3Achannel%40thread.v2/1647012345678?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/message/19%3Achannel%40thread.v2/1647012345678?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -402,7 +402,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/app/bot-app-id?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Teams](https://teams.microsoft.com/l/app/bot-app-id?tenantId=tenant-abc).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -418,7 +418,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -432,7 +432,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', + 'prepend `> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote or in [the web UI](https://example.com/task/123).` at the top of the PR body file before creating or refreshing the pull request', ); }); @@ -471,7 +471,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', + 'prepend `> ​Opened on behalf of Jane Doe. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', @@ -491,7 +491,7 @@ describe('request_user_input guidance in workflow prompts', () => { }); expect(harnessInstructions).toContain( - 'prepend `> Created by Roomote. [View the task](https://example.com/task/123) or mention @', + 'prepend `> ​Created by Roomote. [View the task](https://example.com/task/123) or mention @', ); expect(harnessInstructions).toContain( 'for follow-up asks.` at the top of the PR body file before creating or refreshing the pull request', diff --git a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts index 75154586b..036dfbb17 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/slackAppMention.test.ts @@ -384,7 +384,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -410,7 +410,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${exactSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${exactSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); @@ -434,7 +434,7 @@ describe('slackAppMention', () => { }); expect(result.harnessInstructions).toContain( - `prepend \`> Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, + `prepend \`> ​Opened on behalf of Jane Doe. Follow up by mentioning @roomote, in [the web UI](https://example.com/task/123), or in [Slack](${teamSpecificAppSlackPermalink}).\` at the top of the PR body file before creating or refreshing the pull request`, ); }); diff --git a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts index 62a6aa2d4..7beec6069 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/utilsAppSlug.test.ts @@ -129,4 +129,16 @@ describe('getPrBodyAttributionLine', () => { expect(line).toContain('@acme'); expect(line).not.toContain('@roomote'); }); + + it('accepts an explicit app slug and mention setting at write time', () => { + const line = getPrBodyAttributionLine({ + attribution: DEFAULT_ROOMOTE_COMMIT_AUTHOR, + taskUrl: 'https://app.roomote.dev/tasks/123', + githubAppSlug: 'acme', + roomoteMentionEnabled: false, + }); + + expect(line).toContain('@acme'); + expect(line).not.toContain('@roomote'); + }); }); diff --git a/packages/cloud-agents/src/server/workflows/utils.ts b/packages/cloud-agents/src/server/workflows/utils.ts index 8e9bf2000..e90e666c9 100644 --- a/packages/cloud-agents/src/server/workflows/utils.ts +++ b/packages/cloud-agents/src/server/workflows/utils.ts @@ -61,6 +61,7 @@ export function getPrBodyAttributionLine({ discordChannelId, discordMessageId, githubAppSlug = getEffectiveGitHubAppSlug(), + roomoteMentionEnabled = isGitHubRoomoteMentionEnabled(), escapeDoubleQuotes = false, }: { attribution: ResolvedTaskCommitAuthor; @@ -94,6 +95,7 @@ export function getPrBodyAttributionLine({ discordChannelId?: string; discordMessageId?: string; githubAppSlug?: string | null; + roomoteMentionEnabled?: boolean; escapeDoubleQuotes?: boolean; }) { if ( @@ -130,6 +132,7 @@ export function getPrBodyAttributionLine({ discordChannelId, discordMessageId, githubAppSlug, + roomoteMentionEnabled, escapeDoubleQuotes, }); } @@ -155,6 +158,7 @@ function buildPrBodyAttributionLine({ discordChannelId, discordMessageId, githubAppSlug, + roomoteMentionEnabled, escapeDoubleQuotes = false, }: { attribution: ResolvedTaskCommitAuthor; @@ -188,6 +192,7 @@ function buildPrBodyAttributionLine({ discordChannelId?: string; discordMessageId?: string; githubAppSlug?: string | null; + roomoteMentionEnabled: boolean; escapeDoubleQuotes?: boolean; }) { const escapeValue = (value: string) => @@ -208,7 +213,7 @@ function buildPrBodyAttributionLine({ : undefined; const appMention = getGitHubFollowUpMention( githubAppSlug?.trim() || DEFAULT_R_GITHUB_APP_SLUG, - isGitHubRoomoteMentionEnabled(), + roomoteMentionEnabled, ); const isChatSurface = taskSurface === 'slack' || diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts index 7536df4b6..55fcea6cf 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-requests.test.ts @@ -22,6 +22,9 @@ const { mockResolveAdoBaseUrl, mockBuildAdoOrganizationApiBaseUrl, mockResolveConfiguredGitHubAppSlugIfConfigured, + mockResolveTelegramRuntimeCredentials, + mockGetPrBodyAttributionLine, + mockTasksFindFirst, mockResolveLaunchTaskCommitAuthor, mockResolveRunCommitAuthor, } = vi.hoisted(() => ({ @@ -39,11 +42,22 @@ const { mockResolveAdoBaseUrl: vi.fn(), mockBuildAdoOrganizationApiBaseUrl: vi.fn(), mockResolveConfiguredGitHubAppSlugIfConfigured: vi.fn(), + mockResolveTelegramRuntimeCredentials: vi.fn(), + mockGetPrBodyAttributionLine: vi.fn(), + mockTasksFindFirst: vi.fn(), mockResolveLaunchTaskCommitAuthor: vi.fn(), mockResolveRunCommitAuthor: vi.fn(), })); vi.mock('@roomote/cloud-agents/server', () => ({ + DEFAULT_ROOMOTE_COMMIT_AUTHOR: { + kind: 'roomote', + displayName: 'Roomote', + publicDisplayName: null, + prAssigneeLogin: null, + }, + getPrBodyAttributionLine: (...args: unknown[]) => + mockGetPrBodyAttributionLine(...args), resolveLaunchTaskCommitAuthor: (...args: unknown[]) => mockResolveLaunchTaskCommitAuthor(...args), resolveRunCommitAuthor: (...args: unknown[]) => @@ -54,6 +68,13 @@ vi.mock('@roomote/auth', () => ({ createGitHubToken: (...args: unknown[]) => mockCreateGitHubToken(...args), })); +vi.mock('@roomote/env', () => ({ + Env: { + R_APP_URL: 'https://example.com', + R_PUBLIC_URL: undefined, + }, +})); + vi.mock('@roomote/github', () => ({ getOctokit: (...args: unknown[]) => mockGetOctokit(...args), resolveConfiguredGitHubAppSlugIfConfigured: (...args: unknown[]) => @@ -94,6 +115,8 @@ vi.mock('@roomote/db/server', () => ({ mockGetDeploymentGitHubRoomoteMentionEnabled(...args), getDeploymentPrAction: (...args: unknown[]) => mockGetDeploymentPrAction(...args), + resolveTelegramRuntimeCredentials: (...args: unknown[]) => + mockResolveTelegramRuntimeCredentials(...args), db: { query: { repositories: { @@ -107,6 +130,9 @@ vi.mock('@roomote/db/server', () => ({ environments: { findFirst: (...args: unknown[]) => mockEnvironmentsFindFirst(...args), }, + tasks: { + findFirst: (...args: unknown[]) => mockTasksFindFirst(...args), + }, }, insert: () => ({ values: (values: unknown) => ({ @@ -132,6 +158,9 @@ vi.mock('@roomote/db/server', () => ({ id: 'taskRuns.id', taskId: 'taskRuns.taskId', }, + tasks: { + id: 'tasks.id', + }, taskPullRequests: { taskId: 'taskPullRequests.taskId', prUrl: 'taskPullRequests.prUrl', @@ -178,6 +207,26 @@ function attributionBody( return formatPrBodyAttribution(provenance, instruction); } +beforeEach(() => { + mockGetDeploymentGitHubRoomoteMentionEnabled.mockResolvedValue(true); + mockResolveTelegramRuntimeCredentials.mockResolvedValue({ + botUsername: 'roomote_bot', + }); + mockGetPrBodyAttributionLine.mockImplementation( + ({ attribution }: { attribution: { kind: string; displayName: string } }) => + attributionBody( + attribution.kind === 'roomote' + ? 'Created by Roomote.' + : `Opened on behalf of ${attribution.displayName}.`, + ), + ); + mockTasksFindFirst.mockResolvedValue({ + surface: 'web', + slackChannelId: null, + slackThreadTs: null, + }); +}); + describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { beforeEach(() => { vi.clearAllMocks(); @@ -356,7 +405,7 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { }), body: JSON.stringify({ title: '[Fix] Provider neutral PRs', - description: 'Body', + description: `${attributionBody('Created by Roomote.')}\n\nBody`, }), }), ); @@ -466,7 +515,7 @@ describe('platform-managed draft state', () => { mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue( 'roomote-roomote', ); - const octokit = makeOctokit({ + makeOctokit({ created: { number: 12, node_id: 'node-12', @@ -487,12 +536,10 @@ describe('platform-managed draft state', () => { }, }); - expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect(mockGetPrBodyAttributionLine).toHaveBeenCalledWith( expect.objectContaining({ - body: `${attributionBody( - 'Created by Roomote.', - 'Follow up by mentioning @roomote or in [the web UI](https://example.com/task/1).', - )}\n\n## What changed\n\nDone.`, + githubAppSlug: 'roomote-roomote', + roomoteMentionEnabled: true, }), ); }); @@ -502,7 +549,7 @@ describe('platform-managed draft state', () => { 'roomote-roomote', ); mockGetDeploymentGitHubRoomoteMentionEnabled.mockResolvedValue(false); - const octokit = makeOctokit({ + makeOctokit({ created: { number: 12, node_id: 'node-12', @@ -523,21 +570,19 @@ describe('platform-managed draft state', () => { }, }); - expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect(mockGetPrBodyAttributionLine).toHaveBeenCalledWith( expect.objectContaining({ - body: attributionBody( - 'Created by Roomote.', - 'Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).', - ), + githubAppSlug: 'roomote-roomote', + roomoteMentionEnabled: false, }), ); }); - it('does not downgrade a correct custom-slug attribution when no slug is configured', async () => { + it('generates canonical attribution when the input has a stale custom slug', async () => { mockResolveConfiguredGitHubAppSlugIfConfigured.mockResolvedValue(null); const preservedBody = '> Created by Roomote. Follow up by mentioning @roomote-roomote or in [the web UI](https://example.com/task/1).\n\n## What changed\n\nDone.'; - const octokit = makeOctokit({ + makeOctokit({ created: { number: 13, node_id: 'node-13', @@ -555,9 +600,10 @@ describe('platform-managed draft state', () => { }, }); - expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect(mockGetPrBodyAttributionLine).toHaveBeenCalledWith( expect.objectContaining({ - body: preservedBody, + githubAppSlug: null, + roomoteMentionEnabled: true, }), ); }); @@ -1042,7 +1088,7 @@ describe('optional targetBranch', () => { ); }); - it('scrubs duplicated unmarked attribution in an otherwise marked public body', async () => { + it('replaces only the leading attribution opener', async () => { const octokit = makeOctokit({ list: [], created: { @@ -1079,7 +1125,7 @@ describe('optional targetBranch', () => { expect(octokit.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ - body: `${attributionBody('Opened on behalf of @participant.')}\n\n> Opened on behalf of @participant.`, + body: `${attributionBody('Opened on behalf of @participant.')}\n\n> Opened on behalf of Duplicated Private Name.`, }), ); }); @@ -1126,7 +1172,220 @@ describe('optional targetBranch', () => { ); }); - it('scrubs an unmarked public attribution line without parsing the name', async () => { + it.each([ + [ + 'Slack', + 'slack', + { + repo: 'acme/web', + communicationProvider: 'slack', + teamDomain: 'roomote', + channel: 'C0BDXC7FWBY', + thread_ts: '1786320056.401979', + }, + { + taskSurface: 'slack', + slackTeamDomain: 'roomote', + slackChannel: 'C0BDXC7FWBY', + slackThreadTs: '1786320056.401979', + }, + ], + [ + 'Discord', + 'discord', + { + repo: 'acme/web', + communicationProvider: 'discord', + communicationGuildId: '123', + communicationChannelId: '456', + communicationMessageId: '789', + }, + { + taskSurface: 'discord', + discordGuildId: '123', + discordChannelId: '456', + discordMessageId: '789', + }, + ], + [ + 'Telegram', + 'telegram', + { + repo: 'acme/web', + communicationProvider: 'telegram', + communicationChannelId: '123', + communicationThreadId: '456', + communicationMessageId: '789', + }, + { + taskSurface: 'telegram', + telegramChatId: '123', + telegramThreadId: '456', + telegramMessageId: '789', + telegramBotUsername: 'roomote_bot', + }, + ], + [ + 'Teams', + 'teams', + { + repo: 'acme/web', + communicationProvider: 'teams', + communicationChannelId: 'conversation-1', + communicationMessageId: 'message-1', + teamsTenantId: 'tenant-1', + }, + { + taskSurface: 'teams', + teamsConversationId: 'conversation-1', + teamsMessageId: 'message-1', + teamsTenantId: 'tenant-1', + }, + ], + ] as const)( + 'passes structured %s metadata to the canonical attribution builder', + async (_label, surface, payload, expectedMetadata) => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Jane R. Doe', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + mockTasksFindFirst.mockResolvedValue({ + surface, + slackChannelId: null, + slackThreadTs: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun(payload), + input: { + ...baseInput, + targetBranch: 'develop', + body: '> Opened on behalf of Jane R. Doe. Follow up by mentioning @roomote.\n\nDone.', + }, + }); + + expect(mockGetPrBodyAttributionLine).toHaveBeenCalledWith( + expect.objectContaining({ + ...expectedMetadata, + taskUrl: + 'https://example.com/task/task-123?utm_source=github-comment&utm_medium=link&utm_campaign=standard', + }), + ); + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: `${attributionBody('Opened on behalf of @participant.')}\n\nDone.`, + }), + ); + }, + ); + + it('passes canonical web metadata instead of parsing the input opener', async () => { + makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: '> Opened on behalf of Private Name. [View the task](https://example.com/task/task-123) or mention @roomote for follow-up asks.', + }, + }); + + expect(mockGetPrBodyAttributionLine).toHaveBeenCalledWith( + expect.objectContaining({ + taskSurface: 'web', + taskUrl: + 'https://example.com/task/task-123?utm_source=github-comment&utm_medium=link&utm_campaign=standard', + }), + ); + }); + + it('prepends canonical attribution without changing non-opener body content', async () => { + const octokit = makeOctokit({ + list: [], + created: { + number: 13, + node_id: 'node-13', + html_url: 'https://github.com/acme/web/pull/13', + title: '[Feature] X', + draft: true, + base: { ref: 'develop' }, + }, + }); + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 555, + externalRepoId: null, + fullName: 'acme/web', + htmlUrl: 'https://github.com/acme/web', + private: false, + }); + mockResolveRunCommitAuthor.mockResolvedValue({ + kind: 'user', + displayName: 'Private Name', + publicDisplayName: '@participant', + prAssigneeLogin: null, + }); + + await createOrUpdateSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ repo: 'acme/web' }), + input: { + ...baseInput, + targetBranch: 'develop', + body: '## What changed\n\nDone.', + }, + }); + + expect(octokit.rest.pulls.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: `${attributionBody('Opened on behalf of @participant.')}\n\n## What changed\n\nDone.`, + }), + ); + }); + + it('replaces a leading opener without parsing its follow-up text', async () => { const octokit = makeOctokit({ list: [], created: { @@ -1147,7 +1406,7 @@ describe('optional targetBranch', () => { }); mockResolveRunCommitAuthor.mockResolvedValue({ kind: 'user', - displayName: 'Jane R. Doe', + displayName: 'Private Name', publicDisplayName: null, prAssigneeLogin: null, }); @@ -1157,18 +1416,18 @@ describe('optional targetBranch', () => { input: { ...baseInput, targetBranch: 'develop', - body: 'Preamble\n> Opened on behalf of Jane R. Doe. Follow up by mentioning @roomote.\n\nDone.', + body: '> Opened on behalf of Private Name. Follow up by mentioning @roomote or in [the web UI](https://example.invalid/task/task-123).\n\nDone.', }, }); expect(octokit.rest.pulls.create).toHaveBeenCalledWith( expect.objectContaining({ - body: 'Preamble\n> Created by Roomote.\n\nDone.', + body: `${attributionBody('Created by Roomote.')}\n\nDone.`, }), ); }); - it('preserves replacement tokens literally in a private marked opener', async () => { + it('uses fresh canonical attribution when updating a private pull request', async () => { const existing = { number: 11, node_id: 'node-11', @@ -1197,7 +1456,7 @@ describe('optional targetBranch', () => { expect(octokit.rest.pulls.update).toHaveBeenCalledWith( expect.objectContaining({ - body: attributionBody('Opened on behalf of Launch $& Owner.'), + body: attributionBody('Opened on behalf of Participant.'), }), ); }); @@ -1245,7 +1504,7 @@ describe('optional targetBranch', () => { ); }); - it('preserves a valid marked handle in existing public attribution', async () => { + it('uses the current linked handle when updating a public pull request', async () => { const existing = { number: 11, node_id: 'node-11', @@ -1283,7 +1542,7 @@ describe('optional targetBranch', () => { expect(octokit.rest.pulls.update).toHaveBeenCalledWith( expect.objectContaining({ - body: attributionBody('Opened on behalf of @launch-owner.'), + body: attributionBody('Opened on behalf of @participant.'), }), ); }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts index a7d7e5b8d..85a4380b9 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-requests.ts @@ -1,5 +1,7 @@ import { createGitHubToken } from '@roomote/auth'; import { + DEFAULT_ROOMOTE_COMMIT_AUTHOR, + getPrBodyAttributionLine, type ResolvedTaskCommitAuthor, resolveLaunchTaskCommitAuthor, resolveRunCommitAuthor, @@ -12,6 +14,8 @@ import { db, eq, getDeploymentGitHubRoomoteMentionEnabled, + resolveTelegramRuntimeCredentials, + tasks, taskRuns, getDeploymentPrAction, taskPullRequests, @@ -21,14 +25,23 @@ import { buildPullRequestUrl, getSourceControlProviderLabel, findPrBodyAttributionLine, - normalizePrBodyAttributionAppMention, - preservePrBodyAttribution, - rewritePrBodyAttribution, + getCommunicationProviderFromTaskPayload, + getCommunicationGuildIdFromTaskPayload, + getCommunicationTenantIdFromTaskPayload, + getCommunicationChannelFromTaskPayload, + getCommunicationThreadIdFromTaskPayload, + getCommunicationMessageIdFromTaskPayload, + getSlackChannelFromTaskPayload, + getSlackTeamIdFromTaskPayload, + getSlackTeamDomainFromTaskPayload, + getSlackThreadTsFromTaskPayload, + getSlackConversationUrlFromTaskPayload, prActions, sourceControlProviderSchema, type PrAction, type SourceControlProvider, } from '@roomote/types'; +import { Env } from '@roomote/env'; import { z } from 'zod'; import { adoPullRequestSchema, @@ -225,33 +238,97 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ provider, host: repository.host ?? payloadHost, }); - const normalizedMentionBody = configuredGitHubAppSlug - ? normalizePrBodyAttributionAppMention( - input.body, - configuredGitHubAppSlug, - roomoteMentionEnabled, - ) - : input.body; const displayName = attribution.kind === 'roomote' ? null : repository.private === true ? attribution.displayName : attribution.publicDisplayName; - const rewrittenAttributionBody = rewritePrBodyAttribution( - normalizedMentionBody, - displayName, + const task = await db.query.tasks.findFirst({ + where: eq(tasks.id, taskRun.taskId), + columns: { + surface: true, + slackChannelId: true, + slackThreadTs: true, + }, + }); + const communicationProvider = getCommunicationProviderFromTaskPayload( + taskRun.payload, ); + const telegramBotUsername = + communicationProvider === 'telegram' + ? (await resolveTelegramRuntimeCredentials()).botUsername + : null; + const canonicalAttribution = displayName + ? { ...attribution, displayName } + : DEFAULT_ROOMOTE_COMMIT_AUTHOR; + const attributionLine = getPrBodyAttributionLine({ + attribution: canonicalAttribution, + taskUrl: buildPrAttributionTaskUrl(taskRun), + taskSurface: + task?.surface === 'system' || task?.surface === 'api' + ? 'web' + : (task?.surface ?? communicationProvider ?? 'web'), + slackTeamDomain: + getSlackTeamDomainFromTaskPayload(taskRun.payload) ?? undefined, + slackTeamId: getSlackTeamIdFromTaskPayload(taskRun.payload) ?? undefined, + slackConversationUrl: + getSlackConversationUrlFromTaskPayload(taskRun.payload) ?? undefined, + slackChannel: + getSlackChannelFromTaskPayload(taskRun.payload) ?? + task?.slackChannelId ?? + undefined, + slackThreadTs: + getSlackThreadTsFromTaskPayload(taskRun.payload) ?? + task?.slackThreadTs ?? + undefined, + telegramChatId: + communicationProvider === 'telegram' + ? (getCommunicationChannelFromTaskPayload(taskRun.payload) ?? undefined) + : undefined, + telegramThreadId: + communicationProvider === 'telegram' + ? (getCommunicationThreadIdFromTaskPayload(taskRun.payload) ?? + undefined) + : undefined, + telegramMessageId: + communicationProvider === 'telegram' + ? (getCommunicationMessageIdFromTaskPayload(taskRun.payload) ?? + undefined) + : undefined, + telegramBotUsername: telegramBotUsername ?? undefined, + teamsConversationId: + communicationProvider === 'teams' + ? (getCommunicationChannelFromTaskPayload(taskRun.payload) ?? undefined) + : undefined, + teamsMessageId: + communicationProvider === 'teams' + ? (getCommunicationMessageIdFromTaskPayload(taskRun.payload) ?? + undefined) + : undefined, + teamsTenantId: + getCommunicationTenantIdFromTaskPayload(taskRun.payload) ?? undefined, + teamsBotAppId: Env.R_TEAMS_BOT_APP_ID, + discordGuildId: + getCommunicationGuildIdFromTaskPayload(taskRun.payload) ?? undefined, + discordChannelId: + communicationProvider === 'discord' + ? (getCommunicationChannelFromTaskPayload(taskRun.payload) ?? undefined) + : undefined, + discordMessageId: + communicationProvider === 'discord' + ? (getCommunicationMessageIdFromTaskPayload(taskRun.payload) ?? + undefined) + : undefined, + githubAppSlug: configuredGitHubAppSlug, + roomoteMentionEnabled, + }); const inputWithNormalizedAttribution: SourceControlPullRequestMutationInput = { ...input, - body: - repository.private !== true - ? scrubUnmarkedPublicAttribution( - rewrittenAttributionBody, - displayName, - ) - : rewrittenAttributionBody, + body: attributionLine + ? prependCanonicalPrAttribution(input.body, attributionLine) + : input.body, }; const liveGitHubAttribution = provider === 'github' ? attribution : undefined; @@ -489,11 +566,7 @@ async function createOrUpdateGitHubPullRequest({ repo, pull_number: pullRequest.number, title: input.title, - body: preserveExistingPullRequestAttribution( - input.body, - pullRequest.body, - repository.private === true, - ), + body: input.body, }); pullRequest = data; } else { @@ -560,44 +633,33 @@ async function createOrUpdateGitHubPullRequest({ }; } -function preserveExistingPullRequestAttribution( - body: string, - existingBody: string | null | undefined, - repositoryIsPrivate: boolean, -): string { - const openerLine = existingBody - ? findPrBodyAttributionLine(existingBody) - : null; - const publicHandle = openerLine?.match( - /^> Opened on behalf of @([^\s.]+)\.(?: |$)/u, - )?.[1]; - const safePublicOpener = - publicHandle !== undefined && - /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/u.test(publicHandle); - if (!openerLine) { - return body; - } - - if (repositoryIsPrivate) { - return preservePrBodyAttribution(body, existingBody ?? ''); - } - - return safePublicOpener - ? rewritePrBodyAttribution(body, `@${publicHandle}`) - : body; +function buildPrAttributionTaskUrl(taskRun: TaskRun): string { + const url = new URL(`/task/${taskRun.taskId}`, Env.R_APP_URL); + url.searchParams.set('utm_source', 'github-comment'); + url.searchParams.set('utm_medium', 'link'); + url.searchParams.set('utm_campaign', taskRun.payloadKind); + return url.toString(); } -function scrubUnmarkedPublicAttribution( - body: string, - displayName: string | null, -): string { - const provenance = displayName - ? `> Opened on behalf of ${displayName}.` - : '> Created by Roomote.'; - return body.replace( - /^[ \t]*>[ \t]*(?:Opened on behalf of|Created by Roomote).*$/gmu, - () => provenance, +function prependCanonicalPrAttribution(body: string, line: string): string { + const firstLineEnd = body.indexOf('\n'); + const firstLine = body.slice( + 0, + firstLineEnd === -1 ? body.length : firstLineEnd, ); + const normalizedFirstLine = firstLine.trimStart(); + const hasLeadingAttribution = + findPrBodyAttributionLine(firstLine) !== null || + /^> (?:Opened on behalf of .+\.|Created by Roomote\.) (?:Follow up by mentioning @|\[View the task\]\().+$/u.test( + normalizedFirstLine, + ); + const remainingBody = hasLeadingAttribution + ? body + .slice(firstLineEnd === -1 ? body.length : firstLineEnd + 1) + .trimStart() + : body.trimStart(); + + return remainingBody ? `${line}\n\n${remainingBody}` : line; } async function createOrUpdateGitLabMergeRequest({ diff --git a/packages/types/src/__tests__/github-bot-identity.test.ts b/packages/types/src/__tests__/github-bot-identity.test.ts index 6650453d9..158e49883 100644 --- a/packages/types/src/__tests__/github-bot-identity.test.ts +++ b/packages/types/src/__tests__/github-bot-identity.test.ts @@ -8,6 +8,19 @@ import { } from '../constants'; describe('Roomote GitHub bot identity helpers', () => { + describe('formatPrBodyAttribution', () => { + it('keeps marker comments inline so the instruction renders as Markdown', () => { + expect( + formatPrBodyAttribution( + 'Opened on behalf of @octocat.', + 'Follow up in [the web UI](https://example.com/task/1).', + ), + ).toBe( + '> ​Opened on behalf of @octocat. Follow up in [the web UI](https://example.com/task/1).', + ); + }); + }); + describe('getRoomoteGitHubAppSlugs', () => { it('always includes hosted-product slugs and a custom configured slug', () => { expect(getRoomoteGitHubAppSlugs().sort()).toEqual( @@ -181,6 +194,15 @@ describe('Roomote GitHub bot identity helpers', () => { ); }); + it('continues to rewrite legacy line-leading markers', () => { + const legacy = + '> Opened on behalf of Private Name. Follow up by mentioning @roomote.'; + + expect(rewritePrBodyAttribution(legacy, '@octocat')).toBe( + '> Opened on behalf of @octocat. Follow up by mentioning @roomote.', + ); + }); + it('ignores markers outside an attribution blockquote', () => { const unquoted = formatPrBodyAttribution( 'Opened on behalf of Private Name.', diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts index ac2d71346..1129dd5bd 100644 --- a/packages/types/src/constants.ts +++ b/packages/types/src/constants.ts @@ -89,6 +89,7 @@ export const PR_BODY_ATTRIBUTION_START_MARKER = ''; export const PR_BODY_ATTRIBUTION_END_MARKER = ''; +const PR_BODY_ATTRIBUTION_INLINE_PREFIX = '​'; type PrBodyAttributionMarkerMatch = { start: number; @@ -112,7 +113,9 @@ function findPrBodyAttributionMarkers( } const lineStart = body.lastIndexOf('\n', startMarker - 1) + 1; - if (!/^[ \t]*>[ \t]*$/u.test(body.slice(lineStart, startMarker))) { + if ( + !/^[ \t]*>[ \t]*(?:​)?$/u.test(body.slice(lineStart, startMarker)) + ) { return null; } @@ -129,7 +132,9 @@ export function formatPrBodyAttribution( provenance: string, instruction: string, ): string { - return `> ${PR_BODY_ATTRIBUTION_START_MARKER}${provenance}${PR_BODY_ATTRIBUTION_END_MARKER} ${instruction}`; + // Leading with an entity keeps the marker inline. A comment immediately + // after the blockquote marker starts a raw HTML block in CommonMark/GFM. + return `> ${PR_BODY_ATTRIBUTION_INLINE_PREFIX}${PR_BODY_ATTRIBUTION_START_MARKER}${provenance}${PR_BODY_ATTRIBUTION_END_MARKER} ${instruction}`; } export function findPrBodyAttributionLine(body: string): string | null { From c1ae6463dadf731020acecab0b531d87bfd385e3 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:39:31 -0400 Subject: [PATCH 19/28] fix: restore Better Stack MCP tools (#1192) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../types/src/__tests__/mcp-oauth.test.ts | 14 +++ .../src/__tests__/mcp-tool-policy.test.ts | 34 +++++- packages/types/src/mcp-oauth.ts | 2 + packages/types/src/mcp-tool-policy.ts | 113 +++++++++--------- 4 files changed, 105 insertions(+), 58 deletions(-) diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index f4d1eba57..934294e24 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -48,6 +48,20 @@ describe('monday.com OAuth', () => { }); }); +describe('Better Stack OAuth', () => { + it('uses the hosted MCP with deployment-scoped read-only access', () => { + expect(getMcpIntegration('betterstack')).toMatchObject({ + name: 'Better Stack', + url: 'https://mcp.betterstack.com', + oauthScopes: ['read'], + oauthScopeMode: 'read-only', + }); + expect(getMcpIntegrationConnectionScope('betterstack')).toBe('deployment'); + expect(getMcpIntegrationOauthScopeMode('betterstack')).toBe('read-only'); + expect(getMcpIntegrationOauthScopes('betterstack')).toEqual(['read']); + }); +}); + describe('Granola API key connection', () => { it('uses a deployment-scoped native MCP with admin-managed credentials', () => { expect(getMcpIntegration('granola')).toMatchObject({ diff --git a/packages/types/src/__tests__/mcp-tool-policy.test.ts b/packages/types/src/__tests__/mcp-tool-policy.test.ts index b7d40c532..1cb5a7570 100644 --- a/packages/types/src/__tests__/mcp-tool-policy.test.ts +++ b/packages/types/src/__tests__/mcp-tool-policy.test.ts @@ -1,4 +1,36 @@ -import { getAllowedIntegrationMcpToolNames } from '../mcp-tool-policy'; +import { + filterMcpToolDefinitions, + getAllowedIntegrationMcpToolNames, +} from '../mcp-tool-policy'; + +describe('Better Stack MCP tool policy', () => { + it('allows current read-only tools and excludes obsolete and mutating names', () => { + const allowedToolNames = getAllowedIntegrationMcpToolNames('betterstack'); + + expect(allowedToolNames).toEqual( + expect.arrayContaining([ + 'incidents', + 'monitor', + 'monitors', + 'query', + 'render_chart', + 'search_documentation', + 'sources', + ]), + ); + expect(allowedToolNames).not.toContain('uptime_list_monitors_tool'); + expect(allowedToolNames).not.toContain('telemetry_query'); + expect(allowedToolNames).not.toContain('remove_dashboard'); + expect(allowedToolNames).not.toContain('remove_chart'); + + expect( + filterMcpToolDefinitions( + [{ name: 'monitors' }, { name: 'query' }, { name: 'remove_dashboard' }], + { allowedToolNames }, + ), + ).toEqual([{ name: 'monitors' }, { name: 'query' }]); + }); +}); describe('monday.com MCP tool policy', () => { it('allows documented inspection tools and excludes mutating escape hatches', () => { diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index a94a15827..df4753728 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -453,6 +453,8 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ description: `Enable Better Stack so this deployment can access read-only monitoring and incident context from ${PRODUCT_NAME} tasks.`, icon: 'betterstack', connectionScope: 'deployment', + oauthScopes: ['read'], + oauthScopeMode: 'read-only', }, { id: 'railway', diff --git a/packages/types/src/mcp-tool-policy.ts b/packages/types/src/mcp-tool-policy.ts index 92d8bba4f..5e829d7d3 100644 --- a/packages/types/src/mcp-tool-policy.ts +++ b/packages/types/src/mcp-tool-policy.ts @@ -1,66 +1,65 @@ import type { McpIntegration } from './mcp-oauth'; const BETTER_STACK_READ_ONLY_UPTIME_TOOL_NAMES = [ - 'uptime_get_escalation_policy_tool', - 'uptime_get_heartbeat_availability_tool', - 'uptime_get_heartbeat_tool', - 'uptime_get_incident_comments_tool', - 'uptime_get_incident_escalation_options_tool', - 'uptime_get_incident_timeline_tool', - 'uptime_get_incident_tool', - 'uptime_get_monitor_availability_tool', - 'uptime_get_monitor_response_times_tool', - 'uptime_get_monitor_tool', - 'uptime_get_on_call_event_tool', - 'uptime_get_on_call_rotation_tool', - 'uptime_get_on_call_tool', - 'uptime_get_severity_tool', - 'uptime_get_status_page_report_update_tool', - 'uptime_get_status_page_resources_tool', - 'uptime_get_status_page_tool', - 'uptime_list_escalation_policies_tool', - 'uptime_list_heartbeats_tool', - 'uptime_list_incidents_tool', - 'uptime_list_monitors_tool', - 'uptime_list_on_call_events_tool', - 'uptime_list_on_calls_tool', - 'uptime_list_severities_tool', - 'uptime_list_status_page_report_updates_tool', - 'uptime_list_status_page_reports_tool', - 'uptime_list_status_pages_tool', + 'escalation_policy', + 'heartbeat_availability', + 'heartbeat', + 'incident_comments', + 'incident_escalation_options', + 'incident_timeline', + 'incident', + 'monitor_availability', + 'monitor_response_times', + 'monitor', + 'on_call_event', + 'on_call_rotation', + 'on_call', + 'severity', + 'status_page_report_update', + 'status_page_resources', + 'status_page', + 'escalation_policies', + 'heartbeats', + 'incidents', + 'monitors', + 'on_call_events', + 'on_calls', + 'severities', + 'status_page_report_updates', + 'status_page_reports', + 'status_pages', ] as const; const BETTER_STACK_READ_ONLY_TELEMETRY_TOOL_NAMES = [ - 'better_stack_search_documentation_tool', - 'telemetry_build_explore_query_tool', - 'telemetry_build_metric_query_tool', - 'telemetry_chart', - 'telemetry_export_dashboard_tool', - 'telemetry_get_application_details_tool', - 'telemetry_get_chart_alert_details_tool', - 'telemetry_get_chart_alert_instructions_tool', - 'telemetry_get_chart_building_instructions_tool', - 'telemetry_get_chart_details_tool', - 'telemetry_get_dashboard_details_tool', - 'telemetry_get_error_details_tool', - 'telemetry_get_errors_query_instructions_tool', - 'telemetry_get_metric_details_tool', - 'telemetry_get_metric_query_instructions_tool', - 'telemetry_get_metrics_and_cardinality_tool', - 'telemetry_get_query_instructions_tool', - 'telemetry_get_replays_query_instructions_tool', - 'telemetry_get_source_details_tool', - 'telemetry_get_source_fields_tool', - 'telemetry_list_applications_tool', - 'telemetry_list_chart_alerts_tool', - 'telemetry_list_clusters_tool', - 'telemetry_list_dashboard_templates_tool', - 'telemetry_list_dashboards_tool', - 'telemetry_list_data_regions_tool', - 'telemetry_list_releases_tool', - 'telemetry_list_sources_tool', - 'telemetry_list_teams_tool', - 'telemetry_query', + 'search_documentation', + 'explore_query_instructions', + 'render_chart', + 'export_dashboard', + 'application', + 'chart_alert', + 'chart_alert_instructions', + 'chart_building_instructions', + 'chart', + 'dashboard', + 'error', + 'errors_query_instructions', + 'metric', + 'metric_query_instructions', + 'metrics', + 'query_instructions', + 'replays_query_instructions', + 'source', + 'source_fields', + 'applications', + 'chart_alerts', + 'clusters', + 'dashboard_templates', + 'dashboards', + 'data_regions', + 'releases', + 'sources', + 'teams', + 'query', ] as const; const BETTER_STACK_READ_ONLY_TOOL_NAMES = [ From d6e7f6adfafd07a092ed00934d6d590bd3cd7464 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 10 Aug 2026 00:56:57 -0400 Subject: [PATCH 20/28] [Feat] Configure Statuspage incident feed by URL (#1194) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- apps/docs/environment-variables.mdx | 1 + packages/env/src/__tests__/index.test.ts | 2 + packages/env/src/index.ts | 6 +- .../__tests__/statuspage-incidents.test.ts | 70 ++++++++++++++++--- packages/slack/src/statuspage-incidents.ts | 48 +++++++------ 5 files changed, 93 insertions(+), 34 deletions(-) diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index ff27fb411..3d13ff6a2 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -123,6 +123,7 @@ as per-task auth tokens or workspace paths. | `TRPC_URL` | Production | API/tRPC origin used by workers and services. In single-origin production, this is often the app URL plus `/_roomote-api`. | | `R_PING_BASE_URL` | Optional | Base URL for anonymous telemetry and version checks. Defaults to `https://ping.roomote.dev`. | | `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. | +| `R_STATUSPAGE_INCIDENTS_URL` | Optional | URL of a Statuspage-compatible unresolved-incidents JSON feed. Setting it enables incident banners and Slack warnings; leaving it unset disables Statuspage checks. | | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index 98a991ae6..6f536487d 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -704,6 +704,7 @@ describe('Env', () => { ...productionCoreEnv, R_PUBLIC_URL: '', R_INSTANCE_ID: '', + R_STATUSPAGE_INCIDENTS_URL: '', R_TEAMS_BOT_APP_ID: '', R_TEAMS_BOT_APP_PASSWORD: '', R_TEAMS_BOT_TENANT_ID: '', @@ -733,6 +734,7 @@ describe('Env', () => { expect(env.R_PUBLIC_URL).toBeUndefined(); expect(env.R_INSTANCE_ID).toBeUndefined(); + expect(env.R_STATUSPAGE_INCIDENTS_URL).toBeUndefined(); expect(env.R_TEAMS_BOT_APP_ID).toBeUndefined(); expect(env.R_TEAMS_BOT_NAME).toBeUndefined(); expect(env.R_TELEGRAM_BOT_TOKEN).toBeUndefined(); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index a97d92081..4622ab391 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -113,8 +113,8 @@ const serverSchema = { .max(128) .regex(/^[A-Za-z0-9._:-]+$/) .optional(), - // Explicitly enables public Statuspage incident checks for Roomote Cloud. - STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID: z.string().min(1).optional(), + // Optional unresolved-incidents feed. Presence enables Statuspage checks. + R_STATUSPAGE_INCIDENTS_URL: z.string().url().optional(), // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), @@ -447,7 +447,7 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'RELEASE_PRODUCT_VERSION', 'R_PING_BASE_URL', 'R_INSTANCE_ID', - 'STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID', + 'R_STATUSPAGE_INCIDENTS_URL', 'R_CUSTOM_MCP_ALLOWED_PRIVATE_CIDRS', 'R_INTERCOM_APP_ID', 'R_POSTHOG_PROJECT_KEY', diff --git a/packages/slack/src/__tests__/statuspage-incidents.test.ts b/packages/slack/src/__tests__/statuspage-incidents.test.ts index 3f7f4d1de..97378c18e 100644 --- a/packages/slack/src/__tests__/statuspage-incidents.test.ts +++ b/packages/slack/src/__tests__/statuspage-incidents.test.ts @@ -6,10 +6,10 @@ const { getRedisMock, redis, env } = vi.hoisted(() => ({ del: vi.fn(), }, env: { - R_INSTANCE_ID: 'roomote-cloud', - STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID: 'roomote-cloud' as - | string - | undefined, + R_STATUSPAGE_INCIDENTS_URL: + 'https://roomote.statuspage.io/api/v2/incidents/unresolved.json' as + | string + | undefined, }, })); @@ -36,8 +36,8 @@ const criticalIncident = { describe('Statuspage incidents', () => { beforeEach(() => { vi.clearAllMocks(); - env.R_INSTANCE_ID = 'roomote-cloud'; - env.STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID = 'roomote-cloud'; + env.R_STATUSPAGE_INCIDENTS_URL = + 'https://roomote.statuspage.io/api/v2/incidents/unresolved.json'; getRedisMock.mockReturnValue(redis); redis.get.mockResolvedValue(null); redis.set.mockResolvedValue('OK'); @@ -45,7 +45,7 @@ describe('Statuspage incidents', () => { }); it('does not touch Redis or Statuspage when the deployment gate is disabled', async () => { - env.STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID = undefined; + env.R_STATUSPAGE_INCIDENTS_URL = undefined; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -55,13 +55,61 @@ describe('Statuspage incidents', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('does not couple the configured rollout value to R_INSTANCE_ID', () => { - env.STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID = 'roomote-cloud'; - env.R_INSTANCE_ID = 'different-instance'; - + it('enables incident checks when the feed URL is configured', () => { expect(isStatuspageIncidentsEnabled()).toBe(true); }); + it('fetches incidents from the configured feed URL', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + incidents: [criticalIncident], + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(getStatuspageIncident()).resolves.toEqual(criticalIncident); + + expect(fetchMock).toHaveBeenCalledWith(env.R_STATUSPAGE_INCIDENTS_URL); + }); + + it('scopes Redis keys by the configured feed URL', async () => { + const firstUrl = env.R_STATUSPAGE_INCIDENTS_URL!; + const secondUrl = + 'https://status.example.com/api/v2/incidents/unresolved.json'; + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ incidents: [] })); + vi.stubGlobal('fetch', fetchMock); + + await getStatuspageIncident(); + const firstKeys = [ + redis.get.mock.calls[0]?.[0], + redis.get.mock.calls[1]?.[0], + redis.set.mock.calls[0]?.[0], + ]; + + redis.get.mockClear(); + redis.set.mockClear(); + redis.del.mockClear(); + redis.get.mockResolvedValue(null); + redis.set.mockResolvedValue('OK'); + redis.del.mockResolvedValue(1); + env.R_STATUSPAGE_INCIDENTS_URL = secondUrl; + + await getStatuspageIncident(); + const secondKeys = [ + redis.get.mock.calls[0]?.[0], + redis.get.mock.calls[1]?.[0], + redis.set.mock.calls[0]?.[0], + ]; + + expect(secondKeys[0]).not.toBe(firstKeys[0]); + expect(secondKeys[1]).not.toBe(firstKeys[1]); + expect(secondKeys[2]).not.toBe(firstKeys[2]); + expect(fetchMock).toHaveBeenNthCalledWith(1, firstUrl); + expect(fetchMock).toHaveBeenNthCalledWith(2, secondUrl); + }); + it('selects the highest-impact incident, then the newest incident', () => { expect( selectStatuspageIncident([ diff --git a/packages/slack/src/statuspage-incidents.ts b/packages/slack/src/statuspage-incidents.ts index e9b7d9c1b..c80a99d60 100644 --- a/packages/slack/src/statuspage-incidents.ts +++ b/packages/slack/src/statuspage-incidents.ts @@ -1,11 +1,8 @@ +import { createHash } from 'node:crypto'; + import { Env } from '@roomote/env'; import { getRedis } from '@roomote/redis'; -const STATUSPAGE_URL = - 'https://roomote.statuspage.io/api/v2/incidents/unresolved.json'; -const CACHE_KEY = 'statuspage:incidents:unresolved'; -const NEGATIVE_CACHE_KEY = `${CACHE_KEY}:empty`; -const REFRESH_LOCK_KEY = `${CACHE_KEY}:refresh`; const FRESH_FOR_MS = 5 * 60 * 1000; const STALE_FOR_SECONDS = 24 * 60 * 60; const NEGATIVE_CACHE_FOR_SECONDS = 60; @@ -28,6 +25,16 @@ interface CachedIncident { fetchedAt: number; } +function incidentCacheKeys(url: string) { + const feedScope = createHash('sha256').update(url).digest('hex'); + const cacheKey = `statuspage:incidents:${feedScope}:unresolved`; + return { + cacheKey, + negativeCacheKey: `${cacheKey}:empty`, + refreshLockKey: `${cacheKey}:refresh`, + }; +} + const impactRank: Record = { critical: 0, major: 1, @@ -85,9 +92,7 @@ export function selectStatuspageIncident( } export function isStatuspageIncidentsEnabled(): boolean { - // Roomote Cloud enables this explicitly through its deployment configuration. - // Do not couple the rollout to the deployment's telemetry instance identifier. - return Boolean(Env.STATUSPAGE_INCIDENTS_ENABLED_INSTANCE_ID); + return Boolean(Env.R_STATUSPAGE_INCIDENTS_URL); } export function buildStatuspageSlackWarning( @@ -111,27 +116,30 @@ function parseCachedIncident(value: string | null): CachedIncident | null { } } -async function fetchIncident(): Promise { - const response = await fetch(STATUSPAGE_URL); +async function fetchIncident(url: string): Promise { + const response = await fetch(url); if (!response.ok) throw new Error(`Statuspage returned ${response.status}`); const body = (await response.json()) as { incidents?: unknown[] }; return selectStatuspageIncident(body.incidents ?? []); } export async function getStatuspageIncident(): Promise { - if (!isStatuspageIncidentsEnabled()) return null; + const incidentsUrl = Env.R_STATUSPAGE_INCIDENTS_URL; + if (!incidentsUrl) return null; try { const redis = getRedis(); - const cached = parseCachedIncident(await redis.get(CACHE_KEY)); + const { cacheKey, negativeCacheKey, refreshLockKey } = + incidentCacheKeys(incidentsUrl); + const cached = parseCachedIncident(await redis.get(cacheKey)); if (cached && Date.now() - cached.fetchedAt < FRESH_FOR_MS) { return cached.incident; } - if (await redis.get(NEGATIVE_CACHE_KEY)) return cached?.incident ?? null; + if (await redis.get(negativeCacheKey)) return cached?.incident ?? null; const acquiredLock = await redis.set( - REFRESH_LOCK_KEY, + refreshLockKey, '1', 'EX', REFRESH_LOCK_FOR_SECONDS, @@ -140,11 +148,11 @@ export async function getStatuspageIncident(): Promise undefined); + await redis.del(refreshLockKey).catch(() => undefined); } } catch (error) { console.warn( From ed233af9c140552b42d35f1eea8efd6861153985 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 10 Aug 2026 09:27:30 -0400 Subject: [PATCH 21/28] [Fix] Reuse compute provider clients across sleep-check ticks (#1195) * [Fix] Reuse compute provider clients across sleep-check ticks The sleep check runs every 60 seconds and built a brand-new compute provider client on every tick that found active or idle runs. Each client carries retained SDK state (connection channels, and Sandbox objects held in the adapters' static caches pin the client that created them), so deployments that always have active runs leaked roughly 1.5 MB per minute until the bullmq service hit its heap cap and was OOM-killed on a ~5.5 hour cycle. Cache one client per provider across ticks, fingerprinting the resolved provider env values so a credential rotation rebuilds the client instead of serving a stale one. The per-run memoization and the per-run env resolution frequency are unchanged; only the client construction stops repeating. * Make adapter sandbox caches per-instance The static sandbox caches served two failure modes: every cached Sandbox pinned the (possibly long-dead) client that created it, and a client rebuilt for rotated credentials kept receiving cached handles that still used the old credentials until the TTL expired. Instance caches make the handles die with their client, so a credential rotation starts clean and no cross-client object graphs can accumulate. --------- Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../__tests__/sleep-check.test.ts | 74 +++++++++++++++- apps/bullmq/src/scheduled-jobs/sleep-check.ts | 87 +++++++++++++------ .../compute-providers/src/adapters/daytona.ts | 18 ++-- .../compute-providers/src/adapters/e2b.ts | 16 ++-- .../src/adapters/modal.test.ts | 5 -- .../compute-providers/src/adapters/modal.ts | 18 ++-- 6 files changed, 162 insertions(+), 56 deletions(-) diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts index 1ec24397c..80989813f 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts @@ -183,7 +183,12 @@ vi.mock('@roomote/db/server', () => ({ })); // Import after mocks are set up. -import { sleepCheckJob, sleepTaskRunNow } from '../sleep-check'; +import { + clearSleepCheckClientCache, + sleepCheckJob, + sleepTaskRunNow, +} from '../sleep-check'; +import { resolveComputeProviderEnvValues } from '@roomote/db/server'; /** * Mock the sequential DB select queries in sleepCheckJob. @@ -224,6 +229,7 @@ function mockJobQueries({ describe('sleepTaskRunNow', () => { beforeEach(() => { vi.clearAllMocks(); + clearSleepCheckClientCache(); transactionFn.mockImplementation(async (callback) => callback({ update: updateFn }), ); @@ -312,6 +318,7 @@ describe('sleepCheckJob', () => { beforeEach(() => { vi.clearAllMocks(); + clearSleepCheckClientCache(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -717,6 +724,71 @@ describe('sleepCheckJob', () => { ); }); + it('reuses the provider client across scheduler runs', async () => { + const mockJob = { + id: 6626, + machineId: 'modal-reuse', + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + vendor: 'modal', + snapshotId: null, + sleepRequestedAt: null, + snapshotRequestedAt: null, + }; + + mockGetInstanceStatus.mockResolvedValue({ + status: 'running', + timeoutRemainingMs: 5 * 60 * 60 * 1_000, + }); + returningFn.mockResolvedValue([{ id: 6626 }]); + mockCreateSnapshot.mockResolvedValue(true); + + mockJobQueries({ dueJobs: [mockJob] }); + await sleepCheckJob(); + mockJobQueries({ dueJobs: [mockJob] }); + await sleepCheckJob(); + + expect(mockGetInstanceStatus).toHaveBeenCalledTimes(2); + expect(mockCreateComputeProviderClient).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the provider client when provider credentials change', async () => { + const mockJob = { + id: 6627, + machineId: 'modal-rotate', + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + vendor: 'modal', + snapshotId: null, + sleepRequestedAt: null, + snapshotRequestedAt: null, + }; + + mockGetInstanceStatus.mockResolvedValue({ + status: 'running', + timeoutRemainingMs: 5 * 60 * 60 * 1_000, + }); + returningFn.mockResolvedValue([{ id: 6627 }]); + mockCreateSnapshot.mockResolvedValue(true); + + mockJobQueries({ dueJobs: [mockJob] }); + await sleepCheckJob(); + + vi.mocked(resolveComputeProviderEnvValues).mockResolvedValueOnce({ + MODAL_TOKEN_ID: 'rotated', + }); + mockJobQueries({ dueJobs: [mockJob] }); + await sleepCheckJob(); + + expect(mockCreateComputeProviderClient).toHaveBeenCalledTimes(2); + expect(mockCreateComputeProviderClient).toHaveBeenLastCalledWith({ + provider: 'modal', + envFallback: { MODAL_TOKEN_ID: 'rotated' }, + }); + }); + it('retains due Blaxel jobs on standby without creating a snapshot', async () => { const mockJob = { id: 6626, diff --git a/apps/bullmq/src/scheduled-jobs/sleep-check.ts b/apps/bullmq/src/scheduled-jobs/sleep-check.ts index 2f9362d54..ec211ca0b 100644 --- a/apps/bullmq/src/scheduled-jobs/sleep-check.ts +++ b/apps/bullmq/src/scheduled-jobs/sleep-check.ts @@ -141,38 +141,23 @@ function getDestroyInstanceSentryMessage( } } -async function createSleepCheckClient(provider: ComputeProvider) { +function buildSleepCheckClient( + provider: ComputeProvider, + envFallback: Partial>, +) { switch (provider) { case 'modal': - return createComputeProviderClient({ - provider: 'modal', - envFallback: await resolveComputeProviderEnvValues('modal'), - }); + return createComputeProviderClient({ provider: 'modal', envFallback }); case 'roomote': - return createComputeProviderClient({ - provider: 'roomote', - envFallback: await resolveComputeProviderEnvValues('roomote'), - }); + return createComputeProviderClient({ provider: 'roomote', envFallback }); case 'daytona': - return createComputeProviderClient({ - provider: 'daytona', - envFallback: await resolveComputeProviderEnvValues('daytona'), - }); + return createComputeProviderClient({ provider: 'daytona', envFallback }); case 'e2b': - return createComputeProviderClient({ - provider: 'e2b', - envFallback: await resolveComputeProviderEnvValues('e2b'), - }); + return createComputeProviderClient({ provider: 'e2b', envFallback }); case 'blaxel': - return createComputeProviderClient({ - provider: 'blaxel', - envFallback: await resolveComputeProviderEnvValues('blaxel'), - }); + return createComputeProviderClient({ provider: 'blaxel', envFallback }); case 'azure': - return createComputeProviderClient({ - provider: 'azure', - envFallback: await resolveComputeProviderEnvValues('azure'), - }); + return createComputeProviderClient({ provider: 'azure', envFallback }); case 'docker': return createComputeProviderClient({ provider: 'docker' }); default: @@ -182,6 +167,52 @@ async function createSleepCheckClient(provider: ComputeProvider) { } } +interface CachedSleepCheckClient { + client: ReturnType; + fingerprint: string; +} + +// Reuse one client per provider across scheduler ticks. Building a fresh SDK +// client every minute retained each client's connection state (pinned via the +// adapters' static sandbox caches) on deployments that always have active +// runs, leaking ~1.5 MB/min until the bullmq service hit its heap cap. The +// fingerprint rebuilds the client when the deployment's provider credentials +// change. +const sleepCheckClientCache = new Map< + ComputeProvider, + CachedSleepCheckClient +>(); + +/** Test-only: drop cached clients so mocks do not leak across tests. */ +export function clearSleepCheckClientCache(): void { + sleepCheckClientCache.clear(); +} + +function fingerprintEnvValues(values: Partial>): string { + return JSON.stringify( + Object.entries(values) + .filter(([, value]) => value !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + ); +} + +async function getSleepCheckClient(provider: ComputeProvider) { + const envValues = + provider === 'docker' + ? {} + : await resolveComputeProviderEnvValues(provider); + const fingerprint = fingerprintEnvValues(envValues); + const cached = sleepCheckClientCache.get(provider); + + if (cached && cached.fingerprint === fingerprint) { + return cached.client; + } + + const client = buildSleepCheckClient(provider, envValues); + sleepCheckClientCache.set(provider, { client, fingerprint }); + return client; +} + /** * Whether the sleep action can preserve this task through either an immutable * snapshot or a provider-native standby handle. Other providers fall through @@ -291,7 +322,7 @@ export const sleepCheckJob = async () => { const candidateJobsByMachineId = new Map(); const providerClients = new Map< ComputeProvider, - Awaited> + Awaited> >(); await mergeSleepCheckCandidates(candidateJobsByMachineId, dueJobs, 'dueJob', { @@ -358,7 +389,7 @@ export const sleepCheckJob = async () => { let client = providerClients.get(provider); if (!client) { - client = await createSleepCheckClient(provider); + client = await getSleepCheckClient(provider); providerClients.set(provider, client); } @@ -809,7 +840,7 @@ export async function sleepTaskRunNow(runId: number): Promise { return; } - const client = await createSleepCheckClient(job.vendor); + const client = await getSleepCheckClient(job.vendor); const { status } = await client.getInstanceStatus({ instanceId: job.machineId, }); diff --git a/packages/compute-providers/src/adapters/daytona.ts b/packages/compute-providers/src/adapters/daytona.ts index f9e9f0aca..b9faf9082 100644 --- a/packages/compute-providers/src/adapters/daytona.ts +++ b/packages/compute-providers/src/adapters/daytona.ts @@ -60,7 +60,9 @@ export class DaytonaClient implements ComputeProviderClient { public readonly capabilities: ComputeProviderCapabilities = DAYTONA_CAPABILITIES_VALUE; - private static readonly sandboxCache = new LRUCache({ + // Per-instance so cached sandbox handles die with the client that created + // them and never outlive its credentials (see ModalClient.sandboxCache). + private readonly sandboxCache = new LRUCache({ max: 100, ttl: DAYTONA_SANDBOX_CACHE_TTL_MS, }); @@ -105,7 +107,7 @@ export class DaytonaClient implements ComputeProviderClient { ): Promise { throwIfAborted(signal); - const cached = DaytonaClient.sandboxCache.get(sandboxId); + const cached = this.sandboxCache.get(sandboxId); if (cached) { return cached; @@ -118,11 +120,11 @@ export class DaytonaClient implements ComputeProviderClient { abortMessage: `Fetching Daytona sandbox ${sandboxId} was aborted`, }); - DaytonaClient.sandboxCache.set(sandboxId, sandbox); + this.sandboxCache.set(sandboxId, sandbox); return sandbox; } catch (error) { - DaytonaClient.sandboxCache.delete(sandboxId); + this.sandboxCache.delete(sandboxId); console.error( `[DaytonaClient] Failed to fetch sandbox "${sandboxId}" ${JSON.stringify( @@ -166,7 +168,7 @@ export class DaytonaClient implements ComputeProviderClient { abortMessage: `Fetching Daytona sandbox ${input.instanceId} was aborted`, }); - DaytonaClient.sandboxCache.set(input.instanceId, sandbox); + this.sandboxCache.set(input.instanceId, sandbox); return { status: mapSandboxState(sandbox.state) }; } catch (error) { @@ -235,7 +237,7 @@ export class DaytonaClient implements ComputeProviderClient { } try { - DaytonaClient.sandboxCache.set(sandbox.id, sandbox); + this.sandboxCache.set(sandbox.id, sandbox); const domains = await this.resolvePreviewDomains( sandbox, @@ -691,7 +693,7 @@ export class DaytonaClient implements ComputeProviderClient { } try { - DaytonaClient.sandboxCache.set(sandbox.id, sandbox); + this.sandboxCache.set(sandbox.id, sandbox); const domains = await this.resolvePreviewDomains( sandbox, @@ -783,7 +785,7 @@ export class DaytonaClient implements ComputeProviderClient { } private invalidateSandboxCache(instanceId: string): void { - DaytonaClient.sandboxCache.delete(instanceId); + this.sandboxCache.delete(instanceId); } private async cleanupSandboxAfterFailure( diff --git a/packages/compute-providers/src/adapters/e2b.ts b/packages/compute-providers/src/adapters/e2b.ts index d19c4e5c8..b61d8ccb5 100644 --- a/packages/compute-providers/src/adapters/e2b.ts +++ b/packages/compute-providers/src/adapters/e2b.ts @@ -74,7 +74,9 @@ export class E2bClient implements ComputeProviderClient { public readonly capabilities: ComputeProviderCapabilities = E2B_CAPABILITIES_VALUE; - private static readonly sandboxCache = new LRUCache({ + // Per-instance so cached sandbox handles die with the client that created + // them and never outlive its credentials (see ModalClient.sandboxCache). + private readonly sandboxCache = new LRUCache({ max: 100, ttl: E2B_SANDBOX_CACHE_TTL_MS, }); @@ -117,7 +119,7 @@ export class E2bClient implements ComputeProviderClient { ): Promise { throwIfAborted(signal); - const cached = E2bClient.sandboxCache.get(sandboxId); + const cached = this.sandboxCache.get(sandboxId); if (cached) { return cached; @@ -130,11 +132,11 @@ export class E2bClient implements ComputeProviderClient { abortMessage: `Connecting to E2B sandbox ${sandboxId} was aborted`, }); - E2bClient.sandboxCache.set(sandboxId, sandbox); + this.sandboxCache.set(sandboxId, sandbox); return sandbox; } catch (error) { - E2bClient.sandboxCache.delete(sandboxId); + this.sandboxCache.delete(sandboxId); console.error( `[E2bClient] Failed to connect to sandbox "${sandboxId}" ${JSON.stringify( @@ -245,7 +247,7 @@ export class E2bClient implements ComputeProviderClient { } try { - E2bClient.sandboxCache.set(sandbox.sandboxId, sandbox); + this.sandboxCache.set(sandbox.sandboxId, sandbox); const domains = resolvePortDomains(sandbox, input.ports); @@ -661,7 +663,7 @@ export class E2bClient implements ComputeProviderClient { } try { - E2bClient.sandboxCache.set(sandbox.sandboxId, sandbox); + this.sandboxCache.set(sandbox.sandboxId, sandbox); const domains = resolvePortDomains(sandbox, input.ports); @@ -783,7 +785,7 @@ export class E2bClient implements ComputeProviderClient { } private invalidateSandboxCache(instanceId: string): void { - E2bClient.sandboxCache.delete(instanceId); + this.sandboxCache.delete(instanceId); } private async cleanupSandboxAfterFailure( diff --git a/packages/compute-providers/src/adapters/modal.test.ts b/packages/compute-providers/src/adapters/modal.test.ts index 76e88106a..cba89b6dd 100644 --- a/packages/compute-providers/src/adapters/modal.test.ts +++ b/packages/compute-providers/src/adapters/modal.test.ts @@ -71,11 +71,6 @@ describe('ModalClient', () => { afterEach(() => { vi.useRealTimers(); - ( - ModalClient as unknown as { - sandboxCache: { clear: () => void }; - } - ).sandboxCache.clear(); }); it('does not mutate the caller config object', () => { diff --git a/packages/compute-providers/src/adapters/modal.ts b/packages/compute-providers/src/adapters/modal.ts index b7571bba0..df5f452b6 100644 --- a/packages/compute-providers/src/adapters/modal.ts +++ b/packages/compute-providers/src/adapters/modal.ts @@ -73,7 +73,11 @@ const MODAL_SNAPSHOT_TIMEOUT_MS = 20 * 60_000; export class ModalClient implements ComputeProviderClient { public readonly vendor: ComputeProvider; - private static readonly sandboxCache = new LRUCache({ + // Per-instance so cached Sandbox handles die with the client that created + // them: a static cache pinned every past client's SDK graph (a steady heap + // leak under per-tick construction) and kept serving handles built with + // rotated-out credentials after a client rebuild. + private readonly sandboxCache = new LRUCache({ max: 100, ttl: MODAL_SANDBOX_CACHE_TTL_MS, }); @@ -206,7 +210,7 @@ export class ModalClient implements ComputeProviderClient { ): Promise { throwIfAborted(signal); - const cached = ModalClient.sandboxCache.get(sandboxId); + const cached = this.sandboxCache.get(sandboxId); if (cached) { return cached; @@ -221,11 +225,11 @@ export class ModalClient implements ComputeProviderClient { abortMessage: `Fetching Modal sandbox ${sandboxId} was aborted`, }); - ModalClient.sandboxCache.set(sandboxId, sandbox); + this.sandboxCache.set(sandboxId, sandbox); return sandbox; } catch (error) { - ModalClient.sandboxCache.delete(sandboxId); + this.sandboxCache.delete(sandboxId); console.error( `[ModalClient] Failed to fetch sandbox "${sandboxId}" ${JSON.stringify({ @@ -495,7 +499,7 @@ export class ModalClient implements ComputeProviderClient { try { await this.applySandboxTags(sandbox, input.tags); - ModalClient.sandboxCache.set(sandbox.sandboxId, sandbox); + this.sandboxCache.set(sandbox.sandboxId, sandbox); const domains = await this.resolveTunnelDomains( sandbox, input.ports, @@ -1072,7 +1076,7 @@ export class ModalClient implements ComputeProviderClient { try { await this.applySandboxTags(sandbox, input.tags); - ModalClient.sandboxCache.set(sandbox.sandboxId, sandbox); + this.sandboxCache.set(sandbox.sandboxId, sandbox); const domains = await this.resolveTunnelDomains( sandbox, input.ports, @@ -1161,7 +1165,7 @@ export class ModalClient implements ComputeProviderClient { } private invalidateSandboxCache(instanceId: string): void { - ModalClient.sandboxCache.delete(instanceId); + this.sandboxCache.delete(instanceId); } private async cleanupSandboxAfterFailure( From cd8f5345dec90a771f733e0006164080f897c590 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:05:59 -0400 Subject: [PATCH 22/28] [Fix] Gitea credentials expire after task wake-up (#1199) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- packages/gitea/src/__tests__/api.test.ts | 28 ++++++ packages/gitea/src/__tests__/oauth.test.ts | 96 +++++++++++++++++++ packages/gitea/src/api.ts | 15 +++ packages/gitea/src/oauth.ts | 32 ++++++- .../__tests__/dequeue-helpers.test.ts | 21 +++- .../__tests__/refresh-github-token.test.ts | 74 ++++++++++++++ .../server/lib/task-runs/dequeue-helpers.ts | 8 +- .../lib/task-runs/refresh-github-token.ts | 20 ++-- 8 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts diff --git a/packages/gitea/src/__tests__/api.test.ts b/packages/gitea/src/__tests__/api.test.ts index b1154fc6b..8f4f880e5 100644 --- a/packages/gitea/src/__tests__/api.test.ts +++ b/packages/gitea/src/__tests__/api.test.ts @@ -563,9 +563,37 @@ describe('Gitea API helpers', () => { originBaseUrl: 'https://git.example.com', }, ], + expiresAt: null, }); }); + it('carries the matching OAuth token expiry into task credentials', async () => { + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + mockResolveGiteaOAuthAccessToken.mockResolvedValue('gitea_oauth_token'); + mockGetGiteaOAuthConnection.mockResolvedValue({ + baseUrl: 'https://git.example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote-bot', + accessToken: 'gitea_oauth_token', + refreshToken: 'refresh-token', + expiresAt, + scopes: ['read:repository'], + status: 'active', + }); + + const result = await createTaskRunGiteaCredentials( + makeTaskRun({ + repo: 'acme/backend', + description: 'Resume work on Gitea', + sourceControlProvider: 'gitea', + }), + ); + + expect(result.expiresAt).toEqual(new Date(expiresAt)); + }); + it('resolves the deployment Gitea instance host from GITEA_BASE_URL', async () => { await expect(resolveGiteaInstanceHost()).resolves.toBe('git.example.com'); }); diff --git a/packages/gitea/src/__tests__/oauth.test.ts b/packages/gitea/src/__tests__/oauth.test.ts index e7e0bfa83..075912b66 100644 --- a/packages/gitea/src/__tests__/oauth.test.ts +++ b/packages/gitea/src/__tests__/oauth.test.ts @@ -166,4 +166,100 @@ describe('Gitea deployment OAuth', () => { expect(writeMock).not.toHaveBeenCalled(); expect(deleteWhereMock).toHaveBeenCalledOnce(); }); + + it('keeps the connection active when refresh fails transiently', async () => { + findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['read:user'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'temporarily_unavailable' }, + { status: 503, statusText: 'Service Unavailable' }, + ), + ); + + await expect(resolveGiteaOAuthAccessToken({ fetchImpl })).rejects.toThrow( + 'Gitea OAuth refresh failed: 503 Service Unavailable', + ); + expect(writeMock).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://gitea.example/login/oauth/access_token', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('keeps the connection active when the refresh request times out', async () => { + findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['read:user'], + status: 'active', + }); + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + resolveGiteaOAuthAccessToken({ + fetchImpl, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('requires reauthorization when Gitea rejects the refresh grant', async () => { + findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['read:user'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'invalid_grant' }, + { status: 400, statusText: 'Bad Request' }, + ), + ); + + await expect(resolveGiteaOAuthAccessToken({ fetchImpl })).rejects.toThrow( + 'Gitea OAuth authorization has expired and must be renewed.', + ); + expect(writeMock).toHaveBeenCalledOnce(); + }); }); diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 3d462bc8a..e68b5f6e0 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -1153,6 +1153,7 @@ export async function createTaskRunGiteaCredentials( }, ): Promise<{ credentials: GiteaRepositoryCredential[]; + expiresAt: Date | null; }> { const deploymentToken = options?.token ?? (await resolveGiteaToken()); @@ -1162,6 +1163,10 @@ export async function createTaskRunGiteaCredentials( ); } + const oauthConnection = options?.token + ? null + : await getGiteaOAuthConnection(); + const baseUrl = options?.baseUrl ?? (await resolveGiteaBaseUrl()); if (!baseUrl?.trim()) { @@ -1172,6 +1177,7 @@ export async function createTaskRunGiteaCredentials( const username = options?.username ?? + oauthConnection?.username ?? (await resolveGiteaUsername()) ?? ( await getGiteaAuthenticatedUser({ @@ -1182,6 +1188,9 @@ export async function createTaskRunGiteaCredentials( ).login; const host = hostFromBaseUrl(baseUrl); const repositoriesList = await resolveGiteaRepositoryRowsForTaskRun(taskRun); + const parsedExpiresAt = oauthConnection + ? new Date(oauthConnection.expiresAt) + : null; return { credentials: repositoriesList.map((repository) => ({ @@ -1191,5 +1200,11 @@ export async function createTaskRunGiteaCredentials( token: deploymentToken, originBaseUrl: baseUrl, })), + expiresAt: + oauthConnection?.accessToken === deploymentToken && + parsedExpiresAt && + !Number.isNaN(parsedExpiresAt.getTime()) + ? parsedExpiresAt + : null, }; } diff --git a/packages/gitea/src/oauth.ts b/packages/gitea/src/oauth.ts index ebaad8f90..ff4bd0b35 100644 --- a/packages/gitea/src/oauth.ts +++ b/packages/gitea/src/oauth.ts @@ -11,6 +11,7 @@ const DEFAULT_SCOPES = [ 'write:issue', 'read:organization', ] as const; +const GITEA_OAUTH_REQUEST_TIMEOUT_MS = 15_000; export type GiteaOAuthConnectionStatus = 'active' | 'reauthorization_required'; @@ -34,6 +35,10 @@ type GiteaOAuthTokenResponse = { scope?: string; }; +type GiteaOAuthErrorResponse = { + error?: string; +}; + let refreshPromise: Promise | null = null; let deletionPromise: Promise | null = null; let connectionGeneration = 0; @@ -188,6 +193,7 @@ export async function exchangeGiteaOAuthCode(input: { export async function resolveGiteaOAuthAccessToken(options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean; + requestTimeoutMs?: number; }): Promise { if (deletionPromise) { await deletionPromise; @@ -224,16 +230,32 @@ export async function resolveGiteaOAuthAccessToken(options?: { refresh_token: connection.refreshToken, grant_type: 'refresh_token', }), + signal: AbortSignal.timeout( + options?.requestTimeoutMs ?? GITEA_OAUTH_REQUEST_TIMEOUT_MS, + ), }, ); if (!response.ok) { if (generation !== connectionGeneration) return null; - await writeConnection({ - ...connection, - status: 'reauthorization_required', - }); + + const oauthError = await response + .clone() + .json() + .then((body) => (body as GiteaOAuthErrorResponse).error) + .catch(() => undefined); + + if (oauthError === 'invalid_grant') { + await writeConnection({ + ...connection, + status: 'reauthorization_required', + }); + throw new Error( + 'Gitea OAuth authorization has expired and must be renewed.', + ); + } + throw new Error( - 'Gitea OAuth authorization has expired and must be renewed.', + `Gitea OAuth refresh failed: ${response.status} ${response.statusText}`, ); } const token = (await response.json()) as GiteaOAuthTokenResponse; diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index 6f47819fd..37470cd8b 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -170,6 +170,7 @@ describe('createSourceControlTokenForTaskRun', () => { originBaseUrl: 'https://git.example.com', }, ], + expiresAt: new Date('2026-08-10T15:00:00.000Z'), }); mockCreateTaskRunAdoCredentials.mockResolvedValue({ credentials: [ @@ -324,7 +325,7 @@ describe('createSourceControlTokenForTaskRun', () => { }, ], source: 'app', - expiresAt: null, + expiresAt: new Date('2026-08-10T15:00:00.000Z'), }); expect(mockCreateTaskRunWorkerGitHubToken).not.toHaveBeenCalled(); expect(mockCreateTaskRunGiteaCredentials).toHaveBeenCalledWith( @@ -451,6 +452,24 @@ describe('createSourceControlTokenForTaskRun', () => { ); }); + it('keeps the earliest expiry when merging multiple providers', async () => { + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'owner/repo', + sourceControlProvider: 'github', + repositoryProviders: { + 'owner/repo': 'github', + 'group/project': 'gitea', + }, + description: 'Work across GitHub and Gitea', + } as TaskRun['payload']), + '[test]', + { maxRetries: 1 }, + ); + + expect(result?.expiresAt).toEqual(new Date('2026-08-10T15:00:00.000Z')); + }); + it('retries only the failing provider and returns no partial token', async () => { mockCreateTaskRunScopedGitLabTokens.mockRejectedValue( new Error('GitLab unavailable'), diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts new file mode 100644 index 000000000..27d85dd91 --- /dev/null +++ b/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockTaskRunsFindFirst, mockCreateSourceControlTokenForTaskRun } = + vi.hoisted(() => ({ + mockTaskRunsFindFirst: vi.fn(), + mockCreateSourceControlTokenForTaskRun: vi.fn(), + })); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { + findFirst: (...args: unknown[]) => mockTaskRunsFindFirst(...args), + }, + }, + }, + taskRuns: { id: 'taskRuns.id' }, + eq: vi.fn(), +})); + +vi.mock('../dequeue-helpers', () => ({ + createSourceControlTokenForTaskRun: (...args: unknown[]) => + mockCreateSourceControlTokenForTaskRun(...args), +})); + +import { refreshGitHubTokenWithMetadata } from '../refresh-github-token'; + +describe('refreshGitHubTokenWithMetadata', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-10T12:00:00.000Z')); + mockTaskRunsFindFirst.mockResolvedValue({ + id: 123, + artifacts: null, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('schedules app-backed Gitea credentials before their OAuth expiry', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitea', + token: '', + envVar: 'GITEA_TOKEN', + envVars: {}, + gitProxyCredentials: [], + source: 'app', + expiresAt: new Date('2026-08-10T12:10:00.000Z'), + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.expiresAt).toBe('2026-08-10T12:10:00.000Z'); + expect(result.nextRefreshAt).toBe('2026-08-10T12:05:00.000Z'); + }); + + it('keeps the default interval for credentials without an expiry', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'github', + token: 'github-token', + envVar: 'GH_TOKEN', + envVars: { GH_TOKEN: 'github-token' }, + source: 'app', + expiresAt: null, + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.nextRefreshAt).toBe('2026-08-10T12:45:00.000Z'); + }); +}); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index 0cbe8f3bb..93a9b512e 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -536,7 +536,7 @@ async function createProviderToken( provider, })), source: 'app', - expiresAt: null, + expiresAt: credentials.expiresAt, }; } case 'bitbucket': { @@ -599,6 +599,12 @@ function mergeProviderTokens( ...(merged.artifactsPatch ?? {}), ...(token.artifactsPatch ?? {}), }, + expiresAt: + merged.expiresAt && token.expiresAt + ? new Date( + Math.min(merged.expiresAt.getTime(), token.expiresAt.getTime()), + ) + : (merged.expiresAt ?? token.expiresAt), }), primaryToken, ); diff --git a/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts b/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts index 05fddd83b..5d2c021aa 100644 --- a/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts +++ b/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts @@ -9,9 +9,9 @@ import { db, taskRuns, eq } from '@roomote/db/server'; import { createSourceControlTokenForTaskRun } from './dequeue-helpers'; -const DEFAULT_GITHUB_TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1000; -const USER_GITHUB_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; -const MIN_GITHUB_TOKEN_REFRESH_DELAY_MS = 60 * 1000; +const DEFAULT_SOURCE_CONTROL_TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1000; +const SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const MIN_SOURCE_CONTROL_TOKEN_REFRESH_DELAY_MS = 60 * 1000; /** * Generate a fresh source-control token for a task run within the caller's @@ -71,13 +71,13 @@ export async function refreshGitHubTokenWithMetadata( const now = Date.now(); - const nextRefreshAtMs = - tokenResult.source === 'user' && tokenResult.expiresAt - ? Math.max( - now + MIN_GITHUB_TOKEN_REFRESH_DELAY_MS, - tokenResult.expiresAt.getTime() - USER_GITHUB_TOKEN_REFRESH_BUFFER_MS, - ) - : now + DEFAULT_GITHUB_TOKEN_REFRESH_INTERVAL_MS; + const nextRefreshAtMs = tokenResult.expiresAt + ? Math.max( + now + MIN_SOURCE_CONTROL_TOKEN_REFRESH_DELAY_MS, + tokenResult.expiresAt.getTime() - + SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS, + ) + : now + DEFAULT_SOURCE_CONTROL_TOKEN_REFRESH_INTERVAL_MS; return { token: tokenResult.token, From c60b2ad5068a3b2b7beb9ef7d5f99b4218ed0b48 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 10 Aug 2026 13:12:40 -0400 Subject: [PATCH 23/28] Restore agent access to authenticated previews (#1200) --- .docker/sandbox/install-browser-agent.sh | 14 +++++-- apps/controller/src/__tests__/utils.test.ts | 4 +- apps/controller/src/utils.ts | 9 ++-- .../src/lib/environment-definition.test.ts | 12 ++++++ apps/web/src/lib/environment-definition.ts | 4 +- .../src/commands/__tests__/utils.test.ts | 42 +++++++++++++++++++ .../__tests__/legacy-runtime-tools.test.ts | 5 +++ apps/worker/src/commands/utils/env-vars.ts | 23 +++++++--- .../__tests__/sandbox-instruction.test.ts | 19 +++++++-- apps/worker/src/run-task/run-task.ts | 5 ++- .../src/run-task/sandbox-instruction.ts | 20 +++++---- packages/types/src/environment-config.ts | 5 ++- 12 files changed, 128 insertions(+), 34 deletions(-) diff --git a/.docker/sandbox/install-browser-agent.sh b/.docker/sandbox/install-browser-agent.sh index abdfb156a..743492849 100644 --- a/.docker/sandbox/install-browser-agent.sh +++ b/.docker/sandbox/install-browser-agent.sh @@ -441,10 +441,10 @@ collect_preview_urls() { local name value while IFS='=' read -r name value; do case "$name" in - ROOMOTE_EDITOR_HOST|ROOMOTE_SANDBOX_SERVER_HOST) + ROOMOTE_EDITOR_HOST|ROOMOTE_SANDBOX_SERVER_HOST|ROOMOTE_SANDBOX_SERVER_PREVIEW_URL) continue ;; - ROOMOTE_*_HOST) + ROOMOTE_*_HOST|ROOMOTE_*_PREVIEW_URL) case "$value" in http://*|https://*) printf '%s\n' "$value" @@ -475,6 +475,7 @@ seed_preview_cookies() { local session_hash local cache_file local url + local -a cookie_security_args cache_key="$(printf '%s\0' "$AGENT_BROWSER_SESSION_VALUE" "$header_name" "$bypass_value" "${AGENT_BROWSER_PREFIX_ARGS[*]}" "${preview_urls[@]}" | sha256sum | awk '{print $1}')" session_hash="$(hash_value "$AGENT_BROWSER_SESSION_VALUE")" @@ -488,11 +489,16 @@ seed_preview_cookies() { resolve_cli_paths for url in "${preview_urls[@]}"; do + cookie_security_args=() + case "$url" in + https://*) cookie_security_args+=(--secure) ;; + esac + if [ -n "$bypass_value" ]; then - AGENT_BROWSER_HEADED=false "$AGENT_BROWSER_BIN" "${AGENT_BROWSER_PREFIX_ARGS[@]}" cookies set "$header_name" "$bypass_value" --url "$url" --secure --sameSite Lax >/dev/null + AGENT_BROWSER_HEADED=false "$AGENT_BROWSER_BIN" "${AGENT_BROWSER_PREFIX_ARGS[@]}" cookies set "$header_name" "$bypass_value" --url "$url" "${cookie_security_args[@]}" --sameSite Lax >/dev/null fi - AGENT_BROWSER_HEADED=false "$AGENT_BROWSER_BIN" "${AGENT_BROWSER_PREFIX_ARGS[@]}" cookies set "$HIDE_PREVIEW_WIDGET_COOKIE" "1" --url "$url" --secure --sameSite Lax >/dev/null + AGENT_BROWSER_HEADED=false "$AGENT_BROWSER_BIN" "${AGENT_BROWSER_PREFIX_ARGS[@]}" cookies set "$HIDE_PREVIEW_WIDGET_COOKIE" "1" --url "$url" "${cookie_security_args[@]}" --sameSite Lax >/dev/null done : > "$cache_file" diff --git a/apps/controller/src/__tests__/utils.test.ts b/apps/controller/src/__tests__/utils.test.ts index 42b2a502b..694a28730 100644 --- a/apps/controller/src/__tests__/utils.test.ts +++ b/apps/controller/src/__tests__/utils.test.ts @@ -465,7 +465,7 @@ describe('shouldEnableAuthBypassForTaskRun', () => { ).toBe(false); }); - it('does not generate a bypass for ordinary unproxied preview ports', () => { + it('generates a bypass for authenticated unproxied preview entrypoints', () => { expect( shouldEnableAuthBypassForTaskRun({ environmentConfig: mockEnvironmentConfig({ @@ -476,7 +476,7 @@ describe('shouldEnableAuthBypassForTaskRun', () => { { name: 'WEB', port: 3000, proxied: false }, ], }), - ).toBe(false); + ).toBe(true); }); }); diff --git a/apps/controller/src/utils.ts b/apps/controller/src/utils.ts index 16f6b20b6..e78eb009f 100644 --- a/apps/controller/src/utils.ts +++ b/apps/controller/src/utils.ts @@ -44,10 +44,6 @@ function requiresPreviewAuth( return port.unauthenticated !== true; } -function configuredPreviewPortNeedsAuthBypass(port: NamedPort): boolean { - return requiresPreviewAuth(port) && port.proxied !== false; -} - async function isPreviewRuntimeReady(): Promise { const previewRuntimeConfig = await resolveEffectivePreviewRuntimeConfig({ runtimeEnv: process.env, @@ -75,7 +71,10 @@ export function shouldEnableAuthBypassForTaskRun({ continue; } - if (configuredPreviewPortNeedsAuthBypass(configuredPort)) { + // Unproxied ports still enter through the authenticated preview URL before + // the preview proxy redirects to the direct machine domain. Agents need a + // task-scoped bypass credential for that entrypoint too. + if (requiresPreviewAuth(configuredPort)) { return true; } } diff --git a/apps/web/src/lib/environment-definition.test.ts b/apps/web/src/lib/environment-definition.test.ts index c2021c943..24eac250d 100644 --- a/apps/web/src/lib/environment-definition.test.ts +++ b/apps/web/src/lib/environment-definition.test.ts @@ -10,6 +10,7 @@ import { import { buildEnvironmentDefinitionFingerprint, + buildEnvironmentPreviewRepairPrompt, buildSetupEnvironmentTaskTitle, buildUpdateEnvironmentDefinitionPrompt, findMatchingDefinedEnvironment, @@ -158,6 +159,17 @@ describe('environment definition helpers', () => { expect(prompt).toContain('name: Roomote App'); }); + it('directs preview repair tasks through the public preview URL', () => { + const prompt = buildEnvironmentPreviewRepairPrompt({ + environmentId: 'env-123', + environmentName: 'Roomote App', + config, + }); + + expect(prompt).toContain('ROOMOTE__PREVIEW_URL'); + expect(prompt).not.toContain('ROOMOTE__HOST'); + }); + it('finds a created environment that matches the repository set after the task started', () => { const environment = findMatchingDefinedEnvironment( [ diff --git a/apps/web/src/lib/environment-definition.ts b/apps/web/src/lib/environment-definition.ts index 45a0bf1a7..16a118ca9 100644 --- a/apps/web/src/lib/environment-definition.ts +++ b/apps/web/src/lib/environment-definition.ts @@ -142,14 +142,14 @@ export function buildEnvironmentPreviewRepairPrompt(input: { }): string { return `Fix live previews for the ${PRODUCT_NAME} environment "${input.environmentName}" (id ${input.environmentId}). -Live previews are configured for this environment, but the user reports the preview does not load or work correctly behind the preview proxy. You are running inside the environment, so its commands and services have already started, and each configured port's public preview origin is available in the sandbox as \`ROOMOTE__HOST\`. +Live previews are configured for this environment, but the user reports the preview does not load or work correctly behind the preview proxy. You are running inside the environment, so its commands and services have already started, and each configured port's public preview origin is available in the sandbox as \`ROOMOTE__PREVIEW_URL\`. Current environment YAML: \`\`\`yaml ${configToYaml(input.config).trim()} \`\`\` -1. Reproduce: check each configured port's surface on localhost, then through its public preview origin from \`ROOMOTE__HOST\`. Compare the two to isolate proxy-specific failures. +1. Reproduce: check each configured port's surface on localhost, then through its public preview origin from \`ROOMOTE__PREVIEW_URL\`. Compare the two to isolate proxy-specific failures. 2. Diagnose the common causes: dev servers that reject unknown hosts (allowed-hosts or host-header checks) or listen only on a loopback interface, hardcoded localhost or 127.0.0.1 origins in client code or API calls, CORS failures on cross-origin API requests, response headers that block framing (\`X-Frame-Options\`, \`Content-Security-Policy\` \`frame-ancestors\`), and websocket or HMR endpoints that bypass the proxy. 3. Fix the root cause: - For environment-definition problems (commands, env vars, port settings, services, docker projects), update the environment using the ${PRODUCT_NAME} MCP tool \`manage_environments\` with \`action: "update"\` and \`environmentId: "${input.environmentId}"\`. Keep every other environment setting unchanged. diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index b847f3c1b..329d1595f 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -168,6 +168,48 @@ describe('injectEnvVars', () => { expect(envVars.ROOMOTE_WEB_HOST).toBe( 'https://task-123-web.preview.octomote.run', ); + expect(envVars.ROOMOTE_WEB_PREVIEW_URL).toBe( + 'https://task-123-web.preview.octomote.run', + ); + }); + + it('keeps direct hosts while exposing preview-proxy URLs for unproxied ports', async () => { + const envVars: Record = {}; + const taskRun = { + taskId: 'task-123', + machineDomains: { + WEB: 'https://sandbox-web.modal.host', + }, + proxyPorts: {}, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect(envVars.ROOMOTE_WEB_HOST).toBe('https://sandbox-web.modal.host'); + expect(envVars.ROOMOTE_WEB_PREVIEW_URL).toBe( + 'https://task-123-web.preview.octomote.run', + ); + }); + + it('does not expose a preview URL for the retired editor identity', async () => { + const envVars: Record = { + ROOMOTE_EDITOR_PREVIEW_URL: 'https://stale-editor.example.com', + }; + const taskRun = { + taskId: 'task-123', + machineDomains: { + EDITOR: 'https://sandbox-editor.modal.host', + }, + proxyPorts: {}, + } as unknown as TaskRun; + + await injectEnvVars(envVars, taskRun, { + previewProxyBaseUrl: 'https://preview.octomote.run', + }); + + expect(envVars.ROOMOTE_EDITOR_PREVIEW_URL).toBeUndefined(); }); describe('PREVIEW_DOMAINS derivation', () => { diff --git a/apps/worker/src/commands/setup/__tests__/legacy-runtime-tools.test.ts b/apps/worker/src/commands/setup/__tests__/legacy-runtime-tools.test.ts index 9d7dc5e42..3a5fdee51 100644 --- a/apps/worker/src/commands/setup/__tests__/legacy-runtime-tools.test.ts +++ b/apps/worker/src/commands/setup/__tests__/legacy-runtime-tools.test.ts @@ -98,9 +98,14 @@ describe('install-browser-agent.sh', () => { 'HIDE_PREVIEW_WIDGET_COOKIE="roomote_hide_preview_widget"', ); expect(script).toContain('ROOMOTE_AUTH_BYPASS_VALUE'); + expect(script).toContain('ROOMOTE_*_PREVIEW_URL'); + expect(script).not.toContain('ROOMOTE_EDITOR_PREVIEW_URL'); expect(script).toContain('open|goto|navigate'); expect(script).toContain('cookies set "$header_name" "$bypass_value"'); expect(script).toContain('cookies set "$HIDE_PREVIEW_WIDGET_COOKIE" "1"'); + expect(script).toContain('https://*) cookie_security_args+=(--secure)'); + expect(script).toContain('"${cookie_security_args[@]}" --sameSite Lax'); + expect(script).not.toContain('--url "$url" --secure'); expect(script).toContain( 'export AGENT_BROWSER_EXECUTABLE_PATH="${AGENT_BROWSER_EXECUTABLE_PATH:-/opt/agent-browser/chrome}"', ); diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index c7906d5a1..f2d8f4521 100644 --- a/apps/worker/src/commands/utils/env-vars.ts +++ b/apps/worker/src/commands/utils/env-vars.ts @@ -199,16 +199,27 @@ export async function injectEnvVars( if (identity && identity.taskId && previewProxyBaseUrl) { for (const [name, domain] of Object.entries(identity.machineDomains)) { const envVarName = `ROOMOTE_${name}_HOST`; + const previewUrlEnvVarName = `ROOMOTE_${name}_PREVIEW_URL`; const isProxied = name === CODE_SERVER_NAMED_PORT.name || name in identity.proxyPorts; + const previewUrl = buildPreviewProxyUrl( + identity.taskId, + portNameToSlug(name), + previewProxyBaseUrl, + previewProxySubdomainSuffix, + ); + + // Keep *_HOST pointing at the direct machine domain for unproxied ports, + // while also exposing the authenticated shareable URL that agents and + // browser tooling must use to exercise the preview entrypoint itself. + if (name === CODE_SERVER_NAMED_PORT.name) { + delete envVars[previewUrlEnvVarName]; + } else { + envVars[previewUrlEnvVarName] = previewUrl; + } if (isProxied) { - envVars[envVarName] = buildPreviewProxyUrl( - identity.taskId, - portNameToSlug(name), - previewProxyBaseUrl, - previewProxySubdomainSuffix, - ); + envVars[envVarName] = previewUrl; } else { envVars[envVarName] = domain; } diff --git a/apps/worker/src/run-task/__tests__/sandbox-instruction.test.ts b/apps/worker/src/run-task/__tests__/sandbox-instruction.test.ts index 67f412633..af90c9954 100644 --- a/apps/worker/src/run-task/__tests__/sandbox-instruction.test.ts +++ b/apps/worker/src/run-task/__tests__/sandbox-instruction.test.ts @@ -210,6 +210,7 @@ describe('buildSandboxInstruction', () => { const instruction = buildSandboxInstruction(true, environmentConfig, { envVars: { ROOMOTE_WEB_HOST: 'https://task-123-web.preview.roomote.run', + ROOMOTE_AUTH_BYPASS_VALUE: 'runtime-only-bypass', }, }); @@ -224,13 +225,14 @@ describe('buildSandboxInstruction', () => { 'This environment exposes a sandbox-local browser surface for delegated visual proof.', ); expect(instruction).toContain( - "Use the exact hostname and port from the environment configuration's local browser URL for proof capture. Preserve `localhost` versus `127.0.0.1` exactly as configured, and treat configured external preview URLs as shareable links only.", + "Use the exact hostname and port from the environment configuration's local browser URL for proof capture, preserving `localhost` versus `127.0.0.1` exactly as configured. Use configured external preview URLs only when the public proxy or hostname itself is part of what you need to validate.", ); expect(instruction).not.toContain('super-secret-value'); expect(instruction).not.toContain('API_KEY'); expect(instruction).not.toContain('agentInstructions'); expect(instruction).not.toContain('ROOT_SECRET'); expect(instruction).not.toContain('top-secret-bypass'); + expect(instruction).not.toContain('runtime-only-bypass'); expect(instruction).toContain('http://127.0.0.1:3000/auth/dev-login'); expect(instruction).toContain('Configured external preview URLs:'); expect(instruction).toContain( @@ -239,6 +241,12 @@ describe('buildSandboxInstruction', () => { expect(instruction).toContain( 'Use these shareable preview URLs when referring to external previews in replies or proof. Do not share raw machine hosts instead.', ); + expect(instruction).toContain( + 'The installed `agent-browser` wrapper automatically applies the task-scoped preview authentication cookie before `open`, `goto`, or `navigate`', + ); + expect(instruction).toContain( + 'Never print, log, or share the bypass credential.', + ); }); it('describes the sandbox browser surface without printing raw service URLs', () => { @@ -251,7 +259,7 @@ describe('buildSandboxInstruction', () => { const browserSurfaceLine = 'This environment exposes a sandbox-local browser surface for delegated visual proof.'; const localhostProofLine = - "Use the exact hostname and port from the environment configuration's local browser URL for proof capture. Preserve `localhost` versus `127.0.0.1` exactly as configured, and treat configured external preview URLs as shareable links only."; + "Use the exact hostname and port from the environment configuration's local browser URL for proof capture, preserving `localhost` versus `127.0.0.1` exactly as configured. Use configured external preview URLs only when the public proxy or hostname itself is part of what you need to validate."; expect(renderedInstruction).toContain(browserSurfaceLine); expect(renderedInstruction).toContain(localhostProofLine); @@ -386,7 +394,7 @@ describe('buildSandboxInstruction', () => { expect(instruction).not.toContain('Configured external preview URLs:'); }); - it('omits non-proxied hosts from the configured preview URL list', () => { + it('uses dedicated preview URLs for non-proxied hosts', () => { const instruction = buildSandboxInstruction( false, { @@ -412,6 +420,7 @@ describe('buildSandboxInstruction', () => { { envVars: { ROOMOTE_WEB_HOST: 'https://sandbox-raw-host.modal.host', + ROOMOTE_WEB_PREVIEW_URL: 'https://task-123-web.preview.roomote.run', ROOMOTE_API_HOST: 'https://task-123-api.preview.roomote.run', }, }, @@ -421,7 +430,9 @@ describe('buildSandboxInstruction', () => { expect(instruction).toContain( '- API: https://task-123-api.preview.roomote.run/trpc', ); + expect(instruction).toContain( + '- WEB (primary): https://task-123-web.preview.roomote.run/auth/dev-login', + ); expect(instruction).not.toContain('https://sandbox-raw-host.modal.host'); - expect(instruction).not.toContain('- WEB (primary):'); }); }); diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index e687c60c5..5d3c6a1d2 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -1018,8 +1018,9 @@ export const runTask = async ({ } // Build sandbox environment context for the agent. - // Reads ROOMOTE_*_HOST vars from the unsanitized env so the generated - // environment note always sees the injected preview URLs. + // Reads ROOMOTE_*_HOST and ROOMOTE_*_PREVIEW_URL vars from the unsanitized + // env so the generated environment note always sees the injected preview + // URLs. const sandboxInstruction = buildSandboxInstruction( Boolean(environmentConfig?.initialUrl), environmentConfig, diff --git a/apps/worker/src/run-task/sandbox-instruction.ts b/apps/worker/src/run-task/sandbox-instruction.ts index bb7c3d581..0d57e7514 100644 --- a/apps/worker/src/run-task/sandbox-instruction.ts +++ b/apps/worker/src/run-task/sandbox-instruction.ts @@ -137,18 +137,18 @@ function getConfiguredPreviewUrls( return environmentConfig.ports .map((port) => { - if (port.proxied === false) { - return null; - } - - const host = envVars[`ROOMOTE_${port.name.toUpperCase()}_HOST`]; + const name = port.name.toUpperCase(); + const previewUrl = envVars[`ROOMOTE_${name}_PREVIEW_URL`]; + const host = + previewUrl ?? + (port.proxied === false ? undefined : envVars[`ROOMOTE_${name}_HOST`]); if (!host) { return null; } return { - name: port.name.toUpperCase(), + name, url: appendInitialPath(host, port.initial_path), primary: Boolean(port.primary), }; @@ -236,6 +236,12 @@ export function buildSandboxInstruction( lines.push( 'Use these shareable preview URLs when referring to external previews in replies or proof. Do not share raw machine hosts instead.', ); + + if (options?.envVars?.ROOMOTE_AUTH_BYPASS_VALUE) { + lines.push( + 'These external preview URLs are also reachable from this sandbox. The installed `agent-browser` wrapper automatically applies the task-scoped preview authentication cookie before `open`, `goto`, or `navigate`. Use the corresponding `ROOMOTE__PREVIEW_URL` when available and append the route you need to test; use the listed external URL otherwise. Use an external URL when you need to validate public-proxy, redirect, cookie, or hostname-dependent behavior. Never print, log, or share the bypass credential.', + ); + } } } @@ -243,7 +249,7 @@ export function buildSandboxInstruction( lines.push( '', 'This environment exposes a sandbox-local browser surface for delegated visual proof.', - "Use the exact hostname and port from the environment configuration's local browser URL for proof capture. Preserve `localhost` versus `127.0.0.1` exactly as configured, and treat configured external preview URLs as shareable links only.", + "Use the exact hostname and port from the environment configuration's local browser URL for proof capture, preserving `localhost` versus `127.0.0.1` exactly as configured. Use configured external preview URLs only when the public proxy or hostname itself is part of what you need to validate.", ); } diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 1fee7654a..633db8411 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -727,8 +727,9 @@ export const environmentConfigSchema = z oidc: environmentOidcSchema.optional(), /** * Named preview ports for human-facing application URLs. - * Each port gets a preview-proxy URL and a corresponding `ROOMOTE__HOST` - * environment variable inside the sandbox. + * Each port gets an authenticated shareable URL in + * `ROOMOTE__PREVIEW_URL`. `ROOMOTE__HOST` points to that same + * URL for proxied ports and to the direct machine URL for unproxied ports. */ ports: z .array(namedPortSchema) From 5cb52af790711e7645a3188e0c9140213242e54c Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:15:19 -0400 Subject: [PATCH 24/28] [Fix] Source-control credentials expire during long-running tasks (#1201) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Pride Musvaire Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- packages/ado/src/__tests__/api.test.ts | 18 +- .../ado/src/__tests__/credentials.test.ts | 76 +++- packages/ado/src/api.ts | 8 +- packages/ado/src/credentials.ts | 272 +++++++------ .../bitbucket/src/__tests__/oauth.test.ts | 188 +++++++++ packages/bitbucket/src/api.ts | 16 +- packages/bitbucket/src/oauth.ts | 158 ++++--- packages/gitea/src/__tests__/oauth.test.ts | 108 ++++- packages/gitea/src/oauth.ts | 23 +- packages/gitlab/src/__tests__/api.test.ts | 53 ++- packages/gitlab/src/__tests__/oauth.test.ts | 385 +++++++++++++++++- packages/gitlab/src/api.ts | 11 +- packages/gitlab/src/oauth.ts | 168 +++++++- .../__tests__/dequeue-helpers.test.ts | 92 ++++- .../__tests__/refresh-github-token.test.ts | 83 +++- .../server/lib/task-runs/dequeue-helpers.ts | 25 +- .../lib/task-runs/refresh-github-token.ts | 37 +- 17 files changed, 1464 insertions(+), 257 deletions(-) diff --git a/packages/ado/src/__tests__/api.test.ts b/packages/ado/src/__tests__/api.test.ts index af04a1d86..ff451dc62 100644 --- a/packages/ado/src/__tests__/api.test.ts +++ b/packages/ado/src/__tests__/api.test.ts @@ -38,6 +38,17 @@ vi.mock('@roomote/db/server', () => ({ select: vi.fn(), insert: vi.fn(), update: (...args: unknown[]) => mockAuthAccountsUpdate(...args), + transaction: async (callback: (tx: unknown) => unknown) => + callback({ + execute: vi.fn(), + query: { + authAccounts: { + findFirst: (...args: unknown[]) => + mockAuthAccountsFindFirst(...args), + }, + }, + update: (...args: unknown[]) => mockAuthAccountsUpdate(...args), + }), }, environments: { id: 'environments.id', @@ -67,6 +78,7 @@ vi.mock('@roomote/db/server', () => ({ const value = process.env[name]?.trim(); return value || null; }), + sql: vi.fn(), })); vi.mock('@roomote/db/encryption', () => ({ @@ -550,7 +562,9 @@ describe('Azure DevOps API helpers', () => { baseUrl: 'https://dev.azure.com', fetchImpl: vi .fn() - .mockResolvedValue(new Response('{}', { status: 400 })), + .mockResolvedValue( + Response.json({ error: 'invalid_grant' }, { status: 400 }), + ), }); expect(refused).toEqual({ status: 'invalid', @@ -815,6 +829,7 @@ describe('Azure DevOps API helpers', () => { originBaseUrl: 'https://dev.azure.com', }, ], + expiresAt: null, }); expect(mockRepositoriesFindMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -853,6 +868,7 @@ describe('Azure DevOps API helpers', () => { originBaseUrl: 'https://ado.example.com/tfs', }, ], + expiresAt: null, }); }); diff --git a/packages/ado/src/__tests__/credentials.test.ts b/packages/ado/src/__tests__/credentials.test.ts index 1ba607d82..431c73f00 100644 --- a/packages/ado/src/__tests__/credentials.test.ts +++ b/packages/ado/src/__tests__/credentials.test.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockAuthAccountsFindFirst, mockAuthAccountsUpdate, + mockTransactionExecute, mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ mockAuthAccountsFindFirst: vi.fn(), mockAuthAccountsUpdate: vi.fn(), + mockTransactionExecute: vi.fn(), mockResolveDeploymentEnvVar: vi.fn(async (name: string) => { const value = process.env[name]?.trim(); return value || null; @@ -21,6 +23,17 @@ vi.mock('@roomote/db/server', () => ({ }, }, update: (...args: unknown[]) => mockAuthAccountsUpdate(...args), + transaction: async (callback: (tx: unknown) => unknown) => + callback({ + execute: (...args: unknown[]) => mockTransactionExecute(...args), + query: { + authAccounts: { + findFirst: (...args: unknown[]) => + mockAuthAccountsFindFirst(...args), + }, + }, + update: (...args: unknown[]) => mockAuthAccountsUpdate(...args), + }), }, authAccounts: { id: 'authAccounts.id', @@ -30,6 +43,7 @@ vi.mock('@roomote/db/server', () => ({ and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, + sql: vi.fn(), })); import { @@ -37,6 +51,7 @@ import { clearAdoEntraTokenCache, describeAdoApiError, resolveAdoToken, + resolveAdoTokenWithMetadata, validateAdoDelegatedCredentials, validateAdoEntraCredentials, validateAdoToken, @@ -103,12 +118,24 @@ describe('Azure DevOps credentials', () => { ), ); - const first = await resolveAdoToken(); + const first = await resolveAdoTokenWithMetadata(); const second = await resolveAdoToken(); - expect(first).toBe('header.payload.signature'); - expect(second).toBe(first); + expect(first?.token).toBe('header.payload.signature'); + expect(first?.expiresAt).toBeInstanceOf(Date); + expect(second).toBe(first?.token); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('returns no expiry metadata for static PAT credentials', async () => { + await expect(resolveAdoTokenWithMetadata()).resolves.toEqual({ + token: 'ado_deployment_token', + expiresAt: null, + }); }); it('refreshes and persists an Azure DevOps delegated token', async () => { @@ -139,6 +166,32 @@ describe('Azure DevOps credentials', () => { await expect(resolveAdoToken()).resolves.toBe('new.header.signature'); expect(fetchMock).toHaveBeenCalledTimes(1); expect(mockAuthAccountsUpdate).toHaveBeenCalledTimes(1); + expect(mockTransactionExecute).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('revalidates the linked account on every delegated token resolve', async () => { + delete process.env.ADO_TOKEN; + process.env.ADO_AUTH_MODE = 'delegated'; + process.env.ADO_LINKED_ACCOUNT_ID = 'ado-user@example.com'; + mockAuthAccountsFindFirst.mockResolvedValue({ + id: 'account-1', + accountId: 'ado-user@example.com', + accessToken: 'header.payload.signature', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000), + }); + + await expect(resolveAdoToken()).resolves.toBe('header.payload.signature'); + + mockAuthAccountsFindFirst.mockResolvedValue(null); + + await expect(resolveAdoToken()).resolves.toBeNull(); + expect(mockAuthAccountsFindFirst).toHaveBeenCalledTimes(2); + expect(mockTransactionExecute).toHaveBeenCalledTimes(2); }); it('validates Azure DevOps tokens against the repository listing the sync uses', async () => { @@ -263,6 +316,19 @@ describe('Azure DevOps credentials', () => { }); expect(outage.status).toBe('unknown'); + const ambiguousBadRequest = await validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + fetchImpl: vi + .fn() + .mockResolvedValue( + Response.json({ error: 'temporarily_unavailable' }, { status: 400 }), + ), + }); + expect(ambiguousBadRequest.status).toBe('unknown'); + const network = await validateAdoEntraCredentials({ clientId: 'client-id', clientSecret: 'client-secret', @@ -292,7 +358,9 @@ describe('Azure DevOps credentials', () => { organization: 'acme', fetchImpl: vi .fn() - .mockResolvedValue(new Response('{}', { status: 400 })), + .mockResolvedValue( + Response.json({ error: 'invalid_grant' }, { status: 400 }), + ), }); expect(refused).toEqual({ status: 'invalid', diff --git a/packages/ado/src/api.ts b/packages/ado/src/api.ts index f7f0fce6f..ab74b5611 100644 --- a/packages/ado/src/api.ts +++ b/packages/ado/src/api.ts @@ -32,6 +32,7 @@ import { resolveAdoBaseUrl, resolveAdoOrganization, resolveAdoToken, + resolveAdoTokenWithMetadata, resolveAdoUsername, stripTrailingSlashes, } from './credentials'; @@ -1662,8 +1663,12 @@ export async function createTaskRunAdoCredentials( }, ): Promise<{ credentials: AdoRepositoryCredential[]; + expiresAt: Date | null; }> { - const deploymentToken = options?.token ?? (await resolveAdoToken()); + const resolvedToken = options?.token + ? { token: options.token, expiresAt: null } + : await resolveAdoTokenWithMetadata(); + const deploymentToken = resolvedToken?.token; if (!deploymentToken?.trim()) { throw new Error( @@ -1693,5 +1698,6 @@ export async function createTaskRunAdoCredentials( originBaseUrl: baseUrl, }), ), + expiresAt: resolvedToken?.expiresAt ?? null, }; } diff --git a/packages/ado/src/credentials.ts b/packages/ado/src/credentials.ts index f05cf072a..0103991bd 100644 --- a/packages/ado/src/credentials.ts +++ b/packages/ado/src/credentials.ts @@ -4,6 +4,7 @@ import { db, eq, resolveDeploymentEnvVar, + sql, } from '@roomote/db/server'; export const DEFAULT_ADO_BASE_URL = 'https://dev.azure.com'; @@ -14,16 +15,20 @@ const ADO_ENTRA_TOKEN_SCOPE = 'https://app.vssps.visualstudio.com/.default'; const ADO_ENTRA_RESOURCE_SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default'; const ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS = 60_000; +const ADO_TOKEN_ENDPOINT_TIMEOUT_MS = 15_000; type AdoAuthMode = 'pat' | 'entra' | 'delegated'; -let cachedAdoEntraToken: { token: string; expiresAt: number } | null = null; -let cachedAdoDelegatedToken: { - accountId: string; +type AdoAccessToken = { token: string; - expiresAt: number; -} | null = null; + expiresAt: Date | null; +}; + +type OAuthErrorResponse = { + error?: string; +}; +let cachedAdoEntraToken: { token: string; expiresAt: number } | null = null; export type AdoTokenValidationResult = | { status: 'valid' } | { status: 'invalid'; error: string } @@ -91,8 +96,24 @@ class AdoTokenAcquisitionError extends Error { } } -function isDefinitiveTokenEndpointStatus(status: number): boolean { - return status === 400 || status === 401; +async function isDefinitiveTokenEndpointFailure( + response: Response, +): Promise { + if (response.status === 401) { + return true; + } + if (response.status !== 400) { + return false; + } + + const error = await response + .clone() + .json() + .then((body) => (body as OAuthErrorResponse).error) + .catch(() => undefined); + return ['invalid_client', 'invalid_grant', 'unauthorized_client'].includes( + error ?? '', + ); } async function requestAdoEntraClientCredentialsToken({ @@ -119,16 +140,14 @@ async function requestAdoEntraClientCredentialsToken({ scope: ADO_ENTRA_TOKEN_SCOPE, grant_type: 'client_credentials', }), - ...(timeoutMs === undefined - ? {} - : { signal: AbortSignal.timeout(timeoutMs) }), + signal: AbortSignal.timeout(timeoutMs ?? ADO_TOKEN_ENDPOINT_TIMEOUT_MS), }, ); if (!response.ok) { throw new AdoTokenAcquisitionError( `Azure DevOps Microsoft Entra token request failed: ${response.status} ${response.statusText}`, - isDefinitiveTokenEndpointStatus(response.status), + await isDefinitiveTokenEndpointFailure(response), ); } @@ -152,7 +171,7 @@ async function requestAdoEntraClientCredentialsToken({ }; } -export async function resolveAdoToken(): Promise { +export async function resolveAdoTokenWithMetadata(): Promise { const authMode = await resolveDeploymentEnvVar('ADO_AUTH_MODE'); if (authMode === 'delegated') { return resolveAdoDelegatedToken(); @@ -160,7 +179,7 @@ export async function resolveAdoToken(): Promise { const pat = await resolveDeploymentEnvVar('ADO_TOKEN'); if (pat?.trim() && authMode !== 'entra') { - return pat; + return { token: pat, expiresAt: null }; } const clientId = await resolveDeploymentEnvVar('ADO_CLIENT_ID'); @@ -177,7 +196,10 @@ export async function resolveAdoToken(): Promise { cachedAdoEntraToken && cachedAdoEntraToken.expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS ) { - return cachedAdoEntraToken.token; + return { + token: cachedAdoEntraToken.token, + expiresAt: new Date(cachedAdoEntraToken.expiresAt), + }; } const { token, expiresIn } = await requestAdoEntraClientCredentialsToken({ @@ -191,7 +213,11 @@ export async function resolveAdoToken(): Promise { expiresAt: Date.now() + expiresIn * 1000, }; - return token; + return { token, expiresAt: new Date(cachedAdoEntraToken.expiresAt) }; +} + +export async function resolveAdoToken(): Promise { + return (await resolveAdoTokenWithMetadata())?.token ?? null; } async function resolveAdoDelegatedToken(overrides?: { @@ -201,7 +227,7 @@ async function resolveAdoDelegatedToken(overrides?: { tenantId?: string; fetchImpl?: typeof fetch; timeoutMs?: number; -}): Promise { +}): Promise { const linkedAccountId = overrides?.linkedAccountId ?? (await resolveDeploymentEnvVar('ADO_LINKED_ACCOUNT_ID')); @@ -219,122 +245,109 @@ async function resolveAdoDelegatedToken(overrides?: { return null; } - const account = await db.query.authAccounts.findFirst({ - where: and( - eq(authAccounts.providerId, 'ado'), - eq(authAccounts.accountId, linkedAccountId.trim()), - ), - columns: { - id: true, - accountId: true, - accessToken: true, - refreshToken: true, - accessTokenExpiresAt: true, - }, - }); - - if (!account?.accessToken) { - return null; - } + return db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`ado:${linkedAccountId.trim()}`}, 0))`, + ); + const account = await tx.query.authAccounts.findFirst({ + where: and( + eq(authAccounts.providerId, 'ado'), + eq(authAccounts.accountId, linkedAccountId.trim()), + ), + columns: { + id: true, + accountId: true, + accessToken: true, + refreshToken: true, + accessTokenExpiresAt: true, + }, + }); - const expiresAt = account.accessTokenExpiresAt?.getTime() ?? 0; - if ( - expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS && - cachedAdoDelegatedToken?.accountId === account.accountId && - cachedAdoDelegatedToken.expiresAt > - Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS - ) { - return cachedAdoDelegatedToken.token; - } + if (!account?.accessToken) { + return null; + } - if (expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS) { - cachedAdoDelegatedToken = { - accountId: account.accountId, - token: account.accessToken, - expiresAt, - }; - return account.accessToken; - } + const expiresAt = account.accessTokenExpiresAt?.getTime() ?? 0; - if ( - !account.refreshToken || - !clientId?.trim() || - !clientSecret?.trim() || - !tenantId?.trim() - ) { - throw new AdoTokenAcquisitionError( - 'Azure DevOps delegated connection needs to be reconnected. Open Settings and connect with Microsoft again.', - true, - ); - } + if (expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS) { + return { token: account.accessToken, expiresAt: new Date(expiresAt) }; + } - const response = await (overrides?.fetchImpl ?? fetch)( - `https://login.microsoftonline.com/${encodeURIComponent(tenantId.trim())}/oauth2/v2.0/token`, - { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: clientId.trim(), - client_secret: clientSecret.trim(), - refresh_token: account.refreshToken, - scope: ADO_ENTRA_RESOURCE_SCOPE, - }), - ...(overrides?.timeoutMs === undefined - ? {} - : { signal: AbortSignal.timeout(overrides.timeoutMs) }), - }, - ); + if ( + !account.refreshToken || + !clientId?.trim() || + !clientSecret?.trim() || + !tenantId?.trim() + ) { + throw new AdoTokenAcquisitionError( + 'Azure DevOps delegated connection needs to be reconnected. Open Settings and connect with Microsoft again.', + true, + ); + } - if (!response.ok) { - throw new AdoTokenAcquisitionError( - `Azure DevOps delegated token refresh failed: ${response.status} ${response.statusText}`, - isDefinitiveTokenEndpointStatus(response.status), + const response = await (overrides?.fetchImpl ?? fetch)( + `https://login.microsoftonline.com/${encodeURIComponent(tenantId.trim())}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: clientId.trim(), + client_secret: clientSecret.trim(), + refresh_token: account.refreshToken, + scope: ADO_ENTRA_RESOURCE_SCOPE, + }), + signal: AbortSignal.timeout( + overrides?.timeoutMs ?? ADO_TOKEN_ENDPOINT_TIMEOUT_MS, + ), + }, ); - } - const payload = (await response.json()) as { - access_token?: unknown; - refresh_token?: unknown; - expires_in?: unknown; - }; - const accessToken = - typeof payload.access_token === 'string' ? payload.access_token : null; - if (!accessToken) { - throw new Error( - 'Azure DevOps delegated token response did not include an access token.', - ); - } + if (!response.ok) { + throw new AdoTokenAcquisitionError( + `Azure DevOps delegated token refresh failed: ${response.status} ${response.statusText}`, + await isDefinitiveTokenEndpointFailure(response), + ); + } - const nextExpiresAt = - Date.now() + - (typeof payload.expires_in === 'number' ? payload.expires_in : 3600) * 1000; - const nextRefreshToken = - typeof payload.refresh_token === 'string' - ? payload.refresh_token - : account.refreshToken; - - await db - .update(authAccounts) - .set({ - accessToken, - refreshToken: nextRefreshToken, - accessTokenExpiresAt: new Date(nextExpiresAt), - updatedAt: new Date(), - }) - .where(eq(authAccounts.id, account.id)); - - cachedAdoDelegatedToken = { - accountId: account.accountId, - token: accessToken, - expiresAt: nextExpiresAt, - }; - return accessToken; + const payload = (await response.json()) as { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; + }; + const accessToken = + typeof payload.access_token === 'string' ? payload.access_token : null; + if (!accessToken) { + throw new Error( + 'Azure DevOps delegated token response did not include an access token.', + ); + } + + const nextExpiresAt = + Date.now() + + (typeof payload.expires_in === 'number' ? payload.expires_in : 3600) * + 1000; + const nextRefreshToken = + typeof payload.refresh_token === 'string' + ? payload.refresh_token + : account.refreshToken; + + await tx + .update(authAccounts) + .set({ + accessToken, + refreshToken: nextRefreshToken, + accessTokenExpiresAt: new Date(nextExpiresAt), + updatedAt: new Date(), + }) + .where(eq(authAccounts.id, account.id)); + + return { token: accessToken, expiresAt: new Date(nextExpiresAt) }; + }); } export function clearAdoEntraTokenCache(): void { cachedAdoEntraToken = null; - cachedAdoDelegatedToken = null; } export function buildAdoBasicAuthHeader(token: string): string { @@ -635,14 +648,17 @@ export async function validateAdoDelegatedCredentials({ let token: string | null; try { - token = await resolveAdoDelegatedToken({ - linkedAccountId, - clientId, - clientSecret, - tenantId, - fetchImpl, - timeoutMs, - }); + token = + ( + await resolveAdoDelegatedToken({ + linkedAccountId, + clientId, + clientSecret, + tenantId, + fetchImpl, + timeoutMs, + }) + )?.token ?? null; } catch (error) { if (error instanceof AdoTokenAcquisitionError && error.definitive) { return { status: 'invalid', error: error.message }; diff --git a/packages/bitbucket/src/__tests__/oauth.test.ts b/packages/bitbucket/src/__tests__/oauth.test.ts index 59213588d..bb855cf1d 100644 --- a/packages/bitbucket/src/__tests__/oauth.test.ts +++ b/packages/bitbucket/src/__tests__/oauth.test.ts @@ -45,6 +45,7 @@ import { getBitbucketOAuthScopes, isBitbucketOAuthAccessToken, resolveBitbucketOAuthAccessToken, + resolveBitbucketOAuthAccessTokenWithMetadata, } from '../oauth'; describe('Bitbucket deployment OAuth', () => { @@ -96,6 +97,9 @@ describe('Bitbucket deployment OAuth', () => { redirectUri: 'https://roomote.example/callback', fetchImpl, }); + expect(fetchImpl.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); expect(isBitbucketOAuthAccessToken('bitbucket-access-token')).toBe(true); await deleteBitbucketOAuthConnection(); @@ -104,6 +108,31 @@ describe('Bitbucket deployment OAuth', () => { expect(isBitbucketOAuthAccessToken('bitbucket-access-token')).toBe(false); }); + it('bounds the OAuth code exchange request', async () => { + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + exchangeBitbucketOAuthCode({ + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'code', + redirectUri: 'https://roomote.example/callback', + fetchImpl, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); + it('waits for an in-flight refresh and prevents it from recreating the connection', async () => { executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); decryptMock.mockResolvedValue({ @@ -142,4 +171,163 @@ describe('Bitbucket deployment OAuth', () => { expect(writeMock).not.toHaveBeenCalled(); expect(deleteWhereMock).toHaveBeenCalledOnce(); }); + + it('returns the matching OAuth expiry with a valid access token', async () => { + const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'active-access-token', + refreshToken: 'refresh-token', + expiresAt, + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }); + + await expect( + resolveBitbucketOAuthAccessTokenWithMetadata(), + ).resolves.toEqual({ + accessToken: 'active-access-token', + expiresAt: new Date(expiresAt), + }); + }); + + it('keeps the connection active when refresh fails transiently', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'temporarily_unavailable' }, + { status: 503, statusText: 'Service Unavailable' }, + ), + ); + + await expect( + resolveBitbucketOAuthAccessToken({ fetchImpl, forceRefresh: true }), + ).rejects.toThrow( + 'Bitbucket OAuth refresh failed: 503 Service Unavailable', + ); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it.each(['invalid_grant', 'invalid_client', 'unauthorized_client'])( + 'requires reauthorization for definitive %s failures', + async (oauthError) => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'expired-access-token', + refreshToken: 'revoked-refresh-token', + expiresAt: new Date(0).toISOString(), + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: oauthError }, + { status: 400, statusText: 'Bad Request' }, + ), + ); + + await expect( + resolveBitbucketOAuthAccessToken({ fetchImpl, forceRefresh: true }), + ).rejects.toThrow('Bitbucket OAuth refresh failed: 400 Bad Request'); + expect(writeMock).toHaveBeenCalledOnce(); + }, + ); + + it('uses a peer-rotated token when the old refresh grant is rejected', async () => { + const peerExpiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock + .mockResolvedValueOnce({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'expired-access-token', + refreshToken: 'stale-refresh-token', + expiresAt: new Date(0).toISOString(), + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }) + .mockResolvedValueOnce({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'peer-access-token', + refreshToken: 'peer-refresh-token', + expiresAt: peerExpiresAt, + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'invalid_grant' }, + { status: 400, statusText: 'Bad Request' }, + ), + ); + + await expect( + resolveBitbucketOAuthAccessToken({ fetchImpl, forceRefresh: true }), + ).resolves.toBe('peer-access-token'); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('keeps the connection active when the refresh request times out', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + clientId: 'client-id', + clientSecret: 'client-secret', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + accountId: '42', + username: 'roomote', + scopes: ['account'], + status: 'active', + }); + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + resolveBitbucketOAuthAccessToken({ + fetchImpl, + forceRefresh: true, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/bitbucket/src/api.ts b/packages/bitbucket/src/api.ts index c16d4f90d..55a972bac 100644 --- a/packages/bitbucket/src/api.ts +++ b/packages/bitbucket/src/api.ts @@ -19,6 +19,7 @@ import { import { getBitbucketOAuthConnection, resolveBitbucketOAuthAccessToken, + resolveBitbucketOAuthAccessTokenWithMetadata, } from './oauth'; export * from './ci'; @@ -311,6 +312,7 @@ export type BitbucketAuthDescriptor = { baseUrl: string; apiBaseUrl: string; authScheme: 'basic' | 'bearer'; + expiresAt: Date | null; }; export async function resolveBitbucketAuth(): Promise { @@ -320,15 +322,16 @@ export async function resolveBitbucketAuth(): Promise { 'Bitbucket OAuth authorization requires reconnection. Reconnect the Bitbucket OAuth consumer in source-control settings.', ); } - const token = await resolveBitbucketOAuthAccessToken(); + const token = await resolveBitbucketOAuthAccessTokenWithMetadata(); if (token && connection) { const baseUrl = await resolveBitbucketBaseUrl(); return { - token, + token: token.accessToken, username: connection.username || 'x-token-auth', baseUrl, apiBaseUrl: buildBitbucketApiBaseUrl(baseUrl), authScheme: 'bearer', + expiresAt: token.expiresAt, }; } throw new Error( @@ -466,6 +469,13 @@ export async function resolveAuthIdentity({ baseUrl: resolvedBaseUrl, apiBaseUrl: resolvedApiBaseUrl, authScheme, + expiresAt: + oauthConnection?.accessToken === resolvedToken + ? (() => { + const parsed = new Date(oauthConnection.expiresAt); + return Number.isNaN(parsed.getTime()) ? null : parsed; + })() + : null, }; } @@ -1290,6 +1300,7 @@ export async function createTaskRunBitbucketCredentials( }, ): Promise<{ credentials: BitbucketRepositoryCredential[]; + expiresAt: Date | null; }> { const auth = await resolveAuthIdentity({ token: options?.token, @@ -1310,5 +1321,6 @@ export async function createTaskRunBitbucketCredentials( originBaseUrl: auth.baseUrl, authScheme: 'basic', })), + expiresAt: auth.expiresAt, }; } diff --git a/packages/bitbucket/src/oauth.ts b/packages/bitbucket/src/oauth.ts index 7aef2e5cf..b99d48933 100644 --- a/packages/bitbucket/src/oauth.ts +++ b/packages/bitbucket/src/oauth.ts @@ -18,6 +18,7 @@ const DEFAULT_SCOPES = [ // CI Failure Triage reads Pipelines and step logs. 'pipeline', ] as const; +const BITBUCKET_OAUTH_REQUEST_TIMEOUT_MS = 15_000; export type BitbucketOAuthConnectionStatus = | 'active' @@ -42,7 +43,22 @@ type BitbucketOAuthTokenResponse = { scopes?: string; }; -let refreshPromise: Promise | null = null; +type BitbucketOAuthErrorResponse = { + error?: string; +}; + +function isDefinitiveOAuthError(error: string | undefined): boolean { + return ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes( + error ?? '', + ); +} + +export type BitbucketOAuthAccessToken = { + accessToken: string; + expiresAt: Date | null; +}; + +let refreshPromise: Promise | null = null; let deletionPromise: Promise | null = null; let connectionGeneration = 0; let cachedAccessToken: string | null = null; @@ -127,12 +143,25 @@ export function isBitbucketOAuthAccessToken(token: string): boolean { return token === cachedAccessToken; } +function toAccessTokenResult( + accessToken: string, + expiresAt: string, +): BitbucketOAuthAccessToken { + cachedAccessToken = accessToken; + const parsedExpiresAt = new Date(expiresAt); + return { + accessToken, + expiresAt: Number.isNaN(parsedExpiresAt.getTime()) ? null : parsedExpiresAt, + }; +} + export async function exchangeBitbucketOAuthCode(input: { clientId: string; clientSecret: string; code: string; redirectUri: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; }): Promise { const response = await (input.fetchImpl ?? fetch)( 'https://bitbucket.org/site/oauth2/access_token', @@ -148,6 +177,9 @@ export async function exchangeBitbucketOAuthCode(input: { grant_type: 'authorization_code', redirect_uri: input.redirectUri, }), + signal: AbortSignal.timeout( + input.requestTimeoutMs ?? BITBUCKET_OAUTH_REQUEST_TIMEOUT_MS, + ), }, ); if (!response.ok) { @@ -197,10 +229,11 @@ export async function exchangeBitbucketOAuthCode(input: { return connection; } -export async function resolveBitbucketOAuthAccessToken(options?: { +export async function resolveBitbucketOAuthAccessTokenWithMetadata(options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean; -}): Promise { + requestTimeoutMs?: number; +}): Promise { if (deletionPromise) { await deletionPromise; return null; @@ -216,66 +249,72 @@ export async function resolveBitbucketOAuthAccessToken(options?: { !options?.forceRefresh && Date.parse(connection.expiresAt) > Date.now() + 60_000 ) { - cachedAccessToken = connection.accessToken; - return connection.accessToken; + return toAccessTokenResult(connection.accessToken, connection.expiresAt); } if (refreshPromise) return refreshPromise; refreshPromise = (async () => { - let requiresReauthorization = false; - try { - const response = await (options?.fetchImpl ?? fetch)( - 'https://bitbucket.org/site/oauth2/access_token', - { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${connection.clientId}:${connection.clientSecret}`).toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: connection.refreshToken, - }), + const response = await (options?.fetchImpl ?? fetch)( + 'https://bitbucket.org/site/oauth2/access_token', + { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${connection.clientId}:${connection.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', }, - ); - if (!response.ok) { - requiresReauthorization = [400, 401, 403].includes(response.status); - throw new Error( - `Bitbucket OAuth refresh failed: ${response.status} ${response.statusText}`, - ); - } - const token = (await response.json()) as BitbucketOAuthTokenResponse; - if (!token.access_token) - throw new Error( - 'Bitbucket OAuth refresh did not return an access token.', - ); - const next = { - ...connection, - accessToken: token.access_token, - refreshToken: token.refresh_token ?? connection.refreshToken, - expiresAt: new Date( - Date.now() + (token.expires_in ?? 3600) * 1000, - ).toISOString(), - status: 'active' as const, - }; + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: connection.refreshToken, + }), + signal: AbortSignal.timeout( + options?.requestTimeoutMs ?? BITBUCKET_OAUTH_REQUEST_TIMEOUT_MS, + ), + }, + ); + if (!response.ok) { if (generation !== connectionGeneration) return null; - await writeConnection(next); - cachedAccessToken = next.accessToken; - return next.accessToken; - } catch (error) { - if (requiresReauthorization) { - try { - if (generation !== connectionGeneration) return null; - await writeConnection({ - ...connection, - status: 'reauthorization_required', - }); - } catch { - // Preserve the original refresh error when persistence is unavailable. + const oauthError = await response + .clone() + .json() + .then((body) => (body as BitbucketOAuthErrorResponse).error) + .catch(() => undefined); + + if (isDefinitiveOAuthError(oauthError)) { + const latest = await readConnection(); + if ( + latest?.status === 'active' && + latest.accessToken !== connection.accessToken && + Date.parse(latest.expiresAt) > Date.now() + 60_000 + ) { + return toAccessTokenResult(latest.accessToken, latest.expiresAt); } + await writeConnection({ + ...(latest ?? connection), + status: 'reauthorization_required', + }); } - throw error; + + throw new Error( + `Bitbucket OAuth refresh failed: ${response.status} ${response.statusText}`, + ); } + const token = (await response.json()) as BitbucketOAuthTokenResponse; + if (!token.access_token) + throw new Error( + 'Bitbucket OAuth refresh did not return an access token.', + ); + const next = { + ...connection, + accessToken: token.access_token, + refreshToken: token.refresh_token ?? connection.refreshToken, + expiresAt: new Date( + Date.now() + (token.expires_in ?? 3600) * 1000, + ).toISOString(), + status: 'active' as const, + }; + if (generation !== connectionGeneration) return null; + await writeConnection(next); + return toAccessTokenResult(next.accessToken, next.expiresAt); })(); try { return await refreshPromise; @@ -284,6 +323,15 @@ export async function resolveBitbucketOAuthAccessToken(options?: { } } +export async function resolveBitbucketOAuthAccessToken(options?: { + fetchImpl?: typeof fetch; + forceRefresh?: boolean; + requestTimeoutMs?: number; +}): Promise { + const result = await resolveBitbucketOAuthAccessTokenWithMetadata(options); + return result?.accessToken ?? null; +} + export async function markBitbucketOAuthReauthorizationRequired() { const connection = await readConnection(); if (connection) diff --git a/packages/gitea/src/__tests__/oauth.test.ts b/packages/gitea/src/__tests__/oauth.test.ts index 075912b66..66261c417 100644 --- a/packages/gitea/src/__tests__/oauth.test.ts +++ b/packages/gitea/src/__tests__/oauth.test.ts @@ -119,6 +119,9 @@ describe('Gitea deployment OAuth', () => { redirectUri: 'https://roomote.example/callback', fetchImpl, }); + expect(fetchImpl.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); expect(isGiteaOAuthAccessToken('gitea-access-token')).toBe(true); await deleteGiteaOAuthConnection(); @@ -127,6 +130,32 @@ describe('Gitea deployment OAuth', () => { expect(isGiteaOAuthAccessToken('gitea-access-token')).toBe(false); }); + it('bounds the OAuth code exchange request', async () => { + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + exchangeGiteaOAuthCode({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'code', + redirectUri: 'https://roomote.example/callback', + fetchImpl, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); + it('waits for an in-flight refresh and prevents it from recreating the connection', async () => { findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); decryptMock.mockResolvedValue({ @@ -234,20 +263,65 @@ describe('Gitea deployment OAuth', () => { expect(writeMock).not.toHaveBeenCalled(); }); - it('requires reauthorization when Gitea rejects the refresh grant', async () => { + it.each(['invalid_grant', 'invalid_client', 'unauthorized_client'])( + 'requires reauthorization for definitive %s failures', + async (oauthError) => { + findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['read:user'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: oauthError }, + { status: 400, statusText: 'Bad Request' }, + ), + ); + + await expect(resolveGiteaOAuthAccessToken({ fetchImpl })).rejects.toThrow( + 'Gitea OAuth authorization has expired and must be renewed.', + ); + expect(writeMock).toHaveBeenCalledOnce(); + }, + ); + + it('uses a peer-rotated token when the old refresh grant is rejected', async () => { findFirstMock.mockResolvedValue({ value: 'encrypted-connection' }); - decryptMock.mockResolvedValue({ - baseUrl: 'https://gitea.example', - clientId: 'client-id', - clientSecret: 'client-secret', - accountId: '42', - username: 'roomote', - accessToken: 'expired-access-token', - refreshToken: 'refresh-token', - expiresAt: new Date(0).toISOString(), - scopes: ['read:user'], - status: 'active', - }); + decryptMock + .mockResolvedValueOnce({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'stale-refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['read:user'], + status: 'active', + }) + .mockResolvedValueOnce({ + baseUrl: 'https://gitea.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'peer-access-token', + refreshToken: 'peer-refresh-token', + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + scopes: ['read:user'], + status: 'active', + }); const fetchImpl = vi .fn() .mockResolvedValue( @@ -257,9 +331,9 @@ describe('Gitea deployment OAuth', () => { ), ); - await expect(resolveGiteaOAuthAccessToken({ fetchImpl })).rejects.toThrow( - 'Gitea OAuth authorization has expired and must be renewed.', - ); - expect(writeMock).toHaveBeenCalledOnce(); + await expect( + resolveGiteaOAuthAccessToken({ fetchImpl, forceRefresh: true }), + ).resolves.toBe('peer-access-token'); + expect(writeMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/gitea/src/oauth.ts b/packages/gitea/src/oauth.ts index ff4bd0b35..e6ef9426c 100644 --- a/packages/gitea/src/oauth.ts +++ b/packages/gitea/src/oauth.ts @@ -39,6 +39,12 @@ type GiteaOAuthErrorResponse = { error?: string; }; +function isDefinitiveOAuthError(error: string | undefined): boolean { + return ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes( + error ?? '', + ); +} + let refreshPromise: Promise | null = null; let deletionPromise: Promise | null = null; let connectionGeneration = 0; @@ -134,6 +140,7 @@ export async function exchangeGiteaOAuthCode(input: { code: string; redirectUri: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; }): Promise { const response = await (input.fetchImpl ?? fetch)( tokenEndpoint(input.baseUrl), @@ -150,6 +157,9 @@ export async function exchangeGiteaOAuthCode(input: { grant_type: 'authorization_code', redirect_uri: input.redirectUri, }), + signal: AbortSignal.timeout( + input.requestTimeoutMs ?? GITEA_OAUTH_REQUEST_TIMEOUT_MS, + ), }, ); if (!response.ok) { @@ -244,9 +254,18 @@ export async function resolveGiteaOAuthAccessToken(options?: { .then((body) => (body as GiteaOAuthErrorResponse).error) .catch(() => undefined); - if (oauthError === 'invalid_grant') { + if (isDefinitiveOAuthError(oauthError)) { + const latest = await readConnection(); + if ( + latest?.status === 'active' && + latest.accessToken !== connection.accessToken && + Date.parse(latest.expiresAt) > Date.now() + 60_000 + ) { + cachedAccessToken = latest.accessToken; + return latest.accessToken; + } await writeConnection({ - ...connection, + ...(latest ?? connection), status: 'reauthorization_required', }); throw new Error( diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 4f6617f88..e93137f13 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -8,11 +8,15 @@ const { mockRepositoriesFindMany, mockEnvironmentsFindFirst, mockGitLabOAuthAccessToken, + mockGitLabOAuthAccessTokenWithMetadata, + mockIsGitLabOAuthAccessToken, } = vi.hoisted(() => ({ mockEnvironmentVariablesFindMany: vi.fn(), mockRepositoriesFindMany: vi.fn(), mockEnvironmentsFindFirst: vi.fn(), mockGitLabOAuthAccessToken: vi.fn(), + mockGitLabOAuthAccessTokenWithMetadata: vi.fn(), + mockIsGitLabOAuthAccessToken: vi.fn((_token?: string) => false), })); vi.mock('@roomote/db/server', () => ({ @@ -63,8 +67,11 @@ vi.mock('@roomote/db/encryption', () => ({ })); vi.mock('../oauth', () => ({ - isGitLabOAuthAccessToken: () => false, + isGitLabOAuthAccessToken: (token: string) => + mockIsGitLabOAuthAccessToken(token), resolveGitLabOAuthAccessToken: () => mockGitLabOAuthAccessToken(), + resolveGitLabOAuthAccessTokenWithMetadata: () => + mockGitLabOAuthAccessTokenWithMetadata(), })); import { @@ -456,6 +463,11 @@ describe('createTaskRunScopedGitLabTokens', () => { beforeEach(() => { vi.clearAllMocks(); mockGitLabOAuthAccessToken.mockResolvedValue('oauth_access_token'); + mockGitLabOAuthAccessTokenWithMetadata.mockResolvedValue({ + accessToken: 'oauth_access_token', + expiresAt: new Date(Date.now() + 90 * 60 * 1000), + }); + mockIsGitLabOAuthAccessToken.mockReturnValue(false); delete process.env.GITLAB_BASE_URL; mockEnvironmentVariablesFindMany.mockResolvedValue([]); mockEnvironmentsFindFirst.mockResolvedValue(null); @@ -516,6 +528,7 @@ describe('createTaskRunScopedGitLabTokens', () => { }, ], }, + expiresAt: null, }); expect(fetchMock).toHaveBeenCalledWith( 'https://gitlab.com/api/v4/projects/42/access_tokens', @@ -869,6 +882,43 @@ describe('createTaskRunScopedGitLabTokens', () => { ); }); + it('routes OAuth access tokens through the proxy and surfaces their expiry', async () => { + const expiresAt = new Date(Date.now() + 90 * 60 * 1000); + mockIsGitLabOAuthAccessToken.mockReturnValue(true); + mockGitLabOAuthAccessTokenWithMetadata.mockResolvedValue({ + accessToken: 'oauth_access_token', + expiresAt, + }); + + const fetchMock = vi.fn(); + const result = await createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: 'group/project', + description: 'Work on GitLab', + sourceControlProvider: 'gitlab', + }), + { fetchImpl: fetchMock }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + credentials: [], + proxyCredentials: [ + { + host: 'gitlab.com', + originBaseUrl: 'https://gitlab.com', + repositoryFullName: 'group/project', + username: 'oauth2', + token: 'oauth_access_token', + }, + ], + artifactsPatch: { + gitlabScopedProjectTokens: [], + }, + expiresAt, + }); + }); + it('falls back to deployment-token proxy credentials when the token cannot mint project access tokens', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ error: 'permission denied' }), { @@ -900,6 +950,7 @@ describe('createTaskRunScopedGitLabTokens', () => { artifactsPatch: { gitlabScopedProjectTokens: [], }, + expiresAt: null, }); }); diff --git a/packages/gitlab/src/__tests__/oauth.test.ts b/packages/gitlab/src/__tests__/oauth.test.ts index 2a6ec2b08..5fece25df 100644 --- a/packages/gitlab/src/__tests__/oauth.test.ts +++ b/packages/gitlab/src/__tests__/oauth.test.ts @@ -7,6 +7,7 @@ const { insertMock, writeMock, decryptMock, + encryptMock, } = vi.hoisted(() => { const deleteWhereMock = vi.fn(async () => undefined); const writeMock = vi.fn(async () => undefined); @@ -21,6 +22,7 @@ const { })), writeMock, decryptMock: vi.fn(), + encryptMock: vi.fn(() => 'encrypted-connection'), }; }); @@ -33,7 +35,7 @@ vi.mock('@roomote/db/server', () => ({ vi.mock('@roomote/db/encryption', () => ({ decryptSecrets: decryptMock, - encryptJSON: vi.fn(() => 'encrypted-connection'), + encryptJSON: encryptMock, })); import { @@ -94,6 +96,9 @@ describe('GitLab deployment OAuth', () => { redirectUri: 'https://roomote.example/callback', fetchImpl, }); + expect(fetchImpl.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); expect(isGitLabOAuthAccessToken('gitlab-access-token')).toBe(true); await deleteGitLabOAuthConnection(); @@ -102,6 +107,384 @@ describe('GitLab deployment OAuth', () => { expect(isGitLabOAuthAccessToken('gitlab-access-token')).toBe(false); }); + it('bounds the OAuth code exchange request', async () => { + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + exchangeGitLabOAuthCode({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'code', + redirectUri: 'https://roomote.example/callback', + fetchImpl, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('does not classify unrelated tokens as OAuth while a session is active', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'session-access-token', + refresh_token: 'session-refresh-token', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 42, username: 'roomote' }), + }); + await exchangeGitLabOAuthCode({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + code: 'code', + redirectUri: 'https://roomote.example/callback', + fetchImpl, + }); + + expect(isGitLabOAuthAccessToken('session-access-token')).toBe(true); + // Self-managed instances can customise the PAT prefix, and deploy/CI job + // tokens carry none, so an unrecognised token must not become a Bearer. + expect(isGitLabOAuthAccessToken('acme-pat-abc123')).toBe(false); + expect(isGitLabOAuthAccessToken('glpat-personal-token')).toBe(false); + + await deleteGitLabOAuthConnection(); + }); + + it('scales the proactive refresh window to a short instance token lifetime', async () => { + // Self-managed instances can configure a much shorter OAuth TTL than + // GitLab's ~2h default. A fixed 10m skew would then exceed the whole + // lifetime and refresh on every single resolve. + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'short-lived-access-token', + refreshToken: 'refresh-token', + expiresInSeconds: 300, + // 4 of its 5 minutes left: well inside a fixed 10m skew, but nowhere + // near the quarter-life mark for a 5m token. + expiresAt: new Date(Date.now() + 4 * 60 * 1000).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn(); + + await expect( + resolveGitLabOAuthAccessToken({ fetchImpl: fetchImpl as typeof fetch }), + ).resolves.toBe('short-lived-access-token'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('still refreshes a short-lived token once it passes its quarter-life mark', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'short-lived-access-token', + refreshToken: 'refresh-token', + expiresInSeconds: 300, + expiresAt: new Date(Date.now() + 60 * 1000).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'short-lived-refreshed-token', + refresh_token: 'new-refresh-token', + expires_in: 300, + }), + ); + + await expect( + resolveGitLabOAuthAccessToken({ fetchImpl: fetchImpl as typeof fetch }), + ).resolves.toBe('short-lived-refreshed-token'); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it.each(['invalid_grant', 'invalid_client', 'unauthorized_client'])( + 'marks reauthorization required for definitive %s failures', + async (oauthError) => { + // The connection re-read after the failure is unchanged, so no peer + // rotated the tokens and the refusal is a genuine invalid_grant. + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'revoked-access-token', + refreshToken: 'revoked-refresh-token', + // Inside the proactive skew but not yet expired. + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: oauthError }), { + status: 400, + statusText: 'Bad Request', + }), + ); + + await expect( + resolveGitLabOAuthAccessToken({ fetchImpl: fetchImpl as typeof fetch }), + ).rejects.toThrow(/must be renewed/); + expect(encryptMock).toHaveBeenCalledWith( + expect.objectContaining({ status: 'reauthorization_required' }), + ); + }, + ); + + it('keeps the connection active when refresh fails transiently', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + Response.json( + { error: 'temporarily_unavailable' }, + { status: 503, statusText: 'Service Unavailable' }, + ), + ); + + await expect( + resolveGitLabOAuthAccessToken({ fetchImpl, forceRefresh: true }), + ).rejects.toThrow('GitLab OAuth refresh failed: 503 Service Unavailable'); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('keeps the connection active when the refresh request times out', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'expired-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + + await expect( + resolveGitLabOAuthAccessToken({ + fetchImpl, + forceRefresh: true, + requestTimeoutMs: 10, + }), + ).rejects.toThrow(); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('ignores a peer rotation that is already inside the refresh window', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock + .mockResolvedValueOnce({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'stale-access-token', + refreshToken: 'stale-refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['api'], + status: 'active', + }) + .mockResolvedValueOnce({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'peer-refreshed-access-token', + refreshToken: 'peer-refreshed-refresh-token', + // Changed, but dies before the worker could refresh again. + expiresAt: new Date(Date.now() + 30 * 1000).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + statusText: 'Bad Request', + }), + ); + + await expect( + resolveGitLabOAuthAccessToken({ + fetchImpl: fetchImpl as typeof fetch, + forceRefresh: true, + }), + ).rejects.toThrow(/must be renewed/); + }); + + it('uses a still-valid token when concurrent refresh fails', async () => { + const stillValidUntil = new Date(Date.now() + 30 * 60 * 1000).toISOString(); + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock + .mockResolvedValueOnce({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'stale-access-token', + refreshToken: 'stale-refresh-token', + expiresAt: new Date(0).toISOString(), + scopes: ['api'], + status: 'active', + }) + .mockResolvedValueOnce({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'peer-refreshed-access-token', + refreshToken: 'peer-refreshed-refresh-token', + expiresAt: stillValidUntil, + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + statusText: 'Bad Request', + }), + ); + + await expect( + resolveGitLabOAuthAccessToken({ + fetchImpl: fetchImpl as typeof fetch, + forceRefresh: true, + }), + ).resolves.toBe('peer-refreshed-access-token'); + expect(writeMock).not.toHaveBeenCalled(); + }); + + it('proactively refreshes OAuth access tokens inside the 10-minute skew window', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + decryptMock.mockResolvedValue({ + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'near-expiry-access-token', + refreshToken: 'refresh-token', + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + scopes: ['api'], + status: 'active', + }); + const fetchImpl = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'proactively-refreshed-token', + refresh_token: 'new-refresh-token', + expires_in: 7200, + }), + ); + + const token = await resolveGitLabOAuthAccessToken({ + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(token).toBe('proactively-refreshed-token'); + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(isGitLabOAuthAccessToken('proactively-refreshed-token')).toBe(true); + }); + + it('keeps the just-rotated token classified as OAuth for in-flight callers', async () => { + executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); + const connection = { + baseUrl: 'https://gitlab.example', + clientId: 'client-id', + clientSecret: 'client-secret', + accountId: '42', + username: 'roomote', + accessToken: 'rotating-access-token', + refreshToken: 'refresh-token', + scopes: ['api'], + status: 'active', + }; + decryptMock + .mockResolvedValueOnce({ + ...connection, + expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(), + }) + .mockResolvedValueOnce({ + ...connection, + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + }); + + // A caller resolves and holds this token... + await expect(resolveGitLabOAuthAccessToken()).resolves.toBe( + 'rotating-access-token', + ); + // ...while a later resolve rotates it out from under them. + await expect( + resolveGitLabOAuthAccessToken({ + fetchImpl: vi.fn().mockResolvedValue( + Response.json({ + access_token: 'rotated-access-token', + refresh_token: 'new-refresh-token', + expires_in: 7200, + }), + ) as typeof fetch, + }), + ).resolves.toBe('rotated-access-token'); + + expect(isGitLabOAuthAccessToken('rotated-access-token')).toBe(true); + expect(isGitLabOAuthAccessToken('rotating-access-token')).toBe(true); + expect(isGitLabOAuthAccessToken('glpat-personal-token')).toBe(false); + expect(isGitLabOAuthAccessToken('acme-pat-abc123')).toBe(false); + }); + it('waits for an in-flight refresh and prevents it from recreating the connection', async () => { executeMock.mockResolvedValue([{ value: 'encrypted-connection' }]); decryptMock.mockResolvedValue({ diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index 08e9d8638..14ef68708 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -20,6 +20,7 @@ import { import { isGitLabOAuthAccessToken, resolveGitLabOAuthAccessToken, + resolveGitLabOAuthAccessTokenWithMetadata, } from './oauth'; export * from './ci'; @@ -1193,8 +1194,13 @@ export async function createTaskRunScopedGitLabTokens( credentials: GitLabScopedProjectTokenCredential[]; proxyCredentials: GitLabScopedProjectTokenCredential[]; artifactsPatch: Record; + /** OAuth access-token expiry for the worker refresh loop, if known. */ + expiresAt: Date | null; }> { - const deploymentToken = await resolveGitLabToken(); + // `options.fetchImpl` is the GitLab *API* fetch used to mint project tokens + // below, so it is deliberately not forwarded to the OAuth token endpoint. + const oauthToken = await resolveGitLabOAuthAccessTokenWithMetadata(); + const deploymentToken = oauthToken?.accessToken; if (!deploymentToken?.trim()) { throw new Error( @@ -1224,6 +1230,7 @@ export async function createTaskRunScopedGitLabTokens( artifactsPatch: { [GITLAB_SCOPED_PROJECT_TOKENS_ARTIFACT_KEY]: [], }, + expiresAt: oauthToken?.expiresAt ?? null, }; } @@ -1316,6 +1323,7 @@ export async function createTaskRunScopedGitLabTokens( artifactsPatch: { [GITLAB_SCOPED_PROJECT_TOKENS_ARTIFACT_KEY]: [], }, + expiresAt: null, }; } @@ -1328,6 +1336,7 @@ export async function createTaskRunScopedGitLabTokens( artifactsPatch: { [GITLAB_SCOPED_PROJECT_TOKENS_ARTIFACT_KEY]: nextDescriptors, }, + expiresAt: null, }; } diff --git a/packages/gitlab/src/oauth.ts b/packages/gitlab/src/oauth.ts index a3e0bc4e3..8b60a338e 100644 --- a/packages/gitlab/src/oauth.ts +++ b/packages/gitlab/src/oauth.ts @@ -20,6 +20,11 @@ export type GitLabOAuthConnection = { accessToken: string; refreshToken: string; expiresAt: string; + /** + * Access-token lifetime reported by the instance. Absent on connections + * written before adaptive refresh, which fall back to the default skew. + */ + expiresInSeconds?: number; scopes: string[]; status: GitLabOAuthConnectionStatus; }; @@ -32,10 +37,88 @@ type GitLabOAuthTokenResponse = { scope?: string; }; -let refreshPromise: Promise | null = null; +export type GitLabOAuthAccessToken = { + accessToken: string; + /** Null when the stored expiry is unreadable; callers keep their default cadence. */ + expiresAt: Date | null; +}; + +/** Proactive OAuth refresh window for GitLab's default ~2h access tokens. */ +const OAUTH_ACCESS_TOKEN_REFRESH_SKEW_MS = 10 * 60 * 1000; + +/** GitLab's default access-token lifetime, used when none is reported. */ +const DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS = 7200; +const GITLAB_OAUTH_REQUEST_TIMEOUT_MS = 15_000; + +type GitLabOAuthErrorResponse = { + error?: string; +}; + +function isDefinitiveOAuthError(error: string | undefined): boolean { + return ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes( + error ?? '', + ); +} + +/** Expiry fields for a freshly issued access token, in the instance's own terms. */ +function accessTokenLifetime(token: GitLabOAuthTokenResponse): { + expiresAt: string; + expiresInSeconds: number; +} { + const expiresInSeconds = + token.expires_in ?? DEFAULT_ACCESS_TOKEN_LIFETIME_SECONDS; + + return { + expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString(), + expiresInSeconds, + }; +} + +/** + * Self-managed instances can configure a much shorter OAuth lifetime than the + * ~2h default. A fixed skew wider than the lifetime itself would refresh on + * every resolve, so cap it at a quarter of the token's life. + */ +function refreshSkewMsFor(connection: GitLabOAuthConnection): number { + const lifetimeMs = (connection.expiresInSeconds ?? 0) * 1000; + + return lifetimeMs > 0 + ? Math.min(OAUTH_ACCESS_TOKEN_REFRESH_SKEW_MS, lifetimeMs / 4) + : OAUTH_ACCESS_TOKEN_REFRESH_SKEW_MS; +} + +let refreshPromise: Promise | null = null; let deletionPromise: Promise | null = null; let connectionGeneration = 0; let cachedAccessToken: string | null = null; +// A rotate leaves in-flight callers holding the token they resolved a moment +// ago. Keep it so those calls still pick the Bearer header. +let previousAccessToken: string | null = null; + +function rememberAccessToken(accessToken: string): void { + if (cachedAccessToken && cachedAccessToken !== accessToken) { + previousAccessToken = cachedAccessToken; + } + cachedAccessToken = accessToken; +} + +function clearCachedAccessToken(): void { + cachedAccessToken = null; + previousAccessToken = null; +} + +function parseConnectionExpiresAt(expiresAt: string): Date | null { + const parsed = Date.parse(expiresAt); + return Number.isNaN(parsed) ? null : new Date(parsed); +} + +function toAccessTokenResult( + accessToken: string, + expiresAt: string, +): GitLabOAuthAccessToken { + rememberAccessToken(accessToken); + return { accessToken, expiresAt: parseConnectionExpiresAt(expiresAt) }; +} function tokenEndpoint(baseUrl: string): string { return new URL('oauth/token', `${baseUrl.replace(/\/$/, '')}/`).toString(); @@ -116,7 +199,7 @@ export async function deleteGitLabOAuthConnection(): Promise { .delete(deploymentSecrets) .where(eq(deploymentSecrets.name, SECRET_NAME)); refreshPromise = null; - cachedAccessToken = null; + clearCachedAccessToken(); })(); } @@ -134,6 +217,7 @@ export async function exchangeGitLabOAuthCode(input: { code: string; redirectUri: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; }): Promise { const response = await (input.fetchImpl ?? fetch)( tokenEndpoint(input.baseUrl), @@ -150,6 +234,9 @@ export async function exchangeGitLabOAuthCode(input: { grant_type: 'authorization_code', redirect_uri: input.redirectUri, }), + signal: AbortSignal.timeout( + input.requestTimeoutMs ?? GITLAB_OAUTH_REQUEST_TIMEOUT_MS, + ), }, ); if (!response.ok) @@ -168,9 +255,7 @@ export async function exchangeGitLabOAuthCode(input: { username: '', accessToken: token.access_token, refreshToken: token.refresh_token, - expiresAt: new Date( - Date.now() + (token.expires_in ?? 7200) * 1000, - ).toISOString(), + ...accessTokenLifetime(token), scopes: token.scope?.split(/\s+/).filter(Boolean) ?? [...DEFAULT_SCOPES], status: 'active', }; @@ -191,14 +276,16 @@ export async function exchangeGitLabOAuthCode(input: { // Token exchange is still valid when the identity lookup is temporarily unavailable. } await writeConnection(connection); - cachedAccessToken = connection.accessToken; + rememberAccessToken(connection.accessToken); return connection; } -export async function resolveGitLabOAuthAccessToken(options?: { +/** Resolve OAuth access token + expiry, refreshing inside the skew window. */ +export async function resolveGitLabOAuthAccessTokenWithMetadata(options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean; -}): Promise { + requestTimeoutMs?: number; +}): Promise { if (deletionPromise) { await deletionPromise; return null; @@ -212,10 +299,9 @@ export async function resolveGitLabOAuthAccessToken(options?: { if (!connection || connection.status !== 'active') return null; if ( !options?.forceRefresh && - Date.parse(connection.expiresAt) > Date.now() + 60_000 + Date.parse(connection.expiresAt) > Date.now() + refreshSkewMsFor(connection) ) { - cachedAccessToken = connection.accessToken; - return connection.accessToken; + return toAccessTokenResult(connection.accessToken, connection.expiresAt); } if (refreshPromise) return refreshPromise; @@ -234,16 +320,40 @@ export async function resolveGitLabOAuthAccessToken(options?: { refresh_token: connection.refreshToken, grant_type: 'refresh_token', }), + signal: AbortSignal.timeout( + options?.requestTimeoutMs ?? GITLAB_OAUTH_REQUEST_TIMEOUT_MS, + ), }, ); if (!response.ok) { if (generation !== connectionGeneration) return null; - await writeConnection({ - ...connection, - status: 'reauthorization_required', - }); + + const oauthError = await response + .clone() + .json() + .then((body) => (body as GitLabOAuthErrorResponse).error) + .catch(() => undefined); + + if (isDefinitiveOAuthError(oauthError)) { + const latest = await readConnection(); + if ( + latest?.status === 'active' && + latest.accessToken !== connection.accessToken && + Date.parse(latest.expiresAt) > Date.now() + refreshSkewMsFor(latest) + ) { + return toAccessTokenResult(latest.accessToken, latest.expiresAt); + } + await writeConnection({ + ...(latest ?? connection), + status: 'reauthorization_required', + }); + throw new Error( + 'GitLab OAuth authorization has expired and must be renewed.', + ); + } + throw new Error( - 'GitLab OAuth authorization has expired and must be renewed.', + `GitLab OAuth refresh failed: ${response.status} ${response.statusText}`, ); } const token = (await response.json()) as GitLabOAuthTokenResponse; @@ -251,16 +361,13 @@ export async function resolveGitLabOAuthAccessToken(options?: { ...connection, accessToken: token.access_token, refreshToken: token.refresh_token ?? connection.refreshToken, - expiresAt: new Date( - Date.now() + (token.expires_in ?? 7200) * 1000, - ).toISOString(), + ...accessTokenLifetime(token), scopes: token.scope?.split(/\s+/).filter(Boolean) ?? connection.scopes, status: 'active' as const, }; if (generation !== connectionGeneration) return null; await writeConnection(next); - cachedAccessToken = next.accessToken; - return next.accessToken; + return toAccessTokenResult(next.accessToken, next.expiresAt); })(); try { return await refreshPromise; @@ -269,8 +376,25 @@ export async function resolveGitLabOAuthAccessToken(options?: { } } +export async function resolveGitLabOAuthAccessToken(options?: { + fetchImpl?: typeof fetch; + forceRefresh?: boolean; + requestTimeoutMs?: number; +}): Promise { + const result = await resolveGitLabOAuthAccessTokenWithMetadata(options); + return result?.accessToken ?? null; +} + +/** + * Bearer (OAuth) vs PRIVATE-TOKEN. Only tokens this process actually minted + * qualify: guessing from prefixes misclassifies deploy tokens, CI job tokens, + * and self-managed instances with a customised PAT prefix. + */ export function isGitLabOAuthAccessToken(token: string): boolean { - return token === cachedAccessToken; + if (!token) { + return false; + } + return token === cachedAccessToken || token === previousAccessToken; } export async function markGitLabOAuthReauthorizationRequired(): Promise { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index 37470cd8b..9f29116b5 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -159,6 +159,7 @@ describe('createSourceControlTokenForTaskRun', () => { }, ], }, + expiresAt: null, }); mockCreateTaskRunGiteaCredentials.mockResolvedValue({ credentials: [ @@ -182,9 +183,11 @@ describe('createSourceControlTokenForTaskRun', () => { originBaseUrl: 'https://dev.azure.com', }, ], + expiresAt: new Date('2026-08-10T14:00:00.000Z'), }); mockCreateTaskRunBitbucketCredentials.mockResolvedValue({ credentials: [], + expiresAt: new Date('2026-08-10T13:00:00.000Z'), }); }); @@ -270,6 +273,7 @@ describe('createSourceControlTokenForTaskRun', () => { artifactsPatch: { gitlabScopedProjectTokens: [], }, + expiresAt: null, }); const result = await createSourceControlTokenForTaskRun( @@ -298,6 +302,48 @@ describe('createSourceControlTokenForTaskRun', () => { }); }); + it('threads GitLab OAuth access-token expiry into runtime token metadata', async () => { + const expiresAt = new Date(Date.now() + 90 * 60 * 1000); + mockCreateTaskRunScopedGitLabTokens.mockResolvedValue({ + credentials: [], + proxyCredentials: [ + { + host: 'gitlab.com', + repositoryFullName: 'group/project', + username: 'oauth2', + token: 'oauth_access_token', + originBaseUrl: 'https://gitlab.com', + }, + ], + artifactsPatch: { + gitlabScopedProjectTokens: [], + }, + expiresAt, + }); + + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'group/project', + description: 'Work on GitLab', + sourceControlProvider: 'gitlab', + }), + '[test]', + { maxRetries: 1 }, + ); + + expect(result).toMatchObject({ + provider: 'gitlab', + source: 'app', + expiresAt, + gitProxyCredentials: [ + { + provider: 'gitlab', + token: 'oauth_access_token', + }, + ], + }); + }); + it('creates Gitea token metadata from proxy-backed credentials', async () => { const result = await createSourceControlTokenForTaskRun( makeTaskRun({ @@ -365,7 +411,7 @@ describe('createSourceControlTokenForTaskRun', () => { }, ], source: 'app', - expiresAt: null, + expiresAt: new Date('2026-08-10T14:00:00.000Z'), }); expect(mockCreateTaskRunWorkerGitHubToken).not.toHaveBeenCalled(); expect(mockCreateTaskRunAdoCredentials).toHaveBeenCalledWith( @@ -378,6 +424,24 @@ describe('createSourceControlTokenForTaskRun', () => { ); }); + it('creates Bitbucket token metadata with its OAuth expiry', async () => { + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'group/project', + description: 'Work on Bitbucket', + sourceControlProvider: 'bitbucket', + }), + '[test]', + { maxRetries: 1 }, + ); + + expect(result).toMatchObject({ + provider: 'bitbucket', + envVar: 'BITBUCKET_OAUTH', + expiresAt: new Date('2026-08-10T13:00:00.000Z'), + }); + }); + it('resolves the provider via the shared resolver when the payload is unstamped', async () => { // Environment-workspace payload (no repo, no explicit provider): the shared // resolver reports gitlab, so a GitLab token is minted instead of the @@ -404,6 +468,29 @@ describe('createSourceControlTokenForTaskRun', () => { }); it('mints the stamped primary provider first and merges aggregate metadata', async () => { + const gitlabExpiresAt = new Date(Date.now() + 90 * 60 * 1000); + mockCreateTaskRunScopedGitLabTokens.mockResolvedValue({ + credentials: [ + { + host: 'gitlab.com', + repositoryFullName: 'group/project', + username: 'oauth2', + token: 'glptt_scoped_token', + }, + ], + proxyCredentials: [], + artifactsPatch: { + gitlabScopedProjectTokens: [ + { + repositoryFullName: 'group/project', + projectId: '101', + tokenId: 202, + }, + ], + }, + expiresAt: gitlabExpiresAt, + }); + const taskRun = makeTaskRun({ repo: 'group/project', selectedRepositories: ['owner/repo', 'group/project'], @@ -434,7 +521,8 @@ describe('createSourceControlTokenForTaskRun', () => { ], gitProxyCredentials: [], source: 'app', - expiresAt: null, + // GitHub has null expiry; keep GitLab OAuth expiry for the refresh loop. + expiresAt: gitlabExpiresAt, artifactsPatch: { gitlabScopedProjectTokens: [ { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts index 27d85dd91..4ae9416b4 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/refresh-github-token.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockTaskRunsFindFirst, mockCreateSourceControlTokenForTaskRun } = - vi.hoisted(() => ({ - mockTaskRunsFindFirst: vi.fn(), - mockCreateSourceControlTokenForTaskRun: vi.fn(), - })); +const { + mockTaskRunsFindFirst, + mockUpdate, + mockCreateSourceControlTokenForTaskRun, +} = vi.hoisted(() => ({ + mockTaskRunsFindFirst: vi.fn(), + mockUpdate: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(async () => undefined), + })), + })), + mockCreateSourceControlTokenForTaskRun: vi.fn(), +})); vi.mock('@roomote/db/server', () => ({ db: { @@ -13,6 +21,7 @@ vi.mock('@roomote/db/server', () => ({ findFirst: (...args: unknown[]) => mockTaskRunsFindFirst(...args), }, }, + update: mockUpdate, }, taskRuns: { id: 'taskRuns.id' }, eq: vi.fn(), @@ -54,7 +63,69 @@ describe('refreshGitHubTokenWithMetadata', () => { const result = await refreshGitHubTokenWithMetadata({} as never, 123); expect(result.expiresAt).toBe('2026-08-10T12:10:00.000Z'); - expect(result.nextRefreshAt).toBe('2026-08-10T12:05:00.000Z'); + expect(result.nextRefreshAt).toBe('2026-08-10T12:07:30.000Z'); + }); + + it('schedules app-backed GitLab credentials before their OAuth expiry', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: '', + envVar: 'GITLAB_TOKEN', + envVars: {}, + gitProxyCredentials: [], + source: 'app', + expiresAt: new Date('2026-08-10T12:30:00.000Z'), + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.expiresAt).toBe('2026-08-10T12:30:00.000Z'); + expect(result.nextRefreshAt).toBe('2026-08-10T12:25:00.000Z'); + }); + + it('never schedules past the default cadence for a long-lived expiry', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'github', + token: 'ghs_app_token', + envVar: 'GH_TOKEN', + envVars: { GH_TOKEN: 'ghs_app_token' }, + source: 'app', + expiresAt: new Date('2026-08-10T14:00:00.000Z'), + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.nextRefreshAt).toBe('2026-08-10T12:45:00.000Z'); + }); + + it('scales the refresh buffer to a token that lives less than the buffer', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'oauth_access_token', + envVar: 'GITLAB_TOKEN', + envVars: {}, + source: 'app', + expiresAt: new Date('2026-08-10T12:05:00.000Z'), + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.nextRefreshAt).toBe('2026-08-10T12:03:45.000Z'); + }); + + it('never schedules a known sub-minute expiry after the token expires', async () => { + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'oauth_access_token', + envVar: 'GITLAB_TOKEN', + envVars: {}, + source: 'app', + expiresAt: new Date('2026-08-10T12:00:30.000Z'), + }); + + const result = await refreshGitHubTokenWithMetadata({} as never, 123); + + expect(result.nextRefreshAt).toBe('2026-08-10T12:00:30.000Z'); }); it('keeps the default interval for credentials without an expiry', async () => { diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index 93a9b512e..f6f19f405 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -519,7 +519,7 @@ async function createProviderToken( }), ), source: 'app', - expiresAt: null, + expiresAt: scopedTokens.expiresAt, artifactsPatch: scopedTokens.artifactsPatch, }; } @@ -552,7 +552,7 @@ async function createProviderToken( provider, })), source: 'app', - expiresAt: null, + expiresAt: credentials.expiresAt, }; } case 'ado': { @@ -568,12 +568,22 @@ async function createProviderToken( provider, })), source: 'app', - expiresAt: null, + expiresAt: credentials.expiresAt, }; } } } +function earliestExpiry(left: Date | null, right: Date | null): Date | null { + if (!left) { + return right; + } + if (!right) { + return left; + } + return left.getTime() <= right.getTime() ? left : right; +} + function mergeProviderTokens( tokens: SourceControlRuntimeToken[], ): SourceControlRuntimeToken { @@ -599,12 +609,9 @@ function mergeProviderTokens( ...(merged.artifactsPatch ?? {}), ...(token.artifactsPatch ?? {}), }, - expiresAt: - merged.expiresAt && token.expiresAt - ? new Date( - Math.min(merged.expiresAt.getTime(), token.expiresAt.getTime()), - ) - : (merged.expiresAt ?? token.expiresAt), + // Keep the soonest known expiry so multi-provider runs still refresh + // short-lived credentials (e.g. GitLab OAuth) on time. + expiresAt: earliestExpiry(merged.expiresAt, token.expiresAt), }), primaryToken, ); diff --git a/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts b/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts index 5d2c021aa..156f762c8 100644 --- a/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts +++ b/packages/sdk/src/server/lib/task-runs/refresh-github-token.ts @@ -13,6 +13,19 @@ const DEFAULT_SOURCE_CONTROL_TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1000; const SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; const MIN_SOURCE_CONTROL_TOKEN_REFRESH_DELAY_MS = 60 * 1000; +/** + * Reserve time to re-mint before expiry. A self-managed provider can issue + * tokens that live for less than the fixed buffer, so cap it at a quarter of + * the token's remaining life rather than scheduling straight into the floor. + */ +function refreshBufferMs(expiresAt: Date, now: number): number { + const remainingMs = expiresAt.getTime() - now; + + return remainingMs > 0 + ? Math.min(SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS, remainingMs / 4) + : SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS; +} + /** * Generate a fresh source-control token for a task run within the caller's * scope. The exported name stays GitHub-specific for existing SDK callers. @@ -71,13 +84,27 @@ export async function refreshGitHubTokenWithMetadata( const now = Date.now(); - const nextRefreshAtMs = tokenResult.expiresAt + // A known expiry may only pull the refresh earlier, never push it out. Not + // every short-lived credential reports one: GitHub installation tokens die + // after ~1h with `expiresAt: null`, and a multi-provider run merges to the + // soonest *known* expiry, so the default cadence stays the upper bound. + const expiryDrivenRefreshAtMs = tokenResult.expiresAt ? Math.max( - now + MIN_SOURCE_CONTROL_TOKEN_REFRESH_DELAY_MS, - tokenResult.expiresAt.getTime() - - SOURCE_CONTROL_TOKEN_REFRESH_BUFFER_MS, + now, + Math.min( + tokenResult.expiresAt.getTime(), + Math.max( + now + MIN_SOURCE_CONTROL_TOKEN_REFRESH_DELAY_MS, + tokenResult.expiresAt.getTime() - + refreshBufferMs(tokenResult.expiresAt, now), + ), + ), ) - : now + DEFAULT_SOURCE_CONTROL_TOKEN_REFRESH_INTERVAL_MS; + : Number.POSITIVE_INFINITY; + const nextRefreshAtMs = Math.min( + now + DEFAULT_SOURCE_CONTROL_TOKEN_REFRESH_INTERVAL_MS, + expiryDrivenRefreshAtMs, + ); return { token: tokenResult.token, From 7f7b394ef7ad160467819de1b2cc8defac217430 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:28:21 -0500 Subject: [PATCH 25/28] [Fix] Auto-start channels appear unresponsive when task launch fails (#1193) * fix: report chat auto-start launch failures * chore: satisfy auto-start test lint * fix: suppress launch-failure replies on bot-authored feed messages Launch-criteria channels are commonly wired to automated feeds, and classifier_error returns before the launch rate cap increments, so a sustained classifier or startup outage would reply "please try again" to every feed message with nothing throttling it. Bot-authored failures now stay log-only; human-authored messages keep the failure reply. --------- Co-authored-by: Roomote Co-authored-by: daniel-lxs --- .../__tests__/channel-auto-start.test.ts | 100 ++++++- .../handlers/discord/channel-auto-start.ts | 50 +++- .../handlers/shared/channel-launch-gate.ts | 3 + .../events/channel-auto-start-failure.test.ts | 272 ++++++++++++++++++ .../handlers/slack/events/message-entry.ts | 75 ++++- 5 files changed, 488 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 4ffdc9751..5b55d2f78 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -39,7 +39,10 @@ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUserId, })); -vi.mock('../../shared/channel-launch-gate.js', () => ({ +vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../shared/channel-launch-gate.js') + >()), evaluateChannelLaunchGate: mocks.evaluateGate, })); @@ -67,10 +70,12 @@ const provider = { createDirectMessage: mocks.createDirectMessage, postMessage: mocks.postMessage, addReaction: mocks.addReaction, - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // oxlint-disable-next-line typescript/no-explicit-any } as any; const MONITORED_CHANNEL_ID = '400000000000000001'; +const FAILURE_MESSAGE = + "Sorry, Roomote couldn't start this task. Please try again in a moment."; function guildChannel( overrides: Partial = {}, @@ -106,7 +111,7 @@ function gatewayEvent(payload: Record): DiscordGatewayEvent { eventType: 'MESSAGE_CREATE', payload, receivedAt: '2026-07-17T15:00:00.000Z', - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // oxlint-disable-next-line typescript/no-explicit-any } as any; } @@ -135,7 +140,7 @@ async function runHandler(input: { const payload = input.payload ?? messagePayload(); return maybeHandleDiscordChannelAutoStart({ event: gatewayEvent(payload), - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // oxlint-disable-next-line typescript/no-explicit-any message: payload as any, channel: input.channel ?? guildChannel(), provider, @@ -446,12 +451,99 @@ describe('maybeHandleDiscordChannelAutoStart', () => { ); expect(mocks.startNewTask).not.toHaveBeenCalled(); expect(mocks.addReaction).not.toHaveBeenCalled(); + expect(mocks.postMessage).not.toHaveBeenCalled(); // The routing lock is released so a redelivery can re-evaluate. expect(mocks.redis.del).toHaveBeenCalledWith( 'discord:routing-lock:message-1', ); }); + it('replies when the launch classifier fails', async () => { + mocks.getBackgroundAgentSettings.mockResolvedValue( + settingsWith([ + { + channelId: MONITORED_CHANNEL_ID, + launchCriteria: 'Only launch on new incidents.', + }, + ]), + ); + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'classifier_error', + debug: { llmDecision: 'error', reason: 'provider unavailable' }, + }); + + await expect(runHandler({})).resolves.toBe(true); + await flushBackgroundWork(); + + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.postMessage).toHaveBeenCalledWith({ + channelId: MONITORED_CHANNEL_ID, + replyToMessageId: 'message-1', + text: FAILURE_MESSAGE, + }); + }); + + it('replies when task startup throws', async () => { + mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); + + await expect(runHandler({})).resolves.toBe(true); + await flushBackgroundWork(); + + expect(mocks.postMessage).toHaveBeenCalledWith({ + channelId: MONITORED_CHANNEL_ID, + replyToMessageId: 'message-1', + text: FAILURE_MESSAGE, + }); + }); + + it('stays silent when the classifier fails on a bot-authored message', async () => { + mocks.getBackgroundAgentSettings.mockResolvedValue( + settingsWith([ + { + channelId: MONITORED_CHANNEL_ID, + launchCriteria: 'Only launch on new incidents.', + }, + ]), + ); + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'classifier_error', + debug: { llmDecision: 'error', reason: 'provider unavailable' }, + }); + + await expect( + runHandler({ + payload: messagePayload({ + author: { id: 'alert-bot', username: 'alerts', bot: true }, + }), + }), + ).resolves.toBe(true); + await flushBackgroundWork(); + + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.postMessage).not.toHaveBeenCalled(); + }); + + it('stays silent when task startup throws for a bot-authored message', async () => { + mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); + + await expect( + runHandler({ + payload: messagePayload({ + author: { id: 'alert-bot', username: 'alerts', bot: true }, + }), + }), + ).resolves.toBe(true); + await flushBackgroundWork(); + + expect(mocks.postMessage).not.toHaveBeenCalled(); + // The routing lock is still released so a redelivery can re-evaluate. + expect(mocks.redis.del).toHaveBeenCalledWith( + 'discord:routing-lock:message-1', + ); + }); + it('dedupes concurrent deliveries via the routing lock', async () => { mocks.redis.set.mockResolvedValue(null); // lock already held diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index d057ba0d9..39546a863 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -24,7 +24,10 @@ import { import { apiLogger } from '../../logging.js'; import { checkAutoStartChannelCache } from '../shared/auto-start-cache.js'; -import { evaluateChannelLaunchGate } from '../shared/channel-launch-gate.js'; +import { + CHANNEL_AUTO_START_FAILURE_MESSAGE, + evaluateChannelLaunchGate, +} from '../shared/channel-launch-gate.js'; import { buildDiscordChannelAutoStartLinkMessage, claimAccountLinkDmSlot, @@ -52,6 +55,33 @@ const CHANNEL_AUTO_START_MESSAGE_TYPES = new Set([0, 19]); const DISCORD_ROUTING_LOCK_PREFIX = 'discord:routing-lock:'; const ROUTING_LOCK_TTL_SECONDS = 60; +async function sendLaunchFailureBestEffort(input: { + provider: DiscordCommunicationProvider; + channelId: string; + messageId: string; + isBotAuthored: boolean; +}): Promise { + // Bot-authored messages are typically automated feeds; a "please try + // again" reply is addressed to nobody, and a sustained classifier or + // startup outage would otherwise reply to every feed message. Failures on + // bot messages stay log-only, like before launch-failure replies existed. + if (input.isBotAuthored) { + return; + } + + await input.provider + .postMessage({ + channelId: input.channelId, + replyToMessageId: input.messageId, + text: CHANNEL_AUTO_START_FAILURE_MESSAGE, + }) + .catch((error) => { + apiLogger.warn( + `[DiscordChannelAutoStart] Failed to post launch failure for ${input.channelId}:${input.messageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} + /** * Discord has no ephemeral channel messages, so the "connect your account" * nudge Slack shows inline arrives as a DM instead — at most once per user @@ -315,7 +345,17 @@ export async function maybeHandleDiscordChannelAutoStart(input: { }); if (!gateResult.shouldLaunch) { - // Silent to the channel by design; the gate logged its reason. + // `rate_limited` stays silent on purpose: a capped channel is + // already at its launch budget, and per-message replies there would + // only add noise on top of an intentional throttle. + if (gateResult.skipReason === 'classifier_error') { + await sendLaunchFailureBestEffort({ + provider, + channelId: channel.channelId, + messageId: message.id, + isBotAuthored, + }); + } await releaseRoutingLock(); return; } @@ -383,6 +423,12 @@ export async function maybeHandleDiscordChannelAutoStart(input: { apiLogger.error( `[DiscordChannelAutoStart] Failed to launch for ${logContext}: ${error instanceof Error ? error.message : String(error)}`, ); + await sendLaunchFailureBestEffort({ + provider, + channelId: channel.channelId, + messageId: message.id, + isBotAuthored, + }); await releaseRoutingLock(); } })(); diff --git a/apps/api/src/handlers/shared/channel-launch-gate.ts b/apps/api/src/handlers/shared/channel-launch-gate.ts index 60192f37d..1674aefab 100644 --- a/apps/api/src/handlers/shared/channel-launch-gate.ts +++ b/apps/api/src/handlers/shared/channel-launch-gate.ts @@ -13,6 +13,9 @@ import { apiLogger } from '../../logging.js'; /** Chat providers with a channel auto-start consume path. */ type ChannelAutoStartProvider = 'slack' | 'discord'; +export const CHANNEL_AUTO_START_FAILURE_MESSAGE = + "Sorry, Roomote couldn't start this task. Please try again in a moment."; + const LAUNCH_RATE_LIMIT_PER_HOUR = 25; const LAUNCH_RATE_WINDOW_SECONDS = 60 * 60; diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts new file mode 100644 index 000000000..446147ebb --- /dev/null +++ b/apps/api/src/handlers/slack/events/channel-auto-start-failure.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const FAILURE_MESSAGE = + "Sorry, Roomote couldn't start this task. Please try again in a moment."; + +const mocks = vi.hoisted(() => ({ + redis: { + set: vi.fn(), + del: vi.fn(), + sadd: vi.fn(), + }, + evaluateGate: vi.fn(), + startTask: vi.fn(), + processAttachments: vi.fn(), + recordInboundMessage: vi.fn(), + postRoutingDebug: vi.fn(), + automationLaunchIdentity: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock('../../../logging.js', () => ({ + apiLogger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: mocks.logWarn, + }, +})); + +vi.mock('@roomote/env', () => ({ + Env: { TRPC_URL: null, R_APP_URL: 'http://localhost:3000' }, +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + ROUTING_AUTO_CONFIRM_TIMEOUT_MS: 0, +})); + +vi.mock('@roomote/cloud-agents', () => ({ + stripLeadingRawSlackMention: vi.fn((text: string) => text), + stripLeadingSlackProductMention: vi.fn((text: string) => text), +})); + +vi.mock('@roomote/redis', async (importOriginal) => ({ + ...(await importOriginal()), + getRedis: () => mocks.redis, +})); + +vi.mock('@roomote/slack', async (importOriginal) => ({ + ...(await importOriginal()), + startAutoRoutedSlackTask: mocks.startTask, +})); + +vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../shared/channel-launch-gate.js') + >()), + evaluateChannelLaunchGate: mocks.evaluateGate, +})); + +vi.mock('../helpers/attachments.js', () => ({ + processSlackAttachments: mocks.processAttachments, +})); + +vi.mock('../helpers/launch-identity.js', () => ({ + getSlackAutomationLaunchIdentity: mocks.automationLaunchIdentity, +})); + +vi.mock('../helpers/channel-auto-start-routing-debug.js', () => ({ + postChannelAutoStartRoutingDebug: mocks.postRoutingDebug, +})); + +vi.mock('../helpers/conversation-log.js', async (importOriginal) => ({ + ...(await importOriginal()), + recordInboundSlackConversationMessage: mocks.recordInboundMessage, +})); + +import { processSlackChannelAutoStartTask } from './message-entry.js'; + +const postMessage = vi.fn(); +const slack = { + addReaction: vi.fn(), + getChannelName: vi.fn(), + normalizeIncomingText: vi.fn(), + postMessage, +}; + +const event = { + type: 'message', + channel: 'C123', + channel_type: 'channel', + user: 'U123', + text: 'Please investigate this failure', + ts: '111.000', +} as never; + +async function runHandler( + launchCriteria?: string, + { isBotAuthored = false }: { isBotAuthored?: boolean } = {}, +) { + return processSlackChannelAutoStartTask({ + event, + isBotAuthored, + slackInstallation: { teamId: 'T123', botUserId: 'UBOT' } as never, + slack: slack as never, + userMapping: { + id: 'mapping-1', + slackUserId: 'U123', + slackTeamId: 'T123', + userId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + }, + teamId: 'T123', + ackEmoji: 'eyes', + channelAutoStartLaunchMode: 'always_start', + ...(launchCriteria ? { launchCriteria } : {}), + }); +} + +async function flushBackgroundWork() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('Slack channel auto-start failures', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.redis.set.mockResolvedValue('OK'); + mocks.redis.del.mockResolvedValue(1); + mocks.redis.sadd.mockResolvedValue(1); + mocks.processAttachments.mockResolvedValue({ + images: [], + attachmentTexts: [], + videoDescriptions: [], + }); + mocks.recordInboundMessage.mockResolvedValue(undefined); + mocks.postRoutingDebug.mockResolvedValue(undefined); + mocks.automationLaunchIdentity.mockResolvedValue({ + launchUserId: 'installer-1', + slackUserId: 'UBOT', + }); + postMessage.mockResolvedValue({ ts: 'reply-1' }); + vi.mocked(slack.addReaction).mockResolvedValue(undefined); + vi.mocked(slack.getChannelName).mockResolvedValue('forge'); + slack.normalizeIncomingText.mockImplementation(async (text: unknown) => + String(text), + ); + }); + + it('stays silent when launch criteria are not met', async () => { + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'criteria_not_met', + debug: { llmDecision: 'skip', reason: 'not actionable' }, + }); + + await expect(runHandler('Only actionable requests')).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).not.toHaveBeenCalled(); + expect(mocks.startTask).not.toHaveBeenCalled(); + }); + + it('stays silent when criteria skip diagnostics cannot be posted', async () => { + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'criteria_not_met', + debug: { llmDecision: 'skip', reason: 'not actionable' }, + }); + mocks.postRoutingDebug.mockRejectedValue(new Error('debug post failed')); + + await expect(runHandler('Only actionable requests')).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).not.toHaveBeenCalled(); + expect(mocks.startTask).not.toHaveBeenCalled(); + expect(mocks.logWarn).toHaveBeenCalledWith( + expect.stringContaining('debug post failed'), + ); + }); + + it('replies when the launch classifier fails', async () => { + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'classifier_error', + debug: { llmDecision: 'error', reason: 'provider unavailable' }, + }); + + await expect(runHandler('Only actionable requests')).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '111.000', + text: FAILURE_MESSAGE, + blocks: [{ type: 'markdown', text: FAILURE_MESSAGE }], + }); + expect(mocks.startTask).not.toHaveBeenCalled(); + }); + + it('replies when task startup throws', async () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mocks.startTask.mockRejectedValue(new Error('task queue unavailable')); + + await expect(runHandler()).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '111.000', + text: FAILURE_MESSAGE, + blocks: [{ type: 'markdown', text: FAILURE_MESSAGE }], + }); + errorSpy.mockRestore(); + }); + + it('still replies when startup and routing diagnostics both fail', async () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: true, + debug: { llmDecision: 'launch', reason: 'actionable' }, + }); + mocks.startTask.mockRejectedValue(new Error('task queue unavailable')); + mocks.postRoutingDebug.mockRejectedValue(new Error('debug post failed')); + + await expect(runHandler('Only actionable requests')).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).toHaveBeenCalledWith({ + channel: 'C123', + thread_ts: '111.000', + text: FAILURE_MESSAGE, + blocks: [{ type: 'markdown', text: FAILURE_MESSAGE }], + }); + errorSpy.mockRestore(); + }); + + it('stays silent when the classifier fails on a bot-authored message', async () => { + mocks.evaluateGate.mockResolvedValue({ + shouldLaunch: false, + skipReason: 'classifier_error', + debug: { llmDecision: 'error', reason: 'provider unavailable' }, + }); + + await expect( + runHandler('Only actionable requests', { isBotAuthored: true }), + ).resolves.toBe(true); + await flushBackgroundWork(); + + expect(postMessage).not.toHaveBeenCalled(); + expect(mocks.startTask).not.toHaveBeenCalled(); + }); + + it('stays silent when task startup throws for a bot-authored message', async () => { + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + mocks.startTask.mockRejectedValue(new Error('task queue unavailable')); + + await expect(runHandler(undefined, { isBotAuthored: true })).resolves.toBe( + true, + ); + await flushBackgroundWork(); + + expect(postMessage).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); +}); diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 0ab4ca869..d131fba60 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -70,7 +70,10 @@ import { } from '../helpers/conversation-log.js'; import { getSlackAutomationLaunchIdentity } from '../helpers/launch-identity.js'; import { checkAutoStartChannelCache } from '../../shared/auto-start-cache.js'; -import { evaluateChannelLaunchGate } from '../../shared/channel-launch-gate.js'; +import { + CHANNEL_AUTO_START_FAILURE_MESSAGE, + evaluateChannelLaunchGate, +} from '../../shared/channel-launch-gate.js'; import type { SlackWebhookContext } from '../context.js'; import { enrichSlackMessageEvent, @@ -694,7 +697,50 @@ async function maybeRecordTrackedAutomationThreadReply(params: { }); } -async function processSlackChannelAutoStartTask(params: { +async function postSlackChannelAutoStartFailureBestEffort(input: { + slack: SlackNotifier; + channelId: string; + threadId: string; + isBotAuthored: boolean; +}): Promise { + // Bot-authored messages are typically automated feeds; a "please try + // again" reply is addressed to nobody, and a sustained classifier or + // startup outage would otherwise reply to every feed message. Failures on + // bot messages stay log-only, like before launch-failure replies existed. + if (input.isBotAuthored) { + return; + } + + await input.slack + .postMessage({ + channel: input.channelId, + thread_ts: input.threadId, + text: CHANNEL_AUTO_START_FAILURE_MESSAGE, + blocks: [ + { + type: 'markdown', + text: CHANNEL_AUTO_START_FAILURE_MESSAGE, + }, + ], + }) + .catch((error) => { + apiLogger.warn( + `[SlackWebhook] Failed to post configured channel auto-start launch failure for ${input.channelId}:${input.threadId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} + +async function postChannelAutoStartRoutingDebugBestEffort( + input: Parameters[0], +): Promise { + await postChannelAutoStartRoutingDebug(input).catch((error) => { + apiLogger.warn( + `[SlackWebhook] Failed to post configured channel auto-start routing debug for ${input.sourceChannelId}:${input.threadId}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); +} + +export async function processSlackChannelAutoStartTask(params: { event: ChannelAutoStartMessageEvent; isBotAuthored: boolean; slackInstallation: SlackInstallation; @@ -792,7 +838,7 @@ async function processSlackChannelAutoStartTask(params: { channelAutoStartDebug = gateResult.debug; if (!gateResult.shouldLaunch) { - await postChannelAutoStartRoutingDebug({ + await postChannelAutoStartRoutingDebugBestEffort({ slack, sourceChannelId: event.channel, sourceChannelName, @@ -807,6 +853,17 @@ async function processSlackChannelAutoStartTask(params: { // Release the routing lock like other no-launch outcomes so a // manual @roomote mention in this thread is not blocked for the // remainder of the lock TTL. + // `rate_limited` stays silent on purpose: a capped channel is + // already at its launch budget, and per-message replies there would + // only add noise on top of an intentional throttle. + if (gateResult.skipReason === 'classifier_error') { + await postSlackChannelAutoStartFailureBestEffort({ + slack, + channelId: event.channel, + threadId, + isBotAuthored, + }); + } await redis.del(routingLockKey).catch(() => {}); return; } @@ -887,7 +944,7 @@ async function processSlackChannelAutoStartTask(params: { if (result.status === 'started') { if (channelAutoStartDebug) { - await postChannelAutoStartRoutingDebug({ + await postChannelAutoStartRoutingDebugBestEffort({ slack, sourceChannelId: event.channel, sourceChannelName, @@ -922,7 +979,7 @@ async function processSlackChannelAutoStartTask(params: { }); if (channelAutoStartDebug) { - await postChannelAutoStartRoutingDebug({ + await postChannelAutoStartRoutingDebugBestEffort({ slack, sourceChannelId: event.channel, sourceChannelName, @@ -972,7 +1029,7 @@ async function processSlackChannelAutoStartTask(params: { error instanceof Error ? error.message : String(error); if (channelAutoStartDebug) { - await postChannelAutoStartRoutingDebug({ + await postChannelAutoStartRoutingDebugBestEffort({ slack, sourceChannelId: event.channel, sourceChannelName, @@ -991,6 +1048,12 @@ async function processSlackChannelAutoStartTask(params: { `❌ Configured channel auto-start failed for thread ${threadId}:`, errorMessage, ); + await postSlackChannelAutoStartFailureBestEffort({ + slack, + channelId: event.channel, + threadId, + isBotAuthored, + }); } })(); From 10de5202ad806ea6184d69958e393085c9f83ab0 Mon Sep 17 00:00:00 2001 From: Pride Musvaire <8037540+pridemusvaire@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:37:57 +0800 Subject: [PATCH 26/28] [Fix] Isolate merged-PR audit pagination fixtures from concurrent suites (#1197) Co-authored-by: Claude Opus 5 (1M context) --- .../merged-pr-audit-runner.pagination.test.ts | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts b/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts index 3def0ed75..50abf3fcf 100644 --- a/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts +++ b/packages/sdk/src/server/automations/__tests__/merged-pr-audit-runner.pagination.test.ts @@ -27,6 +27,32 @@ import { const BATCH_LIMIT = 250; +/** + * The manifest scan is a global time-range query with no repository filter, + * and Vitest runs this file concurrently with the other real-database suites. + * A fixture sharing a merge window with another suite therefore shows up in + * these manifests and breaks the exact-length and exact-membership assertions. + * Give every test a disjoint window well past the historical dates those + * suites use, so a scan can only ever see the rows its own test inserted. + */ +const SCAN_WINDOW_START_MS = Date.UTC(2099, 0, 1); +const SCAN_WINDOW_SPAN_MS = 365 * 24 * 60 * 60 * 1000; +let scanWindowIndex = 0; + +function nextScanWindow() { + const startMs = SCAN_WINDOW_START_MS + scanWindowIndex * SCAN_WINDOW_SPAN_MS; + scanWindowIndex += 1; + + return { + /** Scan lower bound; fixtures merge strictly after it. */ + since: new Date(startMs), + /** Default merge instant for fixtures in this window. */ + mergedAt: new Date(startMs + SCAN_WINDOW_SPAN_MS / 2), + /** Scan upper bound enclosing the whole window. */ + upperBound: new Date(startMs + SCAN_WINDOW_SPAN_MS), + }; +} + const userIds: string[] = []; const repositoryIds: string[] = []; @@ -147,7 +173,7 @@ describe('getMergedPullRequests', () => { provider: 'bitbucket', }); - const mergedAt = new Date('2026-07-10T00:00:00Z'); + const { since, mergedAt, upperBound } = nextScanWindow(); await insertMergedFacts([ { repositoryId: gitlabRepo.id, @@ -166,8 +192,8 @@ describe('getMergedPullRequests', () => { ]); const batch = await getMergedPullRequests( - { kind: 'interval', since: new Date('2026-07-09T00:00:00Z') }, - new Date('2026-07-11T00:00:00Z'), + { kind: 'interval', since }, + upperBound, ); // Both rows share repositoryFullName + prNumber; only the repository id @@ -202,7 +228,7 @@ describe('getMergedPullRequests', () => { host: hostB, }); - const mergedAt = new Date('2026-07-10T00:00:00Z'); + const { since, mergedAt, upperBound } = nextScanWindow(); await insertMergedFacts([ { repositoryId: repoA.id, @@ -221,8 +247,8 @@ describe('getMergedPullRequests', () => { ]); const batch = await getMergedPullRequests( - { kind: 'interval', since: new Date('2026-07-09T00:00:00Z') }, - new Date('2026-07-11T00:00:00Z'), + { kind: 'interval', since }, + upperBound, ); // Same provider, same fullName, same PR number: only the host (via the @@ -302,7 +328,7 @@ describe('getMergedPullRequests', () => { provider: 'bitbucket', }); - const base = new Date('2026-07-01T00:00:00Z').getTime(); + const base = nextScanWindow().since.getTime(); const boundaryMergedAt = new Date(base + BATCH_LIMIT * 60_000); // Rows 1..(BATCH_LIMIT - 1) in repo A with strictly increasing merge @@ -380,9 +406,7 @@ describe('getMergedPullRequests', () => { provider: 'bitbucket', }); - // Keep this legacy-cursor fixture outside the historical windows used by - // the other real-database suites, which run concurrently in Vitest. - const mergedAt = new Date('2099-07-10T00:00:00Z'); + const { mergedAt, upperBound } = nextScanWindow(); await insertMergedFacts([ { repositoryId: repoA.id, @@ -408,7 +432,7 @@ describe('getMergedPullRequests', () => { cursor: { mergedAt: mergedAt.toISOString(), externalPullRequestId: 5 }, cursorDate: mergedAt, }, - new Date('2099-07-11T00:00:00Z'), + upperBound, ); expect(manifestKeys(batch.pullRequests).sort()).toEqual([ @@ -431,7 +455,7 @@ describe('getMergedPullRequests', () => { provider: 'bitbucket', }); - const mergedAt = new Date('2026-07-10T00:00:00Z'); + const { mergedAt, upperBound } = nextScanWindow(); await insertMergedFacts([ { repositoryId: repoA.id, @@ -473,7 +497,7 @@ describe('getMergedPullRequests', () => { }, cursorDate: mergedAt, }, - new Date('2026-07-11T00:00:00Z'), + upperBound, ); expect(manifestKeys(batch.pullRequests)).toEqual([ From c6c7db9c367132aa592ca578dc42694c70885d84 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:03:24 -0500 Subject: [PATCH 27/28] [Improve] Preserve source context for child tasks (#1190) * feat: preserve source context for child tasks * fix: clarify inherited source thread context * fix: keep inherited source context informational * fix: decouple context inheritance from sourceRunId semantics - launchTask reverts to the old sourceRunId stamping (env-definition or notifyOnSettle only) and carries the parent pointer in a dedicated transient communicationContextSourceRunId field instead, so relaunch lineage, settle notifications, and the activation metric are unchanged - inheritance skips launches that carry their own live communication context, stamps 'slack' when parent coordinates come from Slack task columns, and only flags communicationContextInherited when coordinates were actually copied - drop the report-back rule line from task_source_context; coordinates only, and escape the provider like the other fields - restore empty-string option fallback semantics in populateCommunicationMetadata --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> Co-authored-by: daniel-lxs --- .../tasks/__tests__/launchTask.test.ts | 4 +- apps/api/src/handlers/tasks/launchTask.ts | 7 + .../run-task/__tests__/mcp-task-env.test.ts | 13 ++ apps/worker/src/run-task/mcp-task-env.ts | 10 ++ .../src/server/__tests__/enqueue-task.test.ts | 90 ++++++++++ .../src/server/cloud-agent-workflow.ts | 31 +++- .../cloud-agents/src/server/task-run-queue.ts | 46 +++++ .../standardTaskSourceContext.test.ts | 34 ++++ .../src/server/workflows/standardTask.ts | 31 ++++ packages/types/src/task-runs.ts | 170 ++++++++++-------- 10 files changed, 355 insertions(+), 81 deletions(-) create mode 100644 packages/cloud-agents/src/server/workflows/__tests__/standardTaskSourceContext.test.ts diff --git a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts index ba8dd7cde..2d25759ee 100644 --- a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts @@ -325,7 +325,7 @@ describe('launchTask', () => { expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBeUndefined(); }); - it('does not stamp the launching run for run-token launches without notifyOnSettle', async () => { + it('carries the parent pointer for context inheritance without stamping sourceRunId', async () => { mockEnqueueTask.mockResolvedValue({ id: 104, taskId: 'task-plain-child' }); const runAuth = { @@ -349,10 +349,12 @@ describe('launchTask', () => { const enqueuedTask = mockEnqueueTask.mock.calls[0]?.[0] as { task: { sourceRunId?: number; + communicationContextSourceRunId?: number; payload: { notifySourceRunOnSettle?: boolean }; }; }; expect(enqueuedTask.task.sourceRunId).toBeUndefined(); + expect(enqueuedTask.task.communicationContextSourceRunId).toBe(556); expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBeUndefined(); }); diff --git a/apps/api/src/handlers/tasks/launchTask.ts b/apps/api/src/handlers/tasks/launchTask.ts index caaa32abb..94fd91fd2 100644 --- a/apps/api/src/handlers/tasks/launchTask.ts +++ b/apps/api/src/handlers/tasks/launchTask.ts @@ -301,6 +301,13 @@ export async function launchTask( 'runId' in auth.authContext ? { sourceRunId: auth.authContext.runId } : {}), + // Run-token launches carry the parent pointer for read-only + // source-context inheritance, without widening sourceRunId semantics. + ...('runId' in auth.authContext && + (requestedType === 'standard' || + requestedType === 'environment-definition') + ? { communicationContextSourceRunId: auth.authContext.runId } + : {}), }; const task: StandardTask | SuggestedTasksTask = diff --git a/apps/worker/src/run-task/__tests__/mcp-task-env.test.ts b/apps/worker/src/run-task/__tests__/mcp-task-env.test.ts index b85678c13..1df5b5e01 100644 --- a/apps/worker/src/run-task/__tests__/mcp-task-env.test.ts +++ b/apps/worker/src/run-task/__tests__/mcp-task-env.test.ts @@ -88,6 +88,19 @@ describe('getCommunicationReplyContext', () => { threadId: 'thread-1', }); }); + + it('does not activate inherited provider-neutral source context', () => { + expect( + getCommunicationReplyContext({ + payload: { + communicationProvider: 'teams', + communicationChannelId: '19:source-conversation@thread.v2', + communicationThreadId: 'source-activity', + communicationContextInherited: true, + }, + }), + ).toBeNull(); + }); }); describe('buildMcpTaskEnv', () => { diff --git a/apps/worker/src/run-task/mcp-task-env.ts b/apps/worker/src/run-task/mcp-task-env.ts index 6ba0d4651..f08842138 100644 --- a/apps/worker/src/run-task/mcp-task-env.ts +++ b/apps/worker/src/run-task/mcp-task-env.ts @@ -47,6 +47,16 @@ export function getSlackReplyContext(taskRun: { export function getCommunicationReplyContext(taskRun: { payload: unknown; }): CommunicationReplyContext | null { + if ( + taskRun.payload && + typeof taskRun.payload === 'object' && + !Array.isArray(taskRun.payload) && + (taskRun.payload as Record) + .communicationContextInherited === true + ) { + return null; + } + const provider = getCommunicationProviderFromTaskPayload(taskRun.payload); const channelId = getCommunicationChannelFromTaskPayload(taskRun.payload); const threadId = getCommunicationThreadIdFromTaskPayload(taskRun.payload); diff --git a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts index a4bfbc08b..d0b76c9a4 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -642,6 +642,96 @@ describe('enqueueTask initiator stamping', () => { ).toBe(true); }); + it('inherits source communication metadata for child task launches', async () => { + const userId = await createUser(); + const parentRun = await launchFresh({ + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + channels: { slackChannelId: 'C123', slackThreadTs: '123.456' }, + task: standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Parent work', + communicationProvider: 'slack', + communicationChannelId: 'C123', + communicationThreadId: '123.456', + }, + }), + }); + + const childRun = await launchFresh({ + task: { + ...standardTaskInput({ + payload: { repo: 'acme/widgets', description: 'Child work' }, + }), + communicationContextSourceRunId: parentRun.id, + }, + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(childRun.payload).toMatchObject({ + communicationProvider: 'slack', + communicationChannelId: 'C123', + communicationThreadId: '123.456', + communicationContextInherited: true, + }); + expect(childRun.sourceRunId).toBeNull(); + }); + + it('keeps a launch with its own live communication context untouched', async () => { + const userId = await createUser(); + const parentRun = await launchFresh({ + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'slack', + trigger: 'message', + channels: { slackChannelId: 'C123', slackThreadTs: '123.456' }, + task: standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Parent work', + communicationProvider: 'slack', + communicationChannelId: 'C123', + communicationThreadId: '123.456', + }, + }), + }); + + const childRun = await launchFresh({ + task: { + ...standardTaskInput({ + payload: { + repo: 'acme/widgets', + description: 'Child work', + communicationProvider: 'teams', + communicationChannelId: '19:live@thread.v2', + communicationThreadId: 'live-activity', + }, + }), + communicationContextSourceRunId: parentRun.id, + }, + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(childRun.payload).toMatchObject({ + communicationProvider: 'teams', + communicationChannelId: '19:live@thread.v2', + communicationThreadId: 'live-activity', + }); + expect( + (childRun.payload as Record) + .communicationContextInherited, + ).toBeUndefined(); + }); + it('persists an unlinked external actor with actor context and external commit author', async () => { const run = await launchFresh({ task: standardTaskInput({ diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 048b1ab56..e99a8a8e4 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -279,11 +279,19 @@ export async function generatePrompt({ const communicationProvider = getCommunicationProviderFromTaskPayload( taskSpec.payload, ); + const inheritedCommunicationContext = + taskSpec.payload.communicationContextInherited === true; + const activeSlackChannel = inheritedCommunicationContext + ? null + : slackChannel; + const activeCommunicationProvider = inheritedCommunicationContext + ? null + : communicationProvider; const nonSlackChatProvider = - communicationProvider === 'teams' || - communicationProvider === 'telegram' || - communicationProvider === 'discord' - ? communicationProvider + activeCommunicationProvider === 'teams' || + activeCommunicationProvider === 'telegram' || + activeCommunicationProvider === 'discord' + ? activeCommunicationProvider : null; const slackThreadTs = getSlackThreadTsFromTaskPayload(taskSpec.payload) ?? @@ -313,8 +321,8 @@ export async function generatePrompt({ repo: taskSpec.payload.repo, repoFullNames: await getWorkspaceRepositoryFullNames(taskSpec), taskSurface: resolveStandardTaskSurface({ - hasSlackChannel: Boolean(slackChannel), - communicationProvider, + hasSlackChannel: Boolean(activeSlackChannel), + communicationProvider: activeCommunicationProvider, taskSurface: taskRow?.surface, }), conflictResolverLabel: enabledConflictResolverLabel, @@ -322,7 +330,7 @@ export async function generatePrompt({ attribution: commitAuthor, slackTeamDomain: getSlackTeamDomainFromTaskPayload(taskSpec.payload) ?? undefined, - slackChannel: slackChannel ?? undefined, + slackChannel: activeSlackChannel ?? undefined, slackThreadTs: slackThreadTs ?? undefined, telegramChatId: nonSlackChatProvider === 'telegram' @@ -365,6 +373,11 @@ export async function generatePrompt({ nonSlackChatProvider === 'discord' ? (communicationMessageId ?? undefined) : undefined, + sourceProvider: + communicationProvider ?? (slackChannel ? 'slack' : undefined), + sourceChannelId: communicationChannelId ?? undefined, + sourceThreadId: communicationThreadId ?? undefined, + sourceMessageId: communicationMessageId ?? undefined, interactiveMode: taskSpec.payload.bootstrap?.interactiveMode, requestFormat, linkedWorkItems: taskSpec.payload.linkedWorkItems, @@ -377,7 +390,7 @@ export async function generatePrompt({ prAction, }); - if (slackChannel && slackThreadTs) { + if (!inheritedCommunicationContext && slackChannel && slackThreadTs) { const slackInstructions = buildSlackMessageInstructions({ includeRequestUserInputGuidance: true, }); @@ -386,7 +399,7 @@ export async function generatePrompt({ : slackInstructions; } - if (nonSlackChatProvider) { + if (!inheritedCommunicationContext && nonSlackChatProvider) { const chatInstructions = nonSlackChatProvider === 'teams' ? buildTeamsMessageInstructions() diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 52e12e5ff..17ca695b0 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -33,6 +33,7 @@ import { resolveTaskRuntimePolicy, resolveTaskWorkspace, resolveComputeProviderTarget, + populateCommunicationMetadata, sourceControlProviderSchema, TASK_TIMEOUT_MS, isManagedDeploymentReadOnly, @@ -1320,6 +1321,47 @@ export async function enqueueTask( return enqueueFreshLaunch(input as FreshTaskLaunch, options); } +async function inheritSourceCommunicationMetadata( + task: FreshTask, +): Promise { + const sourceRunId = task.communicationContextSourceRunId; + if (!sourceRunId) return; + + const sourceRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, sourceRunId), + columns: { payload: true }, + with: { + task: { + columns: { slackChannelId: true, slackThreadTs: true }, + }, + }, + }); + + if (!sourceRun) return; + + const payload = task.payload as Record; + + // A launch that carries its own live context stays a live chat turn. + if (payload.communicationProvider != null) return; + + populateCommunicationMetadata(payload, { + sourcePayload: sourceRun.payload, + channelId: sourceRun.task?.slackChannelId, + threadId: sourceRun.task?.slackThreadTs, + }); + + // Slack parents keep their coordinates in task columns rather than + // provider-neutral payload fields, so the provider needs stamping here. + if (payload.communicationProvider == null && sourceRun.task?.slackChannelId) { + payload.communicationProvider = 'slack'; + } + + // Only flag payloads that actually gained coordinates from the parent. + if (payload.communicationProvider != null) { + payload.communicationContextInherited = true; + } +} + async function enqueueFreshLaunch( input: FreshTaskLaunch, options: EnqueueTaskOptions, @@ -1330,6 +1372,10 @@ async function enqueueFreshLaunch( await assertUserIsNotDeleted(linkedUserId); + // Child launches inherit the provider-neutral origin coordinates so the + // agent can see where the parent conversation started. + await inheritSourceCommunicationMetadata(task); + if (PR_LINKAGE_REQUIRED_WORKFLOWS.has(workflow) && !input.prLinkage) { throw new Error( `A '${workflow}' launch requires prLinkage so the pull request row can be created with the task.`, diff --git a/packages/cloud-agents/src/server/workflows/__tests__/standardTaskSourceContext.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/standardTaskSourceContext.test.ts new file mode 100644 index 000000000..f04cc293f --- /dev/null +++ b/packages/cloud-agents/src/server/workflows/__tests__/standardTaskSourceContext.test.ts @@ -0,0 +1,34 @@ +import { standardTask } from '../standardTask'; + +describe('standardTask source context', () => { + it('exposes communication coordinates and optional completion reporting', () => { + const { harnessInstructions } = standardTask({ + description: 'Do the work', + repo: 'RooCodeInc/Roomote', + taskSurface: 'slack', + sourceProvider: 'slack', + sourceChannelId: 'C123', + sourceThreadId: '123.456', + sourceMessageId: '123.789', + }); + + expect(harnessInstructions).toContain(''); + expect(harnessInstructions).toContain('slack'); + expect(harnessInstructions).toContain('C123'); + expect(harnessInstructions).toContain('123.456'); + expect(harnessInstructions).toContain('123.789'); + expect(harnessInstructions).not.toContain( + 'report back to the source thread', + ); + }); + + it('does not add source context to web tasks without communication metadata', () => { + const { harnessInstructions } = standardTask({ + description: 'Do the work', + repo: 'RooCodeInc/Roomote', + taskSurface: 'web', + }); + + expect(harnessInstructions).not.toContain(''); + }); +}); diff --git a/packages/cloud-agents/src/server/workflows/standardTask.ts b/packages/cloud-agents/src/server/workflows/standardTask.ts index a494b4863..c817f1fe5 100644 --- a/packages/cloud-agents/src/server/workflows/standardTask.ts +++ b/packages/cloud-agents/src/server/workflows/standardTask.ts @@ -69,6 +69,10 @@ export function standardTask({ discordGuildId, discordChannelId, discordMessageId, + sourceProvider, + sourceChannelId, + sourceThreadId, + sourceMessageId, linkedWorkItems, interactiveMode = false, requestFormat = 'plain', @@ -114,6 +118,10 @@ export function standardTask({ discordGuildId?: string; discordChannelId?: string; discordMessageId?: string; + sourceProvider?: string; + sourceChannelId?: string; + sourceThreadId?: string; + sourceMessageId?: string; linkedWorkItems?: LinkedWorkItem[]; interactiveMode?: boolean; requestFormat?: 'plain' | 'structured'; @@ -322,6 +330,28 @@ ${buildGitHubMessageInstructions()}` If a workflow or packaged skill distinguishes web dashboard tasks from other surfaces, treat this run as a web dashboard task. When a secure web-task flow exists for the current step, prefer that flow over asking the user to paste secrets into chat or make local-only task edits. `; + const sourceContext = + sourceProvider && (sourceChannelId || sourceThreadId || sourceMessageId) + ? ` + + ${escapeTaskContextText(sourceProvider)}${ + sourceChannelId + ? ` + ${escapeTaskContextText(sourceChannelId)}` + : '' + }${ + sourceThreadId + ? ` + ${escapeTaskContextText(sourceThreadId)}` + : '' + }${ + sourceMessageId + ? ` + ${escapeTaskContextText(sourceMessageId)}` + : '' + } + ` + : ''; const sourceControlContext = sourceControlProvider ? ` @@ -375,6 +405,7 @@ ${buildGitHubMessageInstructions()}` ${taskSurfaceContext} + ${sourceContext} ${sourceControlContext} ${codeReviewSelfReviewCloseoutContext} diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 2d4ef0bcf..46f5255eb 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -775,6 +775,14 @@ const sharedTaskSchema = z.object({ // Resume-from-snapshot fields (set at insert time for atomic duplicate detection): sourceSnapshotId: z.string().nullish(), sourceRunId: z.number().nullish(), + + /** + * Run whose provider-neutral communication coordinates should be copied + * into this task's payload at enqueue time, as read-only source context. + * Transient enqueue input; independent of `sourceRunId` so relaunch + * lineage, settle notifications, and activation metrics are unaffected. + */ + communicationContextSourceRunId: z.number().nullish(), }); export const linkedWorkItemProviderSchema = z.enum([ @@ -951,6 +959,8 @@ const sharedTaskPayloadSchema = z.object({ communicationChannelId: z.string().optional(), communicationThreadId: z.string().optional(), communicationMessageId: z.string().optional(), + /** True when communication coordinates were inherited from a parent run. */ + communicationContextInherited: z.boolean().optional(), /** Provider event that caused this fresh launch; used for idempotent retries. */ communicationSourceEventId: z.string().optional(), /** @@ -1641,6 +1651,93 @@ export function getCommunicationMessageIdFromTaskPayload( ); } +export function populateCommunicationMetadata( + payload: Record, + options: { + sourcePayload?: unknown; + teamId?: string | null; + guildId?: string | null; + teamDomain?: string | null; + serviceUrl?: string | null; + channelId?: string | null; + threadId?: string | null; + messageId?: string | null; + } = {}, +): void { + const provider = getCommunicationProviderFromTaskPayload( + options.sourcePayload, + ); + if (provider) payload.communicationProvider = provider; + + // Empty-string options count as "not provided" and fall back to the + // source payload, matching the historical snapshot-resume semantics. + const fromOptionOrSource = ( + option: string | null | undefined, + sourceValue: string | null, + ): string | null => { + if (typeof option === 'string' && hasNonEmptyValue(option)) { + return option; + } + return sourceValue; + }; + + const values = [ + [ + 'communicationTeamId', + fromOptionOrSource( + options.teamId, + getCommunicationTeamIdFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationGuildId', + fromOptionOrSource( + options.guildId, + getCommunicationGuildIdFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationTeamDomain', + fromOptionOrSource( + options.teamDomain, + getCommunicationTeamDomainFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationServiceUrl', + fromOptionOrSource( + options.serviceUrl, + getCommunicationServiceUrlFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationChannelId', + fromOptionOrSource( + options.channelId, + getCommunicationChannelFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationThreadId', + fromOptionOrSource( + options.threadId, + getCommunicationThreadIdFromTaskPayload(options.sourcePayload), + ), + ], + [ + 'communicationMessageId', + fromOptionOrSource( + options.messageId, + getCommunicationMessageIdFromTaskPayload(options.sourcePayload), + ), + ], + ] as const; + + for (const [key, value] of values) { + if (hasNonEmptyValue(value ?? undefined)) payload[key] = value; + } +} + /** * Discord channel + message that intake (👀) and terminal platform reactions * target. Prefer the dedicated reaction fields (always real message ids) over @@ -1824,77 +1921,8 @@ export function populateSnapshotResumeCommunicationMetadata( messageId?: string | null; } = {}, ): void { - const provider = - options.provider ?? - getCommunicationProviderFromTaskPayload(options.sourcePayload); - - if (provider) { - payload.communicationProvider = provider; - } - - const teamId = - (hasNonEmptyValue(options.teamId ?? undefined) ? options.teamId : null) ?? - getCommunicationTeamIdFromTaskPayload(options.sourcePayload); - - if (teamId) { - payload.communicationTeamId = teamId; - } - - const guildId = - (hasNonEmptyValue(options.guildId ?? undefined) ? options.guildId : null) ?? - getCommunicationGuildIdFromTaskPayload(options.sourcePayload); - - if (guildId) { - payload.communicationGuildId = guildId; - } - - const teamDomain = - (hasNonEmptyValue(options.teamDomain ?? undefined) - ? options.teamDomain - : null) ?? - getCommunicationTeamDomainFromTaskPayload(options.sourcePayload); - - if (teamDomain) { - payload.communicationTeamDomain = teamDomain; - } - - const serviceUrl = - (hasNonEmptyValue(options.serviceUrl ?? undefined) - ? options.serviceUrl - : null) ?? - getCommunicationServiceUrlFromTaskPayload(options.sourcePayload); - - if (serviceUrl) { - payload.communicationServiceUrl = serviceUrl; - } - - const channelId = - (hasNonEmptyValue(options.channelId ?? undefined) - ? options.channelId - : null) ?? getCommunicationChannelFromTaskPayload(options.sourcePayload); - - if (channelId) { - payload.communicationChannelId = channelId; - } - - const threadId = - (hasNonEmptyValue(options.threadId ?? undefined) - ? options.threadId - : null) ?? getCommunicationThreadIdFromTaskPayload(options.sourcePayload); - - if (threadId) { - payload.communicationThreadId = threadId; - } - - const messageId = - (hasNonEmptyValue(options.messageId ?? undefined) - ? options.messageId - : null) ?? - getCommunicationMessageIdFromTaskPayload(options.sourcePayload); - - if (messageId) { - payload.communicationMessageId = messageId; - } + populateCommunicationMetadata(payload, options); + if (options.provider) payload.communicationProvider = options.provider; } /** From 3bac4b319d3927bd9b73122fcc1802eb1d88a685 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:25:32 -0400 Subject: [PATCH 28/28] Release Roomote 0.37.0 (#1208) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ef6e729..c33abf075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.37.0 (2026-08-10) + +This release brings voice-driven work to every chat provider, expands organization-wide automations and source-control identity support, and improves task reliability. + +### Highlights + +- Start tasks and send follow-ups with audio or voice messages across Slack, Discord, Telegram, and Microsoft Teams. +- Run custom automations across every active repository while routing suggested follow-up tasks to the correct environment. +- Show verified linked identities and privacy-safe attribution across GitLab, Gitea, Bitbucket, and Azure DevOps. +- Keep long-running tasks, pull-request reviews, authenticated previews, and chat auto-start flows working more reliably. + +### Minor changes + +- Run custom automations across all active repositories while routing each suggested follow-up task to the correct repository environment. +- Start tasks and send follow-ups with audio or voice messages across Slack, Discord, Telegram, and Microsoft Teams, with actionable guidance when transcription is unavailable. +- Configure deployment incident banners and Slack warnings with a Statuspage-compatible unresolved-incidents feed URL, or leave the feed disabled when no URL is set. +- Show verified linked identities for GitLab, Gitea, Bitbucket, and Azure DevOps, and use privacy-safe provider attribution for public source-control changes. + +### Patch changes + +- Let agents open authenticated shareable previews without being redirected to sign-in, including previews that ultimately redirect to direct machine URLs. +- Add a direct link from model settings to Roomote's model recommendations so users can compare supported choices before configuring task roles. +- Preserve provider-neutral source context for child tasks so agents can identify the originating conversation without inheriting live reply behavior. +- Keep pull-request attribution current and privacy-safe across public and private repositories while preserving provider-specific follow-up links. Thanks to @T4cC0re for reporting [#1184](https://github.com/RooCodeInc/Roomote/issues/1184). +- Refresh supported source-control OAuth credentials before they expire during long-running, resumed, and mixed-provider tasks while keeping temporary provider failures retryable. +- Keep pull-request review follow-ups running until their queued work settles, release review actions after stale workers stop, and avoid duplicate completion or action notifications. +- Reply to human-authored Slack and Discord auto-start messages when task classification or startup fails unexpectedly instead of appearing unresponsive. +- Restore Better Stack monitoring, incident, and telemetry inspection tools in tasks while preserving Roomote's read-only integration boundary. +- Reuse compute-provider clients across scheduler checks to prevent memory growth and worker restarts on deployments with continuously active tasks. +- Show every active pull request linked to a task across the web app and supported chat providers instead of displaying only one associated pull request. +- Let agents discover enabled automation models and reject unavailable model overrides when an automation is configured instead of failing later at launch. + ## 0.36.1 (2026-08-07) This release improves compatibility across MCP and Anthropic integrations, tightens automation suggestions, and refreshes Gemini recommendations. diff --git a/package.json b/package.json index 30b58bf83..76718ca35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.36.1", + "version": "0.37.0", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": {