From f0d18e4edb73b668bb3241991d202d7148182305 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:26:30 -0500 Subject: [PATCH 01/11] [Fix] Stop closeout retry loops when chat delivery permanently fails (#1095) * fix: stop closeout retry loops when chat delivery permanently fails An automation task whose Slack channel rejects every chat.postMessage got stuck in a loop: the stop hook demands a terminal closeout, the closeout post fails with an opaque generic 502, the agent retries, and the hook re-blocks on the next idle. The task burned reminder budget on 15 failed posts and then completed silently with no closeout delivered anywhere. Two fixes: - Propagate the real Slack error. SlackNotifier.postMessageDetailed preserves the structured Slack error code (not_in_channel, invalid_auth, ...) instead of collapsing every failure into a missing timestamp, and the thread_reply MCP handler returns it with a structured retryable verdict (422 for permanent errors, 502 for transient ones). - Give the closeout requirement an escape hatch. The worker records failed deliveries in the reply-satisfaction state; a non-retryable error (or five failed attempts) stamps a terminal delivery failure, the send_chat_reply result tells the agent to stop retrying, and the stop/silence hooks stand down so the task can complete instead of being reminded into a post that cannot succeed. A later successful post or a new inbound turn clears the stamp. * fix: only successful posts clear the terminal delivery failure state A successful reaction goes through reactions.add, not chat.postMessage, so it proves nothing about the posting path. Clearing the delivery failure state on reactions would re-arm closeout enforcement against a channel that still rejects posts. --- .../slack-thread-reply-quotes.test.ts | 83 ++++++-- apps/api/src/handlers/mcp/slack.ts | 25 ++- .../__tests__/reply-to-slack-thread.test.ts | 66 +++++++ .../__tests__/slack-api-client.test.ts | 26 +++ .../slack-reply-satisfaction.test.ts | 177 ++++++++++++++++++ .../slack-tool-session-propagation.test.ts | 98 ++++++++++ .../mcp/roomote-mcp-server/chat-api-client.ts | 42 ++++- .../roomote-mcp-server/chat-delivery-error.ts | 48 +++++ .../chat-reply-satisfaction.ts | 96 ++++++++++ .../src/mcp/roomote-mcp-server/index.ts | 77 +++++++- .../mcp/roomote-mcp-server/send-chat-reply.ts | 17 +- .../roomote-mcp-server/slack-post-helpers.ts | 2 + .../src/mcp/roomote-mcp-server/tool-result.ts | 7 +- .../slack-silence-hook-script.test.ts | 22 +++ .../__tests__/slack-stop-hook-script.test.ts | 61 ++++++ .../src/run-task/slack-silence-hook-script.ts | 15 ++ .../src/run-task/slack-stop-hook-script.ts | 13 ++ .../__tests__/post-message-delivery.test.ts | 54 ++++++ .../src/__tests__/slack-notifier.test.ts | 74 ++++++++ packages/slack/src/index.ts | 2 + packages/slack/src/post-message-delivery.ts | 57 ++++++ packages/slack/src/slack-notifier.ts | 25 ++- packages/slack/src/types.ts | 14 ++ 23 files changed, 1073 insertions(+), 28 deletions(-) create mode 100644 apps/worker/src/mcp/roomote-mcp-server/chat-delivery-error.ts create mode 100644 packages/slack/src/__tests__/post-message-delivery.test.ts create mode 100644 packages/slack/src/post-message-delivery.ts diff --git a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts index cb9d378e3..62a854fc1 100644 --- a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts @@ -10,7 +10,7 @@ const { getLatestUserMessageMock, getTaskChannelBindingsMock, maybeSendCommunicationThreadReplyMock, - postMessageMock, + postMessageDetailedMock, slackInstallationFindFirstMock, taskRunFindFirstMock, } = vi.hoisted(() => ({ @@ -20,7 +20,7 @@ const { getLatestUserMessageMock: vi.fn(), getTaskChannelBindingsMock: vi.fn(), maybeSendCommunicationThreadReplyMock: vi.fn(), - postMessageMock: vi.fn(), + postMessageDetailedMock: vi.fn(), slackInstallationFindFirstMock: vi.fn(), taskRunFindFirstMock: vi.fn(), })); @@ -50,7 +50,10 @@ vi.mock('@roomote/db/server', () => ({ workItems: { id: 'id' }, })); -vi.mock('@roomote/slack', () => ({ +vi.mock('@roomote/slack', async (importOriginal) => ({ + SlackPostDeliveryError: ( + await importOriginal() + ).SlackPostDeliveryError, buildSlackThreadFooterText: vi.fn().mockReturnValue('Task footer'), buildSlackThreadReplyFooterBlock: vi.fn(({ footerText }) => ({ type: 'context', @@ -73,7 +76,7 @@ vi.mock('@roomote/slack', () => ({ setSlackThreadReplyFooterMessageTs: vi.fn(), SlackNotifier: vi.fn( class { - postMessage = postMessageMock; + postMessageDetailed = postMessageDetailedMock; }, ), trackLatestUserMessageForSlackQuote: vi.fn(), @@ -177,7 +180,7 @@ describe('Slack thread reply quotes', () => { alt_text: 'Screenshot', }, ]); - postMessageMock.mockResolvedValue('333.444'); + postMessageDetailedMock.mockResolvedValue({ ts: '333.444' }); clearLatestUserMessageForReplyQuoteIfIdMock.mockResolvedValue(true); }); @@ -191,7 +194,7 @@ describe('Slack thread reply quotes', () => { }); expect(response.status).toBe(200); - expect(postMessageMock).toHaveBeenCalledWith( + expect(postMessageDetailedMock).toHaveBeenCalledWith( expect.objectContaining({ channel: 'C123', thread_ts: '111.222', @@ -221,7 +224,7 @@ describe('Slack thread reply quotes', () => { }); expect(response.status).toBe(200); - expect(postMessageMock).toHaveBeenCalledWith( + expect(postMessageDetailedMock).toHaveBeenCalledWith( expect.objectContaining({ channel: 'C123', thread_ts: '111.222', @@ -230,7 +233,7 @@ describe('Slack thread reply quotes', () => { ]), }), ); - expect(postMessageMock.mock.calls[0]?.[0]?.blocks).not.toEqual( + expect(postMessageDetailedMock.mock.calls[0]?.[0]?.blocks).not.toEqual( expect.arrayContaining([ expect.objectContaining({ block_id: 'roomote_thread_reply_quote', @@ -264,7 +267,7 @@ describe('Slack thread reply quotes', () => { }); expect(response.status).toBe(200); - expect(postMessageMock).toHaveBeenCalledWith( + expect(postMessageDetailedMock).toHaveBeenCalledWith( expect.objectContaining({ blocks: expect.arrayContaining([ expect.objectContaining({ @@ -273,7 +276,7 @@ describe('Slack thread reply quotes', () => { ]), }), ); - expect(postMessageMock.mock.calls[0]?.[0]?.blocks).not.toEqual( + expect(postMessageDetailedMock.mock.calls[0]?.[0]?.blocks).not.toEqual( expect.arrayContaining([expect.objectContaining({ block_id: 'footer' })]), ); expect(clearLatestUserMessageForReplyQuoteIfIdMock).toHaveBeenCalledWith( @@ -285,7 +288,9 @@ describe('Slack thread reply quotes', () => { it('keeps the pending quote when Slack delivery fails', async () => { buildThreadReplyImageBlocksMock.mockResolvedValue([]); - postMessageMock.mockRejectedValueOnce(new Error('Slack unavailable')); + postMessageDetailedMock.mockRejectedValueOnce( + new Error('Slack unavailable'), + ); const response = await createApp().request('/mcp/thread_reply', { method: 'POST', @@ -297,6 +302,62 @@ describe('Slack thread reply quotes', () => { expect(clearLatestUserMessageForReplyQuoteIfIdMock).not.toHaveBeenCalled(); }); + it('maps a permanent Slack posting error to a non-retryable structured response', async () => { + buildThreadReplyImageBlocksMock.mockResolvedValue([]); + postMessageDetailedMock.mockResolvedValue({ + slackErrorCode: 'not_in_channel', + }); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'On it' }), + }); + + expect(response.status).toBe(422); + expect(await response.json()).toEqual({ + error: 'Slack chat.postMessage failed: not_in_channel', + slackErrorCode: 'not_in_channel', + retryable: false, + }); + }); + + it('maps a transport-level Slack posting failure to a retryable 502', async () => { + buildThreadReplyImageBlocksMock.mockResolvedValue([]); + postMessageDetailedMock.mockResolvedValue({ transportError: true }); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'On it' }), + }); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: 'Slack chat.postMessage failed: transport error', + slackErrorCode: null, + retryable: true, + }); + }); + + it('keeps reporting a deleted thread root as a 409', async () => { + buildThreadReplyImageBlocksMock.mockResolvedValue([]); + postMessageDetailedMock.mockResolvedValue({ + skippedMissingThreadRoot: true, + }); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'On it' }), + }); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'Slack thread source message no longer exists', + }); + }); + it('clears exactly by id when the clear request carries a quoteId', async () => { const response = await createApp().request('/mcp/clear_reply_quote', { method: 'POST', diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index ac8a52421..c89fc12c6 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -33,6 +33,7 @@ import { setLatestSlackBotReply, setSlackThreadReplyFooterMessageTs, SlackNotifier, + SlackPostDeliveryError, trackSlackBotReply, withSlackThreadReplyFooterLock, THREAD_REPLY_FOOTER_LOCK_TIMEOUT_MESSAGE as SLACK_THREAD_REPLY_FOOTER_LOCK_TIMEOUT_MESSAGE, @@ -971,16 +972,17 @@ slackMcp.post('/thread_reply', async (c) => { blocks.push(...imageBlocks); blocks.push(...rootFooterBlocks); - const rootMessageTs = await slack.postMessage({ + const rootPostResult = await slack.postMessageDetailed({ channel: slackReplyTarget.channel, text: getSlackFallbackText(fallbackText, imageBlocks.length), unfurl_links: false, unfurl_media: false, blocks, }); + const rootMessageTs = rootPostResult.ts; if (!rootMessageTs) { - throw new Error('Slack chat.postMessage returned no message timestamp'); + throw new SlackPostDeliveryError(rootPostResult); } // The root message is already visible in Slack; failing the reply here @@ -1141,7 +1143,7 @@ slackMcp.post('/thread_reply', async (c) => { ); } - const nextMessageTs = await slack.postMessage({ + const replyPostResult = await slack.postMessageDetailed({ channel: slackReplyTarget.channel, thread_ts: existingThreadTs, text: getSlackFallbackText(fallbackText, imageBlocks.length), @@ -1149,9 +1151,13 @@ slackMcp.post('/thread_reply', async (c) => { unfurl_media: false, blocks, }); + const nextMessageTs = replyPostResult.ts; if (!nextMessageTs) { - throw new Error('Slack thread source message no longer exists'); + if (replyPostResult.skippedMissingThreadRoot) { + throw new Error('Slack thread source message no longer exists'); + } + throw new SlackPostDeliveryError(replyPostResult); } if (pendingQuote) { @@ -1343,6 +1349,17 @@ slackMcp.post('/thread_reply', async (c) => { ); } + if (error instanceof SlackPostDeliveryError) { + return c.json( + { + error: error.message, + slackErrorCode: error.slackErrorCode ?? null, + retryable: error.retryable, + }, + error.retryable ? 502 : 422, + ); + } + if (message === 'Slack chat.postMessage returned no message timestamp') { return c.json( { error: 'Slack chat.postMessage returned no message timestamp' }, diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts index b6ac41413..52b770a9c 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/reply-to-slack-thread.test.ts @@ -12,6 +12,7 @@ import { uploadPreparedArtifact, } from '../local-file-upload.js'; import { replyToChatThread } from '../chat-api-client.js'; +import { ChatDeliveryError } from '../chat-delivery-error.js'; import { handleSendChatReply } from '../send-chat-reply.js'; import type { ArtifactConfig, RoomoteConfig } from '../types.js'; @@ -293,6 +294,71 @@ describe('handleReplyToSlackThread', () => { success: false, error: 'Slack API unavailable', uploadedArtifactIds: ['art-1'], + deliveryFailure: { retryable: true }, }); }); + + it('flags unclassified delivery errors as retryable delivery failures', async () => { + vi.mocked(replyToChatThread).mockRejectedValue( + new Error('Failed to reply to chat thread: 502 transport hiccup'), + ); + + const result = await handleSendChatReply( + { taskId: 'task-1', summary: 'closeout text' }, + artifactConfig, + roomoteConfig, + ); + + expect(JSON.parse(result.content[0]!.text)).toEqual({ + success: false, + error: 'Failed to reply to chat thread: 502 transport hiccup', + deliveryFailure: { retryable: true }, + }); + }); + + it('carries the structured verdict for non-retryable delivery errors', async () => { + vi.mocked(replyToChatThread).mockRejectedValue( + new ChatDeliveryError({ + message: 'Failed to reply to chat thread: 422 not_in_channel', + status: 422, + retryable: false, + providerErrorCode: 'not_in_channel', + }), + ); + + const result = await handleSendChatReply( + { taskId: 'task-1', summary: 'closeout text' }, + artifactConfig, + roomoteConfig, + ); + + expect(JSON.parse(result.content[0]!.text)).toEqual({ + success: false, + error: 'Failed to reply to chat thread: 422 not_in_channel', + deliveryFailure: { + retryable: false, + providerErrorCode: 'not_in_channel', + }, + }); + }); + + it('does not flag pre-delivery failures as delivery failures', async () => { + const result = await handleSendChatReply( + { + taskId: 'task-1', + summary: 'has image', + imagePaths: ['screenshots/after.png'], + }, + { ...artifactConfig, workspacePath: undefined }, + roomoteConfig, + ); + + const parsed = JSON.parse(result.content[0]!.text) as Record< + string, + unknown + >; + expect(parsed.success).toBe(false); + expect(parsed.deliveryFailure).toBeUndefined(); + expect(replyToChatThread).not.toHaveBeenCalled(); + }); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts index 5ce03f24d..9e2e9070d 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-api-client.test.ts @@ -7,6 +7,7 @@ import { replyToSlackThread, trackSlackReplyQuote, } from '../slack-api-client.js'; +import { ChatDeliveryError } from '../chat-delivery-error.js'; import type { RoomoteConfig } from '../types.js'; const config: RoomoteConfig = { @@ -173,6 +174,31 @@ describe('replyToSlackThread', () => { ).rejects.toThrow('Failed to reply to Slack thread: 403 forbidden'); expect(fetch).toHaveBeenCalledTimes(1); }); + + it('throws a typed delivery error when the platform marks the failure non-retryable', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 422, + text: async () => + JSON.stringify({ + error: 'Slack chat.postMessage failed: not_in_channel', + slackErrorCode: 'not_in_channel', + retryable: false, + }), + }); + + const error = (await replyToSlackThread(config, { text: 'blocked' }).catch( + (thrown: unknown) => thrown, + )) as ChatDeliveryError; + + expect(error).toBeInstanceOf(ChatDeliveryError); + expect(error.message).toBe( + 'Failed to reply to Slack thread: 422 Slack chat.postMessage failed: not_in_channel', + ); + expect(error.status).toBe(422); + expect(error.retryable).toBe(false); + expect(error.providerErrorCode).toBe('not_in_channel'); + }); }); describe('postToChannel', () => { diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts index 1d3db3af0..6b760700a 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-reply-satisfaction.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { CHAT_REPLY_SATISFACTION_STATE_FILE_ENV as SLACK_REPLY_SATISFACTION_STATE_FILE_ENV, + recordChatReplyDeliveryFailure, recordChatReplySatisfaction as recordSlackReplySatisfaction, recordChatTurnStart as recordSlackTurnStart, } from '../chat-reply-satisfaction'; @@ -715,4 +716,180 @@ describe('Slack reply satisfaction state', () => { satisfiedTurnMessageTs: '111.222', }); }); + + describe('recordChatReplyDeliveryFailure', () => { + function writeStateFile(state: Record): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-slack-')); + tempDirs.push(tempDir); + const stateFilePath = path.join(tempDir, 'reply-state.json'); + fs.writeFileSync(stateFilePath, JSON.stringify(state), 'utf8'); + return stateFilePath; + } + + it('counts a retryable failure without stamping a terminal outcome', () => { + const stateFilePath = writeStateFile({ startedAtMs: 1000 }); + + const result = recordChatReplyDeliveryFailure({ + stateFilePath, + retryable: true, + nowMs: 2000, + }); + + expect(result).toEqual({ terminalDeliveryFailure: false }); + expect(JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))).toEqual({ + startedAtMs: 1000, + deliveryFailureCount: 1, + lastDeliveryFailureAtMs: 2000, + }); + }); + + it('stamps a terminal outcome on a non-retryable failure', () => { + const stateFilePath = writeStateFile({ startedAtMs: 1000 }); + + const result = recordChatReplyDeliveryFailure({ + stateFilePath, + retryable: false, + providerErrorCode: 'not_in_channel', + nowMs: 2000, + }); + + expect(result).toEqual({ terminalDeliveryFailure: true }); + expect(JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))).toEqual({ + startedAtMs: 1000, + deliveryFailureCount: 1, + lastDeliveryFailureAtMs: 2000, + lastDeliveryFailureCode: 'not_in_channel', + terminalDeliveryFailureAtMs: 2000, + }); + }); + + it('stamps a terminal outcome when the bounded retry budget is spent', () => { + const stateFilePath = writeStateFile({ + startedAtMs: 1000, + deliveryFailureCount: 4, + lastDeliveryFailureAtMs: 1900, + }); + + const result = recordChatReplyDeliveryFailure({ + stateFilePath, + retryable: true, + nowMs: 2000, + }); + + expect(result).toEqual({ terminalDeliveryFailure: true }); + expect(JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))).toEqual({ + startedAtMs: 1000, + deliveryFailureCount: 5, + lastDeliveryFailureAtMs: 2000, + terminalDeliveryFailureAtMs: 2000, + }); + }); + + it('keeps the earliest terminal stamp across later failures', () => { + const stateFilePath = writeStateFile({ + startedAtMs: 1000, + deliveryFailureCount: 5, + lastDeliveryFailureAtMs: 2000, + terminalDeliveryFailureAtMs: 2000, + }); + + const result = recordChatReplyDeliveryFailure({ + stateFilePath, + retryable: true, + nowMs: 3000, + }); + + expect(result).toEqual({ terminalDeliveryFailure: true }); + const state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + expect(state.terminalDeliveryFailureAtMs).toBe(2000); + expect(state.deliveryFailureCount).toBe(6); + }); + + it('ignores failures reported from non-parent sessions', () => { + const stateFilePath = writeStateFile({ + startedAtMs: 1000, + parentThreadId: 'thread-parent', + }); + + const result = recordChatReplyDeliveryFailure({ + stateFilePath, + retryable: false, + sessionId: 'thread-subagent', + nowMs: 2000, + }); + + expect(result).toEqual({ terminalDeliveryFailure: false }); + expect(JSON.parse(fs.readFileSync(stateFilePath, 'utf8'))).toEqual({ + startedAtMs: 1000, + parentThreadId: 'thread-parent', + }); + }); + + it('is not cleared by a successful reaction', () => { + const stateFilePath = writeStateFile({ + startedAtMs: 1000, + currentTurnMessageTs: '111.222', + deliveryFailureCount: 3, + lastDeliveryFailureCode: 'not_in_channel', + terminalDeliveryFailureAtMs: 2000, + }); + + recordSlackReplySatisfaction({ + stateFilePath, + messageTs: '111.222', + tool: 'send_chat_reaction_emoji', + nowMs: 3000, + }); + + const state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + expect(state.deliveryFailureCount).toBe(3); + expect(state.lastDeliveryFailureCode).toBe('not_in_channel'); + expect(state.terminalDeliveryFailureAtMs).toBe(2000); + }); + + it('is cleared by a later successful post', () => { + const stateFilePath = writeStateFile({ + startedAtMs: 1000, + deliveryFailureCount: 3, + lastDeliveryFailureAtMs: 2000, + lastDeliveryFailureCode: 'not_in_channel', + terminalDeliveryFailureAtMs: 2000, + }); + + recordSlackReplySatisfaction({ + stateFilePath, + messageTs: '111.222', + tool: 'send_chat_reply', + replyPurpose: 'closeout', + nowMs: 3000, + }); + + const state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + expect(state.deliveryFailureCount).toBeUndefined(); + expect(state.lastDeliveryFailureAtMs).toBeUndefined(); + expect(state.lastDeliveryFailureCode).toBeUndefined(); + expect(state.terminalDeliveryFailureAtMs).toBeUndefined(); + expect(state.messageTs).toBe('111.222'); + }); + + it('is reset by a new inbound turn', () => { + const stateFilePath = writeStateFile({ + currentTurnMessageTs: '111.222', + deliveryFailureCount: 3, + lastDeliveryFailureAtMs: 2000, + terminalDeliveryFailureAtMs: 2000, + }); + + recordSlackTurnStart({ + stateFilePath, + turnMessageTs: '333.444', + nowMs: 3000, + }); + + const state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8')); + expect(state.deliveryFailureCount).toBeUndefined(); + expect(state.terminalDeliveryFailureAtMs).toBeUndefined(); + expect(state.currentTurnMessageTs).toBe('333.444'); + }); + }); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-tool-session-propagation.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-tool-session-propagation.test.ts index acc83455c..000bb8889 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-tool-session-propagation.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/slack-tool-session-propagation.test.ts @@ -13,6 +13,7 @@ const mockState = vi.hoisted(() => ({ handleSendChatReactionEmoji: vi.fn(), handleAddReactionToSlackMessage: vi.fn(), recordChatReplySatisfaction: vi.fn(), + recordChatReplyDeliveryFailure: vi.fn(), })); vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ @@ -54,6 +55,7 @@ vi.mock('../add-reaction-to-slack-message.js', () => ({ vi.mock('../chat-reply-satisfaction.js', () => ({ recordChatReplySatisfaction: mockState.recordChatReplySatisfaction, + recordChatReplyDeliveryFailure: mockState.recordChatReplyDeliveryFailure, })); function getRegisteredTool(toolName: string): RegisteredTool { @@ -75,6 +77,10 @@ describe('roomote MCP Slack tool session propagation', () => { mockState.handleSendChatReactionEmoji.mockReset(); mockState.handleAddReactionToSlackMessage.mockReset(); mockState.recordChatReplySatisfaction.mockReset(); + mockState.recordChatReplyDeliveryFailure.mockReset(); + mockState.recordChatReplyDeliveryFailure.mockReturnValue({ + terminalDeliveryFailure: false, + }); process.env = { ...originalEnv, @@ -115,6 +121,98 @@ describe('roomote MCP Slack tool session propagation', () => { }); }); + it('records failed deliveries with the MCP session id and keeps the result unchanged while retryable', async () => { + const failedResult = { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: 'Failed to reply to chat thread: 502 transport hiccup', + deliveryFailure: { retryable: true }, + }), + }, + ], + }; + mockState.handleSendChatReply.mockResolvedValue(failedResult); + + const result = await getRegisteredTool('send_chat_reply').handler!( + { message: 'done', purpose: 'closeout' }, + { sessionId: 'thread-child' }, + ); + + expect(mockState.recordChatReplyDeliveryFailure).toHaveBeenCalledWith({ + retryable: true, + providerErrorCode: undefined, + sessionId: 'thread-child', + }); + expect(mockState.recordChatReplySatisfaction).not.toHaveBeenCalled(); + expect(result).toBe(failedResult); + }); + + it('rewrites the result with do-not-retry guidance once delivery is terminal', async () => { + mockState.recordChatReplyDeliveryFailure.mockReturnValue({ + terminalDeliveryFailure: true, + }); + mockState.handleSendChatReply.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: 'Failed to reply to chat thread: 422 not_in_channel', + deliveryFailure: { + retryable: false, + providerErrorCode: 'not_in_channel', + }, + }), + }, + ], + }); + + const result = (await getRegisteredTool('send_chat_reply').handler!( + { message: 'done', purpose: 'closeout' }, + { sessionId: 'thread-child' }, + )) as { content: Array<{ type: string; text: string }> }; + + expect(mockState.recordChatReplyDeliveryFailure).toHaveBeenCalledWith({ + retryable: false, + providerErrorCode: 'not_in_channel', + sessionId: 'thread-child', + }); + + const parsed = JSON.parse(result.content[0]!.text) as Record< + string, + unknown + >; + expect(parsed.success).toBe(false); + expect(parsed.deliveryPermanentlyFailed).toBe(true); + expect(parsed.error).toContain('not_in_channel'); + expect(parsed.error).toContain('failing permanently'); + expect(parsed.error).toContain('do not retry'); + }); + + it('does not record a delivery failure for non-delivery errors', async () => { + mockState.handleSendChatReply.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: 'ROOMOTE_WORKSPACE_PATH not set', + }), + }, + ], + }); + + await getRegisteredTool('send_chat_reply').handler!( + { message: 'done', purpose: 'closeout' }, + { sessionId: 'thread-child' }, + ); + + expect(mockState.recordChatReplyDeliveryFailure).not.toHaveBeenCalled(); + }); + it('passes the MCP session id into send_chat_reaction_emoji satisfaction writes', async () => { mockState.handleSendChatReactionEmoji.mockResolvedValue({ content: [ diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts index 007460ef2..88c608d9b 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-api-client.ts @@ -1,8 +1,5 @@ -import { - buildApiHeaders, - fetchWithTimeout, - parseApiError, -} from './api-client.js'; +import { buildApiHeaders, fetchWithTimeout } from './api-client.js'; +import { ChatDeliveryError } from './chat-delivery-error.js'; import type { CommunicationChannelMessagesResponse, CommunicationMessageContextResponse, @@ -52,8 +49,39 @@ async function postToMcpEndpoint< continue; } - const error = await parseApiError(response); - throw new Error(`${errorPrefix}: ${response.status} ${error}`); + const bodyText = await response.text(); + let parsedBody: { + error?: unknown; + retryable?: unknown; + slackErrorCode?: unknown; + } | null = null; + try { + parsedBody = JSON.parse(bodyText) as { + error?: unknown; + retryable?: unknown; + slackErrorCode?: unknown; + }; + } catch { + parsedBody = null; + } + + const errorDetail = + typeof parsedBody?.error === 'string' ? parsedBody.error : bodyText; + const message = `${errorPrefix}: ${response.status} ${errorDetail}`; + + if (parsedBody?.retryable === false) { + throw new ChatDeliveryError({ + message, + status: response.status, + retryable: false, + providerErrorCode: + typeof parsedBody.slackErrorCode === 'string' + ? parsedBody.slackErrorCode + : undefined, + }); + } + + throw new Error(message); } } diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-delivery-error.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-delivery-error.ts new file mode 100644 index 000000000..a524d84b3 --- /dev/null +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-delivery-error.ts @@ -0,0 +1,48 @@ +/** + * A chat delivery endpoint rejected the post. Carries the platform API's + * structured retryability verdict and provider error code so callers can act + * on fields instead of matching on message wording. + */ +export class ChatDeliveryError extends Error { + readonly status: number; + readonly retryable: boolean; + readonly providerErrorCode?: string; + + constructor(input: { + message: string; + status: number; + retryable: boolean; + providerErrorCode?: string; + }) { + super(input.message); + this.name = 'ChatDeliveryError'; + this.status = input.status; + this.retryable = input.retryable; + this.providerErrorCode = input.providerErrorCode; + } +} + +interface ChatDeliveryFailure { + retryable: boolean; + providerErrorCode?: string; +} + +/** + * Describes a failed delivery attempt. Errors without a structured verdict + * default to retryable; the bounded attempt budget in the satisfaction state + * still caps how long that optimism lasts. + */ +export function describeChatDeliveryFailure( + error: unknown, +): ChatDeliveryFailure { + if (error instanceof ChatDeliveryError) { + return { + retryable: error.retryable, + ...(error.providerErrorCode + ? { providerErrorCode: error.providerErrorCode } + : {}), + }; + } + + return { retryable: true }; +} diff --git a/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts b/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts index 0a5b62669..288d4accb 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/chat-reply-satisfaction.ts @@ -43,6 +43,19 @@ interface ChatReplySatisfactionState { terminalSatisfactionTool?: 'send_chat_reply'; lastNonSlackWorkAfterTerminalAtMs?: number; lastSilenceReminderAtMs?: number; + /** Failed chat delivery attempts since the last successful post this turn. */ + deliveryFailureCount?: number; + lastDeliveryFailureAtMs?: number; + /** Structured provider error code (e.g. Slack `not_in_channel`) from the latest failed delivery. */ + lastDeliveryFailureCode?: string; + /** + * Stamped when delivery to the bound chat channel has permanently failed: + * a non-retryable provider error, or the bounded attempt budget is spent. + * The stop and silence hooks treat this as the terminal outcome so the + * task can complete instead of being reminded into a closeout it has no + * way to deliver. Cleared by any later successful post or a new turn. + */ + terminalDeliveryFailureAtMs?: number; } function trimString(value: unknown): string { @@ -146,6 +159,10 @@ export function recordChatTurnStart(input: { terminalSatisfiedAtMs: undefined, terminalSatisfactionTool: undefined, lastNonSlackWorkAfterTerminalAtMs: undefined, + deliveryFailureCount: undefined, + lastDeliveryFailureAtMs: undefined, + lastDeliveryFailureCode: undefined, + terminalDeliveryFailureAtMs: undefined, } : {}), }; @@ -153,6 +170,73 @@ export function recordChatTurnStart(input: { writeState(stateFilePath, state); } +/** + * Bounded budget of failed delivery attempts before the failure is treated + * as terminal even without a structured non-retryable verdict. Keeps a task + * whose channel fails with an unclassified error from retrying forever. + */ +const MAX_RETRYABLE_DELIVERY_FAILURES_BEFORE_TERMINAL = 5; + +/** + * Records a failed chat delivery attempt. When the failure is non-retryable, + * or the bounded attempt budget is spent, stamps `terminalDeliveryFailureAtMs` + * so the stop and silence hooks stop demanding an undeliverable closeout. + */ +export function recordChatReplyDeliveryFailure(input: { + retryable: boolean; + providerErrorCode?: string; + sessionId?: string; + stateFilePath?: string; + nowMs?: number; +}): { terminalDeliveryFailure: boolean } { + const stateFilePath = getStateFilePath(input.stateFilePath); + + if (!stateFilePath) { + return { terminalDeliveryFailure: false }; + } + + const existingState = readState(stateFilePath); + const sessionId = trimString(input.sessionId); + const parentThreadId = trimString(existingState.parentThreadId); + + if (parentThreadId && sessionId && sessionId !== parentThreadId) { + return { terminalDeliveryFailure: false }; + } + + const nowMs = input.nowMs ?? Date.now(); + const previousCount = + typeof existingState.deliveryFailureCount === 'number' && + Number.isFinite(existingState.deliveryFailureCount) + ? existingState.deliveryFailureCount + : 0; + const deliveryFailureCount = previousCount + 1; + const providerErrorCode = trimString(input.providerErrorCode); + const previousTerminalAtMs = + typeof existingState.terminalDeliveryFailureAtMs === 'number' && + Number.isFinite(existingState.terminalDeliveryFailureAtMs) + ? existingState.terminalDeliveryFailureAtMs + : undefined; + const terminalDeliveryFailure = + previousTerminalAtMs !== undefined || + !input.retryable || + deliveryFailureCount >= MAX_RETRYABLE_DELIVERY_FAILURES_BEFORE_TERMINAL; + const state: ChatReplySatisfactionState = { + ...existingState, + deliveryFailureCount, + lastDeliveryFailureAtMs: nowMs, + ...(providerErrorCode + ? { lastDeliveryFailureCode: providerErrorCode } + : {}), + ...(terminalDeliveryFailure + ? { terminalDeliveryFailureAtMs: previousTerminalAtMs ?? nowMs } + : {}), + }; + + writeState(stateFilePath, state); + + return { terminalDeliveryFailure }; +} + export function recordChatReplySatisfaction(input: { messageTs: string; tool: ChatReplySatisfactionTool; @@ -197,6 +281,18 @@ export function recordChatReplySatisfaction(input: { replyPurpose: input.tool === 'send_chat_reply' ? input.replyPurpose : undefined, recordedAtMs: nowMs, + // A successful post proves the channel is deliverable again. Reactions + // do not: they go through reactions.add, not chat.postMessage, so a + // reaction succeeding must not re-arm closeout enforcement against a + // posting path that is still broken. + ...(input.tool === 'send_chat_reply' + ? { + deliveryFailureCount: undefined, + lastDeliveryFailureAtMs: undefined, + lastDeliveryFailureCode: undefined, + terminalDeliveryFailureAtMs: undefined, + } + : {}), ...(satisfiesCurrentTurn ? { satisfiedTurnMessageTs: currentTurnMessageTs, diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 918a33f69..152544c1e 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -47,6 +47,7 @@ import { handleShowWidget } from './show-widget.js'; import { handleSendChatReply } from './send-chat-reply.js'; import { type ChatReplyPurpose, + recordChatReplyDeliveryFailure, recordChatReplySatisfaction, } from './chat-reply-satisfaction.js'; import { handlePostToChannel } from './post-to-channel.js'; @@ -1358,7 +1359,9 @@ if (shouldRegisterSlackThreadReplyTool()) { replyPurpose: params.purpose, sessionId: extra.sessionId, }); - return result; + return recordFailedChatDeliveryResult(result, { + sessionId: extra.sessionId, + }); }, ); } @@ -1401,6 +1404,78 @@ function recordSuccessfulSlackTurnSatisfactionResult( } } +/** + * Records a failed delivery attempt flagged by the tool handler. When the + * failure becomes terminal (non-retryable, or the bounded attempt budget is + * spent), rewrites the tool result so the agent stops retrying an + * undeliverable post; the stop and silence hooks now allow completion. + */ +function recordFailedChatDeliveryResult( + result: ToolResult, + options: { sessionId?: string } = {}, +): ToolResult { + const text = result.content + .map((entry) => (entry.type === 'text' ? entry.text : '')) + .join('\n'); + + if (!text.trim()) { + return result; + } + + let parsed: Record; + try { + parsed = JSON.parse(text) as Record; + } catch { + return result; + } + + const deliveryFailure = parsed.deliveryFailure; + if ( + parsed.success !== false || + typeof deliveryFailure !== 'object' || + deliveryFailure === null + ) { + return result; + } + + const { retryable, providerErrorCode } = deliveryFailure as { + retryable?: unknown; + providerErrorCode?: unknown; + }; + const failureRecord = recordChatReplyDeliveryFailure({ + retryable: retryable !== false, + providerErrorCode: + typeof providerErrorCode === 'string' ? providerErrorCode : undefined, + sessionId: options.sessionId, + }); + + if (!failureRecord.terminalDeliveryFailure) { + return result; + } + + const codeSuffix = + typeof providerErrorCode === 'string' && providerErrorCode + ? ` (${providerErrorCode})` + : ''; + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + ...parsed, + deliveryPermanentlyFailed: true, + error: + `${typeof parsed.error === 'string' ? parsed.error : 'Chat delivery failed'}. ` + + `Delivery to the configured chat channel is failing permanently${codeSuffix}. ` + + 'This has been recorded as the terminal delivery outcome: do not retry this or any other posting tool. ' + + 'Finish the task now; the task transcript carries the result.', + }), + }, + ], + }; +} + if (shouldRegisterChannelPostTool()) { const postSurface = getChatReplySurfaceLabel(); diff --git a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts index 87887443d..39a7e009f 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/send-chat-reply.ts @@ -1,4 +1,5 @@ import { replyToChatThread } from './chat-api-client.js'; +import { describeChatDeliveryFailure } from './chat-delivery-error.js'; import { errorResultWithArtifacts, normalizeOptionalSlackText, @@ -29,6 +30,7 @@ export async function handleSendChatReply( const uploadedArtifactIds: string[] = []; const allArtifactIds = [...imageArtifactIds]; + let reachedDeliveryCall = false; try { const uploads = await uploadSlackImagePaths({ @@ -50,6 +52,7 @@ export async function handleSendChatReply( return contentValidation; } + reachedDeliveryCall = true; const reply = await replyToChatThread(roomoteConfig, { ...(summary && { text: summary }), ...(allArtifactIds.length > 0 && { @@ -64,13 +67,25 @@ export async function handleSendChatReply( ...(imageArtifactIds.length > 0 && { imageArtifactIds }), }); } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Only the thread_reply call itself counts as a delivery attempt; image + // upload or validation failures say nothing about channel deliverability. + const deliveryFailureFields = reachedDeliveryCall + ? { deliveryFailure: describeChatDeliveryFailure(error) } + : undefined; + if (uploadedArtifactIds.length > 0) { return errorResultWithArtifacts( - error instanceof Error ? error.message : String(error), + message, uploadedArtifactIds, + deliveryFailureFields, ); } + if (deliveryFailureFields) { + return errorResult(message, deliveryFailureFields); + } + return catchError(error); } } diff --git a/apps/worker/src/mcp/roomote-mcp-server/slack-post-helpers.ts b/apps/worker/src/mcp/roomote-mcp-server/slack-post-helpers.ts index bc37bbf83..794563cce 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/slack-post-helpers.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/slack-post-helpers.ts @@ -33,6 +33,7 @@ export function uniqueNonEmpty(values?: string[]): string[] { export function errorResultWithArtifacts( message: string, uploadedArtifactIds: string[], + fields?: Record, ): ToolResult { return { content: [ @@ -42,6 +43,7 @@ export function errorResultWithArtifacts( success: false, error: message, ...(uploadedArtifactIds.length > 0 && { uploadedArtifactIds }), + ...fields, }), }, ], diff --git a/apps/worker/src/mcp/roomote-mcp-server/tool-result.ts b/apps/worker/src/mcp/roomote-mcp-server/tool-result.ts index 9fda32f47..f77371f95 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/tool-result.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/tool-result.ts @@ -1,11 +1,14 @@ import type { ToolResult } from './types.js'; -export function errorResult(message: string): ToolResult { +export function errorResult( + message: string, + fields?: Record, +): ToolResult { return { content: [ { type: 'text', - text: JSON.stringify({ success: false, error: message }), + text: JSON.stringify({ success: false, error: message, ...fields }), }, ], }; diff --git a/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts b/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts index f3d51326a..1165ec2b1 100644 --- a/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts +++ b/apps/worker/src/run-task/__tests__/slack-silence-hook-script.test.ts @@ -485,6 +485,28 @@ describe('SLACK_SILENCE_HOOK_SCRIPT', () => { expect(result.stderr).toContain('reason="slack_update_overdue"'); }); + it('stands down entirely when delivery to the bound channel has permanently failed', () => { + const stateFilePath = writeState({ + startedAtMs: Date.now() - 7 * 60_000 - 1_000, + deliveryFailureCount: 5, + lastDeliveryFailureCode: 'not_in_channel', + terminalDeliveryFailureAtMs: Date.now() - 5_000, + }); + + const result = runHook({ + input: { hook_event_name: 'PostToolUse', tool_name: 'shell' }, + env: { + ROOMOTE_SLACK_HOOK_DEBUG: '1', + ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('decision="allow"'); + expect(result.stderr).toContain('reason="terminal_delivery_failure"'); + }); + it('names the communication surface in the silence reminder for non-Slack providers', () => { const stateFilePath = writeState({ startedAtMs: Date.now() - 7 * 60_000 - 1_000, diff --git a/apps/worker/src/run-task/__tests__/slack-stop-hook-script.test.ts b/apps/worker/src/run-task/__tests__/slack-stop-hook-script.test.ts index c285ad59e..55e422ad9 100644 --- a/apps/worker/src/run-task/__tests__/slack-stop-hook-script.test.ts +++ b/apps/worker/src/run-task/__tests__/slack-stop-hook-script.test.ts @@ -214,6 +214,67 @@ describe('SLACK_STOP_HOOK_SCRIPT', () => { ); }); + it('allows Stop when delivery to the bound channel has permanently failed', () => { + const stateFilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-stop-state-')), + 'state.json', + ); + tempDirs.push(path.dirname(stateFilePath)); + fs.writeFileSync( + stateFilePath, + JSON.stringify({ + startedAtMs: Date.now() - 60_000, + requiresTerminalCloseoutWithoutTurn: true, + deliveryFailureCount: 1, + lastDeliveryFailureCode: 'not_in_channel', + terminalDeliveryFailureAtMs: Date.now() - 5_000, + }), + 'utf8', + ); + + const result = runHook({ + env: { + ROOMOTE_SLACK_HOOK_DEBUG: 'true', + ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('decision="allow"'); + expect(result.stderr).toContain('reason="terminal_delivery_failure"'); + }); + + it('allows Stop on an unsatisfied turn when delivery has permanently failed', () => { + const stateFilePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-stop-state-')), + 'state.json', + ); + tempDirs.push(path.dirname(stateFilePath)); + fs.writeFileSync( + stateFilePath, + JSON.stringify({ + currentTurnMessageTs: '111.222', + currentTurnStartedAtMs: Date.now() - 60_000, + deliveryFailureCount: 5, + terminalDeliveryFailureAtMs: Date.now() - 5_000, + }), + 'utf8', + ); + + const result = runHook({ + env: { + ROOMOTE_SLACK_HOOK_DEBUG: 'true', + ROOMOTE_SLACK_REPLY_SATISFACTION_STATE_FILE: stateFilePath, + }, + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('decision="allow"'); + expect(result.stderr).toContain('reason="terminal_delivery_failure"'); + }); + it('allows Stop when an automation-started task posted its closeout without an inbound turn', () => { const stateFilePath = path.join( fs.mkdtempSync(path.join(os.tmpdir(), 'roomote-stop-state-')), diff --git a/apps/worker/src/run-task/slack-silence-hook-script.ts b/apps/worker/src/run-task/slack-silence-hook-script.ts index 134b3094b..b7a0fc571 100644 --- a/apps/worker/src/run-task/slack-silence-hook-script.ts +++ b/apps/worker/src/run-task/slack-silence-hook-script.ts @@ -581,6 +581,21 @@ function writeInitialAckReminderState(stateFilePath, state, nowMs) { process.exit(0); } + // Delivery to the bound channel has permanently failed; ack and silence + // reminders would demand posts that cannot succeed, so stand down entirely. + const terminalDeliveryFailureAtMs = readFiniteMs( + state && state.terminalDeliveryFailureAtMs, + ); + if (terminalDeliveryFailureAtMs !== null) { + logAllow({ + trigger: hookEventName, + reason: 'terminal_delivery_failure', + tool: getToolName(hookInput), + terminalDeliveryFailureAtMs, + }); + process.exit(0); + } + if ( hookEventName === 'PreToolUse' && isPrematureAutomationReply(hookInput, state) diff --git a/apps/worker/src/run-task/slack-stop-hook-script.ts b/apps/worker/src/run-task/slack-stop-hook-script.ts index afc196475..041f4ef6e 100644 --- a/apps/worker/src/run-task/slack-stop-hook-script.ts +++ b/apps/worker/src/run-task/slack-stop-hook-script.ts @@ -404,6 +404,19 @@ function getTerminalCurrentTurnFailureReason(state) { process.exit(0); } + // Delivery to the bound channel has permanently failed; every reminder this + // hook could issue demands a post that cannot succeed, so let the turn end. + const terminalDeliveryFailureAtMs = readFiniteMs( + state && state.terminalDeliveryFailureAtMs, + ); + if (terminalDeliveryFailureAtMs !== null) { + logAllow({ + reason: 'terminal_delivery_failure', + terminalDeliveryFailureAtMs, + }); + process.exit(0); + } + if (hasConfiguredSlackReplyStateWithoutTurn(state)) { if (state && state.requiresTerminalCloseoutWithoutTurn === true) { logBlock({ diff --git a/packages/slack/src/__tests__/post-message-delivery.test.ts b/packages/slack/src/__tests__/post-message-delivery.test.ts new file mode 100644 index 000000000..1a6a7cd1c --- /dev/null +++ b/packages/slack/src/__tests__/post-message-delivery.test.ts @@ -0,0 +1,54 @@ +// pnpm --filter @roomote/slack test src/__tests__/post-message-delivery.test.ts + +import { + isNonRetryableSlackPostErrorCode, + SlackPostDeliveryError, +} from '../post-message-delivery'; + +describe('isNonRetryableSlackPostErrorCode', () => { + it('classifies channel and credential errors as non-retryable', () => { + expect(isNonRetryableSlackPostErrorCode('not_in_channel')).toBe(true); + expect(isNonRetryableSlackPostErrorCode('channel_not_found')).toBe(true); + expect(isNonRetryableSlackPostErrorCode('is_archived')).toBe(true); + expect(isNonRetryableSlackPostErrorCode('invalid_auth')).toBe(true); + expect(isNonRetryableSlackPostErrorCode('token_revoked')).toBe(true); + }); + + it('keeps content-dependent and unknown errors retryable', () => { + expect(isNonRetryableSlackPostErrorCode('msg_too_long')).toBe(false); + expect(isNonRetryableSlackPostErrorCode('invalid_blocks')).toBe(false); + expect(isNonRetryableSlackPostErrorCode('rate_limited')).toBe(false); + expect(isNonRetryableSlackPostErrorCode('unknown_error')).toBe(false); + expect(isNonRetryableSlackPostErrorCode(undefined)).toBe(false); + }); +}); + +describe('SlackPostDeliveryError', () => { + it('is non-retryable for permanent Slack error codes', () => { + const error = new SlackPostDeliveryError({ + slackErrorCode: 'not_in_channel', + }); + + expect(error.message).toBe('Slack chat.postMessage failed: not_in_channel'); + expect(error.slackErrorCode).toBe('not_in_channel'); + expect(error.retryable).toBe(false); + }); + + it('is retryable for transport failures', () => { + const error = new SlackPostDeliveryError({ transportError: true }); + + expect(error.message).toBe( + 'Slack chat.postMessage failed: transport error', + ); + expect(error.transportError).toBe(true); + expect(error.retryable).toBe(true); + }); + + it('is retryable for content-dependent Slack error codes', () => { + const error = new SlackPostDeliveryError({ + slackErrorCode: 'msg_too_long', + }); + + expect(error.retryable).toBe(true); + }); +}); diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index 780b0d3cf..c78dec581 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -131,6 +131,20 @@ describe('SlackNotifier', () => { expect(ts).toBeUndefined(); }); + it('returns undefined when Slack rejects the message', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, error: 'not_in_channel' }), + }); + + const ts = await notifier.postMessage({ + channel: 'C123', + text: 'rejected case', + }); + + expect(ts).toBeUndefined(); + }); + it('does not change unfurl behavior for plain text messages', async () => { getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ ok: true, @@ -187,6 +201,66 @@ describe('SlackNotifier', () => { }); }); + describe('postMessageDetailed', () => { + it('returns the message ts on success', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, ts: '123.456' }), + }); + + const result = await notifier.postMessageDetailed({ + channel: 'C123', + text: 'hello world', + }); + + expect(result).toEqual({ ts: '123.456' }); + }); + + it('returns the Slack error code when Slack rejects the message', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, error: 'not_in_channel' }), + }); + + const result = await notifier.postMessageDetailed({ + channel: 'C123', + text: 'rejected case', + }); + + expect(result).toEqual({ slackErrorCode: 'not_in_channel' }); + }); + + it('flags a transport error for non-2xx responses', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + const result = await notifier.postMessageDetailed({ + channel: 'C123', + text: 'failure case', + }); + + expect(result).toEqual({ transportError: true }); + }); + + it('flags a skipped threaded reply when the thread root is gone', async () => { + getGlobalWithFetch().fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ ok: false, error: 'thread_not_found' }), + }); + + const result = await notifier.postMessageDetailed({ + channel: 'C123', + thread_ts: '123.000', + text: 'hello thread', + }); + + expect(result).toEqual({ skippedMissingThreadRoot: true }); + }); + }); + describe('getMessagePermalink', () => { it('returns the Slack permalink for a message when Slack succeeds', async () => { getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 3eaed13ac..2cd0ae069 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -20,6 +20,7 @@ export * from './slack-api-base-url'; export * from './slack-api-fetch'; export * from './slack-channel-info-cache'; export * from './slack-messages'; +export * from './post-message-delivery'; export * from './slack-notifier'; export * from './slack-system-messages'; export * from './slack-thread-message-utils'; @@ -49,6 +50,7 @@ export type { SlackFile, SlackMessage, SlackMessageMetadata, + SlackPostMessageResult, SlackResponse, SlackEvent, SlackThreadMessage, diff --git a/packages/slack/src/post-message-delivery.ts b/packages/slack/src/post-message-delivery.ts new file mode 100644 index 000000000..0c4d78644 --- /dev/null +++ b/packages/slack/src/post-message-delivery.ts @@ -0,0 +1,57 @@ +/** + * Slack error codes for chat.postMessage that cannot succeed by retrying the + * same request against the same target: the channel is gone or inaccessible, + * or the installation's credentials are dead. Content-dependent errors such + * as `msg_too_long` and `invalid_blocks` are deliberately excluded — a + * rewritten message can still go through. + */ +const NON_RETRYABLE_SLACK_POST_ERROR_CODES = new Set([ + 'account_inactive', + 'channel_not_found', + 'ekm_access_denied', + 'invalid_auth', + 'is_archived', + 'messages_tab_disabled', + 'no_permission', + 'not_in_channel', + 'org_login_required', + 'restricted_action', + 'team_access_not_granted', + 'token_expired', + 'token_revoked', +]); + +export function isNonRetryableSlackPostErrorCode( + code: string | undefined, +): boolean { + return code !== undefined && NON_RETRYABLE_SLACK_POST_ERROR_CODES.has(code); +} + +/** + * A chat.postMessage attempt failed. Carries the structured Slack error code + * (or transport-failure flag) so API handlers can report retryability to the + * calling agent instead of collapsing every failure into a generic 502. + */ +export class SlackPostDeliveryError extends Error { + readonly slackErrorCode?: string; + readonly transportError: boolean; + + constructor(result: { slackErrorCode?: string; transportError?: boolean }) { + super( + `Slack chat.postMessage failed: ${ + result.slackErrorCode ?? + (result.transportError ? 'transport error' : 'unknown error') + }`, + ); + this.name = 'SlackPostDeliveryError'; + this.slackErrorCode = result.slackErrorCode; + this.transportError = result.transportError === true; + } + + get retryable(): boolean { + return ( + this.transportError || + !isNonRetryableSlackPostErrorCode(this.slackErrorCode) + ); + } +} diff --git a/packages/slack/src/slack-notifier.ts b/packages/slack/src/slack-notifier.ts index 3771e43ba..c1bd326f7 100644 --- a/packages/slack/src/slack-notifier.ts +++ b/packages/slack/src/slack-notifier.ts @@ -3,6 +3,7 @@ import type { SlackConversationMessage, SlackMessage, SlackMessageMetadata, + SlackPostMessageResult, SlackResponse, SlackFile, SlackThreadMessage, @@ -862,6 +863,18 @@ export class SlackNotifier { } public async postMessage(message: SlackMessage) { + return (await this.postMessageDetailed(message)).ts; + } + + /** + * Like postMessage, but preserves why a post produced no timestamp: the + * Slack API error code, a transport failure, or a skipped reply into a + * deleted thread. Callers that report failures to an agent or decide + * retryability must use this instead of inferring from a missing ts. + */ + public async postMessageDetailed( + message: SlackMessage, + ): Promise { if (message.channel && message.thread_ts) { const threadRootExists = await this.hasMessageInThread({ channel: message.channel, @@ -873,7 +886,7 @@ export class SlackNotifier { console.warn( `[postMessage] Skipping threaded Slack reply because thread root ${message.thread_ts} is no longer available in channel ${message.channel}`, ); - return undefined; + return { skippedMissingThreadRoot: true }; } } @@ -883,7 +896,15 @@ export class SlackNotifier { 'regular', ); - return response?.ts; + if (!response) { + return { transportError: true }; + } + + if (!response.ok || !response.ts) { + return { slackErrorCode: response.error ?? 'unknown_error' }; + } + + return { ts: response.ts }; } public async postEphemeralMessage(message: SlackMessage & { user: string }) { diff --git a/packages/slack/src/types.ts b/packages/slack/src/types.ts index ac7b841a1..6b1fa07d0 100644 --- a/packages/slack/src/types.ts +++ b/packages/slack/src/types.ts @@ -28,6 +28,20 @@ export interface SlackResponse { message?: Record; } +/** + * Structured outcome of a chat.postMessage attempt. Exactly one of the + * fields is set: `ts` on success, otherwise the failure discriminator. + */ +export interface SlackPostMessageResult { + ts?: string; + /** Slack API error code (e.g. `not_in_channel`) when Slack rejected the post. */ + slackErrorCode?: string; + /** The HTTP call itself failed (non-2xx status or network error); retryable. */ + transportError?: boolean; + /** Threaded reply skipped because the thread root message no longer exists. */ + skippedMissingThreadRoot?: boolean; +} + export interface SlackMessageMetadata { event_type: string; event_payload: Record; From 90db65016b464f9a01b51e7dd7d92a5876035d74 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:34:09 -0400 Subject: [PATCH 02/11] [Improve] Use the custom Roomote Discord invite (#1100) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- README.md | 2 +- .../components/layout/release-notices/ReleaseNotesDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 910ebfb28..58eea7b31 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ code. "Fix the typo on the pricing page" works. ## Community -- [Discord](https://discord.gg/KNw7Sz75UK): questions, showcase, feature requests +- [Discord](https://discord.gg/roomote): questions, showcase, feature requests - [GitHub Issues](https://github.com/RooCodeInc/Roomote/issues): bug reports and feature requests diff --git a/apps/web/src/components/layout/release-notices/ReleaseNotesDialog.tsx b/apps/web/src/components/layout/release-notices/ReleaseNotesDialog.tsx index 2a8fa50f0..bf3924ccc 100644 --- a/apps/web/src/components/layout/release-notices/ReleaseNotesDialog.tsx +++ b/apps/web/src/components/layout/release-notices/ReleaseNotesDialog.tsx @@ -179,7 +179,7 @@ export function ReleaseNotesDialog({ Date: Tue, 4 Aug 2026 13:47:44 -0400 Subject: [PATCH 03/11] [Feat] Add deployment-wide Granola integration (#1099) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../mcp/__tests__/granola-auth.test.ts | 341 ++++++++++++++++++ apps/api/src/handlers/mcp/granola/api.ts | 81 +++++ .../src/handlers/mcp/granola/connection.ts | 23 ++ apps/api/src/handlers/mcp/granola/index.ts | 141 ++++++++ apps/api/src/handlers/mcp/granola/tools.ts | 175 +++++++++ apps/api/src/handlers/mcp/index.ts | 2 + apps/docs/docs.json | 1 + apps/docs/integrations/granola.mdx | 39 ++ apps/docs/integrations/index.mdx | 1 + apps/docs/logo/integrations/granola.svg | 3 + apps/docs/snippets/integration-name.jsx | 1 + .../components/settings/Integrations.test.tsx | 136 +++++++ .../src/components/settings/Integrations.tsx | 233 ++++++++++++ .../system/custom/logos/brand-icon.tsx | 34 ++ apps/web/src/hooks/mcp-connections/index.ts | 2 + .../hooks/mcp-connections/useDisconnectMcp.ts | 3 + .../mcp-connections/useGranolaConnection.ts | 14 + .../useSaveGranolaConnection.ts | 26 ++ .../trpc/commands/mcp-connections/index.ts | 109 ++++++ apps/web/src/trpc/routers/_app.ts | 13 + apps/web/src/types/mcp-connections.ts | 8 + .../__tests__/integration-setup.test.ts | 13 + .../roomote-mcp-server/integration-setup.ts | 8 + .../src/server/mcp-self-setup/catalog.ts | 7 + .../slack-mcp-setup-matching.test.ts | 1 + .../server/routers/mcp-connections.test.ts | 31 ++ .../sdk/src/server/routers/mcp-connections.ts | 2 + packages/slack/src/mcp-recommendations.ts | 2 + .../types/src/__tests__/mcp-oauth.test.ts | 15 + packages/types/src/mcp-oauth.ts | 35 ++ packages/types/src/mcp-service-detection.ts | 8 + 31 files changed, 1508 insertions(+) create mode 100644 apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts create mode 100644 apps/api/src/handlers/mcp/granola/api.ts create mode 100644 apps/api/src/handlers/mcp/granola/connection.ts create mode 100644 apps/api/src/handlers/mcp/granola/index.ts create mode 100644 apps/api/src/handlers/mcp/granola/tools.ts create mode 100644 apps/docs/integrations/granola.mdx create mode 100644 apps/docs/logo/integrations/granola.svg create mode 100644 apps/web/src/hooks/mcp-connections/useGranolaConnection.ts create mode 100644 apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts diff --git a/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts new file mode 100644 index 000000000..05cb706cb --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts @@ -0,0 +1,341 @@ +import { Hono } from 'hono'; +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +const { mockFindTaskRun, mockFindConnection, mockEq, mockAnd, mockIsNull } = + vi.hoisted(() => ({ + mockFindTaskRun: vi.fn(), + mockFindConnection: vi.fn(), + mockEq: vi.fn((column: unknown, value: unknown) => ({ column, value })), + mockAnd: vi.fn((...clauses: unknown[]) => clauses), + mockIsNull: vi.fn((column: unknown) => ({ type: 'isNull', column })), + })); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { findFirst: mockFindTaskRun }, + mcpConnections: { findFirst: mockFindConnection }, + }, + }, + taskRuns: { id: 'id' }, + mcpConnections: { + mcpId: 'mcpId', + enabled: 'enabled', + authStatus: 'authStatus', + userId: 'userId', + }, + eq: mockEq, + and: mockAnd, + isNull: mockIsNull, +})); + +vi.mock('@roomote/db/encryption', () => ({ + decrypt: vi.fn((value: string) => + value.startsWith('enc:') ? value.slice(4) : value, + ), +})); + +import { db } from '@roomote/db/server'; +import { granolaMcp } from '../granola'; + +type JsonRpcErrorBody = { + error: { message: string }; +}; + +function createInitializeRequest(id: number) { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'roomote-api-test', version: '1.0.0' }, + }, + }; +} + +function createApp(authContext: Variables['authContext']) { + const app = new Hono<{ Variables: Variables }>(); + + app.use('*', async (c, next) => { + c.set('authContext', authContext); + await next(); + }); + + app.route('/mcp', granolaMcp); + return app; +} + +async function postMcp(app: Hono<{ Variables: Variables }>, body: unknown) { + return app.request('/mcp', { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + +function mockConnectionRow(overrides?: Record) { + return { + id: 'conn-1', + userId: null, + mcpId: 'granola', + enabled: true, + authStatus: 'authenticated', + authConfig: { + type: 'granola', + encryptedApiKey: 'enc:granola-secret-key', + ...(overrides ?? {}), + }, + } as unknown as Awaited>; +} + +function createRunToken(): RunTokenContext { + return { + runId: 42, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }; +} + +function createToolCall(name: string, args: Record) { + return { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name, arguments: args }, + }; +} + +describe('Granola MCP auth and tool handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindTaskRun.mockResolvedValue({ id: 42 }); + mockFindConnection.mockResolvedValue(mockConnectionRow()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects requests without authentication', async () => { + const response = await postMcp( + createApp(undefined), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(401); + expect(body.error.message).toContain('missing or invalid bearer token'); + }); + + it('requires a task run token', async () => { + const authToken: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + const response = await postMcp( + createApp(authToken), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(403); + expect(body.error.message).toContain('requires a task run token'); + }); + + it('initializes for a valid run token and deployment connection', async () => { + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(1), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { + serverInfo: { name: 'roomote-granola-mcp', version: '1.0.0' }, + }, + }); + expect(mockIsNull).toHaveBeenCalledWith('userId'); + }); + + it('exposes only the three read-only Granola tools', async () => { + const response = await postMcp(createApp(createRunToken()), { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + }); + const body = (await response.json()) as { + result: { + tools: Array<{ + name: string; + annotations: Record; + }>; + }; + }; + + expect(body.result.tools.map((tool) => tool.name)).toEqual([ + 'list_notes', + 'get_note', + 'list_folders', + ]); + expect(body.result.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }), + ]), + ); + }); + + it('lists notes with official filters, pagination, and decrypted bearer auth', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ notes: [], hasMore: true, cursor: 'next-cursor' }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_notes', { + created_before: '2026-02-01', + created_after: '2026-01-01T00:00:00Z', + updated_after: '2026-01-15', + folder_id: 'fol_4y6LduVdwSKC27', + cursor: 'current-cursor', + page_size: 30, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { + notes: [], + hasMore: true, + cursor: 'next-cursor', + }, + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/notes?created_before=2026-02-01&created_after=2026-01-01T00%3A00%3A00Z&updated_after=2026-01-15&folder_id=fol_4y6LduVdwSKC27&cursor=current-cursor&page_size=30', + ); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer granola-secret-key', + ); + }); + + it('gets a note with transcript inclusion exactly as documented', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: 'not_1d3tmYTlCICgjy' })); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('get_note', { + note_id: 'not_1d3tmYTlCICgjy', + include: 'transcript', + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { note: { id: 'not_1d3tmYTlCICgjy' } }, + }, + }); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/notes/not_1d3tmYTlCICgjy?include=transcript', + ); + }); + + it('lists folders with official pagination', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + folders: [{ id: 'fol_4y6LduVdwSKC27', name: 'Recipes' }], + hasMore: false, + cursor: null, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_folders', { cursor: 'folder-cursor', page_size: 5 }), + ); + + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { + folders: [{ id: 'fol_4y6LduVdwSKC27', name: 'Recipes' }], + hasMore: false, + cursor: null, + }, + }, + }); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/folders?cursor=folder-cursor&page_size=5', + ); + }); + + it('surfaces structured Granola API errors', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + Response.json( + { errors: [{ message: 'The supplied API key is invalid' }] }, + { status: 401 }, + ), + ), + ); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_notes', {}), + ); + const body = (await response.json()) as { + result?: { content?: Array<{ text?: string }> }; + error?: { message?: string }; + }; + + expect(response.status).toBe(200); + expect(body.error?.message ?? body.result?.content?.[0]?.text).toContain( + 'The supplied API key is invalid', + ); + }); + + it('returns 404 when no active Granola deployment connection exists', async () => { + mockFindConnection.mockResolvedValue(undefined); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(404); + expect(body.error.message).toBe( + 'No active Granola connection found for this workspace', + ); + }); +}); diff --git a/apps/api/src/handlers/mcp/granola/api.ts b/apps/api/src/handlers/mcp/granola/api.ts new file mode 100644 index 000000000..78408a8f5 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/api.ts @@ -0,0 +1,81 @@ +import type { McpConnectionGranolaConfig } from '@roomote/types'; + +import { resolveGranolaApiKey } from './connection'; + +const GRANOLA_API_BASE_URL = 'https://public-api.granola.ai'; + +type QueryValue = string | number | null | undefined; + +function findErrorMessage(payload: unknown): string | null { + if (typeof payload === 'string') { + return payload.trim() || null; + } + + if (!payload || typeof payload !== 'object') { + return null; + } + + const record = payload as Record; + + for (const key of ['message', 'error', 'detail']) { + const message = findErrorMessage(record[key]); + if (message) { + return message; + } + } + + if (Array.isArray(record.errors)) { + const messages = record.errors + .map(findErrorMessage) + .filter((message): message is string => Boolean(message)); + if (messages.length > 0) { + return messages.join('; '); + } + } + + return null; +} + +async function parseErrorMessage(response: Response): Promise { + const body = (await response.text().catch(() => '')).trim(); + + if (body) { + try { + const message = findErrorMessage(JSON.parse(body)); + if (message) { + return message; + } + } catch { + return body; + } + } + + return `Granola API request failed with status ${response.status}`; +} + +export async function granolaApiGetJson(params: { + config: McpConnectionGranolaConfig; + path: string; + query?: Record; +}): Promise { + const url = new URL(params.path, `${GRANOLA_API_BASE_URL}/`); + + for (const [key, value] of Object.entries(params.query ?? {})) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${resolveGranolaApiKey(params.config)}`, + }, + }); + + if (!response.ok) { + throw new Error(await parseErrorMessage(response)); + } + + return (await response.json()) as T; +} diff --git a/apps/api/src/handlers/mcp/granola/connection.ts b/apps/api/src/handlers/mcp/granola/connection.ts new file mode 100644 index 000000000..2f778acc8 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/connection.ts @@ -0,0 +1,23 @@ +import { decrypt } from '@roomote/db/encryption'; +import type { McpConnectionGranolaConfig } from '@roomote/types'; + +class GranolaConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'GranolaConfigError'; + } +} + +export function resolveGranolaApiKey( + config: McpConnectionGranolaConfig, +): string { + const apiKey = decrypt(config.encryptedApiKey).trim(); + + if (!apiKey) { + throw new GranolaConfigError( + 'Granola connection is missing a stored API key', + ); + } + + return apiKey; +} diff --git a/apps/api/src/handlers/mcp/granola/index.ts b/apps/api/src/handlers/mcp/granola/index.ts new file mode 100644 index 000000000..47a612b7f --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/index.ts @@ -0,0 +1,141 @@ +import { Hono } from 'hono'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { + and, + db, + eq, + isNull, + mcpConnections, + taskRuns, +} from '@roomote/db/server'; +import { isMcpConnectionGranolaConfig } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +import { + isRunTokenContext, + McpProxyError, + type McpAuthContext, +} from '../proxy-utils'; +import { registerGranolaTools } from './tools'; + +const GRANOLA_MCP_SERVER_INFO = { + name: 'roomote-granola-mcp', + version: '1.0.0', +} as const; + +async function resolveGranolaMcpAuth( + authContext: Variables['authContext'], +): Promise { + if (!authContext) { + throw new McpProxyError( + 401, + 'Unauthorized: missing or invalid bearer token', + ); + } + + if (isRunTokenContext(authContext)) { + const taskRun = await db.query.taskRuns.findFirst({ + columns: { id: true }, + where: eq(taskRuns.id, authContext.runId), + }); + + if (!taskRun) { + throw new McpProxyError(404, 'Task run not found for this MCP token'); + } + + return { + userId: authContext.userId, + tokenType: 'run', + runId: authContext.runId, + }; + } + + throw new McpProxyError( + 403, + 'Granola MCP requires a task run token for server-side credential access', + ); +} + +async function resolveGranolaConnection() { + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + eq(mcpConnections.enabled, true), + eq(mcpConnections.authStatus, 'authenticated'), + ), + }); + + if (!connection) { + throw new McpProxyError( + 404, + 'No active Granola connection found for this workspace', + ); + } + + if (!isMcpConnectionGranolaConfig(connection.authConfig)) { + throw new McpProxyError( + 500, + 'Granola connection is missing a valid stored credential configuration', + ); + } + + return connection.authConfig; +} + +function createGranolaMcpServer( + config: Awaited>, +) { + const server = new McpServer(GRANOLA_MCP_SERVER_INFO, { + instructions: + 'Use these read-only Granola tools to inspect notes, transcripts, and folders through the configured workspace API key.', + }); + + registerGranolaTools(server, config); + return server; +} + +export const granolaMcp = new Hono<{ Variables: Variables }>(); + +granolaMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + + try { + await resolveGranolaMcpAuth(c.get('authContext')); + const connectionConfig = await resolveGranolaConnection(); + const server = createGranolaMcpServer(connectionConfig); + + await server.connect(transport); + return await transport.handleRequest(c.req.raw); + } catch (error) { + if (error instanceof McpProxyError) { + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: error.message }, + }, + { status: error.httpStatus }, + ); + } + + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { + code: -32603, + message: + error instanceof Error + ? error.message + : 'Unknown Granola MCP error', + }, + }, + { status: 500 }, + ); + } +}); diff --git a/apps/api/src/handlers/mcp/granola/tools.ts b/apps/api/src/handlers/mcp/granola/tools.ts new file mode 100644 index 000000000..9444b6a71 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/tools.ts @@ -0,0 +1,175 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpConnectionGranolaConfig } from '@roomote/types'; +import { z } from 'zod'; + +import { toMcpToolResult } from '../proxy-utils'; +import { granolaApiGetJson } from './api'; + +const TOOL_ANNOTATIONS = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +} as const; + +const paginationSchema = { + cursor: z + .string() + .optional() + .describe('Cursor returned by the previous Granola response.'), + page_size: z + .number() + .int() + .min(1) + .max(30) + .optional() + .describe( + 'Maximum results per page. Granola defaults to 10 and caps at 30.', + ), +} as const; + +const dateFilterSchema = z + .string() + .min(1) + .optional() + .describe( + 'An ISO 8601 date or date-time, for example 2026-01-27 or 2026-01-27T15:30:00Z.', + ); + +type ListNotesResponse = { + notes: unknown[]; + hasMore: boolean; + cursor: string | null; +}; + +type ListFoldersResponse = { + folders: unknown[]; + hasMore: boolean; + cursor: string | null; +}; + +function registerListNotesTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'list_notes', + { + title: 'List Notes', + description: 'List Granola notes available to the configured API key.', + inputSchema: { + created_before: dateFilterSchema.describe( + 'Return notes created before this ISO 8601 date or date-time.', + ), + created_after: dateFilterSchema.describe( + 'Return notes created after this ISO 8601 date or date-time.', + ), + updated_after: dateFilterSchema.describe( + 'Return notes updated after this ISO 8601 date or date-time.', + ), + folder_id: z + .string() + .regex(/^fol_[a-zA-Z0-9]{14}$/) + .optional() + .describe( + 'Return notes in this folder and its child folders. Use list_folders to discover IDs.', + ), + ...paginationSchema, + }, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ + created_before: createdBefore, + created_after: createdAfter, + updated_after: updatedAfter, + folder_id: folderId, + cursor, + page_size: pageSize, + }) => { + const response = await granolaApiGetJson({ + config, + path: 'v1/notes', + query: { + created_before: createdBefore, + created_after: createdAfter, + updated_after: updatedAfter, + folder_id: folderId, + cursor, + page_size: pageSize, + }, + }); + + return toMcpToolResult(response); + }, + ); +} + +function registerGetNoteTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'get_note', + { + title: 'Get Note', + description: + 'Fetch a Granola note by ID, optionally including its transcript.', + inputSchema: { + note_id: z + .string() + .regex(/^not_[a-zA-Z0-9]{14}$/) + .describe('The Granola note ID.'), + include: z + .literal('transcript') + .optional() + .describe('Set to transcript to include the note transcript.'), + }, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ note_id: noteId, include }) => { + const note = await granolaApiGetJson>({ + config, + path: `v1/notes/${encodeURIComponent(noteId)}`, + query: { include }, + }); + + return toMcpToolResult({ note }); + }, + ); +} + +function registerListFoldersTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'list_folders', + { + title: 'List Folders', + description: 'List Granola folders available to the configured API key.', + inputSchema: paginationSchema, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ cursor, page_size: pageSize }) => { + const response = await granolaApiGetJson({ + config, + path: 'v1/folders', + query: { cursor, page_size: pageSize }, + }); + + return toMcpToolResult(response); + }, + ); +} + +export function registerGranolaTools( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + registerListNotesTool(server, config); + registerGetNoteTool(server, config); + registerListFoldersTool(server, config); +} diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index b71b3b316..981ce324b 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -10,6 +10,7 @@ import { environmentsRouter } from '../environments'; import { customAutomationsRouter } from '../custom-automations'; import { tasksRouter } from '../tasks'; import { createIntegrationMcpProxy } from './integration-mcp'; +import { granolaMcp } from './granola'; import { grafanaMcp } from './grafana'; import { getIntegrationMcpProxyOptions } from './integration-mcp-policy'; import { linearMcp } from './linear'; @@ -36,6 +37,7 @@ for (const integration of MCP_INTEGRATIONS) { } mcp.route('/asana', asanaMcp); +mcp.route('/granola', granolaMcp); mcp.route('/grafana', grafanaMcp); mcp.route('/linear', linearMcp); mcp.route('/snowflake', snowflakeMcp); diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f53fc9698..a4a8bde95 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -137,6 +137,7 @@ "integrations/better-stack", "integrations/braintrust", "integrations/grafana", + "integrations/granola", "integrations/jira", "integrations/linear", "integrations/monday", diff --git a/apps/docs/integrations/granola.mdx b/apps/docs/integrations/granola.mdx new file mode 100644 index 000000000..378a2873d --- /dev/null +++ b/apps/docs/integrations/granola.mdx @@ -0,0 +1,39 @@ +--- +title: Granola +description: Give Roomote read-only access to shared Granola meeting context. +icon: '/logo/integrations/granola.svg' +--- + +Connect Granola when decisions, action items, and implementation context live in +your meeting notes and you want that context available in Roomote tasks. + +## When to use it + +- Browse past meetings for relevant decisions and discussions +- Read meeting notes and transcripts while planning or implementing work +- Find action items, attendees, and related meeting folders during follow-up work + +## How setup works + +A deployment operator connects Granola once from **Settings > Integrations** +with an API key. Granola API keys require a Business or Enterprise plan. + +Prefer a **workspace API key** from **Granola Settings > Connectors > Workspace +API keys**. Workspace keys belong to the workspace rather than an individual, +do not expire, and cannot read ordinary private notes. They can read: + +- notes visible to everyone in the workspace, including the Team space +- notes in spaces where **Allow Granola API access** is enabled + +Granola enables API access by default for newly created spaces. Review +**Settings > Spaces** and turn it off anywhere Roomote should not read notes. + +If a workspace key is not available, create a personal key from **Settings > +Connectors > API keys** and select only **Public notes**, leaving **Personal +notes** unchecked. + +## What to expect + +The built-in Granola connection is read-only. Roomote can list and read the +notes, folders, meeting details, and transcripts allowed by the configured key, +but it cannot modify Granola content or manage Granola webhooks. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 6419ea307..8788a3679 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -55,6 +55,7 @@ from [Personal Settings](/personal-settings). | | Monitoring and incident context | Admin connection once | | | Prompts, runs, and evaluation context | Enable first, then teammates link accounts | | | Dashboards, alerting, and monitoring context | Admin connection once | +| | Meeting notes, transcripts, and decisions | Admin connection once | | | Issues, projects, and JQL-backed issue context | Admin connection once | | | Turning issues into Roomote work | Workspace plus user identity | | | Board, item, and workspace context | Enable first, then teammates link accounts | diff --git a/apps/docs/logo/integrations/granola.svg b/apps/docs/logo/integrations/granola.svg new file mode 100644 index 000000000..f24b31146 --- /dev/null +++ b/apps/docs/logo/integrations/granola.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/snippets/integration-name.jsx b/apps/docs/snippets/integration-name.jsx index 8346ea341..c4a535c17 100644 --- a/apps/docs/snippets/integration-name.jsx +++ b/apps/docs/snippets/integration-name.jsx @@ -4,6 +4,7 @@ export function IntegrationName({ href, icon, name }) { e2b: '/logo/integrations/e2b.svg', blaxel: '/logo/integrations/blaxel.svg', azure: '/logo/integrations/azure.svg', + granola: '/logo/integrations/granola.svg', monday: '/logo/integrations/monday.svg', }; const iconSrc = diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 47f09c9b7..e4161a933 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -35,6 +35,9 @@ const state = vi.hoisted(() => ({ asanaConnection: null as null | { authStatus?: string | null; }, + granolaConnection: null as null | { + authStatus?: string | null; + }, grafanaConnection: null as null | { authStatus?: string | null; baseUrl: string; @@ -91,6 +94,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ disconnectMcp: vi.fn(), setDisabledTools: vi.fn(), saveAsanaConnection: vi.fn(), + saveGranolaConnection: vi.fn(), saveGrafanaConnection: vi.fn(), saveSnowflakeConnection: vi.fn(), saveVercelConnection: vi.fn(), @@ -242,6 +246,14 @@ vi.mock('@/hooks/mcp-connections', () => ({ data: state.asanaConnection, isPending: false, }), + useSaveGranolaConnection: () => ({ + isPending: false, + mutate: mutations.saveGranolaConnection, + }), + useGranolaConnection: () => ({ + data: state.granolaConnection, + isPending: false, + }), useSaveGrafanaConnection: () => ({ isPending: false, mutate: mutations.saveGrafanaConnection, @@ -443,6 +455,7 @@ describe('Integrations settings', () => { }; state.linearRedirectPath = ''; state.asanaConnection = null; + state.granolaConnection = null; state.grafanaConnection = null; state.vercelConnection = null; state.isAdmin = true; @@ -801,6 +814,7 @@ describe('Integrations settings', () => { 'Better Stack', 'Braintrust', 'Grafana', + 'Granola', 'Jira', 'monday.com', 'Neon', @@ -861,6 +875,9 @@ describe('Integrations settings', () => { expect( screen.getByRole('button', { name: 'Configure Grafana' }), ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Configure Granola' }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Configure Snowflake' }), ).toBeInTheDocument(); @@ -1068,6 +1085,21 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows the Granola confirmation dialog copy for highlighted integrations', () => { + state.searchParams = 'highlight=granola'; + + render(); + + expect( + screen.getByRole('heading', { name: 'Enable Granola?' }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Roomote will use one deployment-wide Granola connection to browse meeting notes, transcripts, decisions, and action items.', + ), + ).toBeInTheDocument(); + }); + it('does not show the confirmation dialog for an enabled highlighted integration', () => { state.searchParams = 'highlight=sentry-mcp'; state.deploymentEnablements = [{ mcpId: 'sentry', enabled: true }]; @@ -1371,6 +1403,32 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('opens the Granola API-key dialog with deployment access guidance', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Granola' })); + + expect( + screen.getByRole('heading', { name: 'Connect Granola' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Granola API Key')).toBeInTheDocument(); + expect( + screen.getByText( + /We strongly recommend a Granola workspace API key\. Workspace keys can read public notes and spaces where "Allow Granola API access" is enabled\./, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'You can also use a personal API key with Public notes selected and Personal notes left unchecked.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Store a Granola API key for this deployment. The secret stays encrypted server-side.', + ), + ).toBeInTheDocument(); + }); + it('submits an Asana token from the dialog', () => { render(); @@ -1415,6 +1473,26 @@ describe('Integrations settings', () => { ); }); + it('submits a trimmed Granola API key from the dialog', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Granola' })); + fireEvent.change(screen.getByLabelText('Granola API Key'), { + target: { value: ' granola-secret-key ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect Granola' })); + + expect(mutations.saveGranolaConnection).toHaveBeenCalledWith( + { + apiKey: 'granola-secret-key', + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('shows Asana connected controls and supports editing', () => { state.deploymentEnablements = [{ mcpId: 'asana', enabled: true }]; state.userConnections = [ @@ -1530,6 +1608,64 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows native Granola connected controls and supports editing', () => { + state.deploymentEnablements = [{ mcpId: 'granola', enabled: true }]; + state.userConnections = [ + { + mcpId: 'granola', + authStatus: 'authenticated', + }, + ]; + state.granolaConnection = { + authStatus: 'authenticated', + }; + + render(); + + const granolaCard = screen + .getByRole('heading', { name: 'Granola' }) + .closest('#integration-granola'); + expect(granolaCard).not.toBeNull(); + expect( + within(granolaCard as HTMLElement) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['Disconnect Granola', 'Edit Granola connection']); + expect( + screen.queryByRole('button', { name: 'Manage Granola tools' }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect Granola' })); + expect(mutations.disconnectMcp).toHaveBeenCalledWith( + { mcpId: 'granola' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Edit Granola connection' }), + ); + + expect( + screen.getByRole('heading', { name: 'Edit Granola' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Granola API Key')).toHaveValue(''); + expect( + screen.getByText('Leave blank to keep the existing API key.'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(mutations.saveGranolaConnection).toHaveBeenCalledWith( + { apiKey: '' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('opens the Vercel credential dialog from the integrations page', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index afc51d9f9..65f803993 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -24,10 +24,12 @@ import { useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, + useGranolaConnection, useDeploymentMcpEnablements, useMcpOauthReadiness, useSaveAsanaConnection, useSaveGrafanaConnection, + useSaveGranolaConnection, useSaveSnowflakeConnection, useSaveVercelConnection, useSetDeploymentMcpEnabled, @@ -39,6 +41,7 @@ import { useAuthorizedUser } from '@/hooks/useUser'; import { saveAsanaConnectionSchema, saveGrafanaConnectionSchema, + saveGranolaConnectionSchema, saveSnowflakeConnectionSchema, saveVercelConnectionSchema, } from '@/types'; @@ -88,6 +91,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect prompts, evaluations, and AI run history.', grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', + granola: + 'Roomote will use one deployment-wide Granola connection to browse meeting notes, transcripts, decisions, and action items.', github: 'Roomote will be able to inspect PRs, issues, and repository context.', jira: 'Roomote will be able to inspect Jira issues, workflows, and JQL search results.', @@ -205,6 +210,10 @@ type AsanaFormState = { accessToken: string; }; +type GranolaFormState = { + apiKey: string; +}; + type GrafanaFormState = { baseUrl: string; serviceAccountToken: string; @@ -241,6 +250,12 @@ function buildEmptyAsanaForm(): AsanaFormState { }; } +function buildEmptyGranolaForm(): GranolaFormState { + return { + apiKey: '', + }; +} + function buildEmptyGrafanaForm(): GrafanaFormState { return { baseUrl: '', @@ -303,6 +318,20 @@ function getAsanaFieldErrors( }; } +function getGranolaFieldErrors( + result: ReturnType, +): Partial> { + if (result.success) { + return {}; + } + + const fieldErrors = result.error.flatten().fieldErrors; + + return { + apiKey: fieldErrors.apiKey, + }; +} + function buildGrafanaForm( connection: GrafanaConnectionData | null | undefined, ): GrafanaFormState { @@ -922,6 +951,65 @@ function AsanaConnectionFields({ ); } +function GranolaConnectionFields({ + form, + fieldErrors, + formError, + allowBlankApiKey, + onFieldChange, +}: { + form: GranolaFormState; + fieldErrors: Partial>; + formError: string | null; + allowBlankApiKey: boolean; + onFieldChange: (field: keyof GranolaFormState, value: string) => void; +}) { + const fieldClassName = + 'mt-2 w-full border-border/70 bg-background data-[invalid=true]:border-destructive'; + + return ( + <> +
+ + onFieldChange('apiKey', event.target.value)} + data-invalid={fieldErrors.apiKey ? 'true' : undefined} + className={fieldClassName} + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + data-1p-ignore + /> +

+ We strongly recommend a Granola workspace API key. Workspace keys can + read public notes and spaces where "Allow Granola API + access" is enabled. New spaces enable API access by default, so + admins should review space settings before connecting. +

+

+ You can also use a personal API key with Public notes selected and + Personal notes left unchecked. +

+ {allowBlankApiKey ? ( +

+ Leave blank to keep the existing API key. +

+ ) : null} + {fieldErrors.apiKey ? ( +

{fieldErrors.apiKey[0]}

+ ) : null} +
+ {formError ? ( +

{formError}

+ ) : null} + + ); +} + function GrafanaConnectionFields({ form, fieldErrors, @@ -1117,6 +1205,14 @@ export function Integrations() { Partial> >({}); const [asanaFormError, setAsanaFormError] = useState(null); + const [isGranolaDialogOpen, setIsGranolaDialogOpen] = useState(false); + const [granolaForm, setGranolaForm] = useState( + buildEmptyGranolaForm(), + ); + const [granolaFieldErrors, setGranolaFieldErrors] = useState< + Partial> + >({}); + const [granolaFormError, setGranolaFormError] = useState(null); const [isGrafanaDialogOpen, setIsGrafanaDialogOpen] = useState(false); const [grafanaForm, setGrafanaForm] = useState( buildEmptyGrafanaForm(), @@ -1170,6 +1266,7 @@ export function Integrations() { const disconnectMcp = useDisconnectMcp(); const saveAsanaConnection = useSaveAsanaConnection(); const saveGrafanaConnection = useSaveGrafanaConnection(); + const saveGranolaConnection = useSaveGranolaConnection(); const saveSnowflakeConnection = useSaveSnowflakeConnection(); const saveVercelConnection = useSaveVercelConnection(); const asanaConnectionSummary = useMemo(() => { @@ -1184,6 +1281,18 @@ export function Integrations() { const asanaConnection = useAsanaConnection( isAdmin && (isAsanaConnected || isAsanaDialogOpen), ); + const granolaConnectionSummary = useMemo(() => { + const connection = (userMcpConnections.data ?? []).find( + (entry) => entry.mcpId === 'granola', + ); + + return connection; + }, [userMcpConnections.data]); + const isGranolaConnected = + granolaConnectionSummary?.authStatus === 'authenticated'; + const granolaConnection = useGranolaConnection( + isAdmin && (isGranolaConnected || isGranolaDialogOpen), + ); const grafanaConnectionSummary = useMemo(() => { const connection = (userMcpConnections.data ?? []).find( (entry) => entry.mcpId === 'grafana', @@ -1237,6 +1346,20 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }, [asanaConnection.isPending, isAsanaConnected, isAsanaDialogOpen]); + useEffect(() => { + if (!isGranolaDialogOpen) { + return; + } + + if (granolaConnection.isPending && isGranolaConnected) { + return; + } + + setGranolaFieldErrors({}); + setGranolaFormError(null); + setGranolaForm(buildEmptyGranolaForm()); + }, [granolaConnection.isPending, isGranolaConnected, isGranolaDialogOpen]); + useEffect(() => { if (!isSnowflakeDialogOpen) { return; @@ -1451,6 +1574,26 @@ export function Integrations() { }); } + if (integration.id === 'granola') { + return buildAdminConfiguredIntegrationItem({ + integration, + connection: userConnectionMap.get(integration.id), + orgEnabled: orgEnablementMap.get(integration.id) ?? false, + highlightedIntegrationId, + savePending: saveGranolaConnection.isPending, + disconnectPending: disconnectMcp.isPending, + disconnectingMcpId: disconnectMcp.variables?.mcpId, + dialogOpen: isGranolaDialogOpen, + connectionPending: granolaConnection.isPending, + canConfigure: isAdmin, + canManageTools: isAdmin, + openDialog: () => setIsGranolaDialogOpen(true), + openToolDialog: () => openMcpToolDialog(integration), + disconnectIntegration: () => + disconnectAdminConfiguredIntegration(integration), + }); + } + if (integration.id === 'snowflake') { return buildAdminConfiguredIntegrationItem({ integration, @@ -1650,6 +1793,7 @@ export function Integrations() { disconnectLinear, disconnectMcp, grafanaConnection.isPending, + granolaConnection.isPending, linearInstallation.data, linearInstallation.isPending, linearOauthSetup.isPending, @@ -1658,9 +1802,11 @@ export function Integrations() { oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, + isGranolaDialogOpen, isLinearOauthSetupOpen, saveAsanaConnection.isPending, saveGrafanaConnection.isPending, + saveGranolaConnection.isPending, saveVercelConnection.isPending, deploymentEnablements.data, pathname, @@ -1752,6 +1898,21 @@ export function Integrations() { setAsanaFormError(null); }; + const handleGranolaFieldChange = ( + field: keyof GranolaFormState, + value: string, + ) => { + setGranolaForm((current) => ({ ...current, [field]: value })); + setGranolaFieldErrors((current) => { + if (!current[field]) { + return current; + } + + return { ...current, [field]: undefined }; + }); + setGranolaFormError(null); + }; + const handleGrafanaFieldChange = ( field: keyof GrafanaFormState, value: string, @@ -1795,6 +1956,19 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }; + const handleGranolaDialogOpenChange = (open: boolean) => { + setIsGranolaDialogOpen(open); + + setGranolaFieldErrors({}); + setGranolaFormError(null); + + if (!open) { + return; + } + + setGranolaForm(buildEmptyGranolaForm()); + }; + const handleSnowflakeDialogOpenChange = (open: boolean) => { setIsSnowflakeDialogOpen(open); @@ -1876,6 +2050,42 @@ export function Integrations() { }); }; + const handleGranolaSubmit = (event: FormEvent) => { + event.preventDefault(); + + const parsed = saveGranolaConnectionSchema.safeParse({ + apiKey: granolaForm.apiKey, + }); + if (!parsed.success) { + setGranolaFieldErrors(getGranolaFieldErrors(parsed)); + return; + } + + if (!isGranolaConnected && parsed.data.apiKey.length === 0) { + setGranolaFieldErrors({ + apiKey: ['API key is required'], + }); + return; + } + + setGranolaFieldErrors({}); + setGranolaFormError(null); + + saveGranolaConnection.mutate(parsed.data, { + onSuccess: () => { + toast.success( + isGranolaConnected + ? 'Granola connection updated for this deployment.' + : 'Granola connected for this deployment.', + ); + handleGranolaDialogOpenChange(false); + }, + onError: (error) => { + setGranolaFormError(error.message); + }, + }); + }; + const handleSnowflakeSubmit = (event: FormEvent) => { event.preventDefault(); @@ -2047,6 +2257,29 @@ export function Integrations() { onFieldChange={handleAsanaFieldChange} /> + + Store a Granola API key for this deployment. The secret stays + encrypted server-side. + + } + onSubmit={handleGranolaSubmit} + > + + + + + ); +} + function ZeroIcon({ name, className, @@ -519,6 +543,16 @@ export function BrandIcon({ icon, name, className }: BrandIconProps) { ); } + if (icon === 'granola') { + return ( + + ); + } + if (icon === 'zero') { return ( diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index cd40b54e7..dc07469c5 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -11,6 +11,8 @@ export { useConnectMcp } from './useConnectMcp'; export { useDisconnectMcp } from './useDisconnectMcp'; export { useAsanaConnection } from './useAsanaConnection'; export { useSaveAsanaConnection } from './useSaveAsanaConnection'; +export { useGranolaConnection } from './useGranolaConnection'; +export { useSaveGranolaConnection } from './useSaveGranolaConnection'; export { useSaveGrafanaConnection } from './useSaveGrafanaConnection'; export { useSaveSnowflakeConnection } from './useSaveSnowflakeConnection'; export { useSnowflakeConnection } from './useSnowflakeConnection'; diff --git a/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts b/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts index 2f9bbcba2..014f77c32 100644 --- a/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts +++ b/apps/web/src/hooks/mcp-connections/useDisconnectMcp.ts @@ -23,6 +23,9 @@ export function useDisconnectMcp() { queryClient.invalidateQueries({ queryKey: trpc.mcpConnections.asanaConnection.queryKey(), }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.granolaConnection.queryKey(), + }); queryClient.invalidateQueries({ queryKey: trpc.mcpConnections.grafanaConnection.queryKey(), }); diff --git a/apps/web/src/hooks/mcp-connections/useGranolaConnection.ts b/apps/web/src/hooks/mcp-connections/useGranolaConnection.ts new file mode 100644 index 000000000..5c152b41f --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useGranolaConnection.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useGranolaConnection(enabled = true) { + const trpc = useTRPC(); + + return useQuery({ + ...trpc.mcpConnections.granolaConnection.queryOptions(), + enabled, + }); +} diff --git a/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts new file mode 100644 index 000000000..bc1240c86 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useSaveGranolaConnection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.mcpConnections.saveGranolaConnection.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.userConnections.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.granolaConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index ea9cf2941..12b3b73bc 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -17,6 +17,7 @@ import { getMcpIntegrationDefaultDisabledTools, type McpConnectionRole, isMcpConnectionAsanaConfig, + isMcpConnectionGranolaConfig, isMcpConnectionGrafanaConfig, isMcpConnectionSnowflakeConfig, isMcpConnectionVercelConfig, @@ -40,6 +41,7 @@ import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrati import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { SaveAsanaConnectionInput, + SaveGranolaConnectionInput, SaveGrafanaConnectionInput, SaveSnowflakeConnectionInput, SaveVercelConnectionInput, @@ -751,6 +753,29 @@ export async function getAsanaConnectionCommand(auth: UserAuthSuccess) { }; } +export async function getGranolaConnectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + authStatus: true, + }, + }); + + if (!connection || !isMcpConnectionGranolaConfig(connection.authConfig)) { + return null; + } + + return { + authStatus: connection.authStatus, + }; +} + export async function getVercelConnectionCommand( auth: UserAuthSuccess, ): Promise { @@ -1037,6 +1062,90 @@ export async function saveAsanaConnectionCommand( }; } +export async function saveGranolaConnectionCommand( + auth: UserAuthSuccess, + input: SaveGranolaConnectionInput, +) { + assertAdmin(auth); + assertCuratedIntegrationsEnabled(); + + const existingConnection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + }, + }); + + const existingConfig = isMcpConnectionGranolaConfig( + existingConnection?.authConfig, + ) + ? existingConnection.authConfig + : null; + const nextEncryptedApiKey = + input.apiKey.length > 0 + ? encrypt(input.apiKey) + : existingConfig?.encryptedApiKey; + + if (!nextEncryptedApiKey) { + throw new Error( + 'Granola API key is required when no Granola key is already stored.', + ); + } + + const authConfig = { + type: 'granola' as const, + encryptedApiKey: nextEncryptedApiKey, + }; + + await db + .insert(mcpConnections) + .values({ + userId: null, + mcpId: 'granola', + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + }) + .onConflictDoUpdate({ + target: [ + mcpConnections.userId, + mcpConnections.mcpId, + mcpConnections.connectionRole, + ], + set: { + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + updatedAt: new Date(), + }, + }); + + await db + .insert(deploymentMcpEnablements) + .values({ + mcpId: 'granola', + enabled: true, + enabledByUserId: auth.userId, + }) + .onConflictDoUpdate({ + target: [deploymentMcpEnablements.mcpId], + set: { + enabled: true, + enabledByUserId: auth.userId, + updatedAt: new Date(), + }, + }); + + return { + authStatus: 'authenticated' as const, + }; +} + export async function saveVercelConnectionCommand( auth: UserAuthSuccess, input: SaveVercelConnectionInput, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index d510672dd..a0ad4f9c9 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -36,6 +36,7 @@ import { pullRequestAnalyticsOverviewInputSchema, filterSchema, saveAsanaConnectionSchema, + saveGranolaConnectionSchema, saveGrafanaConnectionSchema, saveSnowflakeConnectionSchema, saveVercelConnectionSchema, @@ -195,11 +196,13 @@ import { setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, getAsanaConnectionCommand, + getGranolaConnectionCommand, getGrafanaConnectionCommand, getSnowflakeConnectionCommand, getVercelConnectionCommand, listDeploymentMcpIntegrationToolsCommand, saveAsanaConnectionCommand, + saveGranolaConnectionCommand, saveGrafanaConnectionCommand, saveSnowflakeConnectionCommand, saveVercelConnectionCommand, @@ -1592,6 +1595,10 @@ export const appRouter = createRouter({ getAsanaConnectionCommand(auth), ), + granolaConnection: protectedProcedure.query(({ ctx: { auth } }) => + getGranolaConnectionCommand(auth), + ), + grafanaConnection: protectedProcedure.query(({ ctx: { auth } }) => getGrafanaConnectionCommand(auth), ), @@ -1654,6 +1661,12 @@ export const appRouter = createRouter({ saveAsanaConnectionCommand(auth, input), ), + saveGranolaConnection: protectedProcedure + .input(saveGranolaConnectionSchema) + .mutation(({ ctx: { auth }, input }) => + saveGranolaConnectionCommand(auth, input), + ), + saveGrafanaConnection: protectedProcedure .input(saveGrafanaConnectionSchema) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/mcp-connections.ts b/apps/web/src/types/mcp-connections.ts index e0d8d3bf7..b7fdc5514 100644 --- a/apps/web/src/types/mcp-connections.ts +++ b/apps/web/src/types/mcp-connections.ts @@ -36,6 +36,14 @@ export type SaveAsanaConnectionInput = z.infer< typeof saveAsanaConnectionSchema >; +export const saveGranolaConnectionSchema = z.object({ + apiKey: z.string().transform((value) => value.trim()), +}); + +export type SaveGranolaConnectionInput = z.infer< + typeof saveGranolaConnectionSchema +>; + export const saveVercelConnectionSchema = z.object({ accessToken: z.string().transform((value) => value.trim()), defaultTeamIdOrSlug: z diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts index 5e77c2262..18dfb1262 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts @@ -50,6 +50,19 @@ describe('integration setup guide', () => { ); }); + it('documents the deployment-scoped read-only Granola setup flow', () => { + expect(INTEGRATION_SETUP_CONTENT).toContain('# Granola'); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'That operator connects Granola once for the deployment with a Granola API key.', + ); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'If using a personal API key instead, create it with only Public notes selected.', + ); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'The built-in connection is read-only.', + ); + }); + it('documents the admin-managed Vercel setup flow', () => { expect(INTEGRATION_SETUP_CONTENT).toContain('# Vercel'); expect(INTEGRATION_SETUP_CONTENT).toContain( diff --git a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts index 42e5a55d4..b5e6b42e0 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -111,6 +111,14 @@ Braintrust uses OAuth: Once connected, I can access AI evaluation and logging data during tasks. +# Granola + +Granola uses an admin-managed API key: +1. A deployment operator enables Granola from Settings > Integrations. +2. That operator connects Granola once for the deployment with a Granola API key. + +Prefer a workspace API key, which cannot read ordinary private notes. Workspace keys can read public notes and notes in spaces where Allow Granola API access is enabled. If using a personal API key instead, create it with only Public notes selected. Once connected, I can browse and read accessible meeting notes, folders, transcripts, decisions, and action items. The built-in connection is read-only. + # Asana Asana uses an admin-managed access token: diff --git a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts index 0dc0b6245..3958d9a1e 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -158,6 +158,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< 'Tie AI regressions back to code and prompt changes', ], }, + granola: { + capabilities: [ + 'Browse workspace meeting notes for relevant discussions and decisions', + 'Read meeting details and transcripts through a deployment API key', + 'Keep access read-only and limited by the Granola key configuration', + ], + }, supermemory: { capabilities: [ 'Save important decisions and context as shared memories during tasks', diff --git a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts index a3f67f262..6a6d5ed4b 100644 --- a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts @@ -65,6 +65,7 @@ describe('matchSlackMcpSetupService', () => { ['https://vercel.com/acme-team/my-app', 'vercel'], ['https://resend.com/emails/123', 'resend'], ['https://resend.com/domains/example.com', 'resend'], + ['https://notes.granola.ai/d/meeting-id', 'granola'], ])('matches %s to %s', (url, serviceId) => { expect(matchServiceIdForUrl(url)).toBe(serviceId); }); diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 80ee051c6..fa746d020 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -423,6 +423,37 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }); }); + it('returns Granola proxy config without exposing the API key', async () => { + mockOrderBy.mockResolvedValue([ + buildJoinedConnectionRow({ + id: 'conn-granola', + userId: null, + mcpId: 'granola', + authConfig: { + type: 'granola', + encryptedApiKey: 'enc:secret', + }, + }), + ]); + + const result = await createCaller( + 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', + ).getMcpServerConfigs(); + + expect(getValidAccessToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + servers: { + granola: { + url: 'https://api.preview.roomote.run/api/mcp/granola', + headers: { + 'X-MCP-Client': 'Roomote', + }, + }, + }, + }); + expect(JSON.stringify(result)).not.toContain('enc:secret'); + }); + it('returns Vercel proxy config without requesting OAuth tokens', async () => { mockOrderBy.mockResolvedValue([ buildJoinedConnectionRow({ diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index ac82b931b..e77ad4e12 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -17,6 +17,7 @@ import { getMcpIntegrationUpstreamUrl, MCP_INTEGRATIONS, isMcpConnectionAsanaConfig, + isMcpConnectionGranolaConfig, isMcpConnectionGrafanaConfig, getMcpIntegration, getMcpIntegrationConnectionScope, @@ -323,6 +324,7 @@ export const mcpConnectionsRouter = router({ } else if ( isMcpConnectionSnowflakeConfig(authConfig) || isMcpConnectionAsanaConfig(authConfig) || + isMcpConnectionGranolaConfig(authConfig) || isMcpConnectionVercelConfig(authConfig) || isMcpConnectionGrafanaConfig(authConfig) ) { diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index 30516ffb7..425f52635 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -33,6 +33,8 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect workspaces, projects, tasks, teams, and task comments.', grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', + granola: + 'Roomote will be able to browse workspace meeting notes, transcripts, decisions, and action items through a deployment API key.', sentry: 'Roomote will be able to inspect Sentry issue context and run scheduled Sentry triage through MCP.', posthog: diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index 9261b71a6..f4d1eba57 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -48,6 +48,21 @@ describe('monday.com OAuth', () => { }); }); +describe('Granola API key connection', () => { + it('uses a deployment-scoped native MCP with admin-managed credentials', () => { + expect(getMcpIntegration('granola')).toMatchObject({ + name: 'Granola', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'native', + }); + expect(getMcpIntegration('granola')?.url).toBeUndefined(); + expect(getMcpIntegrationConnectionScope('granola')).toBe('deployment'); + expect(getMcpIntegrationOauthScopeMode('granola')).toBeUndefined(); + expect(getMcpIntegrationDefaultDisabledTools('granola')).toEqual([]); + }); +}); + describe('Resend OAuth', () => { it('uses a deployment-scoped hosted MCP with risky tools disabled initially', () => { expect(getMcpIntegration('resend')).toMatchObject({ diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 1bb1144a2..a94a15827 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -123,6 +123,16 @@ export interface McpConnectionAsanaConfig { encryptedToken: string; } +/** + * Deployment-scoped Granola connection config stored in mcpConnections.authConfig. + * + * The API key is expected to be encrypted before persistence. + */ +export interface McpConnectionGranolaConfig { + type: 'granola'; + encryptedApiKey: string; +} + /** * Organization-scoped Vercel connection config stored in mcpConnections.authConfig. * @@ -157,6 +167,7 @@ export type McpConnectionAuthConfig = | McpConnectionOAuthConfig | McpConnectionSnowflakeConfig | McpConnectionAsanaConfig + | McpConnectionGranolaConfig | McpConnectionVercelConfig | McpConnectionGrafanaConfig | Record; @@ -479,6 +490,17 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ description: `Access prompts, runs and results from ${PRODUCT_NAME} tasks`, icon: 'braintrust', }, + { + id: 'granola', + name: 'Granola', + description: `Connect Granola so your agents can browse meeting notes, transcripts, decisions, and action items from ${PRODUCT_NAME} tasks`, + icon: 'granola', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'native', + instructions: + 'Use Granola to browse and read meeting notes, transcripts, folders, decisions, and action items through the deployment API key. The built-in tools are read-only.', + }, { id: 'supermemory', name: 'Supermemory', @@ -787,6 +809,19 @@ export function isMcpConnectionAsanaConfig( ); } +export function isMcpConnectionGranolaConfig( + authConfig: McpConnectionAuthConfig | null | undefined, +): authConfig is McpConnectionGranolaConfig { + return Boolean( + authConfig && + typeof authConfig === 'object' && + 'type' in authConfig && + authConfig.type === 'granola' && + 'encryptedApiKey' in authConfig && + typeof authConfig.encryptedApiKey === 'string', + ); +} + export function isMcpConnectionVercelConfig( authConfig: McpConnectionAuthConfig | null | undefined, ): authConfig is McpConnectionVercelConfig { diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index 37b381f94..0fb0b9206 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -259,6 +259,14 @@ export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ deploymentSettingsPath: '/settings/integrations', userSettingsPath: '/settings/personal', }, + { + id: 'granola', + name: 'Granola', + availabilityKind: 'admin_configured', + hostSuffixes: ['notes.granola.ai'], + deploymentSettingsPath: '/settings/integrations', + userSettingsPath: '/settings/personal', + }, { id: 'supermemory', name: 'Supermemory', From 5808a5902e86c0544191916fc829952f7d53bf9a Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:20:38 +0100 Subject: [PATCH 04/11] [Feat] Add Mind reader mode for expanded LLM thoughts (#1101) * feat: add mind reader preference * fix: preserve concurrent preference updates --------- Co-authored-by: Roomote --- .../task/[taskId]/Messages.client.test.tsx | 52 +++++++++++++++ .../app/(sandbox)/task/[taskId]/Messages.tsx | 6 +- .../task/[taskId]/hooks/SandboxProvider.tsx | 8 +-- .../task/[taskId]/hooks/use-sandbox-store.ts | 6 +- .../messages/acp/AcpReasoningMessage.tsx | 10 ++- .../AcpReasoningMessage.client.test.tsx | 39 +++++++++++- .../ai-elements/message-ui-options.tsx | 1 + .../settings/UserPreferencesSection.test.tsx | 61 +++++++++++++----- .../settings/UserPreferencesSection.tsx | 37 +++++++++-- .../hooks/useMindReaderMode.client.test.tsx | 58 +++++++++++++++++ apps/web/src/hooks/useMindReaderMode.ts | 26 ++++++++ .../usePersonalPreferences.client.test.tsx | 20 ++++++ apps/web/src/hooks/usePersonalPreferences.ts | 9 +++ .../src/trpc/commands/preferences/index.ts | 31 +++++---- .../preferences/personal-preferences.test.ts | 63 +++++++++++++++++++ apps/web/src/trpc/routers/_app.ts | 2 + apps/web/src/types/preferences.ts | 2 + 17 files changed, 382 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/hooks/useMindReaderMode.client.test.tsx create mode 100644 apps/web/src/hooks/useMindReaderMode.ts create mode 100644 apps/web/src/trpc/commands/preferences/personal-preferences.test.ts diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx index 2545394cc..9897af345 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Messages.client.test.tsx @@ -9,6 +9,10 @@ const narrationModeState = vi.hoisted(() => ({ enabled: false, })); +const mindReaderModeState = vi.hoisted(() => ({ + enabled: false, +})); + const taskPhaseState = vi.hoisted(() => ({ phase: null as string | null, })); @@ -36,6 +40,23 @@ vi.mock('@/components/ai-elements', () => ({ Shimmer: ({ children }: { children: ReactNode }) => {children}, })); +vi.mock('@/components/ai-elements/message-ui-options', () => ({ + MessageUiOptionsProvider: ({ + children, + value, + }: { + children: ReactNode; + value?: { expandReasoningByDefault?: boolean }; + }) => ( +
+ {children} +
+ ), +})); + vi.mock('./hooks', () => ({ useSandboxMessages: () => ({ messages: sandboxMessagesState.messages, @@ -53,6 +74,15 @@ vi.mock('@/hooks/useNarrationMode', () => ({ }), })); +vi.mock('@/hooks/useMindReaderMode', () => ({ + useMindReaderMode: () => ({ + enabled: mindReaderModeState.enabled, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }), +})); + vi.mock('./messages/index', () => ({ SleepWakeMessages: () =>
Sleep rows
, })); @@ -109,6 +139,7 @@ describe('Messages', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + mindReaderModeState.enabled = false; narrationModeState.enabled = false; taskPhaseState.phase = null; sandboxMessagesState.messages = []; @@ -173,6 +204,27 @@ describe('Messages', () => { expect(screen.getByText('Sleep rows')).toBeInTheDocument(); }); + it('passes mind reader mode into the reasoning expansion default', () => { + mindReaderModeState.enabled = true; + + render( + , + ); + + expect(screen.getByTestId('message-ui-options')).toHaveAttribute( + 'data-expand-reasoning', + 'true', + ); + }); + it('keeps internal transcript rows hidden when debug UI is disabled', () => { render( >( () => new Set(), @@ -211,8 +213,10 @@ const MessagesBase = ({ displayMode: messageUiOptions?.displayMode ?? (narrationModeEnabled ? 'narration' : 'default'), + expandReasoningByDefault: + messageUiOptions?.expandReasoningByDefault ?? mindReaderModeEnabled, }), - [messageUiOptions, narrationModeEnabled], + [messageUiOptions, mindReaderModeEnabled, narrationModeEnabled], ); useEffect(() => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/SandboxProvider.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/SandboxProvider.tsx index ef1a712b6..f2b31aa3a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/SandboxProvider.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/SandboxProvider.tsx @@ -403,12 +403,12 @@ export function useSandboxReadOnly(): boolean { } /** - * Captures the reasoning-expanded preference at mount time so each + * Captures the conversation's reasoning-expanded override at mount time so each * ReasoningMessage block initialises its local open/close state once. - * Later changes to the global preference only affect *newly mounted* blocks, - * not existing ones (which are independently controllable). + * A null value lets the personal default apply until the user manually changes + * a reasoning block; later overrides only affect newly mounted blocks. */ -export function useInitialSandboxReasoningExpanded(): boolean { +export function useInitialSandboxReasoningExpanded(): boolean | null { const store = useSandboxStore(); const initialValueRef = useRef(store.getState().reasoningExpanded); return initialValueRef.current; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-sandbox-store.ts b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-sandbox-store.ts index 80106e2d9..d5e2085cf 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-sandbox-store.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/hooks/use-sandbox-store.ts @@ -114,7 +114,7 @@ interface SandboxState { logfiles: LogfileInfo[]; // Reasoning expansion preference (user intent based on last expand/collapse). - reasoningExpanded: boolean; + reasoningExpanded: boolean | null; // Current user info for local prompt UX and userId-based message resolution. currentUserInfo: AcpUserInfo | null; @@ -457,7 +457,7 @@ export function createSandboxStore( pendingUserInputRequests: PendingTaskUserInputRequest[]; pendingEnvVarRequest: PendingTaskEnvVarRequest | null; logfiles: LogfileInfo[]; - reasoningExpanded: boolean; + reasoningExpanded: boolean | null; currentUserInfo: AcpUserInfo | null; setLogfiles: (files: LogfileInfo[]) => void; setReasoningExpanded: (expanded: boolean) => void; @@ -580,7 +580,7 @@ export function createSandboxStore( pendingUserInputRequests: [], pendingEnvVarRequest: null, logfiles: [], - reasoningExpanded: false, + reasoningExpanded: null, currentUserInfo: initialCurrentUser, setLogfiles: (files) => set({ logfiles: files }), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpReasoningMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpReasoningMessage.tsx index 0b082a517..364842528 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpReasoningMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpReasoningMessage.tsx @@ -28,14 +28,18 @@ const AcpReasoningMessageBase = ({ onSuppress: _onSuppress, }: AcpReasoningMessageProps) => { const anchorId = messageAnchorId(msg.ts); - const { displayMode = 'default' } = useMessageUiOptions(); + const { displayMode = 'default', expandReasoningByDefault = false } = + useMessageUiOptions(); const initialExpanded = useInitialSandboxReasoningExpanded(); const setReasoningExpanded = useSandboxSetReasoningExpanded(); - const [isOpen, setIsOpen] = useState(initialExpanded); + const [openOverride, setOpenOverride] = useState( + initialExpanded, + ); + const isOpen = openOverride ?? expandReasoningByDefault; const handleOpenChange = (open: boolean) => { - setIsOpen(open); + setOpenOverride(open); setReasoningExpanded(open); }; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpReasoningMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpReasoningMessage.client.test.tsx index cc0f005bf..240052e83 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpReasoningMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpReasoningMessage.client.test.tsx @@ -8,21 +8,25 @@ vi.mock('@/components/ai-elements', () => ({ MessageContent: ({ children }: { children: ReactNode }) => (
{children}
), - Reasoning: ({ children }: { children: ReactNode }) =>
{children}
, + Reasoning: ({ children, open }: { children: ReactNode; open?: boolean }) => ( +
{children}
+ ), ReasoningTrigger: () =>
Thought
, ReasoningContent: ({ children }: { children: ReactNode }) => (
{children}
), })); -const { messageUiOptionsState } = vi.hoisted(() => ({ +const { initialExpandedState, messageUiOptionsState } = vi.hoisted(() => ({ + initialExpandedState: { value: null as boolean | null }, messageUiOptionsState: { displayMode: 'default' as 'default' | 'narration', + expandReasoningByDefault: false, }, })); vi.mock('../../../hooks/SandboxProvider', () => ({ - useInitialSandboxReasoningExpanded: vi.fn().mockReturnValue(false), + useInitialSandboxReasoningExpanded: () => initialExpandedState.value, useSandboxSetReasoningExpanded: vi.fn().mockReturnValue(vi.fn()), })); @@ -54,7 +58,36 @@ function reasoningMessage(text: string, partial: boolean): AcpUiMessage { describe('AcpReasoningMessage', () => { beforeEach(() => { + initialExpandedState.value = null; messageUiOptionsState.displayMode = 'default'; + messageUiOptionsState.expandReasoningByDefault = false; + }); + + it('opens reasoning when mind reader mode supplies the default', () => { + messageUiOptionsState.expandReasoningByDefault = true; + + const { container } = render( + , + ); + + expect( + container.querySelector('[data-reasoning-open="true"]'), + ).not.toBeNull(); + }); + + it('prefers the conversation expansion state after a manual choice', () => { + initialExpandedState.value = false; + messageUiOptionsState.expandReasoningByDefault = true; + + const { container } = render( + , + ); + + expect( + container.querySelector('[data-reasoning-open="false"]'), + ).not.toBeNull(); }); it('renders streaming reasoning immediately in default mode', () => { diff --git a/apps/web/src/components/ai-elements/message-ui-options.tsx b/apps/web/src/components/ai-elements/message-ui-options.tsx index 068760177..e8e2ef4b6 100644 --- a/apps/web/src/components/ai-elements/message-ui-options.tsx +++ b/apps/web/src/components/ai-elements/message-ui-options.tsx @@ -5,6 +5,7 @@ import { createContext, useContext, type ReactNode } from 'react'; export interface MessageUiOptions { compact?: boolean; displayMode?: 'default' | 'narration'; + expandReasoningByDefault?: boolean; hideNewTaskAction?: boolean; } diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index 6d0a893fa..ef268a3c7 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -3,20 +3,28 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; type PersonalColorTheme = 'light' | 'dark' | 'system'; -const { colorThemeState, narrationModeState } = vi.hoisted(() => ({ - colorThemeState: { - colorTheme: 'system' as PersonalColorTheme, - isLoading: false, - isUpdating: false, - setColorTheme: vi.fn(), - }, - narrationModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, -})); +const { colorThemeState, mindReaderModeState, narrationModeState } = vi.hoisted( + () => ({ + colorThemeState: { + colorTheme: 'system' as PersonalColorTheme, + isLoading: false, + isUpdating: false, + setColorTheme: vi.fn(), + }, + mindReaderModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + narrationModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + }), +); vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => colorThemeState, @@ -26,6 +34,10 @@ vi.mock('@/hooks/useNarrationMode', () => ({ useNarrationMode: () => narrationModeState, })); +vi.mock('@/hooks/useMindReaderMode', () => ({ + useMindReaderMode: () => mindReaderModeState, +})); + vi.mock('@/components/system', () => ({ Label: ({ children, @@ -105,6 +117,9 @@ describe('UserPreferencesSection', () => { colorThemeState.colorTheme = 'system' as PersonalColorTheme; colorThemeState.isLoading = false; colorThemeState.isUpdating = false; + mindReaderModeState.enabled = false; + mindReaderModeState.isLoading = false; + mindReaderModeState.isUpdating = false; narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; @@ -112,6 +127,7 @@ describe('UserPreferencesSection', () => { it('renders user preference controls with the current state', () => { colorThemeState.colorTheme = 'dark' as PersonalColorTheme; + mindReaderModeState.enabled = true; narrationModeState.enabled = true; render(); @@ -119,6 +135,13 @@ describe('UserPreferencesSection', () => { expect(screen.getByText('Preferences')).toBeInTheDocument(); expect(screen.getByText('Color theme')).toBeInTheDocument(); expect(screen.getByLabelText('Color theme')).toHaveValue('dark'); + expect(screen.getByText('Mind reader mode')).toHaveClass('font-semibold'); + expect( + screen.getByText( + 'Automatically expand LLM thoughts by default in conversations.', + ), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Toggle mind reader mode')).toBeChecked(); expect(screen.getByText('Narration mode')).toHaveClass('font-semibold'); expect( screen.getByText( @@ -130,11 +153,13 @@ describe('UserPreferencesSection', () => { it('disables controls while the corresponding preference is loading or updating', () => { colorThemeState.isLoading = true; + mindReaderModeState.isLoading = true; narrationModeState.isUpdating = true; render(); expect(screen.getByLabelText('Color theme')).toBeDisabled(); + expect(screen.getByLabelText('Toggle mind reader mode')).toBeDisabled(); expect(screen.getByLabelText('Toggle narration mode')).toBeDisabled(); }); @@ -156,6 +181,14 @@ describe('UserPreferencesSection', () => { expect(narrationModeState.setEnabled).toHaveBeenCalledWith(true); }); + it('updates mind reader mode immediately when the switch changes', () => { + render(); + + fireEvent.click(screen.getByLabelText('Toggle mind reader mode')); + + expect(mindReaderModeState.setEnabled).toHaveBeenCalledWith(true); + }); + it('renders theme choices in a dropdown', () => { render(); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index a5b59edcd..1f54c82fb 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -1,6 +1,7 @@ 'use client'; import { useColorTheme } from '@/hooks/useColorTheme'; +import { useMindReaderMode } from '@/hooks/useMindReaderMode'; import { useNarrationMode } from '@/hooks/useNarrationMode'; import type { PersonalColorTheme } from '@/types/preferences'; @@ -33,7 +34,18 @@ export function UserPreferencesSection() { isUpdating: isThemeUpdating, setColorTheme, } = useColorTheme(); - const { enabled, isLoading, isUpdating, setEnabled } = useNarrationMode(); + const { + enabled: mindReaderModeEnabled, + isLoading: isMindReaderModeLoading, + isUpdating: isMindReaderModeUpdating, + setEnabled: setMindReaderModeEnabled, + } = useMindReaderMode(); + const { + enabled: narrationModeEnabled, + isLoading: isNarrationModeLoading, + isUpdating: isNarrationModeUpdating, + setEnabled: setNarrationModeEnabled, + } = useNarrationMode(); const isThemeDisabled = isThemeLoading || isThemeUpdating; return ( @@ -67,12 +79,29 @@ export function UserPreferencesSection() { +
+ +
+

+ Mind reader mode +

+

+ Automatically expand LLM thoughts by default in conversations. +

+
+
+

diff --git a/apps/web/src/hooks/useMindReaderMode.client.test.tsx b/apps/web/src/hooks/useMindReaderMode.client.test.tsx new file mode 100644 index 000000000..e536f153b --- /dev/null +++ b/apps/web/src/hooks/useMindReaderMode.client.test.tsx @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react'; + +type PersonalColorTheme = 'light' | 'dark' | 'system'; + +const { personalPreferencesState } = vi.hoisted(() => ({ + personalPreferencesState: { + preferences: { + colorTheme: 'system' as PersonalColorTheme, + mindReaderMode: false, + narrationMode: false, + }, + isLoading: false, + isUpdating: false, + setPreferences: vi.fn(), + }, +})); + +vi.mock('./usePersonalPreferences', () => ({ + usePersonalPreferences: () => personalPreferencesState, +})); + +import { useMindReaderMode } from './useMindReaderMode'; + +describe('useMindReaderMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + personalPreferencesState.preferences = { + colorTheme: 'system' as PersonalColorTheme, + mindReaderMode: false, + narrationMode: false, + }; + personalPreferencesState.isLoading = false; + personalPreferencesState.isUpdating = false; + }); + + it('exposes the current mind-reader state from the shared preferences hook', () => { + personalPreferencesState.preferences = { + colorTheme: 'dark' as PersonalColorTheme, + mindReaderMode: true, + narrationMode: false, + }; + + const { result } = renderHook(() => useMindReaderMode()); + + expect(result.current.enabled).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('passes mind-reader updates through the shared preferences hook', () => { + const { result } = renderHook(() => useMindReaderMode()); + + result.current.setEnabled(true); + + expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ + mindReaderMode: true, + }); + }); +}); diff --git a/apps/web/src/hooks/useMindReaderMode.ts b/apps/web/src/hooks/useMindReaderMode.ts new file mode 100644 index 000000000..f55fc503d --- /dev/null +++ b/apps/web/src/hooks/useMindReaderMode.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useCallback } from 'react'; + +import { usePersonalPreferences } from './usePersonalPreferences'; + +export function useMindReaderMode() { + const { preferences, isLoading, isUpdating, setPreferences } = + usePersonalPreferences({ + errorMessage: 'Failed to update mind reader mode.', + }); + + const setEnabled = useCallback( + (enabled: boolean) => { + setPreferences({ mindReaderMode: enabled }); + }, + [setPreferences], + ); + + return { + enabled: preferences.mindReaderMode, + isLoading, + isUpdating, + setEnabled, + }; +} diff --git a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx index 230795267..4211f5502 100644 --- a/apps/web/src/hooks/usePersonalPreferences.client.test.tsx +++ b/apps/web/src/hooks/usePersonalPreferences.client.test.tsx @@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react'; type PersonalPreferences = { colorTheme: 'light' | 'dark' | 'system'; + mindReaderMode: boolean; narrationMode: boolean; }; @@ -52,6 +53,7 @@ const { queryState: { data: { colorTheme: 'system', + mindReaderMode: false, narrationMode: false, } as PersonalPreferences | undefined, isPending: false, @@ -110,12 +112,14 @@ describe('usePersonalPreferences', () => { vi.clearAllMocks(); queryState.data = { colorTheme: 'system', + mindReaderMode: false, narrationMode: false, }; queryState.isPending = false; queryClientMock.cancelQueries.mockResolvedValue(undefined); queryClientMock.getQueryData.mockReturnValue({ colorTheme: 'system', + mindReaderMode: false, narrationMode: false, }); }); @@ -123,6 +127,7 @@ describe('usePersonalPreferences', () => { it('exposes the current personal preferences from the query', () => { queryState.data = { colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }; @@ -130,6 +135,7 @@ describe('usePersonalPreferences', () => { expect(result.current.preferences).toEqual({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }); expect(result.current.isLoading).toBe(false); @@ -142,6 +148,7 @@ describe('usePersonalPreferences', () => { expect(result.current.preferences).toEqual({ colorTheme: 'system', + mindReaderMode: false, narrationMode: false, }); }); @@ -170,6 +177,7 @@ describe('usePersonalPreferences', () => { preferencesQueryKey, { colorTheme: 'dark', + mindReaderMode: false, narrationMode: false, }, ); @@ -177,6 +185,7 @@ describe('usePersonalPreferences', () => { options.onSuccess?.( { colorTheme: 'dark', + mindReaderMode: false, narrationMode: false, }, variables, @@ -185,6 +194,7 @@ describe('usePersonalPreferences', () => { options.onSettled?.( { colorTheme: 'dark', + mindReaderMode: false, narrationMode: false, }, null, @@ -199,10 +209,12 @@ describe('usePersonalPreferences', () => { expect( updateCall?.[1]({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }), ).toEqual({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }); expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ @@ -213,6 +225,7 @@ describe('usePersonalPreferences', () => { it('rolls back the optimistic cache update and shows an error when the mutation fails', async () => { queryClientMock.getQueryData.mockReturnValue({ colorTheme: 'light', + mindReaderMode: true, narrationMode: true, }); @@ -239,10 +252,12 @@ describe('usePersonalPreferences', () => { expect( rollbackCall?.[1]({ colorTheme: 'system', + mindReaderMode: false, narrationMode: false, }), ).toEqual({ colorTheme: 'light', + mindReaderMode: false, narrationMode: false, }); expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ @@ -269,6 +284,7 @@ describe('usePersonalPreferences', () => { options.onSuccess?.( { colorTheme: 'dark', + mindReaderMode: false, narrationMode: false, }, firstUpdate, @@ -281,10 +297,12 @@ describe('usePersonalPreferences', () => { expect( successCall?.[1]({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }), ).toEqual({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }); }); @@ -311,10 +329,12 @@ describe('usePersonalPreferences', () => { expect( rollbackCall?.[1]({ colorTheme: 'dark', + mindReaderMode: true, narrationMode: true, }), ).toEqual({ colorTheme: 'system', + mindReaderMode: true, narrationMode: true, }); }); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index 37c93a6af..32a812ee1 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -43,6 +43,10 @@ function mergeResultForUpdatedFields( updates.colorTheme === undefined ? mergedPreferences.colorTheme : result.colorTheme, + mindReaderMode: + updates.mindReaderMode === undefined + ? mergedPreferences.mindReaderMode + : result.mindReaderMode, narrationMode: updates.narrationMode === undefined ? mergedPreferences.narrationMode @@ -67,6 +71,11 @@ function rollbackUpdatedFields( mergedPreferences.colorTheme === optimisticPreferences.colorTheme ? previousPreferences.colorTheme : mergedPreferences.colorTheme, + mindReaderMode: + updates.mindReaderMode !== undefined && + mergedPreferences.mindReaderMode === optimisticPreferences.mindReaderMode + ? previousPreferences.mindReaderMode + : mergedPreferences.mindReaderMode, narrationMode: updates.narrationMode !== undefined && mergedPreferences.narrationMode === optimisticPreferences.narrationMode diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 225f7e5f1..226187af2 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -1,4 +1,4 @@ -import { and, db, eq, isNull, users } from '@roomote/db/server'; +import { and, db, eq, isNull, sql, users } from '@roomote/db/server'; import { headers } from 'next/headers'; import type { UserAuthSuccess } from '@/types'; @@ -28,6 +28,10 @@ function normalizePersonalPreferences( colorTheme: isPersonalColorTheme(metadata.color_theme) ? metadata.color_theme : DEFAULT_PERSONAL_PREFERENCES.colorTheme, + mindReaderMode: + typeof metadata.mind_reader_mode === 'boolean' + ? metadata.mind_reader_mode + : DEFAULT_PERSONAL_PREFERENCES.mindReaderMode, narrationMode: typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode @@ -113,6 +117,10 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.color_theme = input.colorTheme; } + if (input.mindReaderMode !== undefined) { + nextMetadataRecord.mind_reader_mode = input.mindReaderMode; + } + if (input.narrationMode !== undefined) { nextMetadataRecord.narration_mode = input.narrationMode; } @@ -121,30 +129,19 @@ export async function updatePersonalPreferencesCommand( return getPersonalPreferencesCommand(auth); } - const currentUser = await db.query.users.findFirst({ - where: eq(users.id, auth.userId), - columns: { - metadata: true, - }, - }); - const normalizedMetadata = { - ...normalizeMetadata(currentUser?.metadata), - ...nextMetadataRecord, - }; - - const updatedRows = await db + const [updatedUser] = await db .update(users) .set({ - metadata: normalizedMetadata, + metadata: sql`${users.metadata} || ${JSON.stringify(nextMetadataRecord)}::jsonb`, lastSyncAt: new Date(), updatedAt: new Date(), }) .where(eq(users.id, auth.userId)) - .returning({ id: users.id }); + .returning({ metadata: users.metadata }); - if (updatedRows.length === 0) { + if (!updatedUser) { throw new Error('Unable to update preferences for the active user.'); } - return normalizePersonalPreferences(normalizedMetadata); + return normalizePersonalPreferences(normalizeMetadata(updatedUser.metadata)); } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts new file mode 100644 index 000000000..ce37848b8 --- /dev/null +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -0,0 +1,63 @@ +import { db, eq, userFactory, users } from '@roomote/db/server'; + +import type { UserAuthSuccess } from '@/types'; + +import { + getPersonalPreferencesCommand, + updatePersonalPreferencesCommand, +} from './index'; + +function buildAuth(userId: string) { + return { userId } as UserAuthSuccess; +} + +describe('personal preferences', () => { + it('defaults mind reader mode to disabled', async () => { + const user = await userFactory.create(); + + await expect( + getPersonalPreferencesCommand(buildAuth(user.id)), + ).resolves.toEqual(expect.objectContaining({ mindReaderMode: false })); + }); + + it('persists mind reader mode without replacing other metadata', async () => { + const user = await userFactory.create({ + metadata: { existing_value: 'preserved' }, + }); + + await expect( + updatePersonalPreferencesCommand(buildAuth(user.id), { + mindReaderMode: true, + }), + ).resolves.toEqual(expect.objectContaining({ mindReaderMode: true })); + + const storedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { metadata: true }, + }); + + expect(storedUser?.metadata).toEqual( + expect.objectContaining({ + existing_value: 'preserved', + mind_reader_mode: true, + }), + ); + }); + + it('preserves concurrent updates to different preferences', async () => { + const user = await userFactory.create(); + const auth = buildAuth(user.id); + + await Promise.all([ + updatePersonalPreferencesCommand(auth, { mindReaderMode: true }), + updatePersonalPreferencesCommand(auth, { narrationMode: true }), + ]); + + await expect(getPersonalPreferencesCommand(auth)).resolves.toEqual( + expect.objectContaining({ + mindReaderMode: true, + narrationMode: true, + }), + ); + }); +}); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index a0ad4f9c9..302958fd5 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1341,11 +1341,13 @@ export const appRouter = createRouter({ z .object({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), + mindReaderMode: z.boolean().optional(), narrationMode: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || + input.mindReaderMode !== undefined || input.narrationMode !== undefined, { message: 'Expected at least one personal preference to update.', diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index 5c4f4856d..dbcf508e0 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -14,6 +14,7 @@ export function isPersonalColorTheme( export interface PersonalPreferences { colorTheme: PersonalColorTheme; + mindReaderMode: boolean; narrationMode: boolean; } @@ -21,5 +22,6 @@ export type PersonalPreferencesUpdate = Partial; export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', + mindReaderMode: false, narrationMode: false, }; From bc50299b19cc508d8eaa06ac7c9d7bdeb359a310 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:57:07 -0400 Subject: [PATCH 05/11] [Fix] Mixed-provider workspaces fail during task launch (#1084) Co-authored-by: Roomote Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../createEnvironment.validation.test.ts | 34 ++ .../__tests__/environment-write-auth.test.ts | 39 +++ .../environments/createEnvironment.ts | 20 +- .../environments/updateEnvironment.ts | 12 + .../tasks/__tests__/launchTask.test.ts | 87 ++++- .../__tests__/manageSourceControl.test.ts | 131 ++++++++ .../automation-work-items/repositories.ts | 4 +- apps/api/src/handlers/tasks/launchTask.ts | 66 ++-- .../src/handlers/tasks/manageSourceControl.ts | 22 +- .../handlers/tasks/submitTaskSuggestions.ts | 4 +- apps/docs/environments.mdx | 6 + .../src/lib/environment-definition.test.ts | 31 +- apps/web/src/lib/environment-definition.ts | 6 +- .../src/lib/server/source-control-provider.ts | 52 +-- .../trpc/commands/environments/index.test.ts | 156 ++++++++- .../src/trpc/commands/environments/index.ts | 77 ++++- .../src/trpc/commands/setup-new/index.test.ts | 87 +++++ apps/web/src/trpc/commands/setup-new/index.ts | 67 ++-- .../setup-new/launch-lifecycle.test.ts | 1 - .../src/trpc/commands/task-runs/index.test.ts | 69 +++- apps/web/src/trpc/commands/task-runs/index.ts | 13 +- .../commands/task-suggestions/onboarding.ts | 17 +- .../src/commands/__tests__/snapshot.test.ts | 19 ++ .../setup/__tests__/workspace.test.ts | 147 +++++++++ .../commands/setup/workspace/repositories.ts | 46 ++- .../src/commands/setup/workspace/types.ts | 1 + apps/worker/src/commands/snapshot.ts | 5 + .../commands/utils/execute-task-run.test.ts | 6 + .../src/commands/utils/execute-task-run.ts | 4 + .../commands/utils/repository-providers.ts | 15 + .../utils/scrub-sandbox-secrets.test.ts | 14 + .../workspace/__tests__/tool-versions.test.ts | 48 +++ .../worker/src/workspace/workspace-manager.ts | 8 +- packages/ado/src/api.ts | 19 +- packages/bitbucket/src/api.ts | 19 +- .../src/server/__tests__/enqueue-task.test.ts | 297 ++++++++++++++++++ .../cloud-agents/src/server/task-run-queue.ts | 99 +++--- .../db/src/lib/__tests__/repositories.test.ts | 5 +- .../__tests__/source-control-provider.test.ts | 243 +++++++++++++- packages/db/src/lib/repositories.ts | 5 +- .../db/src/lib/source-control-provider.ts | 166 ++++++++-- packages/gitea/src/api.ts | 23 +- .../src/__tests__/task-run-token.test.ts | 57 ++++ packages/github/src/api.ts | 11 +- packages/gitlab/src/__tests__/api.test.ts | 90 ++++++ packages/gitlab/src/api.ts | 37 ++- packages/sdk/src/server/index.ts | 4 + .../__tests__/source-control-issues.test.ts | 55 ++++ .../lib/issues/source-control-issues.ts | 18 +- .../source-control-pull-request-reads.test.ts | 21 +- ...source-control-pull-request-shared.test.ts | 77 ++++- ...source-control-pull-request-writes.test.ts | 11 +- .../source-control-pull-requests.test.ts | 11 +- .../source-control-pull-request-reads.ts | 15 +- .../source-control-pull-request-shared.ts | 47 +++ .../source-control-pull-request-writes.ts | 15 +- .../source-control-pull-requests.ts | 15 +- .../__tests__/dequeue-helpers.test.ts | 159 ++++++++++ .../__tests__/dequeue-resume-task-run.test.ts | 25 ++ .../__tests__/dequeue-task-run.test.ts | 24 ++ .../__tests__/fetch-snapshot-env.test.ts | 45 ++- .../server/lib/task-runs/dequeue-helpers.ts | 187 ++++++++--- .../lib/task-runs/dequeue-resume-task-run.ts | 18 +- .../server/lib/task-runs/dequeue-task-run.ts | 18 +- .../lib/task-runs/fetch-snapshot-env.ts | 14 +- .../src/__tests__/command-schema.test.ts | 28 ++ .../src/__tests__/source-control.test.ts | 33 ++ .../types/src/__tests__/task-runs.test.ts | 27 ++ packages/types/src/environment-config.ts | 34 ++ .../types/src/environment-definition-tasks.ts | 46 +-- packages/types/src/source-control.ts | 26 ++ packages/types/src/task-runs.ts | 11 + 72 files changed, 2955 insertions(+), 414 deletions(-) create mode 100644 apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts create mode 100644 apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts create mode 100644 apps/worker/src/commands/utils/repository-providers.ts diff --git a/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts b/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts new file mode 100644 index 000000000..0f8bdde24 --- /dev/null +++ b/apps/api/src/handlers/environments/__tests__/createEnvironment.validation.test.ts @@ -0,0 +1,34 @@ +import { MULTI_INSTALLATION_ENVIRONMENT_REPOSITORIES_ERROR } from '@roomote/types'; + +import { getEnvironmentRepositoryConfigError } from '../createEnvironment'; + +describe('getEnvironmentRepositoryConfigError', () => { + it('allows GitHub and GitLab repositories in the same environment', () => { + expect( + getEnvironmentRepositoryConfigError([ + { fullName: 'acme/frontend', installationId: 'github-installation' }, + { fullName: 'acme/backend', installationId: null }, + ]), + ).toBeNull(); + }); + + it('rejects repositories from two GitHub App installations', () => { + expect( + getEnvironmentRepositoryConfigError([ + { fullName: 'acme/frontend', installationId: 'github-installation-1' }, + { fullName: 'other/backend', installationId: 'github-installation-2' }, + ]), + ).toBe(MULTI_INSTALLATION_ENVIRONMENT_REPOSITORIES_ERROR); + }); + + it('rejects ambiguous repository names across source-control connections', () => { + expect( + getEnvironmentRepositoryConfigError([ + { fullName: 'acme/app', installationId: 'github-installation' }, + { fullName: 'acme/app', installationId: null }, + ]), + ).toBe( + 'Multiple repositories are named "acme/app". Environment repository names must be unique across source-control connections.', + ); + }); +}); diff --git a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts index aa58cd14e..3fe966c99 100644 --- a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts +++ b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts @@ -162,6 +162,45 @@ describe.each([ }); }); +describe.each([ + ['createEnvironment', 'POST', '/environments'], + ['updateEnvironment', 'PATCH', '/environments/env-1'], +] as const)('%s config validation', (_name, method, path) => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects duplicate repositories before lookup or persistence', async () => { + const app = createApp({ + userId: 'user-1', + tokenType: 'auth', + version: 1, + }); + const response = await app.request(path, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + config: { + name: 'Duplicate repository', + repositories: [ + { repository: 'acme/api' }, + { repository: 'acme/api' }, + ], + }, + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: + 'Invalid environment configuration: Duplicate repository: acme/api', + }); + expect(mockEnvironmentsFindFirst).not.toHaveBeenCalled(); + expect(mockRepositoriesFindMany).not.toHaveBeenCalled(); + expect(mockEnvironmentInsertValues).not.toHaveBeenCalled(); + }); +}); + describe('createEnvironment attribution', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/api/src/handlers/environments/createEnvironment.ts b/apps/api/src/handlers/environments/createEnvironment.ts index 4cb06fa45..3b9e4b8c5 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -14,6 +14,8 @@ import { import { type TaskPayload, environmentConfigSchema, + getAmbiguousEnvironmentRepositoryError, + getDuplicateEnvironmentRepositoryConfigError, getMissingEnvironmentRepositoryError, getEnvironmentRepositoryInstallationError, } from '@roomote/types'; @@ -69,7 +71,10 @@ export function getEnvironmentRepositoryConfigError( installationId: string | number | null | undefined; }>, ): string | null { - return getEnvironmentRepositoryInstallationError(repositoryRows); + return ( + getAmbiguousEnvironmentRepositoryError(repositoryRows) ?? + getEnvironmentRepositoryInstallationError(repositoryRows) + ); } function extractRunId(auth: McpAuth): number | null { @@ -252,6 +257,19 @@ export async function createEnvironment( } const config = parsedConfig.data; + const duplicateRepositoryError = getDuplicateEnvironmentRepositoryConfigError( + config.repositories, + ); + + if (duplicateRepositoryError) { + return c.json( + { + error: `Invalid environment configuration: ${duplicateRepositoryError}`, + }, + 400, + ); + } + try { const existing = await db.query.environments.findFirst({ where: eq(environments.name, config.name), diff --git a/apps/api/src/handlers/environments/updateEnvironment.ts b/apps/api/src/handlers/environments/updateEnvironment.ts index 1b6e5a4e2..a3204cc82 100644 --- a/apps/api/src/handlers/environments/updateEnvironment.ts +++ b/apps/api/src/handlers/environments/updateEnvironment.ts @@ -11,6 +11,7 @@ import { } from '@roomote/db/server'; import { environmentConfigSchema, + getDuplicateEnvironmentRepositoryConfigError, getMissingEnvironmentRepositoryError, } from '@roomote/types'; @@ -90,6 +91,17 @@ export async function updateEnvironment( } const config = parsedConfig.data; + const duplicateRepositoryError = + getDuplicateEnvironmentRepositoryConfigError(config.repositories); + + if (duplicateRepositoryError) { + return c.json( + { + error: `Invalid environment configuration: ${duplicateRepositoryError}`, + }, + 400, + ); + } const environment = await db.query.environments.findFirst({ where: eq(environments.id, id), diff --git a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts index 543d108fb..ba8dd7cde 100644 --- a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts @@ -11,12 +11,14 @@ const { mockEnvironmentsFindFirst, mockRepositoriesFindMany, mockSelectRows, + mockResolveWorkspaceRepositoryProviders, mockGetMembershipRole, } = vi.hoisted(() => ({ mockEnqueueTask: vi.fn(), mockEnvironmentsFindFirst: vi.fn(), mockRepositoriesFindMany: vi.fn(), mockSelectRows: vi.fn(), + mockResolveWorkspaceRepositoryProviders: vi.fn(), mockGetMembershipRole: vi.fn(), })); @@ -35,6 +37,8 @@ vi.mock('@roomote/db/server', () => ({ environments: {}, environmentRepositoryMappings: {}, repositories: {}, + resolveWorkspaceRepositoryProviders: (...args: unknown[]) => + mockResolveWorkspaceRepositoryProviders(...args), db: { query: { environments: { @@ -88,6 +92,8 @@ describe('launchTask', () => { mockRepositoriesFindMany.mockReset(); mockSelectRows.mockReset(); mockSelectRows.mockReturnValue([]); + mockResolveWorkspaceRepositoryProviders.mockReset(); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({}); mockGetMembershipRole.mockReset(); mockGetMembershipRole.mockResolvedValue('org:admin'); }); @@ -137,7 +143,9 @@ describe('launchTask', () => { it('stamps the source-control provider resolved from environment repositories into the payload', async () => { mockEnqueueTask.mockResolvedValue({ id: 100, taskId: 'task-gl' }); - mockSelectRows.mockReturnValue([{ sourceControlProvider: 'gitlab' }]); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({ + 'group/project': 'gitlab', + }); const app = createApp(authContext); const response = await app.request( @@ -158,6 +166,32 @@ describe('launchTask', () => { expect(enqueuedTask.task.payload.sourceControlProvider).toBe('gitlab'); }); + it('uses the first environment repository provider for mixed environments', async () => { + mockEnqueueTask.mockResolvedValue({ id: 100, taskId: 'task-mixed' }); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({ + 'octo/api': 'github', + 'group/web': 'gitlab', + }); + + const app = createApp(authContext); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Ship a mixed-provider change', + environmentId: '6f1f3f0a-9f5e-4d2a-8f4e-1a2b3c4d5e6f', + }), + }), + ); + + expect(response.status).toBe(200); + const enqueuedTask = mockEnqueueTask.mock.calls[0]?.[0] as { + task: { payload: { sourceControlProvider?: string } }; + }; + expect(enqueuedTask.task.payload.sourceControlProvider).toBe('github'); + }); + it('leaves the provider unset for prompt-only launches with no repository context', async () => { mockEnqueueTask.mockResolvedValue({ id: 101, taskId: 'task-plain' }); @@ -322,15 +356,16 @@ describe('launchTask', () => { expect(enqueuedTask.task.payload.notifySourceRunOnSettle).toBeUndefined(); }); - it('rejects launches whose selected repositories span multiple providers', async () => { + it('allows selected repositories that span multiple providers', async () => { + mockEnqueueTask.mockResolvedValue({ id: 105, taskId: 'task-mixed-set' }); mockRepositoriesFindMany.mockResolvedValue([ { fullName: 'octo/github-repo', installationId: 1 }, { fullName: 'group/gitlab-repo', installationId: null }, ]); - mockSelectRows.mockReturnValue([ - { sourceControlProvider: 'github' }, - { sourceControlProvider: 'gitlab' }, - ]); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({ + 'octo/github-repo': 'github', + 'group/gitlab-repo': 'gitlab', + }); const app = createApp(authContext); const response = await app.request( @@ -344,11 +379,43 @@ describe('launchTask', () => { }), ); - expect(response.status).toBe(400); - const json = (await response.json()) as { error: string }; - expect(json.error).toBe( - 'Selected repositories must belong to a single source control provider.', + expect(response.status).toBe(200); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + selectedRepositories: ['octo/github-repo', 'group/gitlab-repo'], + sourceControlProvider: 'github', + }), + }), + }), + expect.anything(), ); + }); + + it('rejects selected repositories whose source control is ambiguous', async () => { + mockRepositoriesFindMany.mockResolvedValue([ + { fullName: 'group/project', installationId: null }, + ]); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({}); + + const app = createApp(authContext); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'Ship a change', + selectedRepositories: ['group/project'], + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: + 'Could not unambiguously resolve source control for: group/project', + }); expect(mockEnqueueTask).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts b/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts new file mode 100644 index 000000000..7d0a10296 --- /dev/null +++ b/apps/api/src/handlers/tasks/__tests__/manageSourceControl.test.ts @@ -0,0 +1,131 @@ +import { Hono } from 'hono'; + +import type { Variables } from '../../../types'; +import type { McpAuth } from '../../mcp/middleware'; +import { manageSourceControl } from '../manageSourceControl'; + +const { + mockAssertTaskRunTokenTargetExists, + mockClaimLatestUserMessageForReplyQuote, + mockFindTaskRunForSourceControlMutation, + mockManageSourceControlIssueForTaskRun, +} = vi.hoisted(() => ({ + mockAssertTaskRunTokenTargetExists: vi.fn(), + mockClaimLatestUserMessageForReplyQuote: vi.fn(), + mockFindTaskRunForSourceControlMutation: vi.fn(), + mockManageSourceControlIssueForTaskRun: vi.fn(), +})); + +vi.mock('@roomote/communication/messages', () => ({ + claimLatestUserMessageForReplyQuote: mockClaimLatestUserMessageForReplyQuote, + completeClaimedLatestUserMessageForReplyQuote: vi.fn(), + restoreClaimedLatestUserMessageForReplyQuote: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', async (importOriginal) => ({ + ...(await importOriginal()), + findTaskRunForSourceControlMutation: mockFindTaskRunForSourceControlMutation, + manageSourceControlIssueForTaskRun: mockManageSourceControlIssueForTaskRun, +})); + +vi.mock('../../mcp/proxy-utils', async (importOriginal) => ({ + ...(await importOriginal()), + assertTaskRunTokenTargetExists: mockAssertTaskRunTokenTargetExists, +})); + +function createApp() { + const app = new Hono<{ + Variables: Variables & { mcpAuth: McpAuth }; + }>(); + const mcpAuth = { + authContext: { + runId: 123, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }, + userId: 'user-1', + } as McpAuth; + + app.use('*', async (c, next) => { + c.set('mcpAuth', mcpAuth); + await next(); + }); + app.post('/:taskId/source_control', manageSourceControl); + + return app; +} + +describe('manageSourceControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAssertTaskRunTokenTargetExists.mockResolvedValue(undefined); + mockFindTaskRunForSourceControlMutation.mockResolvedValue({ + id: 123, + payload: { + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + }); + mockManageSourceControlIssueForTaskRun.mockResolvedValue({ + success: true, + action: 'create_issue_comment', + provider: 'gitlab', + repositoryFullName: 'acme/backend', + number: 1082, + commentId: '9', + warnings: [], + }); + }); + + it('does not apply GitHub reply quoting to a GitLab target in a GitHub-primary task', async () => { + const response = await createApp().request('/task-1/source_control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'create_issue_comment', + repositoryFullName: 'acme/backend', + issueNumber: 1082, + body: 'Fixed in the latest branch.', + }), + }); + + expect(response.status).toBe(200); + expect(mockClaimLatestUserMessageForReplyQuote).not.toHaveBeenCalled(); + expect(mockManageSourceControlIssueForTaskRun).toHaveBeenCalledWith({ + taskRun: expect.objectContaining({ id: 123 }), + input: expect.objectContaining({ + repositoryFullName: 'acme/backend', + body: 'Fixed in the latest branch.', + }), + }); + }); + + it('defers unmapped provider errors until after repository scope validation', async () => { + mockManageSourceControlIssueForTaskRun.mockRejectedValueOnce( + new Error( + "Repository other/repo is outside this task's source-control scope.", + ), + ); + + const response = await createApp().request('/task-1/source_control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + action: 'get_issue', + repositoryFullName: 'other/repo', + issueNumber: 42, + }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: + "Repository other/repo is outside this task's source-control scope.", + }); + expect(mockManageSourceControlIssueForTaskRun).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/tasks/automation-work-items/repositories.ts b/apps/api/src/handlers/tasks/automation-work-items/repositories.ts index 6ef695a0e..2d75e082a 100644 --- a/apps/api/src/handlers/tasks/automation-work-items/repositories.ts +++ b/apps/api/src/handlers/tasks/automation-work-items/repositories.ts @@ -7,9 +7,7 @@ function getSuggestedTaskRepositoryFullNames( payload: SuggestedTasksPayload, ): string[] { if (payload.repo === ALL_REPOSITORIES) { - return [...new Set(payload.selectedRepositories ?? [])].sort( - (left, right) => left.localeCompare(right), - ); + return [...new Set(payload.selectedRepositories ?? [])]; } if (payload.repo?.trim()) { diff --git a/apps/api/src/handlers/tasks/launchTask.ts b/apps/api/src/handlers/tasks/launchTask.ts index 034a26edb..caaa32abb 100644 --- a/apps/api/src/handlers/tasks/launchTask.ts +++ b/apps/api/src/handlers/tasks/launchTask.ts @@ -8,11 +8,11 @@ import { import { and, db, - environmentRepositoryMappings, environments, eq, inArray, repositories, + resolveWorkspaceRepositoryProviders, } from '@roomote/db/server'; import { ADMIN_REQUIRED_LAUNCH_TYPES, @@ -43,7 +43,7 @@ function normalizeRepositoryFullNames(body: TaskLaunchRequest): string[] { body.repo && body.repo !== ALL_REPOSITORIES ? body.repo : null, ].filter((value): value is string => Boolean(value)), ), - ].sort((left, right) => left.localeCompare(right)); + ]; } async function validateSelectedRepositories( @@ -88,20 +88,6 @@ async function validateSelectedRepositories( return null; } -function resolveSingleSourceControlProvider( - providers: SourceControlProvider[], -): SourceControlProvider | undefined { - const uniqueProviders = [...new Set(providers)]; - - if (uniqueProviders.length > 1) { - throw new Error( - 'Selected repositories must belong to a single source control provider.', - ); - } - - return uniqueProviders[0]; -} - async function resolveLaunchSourceControlProvider({ repositoryFullNames, environmentId, @@ -110,42 +96,30 @@ async function resolveLaunchSourceControlProvider({ environmentId: string | undefined; }): Promise { if (repositoryFullNames.length > 0) { - const rows = await db - .select({ sourceControlProvider: repositories.sourceControlProvider }) - .from(repositories) - .where( - and( - eq(repositories.isActive, true), - inArray(repositories.fullName, repositoryFullNames), - ), - ); - const provider = resolveSingleSourceControlProvider( - rows.map((row) => row.sourceControlProvider), + const repositoryProviders = await resolveWorkspaceRepositoryProviders(db, { + type: 'repository_set', + repositories: repositoryFullNames, + }); + const unresolvedRepositories = repositoryFullNames.filter( + (repositoryFullName) => + repositoryProviders[repositoryFullName] === undefined, ); - if (provider) { - return provider; + if (unresolvedRepositories.length > 0) { + throw new Error( + `Could not unambiguously resolve source control for: ${unresolvedRepositories.join(', ')}`, + ); } + + return Object.values(repositoryProviders)[0]; } if (environmentId) { - const rows = await db - .select({ sourceControlProvider: repositories.sourceControlProvider }) - .from(environmentRepositoryMappings) - .innerJoin( - repositories, - eq(environmentRepositoryMappings.repositoryId, repositories.id), - ) - .where( - and( - eq(environmentRepositoryMappings.environmentId, environmentId), - eq(repositories.isActive, true), - ), - ); - - return resolveSingleSourceControlProvider( - rows.map((row) => row.sourceControlProvider), - ); + const repositoryProviders = await resolveWorkspaceRepositoryProviders(db, { + type: 'environment', + environmentId, + }); + return Object.values(repositoryProviders)[0]; } return undefined; diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index 1ee30b6ab..8393277ad 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -6,13 +6,14 @@ import { completeClaimedLatestUserMessageForReplyQuote, restoreClaimedLatestUserMessageForReplyQuote, } from '@roomote/communication/messages'; -import { resolveSourceControlProviderFromPayload } from '@roomote/types'; - +import { resolveRepositoryProvidersFromPayload } from '@roomote/types'; import { createOrUpdateSourceControlPullRequestForTaskRun, findTaskRunForSourceControlMutation, + getPayloadRecord, manageSourceControlIssueForTaskRun, readSourceControlPullRequestForTaskRun, + resolveSourceControlProviderForRepositoryFromPayload, sourceControlIssueInputSchema, sourceControlPullRequestMutationInputSchema, sourceControlPullRequestReadInputSchema, @@ -93,11 +94,22 @@ export async function manageSourceControl( runId: auth.authContext.runId, taskId, }); - const isGitHubTask = - resolveSourceControlProviderFromPayload(taskRun.payload) === 'github'; + const payload = getPayloadRecord(taskRun.payload); + let targetProvider = input.sourceControlProvider; + const repositoryProviders = resolveRepositoryProvidersFromPayload(payload); + + if ( + !targetProvider && + (!repositoryProviders || input.repositoryFullName in repositoryProviders) + ) { + targetProvider = resolveSourceControlProviderForRepositoryFromPayload( + payload, + input.repositoryFullName, + ); + } const bodyInput = 'body' in input ? input : null; const shouldQuote = - isGitHubTask && + targetProvider === 'github' && (input.action === 'reply_to_pull_request_comment' || input.action === 'create_pull_request_comment' || input.action === 'create_issue_comment') && diff --git a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts index 86db79712..e433348d3 100644 --- a/apps/api/src/handlers/tasks/submitTaskSuggestions.ts +++ b/apps/api/src/handlers/tasks/submitTaskSuggestions.ts @@ -217,9 +217,7 @@ function getSuggestedTaskRepositoryFullNames( payload: SuggestedTasksPayload, ): string[] { if (payload.repo === ALL_REPOSITORIES) { - return [...new Set(payload.selectedRepositories ?? [])].sort( - (left, right) => left.localeCompare(right), - ); + return [...new Set(payload.selectedRepositories ?? [])]; } if (payload.repo?.trim()) { diff --git a/apps/docs/environments.mdx b/apps/docs/environments.mdx index eead94d4b..184c5de22 100644 --- a/apps/docs/environments.mdx +++ b/apps/docs/environments.mdx @@ -37,6 +37,12 @@ environment and add any setup guidance you already know. Roomote then starts an environment setup task that figures out how to run the app locally and prepare the workspace for future tasks. +An environment can combine repositories from different source-control +providers, such as GitHub and GitLab. Each provider must have working +credentials in the deployment or the task cannot prepare the complete +workspace. Repositories connected through GitHub must also belong to the same +GitHub App installation. + The setup task is meant to produce a working environment Roomote can reuse. If it cannot finish, adjust the input and try again from **Settings > Environments**. diff --git a/apps/web/src/lib/environment-definition.test.ts b/apps/web/src/lib/environment-definition.test.ts index 80ec0c59b..c2021c943 100644 --- a/apps/web/src/lib/environment-definition.test.ts +++ b/apps/web/src/lib/environment-definition.test.ts @@ -4,6 +4,7 @@ import { buildCreateEnvironmentDefinitionPrompt, RunStatus, getEnvironmentDefinitionIdFromPayload, + normalizeRepositorySelection, type EnvironmentConfig, } from '@roomote/types'; @@ -36,14 +37,14 @@ describe('environment definition helpers', () => { ); }); - it('builds the create prompt with the environment-setup skill and sorted repositories', () => { + it('builds the create prompt with repositories in selection order', () => { const prompt = buildCreateEnvironmentDefinitionPrompt([ 'acme/web', 'acme/api', ]); expect(prompt).toContain('$environment-setup'); - expect(prompt).toContain('- acme/api\n- acme/web'); + expect(prompt).toContain('- acme/web\n- acme/api'); expect(prompt).toContain( 'Do not mock or stub required services just to make the environment appear to work.', ); @@ -107,10 +108,31 @@ describe('environment definition helpers', () => { buildEnvironmentDefinitionWorkspacePayload(['acme/web', 'acme/api']), ).toEqual({ repo: '__all_repositories__', - selectedRepositories: ['acme/api', 'acme/web'], + selectedRepositories: ['acme/web', 'acme/api'], }); }); + it('rejects duplicate repository names before building a workspace', () => { + expect(() => + buildEnvironmentDefinitionWorkspacePayload([ + 'group/project', + 'group/project', + ]), + ).toThrow( + 'The selected repositories include multiple entries named "group/project".', + ); + }); + + it('deduplicates repository selections without changing their order', () => { + expect( + normalizeRepositorySelection([ + { id: 'repo-web', fullName: 'acme/web' }, + { id: 'repo-api', fullName: 'acme/api' }, + { id: 'repo-web', fullName: 'acme/web' }, + ]), + ).toEqual(['repo-web', 'repo-api']); + }); + it('builds the update prompt with the existing environment context', () => { const prompt = buildUpdateEnvironmentDefinitionPrompt({ environmentId: 'env-123', @@ -123,6 +145,9 @@ describe('environment definition helpers', () => { 'Update the existing Roomote environment definition instead of creating a new one.', ); expect(prompt).toContain('- ID: env-123'); + expect(prompt).toContain( + 'Repositories to inspect:\n- acme/web\n- acme/api', + ); expect(prompt).toContain( 'Keep the existing environment name unless the user explicitly asked to rename it.', ); diff --git a/apps/web/src/lib/environment-definition.ts b/apps/web/src/lib/environment-definition.ts index d444b00b9..45a0bf1a7 100644 --- a/apps/web/src/lib/environment-definition.ts +++ b/apps/web/src/lib/environment-definition.ts @@ -97,11 +97,9 @@ export function buildUpdateEnvironmentDefinitionPrompt(input: { repositoryFullNames: string[]; config: EnvironmentConfig; }): string { - const sortedRepositories = [...input.repositoryFullNames].sort( - (left, right) => left.localeCompare(right), - ); + const orderedRepositories = [...new Set(input.repositoryFullNames)]; - const repositoryLines = sortedRepositories + const repositoryLines = orderedRepositories .map((repositoryFullName) => `- ${repositoryFullName}`) .join('\n'); diff --git a/apps/web/src/lib/server/source-control-provider.ts b/apps/web/src/lib/server/source-control-provider.ts index 1ef23c52c..37b57343e 100644 --- a/apps/web/src/lib/server/source-control-provider.ts +++ b/apps/web/src/lib/server/source-control-provider.ts @@ -1,40 +1,43 @@ import type { SourceControlProvider } from '@roomote/types'; -import { db, resolveWorkspaceSourceControlProvider } from '@roomote/db/server'; +import { db, resolveWorkspaceRepositoryProviders } from '@roomote/db/server'; /** - * Resolve the single provider a launch's explicitly selected repositories - * belong to, so the task payload can carry an explicit `sourceControlProvider`. - * Without it, dequeue falls back to the GitHub default and non-GitHub - * deployments fail source control token creation. - * - * This is the web launch-validation path: it THROWS when the selection spans - * multiple providers, surfacing a clear error before enqueue. This differs from - * the shared {@link resolveWorkspaceSourceControlProvider}, which returns - * `undefined` on ambiguity and defers to the downstream GitHub fallback. + * Resolve the primary provider for an explicitly selected repository set. + * Mixed-provider sets are supported; the queue stamps the complete provider + * map before persistence. */ -export function resolveSingleSourceControlProvider( - providers: SourceControlProvider[], +export function resolveSelectedRepositorySourceControlProvider( + repositories: Array<{ + fullName: string; + sourceControlProvider: SourceControlProvider; + }>, + repositoryOrder: string[], ): SourceControlProvider | undefined { - const uniqueProviders = [...new Set(providers)]; - - if (uniqueProviders.length > 1) { - throw new Error( - 'Selected repositories must belong to a single source control provider.', + for (const repositoryFullName of repositoryOrder) { + const matches = repositories.filter( + (repository) => repository.fullName === repositoryFullName, ); + + if (matches.length > 1) { + throw new Error( + `Could not unambiguously resolve source control for: ${repositoryFullName}`, + ); + } + + if (matches[0]) { + return matches[0].sourceControlProvider; + } } - return uniqueProviders[0]; + return undefined; } /** * Resolve the provider for an environment-backed launch by delegating to the * shared resolver (single source of truth for the environment-repository join). * - * Unlike {@link resolveSingleSourceControlProvider}, this returns `undefined` - * (rather than throwing) when the environment's repositories span multiple - * providers, deferring to the dequeue-time GitHub fallback. Environment - * launches are a secondary fallback behind the explicit repository selection, - * so an ambiguous environment should not hard-fail the launch. + * Mixed environments use their first repository's provider as the scalar + * compatibility value. Queue stamping adds the complete repository map. */ export async function resolveEnvironmentSourceControlProvider( environmentId: string | undefined, @@ -43,8 +46,9 @@ export async function resolveEnvironmentSourceControlProvider( return undefined; } - return resolveWorkspaceSourceControlProvider(db, { + const repositoryProviders = await resolveWorkspaceRepositoryProviders(db, { type: 'environment', environmentId, }); + return Object.values(repositoryProviders)[0]; } diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index e10413712..f2297c035 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -5,6 +5,7 @@ const { mockGetBranches, mockGetRepositoryEmptyStates, mockGetRepositories, + mockUpdateEnvironmentDefinition, mockBeginEnvironmentVerification, mockActiveVerificationRuns, } = vi.hoisted(() => ({ @@ -28,6 +29,7 @@ const { installationId: 'installation-1', }, ]), + mockUpdateEnvironmentDefinition: vi.fn(), mockBeginEnvironmentVerification: vi.fn(), // Active verification run seen inside the retry critical section. Each entry // is returned by the locked transaction's active-run lookup. @@ -51,6 +53,8 @@ vi.mock('@roomote/db/server', () => ({ createEnvironmentConfigVersionSnapshot: vi.fn(), db: { select: mockDbSelect, + transaction: async (callback: (tx: unknown) => Promise) => + callback({}), }, desc: vi.fn(), environmentConfigVersions: {}, @@ -66,7 +70,7 @@ vi.mock('@roomote/db/server', () => ({ sql: vi.fn(), taskRuns: {}, tasks: {}, - updateEnvironmentDefinition: vi.fn(), + updateEnvironmentDefinition: mockUpdateEnvironmentDefinition, users: {}, withEnvironmentVerificationRetryLock: async ( _environmentId: string, @@ -100,12 +104,24 @@ import { TaskPayloadKind } from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; import { createEnvironmentCommand, + getEnvironmentRepositoryConfigError, retryEnvironmentVerificationCommand, startEnvironmentDefinitionTaskCommand, updateEnvironmentCommand, validateConfigCommand, } from './index'; +describe('getEnvironmentRepositoryConfigError', () => { + it('rejects ambiguous active repository names', () => { + expect( + getEnvironmentRepositoryConfigError([ + { id: 'repo-github', fullName: 'acme/app', installationId: '1' }, + { id: 'repo-gitlab', fullName: 'acme/app', installationId: null }, + ]), + ).toContain('Multiple repositories are named "acme/app"'); + }); +}); + function buildMockAuth(): UserAuthSuccess { return { success: true, @@ -155,11 +171,11 @@ describe('startEnvironmentDefinitionTaskCommand', () => { expect(result.taskId).toBe('task-env-definition-1'); expect(mockEnqueueTask).toHaveBeenCalledWith( expect.objectContaining({ - title: 'Set up the api + web environment', + title: 'Set up the web + api environment', task: expect.objectContaining({ type: TaskPayloadKind.StandardTask, payload: expect.objectContaining({ - selectedRepositories: ['acme/api', 'acme/web'], + selectedRepositories: ['acme/web', 'acme/api'], }), }), initiator: { kind: 'user', userId: 'user-1' }, @@ -170,6 +186,24 @@ describe('startEnvironmentDefinitionTaskCommand', () => { ); }); + it('surfaces duplicate repository names as a validation error', async () => { + mockGetRepositories.mockResolvedValueOnce([ + { id: 'repo-github', fullName: 'acme/app', installationId: '1' }, + { id: 'repo-gitlab', fullName: 'acme/app', installationId: null }, + ]); + + await expect( + startEnvironmentDefinitionTaskCommand(buildMockAuth(), { + repositoryIds: ['repo-github', 'repo-gitlab'], + }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: + 'Multiple repositories are named "acme/app". Environment repository names must be unique across source-control connections.', + }); + expect(mockEnqueueTask).not.toHaveBeenCalled(); + }); + it('applies the selected model to settings-created setup tasks', async () => { await startEnvironmentDefinitionTaskCommand(buildMockAuth(), { repositoryIds: ['repo-1'], @@ -203,7 +237,7 @@ describe('startEnvironmentDefinitionTaskCommand', () => { }); expect(mockGetRepositoryEmptyStates).toHaveBeenCalledWith({ - repositoryIds: ['repo-1', 'repo-2'], + repositoryIds: ['repo-2', 'repo-1'], }); const enqueueInput = mockEnqueueTask.mock.calls[0]?.[0] as { @@ -398,6 +432,102 @@ describe('environment repository validation', () => { }); }); + it('rejects duplicate configured repositories before creating mappings', async () => { + const result = await createEnvironmentCommand(buildMockAuth(), { + name: 'Duplicate repository', + config: { + name: 'Duplicate repository', + repositories: [{ repository: 'acme/api' }, { repository: 'acme/api' }], + }, + }); + + expect(result).toEqual({ + success: false, + error: 'Invalid configuration: Duplicate repository: acme/api', + }); + expect(mockDbSelect).not.toHaveBeenCalled(); + }); + + it('rejects duplicate configured repositories before updating mappings', async () => { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + id: 'env-1', + name: 'Existing environment', + description: null, + config: { + name: 'Existing environment', + repositories: [{ repository: 'acme/api' }], + }, + }, + ], + }), + }), + }); + + const result = await updateEnvironmentCommand(buildMockAuth(), { + id: 'env-1', + config: { + name: 'Duplicate repository', + repositories: [{ repository: 'acme/api' }, { repository: 'acme/api' }], + }, + }); + + expect(result).toEqual({ + success: false, + error: 'Invalid configuration: Duplicate repository: acme/api', + }); + expect(mockDbSelect).toHaveBeenCalledTimes(1); + }); + + it('keeps legacy duplicate repositories editable for metadata-only updates', async () => { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: async () => [ + { + id: 'env-1', + name: 'Legacy environment', + description: null, + config: { + name: 'Legacy environment', + repositories: [ + { repository: 'acme/api' }, + { repository: 'acme/api' }, + ], + }, + }, + ], + }), + }), + }); + + const result = await updateEnvironmentCommand(buildMockAuth(), { + id: 'env-1', + description: 'Updated description', + }); + + expect(result).toEqual({ success: true, data: undefined }); + expect(mockUpdateEnvironmentDefinition).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + environmentId: 'env-1', + fields: expect.objectContaining({ + description: 'Updated description', + config: expect.objectContaining({ + repositories: [ + { repository: 'acme/api' }, + { repository: 'acme/api' }, + ], + }), + }), + repositoryIds: undefined, + }), + ); + }); + it('rejects update when a configured repository is not linked', async () => { mockDbSelect .mockReturnValueOnce({ @@ -466,6 +596,24 @@ describe('environment repository validation', () => { expect(mockGetBranches).not.toHaveBeenCalled(); }); + it('uses provider-neutral guidance when repository access fails', async () => { + mockDbSelect.mockReturnValueOnce({ + from: () => ({ where: async () => [] }), + }); + mockCheckRepoAccess.mockResolvedValue(false); + + const result = await validateConfigCommand(buildMockAuth(), { + config: { + name: 'GitLab Test', + repositories: [{ repository: 'acme/backend' }], + }, + }); + + expect(result.errors).toEqual([ + "Repository 'acme/backend' is not accessible. Ensure it is connected through its source-control provider.", + ]); + }); + it('continues warning when a GitHub branch is missing', async () => { mockDbSelect.mockReturnValueOnce({ from: () => ({ diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 4802f39ac..204ed725a 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -1,4 +1,5 @@ import { enqueueTask } from '@roomote/cloud-agents/server'; +import { TRPCError } from '@trpc/server'; import { createEnvironmentConfigVersionSnapshot, db, @@ -37,6 +38,8 @@ import { type ComputeProvider, type EnvironmentConfig, environmentConfigSchema, + getAmbiguousEnvironmentRepositoryError, + getDuplicateEnvironmentRepositoryConfigError, getEnvironmentRepositoryInstallationError, getMissingEnvironmentRepositoryError, isExitedRunStatus, @@ -140,14 +143,17 @@ type EnvironmentRepositoryRow = { installationId: string | null; }; -function getEnvironmentRepositoryConfigError( +export function getEnvironmentRepositoryConfigError( repositoriesToValidate: EnvironmentRepositoryRow[], ): string | null { - return getEnvironmentRepositoryInstallationError( - repositoriesToValidate.map((repository) => ({ - fullName: repository.fullName, - installationId: repository.installationId, - })), + return ( + getAmbiguousEnvironmentRepositoryError(repositoriesToValidate) ?? + getEnvironmentRepositoryInstallationError( + repositoriesToValidate.map((repository) => ({ + fullName: repository.fullName, + installationId: repository.installationId, + })), + ) ); } @@ -197,7 +203,10 @@ async function resolveSelectedRepositories( const repository = repositoriesById.get(repositoryId); if (!repository) { - throw new Error('Selected repositories are no longer available.'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Selected repositories are no longer available.', + }); } return { @@ -211,14 +220,15 @@ async function resolveSelectedRepositories( getEnvironmentRepositoryConfigError(selectedRepositories); if (repositoryConfigError) { - throw new Error(repositoryConfigError); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: repositoryConfigError, + }); } return { normalizedRepositoryIds: normalizeRepositorySelection(selectedRepositories), - selectedRepositories: selectedRepositories.sort((left, right) => - left.fullName.localeCompare(right.fullName), - ), + selectedRepositories, }; } @@ -454,6 +464,17 @@ export async function createEnvironmentCommand( }; } + const duplicateRepositoryError = getDuplicateEnvironmentRepositoryConfigError( + parseResult.data.repositories ?? [], + ); + + if (duplicateRepositoryError) { + return { + success: false, + error: `Invalid configuration: ${duplicateRepositoryError}`, + }; + } + const [existing] = await db .select() .from(environments) @@ -576,6 +597,18 @@ export async function updateEnvironmentCommand( }; } + const duplicateRepositoryError = + getDuplicateEnvironmentRepositoryConfigError( + parseResult.data.repositories ?? [], + ); + + if (duplicateRepositoryError) { + return { + success: false, + error: `Invalid configuration: ${duplicateRepositoryError}`, + }; + } + nextConfig = parseResult.data; } else if ( input.name !== undefined || @@ -817,9 +850,23 @@ export async function startEnvironmentDefinitionTaskCommand( (repository) => repository.fullName, ); const title = buildSetupEnvironmentTaskTitle(selectedRepositoryFullNames); - const workspacePayload = buildEnvironmentDefinitionWorkspacePayload( - selectedRepositoryFullNames, - ); + let workspacePayload: ReturnType< + typeof buildEnvironmentDefinitionWorkspacePayload + >; + try { + workspacePayload = buildEnvironmentDefinitionWorkspacePayload( + selectedRepositoryFullNames, + ); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + error instanceof Error + ? error.message + : 'The selected repositories are invalid.', + cause: error, + }); + } const modelSelection = resolveEvalHarnessSelection({ model: input.selectedModelId, }); @@ -1249,7 +1296,7 @@ export async function validateConfigCommand( if (!hasAccess) { errors.push( - `Repository '${repo.repository}' is not accessible. Ensure it is installed via the GitHub App.`, + `Repository '${repo.repository}' is not accessible. Ensure it is connected through its source-control provider.`, ); return; // skip branch check if repo itself is inaccessible } diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index bd0e22c15..da71d0341 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -243,6 +243,7 @@ vi.mock('../setup/shared', () => ({ })); import { + didSuggestionSourceChange, getSetupBootstrapStatusCommand, saveSetupBootstrapAuthConfigCommand, saveSetupBootstrapAuthProviderChoiceCommand, @@ -257,6 +258,8 @@ import { trackSetupWelcomeSeenCommand, } from './index'; import { + ALL_REPOSITORIES, + createEmptySetupNewState, TaskPayloadKind, WORKER_RUNTIME_SCHEMA_VERSION, type SetupNewState, @@ -327,6 +330,21 @@ function createFromOnlySelectChain(result: unknown) { }; } +describe('didSuggestionSourceChange', () => { + it('treats reordered repository ids as the same suggestion source', () => { + expect( + didSuggestionSourceChange({ + currentState: { + ...createEmptySetupNewState(), + selectedRepositoryIds: ['repo-a', 'repo-b'], + }, + nextRepositoryIds: ['repo-b', 'repo-a'], + nextSetupGuidance: null, + }), + ).toBe(false); + }); +}); + describe('setup-new auth config commands', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1286,6 +1304,75 @@ describe('setup-new onboarding task start command', () => { ); }); + it('uses the first workspace provider when setup repositories are mixed', async () => { + vi.mocked(getRepositories).mockResolvedValue([ + { + id: 'repo-1', + fullName: 'octo/api', + sourceControlProvider: 'github', + }, + { + id: 'repo-2', + fullName: 'group/web', + sourceControlProvider: 'gitlab', + }, + ] as Awaited>); + vi.mocked(normalizeRepositorySelection).mockReturnValue([ + 'repo-1', + 'repo-2', + ]); + vi.mocked(buildSetupNewWorkspacePayload).mockReturnValue({ + repo: ALL_REPOSITORIES, + selectedRepositories: ['octo/api', 'group/web'], + }); + mockOnboardingTransaction({ + slackInstallation: null, + setupNewState: { selectedRepositoryIds: ['repo-1', 'repo-2'] }, + }); + + await startSetupNewOnboardingTaskCommand(buildMockAuth()); + + expect(enqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + sourceControlProvider: 'github', + }), + }), + }), + ); + }); + + it('rejects selected repositories with duplicate full names', async () => { + vi.mocked(getRepositories).mockResolvedValue([ + { + id: 'repo-github', + fullName: 'group/project', + sourceControlProvider: 'github', + }, + { + id: 'repo-gitlab', + fullName: 'group/project', + sourceControlProvider: 'gitlab', + }, + ] as Awaited>); + mockOnboardingTransaction({ + slackInstallation: null, + setupNewState: { + selectedRepositoryIds: ['repo-github', 'repo-gitlab'], + }, + }); + + await expect( + startSetupNewOnboardingTaskCommand(buildMockAuth()), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: + 'The selected repositories include multiple entries named "group/project". Select only one because task workspaces identify repositories by full name.', + }); + expect(enqueueTask).not.toHaveBeenCalled(); + }); + it('launches with bootstrap instructions instead of blocking when every selected repo is empty', async () => { const { getRepositoryEmptyStates } = await import('@roomote/github'); vi.mocked(getRepositoryEmptyStates).mockResolvedValue( diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 903e9235a..35e19a143 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -1,9 +1,7 @@ import * as GitHub from '@roomote/github'; +import { TRPCError } from '@trpc/server'; import { enqueueTask } from '@roomote/cloud-agents/server'; -import { - resolveEnvironmentSourceControlProvider, - resolveSingleSourceControlProvider, -} from '@/lib/server/source-control-provider'; +import { resolveEnvironmentSourceControlProvider } from '@/lib/server/source-control-provider'; import { buildSetupKickoffText } from '@roomote/communication/chat-messages'; import { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; @@ -67,6 +65,7 @@ import { TaskPayloadKind, resolveEvalHarnessSelection, type ComputeProvider, + assertUniqueRepositoryFullNames, type DeploymentModelConfig, deriveWorkerImageFromReleaseVersion, getSetupAuthProvider, @@ -306,7 +305,10 @@ async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ const repository = availableRepositoriesById.get(repositoryId); if (!repository) { - throw new Error('Selected repositories are no longer available.'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Selected repositories are no longer available.', + }); } selectedRepositories.push({ @@ -316,11 +318,24 @@ async function resolveSelectedRepositories(repositoryIds: string[]): Promise<{ }); } + try { + assertUniqueRepositoryFullNames( + selectedRepositories.map((repository) => repository.fullName), + ); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + error instanceof Error + ? error.message + : 'The selected repositories are invalid.', + cause: error, + }); + } + return { normalizedRepositoryIds: normalizeRepositorySelection(selectedRepositories), - selectedRepositories: selectedRepositories.sort((left, right) => - left.fullName.localeCompare(right.fullName), - ), + selectedRepositories, }; } @@ -568,7 +583,7 @@ async function resolveSetupChatFallbackHandoffTarget( return null; } -function didSuggestionSourceChange({ +export function didSuggestionSourceChange({ currentState, nextRepositoryIds, nextSetupGuidance, @@ -577,11 +592,14 @@ function didSuggestionSourceChange({ nextRepositoryIds: string[]; nextSetupGuidance: string | null; }): boolean { + const currentRepositoryIdSet = new Set(currentState.selectedRepositoryIds); + const nextRepositoryIdSet = new Set(nextRepositoryIds); + return ( currentState.setupGuidance !== nextSetupGuidance || - currentState.selectedRepositoryIds.length !== nextRepositoryIds.length || - currentState.selectedRepositoryIds.some( - (repositoryId, index) => repositoryId !== nextRepositoryIds[index], + currentRepositoryIdSet.size !== nextRepositoryIdSet.size || + [...currentRepositoryIdSet].some( + (repositoryId) => !nextRepositoryIdSet.has(repositoryId), ) ); } @@ -2569,16 +2587,25 @@ export async function startSetupNewOnboardingTaskCommand( const onboardingTaskTitle = buildSetupEnvironmentTaskTitle( selectedRepositoryFullNames, ); - const workspacePayload = buildSetupNewWorkspacePayload( - selectedRepositoryFullNames, - ); + let workspacePayload: ReturnType; + try { + workspacePayload = buildSetupNewWorkspacePayload( + selectedRepositoryFullNames, + ); + } catch (error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + error instanceof Error + ? error.message + : 'The selected repositories are invalid.', + cause: error, + }); + } // Stamp the provider explicitly: dequeue defaults to GitHub when the // payload omits it, which breaks non-GitHub deployments. - const setupSourceControlProvider = resolveSingleSourceControlProvider( - selectedRepositories.map( - (repository) => repository.sourceControlProvider, - ), - ); + const setupSourceControlProvider = + selectedRepositories[0]?.sourceControlProvider; const emptyRepositoryFullNames = selectedRepositories .filter((repository) => repositoryEmptyStates.get(repository.id) === true) .map((repository) => repository.fullName); diff --git a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts index 38d42eb9e..9270c91ae 100644 --- a/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts +++ b/apps/web/src/trpc/commands/setup-new/launch-lifecycle.test.ts @@ -89,7 +89,6 @@ vi.mock('@roomote/sdk/server', () => ({ // to null so the enqueue payload is deterministic. vi.mock('@/lib/server/source-control-provider', () => ({ resolveEnvironmentSourceControlProvider: vi.fn(async () => null), - resolveSingleSourceControlProvider: vi.fn(() => null), })); vi.mock('@/lib/server', () => ({ diff --git a/apps/web/src/trpc/commands/task-runs/index.test.ts b/apps/web/src/trpc/commands/task-runs/index.test.ts index c06218997..15fee8b0b 100644 --- a/apps/web/src/trpc/commands/task-runs/index.test.ts +++ b/apps/web/src/trpc/commands/task-runs/index.test.ts @@ -52,7 +52,7 @@ vi.mock('@roomote/db/server', () => ({ right, })), markTaskStartParallelCountEndedAt: vi.fn(), - resolveWorkspaceSourceControlProvider: (...args: unknown[]) => + resolveWorkspaceRepositoryProviders: (...args: unknown[]) => mockResolveWorkspaceProvider(...args), repositories: { id: 'repositories.id', @@ -138,7 +138,7 @@ describe('createStandardTaskRunCommand', () => { }); mockDbWhere.mockResolvedValue([]); // Shared resolver defaults to unresolved; the environment test overrides it. - mockResolveWorkspaceProvider.mockResolvedValue(undefined); + mockResolveWorkspaceProvider.mockResolvedValue({}); mockSuccessfulEnqueue(); }); @@ -265,7 +265,9 @@ describe('createStandardTaskRunCommand', () => { it('stamps an environment source-control provider from its repository mappings', async () => { // The environment resolver delegates to the shared @roomote/db resolver. - mockResolveWorkspaceProvider.mockResolvedValue('ado'); + mockResolveWorkspaceProvider.mockResolvedValue({ + 'acme/Platform/backend': 'ado', + }); const result = await createStandardTaskRunCommand(auth, { payload: { @@ -296,4 +298,65 @@ describe('createStandardTaskRunCommand', () => { }), ); }); + + it('uses the first repository provider for a mixed environment', async () => { + mockResolveWorkspaceProvider.mockResolvedValue({ + 'octo/api': 'github', + 'group/web': 'gitlab', + }); + + const result = await createStandardTaskRunCommand(auth, { + payload: { + repo: ALL_REPOSITORIES, + environmentId: '7bb91386-6282-4c98-9b31-0eb181116822', + description: 'Update the environment', + }, + }); + + expect(result.success).toBe(true); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + sourceControlProvider: 'github', + }), + }), + }), + ); + }); + + it('allows mixed selected repositories and keeps selection order for the primary provider', async () => { + mockGetRepositories.mockResolvedValue([ + { + id: 'repo-github', + fullName: 'octo/api', + sourceControlProvider: 'github', + }, + { + id: 'repo-gitlab', + fullName: 'group/web', + sourceControlProvider: 'gitlab', + }, + ]); + + const result = await createStandardTaskRunCommand(auth, { + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: ['octo/api', 'group/web'], + description: 'Update selected repositories', + }, + }); + + expect(result.success).toBe(true); + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + selectedRepositories: ['octo/api', 'group/web'], + sourceControlProvider: 'github', + }), + }), + }), + ); + }); }); diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index 8cf9062b2..4bf343023 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -36,7 +36,7 @@ import type { UserAuthSuccess } from '@/types'; import { Env, getArtifactById, getRepositories } from '@/lib/server'; import { resolveEnvironmentSourceControlProvider, - resolveSingleSourceControlProvider, + resolveSelectedRepositorySourceControlProvider, } from '@/lib/server/source-control-provider'; import { humanizeFilename } from '@/lib/task-utils'; @@ -59,9 +59,7 @@ function getManualTaskRepositoryFullNames( payload: TaskPayload, ) { if (payload.selectedRepositories?.length) { - return [...new Set(payload.selectedRepositories.filter(Boolean))].sort( - (left, right) => left.localeCompare(right), - ); + return [...new Set(payload.selectedRepositories.filter(Boolean))]; } if (payload.repo && payload.repo !== ALL_REPOSITORIES) { @@ -362,10 +360,9 @@ export async function createStandardTaskRunCommand( ); const sourceControlProvider = input.payload.sourceControlProvider ?? - resolveSingleSourceControlProvider( - selectedRepositories.map( - (repository) => repository.sourceControlProvider, - ), + resolveSelectedRepositorySourceControlProvider( + selectedRepositories, + selectedRepositoryFullNames, ) ?? (await resolveEnvironmentSourceControlProvider( input.payload.environmentId, diff --git a/apps/web/src/trpc/commands/task-suggestions/onboarding.ts b/apps/web/src/trpc/commands/task-suggestions/onboarding.ts index cce2fe55d..e24a821ca 100644 --- a/apps/web/src/trpc/commands/task-suggestions/onboarding.ts +++ b/apps/web/src/trpc/commands/task-suggestions/onboarding.ts @@ -10,9 +10,8 @@ import { deploymentSettings, eq, getBackgroundAgentSettingsForDeployment, - inArray, - repositories, resolveRepositorySelectionByIds, + resolveWorkspaceRepositoryProviders, workItems, } from '@roomote/db/server'; import { @@ -22,10 +21,10 @@ import { createEmptySetupNewState, isExitedRunStatus, normalizeSetupNewState, + resolveTaskWorkspace, } from '@roomote/types'; import { getLatestTaskRunsByTaskId } from '@/lib/server'; -import { resolveSingleSourceControlProvider } from '@/lib/server/source-control-provider'; import type { UserAuthSuccess } from '@/types'; import { assertAdmin } from '../setup/shared'; import { decorateSuggestionsWithEnvironmentIds } from './launch-resolution'; @@ -137,13 +136,13 @@ async function launchSuggestedTasksTask(input: { ); // Stamp the provider explicitly: dequeue defaults to GitHub when the // payload omits it, which breaks non-GitHub deployments. - const scanRepositoryRows = await db - .select({ sourceControlProvider: repositories.sourceControlProvider }) - .from(repositories) - .where(inArray(repositories.fullName, input.repositoryFullNames)); - const scanSourceControlProvider = resolveSingleSourceControlProvider( - scanRepositoryRows.map((row) => row.sourceControlProvider), + const scanRepositoryProviders = await resolveWorkspaceRepositoryProviders( + db, + resolveTaskWorkspace(workspacePayload), ); + const scanSourceControlProvider = input.repositoryFullNames + .map((repositoryFullName) => scanRepositoryProviders[repositoryFullName]) + .find((provider) => provider !== undefined); const launchResult = await enqueueTask( { task: { diff --git a/apps/worker/src/commands/__tests__/snapshot.test.ts b/apps/worker/src/commands/__tests__/snapshot.test.ts index 9b7f4af19..559d1c3b8 100644 --- a/apps/worker/src/commands/__tests__/snapshot.test.ts +++ b/apps/worker/src/commands/__tests__/snapshot.test.ts @@ -3,6 +3,7 @@ import { RunStatus } from '@roomote/types'; const { mockTaskRunsUpdate, mockFetchSnapshotEnv, + mockFindTaskRun, mockFindEnvironment, mockDone, mockUpdateSnapshotStatus, @@ -16,6 +17,7 @@ const { } = vi.hoisted(() => ({ mockTaskRunsUpdate: vi.fn(), mockFetchSnapshotEnv: vi.fn(), + mockFindTaskRun: vi.fn(), mockFindEnvironment: vi.fn(), mockDone: vi.fn(), mockUpdateSnapshotStatus: vi.fn(), @@ -33,6 +35,7 @@ vi.mock('@roomote/sdk/client', () => ({ taskRuns: { update: mockTaskRunsUpdate, fetchSnapshotEnv: mockFetchSnapshotEnv, + findFirstById: mockFindTaskRun, done: mockDone, }, environments: { @@ -91,6 +94,13 @@ describe('snapshot', () => { repositories: [{ repository: 'Roomote/example-app' }], }, }); + mockFindTaskRun.mockResolvedValue({ + payload: { + repositoryProviders: { + 'Roomote/example-app': 'gitlab', + }, + }, + }); mockWorkerEnvFromProcessEnv.mockReturnValue({}); mockCreateStartupLogger.mockReturnValue({ userLog: { log: vi.fn() } }); mockDone.mockResolvedValue(undefined); @@ -120,6 +130,15 @@ describe('snapshot', () => { environmentId: 'env-1', snapshotStatus: 'failed', }); + expect(mockSetup).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: expect.objectContaining({ + repositoryProviders: { + 'Roomote/example-app': 'gitlab', + }, + }), + }), + ); const injectCallOrder = mockInjectEnvVars.mock.invocationCallOrder[0]; const findEnvironmentCallOrder = mockFindEnvironment.mock.invocationCallOrder[0]; diff --git a/apps/worker/src/commands/setup/__tests__/workspace.test.ts b/apps/worker/src/commands/setup/__tests__/workspace.test.ts index ceedbb19c..d35c01fc5 100644 --- a/apps/worker/src/commands/setup/__tests__/workspace.test.ts +++ b/apps/worker/src/commands/setup/__tests__/workspace.test.ts @@ -140,6 +140,73 @@ describe('initializeRepositories', () => { ); }); + it('resolves repository providers from the map before the scalar fallback', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockImplementation(async (repo) => `/tmp/${repo}`); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'repository_set', + repositories: ['acme/github-app', 'acme/gitlab-app'], + }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + sourceControlProvider: 'github', + repositoryProviders: { + 'acme/gitlab-app': 'gitlab', + }, + }); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + {}, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + + it('uses a mapped provider for a single-repository workspace', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockResolvedValue('/tmp/acme/app'); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'repository', + repository: 'acme/app', + }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + repositoryProviders: { 'acme/app': 'gitlab' }, + }); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + it('continues scoped multi-repo workspace setup when at least one selected repository prepares successfully', async () => { const logger = createLogger(); vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( @@ -237,6 +304,43 @@ describe('initializeRepositories', () => { ); }); + it('applies mapped providers to repositories discovered for all-repositories workspaces', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareRepositorySpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareRepository') + .mockImplementation(async (repo) => `/tmp/${repo}`); + + await initializeRepositories(createLogger(), { + workspace: { type: 'all_repositories' }, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + repositoryProviders: { + 'acme/github-app': 'github', + 'acme/gitlab-app': 'gitlab', + }, + }); + + expect(mockListRepositories).not.toHaveBeenCalled(); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + {}, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { sourceControlProvider: 'gitlab' }, + ); + }); + it('fails all-repositories workspace setup when no repositories can be prepared', async () => { vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( undefined, @@ -420,6 +524,49 @@ describe('initializeRepositories', () => { {}, ); }); + + it('passes repository provider overrides to environment preparation', async () => { + vi.spyOn(WorkspaceManager.prototype, 'configure').mockResolvedValue( + undefined, + ); + const prepareEnvironmentRepositoriesSpy = vi + .spyOn(WorkspaceManager.prototype, 'prepareEnvironmentRepositories') + .mockResolvedValue({ repoPaths: {} }); + vi.spyOn( + WorkspaceManager.prototype, + 'installWorkspaceToolVersions', + ).mockResolvedValue(undefined); + + await initializeRepositories(createLogger(), { + workspace: { + type: 'environment', + environmentId: 'env_123', + environmentConfig: { + name: 'Mixed Providers', + repositories: [ + { repository: 'acme/github-app' }, + { repository: 'acme/gitlab-app' }, + ], + }, + } as WorkspaceConfig, + envVars: {}, + taskRunType: TaskPayloadKind.StandardTask, + sourceControlProvider: 'github', + repositoryProviders: { + 'acme/gitlab-app': 'gitlab', + }, + }); + + expect(prepareEnvironmentRepositoriesSpy).toHaveBeenCalledWith( + expect.any(Object), + false, + false, + expect.any(Object), + { + repositoryProviders: { 'acme/gitlab-app': 'gitlab' }, + }, + ); + }); }); describe('initializeSystemServices', () => { diff --git a/apps/worker/src/commands/setup/workspace/repositories.ts b/apps/worker/src/commands/setup/workspace/repositories.ts index c25d05196..96c2a0e86 100644 --- a/apps/worker/src/commands/setup/workspace/repositories.ts +++ b/apps/worker/src/commands/setup/workspace/repositories.ts @@ -70,14 +70,27 @@ export async function initializeRepositories( gitAuthorName, gitAuthorEmail, sourceControlProvider, + repositoryProviders, }: PrepareWorkspaceOptions, ): Promise { const resolvedSourceControlProvider = sourceControlProvider ?? DEFAULT_SOURCE_CONTROL_PROVIDER; - const sourceControlPrepareOptions = - resolvedSourceControlProvider === DEFAULT_SOURCE_CONTROL_PROVIDER + const resolveRepositoryProvider = (repository: string) => + repositoryProviders?.[repository] ?? resolvedSourceControlProvider; + const sourceControlPrepareOptions = (repository: string) => { + const repositoryProvider = resolveRepositoryProvider(repository); + + return repositoryProvider === DEFAULT_SOURCE_CONTROL_PROVIDER + ? {} + : { sourceControlProvider: repositoryProvider }; + }; + const environmentSourceControlPrepareOptions = { + ...(resolvedSourceControlProvider === DEFAULT_SOURCE_CONTROL_PROVIDER ? {} - : { sourceControlProvider: resolvedSourceControlProvider }; + : { sourceControlProvider: resolvedSourceControlProvider }), + ...(repositoryProviders ? { repositoryProviders } : {}), + }; + const mappedRepositoryNames = Object.keys(repositoryProviders ?? {}); const { workspaceRoot, workspaceManager } = createWorkspaceManager( envVars, logger, @@ -104,7 +117,7 @@ export async function initializeRepositories( sourceBranch: workspace.sourceBranch, sourceSha: workspace.sourceSha, }, - sourceControlPrepareOptions, + environmentSourceControlPrepareOptions, ), ); @@ -139,17 +152,22 @@ export async function initializeRepositories( case 'repository_set': case 'all_repositories': { + // A stamped map is the launch-time workspace snapshot. Prefer it over + // a live provider-filtered list so mixed-provider tasks keep every + // repository selected when the task was queued. const repositoriesToPrepare = workspace.type === 'repository_set' ? workspace.repositories.map((fullName) => ({ fullName })) - : await timedStep( - logger, - 'initializeRepositories: list repositories', - () => - sdk.repositories.listRepositories({ - sourceControlProvider: resolvedSourceControlProvider, - }), - ); + : mappedRepositoryNames.length > 0 + ? mappedRepositoryNames.map((fullName) => ({ fullName })) + : await timedStep( + logger, + 'initializeRepositories: list repositories', + () => + sdk.repositories.listRepositories({ + sourceControlProvider: resolvedSourceControlProvider, + }), + ); const limit = pLimit(REPO_PREPARATION_CONCURRENCY); @@ -167,7 +185,7 @@ export async function initializeRepositories( preserveGitState, cleanupLegacyPaths, { - ...sourceControlPrepareOptions, + ...sourceControlPrepareOptions(repo.fullName), }, ), ); @@ -317,7 +335,7 @@ export async function initializeRepositories( workspace.sha, preserveGitState, cleanupLegacyPaths, - sourceControlPrepareOptions, + sourceControlPrepareOptions(workspace.repository), ), ); } catch (error) { diff --git a/apps/worker/src/commands/setup/workspace/types.ts b/apps/worker/src/commands/setup/workspace/types.ts index 64750c34e..d1ba1208f 100644 --- a/apps/worker/src/commands/setup/workspace/types.ts +++ b/apps/worker/src/commands/setup/workspace/types.ts @@ -66,6 +66,7 @@ export interface PrepareWorkspaceOptions { preserveGitState?: boolean; cleanupLegacyPaths?: boolean; sourceControlProvider?: SourceControlProvider; + repositoryProviders?: Record; serviceContext?: ServiceContext; gitAuthorName?: string; gitAuthorEmail?: string; diff --git a/apps/worker/src/commands/snapshot.ts b/apps/worker/src/commands/snapshot.ts index d65989536..f5ad71d92 100644 --- a/apps/worker/src/commands/snapshot.ts +++ b/apps/worker/src/commands/snapshot.ts @@ -17,6 +17,7 @@ import { import { setup } from './setup'; import { injectEnvVars } from './utils/env-vars'; +import { resolveRepositoryProvidersFromPayload } from './utils/repository-providers'; import { scrubSandboxSecretsBeforeSnapshot } from './utils/scrub-sandbox-secrets'; import { findRuntimeEnvironmentConfig } from './utils/workspace-config'; @@ -82,6 +83,7 @@ export async function snapshot({ await injectEnvVars(envVars, undefined, { sourceControlToken }); const environmentConfig = await findRuntimeEnvironmentConfig(environmentId); + const taskRun = await sdk.taskRuns.findFirstById(runId); if (!environmentConfig) { throw new Error(`Environment not found`); @@ -99,6 +101,9 @@ export async function snapshot({ taskRunType: TaskPayloadKind.SnapshotEnvironment, sourceControlProvider: sourceControlToken?.provider ?? DEFAULT_SOURCE_CONTROL_PROVIDER, + repositoryProviders: resolveRepositoryProvidersFromPayload( + taskRun?.payload, + ), }, logger: startupLogger, workerEnv, diff --git a/apps/worker/src/commands/utils/execute-task-run.test.ts b/apps/worker/src/commands/utils/execute-task-run.test.ts index 75b0cc0d1..a390b635a 100644 --- a/apps/worker/src/commands/utils/execute-task-run.test.ts +++ b/apps/worker/src/commands/utils/execute-task-run.test.ts @@ -178,6 +178,9 @@ describe('executeTaskRun', () => { harness: 'opencode-server', payload: { repo: 'owner/repo', + repositoryProviders: { + 'owner/repo': 'gitlab', + }, }, }, envVars: { @@ -234,6 +237,9 @@ describe('executeTaskRun', () => { expect(setupArgs.workspace.envVars).toMatchObject({ FOO: 'bar', }); + expect(setupArgs.workspace.repositoryProviders).toEqual({ + 'owner/repo': 'gitlab', + }); expect(typeof setupArgs.recordPhase).toBe('function'); expect(sdkTaskRunsStampMilestoneMock).toHaveBeenCalledWith({ runId: 42, diff --git a/apps/worker/src/commands/utils/execute-task-run.ts b/apps/worker/src/commands/utils/execute-task-run.ts index f08420aa7..1f92976d8 100644 --- a/apps/worker/src/commands/utils/execute-task-run.ts +++ b/apps/worker/src/commands/utils/execute-task-run.ts @@ -50,6 +50,7 @@ import { import { BackgroundEnvironmentSetupController } from './background-environment-setup-controller'; import { injectEnvVars, writeBashrc } from './env-vars'; +import { resolveRepositoryProvidersFromPayload } from './repository-providers'; import { buildServiceContextForPreviewProxy } from './service-context'; import { finalizeJob, handleTaskRunError } from './task-run-lifecycle'; @@ -539,6 +540,9 @@ export async function executeTaskRun({ sourceControlProvider: resolveSourceControlProviderFromPayload( currentTaskRun.payload, ), + repositoryProviders: resolveRepositoryProvidersFromPayload( + currentTaskRun.payload, + ), gitAuthorName: jobContext.gitAuthor?.name, gitAuthorEmail: jobContext.gitAuthor?.email, }, diff --git a/apps/worker/src/commands/utils/repository-providers.ts b/apps/worker/src/commands/utils/repository-providers.ts new file mode 100644 index 000000000..7dd160673 --- /dev/null +++ b/apps/worker/src/commands/utils/repository-providers.ts @@ -0,0 +1,15 @@ +import type { SourceControlProvider } from '@roomote/types'; + +export function resolveRepositoryProvidersFromPayload( + payload: unknown, +): Record | undefined { + if (!payload || typeof payload !== 'object') { + return undefined; + } + + return ( + payload as { + repositoryProviders?: Record; + } + ).repositoryProviders; +} diff --git a/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts b/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts index a940a202e..2ba043abe 100644 --- a/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts +++ b/apps/worker/src/commands/utils/scrub-sandbox-secrets.test.ts @@ -30,6 +30,12 @@ vi.mock('os', async (importOriginal) => { }); const COMMON_ENV_PATH = '/home/testuser/.roomote/env.sh'; +const TOKEN_ENV_PATHS = [ + '/home/testuser/.roomote/gh-token-env.sh', + '/home/testuser/.roomote/gitlab-token-env.sh', + '/home/testuser/.roomote/gitea-token-env.sh', + '/home/testuser/.roomote/ado-token-env.sh', +]; const EXPECTED_REMOVED_PATHS = [ '/home/testuser/.roomote/gh-token', @@ -89,6 +95,14 @@ describe('scrubSandboxSecretsBeforeSnapshot', () => { } }); + it('recreates every provider token env script before snapshotting', async () => { + await scrubSandboxSecretsBeforeSnapshot(); + + for (const path of TOKEN_ENV_PATHS) { + expect(findWrite(path)).toBeDefined(); + } + }); + it('respects XDG_DATA_HOME when locating OpenCode credential files', async () => { process.env.XDG_DATA_HOME = '/custom/data'; diff --git a/apps/worker/src/workspace/__tests__/tool-versions.test.ts b/apps/worker/src/workspace/__tests__/tool-versions.test.ts index 604a0f9c1..dbb26226b 100644 --- a/apps/worker/src/workspace/__tests__/tool-versions.test.ts +++ b/apps/worker/src/workspace/__tests__/tool-versions.test.ts @@ -876,6 +876,54 @@ describe('WorkspaceManager tool versions', () => { }, ); }); + + it('chooses the source control provider separately for each repository', async () => { + const prepareRepositorySpy = vi + .spyOn(manager, 'prepareRepository') + .mockImplementation(async (repository) => `/workspace/${repository}`); + + await manager.prepareEnvironmentRepositories( + { + name: 'Mixed Providers', + repositories: [ + { repository: 'acme/github-app' }, + { repository: 'acme/gitlab-app' }, + ], + }, + false, + false, + undefined, + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/gitlab-app': 'gitlab' }, + }, + ); + + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/github-app', + undefined, + undefined, + false, + false, + { + sourceControlProvider: 'github', + setDefaultRemote: false, + toolVersionsConfig: undefined, + }, + ); + expect(prepareRepositorySpy).toHaveBeenCalledWith( + 'acme/gitlab-app', + undefined, + undefined, + false, + false, + { + sourceControlProvider: 'gitlab', + setDefaultRemote: false, + toolVersionsConfig: undefined, + }, + ); + }); }); describe('installWorkspaceToolVersions', () => { diff --git a/apps/worker/src/workspace/workspace-manager.ts b/apps/worker/src/workspace/workspace-manager.ts index daa4074a3..5f1757899 100644 --- a/apps/worker/src/workspace/workspace-manager.ts +++ b/apps/worker/src/workspace/workspace-manager.ts @@ -766,7 +766,9 @@ export class WorkspaceManager { sourceBranch?: string; sourceSha?: string; }, - options: Pick = {}, + options: Pick & { + repositoryProviders?: Record; + } = {}, ): Promise<{ repoPaths: Record; }> { @@ -794,7 +796,9 @@ export class WorkspaceManager { preserveGitState, cleanupLegacyPaths, { - sourceControlProvider: options.sourceControlProvider, + sourceControlProvider: + options.repositoryProviders?.[repoConfig.repository] ?? + options.sourceControlProvider, toolVersionsConfig: repoConfig.tool_versions, setDefaultRemote: false, }, diff --git a/packages/ado/src/api.ts b/packages/ado/src/api.ts index 3aa16975b..f7f0fce6f 100644 --- a/packages/ado/src/api.ts +++ b/packages/ado/src/api.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ALL_REPOSITORIES, buildRepositoryCloneUrl, + filterRepositoryNamesForSourceControlProvider, stripCloneUrlUserInfo, type SourceControlProvider, } from '@roomote/types'; @@ -1482,6 +1483,14 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { async function resolveAdoRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { + const filterForAdo = (repositoryNames: string[]) => { + return filterRepositoryNamesForSourceControlProvider( + taskRun.payload, + repositoryNames, + ADO_PROVIDER, + ); + }; + if (taskRun.payload.environmentId) { const environment = await db.query.environments.findFirst({ where: eq(environments.id, taskRun.payload.environmentId), @@ -1493,9 +1502,11 @@ async function resolveAdoRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterForAdo( + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -1506,7 +1517,7 @@ async function resolveAdoRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterForAdo(selectedRepositories); } } diff --git a/packages/bitbucket/src/api.ts b/packages/bitbucket/src/api.ts index ca757695d..c16d4f90d 100644 --- a/packages/bitbucket/src/api.ts +++ b/packages/bitbucket/src/api.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ALL_REPOSITORIES, buildRepositoryCloneUrl, + filterRepositoryNamesForSourceControlProvider, type SourceControlProvider, } from '@roomote/types'; import { @@ -754,6 +755,14 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { async function resolveBitbucketRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { + const filterForBitbucket = (repositoryNames: string[]) => { + return filterRepositoryNamesForSourceControlProvider( + taskRun.payload, + repositoryNames, + BITBUCKET_PROVIDER, + ); + }; + if (taskRun.payload.environmentId) { const environment = await db.query.environments.findFirst({ where: eq(environments.id, taskRun.payload.environmentId), @@ -765,9 +774,11 @@ async function resolveBitbucketRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterForBitbucket( + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -778,7 +789,7 @@ async function resolveBitbucketRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterForBitbucket(selectedRepositories); } } 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 305907d9b..a4bfbc08b 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -15,6 +15,7 @@ vi.mock('../llm-task-title', async (importOriginal) => ({ })); import { + ALL_REPOSITORIES, type TaskSpec, type SnapshotResumeTask, RunStatus, @@ -805,6 +806,10 @@ describe('enqueueTask snapshot resume', () => { description: 'Do the thing', sourceControlProvider: 'ado', sourceControlHost: 'dev.azure.com', + repositoryProviders: { + 'roomote/Test ADO/Test ADO': 'ado', + 'group/web': 'gitlab', + }, }, }), initiator: { kind: 'user', userId }, @@ -833,10 +838,15 @@ describe('enqueueTask snapshot resume', () => { const resumePayload = resumeRun.payload as { sourceControlProvider?: string; sourceControlHost?: string; + repositoryProviders?: Record; }; expect(resumePayload.sourceControlProvider).toBe('ado'); expect(resumePayload.sourceControlHost).toBe('dev.azure.com'); + expect(resumePayload.repositoryProviders).toEqual({ + 'roomote/Test ADO/Test ADO': 'ado', + 'group/web': 'gitlab', + }); }); it('walks the resume chain for stamps when the source run predates inheritance', async () => { @@ -909,6 +919,10 @@ describe('enqueueTask snapshot resume', () => { description: 'Do the thing', sourceControlProvider: 'ado', sourceControlHost: 'dev.azure.com', + repositoryProviders: { + 'acme/widgets': 'ado', + 'group/web': 'gitlab', + }, }, }), initiator: { kind: 'user', userId }, @@ -943,6 +957,13 @@ describe('enqueueTask snapshot resume', () => { expect( (resumeRun.payload as { sourceControlHost?: string }).sourceControlHost, ).toBeUndefined(); + expect( + ( + resumeRun.payload as { + repositoryProviders?: Record; + } + ).repositoryProviders, + ).toBeUndefined(); }); it('rejects a resume without a source run id', async () => { @@ -1299,6 +1320,10 @@ describe('enqueueTask source-control provider stamping', () => { const environment = await environmentFactory.create({ createdByUserId: userId, + config: { + name: 'GitLab environment', + repositories: [{ repository: 'group/project' }], + }, }); createdEnvironmentIds.push(environment.id); @@ -1332,6 +1357,278 @@ describe('enqueueTask source-control provider stamping', () => { (persistedRun!.payload as { sourceControlProvider?: string }) .sourceControlProvider, ).toBe('gitlab'); + expect( + (persistedRun!.payload as { repositoryProviders?: unknown }) + .repositoryProviders, + ).toBeUndefined(); + }); + + it('stamps a provider map and the first repository provider for a mixed environment', async () => { + const userId = await createUser(); + const primaryRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitlab', + linkedByUserId: userId, + fullName: 'group/mixed-api', + isActive: true, + }); + const secondaryRepository = await repositoryFactory.create({ + sourceControlProvider: 'ado', + linkedByUserId: userId, + fullName: 'acme/Platform/mixed-web', + isActive: true, + }); + createdRepositoryIds.push(primaryRepository.id, secondaryRepository.id); + + const environment = await environmentFactory.create({ + createdByUserId: userId, + config: { + name: 'Mixed environment', + repositories: [ + { repository: 'group/mixed-api' }, + { repository: 'acme/Platform/mixed-web' }, + ], + }, + }); + createdEnvironmentIds.push(environment.id); + + await db.insert(environmentRepositoryMappings).values([ + { + environmentId: environment.id, + repositoryId: primaryRepository.id, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + { + environmentId: environment.id, + repositoryId: secondaryRepository.id, + createdAt: new Date('2026-01-01T00:00:01.000Z'), + }, + ]); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'group/mixed-api', + environmentId: environment.id, + description: 'Work in the mixed environment', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload).toMatchObject({ + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/mixed-api': 'gitlab', + 'acme/Platform/mixed-web': 'ado', + }, + }); + expect(run.payload.sourceControlHost).toBeUndefined(); + }); + + it('stamps mixed selected repositories in selection order', async () => { + const userId = await createUser(); + const gitLabRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitlab', + linkedByUserId: userId, + fullName: 'group/selected-web', + isActive: true, + }); + const adoRepository = await repositoryFactory.create({ + sourceControlProvider: 'ado', + linkedByUserId: userId, + fullName: 'acme/Platform/selected-api', + isActive: true, + }); + createdRepositoryIds.push(gitLabRepository.id, adoRepository.id); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: [ + 'group/selected-web', + 'acme/Platform/selected-api', + ], + description: 'Work across selected providers', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload).toMatchObject({ + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/selected-web': 'gitlab', + 'acme/Platform/selected-api': 'ado', + }, + }); + }); + + it('re-stamps a PR launch after auto-resolving a mixed environment', async () => { + const userId = await createUser(); + const primaryRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitlab', + linkedByUserId: userId, + fullName: 'group/pr-context', + isActive: true, + }); + const pullRequestRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + linkedByUserId: userId, + fullName: 'octo/pr-target', + isActive: true, + }); + createdRepositoryIds.push(primaryRepository.id, pullRequestRepository.id); + + const environment = await environmentFactory.create({ + createdByUserId: userId, + config: { + name: 'PR mixed environment', + repositories: [ + { repository: 'group/pr-context' }, + { repository: 'octo/pr-target' }, + ], + }, + }); + createdEnvironmentIds.push(environment.id); + await db.insert(environmentRepositoryMappings).values([ + { + environmentId: environment.id, + repositoryId: primaryRepository.id, + }, + { + environmentId: environment.id, + repositoryId: pullRequestRepository.id, + }, + ]); + + const run = await launchFresh({ + task: { + type: TaskPayloadKind.GithubPrReview, + requestedWorkKindDecision: explicitWorkKind, + payload: { + repo: 'octo/pr-target', + prNumber: 1082, + prTitle: 'Support mixed environments', + prUrl: 'https://github.com/octo/pr-target/pull/1082', + headSha: 'a'.repeat(40), + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + }, + } as Extract, + initiator: { kind: 'automation', key: 'review_code' }, + workflow: 'pr_review', + surface: 'github', + trigger: 'webhook', + prLinkage: { + provider: 'gitea', + repository: 'octo/pr-target', + prNumber: 1082, + prUrl: 'https://github.com/octo/pr-target/pull/1082', + prTitle: 'Support mixed environments', + prSha: 'a'.repeat(40), + }, + }); + + expect(run.payload).toMatchObject({ + environmentId: environment.id, + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/pr-context': 'gitlab', + 'octo/pr-target': 'gitea', + }, + }); + expect(run.payload.sourceControlHost).toBeUndefined(); + }); + + it('recomputes mixed-provider stamps for a failed-start relaunch', async () => { + const userId = await createUser(); + const primaryRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitlab', + linkedByUserId: userId, + fullName: 'group/relaunch-api', + isActive: true, + }); + const secondaryRepository = await repositoryFactory.create({ + sourceControlProvider: 'ado', + linkedByUserId: userId, + fullName: 'acme/Platform/relaunch-web', + isActive: true, + }); + createdRepositoryIds.push(primaryRepository.id, secondaryRepository.id); + + const environment = await environmentFactory.create({ + createdByUserId: userId, + config: { + name: 'Relaunch environment', + repositories: [ + { repository: 'group/relaunch-api' }, + { repository: 'acme/Platform/relaunch-web' }, + ], + }, + }); + createdEnvironmentIds.push(environment.id); + await db.insert(environmentRepositoryMappings).values([ + { + environmentId: environment.id, + repositoryId: primaryRepository.id, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + { + environmentId: environment.id, + repositoryId: secondaryRepository.id, + createdAt: new Date('2026-01-02T00:00:01.000Z'), + }, + ]); + + const failedRun = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: 'group/relaunch-api', + environmentId: environment.id, + description: 'Work in the mixed environment', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + const legacyPayload = { ...failedRun.payload }; + delete legacyPayload.sourceControlProvider; + delete legacyPayload.repositoryProviders; + await db + .update(taskRuns) + .set({ + payload: legacyPayload, + status: RunStatus.Failed, + error: 'Failed to create source control token', + completedAt: new Date(), + }) + .where(eq(taskRuns.id, failedRun.id)); + await db + .update(tasks) + .set({ state: 'failed' }) + .where(eq(tasks.id, failedRun.taskId)); + + const relaunchRun = await enqueueTaskRelaunch( + { sourceRunId: failedRun.id, actingUserId: userId }, + { enqueue: false }, + ); + + expect(relaunchRun.payload).toMatchObject({ + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/relaunch-api': 'gitlab', + 'acme/Platform/relaunch-web': 'ado', + }, + }); }); }); diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 82d12c813..52e12e5ff 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -64,7 +64,7 @@ import { lt, recordSnapshotResumeEvent, resolveDefaultComputeProvider, - resolveWorkspaceSourceControlProvider, + resolveWorkspaceRepositoryProviders, sql, } from '@roomote/db/server'; import { type Redis, getRedis } from '@roomote/redis'; @@ -1346,26 +1346,7 @@ async function enqueueFreshLaunch( ]); const workspace = resolveTaskWorkspace(task.payload); - // Stamp the source-control provider once at launch when the caller omitted - // it. Downstream consumers (token minting, worker repository resolution) - // otherwise fall back to the GitHub default, which breaks GitLab/Gitea/ADO - // deployments for any launch surface that forgot the stamp. The shared - // resolver covers every workspace shape (repository, repository_set, - // environment, all_repositories) so environment- and all-repositories-based - // launches (e.g. Linear) get stamped too. - if ( - !('sourceControlProvider' in task.payload) || - !task.payload.sourceControlProvider - ) { - const resolvedProvider = await resolveWorkspaceSourceControlProvider( - db, - workspace, - ); - - if (resolvedProvider) { - task.payload.sourceControlProvider = resolvedProvider; - } - } + await stampWorkspaceSourceControlProviders(task.payload, workspace); if ( PR_TASK_TYPES.has(task.type) && @@ -1379,6 +1360,10 @@ async function enqueueFreshLaunch( if (envId) { task.payload.environmentId = envId; + await stampWorkspaceSourceControlProviders(task.payload, { + type: 'environment', + environmentId: envId, + }); console.log( `[enqueueTask] Auto-resolved environment ${envId} for ${workspace.repo}`, @@ -1808,6 +1793,37 @@ function reconstructFreshTaskFromFailedRun(sourceRun: TaskRun): FreshTask { } as FreshTask; } +async function stampWorkspaceSourceControlProviders( + payload: FreshTask['payload'], + workspace: ReturnType, +): Promise { + const repositoryProviders = await resolveWorkspaceRepositoryProviders( + db, + workspace, + ); + const providers = Object.values(repositoryProviders); + const spansProviders = new Set(providers).size > 1; + + if (spansProviders) { + payload.repositoryProviders = repositoryProviders; + } + + const primaryProvider = providers[0]; + if ( + primaryProvider && + (spansProviders || payload.sourceControlProvider === undefined) + ) { + if ( + spansProviders || + (payload.sourceControlProvider !== undefined && + payload.sourceControlProvider !== primaryProvider) + ) { + payload.sourceControlHost = undefined; + } + payload.sourceControlProvider = primaryProvider; + } +} + /** * Re-enqueues a failed first-start run on the same task (new run row, same task * id). Used when environment creation fails before the session can start and the @@ -1875,19 +1891,7 @@ export async function enqueueTaskRelaunch( const workspace = resolveTaskWorkspace(task.payload); - if ( - !('sourceControlProvider' in task.payload) || - !task.payload.sourceControlProvider - ) { - const resolvedProvider = await resolveWorkspaceSourceControlProvider( - db, - workspace, - ); - - if (resolvedProvider) { - task.payload.sourceControlProvider = resolvedProvider; - } - } + await stampWorkspaceSourceControlProviders(task.payload, workspace); const { initialPaths } = await resolveEnvironmentContext(task); const resolvedHarness = await resolveRequestedHarness(task); @@ -2024,23 +2028,40 @@ function inheritSnapshotResumeSourceControlStamps( sourcePayload: unknown, ): void { const source = (sourcePayload ?? {}) as { + repositoryProviders?: unknown; sourceControlProvider?: unknown; sourceControlHost?: unknown; }; const inheritsProvider = payload.sourceControlProvider === undefined; + const provider = sourceControlProviderSchema.safeParse( + source.sourceControlProvider, + ); if (inheritsProvider) { - const provider = sourceControlProviderSchema.safeParse( - source.sourceControlProvider, - ); - if (provider.success) { payload.sourceControlProvider = provider.data; } } - if (inheritsProvider && payload.sourceControlHost === undefined) { + const usesSourceProvider = + provider.success && payload.sourceControlProvider === provider.data; + + if (usesSourceProvider && payload.repositoryProviders === undefined) { + const repositoryProviders = z + .record(sourceControlProviderSchema) + .safeParse(source.repositoryProviders); + + if (repositoryProviders.success) { + payload.repositoryProviders = repositoryProviders.data; + } + } + + if ( + inheritsProvider && + provider.success && + payload.sourceControlHost === undefined + ) { const host = typeof source.sourceControlHost === 'string' ? source.sourceControlHost.trim() diff --git a/packages/db/src/lib/__tests__/repositories.test.ts b/packages/db/src/lib/__tests__/repositories.test.ts index f2a643310..9195481d0 100644 --- a/packages/db/src/lib/__tests__/repositories.test.ts +++ b/packages/db/src/lib/__tests__/repositories.test.ts @@ -94,14 +94,14 @@ describe('resolveRepositorySelectionByIds', () => { repositoryIds: [repoB.id, repoA.id], }); - expect(result.normalizedRepositoryIds).toEqual([repoA.id, repoB.id]); + expect(result.normalizedRepositoryIds).toEqual([repoB.id, repoA.id]); expect(result.selectedRepositories).toEqual([ { id: repoB.id, fullName: repoB.fullName }, { id: repoA.id, fullName: repoA.fullName }, ]); expect(result.workspacePayload).toEqual({ repo: '__all_repositories__', - selectedRepositories: [repoA.fullName, repoB.fullName], + selectedRepositories: [repoB.fullName, repoA.fullName], }); }); @@ -116,7 +116,6 @@ describe('resolveRepositorySelectionByIds', () => { expect(result.normalizedRepositoryIds).toEqual([repoA.id]); expect(result.selectedRepositories).toEqual([ { id: repoA.id, fullName: repoA.fullName }, - { id: repoA.id, fullName: repoA.fullName }, ]); expect(result.workspacePayload).toEqual({ repo: repoA.fullName }); }); 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 84b26fb59..3da2ceed5 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -1,25 +1,71 @@ // pnpm --filter @roomote/db exec vitest run src/lib/__tests__/source-control-provider.test.ts import type { DatabaseOrTransaction } from '../../db'; -import { resolveWorkspaceSourceControlProvider } from '../source-control-provider'; +import { + resolveWorkspaceRepositoryProviders, + resolveWorkspaceSourceControlProvider, +} from '../source-control-provider'; const mockWhere = vi.fn(); +const mockOrderBy = vi.fn(); +let mockRows: Array<{ + fullName: string; + host: string | null; + isActive?: boolean; + sourceControlProvider: 'github' | 'gitlab' | 'gitea' | 'ado' | 'bitbucket'; +}> = []; +let mockEnvironmentRepositories: string[] = []; + +const query = { + innerJoin: vi.fn(() => query), + where: vi.fn((...args: unknown[]) => { + mockWhere(...args); + return query; + }), + orderBy: vi.fn((...args: unknown[]) => { + mockOrderBy(...args); + return Promise.resolve(mockRows); + }), + then: ( + resolve: (value: typeof mockRows) => unknown, + reject: (reason: unknown) => unknown, + ) => Promise.resolve(mockRows).then(resolve, reject), +}; const dbOrTx = { select: vi.fn(() => ({ - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ where: mockWhere })), - where: mockWhere, - })), + from: vi.fn(() => query), })), + query: { + environments: { + findFirst: vi.fn(async () => ({ + config: { + name: 'Test environment', + repositories: mockEnvironmentRepositories.map((repository) => ({ + repository, + })), + }, + })), + }, + }, } as unknown as DatabaseOrTransaction; describe('resolveWorkspaceSourceControlProvider', () => { beforeEach(() => { + mockRows = []; + mockEnvironmentRepositories = []; mockWhere.mockReset(); + mockOrderBy.mockReset(); }); it('resolves the provider from an environment with a single-provider mapping', async () => { - mockWhere.mockResolvedValue([{ sourceControlProvider: 'ado' }]); + mockEnvironmentRepositories = ['acme/Platform/backend']; + mockRows = [ + { + fullName: 'acme/Platform/backend', + host: 'dev.azure.com', + sourceControlProvider: 'ado', + }, + ]; await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { @@ -29,8 +75,40 @@ describe('resolveWorkspaceSourceControlProvider', () => { ).resolves.toBe('ado'); }); + it('orders environment providers by the declared repository config', async () => { + mockEnvironmentRepositories = ['group/web', 'octo/api']; + mockRows = [ + { + fullName: 'octo/api', + host: 'github.com', + sourceControlProvider: 'github', + }, + { + fullName: 'group/web', + host: 'gitlab.com', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'environment', + environmentId: 'env-1', + }), + ).resolves.toEqual({ + 'group/web': 'gitlab', + 'octo/api': 'github', + }); + }); + it('resolves the provider from a single repository workspace', async () => { - mockWhere.mockResolvedValue([{ sourceControlProvider: 'gitlab' }]); + mockRows = [ + { + fullName: 'group/subgroup/repo', + host: 'gitlab.com', + sourceControlProvider: 'gitlab', + }, + ]; await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { @@ -41,10 +119,18 @@ describe('resolveWorkspaceSourceControlProvider', () => { }); it('resolves the provider from a repository set sharing one provider', async () => { - mockWhere.mockResolvedValue([ - { sourceControlProvider: 'ado' }, - { sourceControlProvider: 'ado' }, - ]); + mockRows = [ + { + fullName: 'acme/Platform/frontend', + host: 'dev.azure.com', + sourceControlProvider: 'ado', + }, + { + fullName: 'acme/Platform/backend', + host: 'dev.azure.com', + sourceControlProvider: 'ado', + }, + ]; await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { @@ -55,10 +141,18 @@ describe('resolveWorkspaceSourceControlProvider', () => { }); it('returns undefined when the workspace spans multiple providers', async () => { - mockWhere.mockResolvedValue([ - { sourceControlProvider: 'ado' }, - { sourceControlProvider: 'github' }, - ]); + mockRows = [ + { + fullName: 'acme/Platform/backend', + host: 'dev.azure.com', + sourceControlProvider: 'ado', + }, + { + fullName: 'octo/web', + host: 'github.com', + sourceControlProvider: 'github', + }, + ]; await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { @@ -68,8 +162,6 @@ describe('resolveWorkspaceSourceControlProvider', () => { }); it('returns undefined when no repository rows match', async () => { - mockWhere.mockResolvedValue([]); - await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { type: 'repository', @@ -87,4 +179,121 @@ describe('resolveWorkspaceSourceControlProvider', () => { ).resolves.toBeUndefined(); expect(mockWhere).not.toHaveBeenCalled(); }); + + it('returns a mixed-provider map in repository workspace order', async () => { + mockRows = [ + { + fullName: 'group/web', + host: 'gitlab.com', + sourceControlProvider: 'gitlab', + }, + { + fullName: 'octo/api', + host: 'github.com', + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository_set', + repositories: ['octo/api', 'group/web'], + }), + ).resolves.toEqual({ + 'octo/api': 'github', + 'group/web': 'gitlab', + }); + }); + + it('uses sourceControlHost to disambiguate same-name repository rows', async () => { + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.alpha.example', + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/project', + host: 'git.example.com', + sourceControlProvider: 'gitea', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + sourceControlHost: 'git.example.com', + }), + ).resolves.toEqual({ 'group/project': 'gitea' }); + }); + + it('prefers active rows over stale inactive rows with the same name', async () => { + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.example.com', + isActive: false, + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/project', + host: 'github.com', + isActive: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + }), + ).resolves.toEqual({ 'group/project': 'github' }); + }); + + it('falls back to inactive rows when no active row matches', async () => { + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.example.com', + isActive: false, + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + }), + ).resolves.toEqual({ 'group/project': 'gitlab' }); + }); + + it('omits and logs ambiguous same-name repository rows', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + mockRows = [ + { + fullName: 'group/project', + host: 'gitlab.alpha.example', + sourceControlProvider: 'gitlab', + }, + { + fullName: 'group/project', + host: 'gitlab.beta.example', + sourceControlProvider: 'gitlab', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'repository', + repo: 'group/project', + }), + ).resolves.toEqual({}); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Omitting ambiguous repository group/project'), + ); + warn.mockRestore(); + }); }); diff --git a/packages/db/src/lib/repositories.ts b/packages/db/src/lib/repositories.ts index 60d09b45a..437eeaab2 100644 --- a/packages/db/src/lib/repositories.ts +++ b/packages/db/src/lib/repositories.ts @@ -30,18 +30,19 @@ export async function resolveRepositorySelectionByIds(params: { } const executor = params.executor ?? db; + const repositoryIds = [...new Set(params.repositoryIds)]; const rows = await executor .select({ id: repositories.id, fullName: repositories.fullName, }) .from(repositories) - .where(inArray(repositories.id, params.repositoryIds)); + .where(inArray(repositories.id, repositoryIds)); const rowsById = new Map( rows.map((repository) => [repository.id, repository]), ); - const selectedRepositories = params.repositoryIds + const selectedRepositories = repositoryIds .map((repositoryId) => rowsById.get(repositoryId)) .filter( ( diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index 73e256356..9a1e22eb1 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -1,8 +1,12 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, asc, eq, inArray } from 'drizzle-orm'; import type { TaskWorkspace, SourceControlProvider } from '@roomote/types'; import type { DatabaseOrTransaction } from '../db'; -import { environmentRepositoryMappings, repositories } from '../schema'; +import { + environmentRepositoryMappings, + environments, + repositories, +} from '../schema'; /** * Collapse a set of repository providers to the single provider they all @@ -17,28 +21,95 @@ function toSingleProvider( return unique.length === 1 ? unique[0] : undefined; } -async function resolveProviderByFullNames( +type RepositoryProviderRow = { + fullName: string; + host: string | null; + isActive?: boolean; + sourceControlProvider: SourceControlProvider; +}; + +function toRepositoryProviderMap( + rows: RepositoryProviderRow[], + repositoryOrder: string[], + sourceControlHost?: string, +): Record { + const rowsByFullName = new Map(); + + for (const row of rows) { + const matches = rowsByFullName.get(row.fullName) ?? []; + matches.push(row); + rowsByFullName.set(row.fullName, matches); + } + + const result: Record = {}; + + 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 = + candidates.length > 1 && sourceControlHost + ? candidates.filter((row) => row.host === sourceControlHost) + : candidates; + + if (candidates.length > 1 && hostMatches.length !== 1) { + console.warn( + `[resolveWorkspaceRepositoryProviders] Omitting ambiguous repository ${fullName}; matched ${candidates.length} candidate rows.`, + ); + continue; + } + + const match = hostMatches[0]; + if (match) { + result[fullName] = match.sourceControlProvider; + } + } + + return result; +} + +async function resolveProvidersByFullNames( dbOrTx: DatabaseOrTransaction, fullNames: string[], -): Promise { + sourceControlHost?: string, +): Promise> { if (fullNames.length === 0) { - return undefined; + return {}; } const rows = await dbOrTx - .select({ sourceControlProvider: repositories.sourceControlProvider }) + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + sourceControlProvider: repositories.sourceControlProvider, + }) .from(repositories) .where(inArray(repositories.fullName, fullNames)); - return toSingleProvider(rows.map((row) => row.sourceControlProvider)); + return toRepositoryProviderMap(rows, fullNames, sourceControlHost); } -async function resolveEnvironmentProvider( +async function resolveEnvironmentProviders( dbOrTx: DatabaseOrTransaction, environmentId: string, -): Promise { +): Promise> { + const environment = await dbOrTx.query.environments.findFirst({ + where: eq(environments.id, environmentId), + columns: { config: true }, + }); + + if (!environment) { + return {}; + } + const rows = await dbOrTx - .select({ sourceControlProvider: repositories.sourceControlProvider }) + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + sourceControlProvider: repositories.sourceControlProvider, + }) .from(environmentRepositoryMappings) .innerJoin( repositories, @@ -49,20 +120,61 @@ async function resolveEnvironmentProvider( eq(environmentRepositoryMappings.environmentId, environmentId), eq(repositories.isActive, true), ), + ) + .orderBy( + asc(environmentRepositoryMappings.createdAt), + asc(environmentRepositoryMappings.id), ); - return toSingleProvider(rows.map((row) => row.sourceControlProvider)); + return toRepositoryProviderMap( + rows, + environment.config.repositories.map((repository) => repository.repository), + ); } -async function resolveAllRepositoriesProvider( +async function resolveAllRepositoriesProviders( dbOrTx: DatabaseOrTransaction, -): Promise { +): Promise> { const rows = await dbOrTx - .select({ sourceControlProvider: repositories.sourceControlProvider }) + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + sourceControlProvider: repositories.sourceControlProvider, + }) .from(repositories) - .where(eq(repositories.isActive, true)); + .where(eq(repositories.isActive, true)) + .orderBy(asc(repositories.createdAt), asc(repositories.id)); - return toSingleProvider(rows.map((row) => row.sourceControlProvider)); + return toRepositoryProviderMap( + rows, + rows.map((row) => row.fullName), + ); +} + +/** Resolve repository full names to providers in workspace order. */ +export async function resolveWorkspaceRepositoryProviders( + dbOrTx: DatabaseOrTransaction, + workspace: TaskWorkspace, +): Promise> { + switch (workspace.type) { + case 'repository': + return resolveProvidersByFullNames( + dbOrTx, + [workspace.repo], + workspace.sourceControlHost, + ); + case 'repository_set': + return resolveProvidersByFullNames( + dbOrTx, + workspace.repositories, + workspace.sourceControlHost, + ); + case 'environment': + return resolveEnvironmentProviders(dbOrTx, workspace.environmentId); + case 'all_repositories': + return resolveAllRepositoriesProviders(dbOrTx); + } } /** @@ -71,23 +183,17 @@ async function resolveAllRepositoriesProvider( * every workspace shape (single repo, repo set, environment, all repositories). * * Returns `undefined` when the provider is ambiguous (spans multiple providers) - * or unknown (no matching repositories). This never throws — an unresolved - * provider means the caller should leave the payload unstamped and let the - * downstream GitHub default apply. The web launch-validation path wraps this - * resolver to add its own throw-on-multi-provider behavior. + * or unknown (no matching repositories). This never throws — callers that + * require a resolved provider validate the returned repository map before + * enqueue, while legacy callers may leave the scalar provider unstamped. */ export async function resolveWorkspaceSourceControlProvider( dbOrTx: DatabaseOrTransaction, workspace: TaskWorkspace, ): Promise { - switch (workspace.type) { - case 'repository': - return resolveProviderByFullNames(dbOrTx, [workspace.repo]); - case 'repository_set': - return resolveProviderByFullNames(dbOrTx, workspace.repositories); - case 'environment': - return resolveEnvironmentProvider(dbOrTx, workspace.environmentId); - case 'all_repositories': - return resolveAllRepositoriesProvider(dbOrTx); - } + const repositoryProviders = await resolveWorkspaceRepositoryProviders( + dbOrTx, + workspace, + ); + return toSingleProvider(Object.values(repositoryProviders)); } diff --git a/packages/gitea/src/api.ts b/packages/gitea/src/api.ts index 31410af03..3d462bc8a 100644 --- a/packages/gitea/src/api.ts +++ b/packages/gitea/src/api.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { ALL_REPOSITORIES, buildRepositoryCloneUrl, + filterRepositoryNamesForSourceControlProvider, type SourceControlProvider, } from '@roomote/types'; import { @@ -481,6 +482,17 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { return [...new Set(repositoryNames.filter(Boolean))]; } +function filterRepositorySelectionForGitea( + taskRun: TaskRun, + repositoryNames: string[], +): string[] { + return filterRepositoryNamesForSourceControlProvider( + taskRun.payload, + repositoryNames, + GITEA_PROVIDER, + ); +} + async function resolveGiteaRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { @@ -495,9 +507,12 @@ async function resolveGiteaRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterRepositorySelectionForGitea( + taskRun, + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -508,7 +523,7 @@ async function resolveGiteaRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterRepositorySelectionForGitea(taskRun, selectedRepositories); } } diff --git a/packages/github/src/__tests__/task-run-token.test.ts b/packages/github/src/__tests__/task-run-token.test.ts index a9cc2647f..45a12225f 100644 --- a/packages/github/src/__tests__/task-run-token.test.ts +++ b/packages/github/src/__tests__/task-run-token.test.ts @@ -42,6 +42,7 @@ vi.mock('@roomote/db/server', () => ({ repositories: { fullName: 'repositories.fullName', isActive: 'repositories.isActive', + sourceControlProvider: 'repositories.sourceControlProvider', }, })); @@ -123,6 +124,62 @@ describe('createTaskRunGitHubToken', () => { }); }); + it('ignores selected repositories mapped to another provider', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'ExampleOrg/example-backend', + installationId: 'install-exampleorg', + githubRepoId: 101, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: 'ExampleOrg/example-backend', + selectedRepositories: ['ExampleOrg/example-backend', 'group/project'], + repositoryProviders: { + 'ExampleOrg/example-backend': 'github', + 'group/project': 'gitlab', + }, + } as TaskRun['payload']), + ), + ).resolves.toBe('ghs_test_token'); + + expect(mockCreateGitHubToken).toHaveBeenCalledWith({ + type: 'installationId', + installationId: 'install-exampleorg', + repositoryIds: [101], + }); + }); + + it('ignores selected repository names omitted from a provider map', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'ExampleOrg/example-backend', + installationId: 'install-exampleorg', + githubRepoId: 101, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: '__all_repositories__', + selectedRepositories: [ + 'ExampleOrg/example-backend', + 'group/project', + 'unknown/repository', + ], + repositoryProviders: { + 'ExampleOrg/example-backend': 'github', + 'group/project': 'gitlab', + }, + } as TaskRun['payload']), + ), + ).resolves.toBe('ghs_test_token'); + }); + it('uses the environment repositories installation for environment tasks', async () => { mockFindEnvironmentFirst.mockResolvedValue({ id: '14f1f7c4-b126-4b3f-a6a8-e37f7d299f4d', diff --git a/packages/github/src/api.ts b/packages/github/src/api.ts index e4f4ed76c..8b286d643 100644 --- a/packages/github/src/api.ts +++ b/packages/github/src/api.ts @@ -7,6 +7,7 @@ import pMap from 'p-map'; import { createGitHubToken, resolveGitHubAppCredentials } from '@roomote/auth'; import { DEFAULT_SOURCE_CONTROL_PROVIDER, + filterRepositoryNamesForSourceControlProvider, normalizePemEnvValue, } from '@roomote/types'; import { @@ -43,7 +44,15 @@ async function createTokenForRepositoryNames({ missingMessagePrefix: string; spanningMessagePrefix: string; }): Promise { - const uniqueRepositoryNames = [...new Set(repositoryNames.filter(Boolean))]; + const uniqueRepositoryNames = [ + ...new Set( + filterRepositoryNamesForSourceControlProvider( + taskRun.payload, + repositoryNames.filter(Boolean), + DEFAULT_SOURCE_CONTROL_PROVIDER, + ), + ), + ]; const selectedRepoRows = await db.query.repositories.findMany({ where: and( diff --git a/packages/gitlab/src/__tests__/api.test.ts b/packages/gitlab/src/__tests__/api.test.ts index 454ae20a4..4f6617f88 100644 --- a/packages/gitlab/src/__tests__/api.test.ts +++ b/packages/gitlab/src/__tests__/api.test.ts @@ -530,6 +530,96 @@ describe('createTaskRunScopedGitLabTokens', () => { ); }); + it('ignores selected repositories mapped to another provider', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 999, + token: 'glptt_repo_scoped', + username: 'oauth2', + }), + { status: 201 }, + ), + ); + + const result = await createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: 'group/project', + selectedRepositories: ['group/project', 'ExampleOrg/example-backend'], + repositoryProviders: { + 'group/project': 'gitlab', + 'ExampleOrg/example-backend': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']), + { fetchImpl: fetchMock }, + ); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]?.repositoryFullName).toBe('group/project'); + }); + + it('uses mapped GitLab repositories as the scope for all-repositories tasks', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 999, + token: 'glptt_repo_scoped', + username: 'oauth2', + }), + { status: 201 }, + ), + ); + + const result = await createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: '__all_repositories__', + sourceControlProvider: 'github', + repositoryProviders: { + 'group/project': 'gitlab', + 'ExampleOrg/example-backend': 'github', + }, + description: 'Work across all mapped repositories', + } as TaskRun['payload']), + { fetchImpl: fetchMock }, + ); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]?.repositoryFullName).toBe('group/project'); + }); + + it('ignores selected repository names omitted from a provider map', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 999, + token: 'glptt_repo_scoped', + username: 'oauth2', + }), + { status: 201 }, + ), + ); + const result = await createTaskRunScopedGitLabTokens( + makeTaskRun({ + repo: 'group/project', + selectedRepositories: [ + 'group/project', + 'ExampleOrg/example-backend', + 'unknown/repository', + ], + repositoryProviders: { + 'group/project': 'gitlab', + 'ExampleOrg/example-backend': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']), + { fetchImpl: fetchMock }, + ); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]?.repositoryFullName).toBe('group/project'); + }); + it('mints scoped tokens against a self-managed GITLAB_BASE_URL with the self-managed credential host', async () => { process.env.GITLAB_BASE_URL = 'https://gitlab.example.com'; diff --git a/packages/gitlab/src/api.ts b/packages/gitlab/src/api.ts index f35f6ad3d..08e9d8638 100644 --- a/packages/gitlab/src/api.ts +++ b/packages/gitlab/src/api.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { ALL_REPOSITORIES, buildRepositoryCloneUrl, + filterRepositoryNamesForSourceControlProvider, + resolveRepositoryProvidersFromPayload, type SourceControlProvider, } from '@roomote/types'; import { @@ -913,6 +915,17 @@ function normalizeRepositorySelection(repositoryNames: string[]): string[] { return [...new Set(repositoryNames.filter(Boolean))]; } +function filterRepositorySelectionForGitLab( + taskRun: TaskRun, + repositoryNames: string[], +): string[] { + return filterRepositoryNamesForSourceControlProvider( + taskRun.payload, + repositoryNames, + GITLAB_PROVIDER, + ); +} + async function resolveGitLabRepositoryNamesForTaskRun( taskRun: TaskRun, ): Promise { @@ -927,9 +940,12 @@ async function resolveGitLabRepositoryNamesForTaskRun( ); } - return normalizeRepositorySelection( - environment.config.repositories.map( - (repository) => repository.repository, + return filterRepositorySelectionForGitLab( + taskRun, + normalizeRepositorySelection( + environment.config.repositories.map( + (repository) => repository.repository, + ), ), ); } @@ -940,7 +956,7 @@ async function resolveGitLabRepositoryNamesForTaskRun( ); if (selectedRepositories.length > 0) { - return selectedRepositories; + return filterRepositorySelectionForGitLab(taskRun, selectedRepositories); } } @@ -948,6 +964,19 @@ async function resolveGitLabRepositoryNamesForTaskRun( return [taskRun.payload.repo]; } + const repositoryProviders = resolveRepositoryProvidersFromPayload( + taskRun.payload, + ); + if (repositoryProviders) { + const mappedRepositories = Object.entries(repositoryProviders) + .filter(([, provider]) => provider === GITLAB_PROVIDER) + .map(([repositoryName]) => repositoryName); + + if (mappedRepositories.length > 0) { + return mappedRepositories; + } + } + throw new Error( `GitLab source control jobs require an explicit repository scope for task run ${taskRun.id}.`, ); diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 1fba1362a..1c84a7056 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -255,6 +255,10 @@ export { type SourceControlPullRequestMutationInput, type SourceControlPullRequestMutationResult, } from './lib/pull-requests/source-control-pull-requests'; +export { + getPayloadRecord, + resolveSourceControlProviderForRepositoryFromPayload, +} from './lib/pull-requests/source-control-pull-request-shared'; export { readSourceControlPullRequestForTaskRun, sourceControlPullRequestReadInputSchema, diff --git a/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts b/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts index ec52a4843..8538331d7 100644 --- a/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts +++ b/packages/sdk/src/server/lib/issues/__tests__/source-control-issues.test.ts @@ -147,6 +147,61 @@ describe('manageSourceControlIssueForTaskRun', () => { }); }); + it('routes a GitLab issue in a GitHub-primary mixed task', async () => { + mockResolveRepositoryRow.mockResolvedValue({ + id: 'repo-1', + sourceControlProvider: 'gitlab', + host: null, + installationId: null, + externalRepoId: '123', + fullName: 'acme/backend', + htmlUrl: 'https://gitlab.com/acme/backend', + }); + mockResolveGitLabProviderContext.mockResolvedValue({ + projectId: '123', + token: 'server-side-token', + apiBaseUrl: 'https://gitlab.com/api/v4', + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + iid: 9, + title: 'Broken checkout', + state: 'opened', + web_url: 'https://gitlab.com/acme/backend/-/issues/9', + author: { username: 'alice' }, + labels: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + const mixedTaskRun = taskRun('github'); + mixedTaskRun.payload = { + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + sourceControlHost: 'github.com', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']; + + const result = await manageSourceControlIssueForTaskRun({ + taskRun: mixedTaskRun, + input: { + action: 'get_issue', + repositoryFullName: 'acme/backend', + issueNumber: 9, + }, + fetchImpl, + }); + + expect(mockResolveRepositoryRow).toHaveBeenCalledWith({ + provider: 'gitlab', + repositoryFullName: 'acme/backend', + host: undefined, + }); + expect(result).toMatchObject({ provider: 'gitlab', number: 9 }); + }); + it('posts a Gitea issue comment with server-resolved credentials', async () => { mockResolveRepositoryRow.mockResolvedValue({ id: 'repo-2', diff --git a/packages/sdk/src/server/lib/issues/source-control-issues.ts b/packages/sdk/src/server/lib/issues/source-control-issues.ts index 86a3322c1..a3679a1de 100644 --- a/packages/sdk/src/server/lib/issues/source-control-issues.ts +++ b/packages/sdk/src/server/lib/issues/source-control-issues.ts @@ -1,14 +1,12 @@ import { type TaskRun } from '@roomote/db/server'; -import { - getSourceControlProviderLabel, - resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, -} from '@roomote/types'; +import { getSourceControlProviderLabel } from '@roomote/types'; import { assertRepositoryInTaskRunScope, getPayloadRecord, resolveRepositoryRow, + resolveSourceControlHostForRepositoryFromPayload, + resolveSourceControlProviderForRepositoryFromPayload, type FetchImpl, } from '../pull-requests/source-control-pull-request-shared'; import { getIssueProviderOperations } from './source-control-issue-providers'; @@ -39,7 +37,10 @@ export async function manageSourceControlIssueForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payload = getPayloadRecord(taskRun.payload); - const payloadProvider = resolveSourceControlProviderFromPayload(payload); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payload, + input.repositoryFullName, + ); const provider = input.sourceControlProvider ?? payloadProvider; if (provider !== payloadProvider) { @@ -63,7 +64,10 @@ export async function manageSourceControlIssueForTaskRun({ const repository = await resolveRepositoryRow({ provider, repositoryFullName: input.repositoryFullName, - host: resolveSourceControlHostFromPayload(payload), + host: resolveSourceControlHostForRepositoryFromPayload( + payload, + input.repositoryFullName, + ), }); const ops = getIssueProviderOperations(provider); diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts index c00fe2064..a65884519 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-reads.test.ts @@ -223,7 +223,7 @@ describe('readSourceControlPullRequestForTaskRun', () => { }); }); - it('reads GitLab merge request details through the deployment token', async () => { + it('reads a GitLab merge request in a GitHub-primary mixed task', async () => { mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, externalRepoId: '101', @@ -253,14 +253,15 @@ describe('readSourceControlPullRequestForTaskRun', () => { const result = await readSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'get_pull_request', repositoryFullName: 'acme/backend', prNumber: 42, - sourceControlProvider: 'gitlab', }, fetchImpl, }); @@ -1163,15 +1164,17 @@ describe('readSourceControlPullRequestForTaskRun', () => { ).rejects.toThrow('prNumber is required for get_pull_request.'); }); - it('rejects reads whose provider does not match the task payload', async () => { + it('rejects an explicit provider that conflicts with the repository map', async () => { const fetchImpl = vi.fn(); await expect( readSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'get_pull_request', repositoryFullName: 'acme/backend', diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts index 0a75ad389..90142b817 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-shared.test.ts @@ -4,7 +4,82 @@ vi.mock('@roomote/gitlab', () => ({ isGitLabOAuthAccessToken: (token: string) => token === 'oauth-token', })); -import { buildGitLabTokenHeader } from '../source-control-pull-request-shared'; +import { + buildGitLabTokenHeader, + resolveSourceControlHostForRepositoryFromPayload, + resolveSourceControlProviderForRepositoryFromPayload, +} from '../source-control-pull-request-shared'; + +describe('resolveSourceControlProviderForRepositoryFromPayload', () => { + it('prefers the target repository provider over the task primary provider', () => { + expect( + resolveSourceControlProviderForRepositoryFromPayload( + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + 'acme/backend', + ), + ).toBe('gitlab'); + }); + + it('rejects repositories omitted from a provider map', () => { + expect(() => + resolveSourceControlProviderForRepositoryFromPayload( + { + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + 'acme/frontend', + ), + ).toThrow( + 'Repository acme/frontend is not mapped to a source control provider.', + ); + }); +}); + +describe('resolveSourceControlHostForRepositoryFromPayload', () => { + it('does not apply the primary provider host to a mapped secondary repository', () => { + expect( + resolveSourceControlHostForRepositoryFromPayload( + { + sourceControlProvider: 'github', + sourceControlHost: 'github.com', + repositoryProviders: { 'acme/backend': 'gitlab' }, + }, + 'acme/backend', + ), + ).toBeUndefined(); + }); + + it('keeps the scalar host for legacy payloads without a provider map', () => { + expect( + resolveSourceControlHostForRepositoryFromPayload( + { + sourceControlProvider: 'github', + sourceControlHost: 'github.com', + }, + 'acme/frontend', + ), + ).toBe('github.com'); + }); + + it('does not apply a scalar host to a mapped primary repository', () => { + expect( + resolveSourceControlHostForRepositoryFromPayload( + { + sourceControlProvider: 'github', + sourceControlHost: 'github.enterprise.example', + repositoryProviders: { + 'acme/frontend': 'github', + 'acme/backend': 'gitlab', + }, + }, + 'acme/frontend', + ), + ).toBeUndefined(); + }); +}); describe('buildGitLabTokenHeader', () => { it('uses the Bearer authorization header for OAuth tokens', () => { diff --git a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts index b4c1d9766..5884c1ec7 100644 --- a/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts +++ b/packages/sdk/src/server/lib/pull-requests/__tests__/source-control-pull-request-writes.test.ts @@ -128,7 +128,7 @@ describe('writeSourceControlPullRequestForTaskRun', () => { ); }); - it('replies to a GitLab discussion through the deployment token', async () => { + it('replies to a GitLab discussion in a GitHub-primary mixed task', async () => { mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, externalRepoId: '101', @@ -141,16 +141,17 @@ describe('writeSourceControlPullRequestForTaskRun', () => { const result = await writeSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'reply_to_pull_request_comment', repositoryFullName: 'acme/backend', prNumber: 42, threadId: 'abc123', body: 'Thanks, fixed.', - sourceControlProvider: 'gitlab', }, fetchImpl, }); 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 9ed53fef6..765008932 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 @@ -192,7 +192,7 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { ); }); - it('creates a GitLab merge request through the deployment token', async () => { + it('creates a GitLab merge request in a GitHub-primary mixed task', async () => { mockGetDeploymentPrAction.mockResolvedValue('create'); mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, @@ -214,9 +214,11 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { const result = await createOrUpdateSourceControlPullRequestForTaskRun({ taskRun: makeTaskRun({ - repo: 'acme/backend', - sourceControlProvider: 'gitlab', - }), + repo: 'acme/frontend', + selectedRepositories: ['acme/frontend', 'acme/backend'], + sourceControlProvider: 'github', + repositoryProviders: { 'acme/backend': 'gitlab' }, + } as unknown as TaskRun['payload']), input: { action: 'create_or_update_pull_request', repositoryFullName: 'acme/backend', @@ -226,7 +228,6 @@ describe('createOrUpdateSourceControlPullRequestForTaskRun', () => { body: 'Body', labels: ['roomote'], assignees: [], - sourceControlProvider: 'gitlab', }, fetchImpl, }); diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts index 0c6e3500c..ec71d3442 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-reads.ts @@ -4,8 +4,6 @@ import { type TaskRun } from '@roomote/db/server'; import { buildPullRequestUrl, getSourceControlProviderLabel, - resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type SourceControlProvider, } from '@roomote/types'; @@ -26,6 +24,8 @@ import { isDraftTitle, isGitLabDraft, resolveRepositoryRow, + resolveSourceControlHostForRepositoryFromPayload, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -609,9 +609,14 @@ export async function readSourceControlPullRequestForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); - const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); + const payloadHost = resolveSourceControlHostForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const provider = input.sourceControlProvider ?? payloadProvider; if (provider !== payloadProvider) { 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 3c311281a..3d17ee81a 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 @@ -10,6 +10,10 @@ import { ALL_REPOSITORIES, environmentConfigSchema, getSourceControlProviderLabel, + normalizeSourceControlProvider, + resolveRepositoryProvidersFromPayload, + resolveSourceControlHostFromPayload, + resolveSourceControlProviderFromPayload, type SourceControlProvider, } from '@roomote/types'; import { isGitLabOAuthAccessToken } from '@roomote/gitlab'; @@ -35,6 +39,49 @@ export type RepositoryRow = { htmlUrl: string; }; +export function resolveSourceControlProviderForRepositoryFromPayload( + payload: Record, + repositoryFullName: string, +): SourceControlProvider { + const repositoryProviders = resolveRepositoryProvidersFromPayload(payload); + + if (repositoryProviders) { + const repositoryProvider = repositoryProviders[repositoryFullName]; + + if (repositoryProvider === undefined) { + throw new Error( + `Repository ${repositoryFullName} is not mapped to a source control provider.`, + ); + } + + return normalizeSourceControlProvider(repositoryProvider); + } + + return resolveSourceControlProviderFromPayload(payload); +} + +export function resolveSourceControlHostForRepositoryFromPayload( + payload: Record, + repositoryFullName: string, +): string | undefined { + const repositoryProviders = resolveRepositoryProvidersFromPayload(payload); + + if (repositoryProviders?.[repositoryFullName] !== undefined) { + return undefined; + } + + const repositoryProvider = + resolveSourceControlProviderForRepositoryFromPayload( + payload, + repositoryFullName, + ); + const primaryProvider = resolveSourceControlProviderFromPayload(payload); + + return repositoryProvider === primaryProvider + ? resolveSourceControlHostFromPayload(payload) + : undefined; +} + /** * Shared provider-resolution and name/url plumbing for the provider-neutral * source-control pull-request surface. HTTP transport lives in diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts index ec93eac48..29059b0d7 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-writes.ts @@ -3,8 +3,6 @@ import { getOctokit } from '@roomote/github'; import { type TaskRun } from '@roomote/db/server'; import { getSourceControlProviderLabel, - resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type SourceControlProvider, } from '@roomote/types'; @@ -28,6 +26,8 @@ import { formatResponseBody, getPayloadRecord, resolveRepositoryRow, + resolveSourceControlHostForRepositoryFromPayload, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -233,9 +233,14 @@ export async function writeSourceControlPullRequestForTaskRun({ assertWriteInputFields(input); const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); - const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); + const payloadHost = resolveSourceControlHostForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const provider = input.sourceControlProvider ?? payloadProvider; if (provider !== payloadProvider) { 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 7563e4116..09f16d51b 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 @@ -22,8 +22,6 @@ import { getSourceControlProviderLabel, normalizePrBodyAttributionAppMention, prActions, - resolveSourceControlHostFromPayload, - resolveSourceControlProviderFromPayload, sourceControlProviderSchema, type PrAction, type SourceControlProvider, @@ -57,6 +55,8 @@ import { isDraftTitle, isGitLabDraft, resolveRepositoryRow, + resolveSourceControlHostForRepositoryFromPayload, + resolveSourceControlProviderForRepositoryFromPayload, splitRepositoryFullName, type FetchImpl, type RepositoryRow, @@ -182,9 +182,14 @@ export async function createOrUpdateSourceControlPullRequestForTaskRun({ fetchImpl?: FetchImpl; }): Promise { const payloadRecord = getPayloadRecord(taskRun.payload); - const payloadProvider = - resolveSourceControlProviderFromPayload(payloadRecord); - const payloadHost = resolveSourceControlHostFromPayload(payloadRecord); + const payloadProvider = resolveSourceControlProviderForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); + const payloadHost = resolveSourceControlHostForRepositoryFromPayload( + payloadRecord, + input.repositoryFullName, + ); const provider = input.sourceControlProvider ?? payloadProvider; if (provider !== payloadProvider) { 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 341fc57f5..6f47819fd 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 @@ -8,6 +8,7 @@ const { mockCreateTaskRunScopedGitLabTokens, mockCreateTaskRunGiteaCredentials, mockCreateTaskRunAdoCredentials, + mockCreateTaskRunBitbucketCredentials, mockResolveSandboxModelRuntimeEnv, mockTaskRunsFindFirst, mockNotifySourceRunOnSettle, @@ -19,6 +20,7 @@ const { mockCreateTaskRunScopedGitLabTokens: vi.fn(), mockCreateTaskRunGiteaCredentials: vi.fn(), mockCreateTaskRunAdoCredentials: vi.fn(), + mockCreateTaskRunBitbucketCredentials: vi.fn(), mockResolveSandboxModelRuntimeEnv: vi.fn(), mockTaskRunsFindFirst: vi.fn(), mockNotifySourceRunOnSettle: vi.fn(), @@ -86,6 +88,11 @@ vi.mock('@roomote/ado', () => ({ mockCreateTaskRunAdoCredentials(...args), })); +vi.mock('@roomote/bitbucket', () => ({ + createTaskRunBitbucketCredentials: (...args: unknown[]) => + mockCreateTaskRunBitbucketCredentials(...args), +})); + vi.mock('@roomote/cloud-agents/server', () => ({ releaseTaskRun: vi.fn(), })); @@ -175,6 +182,9 @@ describe('createSourceControlTokenForTaskRun', () => { }, ], }); + mockCreateTaskRunBitbucketCredentials.mockResolvedValue({ + credentials: [], + }); }); it('creates GitHub token metadata by default', async () => { @@ -392,6 +402,124 @@ describe('createSourceControlTokenForTaskRun', () => { expect(mockCreateTaskRunScopedGitLabTokens).toHaveBeenCalled(); }); + it('mints the stamped primary provider first and merges aggregate metadata', async () => { + const taskRun = makeTaskRun({ + repo: 'group/project', + selectedRepositories: ['owner/repo', 'group/project'], + sourceControlProvider: 'github', + repositoryProviders: { + 'group/project': 'gitlab', + 'owner/repo': 'github', + }, + description: 'Work across providers', + } as TaskRun['payload']); + + const result = await createSourceControlTokenForTaskRun(taskRun, '[test]', { + maxRetries: 1, + }); + + expect(result).toEqual({ + provider: 'github', + token: 'ghs_app_token', + envVar: 'GH_TOKEN', + envVars: { GH_TOKEN: 'ghs_app_token' }, + gitCredentials: [ + { + host: 'gitlab.com', + repositoryFullName: 'group/project', + username: 'oauth2', + token: 'glptt_scoped_token', + }, + ], + gitProxyCredentials: [], + source: 'app', + expiresAt: null, + artifactsPatch: { + gitlabScopedProjectTokens: [ + { + repositoryFullName: 'group/project', + projectId: '101', + tokenId: 202, + }, + ], + }, + }); + expect( + mockCreateTaskRunWorkerGitHubToken.mock.invocationCallOrder[0], + ).toBeLessThan( + mockCreateTaskRunScopedGitLabTokens.mock.invocationCallOrder[0]!, + ); + }); + + it('retries only the failing provider and returns no partial token', async () => { + mockCreateTaskRunScopedGitLabTokens.mockRejectedValue( + new Error('GitLab unavailable'), + ); + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'owner/repo', + repositoryProviders: { + 'owner/repo': 'github', + 'group/project': 'gitlab', + }, + description: 'Work across providers', + } as TaskRun['payload']), + '[test]', + { maxRetries: 2, baseDelayMs: 0 }, + ); + + expect(result).toBeNull(); + expect(mockCreateTaskRunWorkerGitHubToken).toHaveBeenCalledTimes(1); + expect(mockCreateTaskRunScopedGitLabTokens).toHaveBeenCalledTimes(2); + } finally { + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + } + }); + + it('does not mint GitLab scoped tokens before a later provider succeeds', async () => { + mockCreateTaskRunAdoCredentials.mockRejectedValue( + new Error('Azure DevOps unavailable'), + ); + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + const result = await createSourceControlTokenForTaskRun( + makeTaskRun({ + repo: 'group/project', + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/project': 'gitlab', + 'acme/Platform/backend': 'ado', + }, + description: 'Work across providers', + } as TaskRun['payload']), + '[test]', + { maxRetries: 2, baseDelayMs: 0 }, + ); + + expect(result).toBeNull(); + expect(mockCreateTaskRunAdoCredentials).toHaveBeenCalledTimes(2); + expect(mockCreateTaskRunScopedGitLabTokens).not.toHaveBeenCalled(); + } finally { + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + } + }); + it('returns null when GitLab token is missing', async () => { mockCreateTaskRunScopedGitLabTokens.mockRejectedValueOnce( new Error('GITLAB_TOKEN is required for GitLab source control jobs.'), @@ -509,6 +637,20 @@ describe('redactSourceControlProviderEnvVars', () => { }); }); + it('removes the deployment Bitbucket token after credentials are derived', () => { + expect( + redactSourceControlProviderEnvVars( + { + BITBUCKET_OAUTH: 'bitbucket_deployment_token', + OPENAI_API_KEY: 'sk-test', + }, + 'bitbucket', + ), + ).toEqual({ + OPENAI_API_KEY: 'sk-test', + }); + }); + it('leaves unrelated env vars intact for GitHub jobs', () => { const envVars = { GITLAB_TOKEN: 'glpat_deployment_token', @@ -519,6 +661,23 @@ describe('redactSourceControlProviderEnvVars', () => { expect(redactSourceControlProviderEnvVars(envVars, 'github')).toBe(envVars); }); + + it('redacts every non-GitHub deployment token for mixed-provider jobs', () => { + expect( + redactSourceControlProviderEnvVars( + { + GH_TOKEN: 'operator-github-token', + GITLAB_TOKEN: 'glpat_deployment_token', + BITBUCKET_OAUTH: 'bitbucket_deployment_token', + OPENAI_API_KEY: 'sk-test', + }, + ['github', 'gitlab', 'bitbucket'], + ), + ).toEqual({ + GH_TOKEN: 'operator-github-token', + OPENAI_API_KEY: 'sk-test', + }); + }); }); describe('redactControlPlaneEnvVars', () => { diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts index d7290f758..b3149a3e9 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts @@ -24,6 +24,7 @@ const { mockRecordTaskRunLifecycleEvent, mockRecordSnapshotResumeEvent, mockResolveSlackTaskRunRouting, + mockResolveTaskRunSourceControlProviders, onBootstrapFailureMock, } = vi.hoisted(() => ({ mockDbTransaction: vi.fn(), @@ -48,6 +49,7 @@ const { mockRecordTaskRunLifecycleEvent: vi.fn(), mockRecordSnapshotResumeEvent: vi.fn(), mockResolveSlackTaskRunRouting: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), onBootstrapFailureMock: vi.fn(), })); @@ -88,6 +90,8 @@ vi.mock('../dequeue-helpers', () => ({ reportBootstrapFailure: (...args: unknown[]) => mockReportBootstrapFailure(...args), resolveGitAuthor: (...args: unknown[]) => mockResolveGitAuthor(...args), + resolveTaskRunSourceControlProviders: (...args: unknown[]) => + mockResolveTaskRunSourceControlProviders(...args), })); vi.mock('../slack-task-run-routing', () => ({ @@ -149,6 +153,7 @@ describe('dequeueResumeTaskRun', () => { mockEq.mockReturnValue('eq-clause'); mockClaimJobById.mockReturnValue('claim-query'); mockFetchEnvVars.mockResolvedValue({ ORG_ENV: '1' }); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); mockResolveGitAuthor.mockResolvedValue({ name: 'Roomote', email: 'roomote@example.com', @@ -217,6 +222,18 @@ describe('dequeueResumeTaskRun', () => { it("returns the task's harnessSessionId for snapshot resume", async () => { const resumeRun = makeSnapshotResumeRun(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'gl-token', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'gh-token' }, + source: 'app', + expiresAt: null, + }); mockTxExecute.mockResolvedValue([{ id: resumeRun.id }]); mockTxFindFirstTaskRuns.mockResolvedValueOnce(resumeRun); @@ -225,6 +242,14 @@ describe('dequeueResumeTaskRun', () => { }); expect(result?.harnessSessionId).toBe('session-canonical'); + expect(result?.gitHubToken).toBe('gh-token'); + expect(mockFetchEnvVars).toHaveBeenCalledWith(expect.anything(), { + sourceControlProvider: ['gitlab', 'github'], + }); + expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith( + { ORG_ENV: '1' }, + { sourceControlProvider: ['gitlab', 'github'] }, + ); expect(result?.harnessInstructions).toBe('preserved instructions'); expect(result?.sourceSelectedRepositories).toEqual([ 'acme/api', diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts index 5f8849341..c294261c3 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-task-run.test.ts @@ -25,6 +25,7 @@ const { mockRecordTaskRunLifecycleEvent, mockGetRedis, mockResolveSlackTaskRunRouting, + mockResolveTaskRunSourceControlProviders, onBootstrapFailureMock, } = vi.hoisted(() => ({ mockDbTransaction: vi.fn(), @@ -50,6 +51,7 @@ const { mockRecordTaskRunLifecycleEvent: vi.fn(), mockGetRedis: vi.fn(() => 'redis-client'), mockResolveSlackTaskRunRouting: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), onBootstrapFailureMock: vi.fn(), })); @@ -99,6 +101,8 @@ vi.mock('../dequeue-helpers', () => ({ reportBootstrapFailure: (...args: unknown[]) => mockReportBootstrapFailure(...args), resolveGitAuthor: (...args: unknown[]) => mockResolveGitAuthor(...args), + resolveTaskRunSourceControlProviders: (...args: unknown[]) => + mockResolveTaskRunSourceControlProviders(...args), })); import { dequeueTaskRun } from '../dequeue-task-run'; @@ -217,6 +221,7 @@ describe('dequeueTaskRun', () => { mockSql.mockReturnValue('claim-query'); mockClaimJobById.mockReturnValue('claim-query'); mockFetchEnvVars.mockResolvedValue({ ORG_ENV: '1' }); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); mockResolveGitAuthor.mockResolvedValue({ name: 'Roomote', email: 'roomote@example.com', @@ -301,6 +306,18 @@ describe('dequeueTaskRun', () => { it('treats StandardTask jobs without identity metadata as runnable', async () => { const taskRun = makeStandardTaskRun(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'gl-token', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'gh-token' }, + source: 'app', + expiresAt: null, + }); mockTxExecute.mockResolvedValue([{ id: taskRun.id }]); mockTxFindFirstTaskRuns.mockResolvedValue(taskRun); @@ -319,6 +336,13 @@ describe('dequeueTaskRun', () => { expect(result?.prompt).toBe('prompt'); expect(result?.harnessInstructions).toBe('instructions'); expect(result?.requestedWorkKind).toBe('unknown'); + expect(mockFetchEnvVars).toHaveBeenCalledWith(expect.anything(), { + sourceControlProvider: ['gitlab', 'github'], + }); + expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith( + { ORG_ENV: '1' }, + { sourceControlProvider: ['gitlab', 'github'] }, + ); expect(result?.task).toMatchObject({ id: 'task-101', title: 'Task 101', diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts index b48f67639..27b1f0662 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/fetch-snapshot-env.test.ts @@ -6,10 +6,12 @@ const { mockFindFirst, mockFetchResolvedRuntimeEnvVars, mockCreateSourceControlTokenForTaskRun, + mockResolveTaskRunSourceControlProviders, } = vi.hoisted(() => ({ mockFindFirst: vi.fn(), mockFetchResolvedRuntimeEnvVars: vi.fn(), mockCreateSourceControlTokenForTaskRun: vi.fn(), + mockResolveTaskRunSourceControlProviders: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -31,6 +33,8 @@ vi.mock('@roomote/db/server', () => ({ vi.mock('../dequeue-helpers', () => ({ fetchResolvedRuntimeEnvVars: mockFetchResolvedRuntimeEnvVars, createSourceControlTokenForTaskRun: mockCreateSourceControlTokenForTaskRun, + resolveTaskRunSourceControlProviders: + mockResolveTaskRunSourceControlProviders, })); import { fetchSnapshotEnv } from '../fetch-snapshot-env'; @@ -62,6 +66,7 @@ function makeGitHubToken(token: string) { describe('fetchSnapshotEnv', () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveTaskRunSourceControlProviders.mockResolvedValue(['github']); }); // ── Happy path: deployment-scoped env vars ─────────────────────────── @@ -84,7 +89,7 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); expect(result).toEqual({ - envVars: { MY_SECRET: 'value123' }, + envVars: { MY_SECRET: 'value123', GH_TOKEN: 'ghs_token_abc' }, gitHubToken: 'ghs_token_abc', sourceControlToken: token, taskId: 'task_123', @@ -96,7 +101,7 @@ describe('fetchSnapshotEnv', () => { // Verify the gateway-aware resolution was used (so snapshot env withholds // gateway-served provider keys, like the task dequeue path). expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith(undefined, { - sourceControlProvider: 'github', + sourceControlProvider: ['github'], }); // Verify createSourceControlTokenForTaskRun was called with the task run. @@ -126,14 +131,14 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); expect(result).toEqual({ - envVars: {}, + envVars: { GH_TOKEN: 'ghs_job_token' }, gitHubToken: 'ghs_job_token', sourceControlToken: token, taskId: 'task_123', }); expect(mockFetchResolvedRuntimeEnvVars).toHaveBeenCalledWith(undefined, { - sourceControlProvider: 'github', + sourceControlProvider: ['github'], }); }); @@ -174,11 +179,41 @@ describe('fetchSnapshotEnv', () => { const result = await fetchSnapshotEnv(auth, { runId: 42 }); - expect(result.envVars).toEqual({}); + expect(result.envVars).toEqual({ GH_TOKEN: 'ghs_token_xyz' }); expect(result.gitHubToken).toBe('ghs_token_xyz'); expect(result.taskId).toBe('task_123'); }); + it('derives gitHubToken from merged source-control env vars', async () => { + const auth: AuthTokenContext = { + userId: 'user_456', + tokenType: 'auth', + version: 1, + }; + mockFindFirst.mockResolvedValue(makeTaskRun()); + mockResolveTaskRunSourceControlProviders.mockResolvedValue([ + 'gitlab', + 'github', + ]); + mockFetchResolvedRuntimeEnvVars.mockResolvedValue({ MY_SECRET: 'value' }); + mockCreateSourceControlTokenForTaskRun.mockResolvedValue({ + provider: 'gitlab', + token: 'glptt_primary', + envVar: 'GITLAB_TOKEN', + envVars: { GH_TOKEN: 'ghs_merged' }, + source: 'app', + expiresAt: null, + }); + + const result = await fetchSnapshotEnv(auth, { runId: 42 }); + + expect(result.gitHubToken).toBe('ghs_merged'); + expect(result.envVars).toEqual({ + MY_SECRET: 'value', + GH_TOKEN: 'ghs_merged', + }); + }); + // ── Source-control token creation fails ───────────────────────────── it('throws when createSourceControlTokenForTaskRun returns null', 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 a0a8f8f20..4a027dc80 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -9,6 +9,7 @@ import { RunStatus, buildSourceControlTokenMetadata, getSourceControlProviderLabel, + normalizeSourceControlProvider, resolveTaskWorkspace, resolveSourceControlProviderFromPayload, type SourceControlProvider, @@ -80,22 +81,31 @@ export function redactControlPlaneEnvVars( export function redactSourceControlProviderEnvVars( envVars: Record, - sourceControlProvider?: SourceControlProvider, + sourceControlProvider?: SourceControlProvider | SourceControlProvider[], ): Record { - if (sourceControlProvider === 'github') { - return envVars; - } - - const providerTokenEnvVars = - sourceControlProvider === 'gitlab' + // Provider credential builders have already converted deployment secrets + // into scoped or proxy-backed worker credentials. Never expose the broad + // deployment token itself inside the task sandbox, including Bitbucket. + const providers = Array.isArray(sourceControlProvider) + ? sourceControlProvider + : sourceControlProvider + ? [sourceControlProvider] + : []; + const providerTokenEnvVars = providers.flatMap((provider) => + provider === 'gitlab' ? ['GITLAB_TOKEN'] - : sourceControlProvider === 'gitea' + : provider === 'gitea' ? ['GITEA_TOKEN'] - : sourceControlProvider === 'bitbucket' - ? [] - : sourceControlProvider === 'ado' + : provider === 'bitbucket' + ? ['BITBUCKET_OAUTH'] + : provider === 'ado' ? ['ADO_TOKEN'] - : []; + : [], + ); + + if (providerTokenEnvVars.length === 0) { + return envVars; + } const shouldRedact = providerTokenEnvVars.some( (envVar) => envVars[envVar] !== undefined, ); @@ -133,7 +143,7 @@ export function claimJobById(runId: number) { export async function fetchEnvVars( tx: Parameters[0]>[0], options?: { - sourceControlProvider?: SourceControlProvider; + sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; }, ): Promise> { const encryptedEnvVars = await tx.query.environmentVariables.findMany(); @@ -243,7 +253,7 @@ function redactModelRuntimeManagedEnvVars( export async function fetchResolvedRuntimeEnvVars( deploymentEnvVars?: Record, options?: { - sourceControlProvider?: SourceControlProvider; + sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; }, ): Promise> { const envVars = @@ -406,23 +416,47 @@ export type SourceControlRuntimeToken = SourceControlTokenMetadata & { }; /** - * Resolve the provider for a run's source-control token. Prefers the explicit - * payload stamp; when absent, resolves from the synced repositories the - * workspace references, so non-GitHub deployments work even when a launch - * site forgot to stamp the payload. Falls back to the GitHub default only - * when the workspace repositories are unknown or span providers. + * Resolve the ordered providers for a run's source-control tokens. A repository + * map is authoritative and keeps the primary repository's provider first. + * Legacy payloads retain the existing scalar, workspace, and default fallback. */ -async function resolveTaskRunSourceControlProvider( +export async function resolveTaskRunSourceControlProviders( taskRun: Pick, -): Promise { - const payload = taskRun.payload as { sourceControlProvider?: unknown }; + dbOrTx: Parameters[0] = db, +): Promise { + const payload = taskRun.payload as { + repo?: string; + repositoryProviders?: Record; + sourceControlProvider?: unknown; + }; + + if ( + payload.repositoryProviders && + Object.keys(payload.repositoryProviders).length > 0 + ) { + const mappedProviders = Object.values(payload.repositoryProviders).map( + normalizeSourceControlProvider, + ); + const primaryProvider = + payload.sourceControlProvider === undefined || + payload.sourceControlProvider === null || + payload.sourceControlProvider === '' + ? mappedProviders[0] + : resolveSourceControlProviderFromPayload(payload); + const providers = [ + ...(primaryProvider === undefined ? [] : [primaryProvider]), + ...mappedProviders, + ]; + + return [...new Set(providers)]; + } if ( payload.sourceControlProvider !== undefined && payload.sourceControlProvider !== null && payload.sourceControlProvider !== '' ) { - return resolveSourceControlProviderFromPayload(payload); + return [resolveSourceControlProviderFromPayload(payload)]; } // No explicit stamp: resolve from the workspace's synced repositories via the @@ -431,12 +465,12 @@ async function resolveTaskRunSourceControlProvider( // GitHub default that resolveSourceControlProviderFromPayload applies. const workspace = resolveTaskWorkspace(taskRun.payload); const resolvedProvider = await resolveWorkspaceSourceControlProvider( - db, + dbOrTx, workspace, ); if (resolvedProvider) { - return resolvedProvider; + return [resolvedProvider]; } // The GitHub default is wrong whenever the workspace actually spans @@ -445,17 +479,16 @@ async function resolveTaskRunSourceControlProvider( // scopes into per-provider runs; log loudly so the surface that forgot is // diagnosable from the run's cancellation. console.warn( - `[resolveTaskRunSourceControlProvider] Task run ${taskRun.id} has no sourceControlProvider stamp and its ${workspace.type} workspace resolves to no single provider; falling back to the GitHub default. The launch site should stamp the payload or split multi-provider scopes into per-provider runs.`, + `[resolveTaskRunSourceControlProviders] Task run ${taskRun.id} has no sourceControlProvider stamp and its ${workspace.type} workspace resolves to no single provider; falling back to the GitHub default. The launch site should stamp the payload or split multi-provider scopes into per-provider runs.`, ); - return resolveSourceControlProviderFromPayload(taskRun.payload); + return [resolveSourceControlProviderFromPayload(taskRun.payload)]; } async function createProviderToken( taskRun: TaskRun, + provider: SourceControlProvider, ): Promise { - const provider = await resolveTaskRunSourceControlProvider(taskRun); - switch (provider) { case 'github': { const token = await createTaskRunWorkerGitHubToken(taskRun); @@ -536,26 +569,48 @@ async function createProviderToken( } } -/** - * Creates a source-control token for the task run with retry logic. - * Retries up to {@link SOURCE_CONTROL_TOKEN_MAX_RETRIES} times with - * exponential backoff (1s, 2s, 4s) to handle transient provider API failures. - * Returns null if all attempts fail (caller should handle the error). - */ -export async function createSourceControlTokenForTaskRun( +function mergeProviderTokens( + tokens: SourceControlRuntimeToken[], +): SourceControlRuntimeToken { + const [primaryToken, ...secondaryTokens] = tokens; + + if (!primaryToken) { + throw new Error('No source control providers resolved for task run.'); + } + + return secondaryTokens.reduce( + (merged, token) => ({ + ...merged, + envVars: { ...merged.envVars, ...token.envVars }, + gitCredentials: [ + ...(merged.gitCredentials ?? []), + ...(token.gitCredentials ?? []), + ], + gitProxyCredentials: [ + ...(merged.gitProxyCredentials ?? []), + ...(token.gitProxyCredentials ?? []), + ], + artifactsPatch: { + ...(merged.artifactsPatch ?? {}), + ...(token.artifactsPatch ?? {}), + }, + }), + primaryToken, + ); +} + +async function createProviderTokenWithRetry( taskRun: TaskRun, + provider: SourceControlProvider, logPrefix: string, - { - maxRetries = SOURCE_CONTROL_TOKEN_MAX_RETRIES, - baseDelayMs = SOURCE_CONTROL_TOKEN_BASE_DELAY_MS, - } = {}, + maxRetries: number, + baseDelayMs: number, ): Promise { - const provider = await resolveTaskRunSourceControlProvider(taskRun); const label = getSourceControlProviderLabel(provider); for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - return await createProviderToken(taskRun); + return await createProviderToken(taskRun, provider); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -576,6 +631,54 @@ export async function createSourceControlTokenForTaskRun( return null; } +/** + * Creates a source-control token for the task run with retry logic. + * Retries up to {@link SOURCE_CONTROL_TOKEN_MAX_RETRIES} times with + * exponential backoff (1s, 2s, 4s) to handle transient provider API failures. + * Returns null if all attempts fail (caller should handle the error). + */ +export async function createSourceControlTokenForTaskRun( + taskRun: TaskRun, + logPrefix: string, + { + maxRetries = SOURCE_CONTROL_TOKEN_MAX_RETRIES, + baseDelayMs = SOURCE_CONTROL_TOKEN_BASE_DELAY_MS, + } = {}, +): Promise { + const providers = await resolveTaskRunSourceControlProviders(taskRun); + + // GitLab scoped tokens create revocable remote resources. Mint them last so + // a later provider failure cannot orphan a successful GitLab token set. + const mintOrder = [ + ...providers.filter((provider) => provider !== 'gitlab'), + ...providers.filter((provider) => provider === 'gitlab'), + ]; + const tokensByProvider = new Map< + SourceControlProvider, + SourceControlRuntimeToken + >(); + + for (const provider of mintOrder) { + const token = await createProviderTokenWithRetry( + taskRun, + provider, + logPrefix, + maxRetries, + baseDelayMs, + ); + + if (!token) { + return null; + } + + tokensByProvider.set(provider, token); + } + + return mergeProviderTokens( + providers.map((provider) => tokensByProvider.get(provider)!), + ); +} + /** * Marks a task run as canceled with an error message. */ diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts index 42432b6ae..df77e8c46 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts @@ -25,6 +25,7 @@ import { claimJobById, fetchEnvVars, fetchResolvedRuntimeEnvVars, + resolveTaskRunSourceControlProviders, cancelAndReleaseTaskRun, createSourceControlTokenForTaskRun, type SourceControlRuntimeToken, @@ -135,6 +136,9 @@ export const dequeueResumeTaskRun = async ( sourceRepo?: string; sourceEnvironmentId?: string; sourceSelectedRepositories?: string[]; + sourceControlProviders: Awaited< + ReturnType + >; }; const result: TransactionResult = await db.transaction(async (tx) => { @@ -283,10 +287,12 @@ export const dequeueResumeTaskRun = async ( ); // Fetch environment variables + const sourceControlProviders = await resolveTaskRunSourceControlProviders( + taskRun, + tx, + ); const envVars = await fetchEnvVars(tx, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + sourceControlProvider: sourceControlProviders, }); const settings = await tx.query.deploymentSettings.findFirst({ columns: { @@ -342,6 +348,7 @@ export const dequeueResumeTaskRun = async ( sourceRepo, sourceEnvironmentId, sourceSelectedRepositories, + sourceControlProviders, }; }); @@ -410,14 +417,13 @@ export const dequeueResumeTaskRun = async ( return undefined; } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const gitHubToken = sourceControlToken.envVars.GH_TOKEN ?? ''; let resolvedEnvVars: Record; try { resolvedEnvVars = await fetchResolvedRuntimeEnvVars(result.envVars, { - sourceControlProvider: sourceControlToken.provider, + sourceControlProvider: result.sourceControlProviders, }); } catch (error) { const message = diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts index ac5ea054e..990c37b6a 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts @@ -24,6 +24,7 @@ import { type GitAuthor, fetchEnvVars, fetchResolvedRuntimeEnvVars, + resolveTaskRunSourceControlProviders, cancelAndReleaseTaskRun, createSourceControlTokenForTaskRun, type SourceControlRuntimeToken, @@ -281,6 +282,9 @@ export const dequeueTaskRun = async ( envVars: Record; orgAgentInstructions?: string; gitAuthor: GitAuthor; + sourceControlProviders: Awaited< + ReturnType + >; }; // Phase 1: Transaction — claim the run and fetch all data needed for @@ -309,10 +313,12 @@ export const dequeueTaskRun = async ( const task = taskRun.task; + const sourceControlProviders = await resolveTaskRunSourceControlProviders( + taskRun, + tx, + ); const envVars = await fetchEnvVars(tx, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + sourceControlProvider: sourceControlProviders, }); const settings = await tx.query.deploymentSettings.findFirst({ columns: { @@ -386,6 +392,7 @@ export const dequeueTaskRun = async ( envVars, orgAgentInstructions: settings?.globalAgentInstructions ?? undefined, gitAuthor, + sourceControlProviders, }; }); @@ -455,8 +462,7 @@ export const dequeueTaskRun = async ( return undefined; } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const gitHubToken = sourceControlToken.envVars.GH_TOKEN ?? ''; const sourceControlArtifacts = sourceControlToken.artifactsPatch ?? {}; let prompt: string; @@ -531,7 +537,7 @@ export const dequeueTaskRun = async ( }, fn: async () => await fetchResolvedRuntimeEnvVars(txResult.envVars, { - sourceControlProvider: sourceControlToken.provider, + sourceControlProvider: txResult.sourceControlProviders, }), }); } catch (error) { diff --git a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts index f2c6186df..13aa5a4d3 100644 --- a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts +++ b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts @@ -1,5 +1,4 @@ import { - resolveSourceControlProviderFromPayload, type AuthTokenContext, type RunTokenContext, type SourceControlTokenMetadata, @@ -9,6 +8,7 @@ import { db, taskRuns, eq } from '@roomote/db/server'; import { fetchResolvedRuntimeEnvVars, createSourceControlTokenForTaskRun, + resolveTaskRunSourceControlProviders, } from './dequeue-helpers'; /** @@ -40,10 +40,10 @@ export async function fetchSnapshotEnv( // dequeue so gateway-covered provider keys are withheld here too; otherwise // a snapshot taken with the flag on would bake raw provider keys into the // snapshot's shell env and the persisted image. - const envVars = await fetchResolvedRuntimeEnvVars(undefined, { - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskRun.payload, - ), + const sourceControlProviders = + await resolveTaskRunSourceControlProviders(taskRun); + const deploymentEnvVars = await fetchResolvedRuntimeEnvVars(undefined, { + sourceControlProvider: sourceControlProviders, }); const sourceControlToken = await createSourceControlTokenForTaskRun( @@ -76,8 +76,8 @@ export async function fetchSnapshotEnv( .where(eq(taskRuns.id, taskRun.id)); } - const gitHubToken = - sourceControlToken.provider === 'github' ? sourceControlToken.token : ''; + const envVars = { ...deploymentEnvVars, ...sourceControlToken.envVars }; + const gitHubToken = envVars.GH_TOKEN ?? ''; return { envVars, gitHubToken, sourceControlToken, taskId: taskRun.taskId }; } diff --git a/packages/types/src/__tests__/command-schema.test.ts b/packages/types/src/__tests__/command-schema.test.ts index 7d10be0c4..bf79ef53f 100644 --- a/packages/types/src/__tests__/command-schema.test.ts +++ b/packages/types/src/__tests__/command-schema.test.ts @@ -6,6 +6,7 @@ import { commandSchema, environmentConfigSchema, environmentRepositoryConfigSchema, + getDuplicateEnvironmentRepositoryConfigError, getMissingEnvironmentRepositoryError, } from '../environment-config'; @@ -431,6 +432,33 @@ commands: }); describe('environmentConfigSchema', () => { + it('keeps legacy duplicate repository entries parseable on read', () => { + const result = environmentConfigSchema.safeParse({ + name: 'Env', + repositories: [ + { repository: 'owner/repo' }, + { repository: 'owner/repo' }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('reports duplicate repository entries for write validation', () => { + expect( + getDuplicateEnvironmentRepositoryConfigError([ + { repository: 'owner/repo' }, + { repository: 'owner/repo' }, + ]), + ).toBe('Duplicate repository: owner/repo'); + expect( + getDuplicateEnvironmentRepositoryConfigError([ + { repository: 'owner/repo' }, + { repository: 'owner/other' }, + ]), + ).toBeNull(); + }); + describe('tool_versions', () => { it('should accept root-level tool_versions for environment workspaces', () => { const result = environmentConfigSchema.safeParse({ diff --git a/packages/types/src/__tests__/source-control.test.ts b/packages/types/src/__tests__/source-control.test.ts index d411dc909..f058030bb 100644 --- a/packages/types/src/__tests__/source-control.test.ts +++ b/packages/types/src/__tests__/source-control.test.ts @@ -2,6 +2,7 @@ import { buildPullRequestUrl, buildRepositoryCloneUrl, buildSourceControlTokenMetadata, + filterRepositoryNamesForSourceControlProvider, getSourceControlTokenEnvVar, normalizeSourceControlProvider, parsePullRequestUrl, @@ -36,6 +37,38 @@ describe('source control provider helpers', () => { ).toBe('ado'); }); + it('uses provider maps as an authoritative repository allowlist', () => { + const repositoryNames = ['octo/api', 'group/web', 'unknown/repository']; + const payload = { + repositoryProviders: { + 'octo/api': 'github', + 'group/web': 'gitlab', + }, + }; + + expect( + filterRepositoryNamesForSourceControlProvider( + payload, + repositoryNames, + 'github', + ), + ).toEqual(['octo/api']); + expect( + filterRepositoryNamesForSourceControlProvider( + payload, + repositoryNames, + 'gitlab', + ), + ).toEqual(['group/web']); + expect( + filterRepositoryNamesForSourceControlProvider( + {}, + repositoryNames, + 'github', + ), + ).toEqual(repositoryNames); + }); + it('maps providers to their runtime token environment variable', () => { expect(getSourceControlTokenEnvVar('github')).toBe('GH_TOKEN'); expect(getSourceControlTokenEnvVar('gitlab')).toBe('GITLAB_TOKEN'); diff --git a/packages/types/src/__tests__/task-runs.test.ts b/packages/types/src/__tests__/task-runs.test.ts index db39107bd..1653ccfa5 100644 --- a/packages/types/src/__tests__/task-runs.test.ts +++ b/packages/types/src/__tests__/task-runs.test.ts @@ -39,6 +39,7 @@ import { WORK_ITEM_ACTIVE_STATUSES, shouldUseAppTokenOnly, } from '../task-runs'; +import { ALL_REPOSITORIES } from '../constants'; describe('isSourceControlTaskSurface', () => { it.each(['github', 'gitlab', 'gitea', 'bitbucket', 'ado'] as const)( @@ -287,6 +288,32 @@ describe('taskSpecSchema', () => { expect(parsed.payload.sourceControlProvider).toBe('gitlab'); }); + it('preserves repositoryProviders on mixed-provider task payloads', () => { + const parsed = taskSpecSchema.parse({ + userId: 'user-1', + type: TaskPayloadKind.StandardTask, + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: ['octo/api', 'group/web'], + sourceControlProvider: 'github', + repositoryProviders: { + 'octo/api': 'github', + 'group/web': 'gitlab', + }, + description: 'Update a mixed-provider workspace', + }, + }); + + if (parsed.type !== TaskPayloadKind.StandardTask) { + throw new Error('Expected StandardTask payload'); + } + + expect(parsed.payload.repositoryProviders).toEqual({ + 'octo/api': 'github', + 'group/web': 'gitlab', + }); + }); + it('preserves customAutomationId and Slack channel context on StandardTask payloads', () => { const parsed = taskSpecSchema.parse({ userId: 'user-1', diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 4f6f32993..1fee7654a 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -1028,6 +1028,40 @@ export function hasEnvironmentOidcTargets( export const MULTI_INSTALLATION_ENVIRONMENT_REPOSITORIES_ERROR = 'Environment repositories must all belong to the same GitHub App installation.'; +export function getDuplicateEnvironmentRepositoryConfigError( + repositories: Array<{ repository: string }>, +): string | null { + const seen = new Set(); + const duplicate = repositories.find((repository) => { + if (seen.has(repository.repository)) { + return true; + } + + seen.add(repository.repository); + return false; + }); + + return duplicate ? `Duplicate repository: ${duplicate.repository}` : null; +} + +export function getAmbiguousEnvironmentRepositoryError( + repositories: Array<{ fullName: string }>, +): string | null { + const seen = new Set(); + const duplicate = repositories.find((repository) => { + if (seen.has(repository.fullName)) { + return true; + } + + seen.add(repository.fullName); + return false; + }); + + return duplicate + ? `Multiple repositories are named "${duplicate.fullName}". Environment repository names must be unique across source-control connections.` + : null; +} + type EnvironmentRepositoryInstallationReference = { fullName: string; installationId: string | number | null | undefined; diff --git a/packages/types/src/environment-definition-tasks.ts b/packages/types/src/environment-definition-tasks.ts index b367f7143..1905c7aaa 100644 --- a/packages/types/src/environment-definition-tasks.ts +++ b/packages/types/src/environment-definition-tasks.ts @@ -5,6 +5,20 @@ type RepositoryReference = { fullName: string; }; +export function assertUniqueRepositoryFullNames( + repositoryFullNames: string[], +): void { + const duplicateRepository = repositoryFullNames.find( + (repository, index) => repositoryFullNames.indexOf(repository) !== index, + ); + + if (duplicateRepository) { + throw new Error( + `The selected repositories include multiple entries named "${duplicateRepository}". Select only one because task workspaces identify repositories by full name.`, + ); + } +} + export const ENVIRONMENT_DEFINITION_SETUP_GUIDANCE_PLACEHOLDER = 'Optional agent guidance, like what services in a monorepo to set up or context that may be missing from the repo itself'; @@ -33,36 +47,23 @@ export function getEnvironmentDefinitionIdFromPayload( export function normalizeRepositorySelection( repositories: RepositoryReference[], ): string[] { - const uniqueRepositories = Array.from( - new Map( - repositories.map((repository) => [repository.id, repository]), - ).values(), - ); - - return uniqueRepositories - .sort( - (left, right) => - left.fullName.localeCompare(right.fullName) || - left.id.localeCompare(right.id), - ) - .map((repository) => repository.id); + return [...new Set(repositories.map((repository) => repository.id))]; } export function buildCreateEnvironmentDefinitionPrompt( repositoryFullNames: string[], options?: { emptyRepositoryFullNames?: string[] }, ): string { - const sortedRepositories = [...repositoryFullNames].sort((left, right) => - left.localeCompare(right), - ); + const orderedRepositories = [...new Set(repositoryFullNames)]; - const repositoryLines = sortedRepositories + const repositoryLines = orderedRepositories .map((repositoryFullName) => `- ${repositoryFullName}`) .join('\n'); - const emptyRepositories = [...(options?.emptyRepositoryFullNames ?? [])] - .filter((fullName) => repositoryFullNames.includes(fullName)) - .sort((left, right) => left.localeCompare(right)); + const emptyRepositoryNames = new Set(options?.emptyRepositoryFullNames ?? []); + const emptyRepositories = orderedRepositories.filter((fullName) => + emptyRepositoryNames.has(fullName), + ); // Restate the skill's empty-repository bootstrap rules inline so a worker // whose packaged environment-setup skill predates the bootstrap section @@ -134,9 +135,8 @@ export function buildEnvironmentDefinitionWorkspacePayload( repo: string; selectedRepositories?: string[]; } { - const normalizedRepositories = [...new Set(repositoryFullNames)].sort( - (left, right) => left.localeCompare(right), - ); + assertUniqueRepositoryFullNames(repositoryFullNames); + const normalizedRepositories = [...new Set(repositoryFullNames)]; const primaryRepository = normalizedRepositories[0]; if (!primaryRepository) { diff --git a/packages/types/src/source-control.ts b/packages/types/src/source-control.ts index 63caa5f1e..2fbdce63c 100644 --- a/packages/types/src/source-control.ts +++ b/packages/types/src/source-control.ts @@ -153,6 +153,32 @@ export function resolveSourceControlProviderFromPayload(payload: { return normalizeSourceControlProvider(payload.sourceControlProvider); } +export function resolveRepositoryProvidersFromPayload(payload: { + repositoryProviders?: unknown; +}): Record | undefined { + const parsed = z + .record(sourceControlProviderSchema) + .safeParse(payload.repositoryProviders); + + return parsed.success && Object.keys(parsed.data).length > 0 + ? parsed.data + : undefined; +} + +export function filterRepositoryNamesForSourceControlProvider( + payload: { repositoryProviders?: unknown }, + repositoryNames: string[], + provider: SourceControlProvider, +): string[] { + const repositoryProviders = resolveRepositoryProvidersFromPayload(payload); + + return repositoryProviders + ? repositoryNames.filter( + (repositoryName) => repositoryProviders[repositoryName] === provider, + ) + : repositoryNames; +} + /** * Reads the optional `sourceControlHost` field from a task payload. Returns * the trimmed host, or undefined when the payload carries no usable host so diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index d57bc6ccf..2d4ef0bcf 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -822,6 +822,12 @@ const sharedTaskPayloadSchema = z.object({ */ sourceControlProvider: sourceControlProviderSchema.optional(), + /** + * Source-control provider keyed by repository full name for workspaces that + * span multiple providers. Single-provider payloads omit this field. + */ + repositoryProviders: z.record(sourceControlProviderSchema).optional(), + /** * Source-control instance host for repository resolution (for example * `gitlab.example.com`), matching `repositories.host`. Stamped by launch @@ -1924,6 +1930,7 @@ type TaskWorkspacePayload = { repo?: string; branch?: string; sha?: string; + sourceControlHost?: string; environmentId?: string; selectedRepositories?: string[]; }; @@ -1934,10 +1941,12 @@ export type TaskWorkspace = repo: string; branch?: string; sha?: string; + sourceControlHost?: string; } | { type: 'repository_set'; repositories: string[]; + sourceControlHost?: string; } | { type: 'all_repositories'; @@ -1983,6 +1992,7 @@ export function resolveTaskWorkspace( ? { type: 'repository_set', repositories, + sourceControlHost: payload.sourceControlHost, } : { type: 'all_repositories', @@ -2000,6 +2010,7 @@ export function resolveTaskWorkspace( repo: payload.repo, branch: payload.branch, sha: payload.sha, + sourceControlHost: payload.sourceControlHost, }; } From 4825ed22f75d6b6f6b460a71ba4c2085f5b86c96 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:48:05 -0400 Subject: [PATCH 06/11] [Feat] Re-enable Requesty model provider (#1104) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../commands/task-models/models-dev.test.ts | 30 ++++++------ .../types/src/model-provider-config.test.ts | 49 +++++++++++++------ packages/types/src/model-provider-config.ts | 29 ++++++++--- packages/types/src/task-models.test.ts | 4 +- packages/types/src/task-models.ts | 2 +- 5 files changed, 73 insertions(+), 41 deletions(-) diff --git a/apps/web/src/trpc/commands/task-models/models-dev.test.ts b/apps/web/src/trpc/commands/task-models/models-dev.test.ts index bb31ba999..5006dc6b2 100644 --- a/apps/web/src/trpc/commands/task-models/models-dev.test.ts +++ b/apps/web/src/trpc/commands/task-models/models-dev.test.ts @@ -42,12 +42,12 @@ describe('resolveModelsDevSlug', () => { }); it('strips the requesty/ prefix for Requesty routed models', () => { - expect(resolveModelsDevSlug('requesty/openai/gpt-5.4')).toBe( - 'openai/gpt-5.4', + expect(resolveModelsDevSlug('requesty/gpt-5.6-terra@eu')).toBe( + 'gpt-5.6-terra@eu', + ); + expect(resolveModelsDevSlug('requesty/~claude-sonnet-5')).toBe( + 'claude-sonnet-5', ); - expect( - resolveModelsDevSlug('requesty/~anthropic/claude-sonnet-latest'), - ).toBe('anthropic/claude-sonnet-latest'); }); it('strips the baseten/ prefix for Baseten routed models', () => { @@ -317,12 +317,12 @@ describe('lookupModelMetadataFromCatalog', () => { const catalog = buildCatalog({ gatewayModelsByLowerSlug: { requesty: { - 'anthropic/claude-sonnet-4': { - id: 'anthropic/claude-sonnet-4', - name: 'Claude Sonnet 4', + 'claude-sonnet-5': { + id: 'claude-sonnet-5', + name: 'Claude Sonnet 5', modalities: { input: ['text', 'image', 'pdf'] }, - limit: { context: 200000 }, - cost: { input: 3, output: 15 }, + limit: { context: 1000000 }, + cost: { input: 2, output: 10 }, }, }, }, @@ -330,16 +330,16 @@ describe('lookupModelMetadataFromCatalog', () => { const result = lookupModelMetadataFromCatalog( catalog, - 'requesty/anthropic/claude-sonnet-4', + 'requesty/claude-sonnet-5', ); expect(result.metadata).toEqual({ - contextWindow: 200000, + contextWindow: 1000000, inputTypes: ['text', 'image', 'pdf'], - inputPricePerToken: 3 / 1_000_000, - outputPricePerToken: 15 / 1_000_000, + inputPricePerToken: 2 / 1_000_000, + outputPricePerToken: 10 / 1_000_000, }); - expect(result.displayName).toBe('Claude Sonnet 4'); + expect(result.displayName).toBe('Claude Sonnet 5'); }); it('falls back to the provider-agnostic models map for requesty-routed models missing from the gateway entry', () => { diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index f3f5b81fa..9cb0cc30a 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -347,6 +347,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { expect(kimiK3ByProvider).toEqual([ { providerId: 'openrouter', modelId: 'openrouter/moonshotai/kimi-k3' }, { providerId: 'vercel', modelId: 'vercel/moonshotai/kimi-k3' }, + { providerId: 'requesty', modelId: 'requesty/kimi-k3' }, { providerId: 'baseten', modelId: 'baseten/moonshotai/Kimi-K3' }, { providerId: 'togetherai', modelId: 'togetherai/moonshotai/Kimi-K3' }, { providerId: 'moonshotai', modelId: 'moonshotai/kimi-k3' }, @@ -418,6 +419,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { expect(providersByModel).toEqual([ { providerId: 'openrouter', modelId: `openrouter/openai/${modelId}` }, { providerId: 'vercel', modelId: `vercel/openai/${modelId}` }, + { providerId: 'requesty', modelId: `requesty/${modelId}@eu` }, { providerId: 'openai', modelId: `openai/${modelId}` }, { providerId: 'azure', modelId: `azure/${modelId}` }, { @@ -460,6 +462,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { modelId: 'openrouter/google/gemini-3.6-flash', }, { providerId: 'vercel', modelId: 'vercel/google/gemini-3.6-flash' }, + { providerId: 'requesty', modelId: 'requesty/gemini-3.6-flash' }, { providerId: 'opencode', modelId: 'opencode/gemini-3.6-flash' }, { providerId: 'google', modelId: 'google/gemini-3.6-flash' }, ]); @@ -485,6 +488,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { providerId: 'vercel', modelId: 'vercel/deepseek/deepseek-v4-flash-0731', }, + { + providerId: 'requesty', + modelId: 'requesty/deepseek-v4-flash-0731', + }, { providerId: 'baseten', modelId: 'baseten/deepseek-ai/DeepSeek-V4-Flash-0731', @@ -742,7 +749,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { }); }); - it('maps Requesty to the REQUESTY_API_KEY env var and hides it from new connections', () => { + it('maps Requesty to its current models.dev catalog and recommended roles', () => { const requestyProvider = SETUP_MODEL_PROVIDER_CATALOG.find( (provider) => provider.id === 'requesty', ); @@ -750,26 +757,38 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { expect(requestyProvider).toMatchObject({ label: 'Requesty', envVarName: 'REQUESTY_API_KEY', - defaultRoomoteModel: 'requesty/anthropic/claude-haiku-4-5', - hidden: true, + defaultRoomoteModel: 'requesty/claude-sonnet-5', + recommendedRoleModels: { + helper: 'requesty/gemini-3.6-flash', + codeReview: 'requesty/claude-sonnet-5', + explore: 'requesty/gemini-3.6-flash', + planning: 'requesty/claude-opus-5', + }, }); + expect( + requestyProvider?.suggestedTaskModels.map((model) => model.id), + ).toEqual([ + 'requesty/claude-fable-5', + 'requesty/claude-haiku-4-5', + 'requesty/claude-opus-5', + 'requesty/claude-sonnet-5', + 'requesty/gpt-5.6-sol@eu', + 'requesty/gpt-5.6-terra@eu', + 'requesty/gpt-5.6-luna@eu', + 'requesty/gemini-3.6-flash', + 'requesty/deepseek-v4-flash-0731', + 'requesty/glm-5.2', + 'requesty/kimi-k3', + 'requesty/grok-4.5', + ]); }); - it('excludes hidden providers from the setup status unless they are connected', () => { + it('offers Requesty for new connections', () => { const unconnected = buildSetupModelStatus({}); expect( unconnected.providers.some((provider) => provider.id === 'requesty'), - ).toBe(false); - - const connected = buildSetupModelStatus({ - persistedEnvVarNames: ['REQUESTY_API_KEY'], - }); - const requestyStatus = connected.providers.find( - (provider) => provider.id === 'requesty', - ); - - expect(requestyStatus?.savedApiKeySatisfied).toBe(true); + ).toBe(true); }); it('maps Baseten to the BASETEN_API_KEY env var', () => { @@ -1423,7 +1442,7 @@ describe('buildSetupModelStatus', () => { it('resolves the requesty provider from a runtime Requesty model id', () => { const status = buildSetupModelStatus({ runtimeEnv: { - R_MODEL: 'requesty/anthropic/claude-sonnet-4', + R_MODEL: 'requesty/claude-sonnet-5', REQUESTY_API_KEY: 'rty-runtime', }, }); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 1a32ec69c..fe7f583d3 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -368,17 +368,30 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ id: 'requesty', label: 'Requesty', envVarName: 'REQUESTY_API_KEY', - defaultRoomoteModel: 'requesty/anthropic/claude-haiku-4-5', + defaultRoomoteModel: 'requesty/claude-sonnet-5', authKind: 'api-key', - // Requesty's models.dev catalog does not yet list GPT 5.6 Sol/Terra/Luna, - // so only recommended models that resolve there are suggested. + // Requesty's models.dev slugs are provider-local rather than lab/model. suggestedTaskModels: mapRecommendedTaskModels({ - 'claude-haiku-4-5': 'requesty/anthropic/claude-haiku-4-5', + 'claude-fable-5': 'requesty/claude-fable-5', + 'claude-haiku-4-5': 'requesty/claude-haiku-4-5', + 'claude-opus-5': 'requesty/claude-opus-5', + 'claude-sonnet-5': 'requesty/claude-sonnet-5', + 'gpt-5-6-sol': 'requesty/gpt-5.6-sol@eu', + 'gpt-5-6-terra': 'requesty/gpt-5.6-terra@eu', + 'gpt-5-6-luna': 'requesty/gpt-5.6-luna@eu', + 'gemini-3-6-flash': 'requesty/gemini-3.6-flash', + 'deepseek-v4-flash-0731': 'requesty/deepseek-v4-flash-0731', + 'glm-5-2': 'requesty/glm-5.2', + 'kimi-k3': 'requesty/kimi-k3', + 'grok-4-5': 'requesty/grok-4.5', }), - // Hidden from new connections for now: the catalog above resolves too - // few recommended models to seed a useful default list. Existing - // connections keep working. - hidden: true, + recommendedRoleModels: { + helper: 'requesty/gemini-3.6-flash', + codeReview: 'requesty/claude-sonnet-5', + explore: 'requesty/gemini-3.6-flash', + planning: 'requesty/claude-opus-5', + }, + recommendedRoleReasoningEfforts: { codeReview: 'medium' }, }, { id: 'baseten', diff --git a/packages/types/src/task-models.test.ts b/packages/types/src/task-models.test.ts index 98030c9ec..fe9cd1c83 100644 --- a/packages/types/src/task-models.test.ts +++ b/packages/types/src/task-models.test.ts @@ -61,8 +61,8 @@ describe('normalizeTaskModelId', () => { expect(normalizeTaskModelId('vercel/openai/gpt-5.4')).toBe( 'vercel/openai/gpt-5.4', ); - expect(normalizeTaskModelId('requesty/openai/gpt-5.4')).toBe( - 'requesty/openai/gpt-5.4', + expect(normalizeTaskModelId('requesty/gpt-5.6-terra@eu')).toBe( + 'requesty/gpt-5.6-terra@eu', ); expect(normalizeTaskModelId('baseten/moonshotai/Kimi-K2.7-Code')).toBe( 'baseten/moonshotai/Kimi-K2.7-Code', diff --git a/packages/types/src/task-models.ts b/packages/types/src/task-models.ts index ec92b5ae7..855330b22 100644 --- a/packages/types/src/task-models.ts +++ b/packages/types/src/task-models.ts @@ -69,7 +69,7 @@ const DISABLED_TASK_MODEL_PROVIDER_ID_SET = new Set( /** * Gateway providers route models from many labs under a single provider * prefix (e.g. `openrouter/z-ai/glm-5.2`, `vercel/openai/gpt-5.6-terra`, - * `requesty/anthropic/claude-sonnet-5`). This is the canonical set: + * `requesty/claude-sonnet-5`). This is the canonical set: * models.dev slug resolution and gateway pricing lookups derive from it. * `vercel` and `requesty` also appear in `DIRECT_TASK_MODEL_PROVIDER_IDS` * because their slugs are addressed by their own prefix; `openrouter` is the From 2a3bc5dea561b53b103c0a6ef9dc2455ab0b33e3 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:56:47 -0400 Subject: [PATCH 07/11] [Fix] Tasks crash when agents read icon files (#1103) * fix: prevent unsupported icon reads from crashing tasks * fix: resolve symlinks before icon reads --------- Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- ...opencode-tool-safety-plugin-script.test.ts | 132 ++++++++++++++++++ apps/worker/src/run-task/agent-home.ts | 12 ++ .../opencode-tool-safety-plugin-script.ts | 61 ++++++++ .../opencode-server-bootstrap.test.ts | 31 ++++ 4 files changed, 236 insertions(+) create mode 100644 apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts create mode 100644 apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts diff --git a/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts b/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts new file mode 100644 index 000000000..4a400081c --- /dev/null +++ b/apps/worker/src/run-task/__tests__/opencode-tool-safety-plugin-script.test.ts @@ -0,0 +1,132 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT } from '../opencode-tool-safety-plugin-script'; + +interface ToolHookInput { + tool: string; + args?: unknown; +} + +type ToolHooks = { + 'tool.execute.before': ( + input: ToolHookInput, + output: { args?: unknown }, + ) => Promise; +}; + +describe('OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'roomote-opencode-tool-safety-plugin-'), + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + async function loadHooks(): Promise { + const pluginPath = path.join(tempDir, 'roomote-tool-safety.mjs'); + fs.writeFileSync(pluginPath, OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT, 'utf8'); + + const module = (await import( + /* @vite-ignore */ pathToFileURL(pluginPath).href + )) as { + RoomoteOpenCodeToolSafety: () => Promise; + }; + + return await module.RoomoteOpenCodeToolSafety(); + } + + it.each([ + '/tmp/site-icon.ico', + '/tmp/site-icon.CUR', + String.raw`C:\tmp\site-icon.ICO`, + '/tmp/site-icon.ico?cache=1', + ])('rejects unsupported icon reads for %s', async (filePath) => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']({ tool: 'read' }, { args: { filePath } }), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('checks read arguments supplied on the hook input', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'read', args: { file_path: '/tmp/site-icon.ico' } }, + {}, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('accepts the generic path argument shape', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { path: '/tmp/site-icon.ico' } }, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('rejects a safe-looking symlink whose target is an unsupported icon', async () => { + const hooks = await loadHooks(); + const targetPath = path.join(tempDir, 'target.ico'); + const symlinkPath = path.join(tempDir, 'preview.png'); + fs.writeFileSync(targetPath, 'not inspected by the plugin', 'utf8'); + fs.symlinkSync(targetPath, symlinkPath); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: symlinkPath } }, + ), + ).rejects.toThrow('cannot safely attach ICO or CUR image files'); + }); + + it('allows a symlink to a supported image path', async () => { + const hooks = await loadHooks(); + const targetPath = path.join(tempDir, 'target.png'); + const symlinkPath = path.join(tempDir, 'preview.png'); + fs.writeFileSync(targetPath, 'not inspected by the plugin', 'utf8'); + fs.symlinkSync(targetPath, symlinkPath); + + await expect( + hooks['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: symlinkPath } }, + ), + ).resolves.toBeUndefined(); + }); + + it.each(['/tmp/screenshot.png', '/tmp/component.ts'])( + 'allows safe reads for %s', + async (filePath) => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']({ tool: 'read' }, { args: { filePath } }), + ).resolves.toBeUndefined(); + }, + ); + + it('does not inspect arguments for other tools', async () => { + const hooks = await loadHooks(); + + await expect( + hooks['tool.execute.before']( + { tool: 'bash' }, + { args: { filePath: '/tmp/site-icon.ico' } }, + ), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index 8a7a0855d..2f2c92265 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -50,6 +50,7 @@ import { SLACK_POSTING_TOOL_EXCLUSIONS } from './slack-posting-tools'; import { SLACK_STOP_HOOK_SCRIPT } from './slack-stop-hook-script'; import { OPENCODE_SLACK_HOOKS_PLUGIN_SCRIPT } from './opencode-slack-hooks-plugin-script'; import { OPENCODE_CHATGPT_GATEWAY_PLUGIN_SCRIPT } from './opencode-chatgpt-gateway-plugin-script'; +import { OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT } from './opencode-tool-safety-plugin-script'; import { resolveOpenCodeModelSelection } from './opencode-model'; import { createProofRunnerAgentPrompt, @@ -192,6 +193,8 @@ const ROOMOTE_OPENCODE_SLACK_HOOKS_PLUGIN_FILE_NAME = 'roomote-slack-hooks.js'; const ROOMOTE_OPENCODE_CHATGPT_GATEWAY_PLUGIN_FILE_NAME = 'roomote-chatgpt-gateway.js'; +const ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME = 'roomote-tool-safety.js'; + const OPENCODE_ALLOW_ALL_PERMISSION = { read: 'allow', edit: 'allow', @@ -620,6 +623,10 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { pluginsDir, ROOMOTE_OPENCODE_CHATGPT_GATEWAY_PLUGIN_FILE_NAME, ); + const toolSafetyPluginPath = path.join( + pluginsDir, + ROOMOTE_OPENCODE_TOOL_SAFETY_PLUGIN_FILE_NAME, + ); const silenceHookPath = path.join( openCodeConfigDir, ROOMOTE_OPENCODE_SLACK_SILENCE_HOOK_FILE_NAME, @@ -636,6 +643,11 @@ function writeOpenCodeManagedFiles(openCodeConfigDir: string): void { OPENCODE_CHATGPT_GATEWAY_PLUGIN_SCRIPT, 'utf8', ); + fs.writeFileSync( + toolSafetyPluginPath, + OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT, + 'utf8', + ); fs.writeFileSync(silenceHookPath, SLACK_SILENCE_HOOK_SCRIPT, 'utf8'); fs.writeFileSync(stopHookPath, SLACK_STOP_HOOK_SCRIPT, 'utf8'); fs.chmodSync(silenceHookPath, 0o755); diff --git a/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts b/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts new file mode 100644 index 000000000..aa81cc3e6 --- /dev/null +++ b/apps/worker/src/run-task/opencode-tool-safety-plugin-script.ts @@ -0,0 +1,61 @@ +export const OPENCODE_TOOL_SAFETY_PLUGIN_SCRIPT = `import { realpath } from 'node:fs/promises'; + +const UNSUPPORTED_READ_IMAGE_EXTENSIONS = new Set(['.cur', '.ico']); + +function getReadPath(input, context) { + const args = context?.args ?? input?.args; + + if (!args || typeof args !== 'object') { + return undefined; + } + + return typeof args.filePath === 'string' + ? args.filePath + : typeof args.file_path === 'string' + ? args.file_path + : typeof args.path === 'string' + ? args.path + : undefined; +} + +function getExtension(filePath) { + const normalized = filePath.split(/[?#]/u, 1)[0]?.toLowerCase() ?? ''; + const basename = normalized.split(/[\\/]/u).pop() ?? ''; + const extensionIndex = basename.lastIndexOf('.'); + + return extensionIndex >= 0 ? basename.slice(extensionIndex) : ''; +} + +async function resolvesToUnsupportedImage(filePath) { + if (UNSUPPORTED_READ_IMAGE_EXTENSIONS.has(getExtension(filePath))) { + return true; + } + + try { + const resolvedPath = await realpath(filePath.split(/[?#]/u, 1)[0]); + return UNSUPPORTED_READ_IMAGE_EXTENSIONS.has(getExtension(resolvedPath)); + } catch { + // Let the read tool report missing or inaccessible paths itself. + return false; + } +} + +export const RoomoteOpenCodeToolSafety = async () => ({ + 'tool.execute.before': async (input, context) => { + if (input?.tool !== 'read') { + return; + } + + const filePath = getReadPath(input, context); + + if (!filePath || !(await resolvesToUnsupportedImage(filePath))) { + return; + } + + throw new Error( + 'The read tool cannot safely attach ICO or CUR image files to the model conversation. ' + + 'Inspect metadata with a text-only command or convert the image to PNG in a temporary directory first.', + ); + }, +}); +`; diff --git a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts index d2b1d76e5..f51669f0d 100644 --- a/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts +++ b/apps/worker/src/sandbox-server/lib/harnesses/__tests__/opencode-server-bootstrap.test.ts @@ -78,6 +78,19 @@ describe('opencode-server bootstrap', () => { ); } + function readOpenCodeToolSafetyPlugin(homeDir: string): string { + return fs.readFileSync( + path.join( + homeDir, + '.config', + 'opencode', + 'plugins', + 'roomote-tool-safety.js', + ), + 'utf8', + ); + } + // The plugin seed gate probes `opencode --version` through OPENCODE_COMMAND. // Pin the probe to a stub so the resolved version always matches the seed // fixtures regardless of whatever opencode CLI the host machine has on PATH @@ -1742,6 +1755,24 @@ describe('opencode-server bootstrap', () => { expect(pluginContent).toContain('R_INFERENCE_GATEWAY_CHATGPT'); }); + it('installs the OpenCode tool safety plugin', async () => { + const { prepareOpenCodeCommandEnv } = + await import('../opencode-server/bootstrap'); + + const homeDir = createTempHome(); + + await prepareOpenCodeCommandEnv({ + runtimeEnv: createDirectHarnessRuntimeEnv(homeDir), + workspacePath: '/tmp/workspace', + logger: createLogger(), + }); + + const pluginContent = readOpenCodeToolSafetyPlugin(homeDir); + + expect(pluginContent).toContain('RoomoteOpenCodeToolSafety'); + expect(pluginContent).toContain("'.ico'"); + }); + it('enables Slack hook debug logs only when Slack reply satisfaction is configured', async () => { const { prepareOpenCodeCommandEnv } = await import('../opencode-server/bootstrap'); From 140a12b791ad143fbab278a51c0da538e0a41d69 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:44:22 -0500 Subject: [PATCH 08/11] [Fix] Prevent members from rewriting deployment environments (#1102) Co-authored-by: Roomote --- .../__tests__/environment-write-auth.test.ts | 67 +++++++++++++++++-- .../environments/createEnvironment.ts | 26 +++++-- .../environments/updateEnvironment.ts | 6 +- 3 files changed, 85 insertions(+), 14 deletions(-) diff --git a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts index 3fe966c99..3b57e0768 100644 --- a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts +++ b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts @@ -11,12 +11,14 @@ const { mockTaskRunFindFirst, mockRepositoriesFindMany, mockEnvironmentsFindFirst, + mockUsersFindFirst, mockCreateSnapshot, mockEnvironmentInsertValues, } = vi.hoisted(() => ({ mockTaskRunFindFirst: vi.fn(), mockRepositoriesFindMany: vi.fn().mockResolvedValue([]), mockEnvironmentsFindFirst: vi.fn().mockResolvedValue(null), + mockUsersFindFirst: vi.fn(), mockCreateSnapshot: vi.fn(), mockEnvironmentInsertValues: vi.fn(), })); @@ -46,6 +48,9 @@ vi.mock('@roomote/db/server', async (importOriginal) => { environments: { findFirst: mockEnvironmentsFindFirst, }, + users: { + findFirst: mockUsersFindFirst, + }, repositories: { findMany: mockRepositoriesFindMany, }, @@ -100,6 +105,7 @@ describe.each([ ] as const)('%s user-context gate', (_name, method, path) => { beforeEach(() => { vi.clearAllMocks(); + mockUsersFindFirst.mockResolvedValue({ role: 'admin', deletedAt: null }); }); it('rejects a deployment-principal run token with no live acting user', async () => { @@ -110,8 +116,9 @@ describe.each([ expect(response.status).toBe(403); await expect(response.json()).resolves.toEqual({ - error: 'User context required', + error: 'Admin access is required to create or update environments.', }); + expect(mockUsersFindFirst).not.toHaveBeenCalled(); }); it('accepts a deployment-principal run token once a live acting user is attached', async () => { @@ -125,13 +132,20 @@ describe.each([ expect(response.status).toBe(400); }); - it('rejects a run token whose task run no longer exists and has no mint-time user', async () => { + it('rejects a run token whose task run no longer exists despite an admin mint-time user', async () => { mockTaskRunFindFirst.mockResolvedValueOnce(null); - const app = createApp(deploymentRunToken()); + const app = createApp({ + runId: 42, + userId: 'user-mint-admin', + principal: 'user', + tokenType: 'run', + version: 1, + }); const response = await app.request(invalidBodyRequest(method, path)); expect(response.status).toBe(403); + expect(mockUsersFindFirst).not.toHaveBeenCalled(); }); it('prefers the live acting user but falls back to the mint-time claim', async () => { @@ -160,6 +174,45 @@ describe.each([ expect(response.status).toBe(400); expect(mockTaskRunFindFirst).not.toHaveBeenCalled(); }); + + it.each([ + ['member', { role: 'member', deletedAt: null }], + ['deleted admin', { role: 'admin', deletedAt: new Date() }], + ])('rejects a %s before processing the write', async (_label, user) => { + mockUsersFindFirst.mockResolvedValueOnce({ + ...user, + }); + + const app = createApp({ + userId: 'user-member', + tokenType: 'auth', + version: 1, + }); + const response = await app.request(invalidBodyRequest(method, path)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: 'Admin access is required to create or update environments.', + }); + expect(mockUsersFindFirst).toHaveBeenCalledOnce(); + expect(mockEnvironmentsFindFirst).not.toHaveBeenCalled(); + }); + + it('checks the live acting user role for deployment-principal runs', async () => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + actingUserId: 'user-live-member', + }); + mockUsersFindFirst.mockResolvedValueOnce({ + role: 'member', + deletedAt: null, + }); + + const app = createApp(deploymentRunToken()); + const response = await app.request(invalidBodyRequest(method, path)); + + expect(response.status).toBe(403); + expect(mockUsersFindFirst).toHaveBeenCalledOnce(); + }); }); describe.each([ @@ -204,6 +257,7 @@ describe.each([ describe('createEnvironment attribution', () => { beforeEach(() => { vi.clearAllMocks(); + mockUsersFindFirst.mockResolvedValue({ role: 'admin', deletedAt: null }); mockEnvironmentsFindFirst.mockResolvedValue(null); mockRepositoriesFindMany.mockResolvedValue([ { @@ -285,6 +339,7 @@ describe('createEnvironment attribution', () => { describe('updateEnvironment repository validation', () => { beforeEach(() => { vi.clearAllMocks(); + mockUsersFindFirst.mockResolvedValue({ role: 'admin', deletedAt: null }); mockEnvironmentsFindFirst.mockResolvedValue({ id: 'env-1', name: 'ADO Test', @@ -327,15 +382,15 @@ describe('updateEnvironment repository validation', () => { describe('environment MCP config reserved env var rejection', () => { beforeEach(() => { vi.clearAllMocks(); + mockUsersFindFirst.mockResolvedValue({ role: 'admin', deletedAt: null }); mockEnvironmentsFindFirst.mockResolvedValue(null); mockRepositoriesFindMany.mockResolvedValue([ { id: 'repo-1', fullName: 'acme/app', installationId: null }, ]); }); - // Environment writes are not admin-gated on this path, so a non-admin can - // reach it through the in-sandbox `environment` MCP tool. Reject configs - // that interpolate runtime credentials before they are ever persisted. + // Admin-gated environment writes must still reject configs that interpolate + // runtime credentials before they are ever persisted. it.each([ [ 'a header value in shell syntax', diff --git a/apps/api/src/handlers/environments/createEnvironment.ts b/apps/api/src/handlers/environments/createEnvironment.ts index 3b9e4b8c5..937e96379 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -10,6 +10,7 @@ import { inArray, repositories, taskRuns, + users, } from '@roomote/db/server'; import { type TaskPayload, @@ -32,6 +33,8 @@ export const DUPLICATE_ENVIRONMENT_NAME_ERROR = 'An environment with this name already exists. This endpoint only creates new environments.'; export const EVAL_ENVIRONMENT_WRITE_ERROR = 'isEval is reserved for internal eval environments.'; +export const ENVIRONMENT_ADMIN_REQUIRED_ERROR = + 'Admin access is required to create or update environments.'; type PostgresErrorLike = { code?: string; @@ -114,18 +117,29 @@ export async function resolveEnvironmentWriteUserId( runId: extractRunId(auth) ?? undefined, }); } catch (error) { - // A malformed run token or a missing task run means there is no - // resolvable live actor; fall back to mint-time attribution. Unexpected - // lookup failures degrade the same way (matching pre-live-actor - // behavior) instead of escaping the handler's structured error path. + // Environment writes must fail closed when a run token is malformed, its + // task run no longer exists, or the live-actor lookup otherwise fails. if (!(error instanceof McpProxyError)) { logHandlerError('resolveEnvironmentWriteUserId', error); } + + return null; } return liveActingUserId ?? auth.userId ?? null; } +export async function canAdministerEnvironments( + userId: string, +): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { role: true, deletedAt: true }, + }); + + return user?.role === 'admin' && user.deletedAt == null; +} + /** * When environment creation/update is triggered by a running task run, persist * the resulting environment id on that job payload so the UI can resolve @@ -216,8 +230,8 @@ export async function createEnvironment( const auth = c.get('mcpAuth'); const userId = await resolveEnvironmentWriteUserId(auth); - if (!userId) { - return c.json({ error: 'User context required' }, 403); + if (!userId || !(await canAdministerEnvironments(userId))) { + return c.json({ error: ENVIRONMENT_ADMIN_REQUIRED_ERROR }, 403); } let body: unknown; diff --git a/apps/api/src/handlers/environments/updateEnvironment.ts b/apps/api/src/handlers/environments/updateEnvironment.ts index a3204cc82..016aac983 100644 --- a/apps/api/src/handlers/environments/updateEnvironment.ts +++ b/apps/api/src/handlers/environments/updateEnvironment.ts @@ -20,8 +20,10 @@ import type { McpAuth } from '../mcp/middleware'; import { logHandlerError } from '../utils'; import { DUPLICATE_ENVIRONMENT_NAME_ERROR, + ENVIRONMENT_ADMIN_REQUIRED_ERROR, EVAL_ENVIRONMENT_WRITE_ERROR, attachEnvironmentIdToTaskRun, + canAdministerEnvironments, getEnvironmentRepositoryConfigError, isEnvironmentNameUniqueViolation, resolveCallingVerificationTaskId, @@ -44,8 +46,8 @@ export async function updateEnvironment( const auth = c.get('mcpAuth'); const userId = await resolveEnvironmentWriteUserId(auth); - if (!userId) { - return c.json({ error: 'User context required' }, 403); + if (!userId || !(await canAdministerEnvironments(userId))) { + return c.json({ error: ENVIRONMENT_ADMIN_REQUIRED_ERROR }, 403); } const id = c.req.param('id'); From 46bbc0d45a643a1eb164faeea78ef75ae6fa7775 Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:03:00 -0400 Subject: [PATCH 09/11] [Fix] Custom provider icons are missing from docs sidebar (#1105) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- apps/docs/logo/integrations/daytona.svg | 2 +- apps/docs/logo/integrations/e2b.svg | 2 +- apps/docs/logo/integrations/pylon.svg | 2 +- apps/docs/logo/integrations/supermemory.svg | 2 +- apps/docs/logo/integrations/zero.svg | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/docs/logo/integrations/daytona.svg b/apps/docs/logo/integrations/daytona.svg index 8e95ad9ec..09740913c 100644 --- a/apps/docs/logo/integrations/daytona.svg +++ b/apps/docs/logo/integrations/daytona.svg @@ -1,4 +1,4 @@ - + + Date: Tue, 4 Aug 2026 17:04:01 -0400 Subject: [PATCH 10/11] [Docs] Add Requesty to inference provider docs (#1106) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- apps/docs/docs.json | 1 + apps/docs/environment-variables.mdx | 1 + apps/docs/logo/integrations/requesty.png | Bin 0 -> 9273 bytes apps/docs/models.mdx | 14 ++--- apps/docs/providers/inference/requesty.mdx | 51 ++++++++++++++++++ .../setup/SetupDocs.client.test.tsx | 1 + .../src/app/(onboarding)/setup/setup-docs.ts | 1 + 7 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 apps/docs/logo/integrations/requesty.png create mode 100644 apps/docs/providers/inference/requesty.mdx diff --git a/apps/docs/docs.json b/apps/docs/docs.json index a4a8bde95..132c3a292 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -79,6 +79,7 @@ "providers/inference/opencode", "providers/inference/opencode-go", "providers/inference/openrouter", + "providers/inference/requesty", "providers/inference/together-ai", "providers/inference/vercel-ai-gateway", "providers/inference/vllm", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 7894de280..ff27fb411 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -183,6 +183,7 @@ as per-task auth tokens or workspace paths. | `R_PLANNING_MODEL_REASONING_EFFORT` | Optional | Reasoning level for the advisor model. | | `R_MODEL_ENV_KEYS` | Optional | Comma- or space-separated list of extra provider key env vars to forward to task workers. | | `OPENROUTER_API_KEY` | Provider key | OpenRouter API key. Can also be saved from **Settings > Models**. | +| `REQUESTY_API_KEY` | Provider key | Requesty API key. Can also be saved from **Settings > Models**. | | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | | `BASETEN_API_KEY` | Provider key | Baseten API key. | | `TOGETHER_API_KEY` | Provider key | Together AI API key. | diff --git a/apps/docs/logo/integrations/requesty.png b/apps/docs/logo/integrations/requesty.png new file mode 100644 index 0000000000000000000000000000000000000000..1ca4d0a2e74d98aca0ec9febd1f14ffd1cd912cb GIT binary patch literal 9273 zcmbVyg3KAk+OD+hKDj>B> zhrkjNQorjPf57|hv(IyP=eaZI%$YOieCBf|&OlFtl8l86002rYO*JC`fPgaG*(NZB!ZQn zX^ewVmMpOn(!#nKRoq(fse=+?m>a7S=^hpv&f;aj_4z*R=75;MQ#CpoHg{41G7{zc zJ~PU8t%t#lA9du}Q>wN;%B-6NFCMn(8>{;MkZ;_&9u0kSt%zYqXr=M}|9p*FJ}5^J zV*9&*>DQjK4} zdsw7imJI$}f~QCgEvlOBh= zb_Y=&qyEEI;P>`Vrckf3!FWj|t4>&nAMFKW!D0kv5mit`9@4>V0jnauPC98zNg}*H zxl~VN`5_wY$|v}rF^t%x(RsE(@wSi<1seOTXX6UOI|qS>tDd9xoIj$JNo$#)do0@^xUbRD)`UB zVvbJ!Dkx~M5m8%yLY|;`EL-eo*GR{*_0s!Fy40N6hi6ZwnEP7=mI!epQ7#h0T_jSF zxl{||ke2kYwYOib*K^c76qmcEk%En~j-?9lq#9KP{MibwcABcXHZd`|M`kr4Vt-1y zZZMsy_LfFw>YH^tUA#Jvo*nloc^0G$HI7wA0vVnzgvpi?1fQiUA(`l~i>w|NImKMM zPnYK>ism$Nk9ghFsq zbRq6We>e@6<06PvpzBah&IirAuAURKt{yT`u3xC`#=rh&cmAB>BIp+TV5NE8ispnd z+n(?5X;Iex>u5R_k=(2BSKr-`7ygr}ZsD)TNq9T&x{(a!jji13q^m}4TJxB`z@Kt) z>@B5p%Lh_NK0x2glfeO^vP?M_c8bzE6^E7XLtk|MwWE8*nW!DuW5c!-Z zcN8vl?ZpGCG1xLls=YkxX{6Z8t_8cT=7y5oND=wlPd(YN8QptOI zSX?a$ziT(s;ViG(+S<0q&OHaUw1ZXnqBSK{VBOr_c8J>u^AgFzPbg2bc+7pjAj;(Q z?kCt*Mh1p^F5_?SLw`9=uqo9!eB1K;v`G3%Be&zZik(dAk1S_5!XcYny7>6`16l7a zuaIO8dD>kPspECxG-Cz{3zGfnG47XLHP-xW$}SZqpGHdO`FsD0bY2|%WjgmPY<0Md zs>xN|crTfek&&=BUAt^N_u+{XY^=d~b_jXAt`_a{mlQ{J3?m4Wq@n!cG*PN`p`FR5 ze}jSHja0F>$z&p}%J)1~CfcCx=CGfio)q55gbsZD`jJXstbgC?aWyr8p=>l^;RGqt z7vTU)$Ho!0C4}*jX35{aR*RNmK8l9$g z%SEM8F7qw5TzznI7Ou$m?2Dk9a=$(rS3GpGyO?`1UH5D>Ea}a4q$cCN?y}kMjsj?T z%xQ!MoAV_-&wp40YoG0(pY#>Wpw???bPtL%pwv9N@87?lUZ^mww&D~gvrg5z=z64~ zjZ!J4iy;U@f3K{pe3-;c6#0;G6^PUn`RTJUhKtXULJ2ZhR8sTQ{(k11Z2Oar#8g2v zxK<^$*Mq4Cb?v;+b~d=U3E*G(K6V3J3488z|JUksiK}G;^nd@jg`(cXBwbuwRPK2J zUDh@2B@>G(rMxgO$`~Ky!;8Qqz=C*GK77nZ-Ffs7@o4k=|7sbNMuE4t3Z8oC0e?MOZI|E-Y<3{Q}Lm9zzHu;-11 zC6|eqkfi#R1+Vj2`*Bzo5Ha40)Rd!pu>@Q>c13~m$Po|?wn+$H!kAnbeA8?LApY44 z!7CZi6VdauW8sU|y3v3<+dD$?R2Mev^IpuqDIhV1|0!`#Bwo4!ogd?%O%tT-<}d$- zgBnGYlUsS~zzodI&v-AE^vH$HNK&Pai3!5g!H(^LCko`9ZT;co3@Us{-(q)1<;tK~ zAL|7AH|B0#pfzjD^a~aKIElgB2txeBh#1)xpRvv&h{Lc@;CXtk7ks z4onG``3&sl?s+or+j`ri(oNF&*NX7tF9X2`gNXUo-Ik$Gm6Z}9&MH}e;pN4i4q-e) z$e9)flN;vv+Y9(!tQs>1E>iPQriibVACBmx<9zqwBQCMj&AVb(ZiLA1`?XLkHMS&K z!K1|c6mR3Z*b%(@tKMl|a>1`B_+lS34}lUhb((M+0nYwGiR=ImN+Btk9q5^(}g>Y@DK>$)Fw>XJN1)w>kPS^iy~*_ z_yJMVAhHU*tUsT5R@m&hHtfzBu)#g*pVbkRtwL6K2g*IZlmmR)l2=0PIrc=YH4eciIhog!o(M-G=Nk?zk=;=v@X1?)t zg4*ll%LG$#_c$?F_{}A7DlUlsw2SMB`1GW)Wv}DBO4fZz&3CLwIY;6~!{_?S%HKyU zvH)4=Q5z8>hoo(Pg8Rwte69qHnx=%HV|43B#eBB-vjFY5JPC>U5bn2TQ8t$!YGqIw z?3}K|OL~e)vkz~|5U3Ne-h1iBC}f6J`1AeYE;4y7uSe?aE8BJWXWb(}U?-@?DS^CUqE70Yu5 zvv`oF7}{|@ZJXTeJLewrmLT=T6@q`6N)}}$s~`;jYMF^^y0esK$9HXgurj#27jh!2 z%M$+xEpXLo>jP}O`^?742zwV6yx+%IwsU25BU{3v&LQ#qU`QlT_4TYvtFB>~CPTu~ z-m4i1VMJFbhI6-}2;$z=ejy>2+xH%I2HR^pOp<&);It;JK6#>7N)H23=->WjAOs6~ zAF^>rJwAL|SdV3QrVexav6yW?#Yq^91OJv_^2DH&Xn5;R(hS5pC_v`Qt)7T7_7yKZ z(S8Jvc3gGmRg39Y9a+lzcg~1ulv=acl|V<<>*5soC*HmCEmy;S8!n+v7I z2UKc#L5YqX_QP=0cJ%48G=l_}aMSL{Tu7%3XeV%e#_}Xx3uK@6lH9o&Z#JDsf=$a} z2ci@UUMfSEcnbcFmmWRa;OxmLV)=VjM&MfMJ1T!eNH}%~g7!@C2x?)ixp!arf=l>j z)#LiF|IF3?_9p0s8aCR){(vD3S_xC$3VBWe=FpGchSan7VVFaztG`p7Ay2-jX~*%z zzjg!uEO`Uho2Or-1lWO5h^Fs9FO3+-aEAU~<#glC;HM-qvoJRHHnM;N1tsmvlS@Tj zz@Pxx60G*yup+h;_ujB%Zq~K)zn+L;d%fW}B|~^o_VV&l5tZUaO(S#c(DOVc?Wr?V zN!&Pk_x;H3$4&F8yuHH)&DDP5=n?8M)X?ei^Evqc|{o}9@ zLp#CLooC}|FwP)@^yq8|0j4SgN>LNaU-Ckf+M3v}Az_99&;NDzhQ#1`xavIv&Ic zWT4OH39ag>Qc_L8`3WxhU0~?L-hBdB^RblDE)n^BNT8|=^yl%=mjTeZtU}J-o&R#G z{@|z;&=tTTzXZB0y&3yy=p9GR2eDESiWfMlU8}1D#=T*sqbA3?R^C%Tx& zAa3^PBYB4Ur*wz|r)}VuRPi9;WMXa3K%O$67}mr>(N*;gRFhmmnULCwLn#A#`tILK z^T$d-*M8Rtkkq)*EBR{d?K(>C>0=2;*b>2&svL z>Wf656XipuW)M1ASbC9FWLMdM5*&TP7CoO-`Fj81{rmU5Cxc~cw3=4mjG(k#yof7L zG?vA8C{}==QSRxTvB$C~H*f4FgM`3Nu{d^uC%>z1RAGB{zm7!UWNVVU)T61!d)q`B z2<>@yZvBZJAkr868BT0}!;4tRdBlbwAL7gPS;Ft53GD{phyJ*E&b8#A_`89N8xDxv z`#y-@LG$CNZHBW%EfvDqQ17|tTGPR(kIs9l(F%D;7DFJR^z-hD0V+FA&Qi`e4T_ha zYdsu_lmgles1O-XUbYxMr%VjNW4kPG3Hv%smHl5X00GTTWt>sU*wR(=06LxpBAG7C z-?9OX&`@XPl8Gcu4;s}V4wA1y1gsND(r13xmnW>9iaahIA1f>6L_pmNO5^@5Qv=Nh z{ed*QO8q8>Z!Fs%sRtpa1>W_baY+hFQ`7nTly$qzgWvpm`r+VTI=ruC9*p5HNq)nI z>iqEjy&1!aKk%ckt`GlqTSxM1GTs3G23H{C{qejD+?=h07_Zn+p>c>D9u4i^D~!!0 zsvm*IdH@I>c=cHqL5@9;N=0xAvX6KY`pq#s!|NjU_#T6ni@qzjr}ZAHr0L$6 zUfier{3x$0bfY+CZgaA{b?D8V^JDb57d_(kT|)ewVW@!a7wEwI9kfNm_F`w}v!hYP z>$XLpICmyARCo>m0Ons z#=bbo>s~;7AWhaYbY4Up9b${g{ko3v_-{p|4A8bgX`M`zs4XQCFoI2Hl^m&<#q2a~ zhTvg+@3c%r))-b&c{R3l&__0m3K%Ea9Y7d5E97XQ5<84NG%y&!icb}!`)|4&z2r2# zrEqm>>*`5c!k!Pw@_9+bi1-dcmsp?}=U*x>zaTon9um%(TKf0pc8zA(cFm}d*a%rP zjmhq%+-A<3leqMcEv*q`q9}N607D_qZeYr1!)AKi#rFCbt-KWfZt%Lc<&KD-jxQNR zpVMW9q66J#og102<HFhQOmEGpHqG#Ay~ zvWM#sK6tj2IurtKyUc>4W5NyPz#6^wu!6q_D zi19ZG_0M>)vICNc+90OUt4TfiRG^_YA=HQscoosz8szd%BDX}{#mkvP&g}0`I)a)( zt=`U1u(k^b!nxX*2Yi2oF+UJL!+e2SUqR?R76P{Kh>)k=?9p9hP4%hVnJgdO)N%*@ z0R`xu7$#i3pQ37BlYlygy%IiLe=hC0=9eDuD-$Fw6ln`cZro|>A|z*F7N6tKeE_4* z{=RI(Rry)maf~bKjE6obMPj*}8j?^`lq%X|+t6H9pi|qp4*HBJod-z8y+c}+=18V( z0eE6x83k~7sBGG6I2&ju|Ih@9XbR@Hk!&{cf?D1{ED(Ux?Wip(7VXlKWO>fh$4#`o zUgsx3=kdiyLCI^yWn0_uY|nZ@vTkG{Jn~7rxtN91NwFK^D#5ijI<21QySr>Uu*CATNP zBdRj+w3klb&wix&YtJRa++%s?UlP@1V!okb9=0-n-rj%JHu#u7s zG7n+}Pu|l^K(1(Z+a`HX%iuVPjic595~$%Iu9t=Enhosoq<=P&x7l%VFtmGdcG#Xr z;S%NG!644rY#86)d-G9@}izz&A~ZA9Lw?TVN`?SU7e|LxOM zv#gVp4{(%o;}~REI}YlmG8^XmWX#$oMaKuQaFGbUu82rF(-(9H@Il}3(X+aeW?50& zJK0-6)9~}ZSDaxQ50?PEI-_jyP1qzQ1MwgzB=Kq4Ug+H{5-Mb2`EA`P4}bG#0RI5@vrxJBLrm|F#%O#HN0PZW2S zX=)W5WuEYo^tBFIB4bc`jtor{sny&sJNNAiHQ%0#({;J){-4GbK)I%QA@2l zs}N?JV#d5Z`!2DW6Dp>5BG&ZNl;vNabKSGKHS0@ww-oG|-xOfBx1?h#; z+mn=xYgj#{JqklNv&fZPeND3eVLnH^5l$J$2Y7E0-F7UA}RP z(Jp`_+*pl-^GO6lBM0n${^Q<_=-@@<3qjRHl1i~UYdzM;)1C}jfI}@-a?z%XlA0$y zn+WFi;ww9_aP$C?5%cmdF-htNHX#ua&aYy@pu|`pB$C|Bxl5OthgTWV2BL=D54VXJ zK7n{#UuTh!Ta11h3(J@F)w?_`{P^*s3@PJ&(q=A~TC_(|W#vmI9nn9yP_?F5e}*{{ zZZY`LRpILD>VLMw;1$j)coSwY|{r=jYD|4OZUE)KGs|qE@+9^xgis!#3HlN6Ppe^1Bw> zPm+3^k(ntnGBngmR%VL%vJ6W{0aE%nWJuX*FjQ?W}9C{>0z0qzOPq_a$r9L)VB9`Z5t`U+vogttoC zA|Oe1JhmBDEH5g;uA4+%j_2HUGGVu{la&h7Wtc;9PlTz~lzYck!5W`WuFWmK%*M;+ zczAdStHr*()1~qqp7f`&`%RhG-oj<(ze3vh7w0L>HiJmP%Vy=S@LYk&!lLm|%2{*D zo=}SDMY-FzE2^I7CYiI#`m{+fQ3!v*Mw8i)=P@Z_BiAr~Y4@lm=Lv1EVW+_HfrpmX zAih98aA6K)4-c!FmdFle&zFqnTfFrJgoN6@8O9+~L&j35WQy6VB^=)176^DP$$2f~ zFgL9s1*6I$SLG8E6W7r;*Bz$SmQd?C`y!{1kdW=UxjCmUdT_ZrYCuNv**({Vixm(W z%*b~n-*>d@IXF1@fXlW2t4jA)hDOz)TF17GJCtbolD3k7Gb{?Z(u`dBfLy6Xu4tg= zk!w$E2K+ZxT-37`2%_gopESBKdT)LIU;{$>(dNHf6|533r)xMex9}&J$r|3QhM|nO z#6HTeONm}ymMBelOph9?_paX zxuvD0ZNj_Iy!`xm>i!^;$ueF22%5qh&cn<`M@*K45Nqbs=aMguVe#Oh&EE!yHl(H z{W@XxgeJ07E9rpMC-~;Z9s_(pQaV^G3mr@ZNn((`Wb%YY&LNgt-6v8L&YDuVhq5N` z_P+teAH3KzlvEJGjqxD9((T@Louc!XG3@15ld*HUwi&ifoTmow;w7 z=lV$I>dJ}*p{WLe>L|8N1b%}E@MD%{olHMchR{;O~ibJHN)g7+60YuW;C z#R&0f2CGP8(lX@!l?z*A@iLl4RVR+HD?_XD`4HHcH)5r zP82cb_Mg7FHU3B9wewuMll?_nb+6^?%{~bsHIj9-<7kYoWVq-xq5NUgMz(a|Wdx{h zUKFnPWWSArGCTAkt7?*|(}!_%bBG&UJYdk}{KzG#ok*ZR7GFZ2z$7y$Yza zIiD0t=y+J`-W4VCs-UvlwxvCd?>@-Oyca~Qra4~ZYHMo`*^eOkI=wmQ(FM7P zC6c0byqKjqrn3G7opes;+hn>WARxsF$9^MZxt5ofmKF=qI*0&$Y!s)QL5jR$_si!7 z?^DS4dFAqHeJln8_edmer1IVVUa!+!a?Q2G1TlJh44vdZ}&K}ikr)2=eh|w^c>gurl zy=5&GvSC6!&J+hvn3kIhdNf(d|GkF`NIFTNPyna zlW*XRg5tQlaBkJt&%yH^F<2H=whhsGU@Gcg4)Osk3H#?=cut~Z)Ms$g+R~=hfB*i! fU7xYqxQ5)zS0sH<=sOGE{{gh_>#0?#Scm@)tM`^& literal 0 HcmV?d00001 diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index c716bb9e9..7fafd53da 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -53,6 +53,7 @@ These connections use metered API billing or a provider-managed gateway: | [OpenAI](/providers/inference/openai) | OpenAI API key | OpenAI API project | | [OpenCode Zen](/providers/inference/opencode) | OpenCode API key | OpenCode account | | [OpenRouter](/providers/inference/openrouter) | OpenRouter API key | OpenRouter balance and key limit | +| [Requesty](/providers/inference/requesty) | Requesty API key | Requesty organization | | [Together AI](/providers/inference/together-ai) | Together AI API key | Together AI account | | [Vercel AI Gateway](/providers/inference/vercel-ai-gateway) | AI Gateway API key | Vercel team | | [xAI](/providers/inference/xai) | xAI API key | xAI API team | @@ -154,12 +155,13 @@ R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna ``` Roomote automatically makes common provider configuration available to the -model runtime, including OpenRouter, Vercel AI Gateway, OpenAI, Azure OpenAI, -Azure AI Foundry, Anthropic, Google Gemini, Moonshot, Kimi for Coding, MiniMax, -Z.AI, OpenCode, Amazon Bedrock, xAI, and GitHub Copilot. For gateway-supported -providers, credentials stay on the control plane and model requests are proxied -instead of forwarding the credentials into task workers. Use `R_MODEL_ENV_KEYS` -when a provider key uses a custom env var name and must be forwarded: +model runtime, including OpenRouter, Requesty, Vercel AI Gateway, OpenAI, Azure +OpenAI, Azure AI Foundry, Anthropic, Google Gemini, Moonshot, Kimi for Coding, +MiniMax, Z.AI, OpenCode, Amazon Bedrock, xAI, and GitHub Copilot. For +gateway-supported providers, credentials stay on the control plane and model +requests are proxied instead of forwarding the credentials into task workers. +Use `R_MODEL_ENV_KEYS` when a provider key uses a custom env var name and must be +forwarded: ```sh R_MODEL_ENV_KEYS=CUSTOM_PROVIDER_API_KEY diff --git a/apps/docs/providers/inference/requesty.mdx b/apps/docs/providers/inference/requesty.mdx new file mode 100644 index 000000000..4aa878f74 --- /dev/null +++ b/apps/docs/providers/inference/requesty.mdx @@ -0,0 +1,51 @@ +--- +title: Requesty +icon: '/logo/integrations/requesty.png' +description: Route Roomote model calls through Requesty's multi-provider AI gateway. +--- + +Requesty is a multi-provider inference gateway with centralized routing, +fallbacks, usage analytics, and cost controls. Use it when you want one +connection for models from several vendors. + +## Get an API key + +Sign up for Requesty and create a key on the [API Keys page](https://app.requesty.ai/api-keys). +Add credits or configure billing and any key-level spending limits before +connecting the key to Roomote. + +## Configuration + +Add **Requesty** in **Settings > Models** and paste the key, or configure: + +```sh +REQUESTY_API_KEY=... +``` + +Roomote exposes its recommended Requesty models under the `requesty/` +prefix. Some model IDs include a region suffix such as `@eu`. Apply the +recommended mapping or select models individually for each role. + +## Cost behavior + +Requesty bills routed model usage according to the account's plan, provider +pricing, and any bring-your-own-key configuration. Roomote records token usage +and estimates model cost from metadata, but Requesty's usage dashboard and bill +are authoritative. See [Requesty pricing](https://www.requesty.ai/pricing) for +current terms. + +## Verify setup + +1. save `REQUESTY_API_KEY` +2. confirm Requesty models appear in **Settings > Models** +3. apply the recommended mapping or assign a model to the coding role +4. run a small task and confirm it appears in [Requesty analytics](https://app.requesty.ai/analytics) + +## Common issues + +- **The key is rejected.** Confirm it is active and belongs to the intended + Requesty organization. +- **A model cannot be routed.** Check model availability, approved-model rules, + routing policies, regional support, and account balance in Requesty. +- **A spending limit blocks tasks.** Raise the applicable Requesty key or + organization limit, or choose a model with available budget. diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx index 6ef8b7678..5f6bc71d6 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -54,6 +54,7 @@ describe('SetupDocs', () => { ['opencode', 'opencode'], ['opencode-go', 'opencode-go'], ['openrouter', 'openrouter'], + ['requesty', 'requesty'], ['togetherai', 'together-ai'], ['vercel', 'vercel-ai-gateway'], ['vllm', 'vllm'], diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts index fa9db79aa..988374701 100644 --- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -68,6 +68,7 @@ const MODEL_PROVIDER_DOC_PATHS: Partial< opencode: 'providers/inference/opencode', 'opencode-go': 'providers/inference/opencode-go', openrouter: 'providers/inference/openrouter', + requesty: 'providers/inference/requesty', togetherai: 'providers/inference/together-ai', vercel: 'providers/inference/vercel-ai-gateway', vllm: 'providers/inference/vllm', From 55fa64c370fb8839b127fdd031ab1eb5e344589a Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:38:11 -0400 Subject: [PATCH 11/11] Release Roomote 0.34.0 (#1107) * chore: release Roomote 0.34.0 * chore: remove release contributor acknowledgement --------- Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb4023a6..5034b2584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.34.0 (2026-08-04) + +This release adds Granola and Requesty connections, introduces Mind reader mode for expanded model thoughts, and improves task reliability across mixed-provider workspaces and chat delivery failures. + +### Highlights + +- Connect Granola once for deployment-wide access to approved meeting notes, folders, and transcripts. +- Connect Requesty from setup or model settings with a curated catalog of current supported models. +- Expand model thought blocks by default with the optional Mind reader mode. +- Launch tasks reliably from workspaces that combine repositories across supported source-control providers. + +### Minor changes + +- Let deployment administrators connect Granola once so Roomote can securely browse the notes, folders, and transcripts allowed by the configured API key. +- Add an optional Mind reader mode that expands LLM thought blocks by default while preserving manual conversation-level choices. +- Let users connect Requesty from setup or model settings and start with a curated catalog of current supported models. + +### Patch changes + +- Use Roomote's stable branded Discord invite from the repository and in-app release notes. +- Keep tasks running when agents attempt to read unsupported ICO or CUR files by returning a recoverable tool error before provider submission. +- Launch tasks reliably from workspaces that combine repositories across GitHub, GitLab, Gitea, Azure DevOps, and Bitbucket with provider-scoped credentials. Thanks to @jantekb for reporting [#1082](https://github.com/RooCodeInc/Roomote/issues/1082). +- Require an active deployment administrator to create or update shared environments through Roomote's MCP tools. +- Stop completed chat tasks from entering repeated closeout attempts when message delivery has failed permanently. + ## 0.33.0 (2026-08-04) This release adds new ways to connect and invoke Roomote, gives deployments clearer account-linking guidance, and improves automation and Amazon Bedrock model discovery. diff --git a/package.json b/package.json index e5ca54906..144d1abaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "0.33.0", + "version": "0.34.0", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": {