From ecd5cb690fb0213901257e3e906b8c49301e4de9 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:32:31 -0400 Subject: [PATCH 001/158] [Chore] Pin Infinity image to immutable digest (#1756) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- deploy/compose/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 87807e632..bce742b68 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -566,7 +566,7 @@ services: infinity: profiles: - local-inference - image: michaelf34/infinity:0.0.76-cpu + image: michaelf34/infinity:0.0.76-cpu@sha256:2a464dcc06e659a277bc841b4be196100489076446482925481b2c5c120fce57 restart: unless-stopped networks: [default] command: From 32c9b141fcf4e7d220c08df94905a72baa2087ff Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:33:21 -0400 Subject: [PATCH 002/158] [Docs] Show how to start from a new repository (#1751) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ffe9f22dc..71f84a979 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Roomote handles the work that pulls you off your main project: migration files, boilerplate. - **Build small features.** "Add a dark mode toggle to settings." It writes the code, runs the app, takes a screenshot, and opens a PR with a preview link. +- **Start from scratch.** Create an empty GitHub repository from Roomote, then + use the first task to build the project in an isolated environment. - **Triage issues.** Connect Linear, Jira, or GitHub Issues. It reads new tickets, asks clarifying questions, and starts working. From b476e2ba57d266bedf1011d7f300f50d686a10fa Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:55:13 -0400 Subject: [PATCH 003/158] [Fix] Suggested tasks do not appear in Teams and Telegram automation reports (#1757) * fix: launch Fast automation suggestions on Teams and Telegram * fix: claim automation suggestion sends before posting --------- Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../__tests__/fast-agent-service.test.ts | 74 ++++---- .../server/fast-agent/fast-agent-prompt.ts | 2 +- .../server/fast-agent/fast-agent-service.ts | 7 +- .../lib/fast-agent-parent-event.test.ts | 92 ++++++++++ .../src/server/lib/fast-agent-parent-event.ts | 82 ++++++++- .../lib/fast-automation-suggestions.test.ts | 161 ++++++++++++++++++ .../server/lib/fast-automation-suggestions.ts | 148 +++++++++++++++- 7 files changed, 520 insertions(+), 46 deletions(-) diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index d5f803155..ce25ce75f 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -1919,42 +1919,46 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ); }); - it('passes structured suggestions through an automation closeout', async () => { - const adapter = callbacks(); - const suggestions = [ - { - title: 'Investigate checkout latency', - brief: 'Trace the slow payment-provider requests.', - }, - ]; - mocks.generateText.mockImplementation( - async (_params, _session, options) => { - await options.onSessionReady('opencode-session-1'); - await expect( - invokeTool(nativeToolNames.sendChatReply, { - purpose: 'closeout', - message: 'Checkout latency increased this week.', - suggestions, - }), - ).resolves.toMatchObject({ success: true, closed: true }); - return ''; - }, - ); + it.each(['slack', 'discord', 'teams', 'telegram'] as const)( + 'passes structured suggestions through a %s automation closeout', + async (surface) => { + const adapter = callbacks(); + const suggestions = [ + { + title: 'Investigate checkout latency', + brief: 'Trace the slow payment-provider requests.', + }, + ]; + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + await expect( + invokeTool(nativeToolNames.sendChatReply, { + purpose: 'closeout', + message: 'Checkout latency increased this week.', + suggestions, + }), + ).resolves.toMatchObject({ success: true, closed: true }); + return ''; + }, + ); - await answerFastAgentQuestion({ - ...baseParams, - adapter, - turnSource: 'platform_event', - platformEventKind: 'automation', - platformEventVisibility: 'required', - }); + await answerFastAgentQuestion({ + ...baseParams, + conversation: { ...baseParams.conversation, surface }, + adapter, + turnSource: 'platform_event', + platformEventKind: 'automation', + platformEventVisibility: 'required', + }); - expect(adapter.postReply).toHaveBeenCalledWith({ - purpose: 'closeout', - message: 'Checkout latency increased this week.', - suggestions, - }); - }); + expect(adapter.postReply).toHaveBeenCalledWith({ + purpose: 'closeout', + message: 'Checkout latency increased this week.', + suggestions, + }); + }, + ); it('rejects structured suggestions outside automation reports', async () => { mocks.generateText.mockImplementation( @@ -1969,7 +1973,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ).resolves.toEqual({ success: false, error: - 'Launchable suggestions are available only on Slack or Discord automation closeouts.', + 'Launchable suggestions are available only on chat automation closeouts.', }); return ''; }, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 178d794af..72a0e9467 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -258,7 +258,7 @@ ${ ${ platformEventKind === 'automation' ? `- Execute the automation prompt now. Use integrations directly when sufficient, and launch a task only when repository or workspace execution is actually required. The configured model is a delegated-task default, not the Fast inference model. -- When the automation asks for launchable suggested tasks and this is a Slack or Discord report, put each concrete follow-up in the closeout's \`suggestions\` array. Keep the report summary in \`message\`; do not render suggestion cards or reaction instructions as inline prose because the delivery layer adds them. +- When the automation asks for launchable suggested tasks and this is a Slack, Discord, Teams, or Telegram report, put each concrete follow-up in the closeout's \`suggestions\` array. Keep the report summary in \`message\`; do not render suggestion cards or launch instructions as inline prose because the delivery layer adds them. - If launchable suggestions are unavailable on the current surface, keep follow-ups as ordinary report text and do not promise reaction-triggered launching. ` : '' diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index d6429cf7f..33118bd5f 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -1417,13 +1417,14 @@ export async function answerFastAgentQuestion({ (args.purpose !== 'closeout' || !platformEvent || platformEventKind !== 'automation' || - (conversation.surface !== 'slack' && - conversation.surface !== 'discord')) + !['slack', 'discord', 'teams', 'telegram'].includes( + conversation.surface, + )) ) { return { success: false, error: - 'Launchable suggestions are available only on Slack or Discord automation closeouts.', + 'Launchable suggestions are available only on chat automation closeouts.', }; } if ( diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 2628a0fb1..56e9a7a28 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({ appendSuggestionInstruction: vi.fn((message: string) => message), postSlackSuggestions: vi.fn(), postDiscordSuggestions: vi.fn(), + postTeamsSuggestions: vi.fn(), + postTelegramSuggestions: vi.fn(), })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -166,6 +168,8 @@ vi.mock('./fast-automation-suggestions', () => ({ appendFastAutomationSuggestionInstruction: mocks.appendSuggestionInstruction, postFastAutomationSuggestionsToSlack: mocks.postSlackSuggestions, postFastAutomationSuggestionsToDiscord: mocks.postDiscordSuggestions, + postFastAutomationSuggestionsToTeams: mocks.postTeamsSuggestions, + postFastAutomationSuggestionsToTelegram: mocks.postTelegramSuggestions, })); import { deliverFastAgentParentEvent } from './fast-agent-parent-event'; @@ -233,6 +237,8 @@ describe('deliverFastAgentParentEvent', () => { mocks.resolveUserMcpServerConfigs.mockResolvedValue({}); mocks.postSlackSuggestions.mockResolvedValue(undefined); mocks.postDiscordSuggestions.mockResolvedValue(undefined); + mocks.postTeamsSuggestions.mockResolvedValue(undefined); + mocks.postTelegramSuggestions.mockResolvedValue(undefined); mocks.setPendingPrReviewAction.mockResolvedValue(undefined); mocks.attachPendingPrReviewActionMessage.mockResolvedValue({ attached: true, @@ -858,6 +864,92 @@ describe('deliverFastAgentParentEvent', () => { expect(mocks.teamsPostMessage).not.toHaveBeenCalled(); }); + it.each([ + { + surface: 'teams' as const, + workspaceId: 'tenant-1', + channelId: 'teams-channel-1', + threadId: 'teams-root-1', + rootMessageId: 'teams-root-1', + postSuggestions: mocks.postTeamsSuggestions, + }, + { + surface: 'teams' as const, + workspaceId: 'tenant-1', + channelId: 'teams-channel-1', + threadId: undefined, + rootMessageId: undefined, + postSuggestions: mocks.postTeamsSuggestions, + }, + { + surface: 'telegram' as const, + workspaceId: 'telegram-chat-1', + channelId: 'telegram-chat-1', + threadId: undefined, + rootMessageId: undefined, + postSuggestions: mocks.postTelegramSuggestions, + }, + ])( + 'posts structured suggestions beneath a Fast $surface automation report', + async ({ + surface, + workspaceId, + channelId, + threadId, + rootMessageId, + postSuggestions, + }) => { + const suggestions = [ + { title: 'Verify retry behavior', brief: 'Exercise the failure path.' }, + ]; + mocks.answerQuestion.mockImplementationOnce( + async ({ + adapter, + }: { + adapter: { postReply: (reply: unknown) => unknown }; + }) => + adapter.postReply({ + purpose: 'closeout', + message: 'Retry failures increased.', + suggestions, + }), + ); + + await deliverFastAgentParentEvent({ + parent: { + ...parent, + conversation: { + surface, + workspaceId, + conversationId: `${surface}-occurrence-1`, + replyTarget: { + channelId, + ...(threadId ? { threadId } : {}), + }, + }, + }, + event: { + type: 'automation_triggered', + eventId: `${surface}-occurrence-1`, + automationId: 'automation-1', + automationName: 'Retry scan', + prompt: 'Find actionable retry failures.', + trigger: 'schedule', + ...(rootMessageId ? { rootMessageId } : {}), + }, + }); + + expect(postSuggestions).toHaveBeenCalledWith( + expect.objectContaining({ + channelId, + eventId: `${surface}-occurrence-1`, + createdByUserId: 'u1', + suggestions, + }), + ); + }, + ); + it("refreshes Teams routing from the persisted session's current channel", async () => { const fallbackConversation = { surface: 'teams' as const, diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index fc4384a9b..a0cb12acf 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -58,6 +58,8 @@ import { appendFastAutomationSuggestionInstruction, postFastAutomationSuggestionsToDiscord, postFastAutomationSuggestionsToSlack, + postFastAutomationSuggestionsToTeams, + postFastAutomationSuggestionsToTelegram, } from './fast-automation-suggestions'; import { @@ -1068,12 +1070,25 @@ async function createTeamsFastAgentParentTurn(params: { conversation, serviceUrl, }), - postReply: async ({ message, imageArtifactIds = [], kickoff }) => { + postReply: async ({ + message, + imageArtifactIds = [], + suggestions = [], + kickoff, + }) => { const images = await buildSelectedImages({ artifactIds: imageArtifactIds, event: params.event, }); - const text = `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'teams', sessionId: params.parent.sessionId })}`; + const reportMessage = + params.event.type === 'automation_triggered' && !kickoff + ? appendFastAutomationSuggestionInstruction( + message, + 'teams', + suggestions.length > 0, + ) + : message; + const text = `${reportMessage}\n\n${buildFastSessionReplyFooterText({ provider: 'teams', sessionId: params.parent.sessionId })}`; if ( params.event.type === 'automation_triggered' && params.event.rootMessageId && @@ -1092,6 +1107,19 @@ async function createTeamsFastAgentParentTurn(params: { conversation, messageId: params.event.rootMessageId, }); + if (suggestions.length > 0) { + await postFastAutomationSuggestionsToTeams({ + provider, + channelId: conversation.replyTarget.channelId, + serviceUrl, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } params.onReplyPosted(); return { messageId: params.event.rootMessageId }; } @@ -1108,6 +1136,23 @@ async function createTeamsFastAgentParentTurn(params: { textFormat: 'markdown', images, }); + if ( + params.event.type === 'automation_triggered' && + !kickoff && + suggestions.length > 0 + ) { + await postFastAutomationSuggestionsToTeams({ + provider, + channelId: conversation.replyTarget.channelId, + serviceUrl, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } await recordFastAgentConversationMessageBestEffort({ sessionId: session.id, conversation, @@ -1151,20 +1196,49 @@ async function createTelegramFastAgentParentTurn(params: { userId: session.userId, conversation, }), - postReply: async ({ message, imageArtifactIds = [] }) => { + postReply: async ({ + message, + imageArtifactIds = [], + suggestions = [], + kickoff, + }) => { const images = await buildSelectedImages({ artifactIds: imageArtifactIds, event: params.event, }); + const reportMessage = + params.event.type === 'automation_triggered' && !kickoff + ? appendFastAutomationSuggestionInstruction( + message, + 'telegram', + suggestions.length > 0, + ) + : message; const posted = await provider.postMessage({ channelId: conversation.replyTarget.channelId, ...(conversation.replyTarget.threadId ? { threadId: conversation.replyTarget.threadId } : {}), - text: `${message}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: params.parent.sessionId })}`, + text: `${reportMessage}\n\n${buildFastSessionReplyFooterText({ provider: 'telegram', sessionId: params.parent.sessionId })}`, textFormat: 'markdown', images, }); + if ( + params.event.type === 'automation_triggered' && + !kickoff && + suggestions.length > 0 + ) { + await postFastAutomationSuggestionsToTelegram({ + provider, + channelId: conversation.replyTarget.channelId, + ...(conversation.replyTarget.threadId + ? { threadId: conversation.replyTarget.threadId } + : {}), + eventId: params.event.eventId, + createdByUserId: session.userId, + suggestions, + }); + } params.onReplyPosted(); return { messageId: posted.messageId }; }, diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts index 147e0779f..7f879f467 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.test.ts @@ -11,6 +11,8 @@ import { appendFastAutomationSuggestionInstruction, postFastAutomationSuggestionsToDiscord, postFastAutomationSuggestionsToSlack, + postFastAutomationSuggestionsToTeams, + postFastAutomationSuggestionsToTelegram, } from './fast-automation-suggestions'; describe('Fast automation suggestions', () => { @@ -130,6 +132,162 @@ describe('Fast automation suggestions', () => { }); }); + it('persists and tracks reaction-launchable Teams suggestion cards', async () => { + const user = await userFactory.create(); + const postMessage = vi.fn().mockResolvedValue({ + provider: 'teams', + channelId: 'conversation-1', + threadId: 'thread-1', + messageId: 'message-1', + }); + + await postFastAutomationSuggestionsToTeams({ + provider: { postMessage }, + channelId: 'conversation-1', + serviceUrl: 'https://smba.example.com/amer/', + threadId: 'thread-1', + eventId: 'automation-teams', + createdByUserId: user.id, + suggestions: [ + { title: 'Verify Teams retries', brief: 'Exercise the failure path.' }, + ], + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'conversation-1', + serviceUrl: 'https://smba.example.com/amer/', + threadId: 'thread-1', + }), + ); + const [tracked] = await db + .select() + .from(trackedMessages) + .where(eq(trackedMessages.surface, 'teams')); + expect(tracked).toMatchObject({ + channelId: 'conversation-1', + messageTs: 'message-1', + threadTs: 'thread-1', + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }); + + it('persists and tracks button-launchable Telegram suggestion cards', async () => { + const user = await userFactory.create(); + const postMessage = vi.fn().mockResolvedValue({ + provider: 'telegram', + channelId: 'chat-1', + messageId: 'message-1', + }); + + await postFastAutomationSuggestionsToTelegram({ + provider: { postMessage }, + channelId: 'chat-1', + eventId: 'automation-telegram', + createdByUserId: user.id, + suggestions: [ + { + title: 'Verify Telegram retries', + brief: 'Exercise the failure path.', + }, + ], + }); + + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'chat-1', + buttons: [ + [ + { + text: 'Start', + callbackData: expect.stringMatching(/^idea:/), + }, + ], + ], + }), + ); + const [tracked] = await db + .select() + .from(trackedMessages) + .where(eq(trackedMessages.surface, 'telegram')); + expect(tracked).toMatchObject({ + channelId: 'chat-1', + messageTs: 'message-1', + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }); + + it.each([ + { + surface: 'teams' as const, + post: postFastAutomationSuggestionsToTeams, + providerResult: { + provider: 'teams' as const, + channelId: 'conversation-retry', + messageId: 'teams-message-retry', + }, + extra: { + serviceUrl: 'https://smba.example.com/amer/', + }, + }, + { + surface: 'telegram' as const, + post: postFastAutomationSuggestionsToTelegram, + providerResult: { + provider: 'telegram' as const, + channelId: 'conversation-retry', + messageId: 'telegram-message-retry', + }, + extra: {}, + }, + ])( + 'does not duplicate $surface cards when the provider outcome is unknown', + async ({ post, providerResult, extra }) => { + const user = await userFactory.create(); + const postMessage = vi + .fn() + .mockRejectedValueOnce(new Error('provider response lost')) + .mockResolvedValue(providerResult); + const params = { + provider: { postMessage }, + channelId: 'conversation-retry', + eventId: `automation-retry-${providerResult.provider}`, + createdByUserId: user.id, + suggestions: [ + { + title: 'Retry-safe delivery', + brief: 'Do not post this card twice.', + }, + ], + ...extra, + }; + + await expect(post(params as never)).rejects.toThrow( + 'provider response lost', + ); + await post(params as never); + + expect(postMessage).toHaveBeenCalledOnce(); + const [claim] = await db + .select() + .from(trackedMessages) + .where( + and( + eq(trackedMessages.surface, providerResult.provider), + eq(trackedMessages.channelId, 'conversation-retry'), + ), + ); + expect(claim).toMatchObject({ + channelId: 'conversation-retry', + messageTs: null, + createdByUserId: user.id, + metadata: expect.objectContaining({ launchRouting: 'router' }), + }); + }, + ); + it('serializes concurrent persistence retries for one automation event', async () => { const user = await userFactory.create(); const postMessage = vi.fn().mockResolvedValue('400.001'); @@ -166,5 +324,8 @@ describe('Fast automation suggestions', () => { expect( appendFastAutomationSuggestionInstruction('Report', 'slack', false), ).toBe('Report'); + expect( + appendFastAutomationSuggestionInstruction('Report', 'telegram', true), + ).toContain('Tap Start'); }); }); diff --git a/packages/sdk/src/server/lib/fast-automation-suggestions.ts b/packages/sdk/src/server/lib/fast-automation-suggestions.ts index c0f984767..df36c1599 100644 --- a/packages/sdk/src/server/lib/fast-automation-suggestions.ts +++ b/packages/sdk/src/server/lib/fast-automation-suggestions.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto'; import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; +import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; +import type { TelegramCommunicationProvider } from '@roomote/communication/telegram-provider'; import { and, asc, @@ -10,6 +12,7 @@ import { inArray, registerTrackedSuggestionCards, sql, + trackedMessages, workItems, } from '@roomote/db/server'; import { @@ -28,7 +31,7 @@ type PersistedFastAutomationSuggestion = FastAutomationSuggestion & { export function appendFastAutomationSuggestionInstruction( message: string, - surface: 'slack' | 'discord', + surface: 'slack' | 'discord' | 'teams' | 'telegram', hasSuggestions: boolean, ): string { if (!hasSuggestions) return message; @@ -36,7 +39,9 @@ export function appendFastAutomationSuggestionInstruction( const instruction = surface === 'slack' ? "Want me to take one of these on? React with a :thumbsup: on a suggested task below and I'll start it." - : "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it."; + : surface === 'telegram' + ? "Want me to take one of these on? Tap Start on a suggested task below and I'll launch it." + : "Want me to take one of these on? React with a 👍 on a suggested task below and I'll start it."; return message.includes(instruction) ? message : `${message}\n\n${instruction}`; @@ -148,7 +153,7 @@ function formatSuggestion( } async function trackSuggestion(params: { - surface: 'slack' | 'discord'; + surface: 'slack' | 'discord' | 'teams' | 'telegram'; channelId: string; messageId: string; threadId?: string; @@ -172,6 +177,56 @@ async function trackSuggestion(params: { ]); } +async function claimSuggestionSend(params: { + surface: 'teams' | 'telegram'; + channelId: string; + threadId?: string; + workItemId: string; + createdByUserId: string; + eventId: string; +}): Promise { + const [claim] = await db + .insert(trackedMessages) + .values({ + surface: params.surface, + kind: 'suggestion_card', + dedupeKey: `${params.surface}:${params.channelId}:${params.eventId}:${params.workItemId}`, + channelId: params.channelId, + ...(params.threadId ? { threadTs: params.threadId } : {}), + workItemId: params.workItemId, + createdByUserId: params.createdByUserId, + metadata: { + suggestionType: 'suggested_tasks', + suggestionKey: `${params.eventId}:${params.workItemId}`, + suggestionGroupKey: params.eventId, + launchRouting: 'router', + }, + }) + .onConflictDoNothing({ + target: [trackedMessages.kind, trackedMessages.dedupeKey], + }) + .returning({ id: trackedMessages.id }); + return claim?.id ?? null; +} + +async function finalizeSuggestionSend(params: { + claimId: string; + channelId: string; + messageId: string; + threadId?: string; +}): Promise { + await db + .update(trackedMessages) + .set({ + dedupeKey: `${params.channelId}:${params.messageId}`, + channelId: params.channelId, + messageTs: params.messageId, + threadTs: params.threadId ?? null, + updatedAt: new Date(), + }) + .where(eq(trackedMessages.id, params.claimId)); +} + export async function postFastAutomationSuggestionsToSlack(params: { slack: Pick; channelId: string; @@ -253,3 +308,90 @@ export async function postFastAutomationSuggestionsToDiscord(params: { }); } } + +export async function postFastAutomationSuggestionsToTeams(params: { + provider: Pick; + channelId: string; + serviceUrl: string; + threadId?: string; + eventId: string; + createdByUserId: string; + suggestions: FastAutomationSuggestion[]; +}): Promise { + const suggestions = await persistFastAutomationSuggestions(params); + const trackedWorkItemIds = await findTrackedSuggestionWorkItemIds({ + surface: 'teams', + workItemIds: suggestions.map((suggestion) => suggestion.id), + }); + for (const suggestion of suggestions) { + if (trackedWorkItemIds.has(suggestion.id)) continue; + + const claimId = await claimSuggestionSend({ + surface: 'teams', + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + if (!claimId) continue; + + const posted = await params.provider.postMessage({ + channelId: params.channelId, + serviceUrl: params.serviceUrl, + ...(params.threadId + ? { threadId: params.threadId, replyToMessageId: params.threadId } + : {}), + text: formatSuggestion(suggestion), + textFormat: 'markdown', + }); + await finalizeSuggestionSend({ + claimId, + channelId: posted.channelId, + messageId: posted.messageId, + ...(posted.threadId ? { threadId: posted.threadId } : {}), + }); + } +} + +export async function postFastAutomationSuggestionsToTelegram(params: { + provider: Pick; + channelId: string; + threadId?: string; + eventId: string; + createdByUserId: string; + suggestions: FastAutomationSuggestion[]; +}): Promise { + const suggestions = await persistFastAutomationSuggestions(params); + const trackedWorkItemIds = await findTrackedSuggestionWorkItemIds({ + surface: 'telegram', + workItemIds: suggestions.map((suggestion) => suggestion.id), + }); + for (const suggestion of suggestions) { + if (trackedWorkItemIds.has(suggestion.id)) continue; + + const claimId = await claimSuggestionSend({ + surface: 'telegram', + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + workItemId: suggestion.id, + createdByUserId: params.createdByUserId, + eventId: params.eventId, + }); + if (!claimId) continue; + + const posted = await params.provider.postMessage({ + channelId: params.channelId, + ...(params.threadId ? { threadId: params.threadId } : {}), + text: formatSuggestion(suggestion), + textFormat: 'markdown', + buttons: [[{ text: 'Start', callbackData: `idea:${suggestion.id}` }]], + }); + await finalizeSuggestionSend({ + claimId, + channelId: posted.channelId, + messageId: posted.messageId, + ...(posted.threadId ? { threadId: posted.threadId } : {}), + }); + } +} From 926a73733cf1453e57c8ca028afd5678a457d48a Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:25:26 -0400 Subject: [PATCH 004/158] [Fix] Session right rail stays open on mobile (#1758) * fix(web): collapse session rail on mobile * fix(web): share responsive workspace rail lifecycle * test(web): mock responsive session sidebar hook --------- Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../SessionWorkspace.client.test.tsx | 47 +++++++- .../sessions/[sessionId]/SessionWorkspace.tsx | 6 +- .../sessions/[sessionId]/page.test.tsx | 1 + .../task/[taskId]/page.client.test.tsx | 9 +- .../src/app/(sandbox)/task/[taskId]/page.tsx | 32 +----- .../use-sandbox-layout.client.test.tsx | 107 ++++++++++++++++++ .../src/app/(sandbox)/use-sandbox-layout.ts | 26 ++++- 7 files changed, 190 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 7d0b0fa84..99029b02a 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; @@ -47,14 +47,41 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { function renderWorkspace({ isMobile }: { isMobile: boolean }) { useMediaQueryMock.mockReturnValue(!isMobile); + let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = + null; + const mediaQuery = { + matches: isMobile, + addEventListener: vi.fn( + (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + viewportChangeListener = listener; + } + }, + ), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mediaQuery), + }); - render( + const result = render(
Session transcript
, ); + + return { + ...result, + resizeToMobile() { + mediaQuery.matches = true; + act(() => + viewportChangeListener?.({ matches: true } as MediaQueryListEvent), + ); + }, + }; } describe('SessionWorkspace', () => { @@ -62,6 +89,10 @@ describe('SessionWorkspace', () => { renderWorkspace({ isMobile: true }); expect(screen.getByText('Session transcript')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Chat' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Session info' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Show sidebar' })); + expect(screen.getByRole('button', { name: 'Chat' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Session info' })); @@ -113,6 +144,7 @@ describe('SessionWorkspace', () => { it('preserves the split panel and close control on desktop', () => { renderWorkspace({ isMobile: false }); + expect(screen.getByRole('button', { name: 'Session info' })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect(screen.getByText('Session transcript')).toBeInTheDocument(); @@ -120,4 +152,15 @@ describe('SessionWorkspace', () => { screen.getByRole('button', { name: 'Close session info' }), ).toBeInTheDocument(); }); + + it('collapses the right rail when the viewport changes from desktop to mobile', () => { + const { resizeToMobile } = renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Session info' })).toBeVisible(); + + resizeToMobile(); + + expect(screen.queryByRole('button', { name: 'Session info' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Show sidebar' })).toBeVisible(); + }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index cbbfd945e..ab63de71b 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -21,7 +21,10 @@ import { ResponsiveWorkspacePanels, SandboxSideActions, } from '../../SandboxWorkspacePanels'; -import { useSandboxLayout } from '../../use-sandbox-layout'; +import { + useResponsiveSandboxSidebar, + useSandboxLayout, +} from '../../use-sandbox-layout'; export type SessionInfo = { id: string; @@ -127,6 +130,7 @@ export function SessionWorkspace({ }) { const [isInfoOpen, setIsInfoOpen] = useState(false); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); + useResponsiveSandboxSidebar(session.id); return ( ({ getFastSessionById: getFastSessionByIdMock, })); vi.mock('../../use-sandbox-layout', () => ({ + useResponsiveSandboxSidebar: vi.fn(), useSandboxLayout: () => ({ isSidebarVisible: true, setSidebarVisible: vi.fn(), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx index f244c3de9..e63c45dbd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx @@ -7,7 +7,7 @@ import { RunStatus, TaskPayloadKind } from '@roomote/types'; const { replaceMock, recordVisitMock, - setSidebarVisibleMock, + useResponsiveSandboxSidebarMock, useTaskSessionMock, usePathnameMock, usePageTitleMock, @@ -20,7 +20,7 @@ const { } = vi.hoisted(() => ({ replaceMock: vi.fn(), recordVisitMock: vi.fn(), - setSidebarVisibleMock: vi.fn(), + useResponsiveSandboxSidebarMock: vi.fn(), useTaskSessionMock: vi.fn(), usePathnameMock: vi.fn(() => '/task/route-task'), usePageTitleMock: vi.fn(), @@ -65,9 +65,7 @@ vi.mock('@/hooks/useRecentTasks', () => ({ })); vi.mock('../../use-sandbox-layout', () => ({ - useSandboxLayout: () => ({ - setSidebarVisible: setSidebarVisibleMock, - }), + useResponsiveSandboxSidebar: useResponsiveSandboxSidebarMock, })); vi.mock('./hooks', () => ({ @@ -189,6 +187,7 @@ describe('SandboxPage', () => { expect(useTaskMessageEnvelopesMock).toHaveBeenCalledWith('route-task', { enabled: true, }); + expect(useResponsiveSandboxSidebarMock).toHaveBeenCalledWith('route-task'); expect(screen.getByTestId('sandbox-provider')).toBeInTheDocument(); expect(screen.getByTestId('live-content')).toBeInTheDocument(); expect(screen.queryByTestId('startup')).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 870da81ba..c6756ccb3 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { CircleSlash, TriangleAlert } from '@/components/system'; @@ -25,7 +19,7 @@ import { useRecentTasks } from '@/hooks/useRecentTasks'; import { FramedSurface } from '@/components/layout'; import { EmptyState } from '@/components/system'; -import { useSandboxLayout } from '../../use-sandbox-layout'; +import { useResponsiveSandboxSidebar } from '../../use-sandbox-layout'; import { HistoricalSandboxProvider, @@ -45,7 +39,7 @@ import { TaskWorkspaceSkeleton } from './TaskWorkspaceSkeleton'; export default function SandboxPage() { const { taskId: unresolvedTaskId } = useParams<{ taskId: string }>(); - const { setSidebarVisible } = useSandboxLayout(); + useResponsiveSandboxSidebar(unresolvedTaskId); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -107,26 +101,6 @@ export default function SandboxPage() { }); }, [queryClient, trpc]); - useLayoutEffect(() => { - const mobileQuery = window.matchMedia?.('(max-width: 767px)'); - - if (!mobileQuery?.matches) { - return; - } - - setSidebarVisible(false); - - const handleViewportChange = (event: MediaQueryListEvent) => - setSidebarVisible(!event.matches); - - mobileQuery.addEventListener('change', handleViewportChange); - - return () => { - mobileQuery.removeEventListener('change', handleViewportChange); - setSidebarVisible(true); - }; - }, [setSidebarVisible, unresolvedTaskId]); - // Track this task as recently visited for command palette ordering. // Record immediately with the URL param so visits are captured even when the // session fails to initialise. If the resolved taskId differs (e.g. alias diff --git a/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx b/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx new file mode 100644 index 000000000..295fe3a56 --- /dev/null +++ b/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { + SandboxLayoutContext, + useResponsiveSandboxSidebar, +} from './use-sandbox-layout'; + +const setSidebarVisible = vi.fn(); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function mockViewport(isMobile: boolean) { + let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = + null; + const mediaQuery = { + matches: isMobile, + addEventListener: vi.fn( + (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + viewportChangeListener = listener; + } + }, + ), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mediaQuery), + }); + + return { + mediaQuery, + resize(isMobile: boolean) { + mediaQuery.matches = isMobile; + act(() => + viewportChangeListener?.({ matches: isMobile } as MediaQueryListEvent), + ); + }, + }; +} + +describe('useResponsiveSandboxSidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('tracks the viewport from an initial desktop state and restores the rail on cleanup', () => { + const viewport = mockViewport(false); + const { unmount } = renderHook( + () => useResponsiveSandboxSidebar('workspace-1'), + { wrapper }, + ); + + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + + viewport.resize(true); + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + + viewport.resize(false); + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + + unmount(); + expect(viewport.mediaQuery.removeEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + }); + + it('starts with the rail collapsed on mobile', () => { + mockViewport(true); + + renderHook(() => useResponsiveSandboxSidebar('workspace-1'), { wrapper }); + + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + }); + + it('reinitializes the responsive lifecycle when the workspace changes', () => { + const viewport = mockViewport(true); + const { rerender } = renderHook( + ({ scopeKey }) => useResponsiveSandboxSidebar(scopeKey), + { + initialProps: { scopeKey: 'workspace-1' }, + wrapper, + }, + ); + + rerender({ scopeKey: 'workspace-2' }); + + expect(viewport.mediaQuery.removeEventListener).toHaveBeenCalledTimes(1); + expect(viewport.mediaQuery.addEventListener).toHaveBeenCalledTimes(2); + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/apps/web/src/app/(sandbox)/use-sandbox-layout.ts b/apps/web/src/app/(sandbox)/use-sandbox-layout.ts index d3f798f7d..0b50125f5 100644 --- a/apps/web/src/app/(sandbox)/use-sandbox-layout.ts +++ b/apps/web/src/app/(sandbox)/use-sandbox-layout.ts @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useLayoutEffect } from 'react'; interface SandboxLayoutContextValue { isSidebarVisible: boolean; @@ -22,3 +22,27 @@ export function useSandboxLayout() { return ctx; } + +export function useResponsiveSandboxSidebar(scopeKey: string) { + const { setSidebarVisible } = useSandboxLayout(); + + useLayoutEffect(() => { + const mobileQuery = window.matchMedia?.('(max-width: 767px)'); + + if (!mobileQuery) { + return; + } + + setSidebarVisible(!mobileQuery.matches); + + const handleViewportChange = (event: MediaQueryListEvent) => + setSidebarVisible(!event.matches); + + mobileQuery.addEventListener('change', handleViewportChange); + + return () => { + mobileQuery.removeEventListener('change', handleViewportChange); + setSidebarVisible(true); + }; + }, [scopeKey, setSidebarVisible]); +} From c74776bdb7fa3c2c4f2651b387fdfd6a2e5c854c Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Aug 2026 22:12:56 -0400 Subject: [PATCH 005/158] Disable Infinity anonymous telemetry in the compose bundle (#1762) Infinity posts an anonymous hardware/config fingerprint to PostHog on startup. Self-hosted stacks should not phone home; set DO_NOT_TRACK=1 on the bundled local-inference service. Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- deploy/compose/docker-compose.prod.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index bce742b68..24a10c0c2 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -587,6 +587,10 @@ services: - ${INFINITY_EMBEDDING_MODEL:-BAAI/bge-m3} - --model-id - ${INFINITY_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} + environment: + # Infinity posts an anonymous hardware/config fingerprint to PostHog + # on startup; a self-hosted stack should not phone home. + DO_NOT_TRACK: '1' volumes: - infinity_cache:/app/.cache security_opt: From b39435a7bf74f4e7770cc59c0a1339fb1f0875c9 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:14:34 -0400 Subject: [PATCH 006/158] [Chore] Disable Infinity anonymous telemetry (#1760) * chore: disable Infinity anonymous telemetry * fix: render Infinity security profile in CI --------- Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- deploy/compose/docker-compose.prod.yml | 2 ++ deploy/scripts/validate-compose-security.sh | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 24a10c0c2..912f765b1 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -569,6 +569,8 @@ services: image: michaelf34/infinity:0.0.76-cpu@sha256:2a464dcc06e659a277bc841b4be196100489076446482925481b2c5c120fce57 restart: unless-stopped networks: [default] + environment: + INFINITY_ANONYMOUS_USAGE_STATS: '0' command: - v2 - --device diff --git a/deploy/scripts/validate-compose-security.sh b/deploy/scripts/validate-compose-security.sh index e4b8d126c..077f2318a 100755 --- a/deploy/scripts/validate-compose-security.sh +++ b/deploy/scripts/validate-compose-security.sh @@ -18,7 +18,7 @@ export ENCRYPTION_KEY=12345678901234567890123456789012 export ARTIFACT_SIGNING_KEY=12345678901234567890123456789012 export DASHBOARD_PASSWORD=test-dashboard-password -docker compose -f "$compose_file" config --format json >"$rendered_config" +docker compose --profile local-inference -f "$compose_file" config --format json >"$rendered_config" jq -e ' def hardened: @@ -50,6 +50,15 @@ jq -e ' (.services["docker-proxy"].networks | has("docker-api")) and (.networks["docker-api"].internal == true) and + (.services.infinity.image == "michaelf34/infinity:0.0.76-cpu@sha256:2a464dcc06e659a277bc841b4be196100489076446482925481b2c5c120fce57") and + (.services.infinity.environment.INFINITY_ANONYMOUS_USAGE_STATS == "0") and + (.services.infinity.volumes == [{ + "type": "volume", + "source": "infinity_cache", + "target": "/app/.cache", + "volume": {} + }]) and + (.services["docker-proxy"].environment == { "ALLOW_START": "1", "CONTAINERS": "1", From 4371b29f460c620904622c13f1e905a40828b63f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Aug 2026 22:24:20 -0400 Subject: [PATCH 007/158] Revert "Disable Infinity anonymous telemetry in the compose bundle (#1762)" (#1763) This reverts commit c74776bdb7fa3c2c4f2651b387fdfd6a2e5c854c. --- deploy/compose/docker-compose.prod.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 912f765b1..2cf250633 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -589,10 +589,6 @@ services: - ${INFINITY_EMBEDDING_MODEL:-BAAI/bge-m3} - --model-id - ${INFINITY_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} - environment: - # Infinity posts an anonymous hardware/config fingerprint to PostHog - # on startup; a self-hosted stack should not phone home. - DO_NOT_TRACK: '1' volumes: - infinity_cache:/app/.cache security_opt: From 5df5fa26b239d7161f1281a40db366aed3dc34db Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:12:33 -0400 Subject: [PATCH 008/158] [Feat] Add public Discord context to Memory (#1761) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../__tests__/brain-collector-engine.test.ts | 32 + .../__tests__/brain-discord-collector.test.ts | 634 ++++++++++++ .../src/scheduled-jobs/brain-collectors.ts | 15 + .../brain-collectors/contracts.ts | 1 + .../discord-public-channels.ts | 945 ++++++++++++++++++ apps/docs/memory.mdx | 11 + .../docs/providers/communications/discord.mdx | 9 +- .../commands/brain/summarize-sources.test.ts | 1 + .../src/__tests__/discord-provider.test.ts | 30 + .../communication/src/discord-provider.ts | 135 ++- .../communication/src/mock-discord-server.ts | 10 + packages/communication/src/provider.ts | 1 + .../brain-source-availability.test.ts | 17 + .../server/lib/brain-source-availability.ts | 12 + packages/types/src/brain.test.ts | 8 + packages/types/src/brain.ts | 19 +- 16 files changed, 1852 insertions(+), 28 deletions(-) create mode 100644 apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts create mode 100644 apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts index 95b81fb78..949876091 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts @@ -685,6 +685,38 @@ describe('runBrainCollectors deep backfill', () => { }); }); + it('persists dependent backfill state only after the step pages land', async () => { + const auxiliaryId = uniqueId('backfill-pending'); + const backfill = vi + .fn>() + .mockImplementation(async ({ cursor }) => + cursor === null + ? { + pages: makePages(1, 'queued'), + nextCursor: 'c1', + done: false, + stateUpdates: [{ collectorId: auxiliaryId, cursor: 'remaining' }], + } + : { pages: [], nextCursor: cursor, done: false }, + ); + const collector = makeCollector({ backfill }); + + await runBrainCollectors(connection, { + sink: vi.fn(async () => { + throw new Error('write failed'); + }), + collectors: [collector], + }); + expect(syncStateStore.get(auxiliaryId)).toBeUndefined(); + + await runBrainCollectors(connection, { + sink: vi.fn(async () => {}), + collectors: [collector], + }); + expect(syncStateStore.get(auxiliaryId)?.backfillCursor).toBe('remaining'); + expect(syncStateStore.get(collector.id)?.backfillCursor).toBe('c1'); + }); + it('keeps the last landed cursor when the sink 429s mid-backfill', async () => { const backfill = vi .fn>() diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts new file mode 100644 index 000000000..aa8b75c0b --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts @@ -0,0 +1,634 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const DISCORD_EPOCH_MS = 1_420_070_400_000n; + +const workspace = vi.hoisted(() => ({ + messages: new Map>>(), + fetchCalls: [] as string[], + tracked: [] as Array<{ + collectorId: string; + itemId: string; + slug: string; + lastSeenAt: Date; + }>, + syncState: new Map< + string, + { + watermark?: Date | null; + backfillCursor?: string | null; + backfillCompletedAt?: Date | null; + } + >(), + available: true, + providerEnabled: true, + guildPageNextAfter: null as string | null, + guilds: [] as Array<{ id: string; name: string; icon: null }>, + installations: [] as Array<{ guildId: string }>, + channels: [] as Array<{ + id: string; + name: string; + type: number; + parentId?: string; + }>, + threads: [] as Array<{ + id: string; + name: string; + type: number; + parentId?: string; + }>, +})); + +function snowflake(iso: string, sequence = 0): string { + return ( + ((BigInt(Date.parse(iso)) - DISCORD_EPOCH_MS) << 22n) | + BigInt(sequence) + ).toString(); +} + +const provider = { + getBotInfo: vi.fn(async () => ({ id: '999' })), + listGuildsPage: vi.fn(async () => ({ + guilds: workspace.guilds, + nextAfter: workspace.guildPageNextAfter, + })), + listPublicReadableGuildChannels: vi.fn(async () => workspace.channels), + listGuildActiveThreads: vi.fn(async () => workspace.threads), + fetchChannelMessages: vi.fn( + async ({ + channelId, + oldest, + latest, + }: { + channelId: string; + oldest?: string; + latest?: string; + }) => { + workspace.fetchCalls.push(channelId); + const messages = (workspace.messages.get(channelId) ?? []).filter( + (message) => + (!oldest || BigInt(message.id as string) >= BigInt(oldest)) && + (!latest || BigInt(message.id as string) <= BigInt(latest)), + ); + return { + provider: 'discord' as const, + channelId, + messageCount: messages.length, + messages, + }; + }, + ), +}; + +vi.mock('@roomote/sdk/server', () => ({ + createDiscordCommunicationProviderFromRuntimeCredentials: vi.fn(async () => + workspace.providerEnabled ? provider : null, + ), + isBrainSourceAvailable: vi.fn(async () => workspace.available), + listDiscordInstallations: vi.fn(async () => workspace.installations), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + discordUserMappings: { + findMany: vi.fn(async () => [ + { + discordUserId: '200', + user: { + id: 'alice-id', + name: 'Alice Example ', + createdAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, + }, + }, + ]), + }, + }, + }, + getBrainSyncState: vi.fn( + async (_db: unknown, collectorId: string) => + workspace.syncState.get(collectorId) ?? null, + ), + listBrainCollectorItems: vi.fn(async () => workspace.tracked), + listBrainCollectorItemsBySlugPrefix: vi.fn( + async (_db: unknown, collectorId: string, prefix: string) => + workspace.tracked.filter( + (item) => + item.collectorId === collectorId && item.itemId.startsWith(prefix), + ), + ), +})); + +beforeEach(() => { + workspace.messages.clear(); + workspace.fetchCalls = []; + workspace.tracked = []; + workspace.syncState.clear(); + workspace.available = true; + workspace.providerEnabled = true; + workspace.guildPageNextAfter = null; + workspace.guilds = [{ id: '100', name: 'Community', icon: null }]; + workspace.installations = [{ guildId: '100' }]; + workspace.channels = [{ id: '300', name: 'general', type: 0 }]; + workspace.threads = []; + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe('Discord public-channel Brain collector', () => { + it('formats public channel and active public-thread messages deterministically', async () => { + workspace.threads = [ + { id: '301', name: 'design', type: 11, parentId: '300' }, + { id: '302', name: 'private', type: 12, parentId: '300' }, + { id: '304', name: 'forum-post', type: 11, parentId: '303' }, + ]; + workspace.channels.push({ id: '303', name: 'ideas', type: 15 }); + workspace.messages.set('300', [ + { + provider: 'discord', + id: snowflake('2026-08-28T10:00:00Z'), + user: '200', + username: 'alice', + text: 'A public decision', + channelId: '300', + fileCount: 1, + files: [ + { + id: '1', + name: 'plan.pdf', + mimeType: 'application/pdf', + size: 10, + url: 'https://cdn.discordapp.com/secret', + }, + ], + }, + ]); + workspace.messages.set('301', [ + { + provider: 'discord', + id: snowflake('2026-08-28T11:00:00Z'), + user: '201', + username: 'bob', + text: 'Thread reply', + replyToMessageId: '123', + channelId: '301', + fileCount: 0, + }, + ]); + workspace.messages.set('304', [ + { + provider: 'discord', + id: snowflake('2026-08-28T11:30:00Z'), + user: '201', + username: 'bob', + text: 'Forum context', + channelId: '304', + fileCount: 0, + }, + ]); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + const content = result.pages.map((page) => page.content).join('\n'); + + expect(result.pages.map((page) => page.slug)).toEqual([ + 'discord/100/300/2026-08-28/000', + 'discord/100/threads/300/301/2026-08-28/000', + 'discord/100/threads/303/304/2026-08-28/000', + ]); + expect(content).toContain('[Alice Example](people/roomote-member-'); + expect(content).toContain('[attachments: plan.pdf]'); + expect(content).toContain('(reply to 123)'); + expect(content).not.toContain('alice@example.com'); + expect(content).not.toContain('cdn.discordapp.com/secret'); + expect(workspace.fetchCalls).not.toContain('302'); + }); + + it('retires stale chunks after a complete empty-day read', async () => { + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-28/000', + slug: 'discord/100/300/2026-08-28/000', + lastSeenAt: new Date('2026-08-28T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual({ + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-28/000', + slug: 'discord/100/300/2026-08-28/000', + }); + }); + + it('preserves an archived public thread while its parent remains public', async () => { + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/threads/300/301/2026-08-20/000', + slug: 'discord/100/threads/300/301/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).not.toContainEqual( + expect.objectContaining({ + slug: 'discord/100/threads/300/301/2026-08-20/000', + }), + ); + }); + + it('retires a channel after an authoritative public-permission scan excludes it', async () => { + workspace.channels = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual( + expect.objectContaining({ + slug: 'discord/100/300/2026-08-20/000', + }), + ); + }); + + it('requeues deep history when a retired channel becomes public again', async () => { + workspace.channels = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + let module = await import('../brain-collectors/discord-public-channels'); + const revoked = await module.discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + const revokedUpdate = revoked.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:revoked-partitions-v1', + ); + expect(JSON.parse(revokedUpdate?.cursor ?? '{}')).toEqual({ + keys: ['100/300'], + }); + + workspace.syncState.set('discord-public-channels:revoked-partitions-v1', { + backfillCursor: revokedUpdate!.cursor!, + }); + workspace.syncState.set(module.discordPublicChannelsCollector.id, { + backfillCompletedAt: new Date('2026-08-20T00:00:00Z'), + backfillCursor: JSON.stringify({ + completed: ['100/300'], + key: null, + day: null, + }), + }); + workspace.tracked = []; + workspace.channels = [{ id: '300', name: 'general', type: 0 }]; + vi.resetModules(); + module = await import('../brain-collectors/discord-public-channels'); + + const restored = await module.discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:15:00Z'), + limit: 100, + }); + const pendingUpdate = restored.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:backfill-pending-v1', + ); + + expect(JSON.parse(pendingUpdate?.cursor ?? '{}')).toMatchObject({ + entries: [expect.objectContaining({ key: '100/300' })], + }); + expect(restored.stateUpdates).toContainEqual({ + collectorId: module.discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + }); + + it('persists a day cursor for bounded history backfill', async () => { + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + workspace.syncState.set('discord-public-channels:backfill-pending-v1', { + backfillCursor: JSON.stringify({ + entries: [ + { + key: '100/300', + guildId: '100', + guildName: 'Community', + channelId: '300', + channelName: 'general', + parentChannelId: null, + parentChannelName: null, + isThread: false, + }, + ], + }), + }); + + const result = await discordPublicChannelsCollector.backfill!({ + cursor: null, + limit: 100, + }); + const cursor = JSON.parse(result.nextCursor!) as { + key: string; + day: string; + }; + + expect(result.done).toBe(false); + expect(cursor.key).toBe('100/300'); + expect(cursor.day).toMatch(/^\d{4}-\d{2}-\d{2}$/u); + expect(workspace.fetchCalls).toEqual(['300']); + }); + + it('re-arms a completed backfill when a new public channel appears', async () => { + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + workspace.syncState.set(discordPublicChannelsCollector.id, { + backfillCompletedAt: new Date('2026-08-20T00:00:00Z'), + backfillCursor: JSON.stringify({ + completed: [], + key: null, + day: null, + }), + }); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.stateUpdates).toContainEqual({ + collectorId: discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + const pending = result.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:backfill-pending-v1', + ); + expect(JSON.parse(pending?.cursor ?? '{}')).toMatchObject({ + entries: [expect.objectContaining({ key: '100/300' })], + }); + }); + + it('catches up missed days from the per-channel watermark', async () => { + workspace.syncState.set( + 'discord-public-channels:entity-timeline-v1:100/300', + { watermark: new Date('2026-08-24T00:00:00Z') }, + ); + workspace.messages.set('300', [ + { + provider: 'discord', + id: snowflake('2026-08-24T10:00:00Z'), + user: '200', + username: 'alice', + text: 'Missed during an outage', + channelId: '300', + fileCount: 0, + }, + ]); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pages.map((page) => page.slug)).toContain( + 'discord/100/300/2026-08-24/000', + ); + expect(result.stateUpdates).toContainEqual({ + collectorId: `${discordPublicChannelsCollector.id}:100/300`, + watermark: new Date('2026-08-26T00:00:00Z'), + }); + }); + + it('advances a bounded durable guild-discovery cursor', async () => { + workspace.guildPageNextAfter = '100'; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(provider.listGuildsPage).toHaveBeenCalledWith({ limit: 10 }); + expect(result.stateUpdates).toContainEqual({ + collectorId: 'discord-public-channels:guild-discovery', + cursor: JSON.stringify({ after: '100' }), + }); + }); + + it('discovers channels only for active Discord installations', async () => { + workspace.guilds = [ + { id: '100', name: 'Active', icon: null }, + { id: '101', name: 'Inactive', icon: null }, + ]; + workspace.installations = [{ guildId: '100' }]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(provider.listPublicReadableGuildChannels).toHaveBeenCalledTimes(1); + expect(provider.listPublicReadableGuildChannels).toHaveBeenCalledWith({ + guildId: '100', + userId: '999', + }); + expect(provider.listGuildActiveThreads).toHaveBeenCalledWith('100'); + expect(provider.listGuildActiveThreads).not.toHaveBeenCalledWith('101'); + }); + + it('retires indexed pages when a Discord installation is deactivated', async () => { + workspace.guilds = [ + { id: '100', name: 'Active', icon: null }, + { id: '101', name: 'Inactive', icon: null }, + ]; + workspace.installations = [{ guildId: '100' }]; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/101/301/2026-08-20/000', + slug: 'discord/101/301/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual({ + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/101/301/2026-08-20/000', + slug: 'discord/101/301/2026-08-20/000', + }); + const revoked = result.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:revoked-partitions-v1', + ); + expect(JSON.parse(revoked?.cursor ?? '{}')).toEqual({ + keys: ['101/301'], + }); + }); + + it('keeps cleanup enabled after the final installation is deactivated', async () => { + workspace.available = false; + workspace.installations = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + true, + ); + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + expect(result.pageRetirements).toContainEqual( + expect.objectContaining({ + slug: 'discord/100/300/2026-08-20/000', + }), + ); + + workspace.tracked = []; + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + false, + ); + }); + + it('prunes pending backfill for deactivated installations', async () => { + workspace.installations = [{ guildId: '100' }]; + workspace.syncState.set('discord-public-channels:backfill-pending-v1', { + backfillCursor: JSON.stringify({ + entries: [ + { + key: '101/301', + guildId: '101', + guildName: 'Inactive', + channelId: '301', + channelName: 'general', + parentChannelId: null, + parentChannelName: null, + isThread: false, + }, + ], + }), + }); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.backfill!({ + cursor: null, + limit: 100, + }); + + expect(result.done).toBe(true); + expect(result.stateUpdates).toEqual([ + { + collectorId: 'discord-public-channels:backfill-pending-v1', + cursor: JSON.stringify({ entries: [] }), + }, + ]); + expect(workspace.fetchCalls).toEqual([]); + }); + + it('disables collection without credentials and preserves inventory', async () => { + workspace.available = false; + workspace.providerEnabled = false; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + false, + ); + expect(provider.listGuildsPage).not.toHaveBeenCalled(); + }); + + it('bounds incremental history reads to ten channel partitions', async () => { + workspace.channels = Array.from({ length: 12 }, (_, index) => ({ + id: String(300 + index), + name: `channel-${index}`, + type: 0, + })); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(new Set(workspace.fetchCalls).size).toBe(10); + expect(workspace.fetchCalls).toHaveLength(20); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts index 9d322736e..c6a93f9ca 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts @@ -26,6 +26,7 @@ import { writeCollectorPages, } from './brain-collectors/write-pages'; import { githubIssuesCollector } from './brain-collectors/github-issues'; +import { discordPublicChannelsCollector } from './brain-collectors/discord-public-channels'; import { granolaMeetingsCollector } from './brain-collectors/granola-meetings'; import { notionPagesCollector, @@ -315,6 +316,19 @@ async function drainCollectorBackfill(input: { connection, retireSink, ); + for (const update of step.stateUpdates ?? []) { + await upsertBrainSyncState(db, update.collectorId, { + ...(update.watermark !== undefined + ? { watermark: update.watermark } + : {}), + ...(update.cursor !== undefined + ? { backfillCursor: update.cursor } + : {}), + ...(update.backfillCompletedAt !== undefined + ? { backfillCompletedAt: update.backfillCompletedAt } + : {}), + }); + } budget -= step.pages.length; ingested += step.pages.length; @@ -360,6 +374,7 @@ const BRAIN_COLLECTORS: BrainCollector[] = [ personIdentitiesCollector, ripplingWorkersCollector, slackPublicChannelsCollector, + discordPublicChannelsCollector, notionUsersCollector, notionPagesCollector, granolaMeetingsCollector, diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts index 616ddbc44..41f0f71a1 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts @@ -65,6 +65,7 @@ export interface BrainCollector { pages: CollectorPage[]; nextCursor: string | null; done: boolean; + stateUpdates?: CollectorStateUpdate[]; itemUpdates?: CollectorItemUpdate[]; pageRetirements?: CollectorPageRetirement[]; }>; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts new file mode 100644 index 000000000..cec19f72d --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts @@ -0,0 +1,945 @@ +import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; +import type { CommunicationMessage } from '@roomote/communication/provider'; +import { + db, + getBrainSyncState, + listBrainCollectorItems, + listBrainCollectorItemsBySlugPrefix, +} from '@roomote/db/server'; +import { + createDiscordCommunicationProviderFromRuntimeCredentials, + isBrainSourceAvailable, + listDiscordInstallations, +} from '@roomote/sdk/server'; +import { + BRAIN_COLLECTOR_IDS, + BRAIN_PAGE_TYPES, + brainNamespacePrefix, + buildDiscordMessagePermalink, + renderBrainFrontmatter, +} from '@roomote/types'; + +import type { + BrainCollector, + CollectorItemUpdate, + CollectorPage, + CollectorPageRetirement, + CollectorResult, + CollectorStateUpdate, +} from './contracts'; +import { + brainSafeIdentityValue, + personIdentitySlug, + type PersonIdentityReference, +} from './identity'; + +const LOG_PREFIX = '[brainCollectors]'; +const DISCORD_EPOCH_MS = 1_420_070_400_000n; +const DAY_MS = 24 * 60 * 60 * 1_000; +const DISCORD_HISTORY_PAGE_SIZE = 100; +const DISCORD_DAY_MAX_REQUESTS = 25; +const DISCORD_PAGE_MESSAGE_LIMIT = 200; +const DISCORD_INCREMENTAL_PARTITIONS_PER_PASS = 10; +const DISCORD_GUILDS_PER_DISCOVERY_PASS = 10; +const DISCORD_BACKFILL_DAYS = 90; +const DISCORD_INVENTORY_LIMIT = 10_000; +const DISCORD_INVENTORY_ID = 'discord-public-channels:day-pages'; +const DISCORD_GUILD_DISCOVERY_STATE_ID = + 'discord-public-channels:guild-discovery'; +const DISCORD_BACKFILL_PENDING_STATE_ID = + 'discord-public-channels:backfill-pending-v1'; +const DISCORD_REVOKED_PARTITIONS_STATE_ID = + 'discord-public-channels:revoked-partitions-v1'; +const DISCORD_DISCOVERY_CACHE_MS = 60_000; + +const DISCORD_TEXT_CHANNEL_TYPES = new Set([0, 5]); +const DISCORD_PUBLIC_THREAD_PARENT_TYPES = new Set([0, 5, 15, 16]); +const DISCORD_PUBLIC_THREAD_TYPES = new Set([10, 11]); + +type DiscordCollectionEntry = { + key: string; + guildId: string; + guildName: string; + channelId: string; + channelName: string; + parentChannelId: string | null; + parentChannelName: string | null; + isThread: boolean; +}; + +type DiscordDiscovery = { + provider: DiscordCommunicationProvider; + entries: DiscordCollectionEntry[]; + activeGuildIds: Set; + scannedGuildIds: Set; + readableChannelKeys: Set; + nextGuildCursor: DiscordGuildDiscoveryCursor; +}; + +type DiscordGuildDiscoveryCursor = { + after: string | null; +}; + +type DiscordBackfillCursor = { + completed: string[]; + key: string | null; + day: string | null; +}; + +type DiscordBackfillPending = { + entries: DiscordCollectionEntry[]; +}; + +type DiscordRevokedPartitions = { + keys: string[]; +}; + +let discoveryCache: { + loadedAt: number; + cursorKey: string; + value: DiscordDiscovery; +} | null = null; + +function discordSnowflakeToDate(id: string): Date | null { + try { + const milliseconds = (BigInt(id) >> 22n) + DISCORD_EPOCH_MS; + const value = Number(milliseconds); + return Number.isSafeInteger(value) ? new Date(value) : null; + } catch { + return null; + } +} + +function dateToDiscordSnowflake(date: Date): string { + const milliseconds = BigInt(date.getTime()) - DISCORD_EPOCH_MS; + return (milliseconds > 0n ? milliseconds << 22n : 0n).toString(); +} + +function utcDay(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function startOfUtcDay(day: string): Date { + return new Date(`${day}T00:00:00.000Z`); +} + +function shiftUtcDay(day: string, days: number): string { + return utcDay(new Date(startOfUtcDay(day).getTime() + days * DAY_MS)); +} + +function normalizeMessageText(text: string): string { + return text.replace(/\s+/gu, ' ').trim(); +} + +function discordDayPrefix(entry: DiscordCollectionEntry, day: string): string { + const channelPath = entry.isThread + ? `threads/${entry.parentChannelId}/${entry.channelId}` + : entry.channelId; + return `${brainNamespacePrefix('discord')}${entry.guildId}/${channelPath}/${day}/`.toLowerCase(); +} + +function groupDiscordMessagesIntoDayPages(input: { + entry: DiscordCollectionEntry; + day: string; + messages: CommunicationMessage[]; + people?: ReadonlyMap; +}): CollectorPage[] { + const messages = input.messages + .filter( + (message) => + discordSnowflakeToDate(message.id) && + (message.text.trim() || message.fileCount > 0), + ) + .sort((left, right) => { + try { + const leftId = BigInt(left.id); + const rightId = BigInt(right.id); + return leftId === rightId ? 0 : leftId < rightId ? -1 : 1; + } catch { + return left.id.localeCompare(right.id); + } + }); + const channelLabel = input.entry.parentChannelName + ? `#${input.entry.parentChannelName} / ${input.entry.channelName}` + : `#${input.entry.channelName}`; + const title = `${input.entry.guildName} / ${channelLabel} — ${input.day}`; + const pages: CollectorPage[] = []; + + for ( + let start = 0; + start < messages.length; + start += DISCORD_PAGE_MESSAGE_LIMIT + ) { + const chunk = messages.slice(start, start + DISCORD_PAGE_MESSAGE_LIMIT); + const people = new Set(); + const lines = chunk.map((message) => { + const at = discordSnowflakeToDate(message.id)!; + const person = input.people?.get(message.user); + if (person) people.add(person.slug); + const author = person + ? `[${person.title}](${person.slug}) (${message.user})` + : `<${message.username ? `${message.username} (${message.user})` : message.user}>`; + const text = normalizeMessageText(message.text); + const attachments = (message.files ?? []).map((file) => file.name).sort(); + const details = [ + text, + attachments.length > 0 + ? `[attachments: ${attachments.join(', ')}]` + : '', + ] + .filter(Boolean) + .join(' '); + const permalink = buildDiscordMessagePermalink({ + guildId: input.entry.guildId, + channelId: input.entry.channelId, + messageId: message.id, + }); + const reply = message.replyToMessageId + ? ` (reply to ${message.replyToMessageId})` + : ''; + return `- [${at.toISOString().slice(11, 16)}] ${author}${reply}: ${details}${permalink ? ` ([source](${permalink}))` : ''}`; + }); + const index = Math.floor(start / DISCORD_PAGE_MESSAGE_LIMIT); + const slug = `${discordDayPrefix(input.entry, input.day)}${String(index).padStart(3, '0')}`; + + pages.push({ + slug, + title, + content: [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.discordDay, + title, + created: input.day, + fields: [ + `date: ${input.day}`, + `guild_id: ${JSON.stringify(input.entry.guildId)}`, + `channel_id: ${JSON.stringify(input.entry.channelId)}`, + input.entry.isThread && 'thread: true', + ], + }), + '', + `# ${title}`, + '', + `Discord public ${input.entry.isThread ? 'thread' : 'channel'} ${channelLabel} in ${input.entry.guildName}, messages on ${input.day} (times UTC).`, + '', + ...lines, + ].join('\n'), + timelineEvidence: [...people].map((personSlug) => ({ + slug: personSlug, + date: input.day, + summary: 'Participated in a public Discord channel', + source: `discord:channel-day:${input.entry.guildId}/${input.entry.channelId}/${input.day}`, + })), + }); + } + + return pages; +} + +async function loadDiscordAuthorLabels(): Promise< + Map +> { + try { + const mappings = await db.query.discordUserMappings.findMany({ + with: { + user: { + columns: { id: true, name: true, createdAt: true, deletedAt: true }, + }, + }, + }); + return new Map( + mappings.flatMap(({ discordUserId, user }) => { + const title = brainSafeIdentityValue(user.name); + return !user.deletedAt && title + ? [ + [ + discordUserId, + { + slug: personIdentitySlug(user.id), + title, + effectiveDate: user.createdAt, + }, + ] as const, + ] + : []; + }), + ); + } catch (error) { + console.warn( + `${LOG_PREFIX} could not resolve Discord author names: ${error instanceof Error ? error.message : String(error)}`, + ); + return new Map(); + } +} + +function parseGuildDiscoveryCursor( + raw: string | null, +): DiscordGuildDiscoveryCursor { + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + return { + after: typeof parsed.after === 'string' ? parsed.after : null, + }; + } catch { + // Restarting the guild census is safe; it only delays retirement. + } + } + return { after: null }; +} + +async function discoverDiscordEntries( + cursor: DiscordGuildDiscoveryCursor, +): Promise { + const now = Date.now(); + const cursorKey = JSON.stringify(cursor); + if ( + discoveryCache && + discoveryCache.cursorKey === cursorKey && + now - discoveryCache.loadedAt < DISCORD_DISCOVERY_CACHE_MS + ) { + return discoveryCache.value; + } + + const provider = + await createDiscordCommunicationProviderFromRuntimeCredentials(); + if (!provider) return null; + + const [bot, guildPage, installations] = await Promise.all([ + provider.getBotInfo(), + provider.listGuildsPage({ + ...(cursor.after ? { after: cursor.after } : {}), + limit: DISCORD_GUILDS_PER_DISCOVERY_PASS, + }), + listDiscordInstallations(), + ]); + const activeGuildIds = new Set( + installations.map((installation) => installation.guildId), + ); + const guilds = guildPage.guilds.filter((guild) => + activeGuildIds.has(guild.id), + ); + const entries: DiscordCollectionEntry[] = []; + const scannedGuildIds = new Set(); + const readableChannelKeys = new Set(); + + for (const guild of guilds) { + try { + const channels = await provider.listPublicReadableGuildChannels({ + guildId: guild.id, + userId: bot.id, + }); + const parents = new Map( + channels + .filter((channel) => + DISCORD_PUBLIC_THREAD_PARENT_TYPES.has(channel.type), + ) + .map((channel) => [channel.id, channel] as const), + ); + const activeThreads = await provider.listGuildActiveThreads(guild.id); + + for (const channel of parents.values()) { + readableChannelKeys.add(`${guild.id}/${channel.id}`); + if (!DISCORD_TEXT_CHANNEL_TYPES.has(channel.type)) continue; + entries.push({ + key: `${guild.id}/${channel.id}`, + guildId: guild.id, + guildName: guild.name, + channelId: channel.id, + channelName: channel.name, + parentChannelId: null, + parentChannelName: null, + isThread: false, + }); + } + for (const thread of activeThreads) { + const parent = thread.parentId ? parents.get(thread.parentId) : null; + if (!parent || !DISCORD_PUBLIC_THREAD_TYPES.has(thread.type)) continue; + entries.push({ + key: `${guild.id}/${thread.id}`, + guildId: guild.id, + guildName: guild.name, + channelId: thread.id, + channelName: thread.name, + parentChannelId: parent.id, + parentChannelName: parent.name, + isThread: true, + }); + } + scannedGuildIds.add(guild.id); + } catch (error) { + console.warn( + `${LOG_PREFIX} Discord guild ${guild.id} discovery failed; preserving its existing pages: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + const value = { + provider, + entries, + activeGuildIds, + scannedGuildIds, + readableChannelKeys, + nextGuildCursor: guildPage.nextAfter + ? { after: guildPage.nextAfter } + : { after: null }, + }; + discoveryCache = { loadedAt: now, cursorKey, value }; + return value; +} + +async function fetchDiscordDay(input: { + provider: DiscordCommunicationProvider; + channelId: string; + day: string; +}): Promise<{ messages: CommunicationMessage[]; complete: boolean }> { + const start = startOfUtcDay(input.day); + const end = new Date(start.getTime() + DAY_MS); + const oldest = dateToDiscordSnowflake(start); + let latest = (BigInt(dateToDiscordSnowflake(end)) - 1n).toString(); + const messages = new Map(); + + for (let request = 0; request < DISCORD_DAY_MAX_REQUESTS; request++) { + const page = await input.provider.fetchChannelMessages({ + channelId: input.channelId, + oldest, + latest, + }); + for (const message of page.messages) messages.set(message.id, message); + if (page.messages.length < DISCORD_HISTORY_PAGE_SIZE) { + return { messages: [...messages.values()], complete: true }; + } + + const firstId = page.messages[0]?.id; + if (!firstId) return { messages: [...messages.values()], complete: true }; + const next = BigInt(firstId) - 1n; + if (next < BigInt(oldest)) { + return { messages: [...messages.values()], complete: true }; + } + latest = next.toString(); + } + + console.warn( + `${LOG_PREFIX} Discord channel ${input.channelId} exceeded ${DISCORD_DAY_MAX_REQUESTS} history pages on ${input.day}; holding its checkpoint`, + ); + return { messages: [], complete: false }; +} + +async function reconcileDiscordDay(input: { + entry: DiscordCollectionEntry; + day: string; + pages: CollectorPage[]; + now: Date; +}): Promise<{ + itemUpdates: CollectorItemUpdate[]; + pageRetirements: CollectorPageRetirement[]; +}> { + const prefix = discordDayPrefix(input.entry, input.day); + const tracked = await listBrainCollectorItemsBySlugPrefix( + db, + DISCORD_INVENTORY_ID, + prefix, + 1_000, + ); + const emitted = new Set(input.pages.map((page) => page.slug)); + + return { + itemUpdates: input.pages.map((page) => ({ + collectorId: DISCORD_INVENTORY_ID, + itemId: page.slug, + slug: page.slug, + lastSeenAt: input.now, + })), + pageRetirements: tracked.flatMap((item) => + emitted.has(item.itemId) + ? [] + : [ + { + collectorId: DISCORD_INVENTORY_ID, + itemId: item.itemId, + slug: item.slug, + }, + ], + ), + }; +} + +function parseInventoryEntry(slug: string): { + guildId: string; + channelId: string; + parentChannelId: string | null; + isThread: boolean; +} | null { + const thread = slug.match(/^discord\/([^/]+)\/threads\/([^/]+)\/([^/]+)\//u); + if (thread) { + return { + guildId: thread[1]!, + parentChannelId: thread[2]!, + channelId: thread[3]!, + isThread: true, + }; + } + const channel = slug.match(/^discord\/([^/]+)\/([^/]+)\//u); + return channel + ? { + guildId: channel[1]!, + channelId: channel[2]!, + parentChannelId: null, + isThread: false, + } + : null; +} + +async function collectInaccessiblePageRetirements( + discovery: DiscordDiscovery | null, + limit: number, +): Promise<{ + retirements: CollectorPageRetirement[]; + ineligibleKeys: Set; +}> { + const tracked = await listBrainCollectorItems( + db, + DISCORD_INVENTORY_ID, + DISCORD_INVENTORY_LIMIT, + ); + if (tracked.length === DISCORD_INVENTORY_LIMIT) { + console.warn( + `${LOG_PREFIX} Discord day-page inventory reached its ${DISCORD_INVENTORY_LIMIT} row cleanup scan bound`, + ); + } + const active = new Set(discovery?.entries.map((entry) => entry.key) ?? []); + + const ineligibleKeys = new Set(); + const retirements = tracked + .filter((item) => { + const parsed = parseInventoryEntry(item.slug); + if (!parsed) return false; + if (!discovery) return false; + if (!discovery.activeGuildIds.has(parsed.guildId)) { + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + return true; + } + if (!discovery.scannedGuildIds.has(parsed.guildId)) return false; + if (active.has(`${parsed.guildId}/${parsed.channelId}`)) return false; + if (parsed.isThread && parsed.parentChannelId) { + // Discord omits archived public threads from this bounded discovery + // pass, but they remain readable. Preserve them while their parent is + // public; losing parent visibility is authoritative removal. + const ineligible = !discovery.readableChannelKeys.has( + `${parsed.guildId}/${parsed.parentChannelId}`, + ); + if (ineligible) { + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + } + return ineligible; + } + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + return true; + }) + .slice(0, limit) + .map((item) => ({ + collectorId: DISCORD_INVENTORY_ID, + itemId: item.itemId, + slug: item.slug, + })); + return { retirements, ineligibleKeys }; +} + +function parseBackfillCursor(raw: string | null): DiscordBackfillCursor { + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + return { + completed: Array.isArray(parsed.completed) + ? parsed.completed.filter( + (entry): entry is string => typeof entry === 'string', + ) + : [], + key: typeof parsed.key === 'string' ? parsed.key : null, + day: typeof parsed.day === 'string' ? parsed.day : null, + }; + } catch { + // Restarting is safe because page slugs are stable upserts. + } + } + return { completed: [], key: null, day: null }; +} + +function parseBackfillPending(raw: string | null): DiscordCollectionEntry[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as Partial; + return Array.isArray(parsed.entries) + ? parsed.entries.filter( + (entry): entry is DiscordCollectionEntry => + typeof entry === 'object' && + entry !== null && + typeof entry.key === 'string' && + typeof entry.guildId === 'string' && + typeof entry.guildName === 'string' && + typeof entry.channelId === 'string' && + typeof entry.channelName === 'string' && + (typeof entry.parentChannelId === 'string' || + entry.parentChannelId === null) && + (typeof entry.parentChannelName === 'string' || + entry.parentChannelName === null) && + typeof entry.isThread === 'boolean', + ) + : []; + } catch { + return []; + } +} + +function serializeBackfillPending(entries: DiscordCollectionEntry[]): string { + return JSON.stringify({ + entries: [...entries].sort((left, right) => + left.key.localeCompare(right.key), + ), + } satisfies DiscordBackfillPending); +} + +function parseRevokedPartitions(raw: string | null): Set { + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as Partial; + return new Set( + Array.isArray(parsed.keys) + ? parsed.keys.filter((key): key is string => typeof key === 'string') + : [], + ); + } catch { + return new Set(); + } +} + +function serializeRevokedPartitions(keys: ReadonlySet): string { + return JSON.stringify({ + keys: [...keys].sort(), + } satisfies DiscordRevokedPartitions); +} + +function pendingEntryRemainsEligible( + entry: DiscordCollectionEntry, + discovery: DiscordDiscovery, + activeKeys: ReadonlySet, +): boolean { + if (!discovery.activeGuildIds.has(entry.guildId)) return false; + if (!discovery.scannedGuildIds.has(entry.guildId)) return true; + if (activeKeys.has(entry.key)) return true; + return Boolean( + entry.isThread && + entry.parentChannelId && + discovery.readableChannelKeys.has( + `${entry.guildId}/${entry.parentChannelId}`, + ), + ); +} + +async function collectDiscordIncremental(input: { + now: Date; + limit: number; +}): Promise { + const guildDiscoveryState = await getBrainSyncState( + db, + DISCORD_GUILD_DISCOVERY_STATE_ID, + ); + const discovery = await discoverDiscordEntries( + parseGuildDiscoveryCursor(guildDiscoveryState?.backfillCursor ?? null), + ); + const inaccessible = await collectInaccessiblePageRetirements( + discovery, + input.limit, + ); + const pageRetirements = inaccessible.retirements; + if (!discovery) { + return { pages: [], nextSince: null, pageRetirements }; + } + + const [people, pendingState, backfillState, revokedState] = await Promise.all( + [ + loadDiscordAuthorLabels(), + getBrainSyncState(db, DISCORD_BACKFILL_PENDING_STATE_ID), + getBrainSyncState(db, discordPublicChannelsCollector.id), + getBrainSyncState(db, DISCORD_REVOKED_PARTITIONS_STATE_ID), + ], + ); + const completedBackfills = new Set( + parseBackfillCursor(backfillState?.backfillCursor ?? null).completed, + ); + const activeKeys = new Set(discovery.entries.map((entry) => entry.key)); + const revoked = parseRevokedPartitions(revokedState?.backfillCursor ?? null); + for (const key of inaccessible.ineligibleKeys) revoked.add(key); + const pendingByKey = new Map( + parseBackfillPending(pendingState?.backfillCursor ?? null) + .filter((entry) => + pendingEntryRemainsEligible(entry, discovery, activeKeys), + ) + .map((entry) => [entry.key, entry] as const), + ); + for (const entry of discovery.entries) { + if (!completedBackfills.has(entry.key) || revoked.has(entry.key)) { + pendingByKey.set(entry.key, entry); + revoked.delete(entry.key); + } + } + const pendingCursor = serializeBackfillPending([...pendingByKey.values()]); + const revokedCursor = serializeRevokedPartitions(revoked); + const entries = await Promise.all( + discovery.entries.map(async (entry) => ({ + ...entry, + state: await getBrainSyncState( + db, + `${discordPublicChannelsCollector.id}:${entry.key}`, + ), + })), + ); + entries.sort( + (left, right) => + (left.state?.watermark?.getTime() ?? 0) - + (right.state?.watermark?.getTime() ?? 0) || + left.key.localeCompare(right.key), + ); + + const pages: CollectorPage[] = []; + const itemUpdates: CollectorItemUpdate[] = []; + const stateUpdates: CollectorStateUpdate[] = discovery + ? [ + { + collectorId: DISCORD_GUILD_DISCOVERY_STATE_ID, + cursor: JSON.stringify(discovery.nextGuildCursor), + }, + ] + : []; + if (pendingCursor !== (pendingState?.backfillCursor ?? null)) { + stateUpdates.push({ + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: pendingCursor, + }); + } + if (revokedCursor !== (revokedState?.backfillCursor ?? null)) { + stateUpdates.push({ + collectorId: DISCORD_REVOKED_PARTITIONS_STATE_ID, + cursor: revokedCursor, + }); + } + const today = utcDay(input.now); + const recentStart = shiftUtcDay(today, -1); + + for (const entry of entries.slice( + 0, + DISCORD_INCREMENTAL_PARTITIONS_PER_PASS, + )) { + const entryPages: CollectorPage[] = []; + const entryUpdates: CollectorItemUpdate[] = []; + const entryRetirements: CollectorPageRetirement[] = []; + let complete = true; + const watermarkDay = entry.state?.watermark + ? utcDay(entry.state.watermark) + : recentStart; + const startDay = watermarkDay < recentStart ? watermarkDay : recentStart; + const days = [startDay, shiftUtcDay(startDay, 1)].filter( + (day) => day <= today, + ); + + for (const day of days) { + try { + const fetched = await fetchDiscordDay({ + provider: discovery.provider, + channelId: entry.channelId, + day, + }); + if (!fetched.complete) { + complete = false; + break; + } + const dayPages = groupDiscordMessagesIntoDayPages({ + entry, + day, + messages: fetched.messages, + people, + }); + const reconciled = await reconcileDiscordDay({ + entry, + day, + pages: dayPages, + now: input.now, + }); + entryPages.push(...dayPages); + entryUpdates.push(...reconciled.itemUpdates); + entryRetirements.push(...reconciled.pageRetirements); + } catch (error) { + console.warn( + `${LOG_PREFIX} Discord channel ${entry.channelId} history read failed; preserving its pages and checkpoint: ${error instanceof Error ? error.message : String(error)}`, + ); + complete = false; + break; + } + } + + if (!complete || pages.length + entryPages.length > input.limit) continue; + pages.push(...entryPages); + itemUpdates.push(...entryUpdates); + pageRetirements.push(...entryRetirements); + stateUpdates.push({ + collectorId: `${discordPublicChannelsCollector.id}:${entry.key}`, + watermark: + days.at(-1) === today + ? input.now + : startOfUtcDay(shiftUtcDay(days.at(-1)!, 1)), + }); + } + + if (backfillState?.backfillCompletedAt && pendingByKey.size > 0) { + stateUpdates.push({ + collectorId: discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + } + + return { + pages, + nextSince: null, + stateUpdates, + itemUpdates, + pageRetirements, + }; +} + +async function backfillDiscordHistory(rawCursor: string | null): Promise<{ + pages: CollectorPage[]; + nextCursor: string | null; + done: boolean; + stateUpdates?: CollectorStateUpdate[]; + itemUpdates?: CollectorItemUpdate[]; + pageRetirements?: CollectorPageRetirement[]; +}> { + const noProgress = { pages: [], nextCursor: rawCursor, done: false }; + const [provider, pendingState, installations] = await Promise.all([ + discoveryCache?.value.provider ?? + createDiscordCommunicationProviderFromRuntimeCredentials(), + getBrainSyncState(db, DISCORD_BACKFILL_PENDING_STATE_ID), + listDiscordInstallations(), + ]); + if (!provider) return noProgress; + + const state = parseBackfillCursor(rawCursor); + const completed = new Set(state.completed); + const activeGuildIds = new Set( + installations.map((installation) => installation.guildId), + ); + const savedPending = parseBackfillPending( + pendingState?.backfillCursor ?? null, + ); + const pending = savedPending.filter((entry) => + activeGuildIds.has(entry.guildId), + ); + const prunedPendingUpdate = + pending.length === savedPending.length + ? [] + : [ + { + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: serializeBackfillPending(pending), + }, + ]; + const entry = + (state.key + ? pending.find((candidate) => candidate.key === state.key) + : null) ?? pending[0]; + + if (!entry) { + return { + pages: [], + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: null, + day: null, + } satisfies DiscordBackfillCursor), + done: true, + stateUpdates: prunedPendingUpdate, + }; + } + + const yesterday = shiftUtcDay(utcDay(new Date()), -1); + const oldest = shiftUtcDay(yesterday, -(DISCORD_BACKFILL_DAYS - 1)); + const day = state.key === entry.key && state.day ? state.day : yesterday; + const fetched = await fetchDiscordDay({ + provider, + channelId: entry.channelId, + day, + }); + if (!fetched.complete) return noProgress; + + const people = await loadDiscordAuthorLabels(); + const pages = groupDiscordMessagesIntoDayPages({ + entry, + day, + messages: fetched.messages, + people, + }); + const reconciled = await reconcileDiscordDay({ + entry, + day, + pages, + now: new Date(), + }); + const nextDay = shiftUtcDay(day, -1); + + if (nextDay >= oldest) { + return { + pages, + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: entry.key, + day: nextDay, + } satisfies DiscordBackfillCursor), + done: false, + stateUpdates: prunedPendingUpdate, + ...reconciled, + }; + } + + completed.add(entry.key); + const remainingPending = pending.filter( + (candidate) => candidate.key !== entry.key, + ); + return { + pages, + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: null, + day: null, + } satisfies DiscordBackfillCursor), + done: false, + stateUpdates: [ + { + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: serializeBackfillPending(remainingPending), + }, + ], + ...reconciled, + }; +} + +export const discordPublicChannelsCollector: BrainCollector = { + id: BRAIN_COLLECTOR_IDS.discordPublicChannels, + displayName: 'Discord public channels', + async isEnabled() { + const [available, provider, tracked] = await Promise.all([ + isBrainSourceAvailable('discord'), + createDiscordCommunicationProviderFromRuntimeCredentials(), + listBrainCollectorItems(db, DISCORD_INVENTORY_ID, 1), + ]); + // Keep authoritative deactivation cleanup runnable after the final guild + // is disabled, but preserve historical pages when credentials are removed. + return available || (provider !== null && tracked.length > 0); + }, + collect({ now, limit }) { + return collectDiscordIncremental({ now, limit }); + }, + backfill({ cursor }) { + return backfillDiscordHistory(cursor); + }, +}; diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index 806f71c9b..ed715ae7b 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -21,6 +21,8 @@ Roomote fills Memory from what it can already see: its own work: what it decided, why, and what is still open - **pull requests** from your connected source-control provider - **public Slack channels** the Roomote bot has been added to +- **public Discord server channels and active public threads** the Roomote bot + can read - **GitHub issues** in connected repositories - **Notion pages** explicitly shared with the deployment's Notion integration - **meeting notes** from Granola, when that integration is connected @@ -61,6 +63,15 @@ inside what your team has already made visible company-wide. Slack directory cards contain names, handles, and job titles, but never copy profile email, status, timezone, or avatar fields into Memory. +Discord collection follows the server's permission model: Roomote includes only +channels visible to the server's `@everyone` role where the bot also has **View +Channel** and **Read Message History**. Private channels, private threads, group +DMs, and direct messages are never collected. Active public threads and forum +posts inherit the visibility of their public parent channel. Roomote re-reads a +bounded recent window so edits and deletions are reflected, and removes stored +pages when an authoritative permission scan shows that a channel is no longer +publicly accessible. + Notion only returns pages explicitly shared with its integration. Workspace guests and restricted users may omit email addresses, and some integration configurations cannot list users at all. Roomote still keeps stable Notion user diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 473e84c27..3c8b773ec 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -59,13 +59,20 @@ automatically. If every tag is moderated, the bot needs Manage Threads to apply one. - Unlike a Slack workspace, a Discord server can be public or shared with +Unlike a Slack workspace, a Discord server can be public or shared with people outside your team. Task threads are visible to everyone who can see the channel, including repository names, task descriptions, and PR links. Only add Roomote to servers whose members you trust with that context, and use private channels for sensitive work. +When deployment Memory is enabled, Roomote also collects message history from +server channels visible to `@everyone` where the bot has **View Channel** and +**Read Message History**. Active public threads and forum posts are included; +private channels, private threads, and DMs are not. Removing public visibility +or the bot's read access removes that channel's collected pages after the next +successful permission scan. + If Roomote cannot see the server or channel, confirm that the bot was added to the server and that its role and channel overrides grant the permissions above. Use **Repair** to register commands again and refresh server discovery. diff --git a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts index 5a4d8ea56..da1274db7 100644 --- a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts +++ b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts @@ -39,6 +39,7 @@ function row( const ALL_CONNECTED = { slack: true, + discord: true, github: true, notion: true, granola: true, diff --git a/packages/communication/src/__tests__/discord-provider.test.ts b/packages/communication/src/__tests__/discord-provider.test.ts index 4ad187dd4..2591f1259 100644 --- a/packages/communication/src/__tests__/discord-provider.test.ts +++ b/packages/communication/src/__tests__/discord-provider.test.ts @@ -314,6 +314,28 @@ describe('DiscordCommunicationProvider', () => { }); }); + it('lists active guild threads for bounded collector discovery', async () => { + const { server, provider } = createHarness(); + server.addChannel({ + id: '400000000000000002', + guild_id: server.guildId, + parent_id: '400000000000000001', + name: 'public-thread', + type: 11, + }); + + await expect( + provider.listGuildActiveThreads(server.guildId), + ).resolves.toEqual([ + expect.objectContaining({ + id: '400000000000000002', + parentId: '400000000000000001', + name: 'public-thread', + type: 11, + }), + ]); + }); + it('treats a denied add_reactions overwrite as missing required channel permission', async () => { const { server, provider } = createHarness(); const channelId = '400000000000000001'; @@ -493,6 +515,14 @@ describe('DiscordCommunicationProvider', () => { channelIds: [publicChannelId, privateChannelId], }), ).resolves.toEqual([publicChannelId]); + await expect( + provider.listPublicReadableGuildChannels({ + guildId: server.guildId, + userId: server.bot.id, + }), + ).resolves.toEqual([ + expect.objectContaining({ id: publicChannelId, name: 'public' }), + ]); }); it('applies the everyone overwrite separately from member role overwrites', async () => { diff --git a/packages/communication/src/discord-provider.ts b/packages/communication/src/discord-provider.ts index 87618bbeb..0792732ae 100644 --- a/packages/communication/src/discord-provider.ts +++ b/packages/communication/src/discord-provider.ts @@ -63,6 +63,11 @@ export type DiscordGuild = { permissions?: string; }; +export type DiscordGuildPage = { + guilds: DiscordGuild[]; + nextAfter: string | null; +}; + type DiscordApiGuild = { id: string; name: string; @@ -158,6 +163,7 @@ type DiscordApiMessage = { }; }>; thread?: { id: string }; + message_reference?: { message_id?: string }; }; type DiscordPermissionOverwrite = { @@ -399,6 +405,9 @@ function toCommunicationMessage( text, channelId: message.channel_id, ...(message.thread?.id ? { threadId: message.thread.id } : {}), + ...(message.message_reference?.message_id + ? { replyToMessageId: message.message_reference.message_id } + : {}), fileCount: files.length, ...(files.length ? { files } : {}), }; @@ -1262,27 +1271,45 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte return this.normalizeChannel(channel); } + async listGuildsPage(input?: { + after?: string; + limit?: number; + }): Promise { + const limit = Math.min(200, Math.max(1, input?.limit ?? 200)); + const query = new URLSearchParams({ limit: String(limit) }); + if (input?.after) query.set('after', input.after); + const page = await this.request( + 'GET', + `/users/@me/guilds?${query.toString()}`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ); + const guilds = page.map((guild) => ({ + id: guild.id, + name: guild.name, + icon: guild.icon ?? null, + ...(guild.owner === undefined ? {} : { owner: guild.owner }), + ...(guild.permissions ? { permissions: guild.permissions } : {}), + })); + return { + guilds, + nextAfter: page.length === limit ? (page.at(-1)?.id ?? null) : null, + }; + } + async listGuilds(): Promise { - const guilds: DiscordApiGuild[] = []; + const guilds: DiscordGuild[] = []; const seenCursors = new Set(); let after: string | null = null; while (true) { - const query = new URLSearchParams({ limit: '200' }); - if (after) query.set('after', after); - const page = await this.request( - 'GET', - `/users/@me/guilds?${query.toString()}`, - undefined, - { retryNetworkErrors: true, retryServerErrors: true }, - ); - guilds.push(...page); - - if (page.length < 200) { - break; - } - - const nextCursor = page.at(-1)?.id; + const page = await this.listGuildsPage({ + ...(after ? { after } : {}), + limit: 200, + }); + guilds.push(...page.guilds); + const nextCursor = page.nextAfter; + if (!nextCursor) break; if (!nextCursor || seenCursors.has(nextCursor)) { throw new Error('Discord guild pagination cursor did not advance.'); } @@ -1290,15 +1317,7 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte after = nextCursor; } - return [...new Map(guilds.map((guild) => [guild.id, guild])).values()].map( - (guild) => ({ - id: guild.id, - name: guild.name, - icon: guild.icon ?? null, - ...(guild.owner === undefined ? {} : { owner: guild.owner }), - ...(guild.permissions ? { permissions: guild.permissions } : {}), - }), - ); + return [...new Map(guilds.map((guild) => [guild.id, guild])).values()]; } async listGuildChannels(guildId: string): Promise { @@ -1311,6 +1330,72 @@ export class DiscordCommunicationProvider implements CommunicationProviderAdapte return channels.map((channel) => this.normalizeChannel(channel)); } + async listGuildActiveThreads(guildId: string): Promise { + const response = await this.request<{ threads?: DiscordApiChannel[] }>( + 'GET', + `/guilds/${guildId}/threads/active`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ); + return (response.threads ?? []).map((thread) => + this.normalizeChannel(thread), + ); + } + + /** + * Returns channels visible to @everyone that the selected guild member can + * also read. One guild snapshot avoids per-channel permission API calls. + */ + async listPublicReadableGuildChannels(input: { + guildId: string; + userId: string; + }): Promise { + const [member, roles, channels] = await Promise.all([ + this.request<{ roles?: string[] }>( + 'GET', + `/guilds/${input.guildId}/members/${input.userId}`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ), + this.request>( + 'GET', + `/guilds/${input.guildId}/roles`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ), + this.request( + 'GET', + `/guilds/${input.guildId}/channels`, + undefined, + { retryNetworkErrors: true, retryServerErrors: true }, + ), + ]); + const memberRoleIds = new Set([input.guildId, ...(member.roles ?? [])]); + + return channels.flatMap((channel) => { + const everyone = this.resolveEveryoneChannelPermissions({ + guildId: input.guildId, + roles, + channel, + }); + const memberPermissions = this.resolveMemberChannelPermissions({ + guildId: input.guildId, + userId: input.userId, + memberRoleIds, + roles, + channel, + }); + const required = + DISCORD_PERMISSION_BITS.view_channel | + DISCORD_PERMISSION_BITS.read_message_history; + + return (everyone & DISCORD_PERMISSION_BITS.view_channel) !== 0n && + (memberPermissions & required) === required + ? [this.normalizeChannel(channel)] + : []; + }); + } + async getChannel(channelId: string): Promise { const channel = await this.request( 'GET', diff --git a/packages/communication/src/mock-discord-server.ts b/packages/communication/src/mock-discord-server.ts index e3b7bec12..2f3c0cab0 100644 --- a/packages/communication/src/mock-discord-server.ts +++ b/packages/communication/src/mock-discord-server.ts @@ -420,6 +420,16 @@ export class MockDiscordServer { ), ); } + if (method === 'GET' && path === `/guilds/${this.guildId}/threads/active`) { + return jsonResponse({ + threads: Object.values(this.state.channels).filter( + (channel) => + channel.guild_id === this.guildId && + (channel.type === 10 || channel.type === 11 || channel.type === 12), + ), + members: [], + }); + } const guildMember = /^\/guilds\/([^/]+)\/members\/([^/?]+)$/u.exec(path); if (method === 'GET' && guildMember && guildMember[1] === this.guildId) { // Real Discord rejects the literal `@me` on this route (unlike diff --git a/packages/communication/src/provider.ts b/packages/communication/src/provider.ts index b8cbed224..5ef5960e0 100644 --- a/packages/communication/src/provider.ts +++ b/packages/communication/src/provider.ts @@ -21,6 +21,7 @@ export type CommunicationMessage = { text: string; channelId: string; threadId?: string; + replyToMessageId?: string; fileCount: number; files?: CommunicationMessageAttachment[]; }; diff --git a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts index 1ae1819e4..84eee07b8 100644 --- a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts +++ b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts @@ -1,8 +1,10 @@ const mocks = vi.hoisted(() => ({ findConnection: vi.fn(), + findDiscordInstallation: vi.fn(), findEnablement: vi.fn(), findSlackInstallation: vi.fn(), hasGithubSources: vi.fn(), + resolveDiscordCredentials: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -10,11 +12,13 @@ vi.mock('@roomote/db/server', () => ({ db: { query: { deploymentMcpEnablements: { findFirst: mocks.findEnablement }, + discordInstallations: { findFirst: mocks.findDiscordInstallation }, mcpConnections: { findFirst: mocks.findConnection }, slackInstallations: { findFirst: mocks.findSlackInstallation }, }, }, deploymentMcpEnablements: { enabled: {}, mcpId: {} }, + discordInstallations: { isActive: {} }, eq: vi.fn(), isNull: vi.fn(), mcpConnections: { @@ -24,6 +28,7 @@ vi.mock('@roomote/db/server', () => ({ userId: {}, }, slackInstallations: { isActive: {} }, + resolveDiscordRuntimeCredentials: mocks.resolveDiscordCredentials, })); vi.mock('../brain-github', () => ({ @@ -46,6 +51,7 @@ describe('resolveBrainSourceRequirements', () => { it('resolves every reported source through the collector availability policy', async () => { const availability: Record = { github: true, + discord: true, granola: false, notion: true, rippling: false, @@ -61,6 +67,7 @@ describe('resolveBrainSourceRequirements', () => { expect(new Set(resolveRequirement.mock.calls.flat())).toEqual( new Set([ 'github', + 'discord', 'granola', 'notion', 'rippling', @@ -127,4 +134,14 @@ describe('isBrainSourceAvailable', () => { await expect(isBrainSourceAvailable('slack')).resolves.toBe(true); await expect(isBrainSourceAvailable('github')).resolves.toBe(true); }); + + it('requires Discord credentials and an active guild installation', async () => { + mocks.resolveDiscordCredentials.mockResolvedValue({ botToken: 'token' }); + mocks.findDiscordInstallation.mockResolvedValue({ id: 'installation-id' }); + + await expect(isBrainSourceAvailable('discord')).resolves.toBe(true); + + mocks.findDiscordInstallation.mockResolvedValue(null); + await expect(isBrainSourceAvailable('discord')).resolves.toBe(false); + }); }); diff --git a/packages/sdk/src/server/lib/brain-source-availability.ts b/packages/sdk/src/server/lib/brain-source-availability.ts index 914aea80d..4c9b7d681 100644 --- a/packages/sdk/src/server/lib/brain-source-availability.ts +++ b/packages/sdk/src/server/lib/brain-source-availability.ts @@ -2,10 +2,12 @@ import { and, db, deploymentMcpEnablements, + discordInstallations, eq, isNull, mcpConnections, slackInstallations, + resolveDiscordRuntimeCredentials, } from '@roomote/db/server'; import { BRAIN_SOURCES, @@ -96,6 +98,16 @@ const BRAIN_SOURCE_AVAILABILITY = { }); return Boolean(installation); }, + discord: async () => { + const [credentials, installation] = await Promise.all([ + resolveDiscordRuntimeCredentials(), + db.query.discordInstallations.findFirst({ + columns: { id: true }, + where: eq(discordInstallations.isActive, true), + }), + ]); + return Boolean(credentials.botToken && installation); + }, notion: async () => Boolean(await findBrainSourceConnectionConfig('notion')), granola: async () => Boolean(await findBrainSourceConnectionConfig('granola')), diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 7495b434f..1d3be5a23 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -32,6 +32,9 @@ describe('resolveBrainNamespaceId', () => { ); expect(resolveBrainNamespaceId('people/roomote-member-abc')).toBe('people'); expect(resolveBrainNamespaceId('daily/digests/2026-01-02')).toBe('daily'); + expect(resolveBrainNamespaceId('discord/123/456/2026-01-02/000')).toBe( + 'discord', + ); }); it('does not invent a namespace for an unrecognised prefix', () => { @@ -67,6 +70,11 @@ describe('resolveBrainSourceIdForCollector', () => { expect(resolveBrainSourceIdForCollector('notion-pages:incremental')).toBe( 'notion-pages', ); + expect( + resolveBrainSourceIdForCollector( + 'discord-public-channels:entity-timeline-v1:123/456', + ), + ).toBe('discord-public-channels'); }); it('claims nothing for state rows that are not a source', () => { diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index 05501da94..f7279e9b1 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -32,6 +32,7 @@ export const BRAIN_NAMESPACES = [ { id: 'prs', prefix: 'prs/', label: 'Pull requests' }, { id: 'github', prefix: 'github/', label: 'GitHub issues' }, { id: 'slack', prefix: 'slack/', label: 'Slack' }, + { id: 'discord', prefix: 'discord/', label: 'Discord' }, { id: 'notion', prefix: 'notion/', label: 'Notion' }, { id: 'meetings', prefix: 'meetings/', label: 'Meetings' }, { id: 'daily', prefix: 'daily/', label: 'Daily digests' }, @@ -118,6 +119,7 @@ export const BRAIN_COLLECTOR_IDS = { ripplingWorkers: 'rippling-workers', slackPersonDirectory: 'slack-person-directory:occurrence-date-v2', slackPublicChannels: 'slack-public-channels:entity-timeline-v3', + discordPublicChannels: 'discord-public-channels:entity-timeline-v1', githubIssues: 'github-issues:occurrence-date-v3', notionPages: 'notion-pages', granolaMeetings: 'granola-meetings:entity-timeline-v3', @@ -136,6 +138,7 @@ export const BRAIN_PAGE_TYPES = { pullRequest: 'pull-request', githubIssue: 'github-issue', slackDay: 'slack', + discordDay: 'discord', meeting: 'meeting', notionPage: 'notion-page', person: 'person', @@ -248,6 +251,18 @@ export const BRAIN_SOURCES = [ ] as readonly string[], requires: 'slack', }, + { + id: 'discord-public-channels', + label: 'Discord public channels', + description: + 'History of public server channels and active public threads the Roomote bot can read. Private channels and DMs are never read.', + namespaceId: 'discord', + collectorIdPrefix: 'discord-public-channels', + collectorIds: [ + BRAIN_COLLECTOR_IDS.discordPublicChannels, + ] as readonly string[], + requires: 'discord', + }, { id: 'github-issues', label: 'GitHub issues', @@ -365,7 +380,7 @@ export function parseBrainBackfillCompletedCount( * chosen from gbrain's own description, which is written for a different * product and routes to tools this deployment does not expose. */ -export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack channels, meeting notes, GitHub issues), each stored as a page with citations. +export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack and Discord channels, meeting notes, GitHub issues), each stored as a page with citations. ## Using what it knows @@ -379,7 +394,7 @@ Which tool: - \`query\` when you are describing a concept and do not know how the Brain words it. It expands your phrasing into related queries, so it finds pages that talk about the same thing in different language. This is the default, and the right choice for that first pass. - \`search\` when you already know the exact token: a slug, a repository name, an error string, a person's handle. Cheaper than \`query\` because it skips the expansion step. - \`entity\` for one known person. It resolves names and linked provider handles against canonical deployment-member cards without an LLM call. -- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`notion/\`, \`meetings/\`, \`github/\`. +- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`discord/\`, \`notion/\`, \`meetings/\`, \`github/\`. - \`get_page\` on a slug for a page's full text, once a search result looks relevant. A result set that comes back populated is not proof of coverage, and one query returning nothing is not proof of absence. If the answer matters, try the other phrasing or list the namespace before deciding the Brain has nothing. From d82bef8897f770a8ab5f5564fcaf4c5bb5ddd436 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 27 Aug 2026 23:14:10 -0400 Subject: [PATCH 009/158] Re-add DO_NOT_TRACK to the bundled Infinity service (#1764) --- deploy/compose/docker-compose.prod.yml | 3 +++ deploy/scripts/validate-compose-security.sh | 1 + 2 files changed, 4 insertions(+) diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 2cf250633..50af5f5db 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -571,6 +571,9 @@ services: networks: [default] environment: INFINITY_ANONYMOUS_USAGE_STATS: '0' + # Standard opt-out honored by Infinity (and many other tools); keeps + # the stack quiet even if the Infinity-specific variable is renamed. + DO_NOT_TRACK: '1' command: - v2 - --device diff --git a/deploy/scripts/validate-compose-security.sh b/deploy/scripts/validate-compose-security.sh index 077f2318a..332d0ae91 100755 --- a/deploy/scripts/validate-compose-security.sh +++ b/deploy/scripts/validate-compose-security.sh @@ -52,6 +52,7 @@ jq -e ' (.services.infinity.image == "michaelf34/infinity:0.0.76-cpu@sha256:2a464dcc06e659a277bc841b4be196100489076446482925481b2c5c120fce57") and (.services.infinity.environment.INFINITY_ANONYMOUS_USAGE_STATS == "0") and + (.services.infinity.environment.DO_NOT_TRACK == "1") and (.services.infinity.volumes == [{ "type": "volume", "source": "infinity_cache", From 991fed027f61e80d2559ee017cec5315ed7cbd70 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:28:04 -0400 Subject: [PATCH 010/158] feat: collect Linear issues into Brain (#1759) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../src/scheduled-jobs/brain-collectors.ts | 2 + .../brain-collectors/linear-issues.ts | 28 ++ apps/docs/integrations/linear.mdx | 16 +- .../commands/brain/summarize-sources.test.ts | 4 +- .../src/__tests__/linear-client-brain.test.ts | 102 +++++ packages/linear/src/index.ts | 2 + packages/linear/src/linear-client.ts | 166 +++++++ packages/linear/src/types.ts | 38 ++ packages/sdk/src/server/index.ts | 1 + .../server/lib/__tests__/brain-linear.test.ts | 280 ++++++++++++ .../brain-source-availability.test.ts | 19 + packages/sdk/src/server/lib/brain-linear.ts | 422 ++++++++++++++++++ .../server/lib/brain-source-availability.ts | 10 + packages/types/src/brain.test.ts | 6 + packages/types/src/brain.ts | 17 +- 15 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts create mode 100644 packages/linear/src/__tests__/linear-client-brain.test.ts create mode 100644 packages/sdk/src/server/lib/__tests__/brain-linear.test.ts create mode 100644 packages/sdk/src/server/lib/brain-linear.ts diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts index c6a93f9ca..e648b45f2 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts @@ -28,6 +28,7 @@ import { import { githubIssuesCollector } from './brain-collectors/github-issues'; import { discordPublicChannelsCollector } from './brain-collectors/discord-public-channels'; import { granolaMeetingsCollector } from './brain-collectors/granola-meetings'; +import { linearIssuesCollector } from './brain-collectors/linear-issues'; import { notionPagesCollector, notionUsersCollector, @@ -379,4 +380,5 @@ const BRAIN_COLLECTORS: BrainCollector[] = [ notionPagesCollector, granolaMeetingsCollector, githubIssuesCollector, + linearIssuesCollector, ]; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts new file mode 100644 index 000000000..4303eb2cf --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts @@ -0,0 +1,28 @@ +import { BRAIN_COLLECTOR_IDS } from '@roomote/types'; + +import { + backfillBrainLinearIssuesStep, + collectBrainLinearIssues, + isBrainSourceAvailable, +} from '@roomote/sdk/server'; + +import type { BrainCollector } from './contracts'; + +/** + * Linear issues are durable product context. The SDK collector reuses the + * deployment OAuth connection, keeps comments bounded inside each issue page, + * and performs periodic complete visibility sweeps before retiring pages. + */ +export const linearIssuesCollector: BrainCollector = { + id: BRAIN_COLLECTOR_IDS.linearIssues, + displayName: 'Linear issues', + async isEnabled() { + return isBrainSourceAvailable('linear'); + }, + async collect({ now, limit }) { + return collectBrainLinearIssues({ now, limit }); + }, + async backfill({ cursor, limit }) { + return backfillBrainLinearIssuesStep({ cursor, limit }); + }, +}; diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 237d70b90..889bc4ef4 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -6,7 +6,8 @@ icon: 'https://api.iconify.design/simple-icons:linear.svg?color=currentColor' The Linear integration lets Roomote receive work from Linear, post progress back to the issue or agent session, and keep the full run available in the Roomote -task view. +task view. When the Brain is configured, Roomote also indexes issues from the +connected workspace so agents can recall product context before opening Linear. Linear is optional during onboarding. Connect it when your team wants Roomote to work from issues that already have product context, acceptance criteria, @@ -68,6 +69,19 @@ Roomote can post status, plan updates, and final responses back to Linear. The Roomote task view remains the best place to inspect logs, diffs, artifacts, and previews. +## Brain context + +The Brain keeps one durable page per visible Linear issue, including its current +workflow state, team, project, priority, labels, assignee, description, and a +bounded set of recent comments. Collection uses the same workspace connection +configured above; it does not require another Linear token. + +Roomote refreshes changed issues incrementally and periodically checks the full +visible issue set. Archived issues remain available as historical context. If an +issue is deleted or the connected app can no longer see it, Roomote removes its +page only after a complete visibility check, avoiding deletion from a partial or +failed API response. + Put acceptance criteria and relevant repository links directly in the Linear issue before starting agent work. diff --git a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts index da1274db7..40d7d4f24 100644 --- a/apps/web/src/trpc/commands/brain/summarize-sources.test.ts +++ b/apps/web/src/trpc/commands/brain/summarize-sources.test.ts @@ -44,6 +44,7 @@ const ALL_CONNECTED = { notion: true, granola: true, rippling: false, + linear: true, } as const; function summarize( @@ -176,9 +177,10 @@ describe('summarizeSources', () => { }); it('reports a disconnected requirement over any sync state', () => { - const sources = summarize([]); + const sources = summarize([], { linear: false }); expect(sourceOf(sources, 'rippling-workers').status).toBe('not_connected'); + expect(sourceOf(sources, 'linear-issues').status).toBe('not_connected'); expect(sourceOf(sources, 'task-memories').status).toBe('ingesting'); expect(sourceOf(sources, 'task-memories').lastSyncedAt).toEqual( new Date('2026-08-20T18:50:00Z'), diff --git a/packages/linear/src/__tests__/linear-client-brain.test.ts b/packages/linear/src/__tests__/linear-client-brain.test.ts new file mode 100644 index 000000000..c75d1a669 --- /dev/null +++ b/packages/linear/src/__tests__/linear-client-brain.test.ts @@ -0,0 +1,102 @@ +const rawRequest = vi.hoisted(() => vi.fn()); + +vi.mock('@linear/sdk', () => ({ + AgentActivitySignal: { Select: 'select', Auth: 'auth' }, + LinearClient: class { + client = { rawRequest }; + }, +})); + +import { createLinearClient } from '../linear-client'; + +describe('LinearClient.listIssuesForBrain', () => { + it('normalizes a bounded issue page and comment authors', async () => { + rawRequest.mockResolvedValue({ + data: { + issues: { + nodes: [ + { + id: 'issue-1', + identifier: 'ENG-1', + title: 'Collect Linear issues', + description: null, + url: 'https://linear.app/acme/issue/ENG-1', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + labels: { nodes: [{ name: 'brain' }] }, + comments: { + nodes: [ + { + id: 'comment-1', + body: 'Use the existing OAuth connection.', + createdAt: '2026-08-01T01:00:00.000Z', + updatedAt: '2026-08-01T01:00:00.000Z', + externalUser: { name: 'External author' }, + }, + ], + }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + + const result = await createLinearClient('token').listIssuesForBrain({ + first: 500, + after: 'cursor-1', + updatedAfter: '2026-08-01T00:00:00.000Z', + updatedBefore: '2026-08-03T00:00:00.000Z', + }); + + expect(rawRequest).toHaveBeenCalledWith( + expect.stringContaining('comments(last: 20'), + { + first: 100, + after: 'cursor-1', + filter: { + updatedAt: { + gte: '2026-08-01T00:00:00.000Z', + lte: '2026-08-03T00:00:00.000Z', + }, + }, + }, + ); + expect(result).toEqual({ + issues: [ + expect.objectContaining({ + id: 'issue-1', + labels: ['brain'], + comments: [expect.objectContaining({ author: 'External author' })], + }), + ], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }); + }); + + it('supports stable creation-time pagination for visibility censuses', async () => { + rawRequest.mockResolvedValue({ + data: { + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + + await createLinearClient('token').listIssuesForBrain({ + first: 50, + orderBy: 'createdAt', + createdBefore: '2026-08-20T12:00:00.000Z', + }); + + expect(rawRequest).toHaveBeenCalledWith( + expect.stringContaining('orderBy: createdAt'), + expect.objectContaining({ + filter: { + createdAt: { lte: '2026-08-20T12:00:00.000Z' }, + }, + }), + ); + }); +}); diff --git a/packages/linear/src/index.ts b/packages/linear/src/index.ts index 407b22e34..fc2beaab9 100644 --- a/packages/linear/src/index.ts +++ b/packages/linear/src/index.ts @@ -4,6 +4,8 @@ export type { AgentSessionEventAction, HumanToAgentSignal, LinearIssue, + LinearBrainIssue, + LinearBrainIssuePage, LinearComment, LinearUser, AgentGuidance, diff --git a/packages/linear/src/linear-client.ts b/packages/linear/src/linear-client.ts index 31923eddf..271a60817 100644 --- a/packages/linear/src/linear-client.ts +++ b/packages/linear/src/linear-client.ts @@ -6,6 +6,7 @@ import type { AgentSessionPlanStep, AgentSessionUpdateResult, LinearComment, + LinearBrainIssuePage, LinearOrganization, LinearViewer, } from './types'; @@ -46,6 +47,171 @@ export class LinearClient { }; } + /** + * Read one bounded issue page for durable Brain ingestion. Comments are + * fetched in the same GraphQL request so collection does not create an N+1 + * request pattern. + */ + async listIssuesForBrain(input: { + first: number; + after?: string | null; + orderBy?: 'createdAt' | 'updatedAt'; + createdBefore?: string | null; + updatedAfter?: string | null; + updatedBefore?: string | null; + }): Promise { + const query = ` + query BrainIssues( + $first: Int! + $after: String + $filter: IssueFilter + ) { + issues( + first: $first + after: $after + orderBy: ${input.orderBy ?? 'updatedAt'} + includeArchived: true + filter: $filter + ) { + nodes { + id + identifier + title + description + url + priority + priorityLabel + createdAt + updatedAt + completedAt + canceledAt + archivedAt + dueDate + state { name type } + team { key name } + project { name } + creator { name } + assignee { name } + labels { nodes { name } } + comments(last: 20, orderBy: createdAt) { + nodes { + id + body + createdAt + updatedAt + user { name } + externalUser { name } + botActor { name } + } + } + } + pageInfo { hasNextPage endCursor } + } + } + `; + const response = await this.client.client.rawRequest(query, { + first: Math.max(1, Math.min(input.first, 100)), + after: input.after ?? null, + filter: + input.createdBefore || input.updatedAfter || input.updatedBefore + ? { + ...(input.createdBefore + ? { createdAt: { lte: input.createdBefore } } + : {}), + ...(input.updatedAfter || input.updatedBefore + ? { + updatedAt: { + ...(input.updatedAfter + ? { gte: input.updatedAfter } + : {}), + ...(input.updatedBefore + ? { lte: input.updatedBefore } + : {}), + }, + } + : {}), + } + : null, + }); + const data = response.data as { + issues?: { + nodes?: Array<{ + id: string; + identifier: string; + title: string; + description?: string | null; + url: string; + priority?: number | null; + priorityLabel?: string | null; + createdAt: string; + updatedAt: string; + completedAt?: string | null; + canceledAt?: string | null; + archivedAt?: string | null; + dueDate?: string | null; + state?: { name: string; type: string } | null; + team?: { key: string; name: string } | null; + project?: { name: string } | null; + creator?: { name: string } | null; + assignee?: { name: string } | null; + labels?: { nodes?: Array<{ name: string }> }; + comments?: { + nodes?: Array<{ + id: string; + body: string; + createdAt: string; + updatedAt: string; + user?: { name: string } | null; + externalUser?: { name: string } | null; + botActor?: { name: string } | null; + }>; + }; + }>; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + }; + }; + const connection = data.issues; + + return { + issues: (connection?.nodes ?? []).map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + description: issue.description ?? null, + url: issue.url, + priority: issue.priority ?? null, + priorityLabel: issue.priorityLabel ?? null, + createdAt: issue.createdAt, + updatedAt: issue.updatedAt, + completedAt: issue.completedAt ?? null, + canceledAt: issue.canceledAt ?? null, + archivedAt: issue.archivedAt ?? null, + dueDate: issue.dueDate ?? null, + state: issue.state ?? null, + team: issue.team ?? null, + project: issue.project ?? null, + creator: issue.creator ?? null, + assignee: issue.assignee ?? null, + labels: (issue.labels?.nodes ?? []).map((label) => label.name), + comments: (issue.comments?.nodes ?? []).map((comment) => ({ + id: comment.id, + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + author: + comment.user?.name ?? + comment.externalUser?.name ?? + comment.botActor?.name ?? + null, + })), + })), + pageInfo: { + hasNextPage: connection?.pageInfo?.hasNextPage ?? false, + endCursor: connection?.pageInfo?.endCursor ?? null, + }, + }; + } + /** * Emit an agent activity for a session. * diff --git a/packages/linear/src/types.ts b/packages/linear/src/types.ts index e5d3ef3bc..ff564e428 100644 --- a/packages/linear/src/types.ts +++ b/packages/linear/src/types.ts @@ -262,6 +262,44 @@ export interface LinearIssue { }; } +/** Normalized issue shape used by bounded, read-only Brain collection. */ +export interface LinearBrainIssue { + id: string; + identifier: string; + title: string; + description: string | null; + url: string; + priority: number | null; + priorityLabel: string | null; + createdAt: string; + updatedAt: string; + completedAt: string | null; + canceledAt: string | null; + archivedAt: string | null; + dueDate: string | null; + state: { name: string; type: string } | null; + team: { key: string; name: string } | null; + project: { name: string } | null; + creator: { name: string } | null; + assignee: { name: string } | null; + labels: string[]; + comments: Array<{ + id: string; + body: string; + createdAt: string; + updatedAt: string; + author: string | null; + }>; +} + +export interface LinearBrainIssuePage { + issues: LinearBrainIssue[]; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; +} + /** * Linear Comment from webhook payload * Note: url and createdAt are optional as Linear doesn't always include them diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts index 863996826..122d3b4e6 100644 --- a/packages/sdk/src/server/index.ts +++ b/packages/sdk/src/server/index.ts @@ -429,5 +429,6 @@ export * from './lib/brain-clients'; export * from './lib/brain-corpus'; export * from './lib/brain-mcp'; export * from './lib/brain-github'; +export * from './lib/brain-linear'; export * from './lib/brain-inference'; export * from './lib/brain-source-availability'; diff --git a/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts new file mode 100644 index 000000000..8523d4c10 --- /dev/null +++ b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts @@ -0,0 +1,280 @@ +const mocks = vi.hoisted(() => ({ + syncState: new Map< + string, + { + watermark?: Date | null; + backfillCursor?: string | null; + backfillCompletedAt?: Date | null; + } + >(), + staleItems: [] as Array<{ itemId: string; slug: string }>, + listIssues: vi.fn(), + findConnection: vi.fn(), + getValidAccessToken: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: {}, + getBrainSyncState: vi.fn(async (_db: unknown, collectorId: string) => + mocks.syncState.has(collectorId) + ? { collectorId, ...mocks.syncState.get(collectorId) } + : null, + ), + listBrainCollectorItemsBefore: vi.fn(async () => mocks.staleItems), +})); + +vi.mock('@roomote/linear', () => ({ + createLinearClient: vi.fn(() => ({ + listIssuesForBrain: mocks.listIssues, + })), +})); + +vi.mock('../mcp/data', () => ({ + getValidAccessToken: mocks.getValidAccessToken, +})); + +vi.mock('../mcp/linear-connections', () => ({ + findLinearDeploymentMcpConnection: mocks.findConnection, + getLinearDeploymentMetadata: (config: Record | null) => + typeof config?.linearOrganizationId === 'string' + ? { + linearOrganizationId: config.linearOrganizationId, + linearOrganizationName: config.linearOrganizationName ?? null, + } + : null, +})); + +import { + backfillBrainLinearIssuesStep, + buildLinearIssuePage, + collectBrainLinearIssues, +} from '../brain-linear'; + +const issue = { + id: 'Issue-UUID', + identifier: 'ENG-42', + title: 'Keep preview sessions alive', + description: 'A preview should remain reachable while a task is active.', + url: 'https://linear.app/acme/issue/ENG-42', + priority: 2, + priorityLabel: 'High', + createdAt: '2026-08-01T10:00:00.000Z', + updatedAt: '2026-08-03T12:00:00.000Z', + completedAt: '2026-08-03T12:00:00.000Z', + canceledAt: null, + archivedAt: null, + dueDate: '2026-08-10', + state: { name: 'Done', type: 'completed' }, + team: { key: 'ENG', name: 'Engineering' }, + project: { name: 'Previews' }, + creator: { name: 'Ada' }, + assignee: { name: 'Grace' }, + labels: ['bug', 'customer'], + comments: [ + { + id: 'comment-1', + body: 'The controller must renew the lease.', + createdAt: '2026-08-02T09:00:00.000Z', + updatedAt: '2026-08-02T09:00:00.000Z', + author: 'Linus', + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.syncState.clear(); + mocks.staleItems = []; + mocks.findConnection.mockResolvedValue({ + id: 'connection-1', + authConfig: { + linearOrganizationId: 'Org-UUID', + linearOrganizationName: 'Acme', + }, + }); + mocks.getValidAccessToken.mockResolvedValue('access-token'); +}); + +describe('buildLinearIssuePage', () => { + it('builds a stable canonical issue page without email metadata', () => { + const page = buildLinearIssuePage({ + organizationId: 'Org-UUID', + organizationName: 'Acme', + issue, + }); + + expect(page?.slug).toBe('linear/org-uuid/issues/issue-uuid'); + expect(page?.title).toBe('ENG-42: Keep preview sessions alive'); + expect(page?.content).toContain('type: linear-issue'); + expect(page?.content).toContain('event_date: 2026-08-03'); + expect(page?.content).toContain('team: "Engineering"'); + expect(page?.content).toContain('project: "Previews"'); + expect(page?.content).toContain('state: "Done"'); + expect(page?.content).toContain('## Discussion'); + expect(page?.content).toContain('The controller must renew the lease.'); + expect(page?.content).toContain('provenance: roomote-linear-issues'); + expect(page?.content).not.toContain('@'); + }); + + it('bounds issue and comment text', () => { + const page = buildLinearIssuePage({ + organizationId: 'org', + organizationName: null, + issue: { + ...issue, + description: 'x'.repeat(9_000), + comments: [{ ...issue.comments[0]!, body: 'y'.repeat(2_000) }], + }, + }); + + expect(page?.content).toContain('x'.repeat(8_000)); + expect(page?.content).not.toContain('x'.repeat(8_001)); + expect(page?.content).toContain('y'.repeat(800)); + expect(page?.content).not.toContain('y'.repeat(801)); + }); +}); + +describe('Linear issue collection', () => { + it('persists the upstream cursor inside a frozen incremental window', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [issue], + pageInfo: { hasNextPage: true, endCursor: 'next-page' }, + }); + const now = new Date('2026-08-20T12:00:00.000Z'); + + const result = await collectBrainLinearIssues({ now, limit: 25 }); + + expect(result.pages).toHaveLength(1); + expect(result.itemUpdates).toEqual([ + expect.objectContaining({ + collectorId: 'linear-issues:entity-census-v1', + itemId: 'Issue-UUID', + }), + ]); + expect(mocks.listIssues).toHaveBeenCalledWith({ + first: 25, + after: undefined, + updatedAfter: '2026-07-21T12:00:00.000Z', + updatedBefore: '2026-08-20T11:59:59.000Z', + }); + const cursor = JSON.parse(result.stateUpdates[0]!.cursor!); + expect(cursor).toEqual({ + after: 'next-page', + lowerBound: '2026-07-21T12:00:00.000Z', + upperBound: '2026-08-20T11:59:59.000Z', + }); + }); + + it('advances the watermark only when the frozen window is exhausted', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + const now = new Date('2026-08-20T12:00:00.000Z'); + + const result = await collectBrainLinearIssues({ now, limit: 100 }); + + expect(result.stateUpdates[0]).toEqual({ + collectorId: 'linear-issues:entity-census-v1:incremental', + watermark: new Date('2026-08-20T11:59:59.000Z'), + cursor: null, + }); + }); + + it('re-arms a completed census after one day', async () => { + mocks.syncState.set('linear-issues:entity-census-v1', { + backfillCompletedAt: new Date('2026-08-19T11:00:00.000Z'), + }); + mocks.listIssues.mockResolvedValue({ + issues: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + const result = await collectBrainLinearIssues({ + now: new Date('2026-08-20T12:00:00.000Z'), + limit: 100, + }); + + expect(result.stateUpdates).toContainEqual({ + collectorId: 'linear-issues:entity-census-v1', + cursor: null, + backfillCompletedAt: null, + }); + }); + + it('holds all progress when Linear fails', async () => { + mocks.listIssues.mockRejectedValue(new Error('Linear unavailable')); + + await expect( + collectBrainLinearIssues({ + now: new Date('2026-08-20T12:00:00.000Z'), + limit: 100, + }), + ).resolves.toEqual({ + pages: [], + nextSince: null, + stateUpdates: [], + itemUpdates: [], + }); + }); +}); + +describe('Linear issue census', () => { + it('keeps an issue updated during a creation-ordered census visible', async () => { + mocks.listIssues.mockResolvedValue({ + issues: [{ ...issue, updatedAt: '2026-08-21T12:00:00.000Z' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }); + + const result = await backfillBrainLinearIssuesStep({ + cursor: null, + limit: 100, + now: new Date('2026-08-20T12:00:00.000Z'), + }); + + expect(result.done).toBe(false); + expect(result.pages).toHaveLength(1); + expect(result.itemUpdates).toHaveLength(1); + expect(mocks.listIssues).toHaveBeenCalledWith({ + first: 50, + after: null, + orderBy: 'createdAt', + createdBefore: '2026-08-20T12:00:00.000Z', + }); + expect(JSON.parse(result.nextCursor!)).toEqual({ + phase: 'retire', + sweepStartedAt: '2026-08-20T12:00:00.000Z', + }); + }); + + it('retires only inventory unseen by a completed census', async () => { + mocks.staleItems = [ + { itemId: 'deleted-issue', slug: 'linear/org/issues/deleted-issue' }, + ]; + const cursor = JSON.stringify({ + phase: 'retire', + sweepStartedAt: '2026-08-20T12:00:00.000Z', + }); + + const retirement = await backfillBrainLinearIssuesStep({ + cursor, + limit: 100, + }); + expect(retirement).toMatchObject({ + pages: [], + done: false, + pageRetirements: [ + { + collectorId: 'linear-issues:entity-census-v1', + itemId: 'deleted-issue', + slug: 'linear/org/issues/deleted-issue', + }, + ], + }); + + mocks.staleItems = []; + await expect( + backfillBrainLinearIssuesStep({ cursor, limit: 100 }), + ).resolves.toMatchObject({ pages: [], done: true }); + }); +}); diff --git a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts index 84eee07b8..31ee27040 100644 --- a/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts +++ b/packages/sdk/src/server/lib/__tests__/brain-source-availability.test.ts @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({ findEnablement: vi.fn(), findSlackInstallation: vi.fn(), hasGithubSources: vi.fn(), + findLinearConnection: vi.fn(), resolveDiscordCredentials: vi.fn(), })); @@ -35,6 +36,12 @@ vi.mock('../brain-github', () => ({ hasBrainGithubSources: mocks.hasGithubSources, })); +vi.mock('../mcp/linear-connections', () => ({ + findLinearDeploymentMcpConnection: mocks.findLinearConnection, + getLinearDeploymentMetadata: (config: Record | null) => + typeof config?.linearOrganizationId === 'string' ? config : null, +})); + import type { BrainSourceRequirement } from '@roomote/types'; import { @@ -54,6 +61,7 @@ describe('resolveBrainSourceRequirements', () => { discord: true, granola: false, notion: true, + linear: true, rippling: false, slack: true, }; @@ -70,6 +78,7 @@ describe('resolveBrainSourceRequirements', () => { 'discord', 'granola', 'notion', + 'linear', 'rippling', 'slack', ]), @@ -135,6 +144,16 @@ describe('isBrainSourceAvailable', () => { await expect(isBrainSourceAvailable('github')).resolves.toBe(true); }); + it('requires an authenticated Linear workspace connection with organization metadata', async () => { + mocks.findLinearConnection.mockResolvedValue({ + authConfig: { linearOrganizationId: 'org-1' }, + }); + await expect(isBrainSourceAvailable('linear')).resolves.toBe(true); + + mocks.findLinearConnection.mockResolvedValue({ authConfig: {} }); + await expect(isBrainSourceAvailable('linear')).resolves.toBe(false); + }); + it('requires Discord credentials and an active guild installation', async () => { mocks.resolveDiscordCredentials.mockResolvedValue({ botToken: 'token' }); mocks.findDiscordInstallation.mockResolvedValue({ id: 'installation-id' }); diff --git a/packages/sdk/src/server/lib/brain-linear.ts b/packages/sdk/src/server/lib/brain-linear.ts new file mode 100644 index 000000000..d6caf97ff --- /dev/null +++ b/packages/sdk/src/server/lib/brain-linear.ts @@ -0,0 +1,422 @@ +import { + db, + getBrainSyncState, + listBrainCollectorItemsBefore, +} from '@roomote/db/server'; +import { + createLinearClient, + type LinearBrainIssue, + type LinearBrainIssuePage, +} from '@roomote/linear'; +import { + BRAIN_COLLECTOR_IDS, + BRAIN_PAGE_TYPES, + brainNamespacePrefix, + renderBrainFrontmatter, +} from '@roomote/types'; + +import { getValidAccessToken } from './mcp/data'; +import { + findLinearDeploymentMcpConnection, + getLinearDeploymentMetadata, +} from './mcp/linear-connections'; + +const LINEAR_MCP_URL = 'https://mcp.linear.app/mcp'; +const ISSUE_BODY_CHAR_CAP = 8_000; +const COMMENT_BODY_CHAR_CAP = 800; +const BACKFILL_PAGE_SIZE = 50; +const RETIREMENT_BATCH_SIZE = 100; +const INITIAL_INCREMENTAL_WINDOW_MS = 30 * 24 * 60 * 60 * 1_000; +const REPLAY_OVERLAP_MS = 1_000; +const CENSUS_INTERVAL_MS = 24 * 60 * 60 * 1_000; +const INCREMENTAL_STATE_ID = `${BRAIN_COLLECTOR_IDS.linearIssues}:incremental`; + +export type BrainLinearPage = { + slug: string; + title: string; + content: string; +}; + +type CollectorStateUpdate = { + collectorId: string; + watermark?: Date; + cursor?: string | null; + backfillCompletedAt?: Date | null; +}; + +type CollectorItemUpdate = { + collectorId: string; + itemId: string; + slug: string; + lastSeenAt: Date; +}; + +type CollectorPageRetirement = { + collectorId: string; + itemId: string; + slug: string; +}; + +type IncrementalCursor = { + after: string; + lowerBound: string; + upperBound: string; +}; + +type BackfillCursor = + | { phase: 'issues'; after: string | null; sweepStartedAt: string } + | { phase: 'retire'; sweepStartedAt: string }; + +type LinearSourceContext = { + organizationId: string; + organizationName: string | null; + listIssues(input: { + first: number; + after?: string | null; + orderBy?: 'createdAt' | 'updatedAt'; + createdBefore?: string | null; + updatedAfter?: string | null; + updatedBefore?: string | null; + }): Promise; +}; + +async function getLinearSourceContext(): Promise { + const connection = await findLinearDeploymentMcpConnection(); + const metadata = getLinearDeploymentMetadata(connection?.authConfig); + if (!connection || !metadata) { + return null; + } + + const accessToken = await getValidAccessToken(connection.id, LINEAR_MCP_URL); + if (!accessToken) { + return null; + } + + const client = createLinearClient(accessToken); + return { + organizationId: metadata.linearOrganizationId, + organizationName: metadata.linearOrganizationName, + listIssues: (input) => client.listIssuesForBrain(input), + }; +} + +function parseIncrementalCursor( + value: string | null, +): IncrementalCursor | null { + if (!value) return null; + + try { + const parsed = JSON.parse(value) as Partial; + if ( + typeof parsed.after !== 'string' || + typeof parsed.lowerBound !== 'string' || + typeof parsed.upperBound !== 'string' || + Number.isNaN(new Date(parsed.lowerBound).getTime()) || + Number.isNaN(new Date(parsed.upperBound).getTime()) + ) { + return null; + } + return parsed as IncrementalCursor; + } catch { + return null; + } +} + +function parseBackfillCursor(value: string | null, now: Date): BackfillCursor { + if (value) { + try { + const parsed = JSON.parse(value) as { + phase?: unknown; + after?: unknown; + sweepStartedAt?: unknown; + }; + if ( + (parsed.phase === 'issues' || parsed.phase === 'retire') && + typeof parsed.sweepStartedAt === 'string' && + !Number.isNaN(new Date(parsed.sweepStartedAt).getTime()) + ) { + return parsed.phase === 'retire' + ? { phase: 'retire', sweepStartedAt: parsed.sweepStartedAt } + : { + phase: 'issues', + after: typeof parsed.after === 'string' ? parsed.after : null, + sweepStartedAt: parsed.sweepStartedAt, + }; + } + } catch { + // Idempotent page writes make restarting a malformed census safe. + } + } + + return { + phase: 'issues', + after: null, + sweepStartedAt: now.toISOString(), + }; +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function issueEventDate(issue: LinearBrainIssue): string { + return (issue.completedAt ?? issue.canceledAt ?? issue.createdAt).slice( + 0, + 10, + ); +} + +export function buildLinearIssuePage(input: { + organizationId: string; + organizationName: string | null; + issue: LinearBrainIssue; +}): BrainLinearPage | null { + const { issue } = input; + if (!issue.id || !issue.identifier || !issue.title) { + return null; + } + + const title = `${issue.identifier}: ${issue.title}`; + const eventDate = issueEventDate(issue); + const discussion = issue.comments.flatMap((comment) => { + const body = comment.body.trim(); + return body + ? [ + `**${comment.author ?? 'unknown'}** (${comment.createdAt}):`, + body.slice(0, COMMENT_BODY_CHAR_CAP), + '', + ] + : []; + }); + const description = issue.description?.trim() ?? ''; + const content = [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.linearIssue, + title, + created: issue.createdAt, + fields: [ + `event_date: ${eventDate}`, + `linear_issue_id: ${yamlString(issue.id)}`, + `identifier: ${yamlString(issue.identifier)}`, + `organization_id: ${yamlString(input.organizationId)}`, + input.organizationName && + `organization: ${yamlString(input.organizationName)}`, + issue.team && `team: ${yamlString(issue.team.name)}`, + issue.project && `project: ${yamlString(issue.project.name)}`, + issue.state && `state: ${yamlString(issue.state.name)}`, + issue.priorityLabel && `priority: ${yamlString(issue.priorityLabel)}`, + issue.creator && `creator: ${yamlString(issue.creator.name)}`, + issue.assignee && `assignee: ${yamlString(issue.assignee.name)}`, + issue.labels.length > 0 && + `labels: ${yamlString(issue.labels.join(', '))}`, + issue.dueDate && `due_date: ${issue.dueDate}`, + issue.completedAt && `completed_at: ${issue.completedAt}`, + issue.canceledAt && `canceled_at: ${issue.canceledAt}`, + issue.archivedAt && `archived_at: ${issue.archivedAt}`, + `updated_at: ${issue.updatedAt}`, + 'provenance: roomote-linear-issues', + ], + }), + '', + `# ${title}`, + '', + ...(description ? [description.slice(0, ISSUE_BODY_CHAR_CAP), ''] : []), + ...(discussion.length > 0 ? ['## Discussion', '', ...discussion] : []), + issue.url, + ].join('\n'); + + return { + slug: `${brainNamespacePrefix('linear')}${input.organizationId.toLowerCase()}/issues/${issue.id.toLowerCase()}`, + title, + content, + }; +} + +function pagesAndItems(input: { + source: LinearSourceContext; + issues: LinearBrainIssue[]; + seenAt: Date; +}): { pages: BrainLinearPage[]; itemUpdates: CollectorItemUpdate[] } { + const pages: BrainLinearPage[] = []; + const itemUpdates: CollectorItemUpdate[] = []; + + for (const issue of input.issues) { + const page = buildLinearIssuePage({ + organizationId: input.source.organizationId, + organizationName: input.source.organizationName, + issue, + }); + if (!page) continue; + + pages.push(page); + itemUpdates.push({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + itemId: issue.id, + slug: page.slug, + lastSeenAt: input.seenAt, + }); + } + + return { pages, itemUpdates }; +} + +export async function collectBrainLinearIssues(input: { + now: Date; + limit: number; +}): Promise<{ + pages: BrainLinearPage[]; + nextSince: null; + stateUpdates: CollectorStateUpdate[]; + itemUpdates: CollectorItemUpdate[]; +}> { + try { + const source = await getLinearSourceContext(); + if (!source) { + return { pages: [], nextSince: null, stateUpdates: [], itemUpdates: [] }; + } + + const [incrementalState, backfillState] = await Promise.all([ + getBrainSyncState(db, INCREMENTAL_STATE_ID), + getBrainSyncState(db, BRAIN_COLLECTOR_IDS.linearIssues), + ]); + const cursor = parseIncrementalCursor( + incrementalState?.backfillCursor ?? null, + ); + const lowerBound = + cursor?.lowerBound ?? + ( + incrementalState?.watermark ?? + new Date(input.now.getTime() - INITIAL_INCREMENTAL_WINDOW_MS) + ).toISOString(); + const upperBound = + cursor?.upperBound ?? + new Date(input.now.getTime() - REPLAY_OVERLAP_MS).toISOString(); + const result = await source.listIssues({ + first: input.limit, + after: cursor?.after, + updatedAfter: lowerBound, + updatedBefore: upperBound, + }); + const { pages, itemUpdates } = pagesAndItems({ + source, + issues: result.issues, + seenAt: input.now, + }); + const stateUpdates: CollectorStateUpdate[] = []; + + if (result.pageInfo.hasNextPage && result.pageInfo.endCursor) { + stateUpdates.push({ + collectorId: INCREMENTAL_STATE_ID, + cursor: JSON.stringify({ + after: result.pageInfo.endCursor, + lowerBound, + upperBound, + } satisfies IncrementalCursor), + }); + } else { + stateUpdates.push({ + collectorId: INCREMENTAL_STATE_ID, + watermark: new Date(upperBound), + cursor: null, + }); + } + + if ( + backfillState?.backfillCompletedAt && + input.now.getTime() - backfillState.backfillCompletedAt.getTime() >= + CENSUS_INTERVAL_MS + ) { + stateUpdates.push({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + cursor: null, + backfillCompletedAt: null, + }); + } + + return { pages, nextSince: null, stateUpdates, itemUpdates }; + } catch (error) { + console.warn( + `[brainLinear] issue sync failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return { pages: [], nextSince: null, stateUpdates: [], itemUpdates: [] }; + } +} + +export async function backfillBrainLinearIssuesStep(input: { + cursor: string | null; + limit: number; + now?: Date; +}): Promise<{ + pages: BrainLinearPage[]; + nextCursor: string | null; + done: boolean; + itemUpdates?: CollectorItemUpdate[]; + pageRetirements?: CollectorPageRetirement[]; +}> { + try { + const source = await getLinearSourceContext(); + if (!source) { + return { pages: [], nextCursor: input.cursor, done: false }; + } + + const cursor = parseBackfillCursor(input.cursor, input.now ?? new Date()); + const sweepStartedAt = new Date(cursor.sweepStartedAt); + + if (cursor.phase === 'retire') { + const stale = await listBrainCollectorItemsBefore( + db, + BRAIN_COLLECTOR_IDS.linearIssues, + sweepStartedAt, + Math.min(input.limit, RETIREMENT_BATCH_SIZE), + ); + if (stale.length === 0) { + return { pages: [], nextCursor: input.cursor, done: true }; + } + + return { + pages: [], + nextCursor: input.cursor, + done: false, + pageRetirements: stale.map((item) => ({ + collectorId: BRAIN_COLLECTOR_IDS.linearIssues, + itemId: item.itemId, + slug: item.slug, + })), + }; + } + + const result = await source.listIssues({ + first: Math.min(input.limit, BACKFILL_PAGE_SIZE), + after: cursor.after, + orderBy: 'createdAt', + createdBefore: cursor.sweepStartedAt, + }); + const { pages, itemUpdates } = pagesAndItems({ + source, + issues: result.issues, + seenAt: sweepStartedAt, + }); + + return { + pages, + itemUpdates, + done: false, + nextCursor: + result.pageInfo.hasNextPage && result.pageInfo.endCursor + ? JSON.stringify({ + phase: 'issues', + after: result.pageInfo.endCursor, + sweepStartedAt: cursor.sweepStartedAt, + } satisfies BackfillCursor) + : JSON.stringify({ + phase: 'retire', + sweepStartedAt: cursor.sweepStartedAt, + } satisfies BackfillCursor), + }; + } catch (error) { + console.warn( + `[brainLinear] issue backfill failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return { pages: [], nextCursor: input.cursor, done: false }; + } +} diff --git a/packages/sdk/src/server/lib/brain-source-availability.ts b/packages/sdk/src/server/lib/brain-source-availability.ts index 4c9b7d681..6a3a25d6d 100644 --- a/packages/sdk/src/server/lib/brain-source-availability.ts +++ b/packages/sdk/src/server/lib/brain-source-availability.ts @@ -22,6 +22,10 @@ import { } from '@roomote/types'; import { hasBrainGithubSources } from './brain-github'; +import { + findLinearDeploymentMcpConnection, + getLinearDeploymentMetadata, +} from './mcp/linear-connections'; type BrainMcpSourceId = 'granola' | 'notion' | 'rippling'; type BrainMcpSourceConfig = @@ -112,6 +116,12 @@ const BRAIN_SOURCE_AVAILABILITY = { granola: async () => Boolean(await findBrainSourceConnectionConfig('granola')), github: hasBrainGithubSources, + linear: async () => { + const connection = await findLinearDeploymentMcpConnection(); + return Boolean( + connection && getLinearDeploymentMetadata(connection.authConfig), + ); + }, rippling: async () => Boolean(await findBrainSourceConnectionConfig('rippling')), } satisfies Record Promise>; diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 1d3be5a23..29116f2e5 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -32,6 +32,9 @@ describe('resolveBrainNamespaceId', () => { ); expect(resolveBrainNamespaceId('people/roomote-member-abc')).toBe('people'); expect(resolveBrainNamespaceId('daily/digests/2026-01-02')).toBe('daily'); + expect(resolveBrainNamespaceId('linear/org/issues/issue-id')).toBe( + 'linear', + ); expect(resolveBrainNamespaceId('discord/123/456/2026-01-02/000')).toBe( 'discord', ); @@ -59,6 +62,9 @@ describe('resolveBrainSourceIdForCollector', () => { expect( resolveBrainSourceIdForCollector('github-issues:occurrence-date-v3'), ).toBe('github-issues'); + expect( + resolveBrainSourceIdForCollector('linear-issues:entity-census-v1'), + ).toBe('linear-issues'); }); it('folds a fanned-out collector’s per-partition rows into one source', () => { diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index f7279e9b1..ab7ef916e 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -31,6 +31,7 @@ export const BRAIN_NAMESPACES = [ { id: 'memories', prefix: 'memories/', label: 'Conversation memories' }, { id: 'prs', prefix: 'prs/', label: 'Pull requests' }, { id: 'github', prefix: 'github/', label: 'GitHub issues' }, + { id: 'linear', prefix: 'linear/', label: 'Linear issues' }, { id: 'slack', prefix: 'slack/', label: 'Slack' }, { id: 'discord', prefix: 'discord/', label: 'Discord' }, { id: 'notion', prefix: 'notion/', label: 'Notion' }, @@ -121,6 +122,7 @@ export const BRAIN_COLLECTOR_IDS = { slackPublicChannels: 'slack-public-channels:entity-timeline-v3', discordPublicChannels: 'discord-public-channels:entity-timeline-v1', githubIssues: 'github-issues:occurrence-date-v3', + linearIssues: 'linear-issues:entity-census-v1', notionPages: 'notion-pages', granolaMeetings: 'granola-meetings:entity-timeline-v3', } as const; @@ -137,6 +139,7 @@ export const BRAIN_PAGE_TYPES = { conversationMemory: 'conversation-memory', pullRequest: 'pull-request', githubIssue: 'github-issue', + linearIssue: 'linear-issue', slackDay: 'slack', discordDay: 'discord', meeting: 'meeting', @@ -273,6 +276,16 @@ export const BRAIN_SOURCES = [ collectorIds: [BRAIN_COLLECTOR_IDS.githubIssues] as readonly string[], requires: 'github', }, + { + id: 'linear-issues', + label: 'Linear issues', + description: + 'Issues and bounded discussion from the connected Linear workspace, refreshed as they change upstream.', + namespaceId: 'linear', + collectorIdPrefix: 'linear-issues', + collectorIds: [BRAIN_COLLECTOR_IDS.linearIssues] as readonly string[], + requires: 'linear', + }, { id: 'notion-pages', label: 'Notion', @@ -380,7 +393,7 @@ export function parseBrainBackfillCompletedCount( * chosen from gbrain's own description, which is written for a different * product and routes to tools this deployment does not expose. */ -export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack and Discord channels, meeting notes, GitHub issues), each stored as a page with citations. +export const BRAIN_MCP_READ_INSTRUCTIONS = `The \`gbrain\` server is this deployment's shared memory (the Brain). It holds memories distilled from completed tasks plus activity from connected integrations (pull requests, Slack and Discord channels, meeting notes, GitHub issues, Linear issues), each stored as a page with citations. ## Using what it knows @@ -394,7 +407,7 @@ Which tool: - \`query\` when you are describing a concept and do not know how the Brain words it. It expands your phrasing into related queries, so it finds pages that talk about the same thing in different language. This is the default, and the right choice for that first pass. - \`search\` when you already know the exact token: a slug, a repository name, an error string, a person's handle. Cheaper than \`query\` because it skips the expansion step. - \`entity\` for one known person. It resolves names and linked provider handles against canonical deployment-member cards without an LLM call. -- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`discord/\`, \`notion/\`, \`meetings/\`, \`github/\`. +- \`list_pages\` to enumerate rather than guess, and to answer "what is in the Brain" or "what happened recently" (it sorts by recency). Use it before ever concluding the Brain is empty. Pages are namespaced: \`people/\`, \`tasks/\`, \`prs/\`, \`slack/\`, \`discord/\`, \`notion/\`, \`meetings/\`, \`github/\`, \`linear/\`. - \`get_page\` on a slug for a page's full text, once a search result looks relevant. A result set that comes back populated is not proof of coverage, and one query returning nothing is not proof of absence. If the answer matters, try the other phrasing or list the namespace before deciding the Brain has nothing. From 3dfd694260a70fce63b032fa1e086778b9798c77 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:10:23 +0000 Subject: [PATCH 011/158] [Fix] PR remains blocked after Roomote findings are resolved (#1512) * fix: clear stale PR change requests * fix: validate reviews before dismissal * fix: forward review ids from MCP tool --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> --- .../src/handlers/tasks/manageSourceControl.ts | 1 + .../__tests__/source-control.test.ts | 57 +++++ .../__tests__/tool-descriptions.test.ts | 51 +++++ .../src/mcp/roomote-mcp-server/index.ts | 16 +- .../mcp/roomote-mcp-server/source-control.ts | 15 ++ .../roomote-mcp-server/tasks-api-client.ts | 2 + .../__tests__/githubPrReviewSkill.test.ts | 24 ++- .../skills/standard/review-code/SKILL.md | 11 +- .../source-control-pull-request-reads.test.ts | 51 ++++- ...source-control-pull-request-writes.test.ts | 197 ++++++++++++++++++ .../source-control-pull-request-reads.ts | 130 ++++++++---- .../source-control-pull-request-writes.ts | 91 +++++++- 12 files changed, 586 insertions(+), 60 deletions(-) diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index f1785241a..78a6d9786 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -182,6 +182,7 @@ export async function manageSourceControl( case 'create_pull_request_review_comment': case 'resolve_pull_request_thread': case 'submit_pull_request_review': + case 'dismiss_pull_request_review': case 'update_pull_request_comment': { const writeResult = await runWithQuoteRestorationOnFailure(() => writeSourceControlPullRequestForTaskRun({ diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/source-control.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/source-control.test.ts index a661757e4..59461e9d6 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/source-control.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/source-control.test.ts @@ -388,4 +388,61 @@ describe('handleManageSourceControl issue actions', () => { }); expect(tasksApiClient.writeSourceControl).not.toHaveBeenCalled(); }); + + it('requires and forwards a review id and reason for review dismissal', async () => { + vi.mocked(tasksApiClient.writeSourceControl).mockResolvedValueOnce({ + success: true, + action: 'dismiss_pull_request_review', + provider: 'github', + repositoryFullName: 'acme/web', + number: 12, + commentId: '900', + applied: true, + warnings: [], + } as never); + + const result = await handleManageSourceControl( + { + action: 'dismiss_pull_request_review', + repositoryFullName: 'acme/web', + prNumber: 12, + reviewId: ' 900 ', + body: 'Requested changes have been addressed.', + }, + config, + 'task-1', + ); + + expect(JSON.parse(result.content[0]?.text ?? '')).toMatchObject({ + success: true, + action: 'dismiss_pull_request_review', + }); + expect(tasksApiClient.writeSourceControl).toHaveBeenCalledWith( + config, + 'task-1', + expect.objectContaining({ + action: 'dismiss_pull_request_review', + reviewId: '900', + body: 'Requested changes have been addressed.', + }), + ); + + vi.clearAllMocks(); + const missingReviewId = await handleManageSourceControl( + { + action: 'dismiss_pull_request_review', + repositoryFullName: 'acme/web', + prNumber: 12, + body: 'Requested changes have been addressed.', + }, + config, + 'task-1', + ); + + expect(JSON.parse(missingReviewId.content[0]?.text ?? '')).toMatchObject({ + success: false, + error: 'reviewId is required for dismiss_pull_request_review', + }); + expect(tasksApiClient.writeSourceControl).not.toHaveBeenCalled(); + }); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 9a20ae3ce..8f43c1dac 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -1182,4 +1182,55 @@ describe('roomote MCP tool descriptions', () => { ], }); }); + + it('forwards reviewId from manage_source_control tool params', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + action: 'dismiss_pull_request_review', + provider: 'github', + repositoryFullName: 'RooCodeInc/Roomote', + number: 12, + commentId: '900', + applied: true, + warnings: [], + }), + }), + ); + + const { registeredTools } = await importRoomoteMcpServer({ + ROOMOTE_CLOUD_TOKEN: 'run-token', + ROOMOTE_PLATFORM_API_URL: 'https://platform.example.com', + ROOMOTE_TASK_ID: 'task_123', + }); + const sourceControlTool = getRegisteredTool( + registeredTools, + 'manage_source_control', + ); + + await sourceControlTool.handler?.({ + action: 'dismiss_pull_request_review', + repositoryFullName: 'RooCodeInc/Roomote', + prNumber: 12, + reviewId: '900', + body: 'Requested changes have been addressed.', + }); + + expect(fetch).toHaveBeenCalledWith( + 'https://platform.example.com/api/mcp/tasks/task_123/source_control', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + action: 'dismiss_pull_request_review', + repositoryFullName: 'RooCodeInc/Roomote', + prNumber: 12, + reviewId: '900', + body: 'Requested changes have been addressed.', + }), + }), + ); + }); }); diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 950e7bc89..1ebe3e746 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -764,10 +764,10 @@ roomoteMcpServer.registerTool( 'when an open PR/MR already exists for sourceBranch, targetBranch may be omitted and defaults to its current base. ' + 'Use action "get_pull_request" to read PR/MR details (state, branches, head/base SHAs), ' + '"list_pull_requests" to list open PRs/MRs in a repository (summaries with branches, labels, and mergeability where the provider exposes it), and ' + - '"list_pull_request_comments" to read review threads (with resolution state) and issue comments. ' + + '"list_pull_request_comments" to read review threads, top-level reviews, and issue comments. ' + 'Use "reply_to_pull_request_comment" to answer a review thread, "create_pull_request_comment" for a top-level comment, ' + '"create_pull_request_review_comment" for a new inline comment anchored to a file and line of the current diff, ' + - '"resolve_pull_request_thread" to resolve or reopen a thread, and "submit_pull_request_review" to approve, request changes, or leave a review comment. ' + + '"resolve_pull_request_thread" to resolve or reopen a thread, "submit_pull_request_review" to approve, request changes, or leave a review comment, and "dismiss_pull_request_review" to dismiss a GitHub review. ' + 'Provider gaps are reported as warnings with applied:false instead of errors. ' + 'For the PR diff, use local git against the returned SHAs instead of a provider CLI. ' + 'The platform resolves the current task source-control provider and keeps provider tokens server-side.', @@ -783,13 +783,14 @@ roomoteMcpServer.registerTool( 'create_pull_request_review_comment', 'resolve_pull_request_thread', 'submit_pull_request_review', + 'dismiss_pull_request_review', 'update_pull_request_comment', 'get_issue', 'list_issue_comments', 'create_issue_comment', ]) .describe( - 'get_issue reads a plain issue; list_issue_comments reads its comments; create_issue_comment posts a top-level issue comment. create_or_update_pull_request creates or refreshes the PR/MR for a branch; get_pull_request reads PR/MR details; list_pull_requests lists open PRs/MRs in the repository; list_pull_request_comments reads review threads and issue comments; reply_to_pull_request_comment answers a review thread; create_pull_request_comment posts a top-level PR comment; create_pull_request_review_comment posts one new inline review comment anchored to a file and line of the current diff (one finding per call); resolve_pull_request_thread resolves or reopens a thread; submit_pull_request_review approves, requests changes, or leaves a review comment; update_pull_request_comment edits an existing comment in place.', + 'get_issue reads a plain issue; list_issue_comments reads its comments; create_issue_comment posts a top-level issue comment. create_or_update_pull_request creates or refreshes the PR/MR for a branch; get_pull_request reads PR/MR details; list_pull_requests lists open PRs/MRs in the repository; list_pull_request_comments reads review threads, top-level reviews, and issue comments; reply_to_pull_request_comment answers a review thread; create_pull_request_comment posts a top-level PR comment; create_pull_request_review_comment posts one new inline review comment anchored to a file and line of the current diff (one finding per call); resolve_pull_request_thread resolves or reopens a thread; submit_pull_request_review approves, requests changes, or leaves a review comment; dismiss_pull_request_review dismisses a GitHub review; update_pull_request_comment edits an existing comment in place.', ), repositoryFullName: z .string() @@ -844,6 +845,12 @@ roomoteMcpServer.registerTool( .describe( 'Required for update_pull_request_comment: the comment id from list_pull_request_comments or a prior write result.', ), + reviewId: z + .string() + .optional() + .describe( + 'Required for dismiss_pull_request_review: the review id from list_pull_request_comments.', + ), resolved: z .boolean() .optional() @@ -914,7 +921,7 @@ roomoteMcpServer.registerTool( .string() .optional() .describe( - 'The text content: the PR/MR description for create_or_update_pull_request, the comment text for issue/PR reply or create actions, or the optional review body for submit_pull_request_review.', + 'The text content: the PR/MR description for create_or_update_pull_request, the comment text for issue/PR reply or create actions, the optional review body for submit_pull_request_review, or the required dismissal reason for dismiss_pull_request_review.', ), labels: z .array(z.string()) @@ -960,6 +967,7 @@ roomoteMcpServer.registerTool( limit: params.limit, threadId: params.threadId, commentId: params.commentId, + reviewId: params.reviewId, resolved: params.resolved, reviewEvent: params.reviewEvent, path: params.path, diff --git a/apps/worker/src/mcp/roomote-mcp-server/source-control.ts b/apps/worker/src/mcp/roomote-mcp-server/source-control.ts index 82bbf864e..3ab5d25e3 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/source-control.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/source-control.ts @@ -20,6 +20,7 @@ type ManageSourceControlParams = { | 'create_pull_request_review_comment' | 'resolve_pull_request_thread' | 'submit_pull_request_review' + | 'dismiss_pull_request_review' | 'update_pull_request_comment' | 'get_issue' | 'list_issue_comments' @@ -31,6 +32,7 @@ type ManageSourceControlParams = { limit?: number; threadId?: string; commentId?: string; + reviewId?: string; resolved?: boolean; reviewEvent?: 'approve' | 'request_changes' | 'comment'; path?: string; @@ -228,11 +230,23 @@ export async function handleManageSourceControl( ); } + if (params.action === 'dismiss_pull_request_review') { + if (!params.reviewId?.trim()) { + return errorResult( + 'reviewId is required for dismiss_pull_request_review', + ); + } + if (!params.body?.trim()) { + return errorResult('body is required for dismiss_pull_request_review'); + } + } + // Models often emit empty strings for unused optional fields. blank values // must not be forwarded as present ids (GitHub routes issue vs review // comment updates using the presence of threadId). const threadId = params.threadId?.trim() || undefined; const commentId = params.commentId?.trim() || undefined; + const reviewId = params.reviewId?.trim() || undefined; const path = params.path?.trim() || undefined; return jsonResult( @@ -242,6 +256,7 @@ export async function handleManageSourceControl( prNumber: params.prNumber, threadId, commentId, + reviewId, body: params.body, resolved: params.resolved, reviewEvent: params.reviewEvent, diff --git a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts index 324a1e7d4..bf86dd158 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/tasks-api-client.ts @@ -387,11 +387,13 @@ export async function writeSourceControl( | 'create_pull_request_review_comment' | 'resolve_pull_request_thread' | 'submit_pull_request_review' + | 'dismiss_pull_request_review' | 'update_pull_request_comment'; repositoryFullName: string; prNumber: number; threadId?: string; commentId?: string; + reviewId?: string; body?: string; resolved?: boolean; reviewEvent?: 'approve' | 'request_changes' | 'comment'; diff --git a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts index c80f68781..a6a281c51 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/githubPrReviewSkill.test.ts @@ -52,6 +52,18 @@ describe('review-code GitHub workflow paths', () => { ); }); + it('publishes findings as comments instead of change-request reviews', () => { + expect(skillContent).toContain( + 'Do not submit a `request_changes` review in any pull-request review path.', + ); + expect(skillContent).toContain( + 'Publish actionable findings as inline comments plus the canonical summary', + ); + expect(skillContent).toContain( + 'reserve `submit_pull_request_review` for `approve` only in the approval-enabled clean paths.', + ); + }); + it('keeps the consolidated GitHub review paths in review-code', () => { expect(skillContent).toContain( '', @@ -98,7 +110,7 @@ describe('review-code GitHub workflow paths', () => { ); } expect(skillContent).toContain( - 'When `existing_review_comments` or `issue_comments` are missing, or when current thread or top-level discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`.', + 'When `existing_review_comments` or `issue_comments` are missing, or when current thread, top-level review, or discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`.', ); expect(skillContent).not.toContain('gh pr view'); expect(skillContent).not.toContain('gh pr diff'); @@ -187,6 +199,16 @@ describe('review-code GitHub workflow paths', () => { expect(skillContent).toContain( 'On providers where approval maps to a vote or is not permitted for the token identity, the tool reports `applied: false` with warnings; report that gap honestly instead of claiming the pull request was approved.', ); + expect(skillContent).toContain( + 'top-level `reviews` with review ids and states when exposed', + ); + expect(skillContent).toContain( + 'dismiss each unique top-level review whose `state` is `CHANGES_REQUESTED` and whose author matches the normalized Roomote-managed login set', + ); + expect(skillContent).toContain( + '`action: "dismiss_pull_request_review"`, that review\'s `reviewId`, and body `Requested changes have been addressed.`', + ); + expect(skillContent).toContain("Never dismiss another reviewer's review."); expect(skillContent).toContain( 'Use when you need actionable pull-request review findings, live provider context discovery, and one canonical summary comment without approval.', ); diff --git a/packages/cloud-agents/src/server/workflows/skills/standard/review-code/SKILL.md b/packages/cloud-agents/src/server/workflows/skills/standard/review-code/SKILL.md index 3d757198b..99437cb65 100644 --- a/packages/cloud-agents/src/server/workflows/skills/standard/review-code/SKILL.md +++ b/packages/cloud-agents/src/server/workflows/skills/standard/review-code/SKILL.md @@ -153,6 +153,7 @@ After presenting the table, you are done. Do not skip the pull-request-specific fetch, comment, summary-update, or approval behavior once a pull-request review path is selected. Do not mix multiple review paths in one run. Do not ignore prompt-supplied task context when it already provides the needed snapshot or identifier. Revalidate mutable provider state before side effects when correctness depends on freshness. +Do not submit a `request_changes` review in any pull-request review path. Publish actionable findings as inline comments plus the canonical summary, and reserve `submit_pull_request_review` for `approve` only in the approval-enabled clean paths. Do not spawn the `judge` subagent or any other nested review-only subagent. Perform the review yourself and report findings directly. @@ -238,7 +239,7 @@ You are a pull request review workflow specialist. Review the assigned pull requ If prompt-supplied PR snapshots exist, start from them and skip redundant fetches. Use the Roomote MCP `manage_source_control` read actions only to fill missing context or to revalidate mutable provider state before posting comments, patching summary comments, or approving; do not use provider-specific CLIs such as `gh` for pull-request state. When `pull_request_details` or current head metadata is missing, or when it must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "get_pull_request"`, `repositoryFullName`, and `prNumber`. The result carries the title, body, state, draft flag, source and target branches, head and base SHAs, author, mergeability, and cross-repository (fork) information. When `pull_request_diff` is missing, or when the current diff must be revalidated before a side effect, compute it locally. For a same-repository PR, run `git fetch origin '' ''`. For a GitHub cross-repository PR, run `git fetch origin '' '+refs/pull/[PR_NUMBER]/head:refs/remotes/origin/pr-[PR_NUMBER]-head'`, then verify `git rev-parse refs/remotes/origin/pr-[PR_NUMBER]-head` exactly equals `` from `get_pull_request`. If it differs, call `get_pull_request` once more and proceed only when the fetched SHA matches the refreshed ``; otherwise report the blocker. For a cross-repository PR on another provider whose source branch cannot be fetched with task credentials, report that blocker instead of improvising credentials. Then run `git diff ...`. Use this local git diff for every provider instead of a provider CLI. - When `existing_review_comments` or `issue_comments` are missing, or when current thread or top-level discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors) plus top-level `issueComments`; heed any capability warnings it reports. + When `existing_review_comments` or `issue_comments` are missing, or when current thread, top-level review, or discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors), top-level `reviews` with review ids and states when exposed, and top-level `issueComments`; heed any capability warnings it reports. Before PR checkout or deep repository reading, if `TOP_LEVEL_COMMENT_ID` is already supplied or the available PR issue comments already reveal a reusable canonical summary comment, recover that reusable comment immediately and patch its hidden summary marker to `version=2 phase=reviewing` plus its status block in place (using `mcp__roomote__manage_source_control` `action: "update_pull_request_comment"` with that comment's `commentId`, plus its `threadId` when the provider returns one) to show a short in-progress line such as `Reviewing the PR now. {task_link_follow}`. Preserve the marker's current SHA, mode, and agent attributes at this early stage; rewrite only the content inside the hidden `` and `` markers when they exist, and otherwise normalize the comment into the hidden status/checklist block format before continuing. Carry the recovered comment ID forward as `TOP_LEVEL_COMMENT_ID` for the later canonical-summary step instead of leaving stale status text visible during startup latency. If `linked_issue` context is missing, use the linked-work-item context supplied by the current workflow instructions or referenced in the pull-request body when present; do not fetch issues through provider-specific CLIs. Check out a same-repository PR branch with `git fetch origin '' && git checkout ''`. For a GitHub cross-repository PR, fetch the upstream PR ref with `git fetch origin '+refs/pull/[PR_NUMBER]/head:refs/remotes/origin/pr-[PR_NUMBER]-head'`, verify its resolved SHA exactly equals `` from `get_pull_request`, and if it differs call `get_pull_request` once more and proceed only when the fetched SHA matches the refreshed ``; otherwise report the blocker. Then run `git checkout --detach `. For a cross-repository PR on another provider whose source branch cannot be fetched with task credentials, report that blocker instead of fetching the fork directly or improvising credentials. @@ -515,7 +516,7 @@ You are a pull request review workflow specialist. Review the assigned pull requ If prompt-supplied PR snapshots exist, start from them and skip redundant fetches. Use the Roomote MCP `manage_source_control` read actions only to fill missing context or to revalidate mutable provider state before posting comments, patching summary comments, or approving; do not use provider-specific CLIs such as `gh` for pull-request state. When `pull_request_details` or current head metadata is missing, or when it must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "get_pull_request"`, `repositoryFullName`, and `prNumber`. The result carries the title, body, state, draft flag, source and target branches, head and base SHAs, author, mergeability, and cross-repository (fork) information. When `pull_request_diff` is missing, or when the current diff must be revalidated before a side effect, compute it locally. For a same-repository PR, run `git fetch origin '' ''`. For a GitHub cross-repository PR, run `git fetch origin '' '+refs/pull/[PR_NUMBER]/head:refs/remotes/origin/pr-[PR_NUMBER]-head'`, then verify `git rev-parse refs/remotes/origin/pr-[PR_NUMBER]-head` exactly equals `` from `get_pull_request`. If it differs, call `get_pull_request` once more and proceed only when the fetched SHA matches the refreshed ``; otherwise report the blocker. For a cross-repository PR on another provider whose source branch cannot be fetched with task credentials, report that blocker instead of improvising credentials. Then run `git diff ...`. Use this local git diff for every provider instead of a provider CLI. - When `existing_review_comments` or `issue_comments` are missing, or when current thread or top-level discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors) plus top-level `issueComments`; heed any capability warnings it reports. + When `existing_review_comments` or `issue_comments` are missing, or when current thread, top-level review, or discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors), top-level `reviews` with review ids and states when exposed, and top-level `issueComments`; heed any capability warnings it reports. Before PR checkout or deep repository reading, if `TOP_LEVEL_COMMENT_ID` is already supplied or the available PR issue comments already reveal a reusable canonical summary comment, recover that reusable comment immediately and patch its hidden summary marker to `version=2 phase=reviewing` plus its status block in place (using `mcp__roomote__manage_source_control` `action: "update_pull_request_comment"` with that comment's `commentId`, plus its `threadId` when the provider returns one) to show a short in-progress line such as `Reviewing the PR now. {task_link_follow}`. Preserve the marker's current SHA, mode, and agent attributes at this early stage; rewrite only the content inside the hidden `` and `` markers when they exist, and otherwise normalize the comment into the hidden status/checklist block format before continuing. Carry the recovered comment ID forward as `TOP_LEVEL_COMMENT_ID` for the later canonical-summary step instead of leaving stale status text visible during startup latency. If `linked_issue` context is missing, use the linked-work-item context supplied by the current workflow instructions or referenced in the pull-request body when present; do not fetch issues through provider-specific CLIs. Check out a same-repository PR branch with `git fetch origin '' && git checkout ''`. For a GitHub cross-repository PR, fetch the upstream PR ref with `git fetch origin '+refs/pull/[PR_NUMBER]/head:refs/remotes/origin/pr-[PR_NUMBER]-head'`, verify its resolved SHA exactly equals `` from `get_pull_request`, and if it differs call `get_pull_request` once more and proceed only when the fetched SHA matches the refreshed ``; otherwise report the blocker. Then run `git checkout --detach `. For a cross-repository PR on another provider whose source branch cannot be fetched with task credentials, report that blocker instead of fetching the fork directly or improvising credentials. @@ -841,7 +842,7 @@ You are a sync-review workflow specialist. Re-review pull requests after new com If you are not in `legacy_full_rereview_path`, first decide whether there is any new delta at all with a two-dot diff `git diff [last_review_sha]..[HEAD_SHA]` and `git log --oneline [last_review_sha]..[HEAD_SHA]`. Two-dot (`..`) is the actual content difference between the two reviewed commits. If it is empty — for example the head SHA changed only because the branch was rebased, with no new content — treat it the same as the head-SHA-match case: update the summary comment with a short no-op note, mark the terminal outcome `no_new_delta`, and continue to the linked-task handoff step instead of re-reviewing. When there is a delta, the authoritative set of changes you may review is the PR's current Files Changed — its base-to-head diff, `git diff ...` (three-dot from the current base), scoped to the files in `pull_request_changed_files`/`changed_files_since_last_review` and the supplied `diff_in_range` when present. Report findings only for hunks that appear in that current PR diff. A change that is not in the PR's base-to-head diff — including a base-branch modification to a file the PR also touches — is out of scope: it belongs to the base branch, not this PR, and must not be reported or carried forward. Use the two-dot delta and commit log only to focus on what is new since the last review, never as the review scope itself. If you are in `legacy_full_rereview_path`, re-review the full current PR diff with a local base-to-head comparison: `git fetch origin ''`, then `git diff ...` using the SHAs from `get_pull_request`. Use this local git diff for every provider. - When `existing_review_comments` or `issue_comments` are missing, or when current thread or top-level discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors) plus top-level `issueComments`; heed any capability warnings it reports. + When `existing_review_comments` or `issue_comments` are missing, or when current thread, top-level review, or discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors), top-level `reviews` with review ids and states when exposed, and top-level `issueComments`; heed any capability warnings it reports. Read the changed files in the delta and any related repository files needed to verify correctness in context. The current head SHA, commit range, diff range, and existing review discussion are all available for delta-aware re-review. @@ -907,6 +908,7 @@ You are a sync-review workflow specialist. Re-review pull requests after new com Treat only unchecked markdown checklist items (`- [ ]`) as unresolved actionable inventory. Keep genuinely fixed items checked (`- [x]`). When a carried-forward item is dismissed as invalid, stale, or out of scope, convert that line from unresolved checklist form into a struck-through plain markdown bullet like `- ~~Short finding text~~ — dismissed: brief factual reason.` and leave it out of later actionable inventories. If surviving or net-new code issues remain, use one short status line inside the hidden status block that summarizes the remaining work, such as `1 issue outstanding.` or `3 issues outstanding.` If `task_link_see` is available, keep it inline on that line. Patch the canonical comment in place with `mcp__roomote__manage_source_control` `action: "update_pull_request_comment"`, `commentId` set to `TOP_LEVEL_COMMENT_ID`, and the full refreshed body, passing the recorded `threadId` alongside `commentId` when the provider returned one (always include it on Azure DevOps). + If no surviving or net-new actionable issue remains, dismiss each unique top-level review whose `state` is `CHANGES_REQUESTED` and whose author matches the normalized Roomote-managed login set by calling `mcp__roomote__manage_source_control` with `action: "dismiss_pull_request_review"`, that review's `reviewId`, and body `Requested changes have been addressed.` Never dismiss another reviewer's review. On providers without review dismissal, treat `applied: false` as a non-blocking capability gap and report it honestly. The canonical summary comment accurately reflects the current sync-review state and embeds the new head SHA for future sync discovery. @@ -1155,7 +1157,7 @@ You are a sync-review workflow specialist. Re-review pull requests after new com If you are not in `legacy_full_rereview_path`, first decide whether there is any new delta at all with a two-dot diff `git diff [last_review_sha]..[HEAD_SHA]` and `git log --oneline [last_review_sha]..[HEAD_SHA]`. Two-dot (`..`) is the actual content difference between the two reviewed commits. If it is empty — for example the head SHA changed only because the branch was rebased, with no new content — treat it the same as the head-SHA-match case: update the summary comment with a short no-op note, mark the terminal outcome `no_new_delta`, and continue to the linked-task handoff step instead of re-reviewing. When there is a delta, the authoritative set of changes you may review is the PR's current Files Changed — its base-to-head diff, `git diff ...` (three-dot from the current base), scoped to the files in `pull_request_changed_files`/`changed_files_since_last_review` and the supplied `diff_in_range` when present. Report findings only for hunks that appear in that current PR diff. A change that is not in the PR's base-to-head diff — including a base-branch modification to a file the PR also touches — is out of scope: it belongs to the base branch, not this PR, and must not be reported or carried forward. Use the two-dot delta and commit log only to focus on what is new since the last review, never as the review scope itself. If you are in `legacy_full_rereview_path`, re-review the full current PR diff with a local base-to-head comparison: `git fetch origin ''`, then `git diff ...` using the SHAs from `get_pull_request`. Use this local git diff for every provider. - When `existing_review_comments` or `issue_comments` are missing, or when current thread or top-level discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors) plus top-level `issueComments`; heed any capability warnings it reports. + When `existing_review_comments` or `issue_comments` are missing, or when current thread, top-level review, or discussion state must be revalidated before a side effect, call `mcp__roomote__manage_source_control` with `action: "list_pull_request_comments"`. The result returns review threads (each with a `threadId`, `resolved` state when the provider exposes it, and inline path/line anchors), top-level `reviews` with review ids and states when exposed, and top-level `issueComments`; heed any capability warnings it reports. Read the changed files in the delta and any related repository files needed to verify correctness in context. The current head SHA, commit range, diff range, and existing review discussion are all available for delta-aware re-review. @@ -1230,6 +1232,7 @@ You are a sync-review workflow specialist. Re-review pull requests after new com Never leave comments or submit a non-approval review from this step. If any surviving or net-new actionable issue remains, take no approval action. + If no surviving or net-new actionable issue remains, dismiss each unique top-level review whose `state` is `CHANGES_REQUESTED` and whose author matches the normalized Roomote-managed login set by calling `mcp__roomote__manage_source_control` with `action: "dismiss_pull_request_review"`, that review's `reviewId`, and body `Requested changes have been addressed.` Never dismiss another reviewer's review. On providers without review dismissal, treat `applied: false` as a non-blocking capability gap and report it honestly. Before approval, normalize the PR author login using the same `isRoomoteGitHubLogin()` rules defined in `packages/github/src/schema.ts` rather than checking only one literal bot login. Treat the configured GitHub App slug and any explicitly configured additional trusted app slugs in `[bot]` or `app/...` form as the only Roomote-managed logins ineligible for approval. If the pull request author matches any of those normalized Roomote-managed logins, take no approval action. 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 e5644d41b..7211d9166 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 @@ -172,6 +172,14 @@ describe('readSourceControlPullRequestForTaskRun', () => { status: 200, }) .mockRejectedValueOnce(notModified()); + const listReviews = vi + .fn() + .mockResolvedValueOnce({ + data: [], + headers: { etag: '"pull-reviews-v1"' }, + status: 200, + }) + .mockRejectedValueOnce(notModified()); const listComments = vi .fn() .mockResolvedValueOnce({ @@ -219,7 +227,7 @@ describe('readSourceControlPullRequestForTaskRun', () => { graphql, paginate: vi.fn(), rest: { - pulls: { listReviewComments }, + pulls: { listReviewComments, listReviews }, issues: { listComments }, }, }); @@ -251,6 +259,12 @@ describe('readSourceControlPullRequestForTaskRun', () => { request: { headers: { 'if-none-match': '"reviews-v1"' } }, }), ); + expect(listReviews).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + request: { headers: { 'if-none-match': '"pull-reviews-v1"' } }, + }), + ); expect(listComments).toHaveBeenNthCalledWith( 3, expect.objectContaining({ @@ -543,12 +557,29 @@ describe('readSourceControlPullRequestForTaskRun', () => { }, }, }); + const listReviewComments = vi.fn(); + const listComments = vi.fn(); + const listReviews = vi.fn(); mockGetOctokit.mockReturnValue({ graphql, - paginate: vi.fn().mockResolvedValue([]), + paginate: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + id: 900, + user: { login: 'roomote-dev[bot]' }, + state: 'CHANGES_REQUESTED', + body: 'Please address the findings.', + submitted_at: '2026-08-01T00:00:00Z', + html_url: + 'https://github.com/acme/backend/pull/55#pullrequestreview-900', + }, + ]), rest: { - pulls: { listReviewComments: vi.fn() }, - issues: { listComments: vi.fn() }, + pulls: { listReviewComments, listReviews }, + issues: { listComments }, }, }); @@ -583,6 +614,16 @@ describe('readSourceControlPullRequestForTaskRun', () => { outdated: true, }), ]); + expect(result.reviews).toEqual([ + { + reviewId: '900', + author: 'roomote-dev[bot]', + state: 'CHANGES_REQUESTED', + body: 'Please address the findings.', + submittedAt: '2026-08-01T00:00:00Z', + url: 'https://github.com/acme/backend/pull/55#pullrequestreview-900', + }, + ]); }); it('propagates GitHub rate limits from review-thread pagination', async () => { @@ -756,7 +797,7 @@ describe('readSourceControlPullRequestForTaskRun', () => { graphql, paginate: vi.fn().mockResolvedValue([]), rest: { - pulls: { listReviewComments: vi.fn() }, + pulls: { listReviewComments: vi.fn(), listReviews: vi.fn() }, issues: { listComments: vi.fn() }, }, }); 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 d50c687d6..b9b82dc59 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 @@ -35,6 +35,10 @@ vi.mock('@roomote/auth', () => ({ vi.mock('@roomote/github', () => ({ getOctokit: (...args: unknown[]) => mockGetOctokit(...args), + Schemas: { + isManagedRoomoteGitHubLogin: (login: string) => + login.toLowerCase() === 'roomote[bot]', + }, })); vi.mock('@roomote/gitlab', () => ({ @@ -435,6 +439,199 @@ describe('writeSourceControlPullRequestForTaskRun', () => { }); }); + it('maps an explicit GitHub change-request review to REQUEST_CHANGES', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 'installation-1', + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://github.com/acme/backend', + }); + mockCreateGitHubToken.mockResolvedValue('github-token'); + const createReview = vi.fn().mockResolvedValue({ + data: { + id: 900, + html_url: + 'https://github.com/acme/backend/pull/55#pullrequestreview-900', + }, + }); + mockGetOctokit.mockReturnValue({ + rest: { pulls: { createReview } }, + }); + + await writeSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'github', + }), + input: { + action: 'submit_pull_request_review', + repositoryFullName: 'acme/backend', + prNumber: 55, + reviewEvent: 'request_changes', + body: 'Please address the findings.', + sourceControlProvider: 'github', + }, + }); + + expect(createReview).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'backend', + pull_number: 55, + event: 'REQUEST_CHANGES', + body: 'Please address the findings.', + }); + }); + + it('dismisses a GitHub change-request review through the installation token', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 'installation-1', + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://github.com/acme/backend', + }); + mockCreateGitHubToken.mockResolvedValue('github-token'); + const getReview = vi.fn().mockResolvedValue({ + data: { + id: 901, + state: 'CHANGES_REQUESTED', + user: { login: 'ROOMOTE[BOT]' }, + }, + }); + const dismissReview = vi.fn().mockResolvedValue({ + data: { + id: 901, + html_url: + 'https://github.com/acme/backend/pull/55#pullrequestreview-901', + }, + }); + mockGetOctokit.mockReturnValue({ + rest: { pulls: { dismissReview, getReview } }, + }); + + const result = await writeSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'github', + }), + input: { + action: 'dismiss_pull_request_review', + repositoryFullName: 'acme/backend', + prNumber: 55, + reviewId: '901', + body: 'Requested changes have been addressed.', + sourceControlProvider: 'github', + }, + }); + + expect(getReview).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'backend', + pull_number: 55, + review_id: 901, + }); + expect(dismissReview).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'backend', + pull_number: 55, + review_id: 901, + message: 'Requested changes have been addressed.', + }); + expect(result).toMatchObject({ + success: true, + action: 'dismiss_pull_request_review', + provider: 'github', + number: 55, + commentId: '901', + applied: true, + warnings: [], + }); + }); + + it.each(['alice', 'dependabot[bot]'])( + 'rejects dismissal of another author review from %s', + async (authorLogin) => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 'installation-1', + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://github.com/acme/backend', + }); + mockCreateGitHubToken.mockResolvedValue('github-token'); + const getReview = vi.fn().mockResolvedValue({ + data: { + id: 901, + state: 'CHANGES_REQUESTED', + user: { login: authorLogin }, + }, + }); + const dismissReview = vi.fn(); + mockGetOctokit.mockReturnValue({ + rest: { pulls: { dismissReview, getReview } }, + }); + + await expect( + writeSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'github', + }), + input: { + action: 'dismiss_pull_request_review', + repositoryFullName: 'acme/backend', + prNumber: 55, + reviewId: '901', + body: 'Requested changes have been addressed.', + sourceControlProvider: 'github', + }, + }), + ).rejects.toThrow( + 'GitHub review 901 is not a Roomote-authored CHANGES_REQUESTED review.', + ); + expect(dismissReview).not.toHaveBeenCalled(); + }, + ); + + it('rejects dismissal of a non-change-request review', async () => { + mockRepositoriesFindFirst.mockResolvedValue({ + installationId: 'installation-1', + externalRepoId: null, + fullName: 'acme/backend', + htmlUrl: 'https://github.com/acme/backend', + }); + mockCreateGitHubToken.mockResolvedValue('github-token'); + const getReview = vi.fn().mockResolvedValue({ + data: { + id: 901, + state: 'APPROVED', + user: { login: 'roomote[bot]' }, + }, + }); + const dismissReview = vi.fn(); + mockGetOctokit.mockReturnValue({ + rest: { pulls: { dismissReview, getReview } }, + }); + + await expect( + writeSourceControlPullRequestForTaskRun({ + taskRun: makeTaskRun({ + repo: 'acme/backend', + sourceControlProvider: 'github', + }), + input: { + action: 'dismiss_pull_request_review', + repositoryFullName: 'acme/backend', + prNumber: 55, + reviewId: '901', + body: 'Requested changes have been addressed.', + sourceControlProvider: 'github', + }, + }), + ).rejects.toThrow( + 'GitHub review 901 is not a Roomote-authored CHANGES_REQUESTED review.', + ); + expect(dismissReview).not.toHaveBeenCalled(); + }); + it('updates a GitLab note in place through the notes endpoint', async () => { mockRepositoriesFindFirst.mockResolvedValue({ installationId: null, 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 cd387b8d5..673075a7b 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 @@ -158,6 +158,15 @@ type SourceControlPullRequestCommentThread = { comments: SourceControlPullRequestComment[]; }; +type SourceControlPullRequestReview = { + reviewId: string; + author: string | null; + state: string; + body: string; + submittedAt: string | null; + url: string | null; +}; + export type SourceControlPullRequestCommentsResult = { success: true; provider: SourceControlProvider; @@ -165,6 +174,8 @@ export type SourceControlPullRequestCommentsResult = { number: number; threads: SourceControlPullRequestCommentThread[]; issueComments: SourceControlPullRequestComment[]; + /** Present when the provider exposes top-level review state (currently GitHub). */ + reviews?: SourceControlPullRequestReview[]; warnings: string[]; }; @@ -1026,51 +1037,72 @@ async function listGitHubPullRequestComments({ : (await createGitHubReadClient(repository, provider)).octokit; const warnings: string[] = []; - const [reviewComments, restIssueComments] = useConditionalRequests - ? await Promise.all([ - listConditionalGitHubPages({ - cacheKey: `review-comments:${repository.installationId}:${repository.fullName}#${prNumber}`, - requestPage: (page, headers) => { - onGitHubApiRequest?.(); - return octokit.rest.pulls.listReviewComments({ - owner, - repo, - pull_number: prNumber, - per_page: 100, - page, - request: { headers }, - }); - }, - }), - listConditionalGitHubPages({ - cacheKey: `issue-comments:${repository.installationId}:${repository.fullName}#${prNumber}`, - requestPage: (page, headers) => { - onGitHubApiRequest?.(); - return octokit.rest.issues.listComments({ - owner, - repo, - issue_number: prNumber, - per_page: 100, - page, - request: { headers }, - }); - }, - }), - ]) - : await Promise.all([ - octokit.paginate(octokit.rest.pulls.listReviewComments, { - owner, - repo, - pull_number: prNumber, - per_page: 100, - }), - octokit.paginate(octokit.rest.issues.listComments, { - owner, - repo, - issue_number: prNumber, - per_page: 100, - }), - ]); + const [reviewComments, restIssueComments, restReviews] = + useConditionalRequests + ? await Promise.all([ + listConditionalGitHubPages({ + cacheKey: `review-comments:${repository.installationId}:${repository.fullName}#${prNumber}`, + requestPage: (page, headers) => { + onGitHubApiRequest?.(); + return octokit.rest.pulls.listReviewComments({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + page, + request: { headers }, + }); + }, + }), + listConditionalGitHubPages({ + cacheKey: `issue-comments:${repository.installationId}:${repository.fullName}#${prNumber}`, + requestPage: (page, headers) => { + onGitHubApiRequest?.(); + return octokit.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100, + page, + request: { headers }, + }); + }, + }), + listConditionalGitHubPages({ + cacheKey: `reviews:${repository.installationId}:${repository.fullName}#${prNumber}`, + requestPage: (page, headers) => { + onGitHubApiRequest?.(); + return octokit.rest.pulls.listReviews({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + page, + request: { headers }, + }); + }, + }), + ]) + : await Promise.all([ + octokit.paginate(octokit.rest.pulls.listReviewComments, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }), + octokit.paginate(octokit.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }), + octokit.paginate(octokit.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }), + ]); const issueComments: SourceControlPullRequestComment[] = restIssueComments.map((comment) => ({ @@ -1113,6 +1145,14 @@ async function listGitHubPullRequestComments({ number: prNumber, threads, issueComments, + reviews: restReviews.map((review) => ({ + reviewId: String(review.id), + author: review.user?.login ?? null, + state: review.state, + body: review.body ?? '', + submittedAt: review.submitted_at ?? null, + url: review.html_url ?? null, + })), warnings, }; } 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 d5fad0050..cd0ef996c 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 @@ -1,5 +1,5 @@ import { createGitHubToken } from '@roomote/auth'; -import { getOctokit } from '@roomote/github'; +import { getOctokit, Schemas as GitHubSchemas } from '@roomote/github'; import { type TaskRun } from '@roomote/db/server'; import { getSourceControlProviderLabel, @@ -63,6 +63,7 @@ export const sourceControlPullRequestWriteInputSchema = z.object({ 'update_pull_request_comment', 'resolve_pull_request_thread', 'submit_pull_request_review', + 'dismiss_pull_request_review', ]), repositoryFullName: z.string().trim().min(1), prNumber: z.number().int().positive(), @@ -82,6 +83,8 @@ export const sourceControlPullRequestWriteInputSchema = z.object({ * list_pull_request_comments or a prior write result. */ commentId: optionalTrimmedNonEmptyStringSchema, + /** Required for dismiss_pull_request_review. */ + reviewId: optionalTrimmedNonEmptyStringSchema, /** Required for reply, create_comment, and update_comment; optional for review. */ body: z.string().optional(), /** @@ -341,6 +344,7 @@ function normalizeOptionalWriteIds( ...input, threadId: blankToUndefined(input.threadId), commentId: blankToUndefined(input.commentId), + reviewId: blankToUndefined(input.reviewId), path: blankToUndefined(input.path), }; } @@ -381,6 +385,10 @@ function assertWriteInputFields( case 'submit_pull_request_review': requireReviewEvent(input); break; + case 'dismiss_pull_request_review': + requireReviewId(input); + requireBody(input); + break; } } @@ -406,6 +414,17 @@ function requireCommentId(input: SourceControlPullRequestWriteInput): string { return input.commentId; } +function requireReviewId(input: SourceControlPullRequestWriteInput): string { + if (!input.reviewId) { + throw new SourceControlWriteError( + 400, + `reviewId is required for ${input.action}.`, + ); + } + + return input.reviewId; +} + function requireBody(input: SourceControlPullRequestWriteInput): string { if (!input.body) { throw new SourceControlWriteError( @@ -730,6 +749,44 @@ async function writeGitHubPullRequest({ ...(body !== undefined ? { body } : {}), }); + return buildWriteResult({ + input, + provider, + repository, + commentId: String(data.id), + url: data.html_url ?? null, + }); + } + case 'dismiss_pull_request_review': { + const reviewId = requireReviewId(input); + const body = requireBody(input); + const numericReviewId = Number(reviewId); + const { data: review } = await octokit.rest.pulls.getReview({ + owner, + repo, + pull_number: input.prNumber, + review_id: numericReviewId, + }); + const reviewAuthor = review.user?.login; + + if ( + review.state !== 'CHANGES_REQUESTED' || + !reviewAuthor || + !GitHubSchemas.isManagedRoomoteGitHubLogin(reviewAuthor) + ) { + throw new Error( + `GitHub review ${reviewId} is not a Roomote-authored CHANGES_REQUESTED review.`, + ); + } + + const { data } = await octokit.rest.pulls.dismissReview({ + owner, + repo, + pull_number: input.prNumber, + review_id: numericReviewId, + message: body, + }); + return buildWriteResult({ input, provider, @@ -985,6 +1042,14 @@ async function writeGitLabMergeRequest({ mergeRequestPath, apiBaseUrl, }); + case 'dismiss_pull_request_review': + return buildWriteResult({ + input, + provider, + repository, + applied: false, + warnings: ['GitLab does not expose review dismissal.'], + }); } } @@ -1390,6 +1455,14 @@ async function writeGiteaPullRequest({ url: review.html_url ?? null, }); } + case 'dismiss_pull_request_review': + return buildWriteResult({ + input, + provider, + repository, + applied: false, + warnings: ['Gitea does not expose review dismissal.'], + }); } } @@ -1647,6 +1720,14 @@ async function writeBitbucketPullRequest({ repository, }); } + case 'dismiss_pull_request_review': + return buildWriteResult({ + input, + provider, + repository, + applied: false, + warnings: ['Bitbucket does not expose review dismissal.'], + }); } } @@ -1878,6 +1959,14 @@ async function writeAdoPullRequest({ commentId, }); } + case 'dismiss_pull_request_review': + return buildWriteResult({ + input, + provider, + repository, + applied: false, + warnings: ['Azure DevOps does not expose review dismissal.'], + }); } } From a59b56400c0a2f303bbfb142918cc2ae7c7d1289 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 28 Aug 2026 00:14:26 -0400 Subject: [PATCH 012/158] Remove the Brain search reranker (#1768) --- .docker/gbrain/entrypoint.sh | 37 ++------ .env.production.example | 8 +- .../__tests__/brain-inference.test.ts | 84 +++++-------------- .../api/src/handlers/brain-inference/index.ts | 43 ++++------ apps/docs/environment-variables.mdx | 6 +- apps/docs/memory.mdx | 36 ++++---- deploy/compose/docker-compose.prod.yml | 9 +- deploy/coolify/README.md | 7 +- deploy/coolify/docker-compose.yaml | 2 - deploy/railway/README.md | 7 +- deploy/railway/template.yaml | 3 - deploy/render/README.md | 7 +- docker-compose.self-host.yml | 2 - docker-compose.yml | 1 - packages/env/src/index.ts | 5 +- render.yaml | 7 -- 16 files changed, 73 insertions(+), 191 deletions(-) diff --git a/.docker/gbrain/entrypoint.sh b/.docker/gbrain/entrypoint.sh index e7276212b..f0a1f796c 100644 --- a/.docker/gbrain/entrypoint.sh +++ b/.docker/gbrain/entrypoint.sh @@ -280,35 +280,14 @@ gbrain config set dream.synthesize.link_manifest true >/dev/null gbrain config set agent.use_gateway_loop true >/dev/null echo "[gbrain-entrypoint] corpus checkout: $BRAIN_DIR (filesystem + Postgres index)" -# Route gbrain's OpenRouter reranker through the same Roomote credential -# gateway as embeddings and chat. Do this after initialization so exposing an -# OpenRouter-compatible endpoint does not change which provider gbrain chooses -# when it creates the Brain. An empty forwarded setting restores the default, -# including after a deployment previously selected another reranker. -GBRAIN_RERANKER_MODEL="${GBRAIN_RERANKER_MODEL:-openrouter:voyageai/rerank-2.5-lite}" -case "$GBRAIN_RERANKER_MODEL" in - openrouter:*) - if [ -z "${OPENROUTER_BASE_URL:-}" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then - OPENROUTER_BASE_URL="${OPENAI_BASE_URL%/}" - case "$OPENROUTER_BASE_URL" in - */v1) ;; - *) OPENROUTER_BASE_URL="$OPENROUTER_BASE_URL/v1" ;; - esac - export OPENROUTER_BASE_URL - fi - if [ -z "${OPENROUTER_API_KEY:-}" ] && [ -n "${OPENAI_API_KEY:-}" ]; then - OPENROUTER_API_KEY="$OPENAI_API_KEY" - export OPENROUTER_API_KEY - fi - if [ -z "${OPENROUTER_BASE_URL:-}" ] || [ -z "${OPENROUTER_API_KEY:-}" ]; then - echo "[gbrain-entrypoint] WARNING: $GBRAIN_RERANKER_MODEL needs OPENROUTER_BASE_URL and OPENROUTER_API_KEY." - echo "[gbrain-entrypoint] WARNING: reranking will remain fail-open until the gateway is configured." - fi - ;; -esac - -gbrain config set search.reranker.model "$GBRAIN_RERANKER_MODEL" >/dev/null -echo "[gbrain-entrypoint] reranker: $GBRAIN_RERANKER_MODEL" +# The Brain does not use a reranker. gbrain's own init already writes +# `search.reranker.enabled false` for installs keyed the way ours are, but +# make the choice explicit so every brain — including ones created before +# this line and ones hit by upstream mode-bundle default flips — converges +# on the same shipped behavior. Retrieval is hybrid RRF; autocut no-ops +# without rerank scores by design. +gbrain config set search.reranker.enabled false >/dev/null +echo "[gbrain-entrypoint] reranker: disabled" # Adding a key to a brain created without one is a first-class flow rather # than an edge case: on hosts whose compose parser ignores `profiles` the diff --git a/.env.production.example b/.env.production.example index eb8f77702..20791d1f1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -142,21 +142,19 @@ DEFAULT_COMPUTE_PROVIDER=docker # R_GITHUB_APP_SLUG= # Optional comma-separated GitHub App slugs that are also trusted as Roomote-managed. # R_GITHUB_ADDITIONAL_APP_SLUGS= -# Self-run Brain inference (embeddings/rerank stay on your hardware; chat +# Self-run Brain embeddings (embeddings stay on your hardware; chat # synthesis keeps using the configured provider). With the bundled service, # set COMPOSE_PROFILES=brain,local-inference and ALL of the settings below — -# the model names and dimensions must match what the inference server +# the model name and dimensions must match what the inference server # serves, and the embedding pair is create-time: set everything BEFORE the # Brain's first boot. gbrain's defaults (text-embedding-3-small, 1536) name -# models the bundled server does not serve, so the URLs alone are not a +# models the bundled server does not serve, so the URL alone is not a # working configuration. Self-run model names pass through unchanged and # must exactly match the ids served by the upstream. # R_BRAIN_EMBEDDINGS_UPSTREAM_URL=http://infinity:7997 -# R_BRAIN_RERANK_UPSTREAM_URL=http://infinity:7997 # R_BRAIN_INFERENCE_UPSTREAM_API_KEY= # R_BRAIN_EMBEDDING_MODEL=BAAI/bge-m3 # R_BRAIN_EMBEDDING_DIMENSIONS=1024 -# R_BRAIN_RERANKER_MODEL=BAAI/bge-reranker-v2-m3 # R_GITHUB_APP_ID= # Raw GitHub App private-key PEM with newlines escaped as \n; do not base64 it. # R_GITHUB_APP_PRIVATE_KEY= diff --git a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts index 13d2d27c0..b24f66d26 100644 --- a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts +++ b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts @@ -146,57 +146,6 @@ describe('brain inference gateway', () => { }); }); - it('routes reranking through OpenRouter without exposing its key to gbrain', async () => { - const fetchMock = vi.fn( - async (_url: string, _init: RequestInit) => - new Response(JSON.stringify({ results: [] }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); - - const body = { - model: 'cohere/rerank-v3.5', - query: 'Which result is relevant?', - documents: ['relevant', 'unrelated'], - top_n: 2, - }; - const response = await post('/v1/rerank', { - token: GATEWAY_TOKEN, - body, - }); - - expect(response.status).toBe(200); - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe('https://openrouter.ai/api/v1/rerank'); - expect((init.headers as Headers).get('authorization')).toBe( - `Bearer ${OPENROUTER.apiKey}`, - ); - expect(JSON.parse(init.body as string)).toEqual(body); - }); - - it('reports reranking as unavailable when only OpenAI is configured', async () => { - mockResolveBrainInferenceProvider.mockResolvedValue({ - providerId: 'openai', - apiKey: 'sk-openai-provider-key', - }); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const response = await post('/v1/rerank', { - token: GATEWAY_TOKEN, - body: { - model: 'cohere/rerank-v3.5', - query: 'query', - documents: ['document'], - }, - }); - - expect(response.status).toBe(503); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining('OpenRouter'), - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('surfaces an unreachable provider as 502 rather than a crash', async () => { vi.stubGlobal( 'fetch', @@ -265,27 +214,35 @@ describe('local inference upstreams', () => { expect(mockResolveBrainInferenceProvider).not.toHaveBeenCalled(); }); - it('allows rerank without OpenRouter when a rerank upstream is set', async () => { - mockEnv.R_BRAIN_RERANK_UPSTREAM_URL = 'http://infinity:7997/'; - mockResolveBrainInferenceProvider.mockResolvedValue({ - providerId: 'openai' as const, - apiKey: 'sk-openai', + it('rejects the removed rerank path like any other unlisted path', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/rerank', { + token: GATEWAY_TOKEN, + body: { model: 'bge-reranker-base', query: 'q', documents: ['a'] }, }); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the trailing slash on a configured upstream from doubling up', async () => { + mockEnv.R_BRAIN_EMBEDDINGS_UPSTREAM_URL = 'http://infinity:7997/'; const fetchMock = vi .fn() - .mockResolvedValue( - new Response(JSON.stringify({ results: [] }), { status: 200 }), - ); + .mockResolvedValue(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); - const response = await post('/v1/rerank', { + const response = await post('/v1/embeddings', { token: GATEWAY_TOKEN, - body: { model: 'bge-reranker-base', query: 'q', documents: ['a'] }, + body: { model: 'bge-small-en-v1.5', input: ['a'] }, }); expect(response.status).toBe(200); - // Trailing slash on the configured URL must not double up. - expect(fetchMock.mock.calls[0]![0]).toBe('http://infinity:7997/v1/rerank'); + expect(fetchMock.mock.calls[0]![0]).toBe( + 'http://infinity:7997/v1/embeddings', + ); }); it('sends no authorization header when the upstream has no key', async () => { @@ -306,7 +263,6 @@ describe('local inference upstreams', () => { it('keeps chat on the provider even when upstreams are configured', async () => { mockEnv.R_BRAIN_EMBEDDINGS_UPSTREAM_URL = 'http://infinity:7997'; - mockEnv.R_BRAIN_RERANK_UPSTREAM_URL = 'http://infinity:7997'; const fetchMock = vi .fn() .mockResolvedValue(new Response('{}', { status: 200 })); diff --git a/apps/api/src/handlers/brain-inference/index.ts b/apps/api/src/handlers/brain-inference/index.ts index 398a507aa..f97043588 100644 --- a/apps/api/src/handlers/brain-inference/index.ts +++ b/apps/api/src/handlers/brain-inference/index.ts @@ -20,14 +20,15 @@ import type { Variables } from '../../types'; const LOG_PREFIX = '[Brain Inference]'; /** - * The Brain's whole inference surface: embeddings for recall, reranking for - * precision, and chat for sourced synthesis and query expansion. Deliberately - * narrower than the task-sandbox gateway's allowlist, because this credential - * is a static deployment secret rather than a short-lived run token. + * The Brain's whole inference surface: embeddings for recall and chat for + * sourced synthesis and query expansion. Deliberately narrower than the + * task-sandbox gateway's allowlist, because this credential is a static + * deployment secret rather than a short-lived run token. Reranking is not + * part of the Brain: retrieval is hybrid RRF, and the reranker is disabled + * per-brain by the gbrain entrypoint. */ const BRAIN_ALLOWED_PATHS = new Set([ '/v1/embeddings', - '/v1/rerank', '/v1/chat/completions', '/v1/responses', ]); @@ -119,13 +120,13 @@ async function rewriteBody( } /** - * A self-run inference upstream for one gateway path. Embeddings and rerank - * are the Brain's bulk data paths (memory text in, vectors/scores out), so - * they are the ones a deployment may want on its own hardware; chat synthesis - * stays with the configured model provider. Model names pass through - * unrewritten — the upstream owns its own model registry, and every Brain is - * locked to its embedding model at creation, so the name must mean exactly - * one thing forever. + * A self-run inference upstream for one gateway path. Embeddings are the + * Brain's bulk data path (memory text in, vectors out), so they are the one + * a deployment may want on its own hardware; chat synthesis stays with the + * configured model provider. Model names pass through unrewritten — the + * upstream owns its own model registry, and every Brain is locked to its + * embedding model at creation, so the name must mean exactly one thing + * forever. */ function resolveLocalUpstream( upstreamPath: string, @@ -133,9 +134,7 @@ function resolveLocalUpstream( const baseUrl = upstreamPath === '/v1/embeddings' ? Env.R_BRAIN_EMBEDDINGS_UPSTREAM_URL - : upstreamPath === '/v1/rerank' - ? Env.R_BRAIN_RERANK_UPSTREAM_URL - : undefined; + : undefined; if (!baseUrl?.trim()) { return null; @@ -266,20 +265,6 @@ brainInference.post('/*', async (c) => { ); } - // gbrain's OpenRouter reranker speaks the same authenticated gateway - // contract as embeddings and chat, but OpenAI itself has no compatible - // rerank endpoint. Fail explicitly instead of forwarding a doomed request - // to api.openai.com and obscuring the missing capability as a 404. - if (upstreamPath === '/v1/rerank' && resolved.providerId !== 'openrouter') { - return c.json( - { - error: - 'Brain reranking requires an OpenRouter provider configured in Settings, or a local rerank upstream (R_BRAIN_RERANK_UPSTREAM_URL).', - }, - 503, - ); - } - const provider = getInferenceGatewayProvider(resolved.providerId); if (!provider?.authHeader) { diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index efb846f87..d5542818a 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -220,15 +220,13 @@ as per-task auth tokens or workspace paths. | `OPENAI_COMPATIBLE__LABEL` | Optional | Display label stored with a named OpenAI-compatible connection. | | `VLLM_BASE_URL` | vLLM | vLLM OpenAI-compatible endpoint URL, usually including its `/v1` path. | | `VLLM_API_KEY` | Optional | Bearer API key for a vLLM endpoint that requires authentication. | -| `R_BRAIN_OPENROUTER_API_KEY` | Memory provider | OpenRouter key that enables Memory embeddings, reranking, and synthesis. | +| `R_BRAIN_OPENROUTER_API_KEY` | Memory provider | OpenRouter key that enables Memory embeddings and synthesis. | | `R_BRAIN_OPENAI_API_KEY` | Memory provider | OpenAI key that enables Memory embeddings and synthesis. | | `R_BRAIN_MODEL` | Optional | Memory synthesis model in the configured provider's naming. Changes apply immediately. | | `R_BRAIN_EMBEDDING_MODEL` | Before first Memory boot | Embedding model id that sizes Memory's vector storage. Changing it later requires re-embedding. | | `R_BRAIN_EMBEDDING_DIMENSIONS` | Before first Memory boot | Output width for `R_BRAIN_EMBEDDING_MODEL`; it must match the served model. | -| `R_BRAIN_RERANKER_MODEL` | Optional | Memory reranker model. Use OpenRouter's model id for OpenRouter, or the exact bare model id served by a self-run rerank upstream. | | `R_BRAIN_EMBEDDINGS_UPSTREAM_URL` | Optional | OpenAI-compatible embeddings endpoint used instead of the Memory provider. | -| `R_BRAIN_RERANK_UPSTREAM_URL` | Optional | OpenAI-compatible rerank endpoint used instead of OpenRouter. | -| `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` | Optional | Bearer key shared by the self-run embeddings and rerank upstreams; omit it for a trusted private-network service. | +| `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` | Optional | Bearer key for the self-run embeddings upstream; omit it for a trusted private-network service. | ### Sandbox providers diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index ed715ae7b..e99416b21 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -101,22 +101,18 @@ Changing that key later takes effect on Memory's next request, with no redeploy. OpenRouter and OpenAI both support Memory's embedding and synthesis calls. -Search reranking requires OpenRouter unless a self-run rerank upstream is -configured. -### Run embeddings and reranking locally +### Run embeddings locally -Self-hosted Compose deployments can keep embeddings and reranking on their own -hardware while continuing to send chat synthesis to the configured Memory -provider. Enable both services and point Memory at the bundled inference server: +Self-hosted Compose deployments can keep embeddings on their own hardware +while continuing to send chat synthesis to the configured Memory provider. +Enable both services and point Memory at the bundled inference server: ```sh COMPOSE_PROFILES=brain,local-inference R_BRAIN_EMBEDDINGS_UPSTREAM_URL=http://infinity:7997 -R_BRAIN_RERANK_UPSTREAM_URL=http://infinity:7997 R_BRAIN_EMBEDDING_MODEL=BAAI/bge-m3 R_BRAIN_EMBEDDING_DIMENSIONS=1024 -R_BRAIN_RERANKER_MODEL=BAAI/bge-reranker-v2-m3 ``` The bundled CPU service uses multilingual models so recall can cross languages. @@ -125,11 +121,11 @@ dimensions is a lighter embedding alternative. Choose the embedding model and dimensions before Memory's first boot; changing that pair later requires re-embedding the corpus. -The two upstream URLs can instead target any OpenAI-compatible embedding and -rerank server. Set `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` when that server requires -a bearer key. Roomote forwards model names unchanged to self-run upstreams, so -`R_BRAIN_EMBEDDING_MODEL` and `R_BRAIN_RERANKER_MODEL` must exactly match the -models that server exposes, without a provider prefix. +The upstream URL can instead target any OpenAI-compatible embedding server. +Set `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` when that server requires a bearer +key. Roomote forwards model names unchanged to self-run upstreams, so +`R_BRAIN_EMBEDDING_MODEL` must exactly match a model that server exposes, +without a provider prefix. Without a Memory key, Memory stays inert. Agents are not told it exists, and nothing is ingested. @@ -229,23 +225,19 @@ in staging is distinguishable from one written against production. ## Choosing models -Three settings pick Memory's models: +Two settings pick Memory's models: | Variable | What it does | Written as | Changeable | | ----------------------------- | ----------------- | --------------------------------- | --------------------- | | `R_BRAIN_MODEL` | Sourced synthesis | your provider's naming | any time | | `R_BRAIN_EMBEDDING_MODEL` | Semantic recall | a plain model id | before the first boot | -| `R_BRAIN_RERANKER_MODEL` | Search precision | provider or upstream naming | after a restart | -Leave the first two unset and Memory uses OpenAI's `gpt-5.6-luna` and +Leave them unset and Memory uses OpenAI's `gpt-5.6-luna` and `text-embedding-3-small` through whichever provider you configured. -The reranker defaults to OpenRouter's `voyageai/rerank-2.5-lite`. Set -`R_BRAIN_RERANKER_MODEL` to choose another model from -OpenRouter's reranker catalog. Reranking requires an OpenRouter key; with only -OpenAI configured, gbrain keeps the unreranked results instead of failing the -search. When `R_BRAIN_RERANK_UPSTREAM_URL` is set, use the exact bare model id -served by that upstream instead. +Memory search does not use a cross-encoder reranker: retrieval is hybrid +(vector + keyword fusion), which keeps search latency flat and provider +requirements minimal. The synthesis model is applied by Roomote when it forwards the call and passed to the provider as written, so use that provider's naming diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index 50af5f5db..4c55624ab 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -24,12 +24,10 @@ x-roomote-base-env: &roomote-base-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} - R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} - # Optional self-run inference: point embeddings/rerank at the bundled + # Optional self-run inference: point embeddings at the bundled # `infinity` service (profile local-inference) or any OpenAI-compatible # server. Chat synthesis keeps flowing to the configured model provider. R_BRAIN_EMBEDDINGS_UPSTREAM_URL: ${R_BRAIN_EMBEDDINGS_UPSTREAM_URL:-} - R_BRAIN_RERANK_UPSTREAM_URL: ${R_BRAIN_RERANK_UPSTREAM_URL:-} R_BRAIN_INFERENCE_UPSTREAM_API_KEY: ${R_BRAIN_INFERENCE_UPSTREAM_API_KEY:-} R_GBRAIN_URL: ${R_GBRAIN_URL:-http://gbrain:8931} R_GBRAIN_ADMIN_TOKEN_FILE: /gbrain-data/admin-bootstrap-token @@ -542,7 +540,6 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} - GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. @@ -555,7 +552,7 @@ services: security_opt: - no-new-privileges:true - # Self-run embedding/reranking models for the Brain (opt-in, CPU). + # Self-run embedding model for the Brain (opt-in, CPU). # Enable with the `local-inference` compose profile. The upstream URLs are # NOT sufficient on their own: R_BRAIN_EMBEDDING_MODEL and # R_BRAIN_EMBEDDING_DIMENSIONS must name what this server serves (gbrain's @@ -590,8 +587,6 @@ services: # Brain's first boot; the embedding choice is create-time. - --model-id - ${INFINITY_EMBEDDING_MODEL:-BAAI/bge-m3} - - --model-id - - ${INFINITY_RERANKER_MODEL:-BAAI/bge-reranker-v2-m3} volumes: - infinity_cache:/app/.cache security_opt: diff --git a/deploy/coolify/README.md b/deploy/coolify/README.md index 96753f8cd..fc037a040 100644 --- a/deploy/coolify/README.md +++ b/deploy/coolify/README.md @@ -278,10 +278,9 @@ Two operational notes: database in Postgres holds its searchable index, extracted facts, and durable jobs, so keep it in the normal `pg_data` backup too. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and - `R_BRAIN_RERANKER_MODEL` the reranker. Set them on the app services. Leave - them empty for the defaults. The synthesis model can change at any time; - the reranker changes after a gbrain restart; the embedding model + synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model. Set them + on the app services. Leave them empty for the defaults. The synthesis model + can change at any time; the embedding model sizes Memory's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in Memory's logs rather than silently diff --git a/deploy/coolify/docker-compose.yaml b/deploy/coolify/docker-compose.yaml index 81fada84a..34b0d80a0 100644 --- a/deploy/coolify/docker-compose.yaml +++ b/deploy/coolify/docker-compose.yaml @@ -82,7 +82,6 @@ x-roomote-shared-env: &roomote-shared-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} - R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} R_GBRAIN_URL: http://gbrain:8931 # Roomote uses this only to register its own scoped clients against the # Brain. Coolify's SERVICE_PASSWORD_64_* generates an @@ -166,7 +165,6 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${SERVICE_PASSWORD_64_BRAINGATEWAY} - GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/deploy/railway/README.md b/deploy/railway/README.md index c5be4eebb..39fc8e8b5 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -432,10 +432,9 @@ Two operational notes: no longer recognizes them — but the deployment starts cold until that finishes. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and - `R_BRAIN_RERANKER_MODEL` the reranker, all set on **api**. Leave them empty - for the defaults. The synthesis model can change at any time; the reranker - changes after a gbrain restart; the embedding model + synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model, both set + on **api**. Leave them empty for the defaults. The synthesis model can + change at any time; the embedding model sizes Memory's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in Memory's logs rather than silently diff --git a/deploy/railway/template.yaml b/deploy/railway/template.yaml index 7bad5d363..5dd041913 100644 --- a/deploy/railway/template.yaml +++ b/deploy/railway/template.yaml @@ -144,7 +144,6 @@ services: # goes over the public origin, exactly as TRPC_URL already does. OPENAI_BASE_URL: https://${{api.RAILWAY_PUBLIC_DOMAIN}}/api/brain/inference OPENAI_API_KEY: ${{api.R_BRAIN_GATEWAY_TOKEN}} - GBRAIN_RERANKER_MODEL: ${{api.R_BRAIN_RERANKER_MODEL}} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. @@ -214,7 +213,6 @@ services: R_BRAIN_MODEL: '' R_BRAIN_EMBEDDING_MODEL: '' R_BRAIN_EMBEDDING_DIMENSIONS: '' - R_BRAIN_RERANKER_MODEL: '' # Railway's private network is IPv6-only and never leaves the project, # so the Brain is unreachable from the internet by construction. R_GBRAIN_URL: http://${{gbrain.RAILWAY_PRIVATE_DOMAIN}}:8931 @@ -262,7 +260,6 @@ services: R_BRAIN_GATEWAY_TOKEN: ${{api.R_BRAIN_GATEWAY_TOKEN}} R_BRAIN_MODEL: ${{api.R_BRAIN_MODEL}} R_BRAIN_EMBEDDING_MODEL: ${{api.R_BRAIN_EMBEDDING_MODEL}} - R_BRAIN_RERANKER_MODEL: ${{api.R_BRAIN_RERANKER_MODEL}} R_GBRAIN_URL: ${{api.R_GBRAIN_URL}} R_GBRAIN_ADMIN_TOKEN: ${{api.R_GBRAIN_ADMIN_TOKEN}} R_APP_ENV: ${{api.R_APP_ENV}} diff --git a/deploy/render/README.md b/deploy/render/README.md index 38c16ea34..2148240c2 100644 --- a/deploy/render/README.md +++ b/deploy/render/README.md @@ -397,10 +397,9 @@ Two operational notes: longer recognizes them — but the deployment starts cold until that finishes. - **Model choice is a variable, not a rebuild.** `R_BRAIN_MODEL` selects the - synthesis model, `R_BRAIN_EMBEDDING_MODEL` the embedding model, and - `R_BRAIN_RERANKER_MODEL` the reranker. Set them on the api service. Leave - them empty for the defaults. The synthesis model can change at any time; - the reranker changes after a gbrain restart; the embedding model + synthesis model and `R_BRAIN_EMBEDDING_MODEL` the embedding model. Set them + on the api service. Leave them empty for the defaults. The synthesis model + can change at any time; the embedding model sizes Memory's vector storage when it is first created, so set it (with `R_BRAIN_EMBEDDING_DIMENSIONS`) before first boot or not at all. A later change is ignored and reported in Memory's logs rather than silently diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index e89888772..9c5c9dd93 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -36,7 +36,6 @@ x-roomote-env: &roomote-env R_BRAIN_MODEL: ${R_BRAIN_MODEL:-} R_BRAIN_EMBEDDING_MODEL: ${R_BRAIN_EMBEDDING_MODEL:-} R_BRAIN_EMBEDDING_DIMENSIONS: ${R_BRAIN_EMBEDDING_DIMENSIONS:-} - R_BRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} R_GBRAIN_URL: ${R_GBRAIN_URL:-http://gbrain:8931} # Roomote reads the brain's bootstrap token once to register its own # scoped clients; api and bullmq mount the brain volume read-only. @@ -299,7 +298,6 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://api:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} - GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/docker-compose.yml b/docker-compose.yml index 0c090ffb9..7423912fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,6 @@ services: # provider key below instead makes the Brain call the provider directly. OPENAI_BASE_URL: ${GBRAIN_OPENAI_BASE_URL:-http://host.docker.internal:3001/api/brain/inference} OPENAI_API_KEY: ${R_BRAIN_GATEWAY_TOKEN:-} - GBRAIN_RERANKER_MODEL: ${R_BRAIN_RERANKER_MODEL:-} # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 476fc42be..62793cbc8 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -378,11 +378,10 @@ const serverSchema = { R_TRIAL_OPENROUTER_API_KEY: z.string().min(1).optional(), // Optional self-run inference upstreams for the Brain gateway. When set, // the gateway routes that path's requests there instead of the configured - // model provider — embeddings and rerank can move to a local or fleet + // model provider — embeddings can move to a local or fleet // inference service while chat synthesis keeps flowing to the provider. // Model names pass through unrewritten: the upstream owns its own names. R_BRAIN_EMBEDDINGS_UPSTREAM_URL: z.string().url().optional(), - R_BRAIN_RERANK_UPSTREAM_URL: z.string().url().optional(), // One key for both paths: they are the same service in every planned // deployment shape. Optional because a compose-network upstream may have // no auth at all. @@ -540,7 +539,6 @@ const OPTIONAL_NON_EMPTY_KEYS = new Set([ 'R_BRAIN_OPENAI_API_KEY', 'R_TRIAL_OPENROUTER_API_KEY', 'R_BRAIN_EMBEDDINGS_UPSTREAM_URL', - 'R_BRAIN_RERANK_UPSTREAM_URL', 'R_BRAIN_INFERENCE_UPSTREAM_API_KEY', 'R_BRAIN_GATEWAY_TOKEN', 'R_BRAIN_GATEWAY_TOKEN_FILE', @@ -738,7 +736,6 @@ export function isBrainConfigured(env: { R_BRAIN_OPENROUTER_API_KEY?: string; R_BRAIN_OPENAI_API_KEY?: string; R_BRAIN_EMBEDDINGS_UPSTREAM_URL?: string; - R_BRAIN_RERANK_UPSTREAM_URL?: string; R_BRAIN_INFERENCE_UPSTREAM_API_KEY?: string; }): boolean { return Boolean( diff --git a/render.yaml b/render.yaml index 677f1188a..f33ff57ff 100644 --- a/render.yaml +++ b/render.yaml @@ -164,11 +164,6 @@ services: type: web name: roomote-api envVarKey: R_BRAIN_GATEWAY_TOKEN - - key: GBRAIN_RERANKER_MODEL - fromService: - type: web - name: roomote-api - envVarKey: R_BRAIN_RERANKER_MODEL # Create-time, and it has to be the container that receives these: the # embedding model and its width are decided when the Brain is created, # and the gateway must never substitute a different one afterwards. @@ -267,8 +262,6 @@ services: sync: false - key: R_BRAIN_EMBEDDING_MODEL sync: false - - key: R_BRAIN_RERANKER_MODEL - value: '' - key: ROOMOTE_GBRAIN_HOSTPORT fromService: type: pserv From 5e50b77a135716ad532674f02a99097581121b55 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:57:03 -0400 Subject: [PATCH 013/158] [Improve] Diagnose Fast inference retries precisely (#1770) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../fast-agent-context-telemetry.test.ts | 52 +++- .../__tests__/fast-agent-service.test.ts | 40 +++ .../fast-agent-turn-diagnostics.test.ts | 39 ++- .../fast-agent-context-telemetry.ts | 41 +++ .../server/fast-agent/fast-agent-service.ts | 285 +++++++++++------- .../fast-agent/fast-agent-turn-diagnostics.ts | 64 +++- 6 files changed, 413 insertions(+), 108 deletions(-) diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-context-telemetry.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-context-telemetry.test.ts index 927bd36f4..60068ade2 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-context-telemetry.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-context-telemetry.test.ts @@ -2,7 +2,10 @@ const captureEvent = vi.hoisted(() => vi.fn()); vi.mock('@roomote/telemetry/server', () => ({ captureEvent })); -import { captureFastAgentInferenceContext } from '../fast-agent-context-telemetry'; +import { + captureFastAgentInferenceAttemptOutcome, + captureFastAgentInferenceContext, +} from '../fast-agent-context-telemetry'; describe('captureFastAgentInferenceContext', () => { beforeEach(() => { @@ -12,6 +15,8 @@ describe('captureFastAgentInferenceContext', () => { it('records a privacy-safe component manifest for a complete warm turn', () => { captureFastAgentInferenceContext({ userId: 'private-user-id', + sessionId: 'private-session-id', + turnId: 'private-turn-id', systemPrompt: 'private deployment prompt', surface: 'slack', turnSource: 'human', @@ -49,6 +54,8 @@ describe('captureFastAgentInferenceContext', () => { prompt_kind: 'turn_delta', attempt_number: 1, attempt_scope: 'prompt_submission', + session_id_hash: expect.stringMatching(/^[a-f0-9]{64}$/), + turn_id_hash: expect.stringMatching(/^[a-f0-9]{64}$/), present_components: expect.arrayContaining([ 'current_turn', 'native_history', @@ -63,11 +70,15 @@ describe('captureFastAgentInferenceContext', () => { ); const event = JSON.stringify(captureEvent.mock.calls[0]); expect(event).not.toContain('private deployment prompt'); + expect(event).not.toContain('private-session-id'); + expect(event).not.toContain('private-turn-id'); }); it('marks loader failures and rebuilt retry context explicitly', () => { captureFastAgentInferenceContext({ userId: 'user-1', + sessionId: 'session-1', + turnId: 'turn-1', systemPrompt: 'system', surface: 'automation', turnSource: 'platform_event', @@ -116,4 +127,43 @@ describe('captureFastAgentInferenceContext', () => { expect(properties.present_components).not.toContain('integration_catalog'); expect(properties.present_components).not.toContain('task_model_catalog'); }); + + it('records attempt outcomes without prompt or raw error content', () => { + captureFastAgentInferenceAttemptOutcome({ + userId: 'user-1', + sessionId: 'session-1', + turnId: 'turn-1', + surface: 'web', + sessionPath: 'cold_rebuild', + promptKind: 'bootstrap', + attemptNumber: 1, + outcome: 'failure', + stage: 'opencode_setup', + elapsedMs: 654, + failureReason: 'endpoint_unreachable', + failureRetryable: true, + resolvedModel: 'openrouter/openai/gpt-test', + providerRetryEventCount: 0, + }); + + expect(captureEvent).toHaveBeenCalledWith( + 'fast_agent_inference_attempt_outcome', + expect.objectContaining({ + properties: expect.objectContaining({ + outcome: 'failure', + stage: 'opencode_setup', + elapsed_ms: 654, + failure_reason: 'endpoint_unreachable', + failure_retryable: true, + resolved_model: 'openrouter/openai/gpt-test', + provider: 'openrouter', + provider_retry_event_count: 0, + }), + }), + ); + expect(JSON.stringify(captureEvent.mock.calls[0])).not.toContain( + 'session-1', + ); + expect(JSON.stringify(captureEvent.mock.calls[0])).not.toContain('turn-1'); + }); }); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index ce25ce75f..741944fdd 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ bindExecutor: vi.fn(), bindMcpExecutor: vi.fn(), captureInferenceContext: vi.fn(), + captureInferenceAttemptOutcome: vi.fn(), revokeMcpCapabilities: vi.fn(), nativeExecutor: undefined as | ((call: { @@ -133,6 +134,7 @@ vi.mock('../fast-agent-integration-broker', () => ({ vi.mock('../fast-agent-context-telemetry', () => ({ captureFastAgentInferenceContext: mocks.captureInferenceContext, + captureFastAgentInferenceAttemptOutcome: mocks.captureInferenceAttemptOutcome, })); vi.mock('../fast-agent-tasks', () => ({ @@ -381,6 +383,15 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { degradedComponents: [], }), ); + expect(mocks.captureInferenceAttemptOutcome).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'success', + stage: 'model_generation', + attemptNumber: 1, + resolvedModel: 'openrouter/openai/gpt-5.4', + providerRetryEventCount: 0, + }), + ); expect(mocks.upsertMessage).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'conversation-1', @@ -2547,10 +2558,29 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { attachedImageCount: 1, }), ); + expect(mocks.captureInferenceAttemptOutcome).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + outcome: 'failure', + attemptNumber: 1, + failureReason: 'endpoint_unreachable', + failureRetryable: true, + providerRetryEventCount: 0, + }), + ); + expect(mocks.captureInferenceAttemptOutcome).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + outcome: 'success', + attemptNumber: 2, + providerRetryEventCount: 0, + }), + ); expect(mocks.captureInferenceContext).toHaveBeenNthCalledWith( 2, expect.objectContaining({ promptKind: 'side_effect_retry_recovery', + sessionPath: 'warm', attemptNumber: 2, inputImageCount: 1, attachedImageCount: 0, @@ -2786,10 +2816,20 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { 2, expect.objectContaining({ promptKind: 'clean_retry_bootstrap', + sessionPath: 'cold_rebuild', attemptNumber: 2, attachedImageCount: 1, }), ); + expect(mocks.captureInferenceAttemptOutcome).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + promptKind: 'clean_retry_bootstrap', + sessionPath: 'cold_rebuild', + attemptNumber: 2, + outcome: 'success', + }), + ); expect(adapter.postReply).toHaveBeenNthCalledWith(1, { purpose: 'progress', message: expect.stringContaining('Retrying in 1s (attempt 1/6)'), diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-turn-diagnostics.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-turn-diagnostics.test.ts index 7e7e5b50b..6df1ab19d 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-turn-diagnostics.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-turn-diagnostics.test.ts @@ -47,13 +47,18 @@ describe('FastAgentTurnDiagnostics', () => { currentTime = 1_060; diagnostics.markInferenceStarted(); currentTime = 1_075; - diagnostics.recordOpenCodeProviderRetry(2); + diagnostics.recordSessionPath('cold_rebuild'); + diagnostics.recordOpenCodeSessionReady('opencode-session-1'); + diagnostics.recordOpenCodeProviderRetry(2, 'temporary upstream failure'); currentTime = 1_100; diagnostics.markInferenceFinished(); currentTime = 1_110; diagnostics.finish(); expect(logger.info).toHaveBeenCalledOnce(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[Fast Agent] OpenCode provider retry.'), + ); const logMessage = String(logger.info.mock.calls[0]?.[0]); expect(logMessage).toContain('serviceDurationMs=110'); expect(logMessage).toContain('preInferenceDurationMs=10'); @@ -63,6 +68,38 @@ describe('FastAgentTurnDiagnostics', () => { expect(logMessage).toContain('postInferenceDurationMs=10'); expect(logMessage).toContain('firstOpenCodeProviderRetryElapsedMs=15'); expect(logMessage).toContain('lastOpenCodeProviderRetryElapsedMs=15'); + expect(logMessage).toContain('sessionPath="cold_rebuild"'); + expect(logMessage).toContain('openCodeSessionId="opencode-session-1"'); + expect(logMessage).toContain('recoveredAfterOpenCodeProviderRetry=true'); + }); + + it('records bounded redacted context for each failed inference attempt', () => { + const { diagnostics, logger } = createTestDiagnostics(() => 5_000); + const secret = 'sk-provider-secret-1234567890'; + + diagnostics.setCanonicalConversationId('canonical-1'); + diagnostics.recordSessionPath('cold_rebuild'); + diagnostics.recordModelResolved('openrouter/openai/gpt-test'); + diagnostics.recordInferenceAttemptFailure({ + attemptNumber: 1, + promptKind: 'bootstrap', + stage: 'opencode_setup', + elapsedMs: 654, + reason: 'endpoint_unreachable', + retryable: true, + providerRetryEventCount: 0, + error: new Error(`authorization: Bearer ${secret}`), + }); + + expect(logger.warn).toHaveBeenCalledOnce(); + const logMessage = String(logger.warn.mock.calls[0]?.[0]); + expect(logMessage).toContain('[Fast Agent] Inference attempt failed.'); + expect(logMessage).toContain('attemptNumber=1'); + expect(logMessage).toContain('stage="opencode_setup"'); + expect(logMessage).toContain('reason="endpoint_unreachable"'); + expect(logMessage).toContain('providerRetryEventCount=0'); + expect(logMessage).toContain('[redacted]'); + expect(logMessage).not.toContain(secret); }); it('records completed and still-active native tools without their payloads', () => { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-context-telemetry.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-context-telemetry.ts index 3c430db05..df56c1932 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-context-telemetry.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-context-telemetry.ts @@ -37,6 +37,8 @@ const REQUIRED_SYSTEM_COMPONENTS = [ type CaptureFastAgentInferenceContextInput = { userId: string; + sessionId: string; + turnId: string; systemPrompt: string; surface: FastAgentSurface; turnSource: FastAgentTurnSource; @@ -129,6 +131,8 @@ export function captureFastAgentInferenceContext( attempt_number: input.attemptNumber, attempt_scope: input.attemptScope, provider_retry_attempt: input.providerRetryAttempt ?? null, + session_id_hash: sha256(input.sessionId), + turn_id_hash: sha256(input.turnId), release_present: input.releasePresent, environment_count: input.environmentCount, task_model_count: input.taskModelCount, @@ -146,3 +150,40 @@ export function captureFastAgentInferenceContext( }, }); } + +export function captureFastAgentInferenceAttemptOutcome(input: { + userId: string; + sessionId: string; + turnId: string; + surface: FastAgentSurface; + sessionPath: FastAgentSessionPath; + promptKind: FastAgentPromptKind; + attemptNumber: number; + outcome: 'success' | 'failure'; + stage: 'model_resolution' | 'opencode_setup' | 'model_generation'; + elapsedMs: number; + failureReason?: string; + failureRetryable?: boolean; + resolvedModel?: string; + providerRetryEventCount: number; +}): void { + void captureEvent('fast_agent_inference_attempt_outcome', { + userId: input.userId, + properties: { + session_id_hash: sha256(input.sessionId), + turn_id_hash: sha256(input.turnId), + surface: input.surface, + session_path: input.sessionPath, + prompt_kind: input.promptKind, + attempt_number: input.attemptNumber, + outcome: input.outcome, + stage: input.stage, + elapsed_ms: input.elapsedMs, + failure_reason: input.failureReason ?? null, + failure_retryable: input.failureRetryable ?? null, + resolved_model: input.resolvedModel ?? null, + provider: input.resolvedModel?.split('/')[0] ?? null, + provider_retry_event_count: input.providerRetryEventCount, + }, + }); +} diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 33118bd5f..6a293dd06 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -85,6 +85,7 @@ import { import { getFastAgentUserIdentity } from './fast-agent-user-identity'; import { FastAgentTurnDiagnostics } from './fast-agent-turn-diagnostics'; import { + captureFastAgentInferenceAttemptOutcome, captureFastAgentInferenceContext, type FastAgentPromptKind, } from './fast-agent-context-telemetry'; @@ -449,9 +450,6 @@ async function runFastAgentInferenceWithRetries( failure, attemptNumber, ); - console.warn( - `[Fast Agent] Retrying inference failure attempt=${attemptNumber}/${maxRetries} delayMs=${delayMs} reason=${failure.reason}: ${formatErrorForLog(error)}`, - ); try { await onRetry?.({ failure, @@ -1229,7 +1227,7 @@ export async function answerFastAgentQuestion({ const reportProviderRetryEvent = async ( event: NonTaskProviderRetryEvent, ) => { - diagnostics.recordOpenCodeProviderRetry(event.attempt); + diagnostics.recordOpenCodeProviderRetry(event.attempt, event.message); await reportInferenceRetry({ failure: classifyNonTaskInferenceError(new Error(event.message)), attemptNumber: event.attempt, @@ -1824,6 +1822,7 @@ export async function answerFastAgentQuestion({ prompt: serializedTurnPrompt, bootstrapPrompt: serializedBootstrapPrompt, onPathSelected: (path) => { + diagnostics.recordSessionPath(path); console.info(`[Fast Agent] OpenCode session path=${path}.`); }, execute: async ( @@ -1858,19 +1857,23 @@ export async function answerFastAgentQuestion({ sessionPath === 'warm' || sessionPath === 'cold_resume' ? 'turn_delta' : 'bootstrap'; + let attemptSessionPath = sessionPath; let promptTimeoutMs: number | null = null; + let resolvedInferenceModel: string | undefined; const captureInferenceContext = ( attemptScope: 'prompt_submission' | 'provider_retry', providerRetryAttempt?: number, ) => { captureFastAgentInferenceContext({ userId, + sessionId: session.id, + turnId, systemPrompt: system, surface: conversation.surface, turnSource, platformEventHandling, platformEventKind, - sessionPath, + sessionPath: attemptSessionPath, promptKind, attemptNumber: inferenceAttemptNumber, attemptScope, @@ -1921,113 +1924,183 @@ export async function answerFastAgentQuestion({ let providerRetryTimeout: | ReturnType | undefined; + const attemptStartedAt = Date.now(); + let promptStarted = false; + let providerRetryEventCount = 0; try { inferenceAttemptNumber += 1; + resolvedInferenceModel = undefined; captureInferenceContext('prompt_submission'); - return await generateTrackedNonTaskTextInOpenCodeSession( - { - userId, - surface: - NON_TASK_INFERENCE_SURFACES.fastAgentQuestionAnswering, - modelRole: FAST_AGENT_MODEL_ROLE, - ...(model ? { model } : {}), - ...(reasoningEffort ? { reasoningEffort } : {}), - timeoutMs: promptTimeoutMs, - maxProviderRetryAttempts: FAST_AGENT_INFERENCE_MAX_RETRIES, - system, - prompt: promptForAttempt, - onProviderRetry: async (event) => { - captureInferenceContext('provider_retry', event.attempt); - // Initial turns stay unbounded unless the provider enters - // recovery. Start this deadline once so repeated provider - // retry events cannot extend the conversation lock. - if ( - promptTimeoutMs === null && - providerRetryTimeout === undefined - ) { - providerRetryTimeout = setTimeout(() => { - providerRetryAbortController.abort( - new NonTaskOpenCodePromptTimeoutError( - FAST_AGENT_INFERENCE_RETRY_ATTEMPT_TIMEOUT_MS, - ), - ); - }, FAST_AGENT_INFERENCE_RETRY_ATTEMPT_TIMEOUT_MS); - providerRetryTimeout.unref(); - } - await reportProviderRetryEvent(event); - }, - ...(imageFilesForAttempt.length - ? { - files: imageFilesForAttempt, - requiredInputModality: 'image' as const, + const resultPromise = + generateTrackedNonTaskTextInOpenCodeSession( + { + userId, + surface: + NON_TASK_INFERENCE_SURFACES.fastAgentQuestionAnswering, + modelRole: FAST_AGENT_MODEL_ROLE, + ...(model ? { model } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), + timeoutMs: promptTimeoutMs, + maxProviderRetryAttempts: + FAST_AGENT_INFERENCE_MAX_RETRIES, + system, + prompt: promptForAttempt, + onProviderRetry: async (event) => { + providerRetryEventCount += 1; + captureInferenceContext( + 'provider_retry', + event.attempt, + ); + // Initial turns stay unbounded unless the provider enters + // recovery. Start this deadline once so repeated provider + // retry events cannot extend the conversation lock. + if ( + promptTimeoutMs === null && + providerRetryTimeout === undefined + ) { + providerRetryTimeout = setTimeout(() => { + providerRetryAbortController.abort( + new NonTaskOpenCodePromptTimeoutError( + FAST_AGENT_INFERENCE_RETRY_ATTEMPT_TIMEOUT_MS, + ), + ); + }, FAST_AGENT_INFERENCE_RETRY_ATTEMPT_TIMEOUT_MS); + providerRetryTimeout.unref(); } - : {}), - }, - openCodeSession, - { - directory: nativeRuntime.directory, - env: nativeRuntime.env, - permission: FAST_AGENT_SESSION_PERMISSIONS, - signal: promptSignal, - promptOnlySubagents: true, - trackSessionTreeUsage: true, - validateSession, - tools: buildFastAgentToolFilter( - availableIntegrations.map( - (integration) => integration.id, - ), - ), - onModelResolved: (model) => { - diagnostics.recordModelResolved(model); - }, - onMessageCompleted: (message) => { - completedOpenCodeMessage = message; + await reportProviderRetryEvent(event); + }, + ...(imageFilesForAttempt.length + ? { + files: imageFilesForAttempt, + requiredInputModality: 'image' as const, + } + : {}), }, - onPromptStarted: () => { - diagnostics.markInferenceStarted(); - }, - onSessionReady: async (openCodeSessionID) => { - activeOpenCodeSessionId = openCodeSessionID; - unbindAllExecutors(); - unbindExecutors.add( - bindFastAgentNativeToolExecutor( - openCodeSessionID, - session.id, - executeNativeTool, - { - allowSkillAccess: true, - allowSpillRecovery: true, - skillStore, - spillBudget, - }, + openCodeSession, + { + directory: nativeRuntime.directory, + env: nativeRuntime.env, + permission: FAST_AGENT_SESSION_PERMISSIONS, + signal: promptSignal, + promptOnlySubagents: true, + trackSessionTreeUsage: true, + validateSession, + tools: buildFastAgentToolFilter( + availableIntegrations.map( + (integration) => integration.id, ), - ); - }, - onSubagentSessionReady: (subagentSessionID) => { - if (boundSubagentSessionIDs.has(subagentSessionID)) - return; - boundSubagentSessionIDs.add(subagentSessionID); - unbindExecutors.add( - bindFastAgentNativeToolExecutor( - subagentSessionID, - session.id, - () => - Promise.resolve({ - success: false, - error: - 'That tool is reserved for the Fast parent agent.', - }), - { - allowSkillAccess: false, - allowSpillRecovery: false, - skillStore, - spillBudget, - }, - ), - ); + ), + onModelResolved: (model) => { + resolvedInferenceModel = model; + diagnostics.recordModelResolved(model); + }, + onMessageCompleted: (message) => { + completedOpenCodeMessage = message; + }, + onPromptStarted: () => { + promptStarted = true; + diagnostics.markInferenceStarted(); + }, + onSessionReady: async (openCodeSessionID) => { + activeOpenCodeSessionId = openCodeSessionID; + diagnostics.recordOpenCodeSessionReady( + openCodeSessionID, + ); + unbindAllExecutors(); + unbindExecutors.add( + bindFastAgentNativeToolExecutor( + openCodeSessionID, + session.id, + executeNativeTool, + { + allowSkillAccess: true, + allowSpillRecovery: true, + skillStore, + spillBudget, + }, + ), + ); + }, + onSubagentSessionReady: (subagentSessionID) => { + if (boundSubagentSessionIDs.has(subagentSessionID)) + return; + boundSubagentSessionIDs.add(subagentSessionID); + unbindExecutors.add( + bindFastAgentNativeToolExecutor( + subagentSessionID, + session.id, + () => + Promise.resolve({ + success: false, + error: + 'That tool is reserved for the Fast parent agent.', + }), + { + allowSkillAccess: false, + allowSpillRecovery: false, + skillStore, + spillBudget, + }, + ), + ); + }, }, - }, - ); + ); + const result = await resultPromise; + captureFastAgentInferenceAttemptOutcome({ + userId, + sessionId: session.id, + turnId, + surface: conversation.surface, + sessionPath: attemptSessionPath, + promptKind, + attemptNumber: inferenceAttemptNumber, + outcome: 'success', + stage: !resolvedInferenceModel + ? 'model_resolution' + : promptStarted + ? 'model_generation' + : 'opencode_setup', + elapsedMs: Date.now() - attemptStartedAt, + resolvedModel: resolvedInferenceModel, + providerRetryEventCount, + }); + return result; + } catch (error) { + const failure = classifyNonTaskInferenceError(error); + const attemptStage = !resolvedInferenceModel + ? 'model_resolution' + : promptStarted + ? 'model_generation' + : 'opencode_setup'; + const attemptElapsedMs = Date.now() - attemptStartedAt; + captureFastAgentInferenceAttemptOutcome({ + userId, + sessionId: session.id, + turnId, + surface: conversation.surface, + sessionPath: attemptSessionPath, + promptKind, + attemptNumber: inferenceAttemptNumber, + outcome: 'failure', + stage: attemptStage, + elapsedMs: attemptElapsedMs, + failureReason: failure.reason, + failureRetryable: failure.retryable, + resolvedModel: resolvedInferenceModel, + providerRetryEventCount, + }); + diagnostics.recordInferenceAttemptFailure({ + attemptNumber: inferenceAttemptNumber, + promptKind, + stage: attemptStage, + elapsedMs: attemptElapsedMs, + reason: failure.reason, + retryable: failure.retryable, + providerRetryEventCount, + error, + }); + throw error; } finally { if (providerRetryTimeout) { clearTimeout(providerRetryTimeout); @@ -2059,6 +2132,8 @@ export async function answerFastAgentQuestion({ promptForAttempt = serializedBootstrapPrompt; imageFilesForAttempt = imageFiles; promptKind = 'clean_retry_bootstrap'; + attemptSessionPath = 'cold_rebuild'; + diagnostics.recordSessionPath(attemptSessionPath); } // Keep every recovery attempt bounded so it cannot hold the // conversation lock forever if the provider stalls again. diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-turn-diagnostics.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-turn-diagnostics.ts index fce276ceb..e76274b99 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-turn-diagnostics.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-turn-diagnostics.ts @@ -61,6 +61,8 @@ export class FastAgentTurnDiagnostics { private terminalError: unknown; private visibleReplyCount = 0; private resolvedModel: string | undefined; + private sessionPath: string | undefined; + private openCodeSessionId: string | undefined; private inferenceQueuedAt: number | undefined; private inferenceSetupStartedAt: number | undefined; private inferenceStartedAt: number | undefined; @@ -105,6 +107,14 @@ export class FastAgentTurnDiagnostics { this.resolvedModel = model; } + recordSessionPath(path: string): void { + this.sessionPath = path; + } + + recordOpenCodeSessionReady(sessionId: string): void { + this.openCodeSessionId = sessionId; + } + markInferenceQueued(): void { this.inferenceQueuedAt ??= this.now(); } @@ -128,7 +138,7 @@ export class FastAgentTurnDiagnostics { } } - recordOpenCodeProviderRetry(attempt: number): void { + recordOpenCodeProviderRetry(attempt: number, error?: unknown): void { this.openCodeProviderRetryEventCount += 1; this.lastOpenCodeProviderRetryAttempt = attempt; @@ -139,6 +149,52 @@ export class FastAgentTurnDiagnostics { const elapsedMs = this.now() - this.inferenceStartedAt; this.firstOpenCodeProviderRetryElapsedMs ??= elapsedMs; this.lastOpenCodeProviderRetryElapsedMs = elapsedMs; + + this.logger.warn( + formatSingleLineLog('[Fast Agent] OpenCode provider retry.', { + surface: this.context.conversation.surface, + conversationId: this.context.conversation.conversationId, + messageId: this.context.currentMessageId, + canonicalConversationId: this.canonicalConversationId, + sessionPath: this.sessionPath, + openCodeSessionId: this.openCodeSessionId, + resolvedModel: this.resolvedModel, + attempt, + elapsedMs, + error: error === undefined ? undefined : formatTerminalError(error), + }), + ); + } + + recordInferenceAttemptFailure(input: { + attemptNumber: number; + promptKind: string; + stage: string; + elapsedMs: number; + reason: string; + retryable: boolean; + providerRetryEventCount: number; + error: unknown; + }): void { + this.logger.warn( + formatSingleLineLog('[Fast Agent] Inference attempt failed.', { + surface: this.context.conversation.surface, + conversationId: this.context.conversation.conversationId, + messageId: this.context.currentMessageId, + canonicalConversationId: this.canonicalConversationId, + sessionPath: this.sessionPath, + openCodeSessionId: this.openCodeSessionId, + resolvedModel: this.resolvedModel, + attemptNumber: input.attemptNumber, + promptKind: input.promptKind, + stage: input.stage, + elapsedMs: input.elapsedMs, + reason: input.reason, + retryable: input.retryable, + providerRetryEventCount: input.providerRetryEventCount, + error: formatTerminalError(input.error), + }), + ); } recordRoomoteInferenceRetry(): void { @@ -219,6 +275,8 @@ export class FastAgentTurnDiagnostics { turnSource: this.context.turnSource, modelRole: this.context.modelRole, resolvedModel: this.resolvedModel, + sessionPath: this.sessionPath, + openCodeSessionId: this.openCodeSessionId, release: this.deployMarker.roomote_release, releaseSource: this.deployMarker.roomote_release_source, outcome: this.failed ? 'failure' : 'success', @@ -252,6 +310,10 @@ export class FastAgentTurnDiagnostics { this.lastOpenCodeProviderRetryElapsedMs, lastOpenCodeProviderRetryAttempt: this.lastOpenCodeProviderRetryAttempt, roomoteInferenceRetryCount: this.roomoteInferenceRetryCount, + recoveredAfterOpenCodeProviderRetry: + !this.failed && this.openCodeProviderRetryEventCount > 0, + recoveredAfterRoomoteInferenceRetry: + !this.failed && this.roomoteInferenceRetryCount > 0, nativeToolCallCount: this.nativeToolCallCount, completedNativeToolCallCount, nativeToolStats: From 86278c3e87d6987feb38d858899851c02ad68785 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:26:12 +0000 Subject: [PATCH 014/158] [Fix] Fast conversations lose context after cold starts (#1592) * fix: preserve Fast sessions across cold starts * chore: keep Fast session path type internal --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> From 76c11024ea0af9e8f8229d8e42c1dfe470647e55 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 28 Aug 2026 02:49:12 -0400 Subject: [PATCH 015/158] Run Brain synthesis on the helper model; make Memory a settings toggle (#1771) --- .docker/gbrain/entrypoint.sh | 15 +- .../__tests__/brain-inference.test.ts | 170 +- .../api/src/handlers/brain-inference/index.ts | 219 +- apps/api/src/handlers/tasks/saveTaskMemory.ts | 8 +- apps/docs/memory.mdx | 46 +- .../brain/BrainConfigurationSection.tsx | 92 - .../settings/brain/BrainEnableSection.tsx | 56 + .../BrainSettings.render.client.test.tsx | 64 +- .../settings/brain/BrainSettings.tsx | 18 +- .../settings/settings-navigation.ts | 2 +- apps/web/src/lib/server/auth-context.test.ts | 3 +- apps/web/src/lib/server/auth-context.ts | 9 +- apps/web/src/lib/server/env.ts | 2 + apps/web/src/trpc/commands/brain/index.ts | 52 +- .../trpc/commands/brain/set-enabled.test.ts | 41 + apps/web/src/trpc/routers/_app.ts | 7 + .../__tests__/fast-agent-service.test.ts | 10 +- .../server/fast-agent/fast-agent-service.ts | 4 +- .../src/server/non-task-provider-usage.ts | 1 + packages/db/drizzle/0063_organic_garia.sql | 1 + packages/db/drizzle/meta/0063_snapshot.json | 13215 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/lib/model-runtime-config.test.ts | 77 + packages/db/src/lib/model-runtime-config.ts | 84 +- packages/db/src/schema.ts | 6 + packages/env/src/index.ts | 10 +- packages/sdk/src/server/lib/brain-clients.ts | 11 +- .../server/routers/mcp-connections.test.ts | 16 +- .../sdk/src/server/routers/mcp-connections.ts | 8 +- 29 files changed, 14058 insertions(+), 196 deletions(-) delete mode 100644 apps/web/src/components/settings/brain/BrainConfigurationSection.tsx create mode 100644 apps/web/src/components/settings/brain/BrainEnableSection.tsx create mode 100644 apps/web/src/trpc/commands/brain/set-enabled.test.ts create mode 100644 packages/db/drizzle/0063_organic_garia.sql create mode 100644 packages/db/drizzle/meta/0063_snapshot.json diff --git a/.docker/gbrain/entrypoint.sh b/.docker/gbrain/entrypoint.sh index f0a1f796c..0abacb3f7 100644 --- a/.docker/gbrain/entrypoint.sh +++ b/.docker/gbrain/entrypoint.sh @@ -175,13 +175,26 @@ elif [ -n "${OPENAI_API_KEY:-}" ]; then DEFAULT_CHAT_MODEL="openai:gpt-5.6-luna" else # No credential: the server still boots and serves, it just cannot embed or - # synthesize. Roomote gates every Brain code path on the same keys, so it + # synthesize. Roomote gates every Brain code path on the same signal, so it # will not talk to this container either. BRAIN_PROVIDER="none" DEFAULT_EMBEDDING_MODEL="" DEFAULT_CHAT_MODEL="" fi +# Gateway mode holds no real provider key, so chat defaults to the +# `roomote/helper` sentinel: the Roomote gateway answers it with the +# deployment's helper model instead of forwarding to a provider, which is what +# frees synthesis from needing a Brain provider key. Convergence rule: the +# chat model is a plain env export re-derived on every boot (env wins over +# anything the brain stored at init), so a brain that previously defaulted to +# gpt-5.6-luna picks this up on its next boot — while an operator's explicit +# GBRAIN_MODEL (below) or R_BRAIN_MODEL (applied by the gateway per request) +# still wins over the default. +if [ -n "${OPENAI_BASE_URL:-}" ] && [ "$BRAIN_PROVIDER" != "none" ]; then + DEFAULT_CHAT_MODEL="${BRAIN_PROVIDER}:roomote/helper" +fi + # An operator-chosen embedding model arrives as a bare id (text-embedding-3-large) # because it is written once and must survive a provider switch; gbrain wants # it provider-qualified. Qualify it with whichever provider this container diff --git a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts index b24f66d26..b2a464270 100644 --- a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts +++ b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts @@ -6,11 +6,13 @@ const { mockGetBrainGatewayToken, mockResolveBrainInferenceProvider, mockMapBrainModelName, + mockGenerateTrackedNonTaskText, mockEnv, } = vi.hoisted(() => ({ mockGetBrainGatewayToken: vi.fn(), mockResolveBrainInferenceProvider: vi.fn(), mockMapBrainModelName: vi.fn(), + mockGenerateTrackedNonTaskText: vi.fn(), mockEnv: {} as Record, })); @@ -22,7 +24,12 @@ vi.mock('@roomote/sdk/server', () => ({ mapBrainModelName: mockMapBrainModelName, })); -const { brainInference } = await import('../index'); +vi.mock('@roomote/cloud-agents/server/non-task-provider-usage', () => ({ + generateTrackedNonTaskText: mockGenerateTrackedNonTaskText, + NON_TASK_INFERENCE_SURFACES: { brainSynthesis: 'brain_synthesis' }, +})); + +const { brainInference, BRAIN_HELPER_MODEL_ID } = await import('../index'); const GATEWAY_TOKEN = 'brain-gateway-token-value-0123456789'; @@ -288,3 +295,164 @@ describe('local inference upstreams', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('helper-model synthesis', () => { + it('answers the sentinel with the deployment helper model, never a provider', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + mockGenerateTrackedNonTaskText.mockResolvedValue('a sourced answer'); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + max_tokens: 512, + messages: [ + { role: 'system', content: 'You cite sources.' }, + { role: 'user', content: 'What changed last week?' }, + { role: 'assistant', content: 'Let me look.' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Focus on the API.' }, + { type: 'image_url', image_url: { url: 'ignored' } }, + ], + }, + ], + }, + }); + + expect(response.status).toBe(200); + const payload = (await response.json()) as Record; + expect(payload).toMatchObject({ + object: 'chat.completion', + model: BRAIN_HELPER_MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'a sourced answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); + expect(payload.id).toMatch(/^brain-helper-/); + + expect(mockGenerateTrackedNonTaskText).toHaveBeenCalledExactlyOnceWith({ + surface: 'brain_synthesis', + modelRole: 'small', + system: 'You cite sources.', + prompt: + 'What changed last week?\n\nAssistant: Let me look.\n\nFocus on the API.', + maxOutputTokens: 512, + timeoutMs: 120_000, + }); + + // No provider must be involved: this path exists for deployments with no + // Brain provider key at all. + expect(mockResolveBrainInferenceProvider).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects streaming sentinel requests instead of faking SSE', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('streaming'), + }); + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('translates response_format into a strict JSON instruction', async () => { + mockGenerateTrackedNonTaskText.mockResolvedValue('{"ok":true}'); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'summarize' }], + response_format: { + type: 'json_schema', + json_schema: { name: 'summary', schema: { type: 'object' } }, + }, + }, + }); + + expect(response.status).toBe(200); + const call = mockGenerateTrackedNonTaskText.mock.calls[0]![0] as { + system?: string; + }; + expect(call.system).toContain('only valid JSON'); + expect(call.system).toContain('"type":"object"'); + }); + + it('reports a helper failure as 502 without a stack', async () => { + mockGenerateTrackedNonTaskText.mockRejectedValue( + new Error('Model configuration is required\nfor non-task model calls.'), + ); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(502); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('Brain helper-model synthesis failed'); + expect(payload.error).not.toContain('\n'); + }); + + it('forwards to the provider when R_BRAIN_MODEL overrides the sentinel', async () => { + mockEnv.R_BRAIN_MODEL = 'openai/gpt-5.6-mini'; + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ choices: [] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(200); + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]![1]; + expect(JSON.parse(init.body as string).model).toBe('openai/gpt-5.6-mini'); + }); + + it('leaves non-sentinel chat models on the provider path', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ choices: [] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { model: 'openai/gpt-5.6-luna', messages: [] }, + }); + + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(mockResolveBrainInferenceProvider).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/brain-inference/index.ts b/apps/api/src/handlers/brain-inference/index.ts index f97043588..fe6d8f7b8 100644 --- a/apps/api/src/handlers/brain-inference/index.ts +++ b/apps/api/src/handlers/brain-inference/index.ts @@ -1,7 +1,11 @@ -import { timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; +import { + generateTrackedNonTaskText, + NON_TASK_INFERENCE_SURFACES, +} from '@roomote/cloud-agents/server/non-task-provider-usage'; import { Env } from '@roomote/env'; import { @@ -19,6 +23,24 @@ import type { Variables } from '../../types'; const LOG_PREFIX = '[Brain Inference]'; +/** + * Sentinel chat-model id the Brain requests in gateway mode. It is not a real + * provider model: the gateway answers it itself through the deployment's + * helper ("small") model, which is what lets a Brain synthesize without any + * Brain-specific provider key. An operator's `R_BRAIN_MODEL` still wins — the + * sentinel is only the default the gbrain entrypoint configures. + */ +export const BRAIN_HELPER_MODEL_ID = 'roomote/helper'; + +/** How long a helper-model synthesis call may run before failing the request. */ +const HELPER_SYNTHESIS_TIMEOUT_MS = 120_000; + +/** + * gbrain caps its own synthesis output; when it does not say, stay modest — + * the helper model is a summarizer, not a long-form writer. + */ +const HELPER_SYNTHESIS_DEFAULT_MAX_OUTPUT_TOKENS = 2048; + /** * The Brain's whole inference surface: embeddings for recall and chat for * sourced synthesis and query expansion. Deliberately narrower than the @@ -146,6 +168,95 @@ function resolveLocalUpstream( }; } +/** + * Flatten OpenAI-style message content to plain text. Array content keeps its + * text parts (joined) and drops the rest; the helper path is text-only. + */ +function messageContentText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .flatMap((part) => { + const record = + part && typeof part === 'object' + ? (part as Record) + : undefined; + + return typeof record?.text === 'string' ? [record.text] : []; + }) + .join('\n'); + } + + return ''; +} + +/** + * Convert an OpenAI chat request into the system/prompt pair + * generateTrackedNonTaskText speaks. System messages concatenate into the + * system string; everything else concatenates in order into the prompt, with + * non-user roles labeled so multi-turn context stays attributable. + */ +function toHelperPromptParts(messages: unknown): { + system: string; + prompt: string; +} { + const systemParts: string[] = []; + const promptParts: string[] = []; + + for (const message of Array.isArray(messages) ? messages : []) { + const record = + message && typeof message === 'object' + ? (message as Record) + : undefined; + const role = typeof record?.role === 'string' ? record.role : 'user'; + const text = messageContentText(record?.content); + + if (!text.trim()) { + continue; + } + + if (role === 'system') { + systemParts.push(text); + } else if (role === 'user') { + promptParts.push(text); + } else { + promptParts.push( + `${role.charAt(0).toUpperCase()}${role.slice(1)}: ${text}`, + ); + } + } + + return { + system: systemParts.join('\n\n'), + prompt: promptParts.join('\n\n'), + }; +} + +/** + * gbrain relies on `response_format` for its structured synthesis calls, but + * the helper path runs through a plain-text prompt; translate the contract + * into a strict instruction instead of dropping it silently. + */ +function jsonResponseInstruction(responseFormat: unknown): string | null { + const record = + responseFormat && typeof responseFormat === 'object' + ? (responseFormat as Record) + : undefined; + + if (record?.type === 'json_object') { + return 'Respond with only valid JSON. No prose, no code fences.'; + } + + if (record?.type === 'json_schema') { + return `Respond with only valid JSON that conforms to this JSON Schema. No prose, no code fences.\n${JSON.stringify(record.json_schema ?? {})}`; + } + + return null; +} + /** * Inference gateway for this deployment's Brain. * @@ -193,6 +304,107 @@ brainInference.post('/*', async (c) => { return c.json({ error: 'Path is not allowed through this gateway' }, 403); } + // The helper-model sentinel is answered here, before provider resolution, + // because it exists precisely for deployments with no Brain provider key: + // synthesis rides the deployment's helper model instead. An operator's + // R_BRAIN_MODEL still wins — the sentinel is rewritten to it and forwarded + // through the ordinary provider path below. + let helperOverrideBody: string | undefined; + + if (upstreamPath === '/v1/chat/completions') { + let parsedBody: Record | undefined; + + try { + const candidate = JSON.parse(await c.req.text()) as unknown; + + parsedBody = + candidate && typeof candidate === 'object' && !Array.isArray(candidate) + ? (candidate as Record) + : undefined; + } catch { + // Not JSON we understand; the provider path forwards it untouched. + } + + if (parsedBody?.model === BRAIN_HELPER_MODEL_ID) { + const overrideModel = Env.R_BRAIN_MODEL?.trim(); + + if (overrideModel) { + helperOverrideBody = JSON.stringify({ + ...parsedBody, + model: overrideModel, + }); + } else { + if (parsedBody.stream === true) { + // gbrain's gateway chat is non-streaming by design; refuse rather + // than pretend an SSE stream that would never come. + return c.json( + { + error: + 'The Brain helper model does not support streaming. Retry without stream.', + }, + 400, + ); + } + + const { system, prompt } = toHelperPromptParts(parsedBody.messages); + const jsonInstruction = jsonResponseInstruction( + parsedBody.response_format, + ); + const systemWithFormat = [system, jsonInstruction] + .filter((part): part is string => Boolean(part)) + .join('\n\n'); + + try { + const text = await generateTrackedNonTaskText({ + surface: NON_TASK_INFERENCE_SURFACES.brainSynthesis, + modelRole: 'small', + system: systemWithFormat || undefined, + prompt, + maxOutputTokens: + typeof parsedBody.max_tokens === 'number' && + Number.isFinite(parsedBody.max_tokens) && + parsedBody.max_tokens > 0 + ? parsedBody.max_tokens + : HELPER_SYNTHESIS_DEFAULT_MAX_OUTPUT_TOKENS, + timeoutMs: HELPER_SYNTHESIS_TIMEOUT_MS, + }); + + return c.json({ + id: `brain-helper-${randomUUID()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: BRAIN_HELPER_MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + // Advisory only: gbrain logs usage but never bills from it, and + // the real usage is already recorded by the tracked call above. + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); + } catch (error) { + const detail = ( + error instanceof Error ? error.message : String(error) + ).replace(/\s+/g, ' '); + + console.warn( + formatSingleLineLog(`${LOG_PREFIX} Helper synthesis failed`, { + error: detail, + }), + ); + + return c.json( + { error: `Brain helper-model synthesis failed: ${detail}` }, + 502, + ); + } + } + } + } + const localUpstream = resolveLocalUpstream(upstreamPath); if (localUpstream) { @@ -292,7 +504,10 @@ brainInference.post('/*', async (c) => { : resolved.apiKey, ); - const body = await rewriteBody(await c.req.text(), resolved); + const body = await rewriteBody( + helperOverrideBody ?? (await c.req.text()), + resolved, + ); const startedAt = Date.now(); let upstream: Response; diff --git a/apps/api/src/handlers/tasks/saveTaskMemory.ts b/apps/api/src/handlers/tasks/saveTaskMemory.ts index b7e59fa16..527db7292 100644 --- a/apps/api/src/handlers/tasks/saveTaskMemory.ts +++ b/apps/api/src/handlers/tasks/saveTaskMemory.ts @@ -1,11 +1,7 @@ import type { Context } from 'hono'; import { z } from 'zod'; -import { - db, - isBrainProviderConfigured, - saveBrainAgentSummary, -} from '@roomote/db/server'; +import { db, isBrainEnabled, saveBrainAgentSummary } from '@roomote/db/server'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; @@ -80,7 +76,7 @@ export async function saveTaskMemory( ); } - if (!(await isBrainProviderConfigured())) { + if (!(await isBrainEnabled())) { return c.json( { saved: false, reason: 'This deployment has no Brain configured.' }, 200, diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index e99416b21..f37ab7ef1 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -87,20 +87,23 @@ Memory runs as its own service alongside Roomote, reachable only on your deployment's internal network. On the hosted templates (Railway, Render, Coolify) that service is already there after a deploy, sitting idle. -Setting a Memory provider key, `R_BRAIN_OPENROUTER_API_KEY` or -`R_BRAIN_OPENAI_API_KEY`, is what turns it on. Set either one as an -environment variable or a Settings environment value; the value can be the -same key you already use for tasks, or a separate one to bill Memory -independently. The explicit `R_BRAIN_*` name is the opt-in: the general -provider keys your deployment uses for tasks never activate Memory on -their own, so configuring task models leaves Memory off. - -The Memory service holds no provider key of its own. It asks Roomote for -embeddings and synthesis, and Roomote forwards them under the Memory key. -Changing that key later takes effect on Memory's next request, with no -redeploy. - -OpenRouter and OpenAI both support Memory's embedding and synthesis calls. +The **Enable Memory** toggle at the top of **Settings → Memory** is what +turns it on; no provider key is required for that. Synthesis runs through +your deployment's helper model — the same small model that already writes +task titles and summaries. Deployments that enabled Memory before the toggle +existed, by setting `R_BRAIN_OPENROUTER_API_KEY` or `R_BRAIN_OPENAI_API_KEY`, +stay enabled without doing anything; using the toggle stores an explicit +choice that wins over the key from then on. + +Semantic recall still needs embeddings. Those come from an OpenRouter or +OpenAI key — a Memory-specific `R_BRAIN_*` key to bill Memory separately, or +the deployment's general provider key once Memory is enabled — or from a +self-run embeddings upstream (below). The Memory service holds no provider +key of its own: it asks Roomote for embeddings and synthesis, and Roomote +forwards them. Changing a key later takes effect on Memory's next request, +with no redeploy. + +OpenRouter and OpenAI both support Memory's embedding calls. ### Run embeddings locally @@ -127,7 +130,7 @@ key. Roomote forwards model names unchanged to self-run upstreams, so `R_BRAIN_EMBEDDING_MODEL` must exactly match a model that server exposes, without a provider prefix. -Without a Memory key, Memory stays inert. Agents are not told it exists, +While Memory is disabled, it stays inert. Agents are not told it exists, and nothing is ingested. Roomote schedules one maintenance pass each night. It retrieves a bounded, @@ -156,9 +159,10 @@ that upstream feature requires an operator review workflow before proposals become canonical memory. - Self-hosted Compose deployments start Memory from the `brain` - profile, so set a Memory key in your environment file to bring the container - up. Everything after that is the same. + Self-hosted Compose deployments start Memory from the `brain` profile, so + add `brain` to `COMPOSE_PROFILES` in your environment file to bring the + container up, then enable Memory in Settings. Everything after that is the + same. ## Seeing what it knows @@ -294,6 +298,6 @@ matching on keywords alone. - **Memory has no public service route.** It is never exposed to the internet, and task sandboxes reach it only through Roomote's API with their run token, which grants read access only. -- **To run with no memory at all**, leave both provider keys unset. Deployments - that want to reclaim the resources entirely can delete the Memory service - from their compose file or template. +- **To run with no memory at all**, leave Memory disabled in Settings. + Deployments that want to reclaim the resources entirely can delete the + Memory service from their compose file or template. diff --git a/apps/web/src/components/settings/brain/BrainConfigurationSection.tsx b/apps/web/src/components/settings/brain/BrainConfigurationSection.tsx deleted file mode 100644 index f084ada5e..000000000 --- a/apps/web/src/components/settings/brain/BrainConfigurationSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { Section } from '@/components/settings'; -import { Badge, Lock, Settings2 } from '@/components/system'; - -import type { BrainSettings } from '@/trpc/commands/brain'; -import { BRAIN_INFERENCE_PROVIDER_LABELS } from './brain-presentation'; - -function ConfigRow({ - label, - children, - helper, -}: { - label: string; - children: React.ReactNode; - helper: string; -}) { - return ( -
- {label} -
-
- {children} -
-

{helper}

-
-
- ); -} - -/** - * What the Brain runs on, read-only by design. The provider key lives with - * the other model credentials in Settings, the synthesis model is an env - * override applied at forward time, and the embedding model was fixed when - * the Brain was created. A page that displayed editable controls for values - * it cannot change would be lying about where the levers are. - */ -export function BrainConfigurationSection({ - settings, -}: { - settings: BrainSettings; -}) { - const providerLabel = settings.inferenceProvider - ? BRAIN_INFERENCE_PROVIDER_LABELS[settings.inferenceProvider] - : null; - - return ( -
-
- - - {settings.models?.synthesisModel ?? 'Not resolved'} - - {settings.models?.synthesisSource === 'override' ? ( - Override - ) : null} - - - - - - {settings.models?.embeddingModel ?? 'Not resolved'} - - {settings.models?.embeddingDimensions ? ( - - {settings.models.embeddingDimensions.toLocaleString()} dimensions - - ) : null} - - - - - {providerLabel - ? settings.keySource === 'brain' - ? `Memory-specific ${providerLabel} key` - : `The deployment's ${providerLabel} key` - : 'No provider key resolves'} - - -
-
- ); -} diff --git a/apps/web/src/components/settings/brain/BrainEnableSection.tsx b/apps/web/src/components/settings/brain/BrainEnableSection.tsx new file mode 100644 index 000000000..dec94a4b2 --- /dev/null +++ b/apps/web/src/components/settings/brain/BrainEnableSection.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { Section } from '@/components/settings'; +import { BookOpenText, Switch } from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +import type { BrainSettings } from '@/trpc/commands/brain'; + +export function BrainEnableSection({ settings }: { settings: BrainSettings }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const brainQueryKey = trpc.brain.get.queryKey(); + + const setEnabled = useMutation( + trpc.brain.setMemoryEnabled.mutationOptions({ + onSuccess: ({ enabled }) => { + void queryClient.invalidateQueries({ queryKey: brainQueryKey }); + toast.success(enabled ? 'Memory enabled.' : 'Memory disabled.'); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( +
+
+ + setEnabled.mutate({ enabled: checked === true }) + } + /> +
+
Enable Memory
+

+ {settings.enabled + ? 'Agents share one deployment-wide memory: completed tasks, pull requests, and connected sources are ingested, and agents recall them before they start.' + : 'Memory is off. Agents are not told it exists and nothing is ingested. Sources hold their position, so turning it back on resumes where they left off.'} +

+ {settings.enabledFromLegacyKey ? ( +

+ Currently enabled by a configured Memory provider key (R_BRAIN_*). + Using this toggle stores an explicit choice that wins over the + key. +

+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/settings/brain/BrainSettings.render.client.test.tsx b/apps/web/src/components/settings/brain/BrainSettings.render.client.test.tsx index b81c4af1e..9528c23db 100644 --- a/apps/web/src/components/settings/brain/BrainSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/brain/BrainSettings.render.client.test.tsx @@ -20,6 +20,7 @@ const { state, navigation, mutations } = vi.hoisted(() => ({ mutations: { backfill: vi.fn(), retryFailed: vi.fn(), + setEnabled: vi.fn(), }, })); @@ -65,7 +66,9 @@ vi.mock('@tanstack/react-query', () => ({ useMutation: (options: { mutationKind?: string }) => options.mutationKind === 'retryFailed' ? { isPending: false, mutate: mutations.retryFailed } - : { isPending: false, mutate: mutations.backfill }, + : options.mutationKind === 'setEnabled' + ? { isPending: false, mutate: mutations.setEnabled } + : { isPending: false, mutate: mutations.backfill }, })); vi.mock('@/trpc/client', () => ({ @@ -93,6 +96,9 @@ vi.mock('@/trpc/client', () => ({ retryFailedTaskMemories: { mutationOptions: () => ({ mutationKind: 'retryFailed' }), }, + setMemoryEnabled: { + mutationOptions: () => ({ mutationKind: 'setEnabled' }), + }, }, }), })); @@ -105,16 +111,12 @@ function buildSettings( return { status: 'connected', statusDetail: null, + enabled: true, + enabledFromLegacyKey: false, url: 'http://gbrain:8080', inferenceProvider: 'openrouter', keySource: 'brain', recall: { mode: 'semantic', embeddedCount: 771, chunkCount: 771 }, - models: { - synthesisModel: 'openai/gpt-5.6-luna', - synthesisSource: 'default', - embeddingModel: 'openai/text-embedding-3-small', - embeddingDimensions: 1536, - }, corpus: { reachable: true, listedPages: 30, @@ -173,10 +175,11 @@ beforeEach(() => { navigation.replace.mockClear(); mutations.backfill.mockClear(); mutations.retryFailed.mockClear(); + mutations.setEnabled.mockClear(); }); describe('BrainSettings', () => { - it('shows Memory stats, the embedded browser, sources, and configuration', () => { + it('shows Memory stats, the embedded browser, and sources', () => { render(); expect(screen.getAllByText('Connected')).toHaveLength(1); @@ -185,12 +188,10 @@ describe('BrainSettings', () => { expect(screen.getByText('OpenRouter')).toBeInTheDocument(); expect(screen.getByText('Semantic + keyword')).toBeInTheDocument(); - expect(screen.getByText('Configuration')).toBeInTheDocument(); - expect(screen.getByText('openai/gpt-5.6-luna')).toBeInTheDocument(); - expect( - screen.getByText('openai/text-embedding-3-small'), - ).toBeInTheDocument(); - expect(screen.queryByText('Manage in Models')).not.toBeInTheDocument(); + // The model Configuration section is gone: the synthesis model is the + // deployment helper model and the embedding pair is create-time — neither + // is a per-page setting worth displaying here. + expect(screen.queryByText('Configuration')).not.toBeInTheDocument(); expect(screen.getByText('Memory Stats')).toBeInTheDocument(); expect(screen.getByText('30 memories')).toBeInTheDocument(); @@ -218,13 +219,9 @@ describe('BrainSettings', () => { const memoryStats = screen.getByText('Memory Stats'); const browser = screen.getByText('Explore memories'); - const configuration = screen.getByText('Configuration'); expect(memoryStats.compareDocumentPosition(browser)).toBe( Node.DOCUMENT_POSITION_FOLLOWING, ); - expect(browser.compareDocumentPosition(configuration)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ); }); it('puts actionable memory issues first and keeps their repair controls', () => { @@ -356,6 +353,37 @@ describe('BrainSettings', () => { expect(screen.queryByText('Notion')).not.toBeInTheDocument(); }); + it('shows only the toggle and its explanation while Memory is disabled', () => { + state.query.data = buildSettings({ + enabled: false, + status: 'not_configured', + statusDetail: 'Memory is turned off for this deployment.', + }); + + render(); + + expect( + screen.getByRole('switch', { name: 'Enable Memory' }), + ).toBeInTheDocument(); + expect(screen.getByText(/Memory is off/)).toBeInTheDocument(); + expect(screen.queryByText('Memory Stats')).not.toBeInTheDocument(); + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + expect(screen.queryByText('Sources')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('switch', { name: 'Enable Memory' })); + expect(mutations.setEnabled).toHaveBeenCalledWith({ enabled: true }); + }); + + it('notes when enablement still comes from the legacy provider key', () => { + state.query.data = buildSettings({ enabledFromLegacyKey: true }); + + render(); + + expect( + screen.getByText(/enabled by a configured Memory provider key/), + ).toBeInTheDocument(); + }); + it('stops at the explanation on a deployment with no Brain', () => { state.query.data = buildSettings({ status: 'not_configured', diff --git a/apps/web/src/components/settings/brain/BrainSettings.tsx b/apps/web/src/components/settings/brain/BrainSettings.tsx index be6548e42..52dc30b25 100644 --- a/apps/web/src/components/settings/brain/BrainSettings.tsx +++ b/apps/web/src/components/settings/brain/BrainSettings.tsx @@ -7,9 +7,9 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { ErrorState, Skeleton } from '@/components/system'; import { useTRPC } from '@/trpc/client'; -import { BrainConfigurationSection } from './BrainConfigurationSection'; import { BrainCorpusSection } from './BrainCorpusSection'; import { BrainBrowseSection } from './BrainBrowseSection'; +import { BrainEnableSection } from './BrainEnableSection'; import { BrainMemoryIssuesSection } from './BrainMemoryIssuesSection'; import { BrainSourcesSection } from './BrainSourcesSection'; import { BrainStatusSection } from './BrainStatusSection'; @@ -67,6 +67,19 @@ export function BrainSettings() { return ; } + /* + * A disabled Brain has nothing worth reading: the toggle plus its short + * explanation is the whole page, because rendering empty stats and status + * sections would read as breakage rather than as an off switch. + */ + if (!data.enabled) { + return ( +
+ +
+ ); + } + /* * A deployment without a Brain has no corpus, no collector checkpoints, * and no outbox worth reading: those sections would all render the same @@ -78,6 +91,7 @@ export function BrainSettings() { if (data.status === 'not_configured' || !data.url) { return (
+
); @@ -85,6 +99,7 @@ export function BrainSettings() { return (
+ -
); } diff --git a/apps/web/src/components/settings/settings-navigation.ts b/apps/web/src/components/settings/settings-navigation.ts index 70498e981..b986230a0 100644 --- a/apps/web/src/components/settings/settings-navigation.ts +++ b/apps/web/src/components/settings/settings-navigation.ts @@ -41,7 +41,7 @@ type SettingsNavigationItem = { icon: LucideIcon; adminOnly?: boolean; hiddenWhenCloud?: boolean; - /** Shown only on deployments that have enabled Memory. */ + /** Shown only on deployments where Memory is wired or enabled. */ requiresBrain?: boolean; newGroup?: boolean; matches: (pathname: string) => boolean; diff --git a/apps/web/src/lib/server/auth-context.test.ts b/apps/web/src/lib/server/auth-context.test.ts index 524ce83f8..70407377e 100644 --- a/apps/web/src/lib/server/auth-context.test.ts +++ b/apps/web/src/lib/server/auth-context.test.ts @@ -41,7 +41,7 @@ vi.mock('next/headers', () => ({ vi.mock('@roomote/db/server', () => ({ recordLicenseUsageObservation: vi.fn(async () => undefined), - isBrainProviderConfigured: vi.fn(async () => false), + isBrainEnabled: vi.fn(async () => false), db: { query: { deploymentSettings: { @@ -107,6 +107,7 @@ vi.mock('./env', () => ({ Env: { R_ALLOWED_EMAILS: '', }, + isBrainConfigured: () => false, isRoomoteCloudEnabled: () => false, })); diff --git a/apps/web/src/lib/server/auth-context.ts b/apps/web/src/lib/server/auth-context.ts index 29f54de87..7669aa408 100644 --- a/apps/web/src/lib/server/auth-context.ts +++ b/apps/web/src/lib/server/auth-context.ts @@ -6,7 +6,7 @@ import { deploymentSettings, eq, invites, - isBrainProviderConfigured, + isBrainEnabled, recordLicenseUsageObservation, users, } from '@roomote/db/server'; @@ -30,7 +30,7 @@ import { } from '@/types'; import { bootstrapWebRuntimeEnv } from './bootstrap-runtime-env'; -import { Env, isRoomoteCloudEnabled } from './env'; +import { Env, isBrainConfigured, isRoomoteCloudEnabled } from './env'; import { setSentryUserContext } from './sentry-context'; import { getAuth } from './auth'; import { @@ -443,7 +443,10 @@ export async function authorize(): Promise { featureFlags, anonymousAnalyticsEnabled, cloudEnabled: isRoomoteCloudEnabled(Env.R_CLOUD_ENABLED), - brainConfigured: await isBrainProviderConfigured(), + // Drives the Memory item in Settings navigation. Wiring presence, not + // just the effective toggle: an admin must be able to reach the Memory + // page to turn a wired-but-disabled Brain on. + brainConfigured: isBrainConfigured(Env) || (await isBrainEnabled()), cookieConsentedAt: authContext.cookieConsentedAt, managedAccess: getManagedDeploymentAccessFromMetadata( authContext.deploymentMetadata, diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts index 9a6188d20..dcf197410 100644 --- a/apps/web/src/lib/server/env.ts +++ b/apps/web/src/lib/server/env.ts @@ -14,6 +14,7 @@ import { assertSecureBootBinding as sharedAssertSecureBootBinding, getWebBundledEnvFilePaths as getSharedWebBundledEnvFilePaths, getWebEnvFilePaths as getSharedWebEnvFilePaths, + isBrainConfigured, isEnvFlagEnabled, isExposedBindHost, isRoomoteCloudEnabled, @@ -202,6 +203,7 @@ export { getArtifactSigningKey, getArtifactSigningKeyPrevious, getBetterAuthSecret, + isBrainConfigured, isEnvFlagEnabled, isRoomoteCloudEnabled, resolveAppEnv, diff --git a/apps/web/src/trpc/commands/brain/index.ts b/apps/web/src/trpc/commands/brain/index.ts index a1409e3c1..5be093392 100644 --- a/apps/web/src/trpc/commands/brain/index.ts +++ b/apps/web/src/trpc/commands/brain/index.ts @@ -5,22 +5,21 @@ import { db, eq, getBrainMemoryEventSummary, - isBrainProviderConfigured, isNull, + resolveBrainEnabledState, resolveModelProviderEnvValue, listBrainSyncStates, mcpConnections, requeueFailedBrainMemoryEvents, + setBrainEnabled, } from '@roomote/db/server'; import { - describeBrainModels, readBrainCorpus, readBrainPage, readBrainStats, resolveBrainSourceRequirements, resolveBrainInferenceProvider, type BrainCorpusSnapshot, - type BrainModelSummary, } from '@roomote/sdk/server'; import { BRAIN_MCP_ID, @@ -117,6 +116,13 @@ export type BrainSettings = { status: BrainStatus; /** Why the status is not `connected`, in one sentence, or null. */ statusDetail: string | null; + /** Effective Brain on/off state, as the Settings toggle should render it. */ + enabled: boolean; + /** + * True when `enabled` comes from the legacy activation signal (an explicit + * R_BRAIN_* provider key) rather than an explicit Settings choice. + */ + enabledFromLegacyKey: boolean; url: string | null; inferenceProvider: 'openrouter' | 'openai' | null; /** @@ -126,8 +132,6 @@ export type BrainSettings = { * for the other provider, and the page must not claim otherwise. */ keySource: 'brain' | 'deployment' | null; - /** The models the Brain runs, or null when no provider resolves. */ - models: BrainModelSummary | null; /** * Recall health. `semantic`/`keyword-only` are measured from gbrain's own * embedding counts; `unknown` means the admin census did not answer and @@ -377,10 +381,12 @@ export async function getBrainSettingsCommand( ): Promise { assertAdmin(auth); - // Activation is the explicit brain-specific provider key, in Settings or - // the environment. The gateway token and R_GBRAIN_URL exist as plumbing on - // deployments that never opted in, so neither can mean "the Brain is on". - const configured = await isBrainProviderConfigured(); + // Activation is the Settings toggle, falling back to the legacy explicit + // brain-specific provider key. The gateway token and R_GBRAIN_URL exist as + // plumbing on deployments that never opted in, so neither can mean "the + // Brain is on". + const enabledState = await resolveBrainEnabledState(); + const configured = enabledState.enabled; const url = Env.R_GBRAIN_URL ?? null; // The rollups only describe a Brain that exists; on an unconfigured @@ -450,7 +456,7 @@ export async function getBrainSettingsCommand( return { status: 'not_configured', statusDetail: - 'Memory is not configured for this deployment. Set a Memory provider key to give agents shared memory.', + 'Memory is turned off for this deployment. Enable it to give agents shared memory.', }; } @@ -462,11 +468,14 @@ export async function getBrainSettingsCommand( }; } - if (!inference) { + // Synthesis rides the deployment's helper model in gateway mode, but + // semantic recall still needs embeddings: a provider key or a configured + // embeddings upstream. + if (!inference && !Env.R_BRAIN_EMBEDDINGS_UPSTREAM_URL) { return { status: 'incomplete', statusDetail: - 'Memory has no inference provider, so it can only match keywords. Configure a Memory provider key to enable semantic recall.', + 'Memory has no embeddings provider, so it can only match keywords. Configure a provider key or an embeddings upstream to enable semantic recall.', }; } @@ -492,10 +501,11 @@ export async function getBrainSettingsCommand( return { status, statusDetail, + enabled: enabledState.enabled, + enabledFromLegacyKey: enabledState.enabled && enabledState.fromLegacyKey, url, inferenceProvider: inference?.providerId ?? null, keySource, - models: inference ? describeBrainModels(inference.providerId) : null, recall, corpus, sources: summarizeSources({ @@ -517,6 +527,22 @@ export async function getBrainSettingsCommand( }; } +/** + * Turn the Brain on or off for the deployment. Writes the explicit Settings + * choice, which from then on wins over the legacy R_BRAIN_* key fallback in + * both directions. + */ +export async function setMemoryEnabledCommand( + auth: UserAuthSuccess, + input: { enabled: boolean }, +): Promise<{ enabled: boolean }> { + assertAdmin(auth); + + await setBrainEnabled(input.enabled); + + return { enabled: input.enabled }; +} + /** * Enqueue a memory for every completed run that does not have one. Idempotent * by the outbox's unique(runId), so running it twice costs nothing; the diff --git a/apps/web/src/trpc/commands/brain/set-enabled.test.ts b/apps/web/src/trpc/commands/brain/set-enabled.test.ts new file mode 100644 index 000000000..ed438439e --- /dev/null +++ b/apps/web/src/trpc/commands/brain/set-enabled.test.ts @@ -0,0 +1,41 @@ +const { mockSetBrainEnabled } = vi.hoisted(() => ({ + mockSetBrainEnabled: vi.fn(async () => undefined), +})); + +vi.mock('@roomote/db/server', async (importOriginal) => ({ + ...(await importOriginal()), + setBrainEnabled: mockSetBrainEnabled, +})); + +import type { UserAuthSuccess } from '@/types'; + +import { setMemoryEnabledCommand } from './index'; + +function auth(isAdmin: boolean): UserAuthSuccess { + return { isAdmin } as UserAuthSuccess; +} + +beforeEach(() => { + mockSetBrainEnabled.mockClear(); +}); + +describe('setMemoryEnabledCommand', () => { + it('is admin-only', async () => { + await expect( + setMemoryEnabledCommand(auth(false), { enabled: true }), + ).rejects.toThrow('Unauthorized'); + expect(mockSetBrainEnabled).not.toHaveBeenCalled(); + }); + + it('persists the explicit choice and echoes it back', async () => { + await expect( + setMemoryEnabledCommand(auth(true), { enabled: true }), + ).resolves.toEqual({ enabled: true }); + expect(mockSetBrainEnabled).toHaveBeenLastCalledWith(true); + + await expect( + setMemoryEnabledCommand(auth(true), { enabled: false }), + ).resolves.toEqual({ enabled: false }); + expect(mockSetBrainEnabled).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index daa6a7713..59608ae3e 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -444,6 +444,7 @@ import { getBrainSettingsCommand, listBrainPagesCommand, retryFailedBrainTaskMemoriesCommand, + setMemoryEnabledCommand, } from '../commands/brain'; import { getReleaseNotesCommand, @@ -3000,6 +3001,12 @@ export const appRouter = createRouter({ .input(z.object({ slug: z.string().min(1).max(512) })) .query(({ ctx: { auth }, input }) => getBrainPageCommand(auth, input)), + setMemoryEnabled: protectedProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ ctx: { auth }, input }) => + setMemoryEnabledCommand(auth, input), + ), + backfillTaskMemories: protectedProcedure.mutation(({ ctx: { auth } }) => backfillBrainTaskMemoriesCommand(auth), ), diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 741944fdd..f1afd3692 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ getEnvironments: vi.fn(), getTaskModelOptions: vi.fn(), appendMemory: vi.fn(), - isBrainProviderConfigured: vi.fn(), + isBrainEnabled: vi.fn(), generateText: vi.fn(), classifyInferenceError: vi.fn(), invalidateSession: vi.fn(), @@ -78,7 +78,7 @@ vi.mock('../../router', () => ({ vi.mock('@roomote/db/server', () => ({ getDeploymentTaskModelOptions: mocks.getTaskModelOptions, appendFastAgentMemory: mocks.appendMemory, - isBrainProviderConfigured: mocks.isBrainProviderConfigured, + isBrainEnabled: mocks.isBrainEnabled, db: {}, })); @@ -843,7 +843,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }); it('saves a conversation memory through the outbox', async () => { - mocks.isBrainProviderConfigured.mockResolvedValue(true); + mocks.isBrainEnabled.mockResolvedValue(true); mocks.appendMemory.mockResolvedValue({ saved: true }); mocks.generateText.mockImplementation( async (_params, _session, options) => { @@ -872,7 +872,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }); it('refuses a memory save when no Brain is configured', async () => { - mocks.isBrainProviderConfigured.mockResolvedValue(false); + mocks.isBrainEnabled.mockResolvedValue(false); mocks.generateText.mockImplementation( async (_params, _session, options) => { options.onModelResolved?.('openrouter/openai/gpt-5.4'); @@ -899,7 +899,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }); it('surfaces a full conversation memory as a tool failure', async () => { - mocks.isBrainProviderConfigured.mockResolvedValue(true); + mocks.isBrainEnabled.mockResolvedValue(true); mocks.appendMemory.mockResolvedValue({ saved: false, reason: 'memory_full', diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 6a293dd06..3bfd80abb 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -24,7 +24,7 @@ import { appendFastAgentMemory, db, getDeploymentTaskModelOptions, - isBrainProviderConfigured, + isBrainEnabled, } from '@roomote/db/server'; import { Env } from '@roomote/env'; import { z } from 'zod'; @@ -1724,7 +1724,7 @@ export async function answerFastAgentQuestion({ case FAST_AGENT_NATIVE_TOOL_NAMES.saveMemory: { const args = saveMemoryArgsSchema.parse(call.args); - if (!(await isBrainProviderConfigured())) { + if (!(await isBrainEnabled())) { return { success: false, error: 'This deployment has no Brain configured.', diff --git a/packages/cloud-agents/src/server/non-task-provider-usage.ts b/packages/cloud-agents/src/server/non-task-provider-usage.ts index 273f0fbf7..56b69f808 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -95,6 +95,7 @@ export type NonTaskInferenceTrackingInput = { }; export const NON_TASK_INFERENCE_SURFACES = { + brainSynthesis: 'brain_synthesis', chatAudioTranscription: 'chat_audio_transcription', chatVideoDescription: 'chat_video_description', customAutomationScheduleResolution: 'custom_automation_schedule_resolution', diff --git a/packages/db/drizzle/0063_organic_garia.sql b/packages/db/drizzle/0063_organic_garia.sql new file mode 100644 index 000000000..3d8ab9d5a --- /dev/null +++ b/packages/db/drizzle/0063_organic_garia.sql @@ -0,0 +1 @@ +ALTER TABLE "deployment_settings" ADD COLUMN "brain_enabled" boolean; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0063_snapshot.json b/packages/db/drizzle/meta/0063_snapshot.json new file mode 100644 index 000000000..a0d006036 --- /dev/null +++ b/packages/db/drizzle/meta/0063_snapshot.json @@ -0,0 +1,13215 @@ +{ + "id": "8fe248da-e7e1-4c29-a67f-44344572ce6a", + "prevId": "7d4b9ad0-0598-4d83-be76-5f7b814cbf7d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_accounts": { + "name": "auth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_accounts_user_id_idx": { + "name": "auth_accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_accounts_provider_account_unique": { + "name": "auth_accounts_provider_account_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_auth_users_id_fk": { + "name": "auth_accounts_user_id_auth_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_sessions": { + "name": "auth_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_sessions_user_id_idx": { + "name": "auth_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_auth_users_id_fk": { + "name": "auth_sessions_user_id_auth_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_users": { + "name": "auth_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_users_email_unique": { + "name": "auth_users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_users_created_at_idx": { + "name": "auth_users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verifications": { + "name": "auth_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automations": { + "name": "automations", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule": { + "name": "schedule", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_cursor": { + "name": "scan_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_collector_items": { + "name": "brain_collector_items", + "schema": "", + "columns": { + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_collector_items_collector_seen_idx": { + "name": "brain_collector_items_collector_seen_idx", + "columns": [ + { + "expression": "collector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "brain_collector_items_collector_item_pk": { + "name": "brain_collector_items_collector_item_pk", + "columns": ["collector_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_memory_events": { + "name": "brain_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_summary": { + "name": "agent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brain_memory_events_status_created_idx": { + "name": "brain_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brain_memory_events_run_id_task_runs_id_fk": { + "name": "brain_memory_events_run_id_task_runs_id_fk", + "tableFrom": "brain_memory_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_memory_events_run_unique": { + "name": "brain_memory_events_run_unique", + "nullsNotDistinct": false, + "columns": ["run_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brain_sync_state": { + "name": "brain_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "watermark": { + "name": "watermark", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_cursor": { + "name": "backfill_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brain_sync_state_collector_id_unique": { + "name": "brain_sync_state_collector_id_unique", + "nullsNotDistinct": false, + "columns": ["collector_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage": { + "name": "compute_provider_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_kind": { + "name": "auth_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle_action": { + "name": "lifecycle_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "measurement_source": { + "name": "measurement_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "wall_clock_duration_ms": { + "name": "wall_clock_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active_cpu_duration_ms": { + "name": "active_cpu_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "observed_memory_mib_milliseconds": { + "name": "observed_memory_mib_milliseconds", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_ingress_bytes": { + "name": "network_ingress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "network_egress_bytes": { + "name": "network_egress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "compute_provider_usage_provider_usage_id_unique": { + "name": "compute_provider_usage_provider_usage_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_run_id_idx": { + "name": "compute_provider_usage_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_task_id_idx": { + "name": "compute_provider_usage_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_created_at_idx": { + "name": "compute_provider_usage_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "compute_provider_usage_task_id_tasks_id_fk": { + "name": "compute_provider_usage_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.compute_provider_usage_samples": { + "name": "compute_provider_usage_samples", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_usage_id": { + "name": "provider_usage_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sampled_at": { + "name": "sampled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "cpu_usage_ns_total": { + "name": "cpu_usage_ns_total", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_usage_bytes": { + "name": "memory_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "memory_peak_usage_bytes": { + "name": "memory_peak_usage_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "compute_provider_usage_samples_provider_usage_sampled_at_unique": { + "name": "compute_provider_usage_samples_provider_usage_sampled_at_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sampled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_run_id_idx": { + "name": "compute_provider_usage_samples_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_task_id_idx": { + "name": "compute_provider_usage_samples_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "compute_provider_usage_samples_created_at_idx": { + "name": "compute_provider_usage_samples_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_provider_usage_samples_run_id_task_runs_id_fk": { + "name": "compute_provider_usage_samples_run_id_task_runs_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compute_provider_usage_samples_task_id_tasks_id_fk": { + "name": "compute_provider_usage_samples_task_id_tasks_id_fk", + "tableFrom": "compute_provider_usage_samples", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_automations": { + "name": "custom_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "schedule_mode": { + "name": "schedule_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "all_repositories": { + "name": "all_repositories", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sandbox_task'" + }, + "target": { + "name": "target", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_launched_task_id": { + "name": "last_launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_automations_name_unique_idx": { + "name": "custom_automations_name_unique_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_enabled_idx": { + "name": "custom_automations_enabled_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_automations_environment_id_idx": { + "name": "custom_automations_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_automations_environment_id_environments_id_fk": { + "name": "custom_automations_environment_id_environments_id_fk", + "tableFrom": "custom_automations", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_created_by_user_id_users_id_fk": { + "name": "custom_automations_created_by_user_id_users_id_fk", + "tableFrom": "custom_automations", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "custom_automations_last_launched_task_id_tasks_id_fk": { + "name": "custom_automations_last_launched_task_id_tasks_id_fk", + "tableFrom": "custom_automations", + "tableTo": "tasks", + "columnsFrom": ["last_launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_mcp_servers": { + "name": "custom_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "stdio": { + "name": "stdio", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "manual_client_id": { + "name": "manual_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_client_secret": { + "name": "manual_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata": { + "name": "oauth_server_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth_server_metadata_fetched_at": { + "name": "oauth_server_metadata_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "oauth_resource_indicator_disabled": { + "name": "oauth_resource_indicator_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "custom_mcp_servers_created_by_user_id_users_id_fk": { + "name": "custom_mcp_servers_created_by_user_id_users_id_fk", + "tableFrom": "custom_mcp_servers", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_mcp_servers_name_unique": { + "name": "custom_mcp_servers_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_mcp_enablements": { + "name": "deployment_mcp_enablements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tool_access_mode": { + "name": "tool_access_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "deployment_mcp_enablements_enabled_by_user_id_users_id_fk": { + "name": "deployment_mcp_enablements_enabled_by_user_id_users_id_fk", + "tableFrom": "deployment_mcp_enablements", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_mcp_enablements_mcp_unique": { + "name": "deployment_mcp_enablements_mcp_unique", + "nullsNotDistinct": false, + "columns": ["mcp_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_secrets": { + "name": "deployment_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deployment_secrets_name_unique": { + "name": "deployment_secrets_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_model_settings": { + "name": "task_model_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "workspace_routing_settings": { + "name": "workspace_routing_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "router_debug_provider": { + "name": "router_debug_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_channel_id": { + "name": "router_debug_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "router_debug_disabled": { + "name": "router_debug_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "router_debug_slack_channel_id": { + "name": "router_debug_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_model_config": { + "name": "runtime_model_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "runtime_compute_config": { + "name": "runtime_compute_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_policy": { + "name": "access_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "brain_enabled": { + "name": "brain_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "license_key": { + "name": "license_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license_cloud_state": { + "name": "license_cloud_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "instance_analytics_id": { + "name": "instance_analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_known_version": { + "name": "latest_known_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version_checked_at": { + "name": "latest_version_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_new_state": { + "name": "setup_new_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "slack_onboarding_stage": { + "name": "slack_onboarding_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_slack_channel_id": { + "name": "manager_slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manager_discord_channel_id": { + "name": "manager_discord_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "global_agent_instructions": { + "name": "global_agent_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone_updated_at": { + "name": "time_zone_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "authorship_instructions": { + "name": "authorship_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compiled_authorship_rules": { + "name": "compiled_authorship_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_issues": { + "name": "compiled_authorship_issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "compiled_authorship_at": { + "name": "compiled_authorship_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "style_guidance": { + "name": "style_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_summon_emoji": { + "name": "slack_summon_emoji", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_ack_emoji": { + "name": "slack_ack_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'eyes'" + }, + "slack_completion_emoji": { + "name": "slack_completion_emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'white_check_mark'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_sessions": { + "name": "discord_gateway_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resume_gateway_url": { + "name": "resume_gateway_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shard_count": { + "name": "shard_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_ack_at": { + "name": "last_heartbeat_ack_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disconnected_at": { + "name": "disconnected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installation_channels": { + "name": "discord_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_installation_id": { + "name": "discord_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_available": { + "name": "is_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installation_channels_installation_id_idx": { + "name": "discord_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installation_channels_unique": { + "name": "discord_installation_channels_unique", + "columns": [ + { + "expression": "discord_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installation_channels_discord_installation_id_discord_installations_id_fk": { + "name": "discord_installation_channels_discord_installation_id_discord_installations_id_fk", + "tableFrom": "discord_installation_channels", + "tableTo": "discord_installations", + "columnsFrom": ["discord_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_installations": { + "name": "discord_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "guild_name": { + "name": "guild_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_id": { + "name": "default_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_name": { + "name": "default_channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_channel_type": { + "name": "default_channel_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_installations_guild_id_unique": { + "name": "discord_installations_guild_id_unique", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_active_idx": { + "name": "discord_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_installations_default_channel_idx": { + "name": "discord_installations_default_channel_idx", + "columns": [ + { + "expression": "default_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_installations_installed_by_user_id_users_id_fk": { + "name": "discord_installations_installed_by_user_id_users_id_fk", + "tableFrom": "discord_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_user_mappings": { + "name": "discord_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_user_id": { + "name": "discord_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_global_name": { + "name": "discord_global_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_dm_channel_id": { + "name": "discord_dm_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "discord_user_mappings_user_id_idx": { + "name": "discord_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discord_user_mappings_discord_user_id_unique": { + "name": "discord_user_mappings_discord_user_id_unique", + "columns": [ + { + "expression": "discord_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_user_mappings_user_id_users_id_fk": { + "name": "discord_user_mappings_user_id_users_id_fk", + "tableFrom": "discord_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_config_versions": { + "name": "environment_config_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_config_versions_environment_id_idx": { + "name": "environment_config_versions_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_config_versions_environment_version_unique": { + "name": "environment_config_versions_environment_version_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_config_versions_environment_id_environments_id_fk": { + "name": "environment_config_versions_environment_id_environments_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_config_versions_created_by_user_id_users_id_fk": { + "name": "environment_config_versions_created_by_user_id_users_id_fk", + "tableFrom": "environment_config_versions", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_repository_mappings": { + "name": "environment_repository_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "env_repo_mappings_env_id_idx": { + "name": "env_repo_mappings_env_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "env_repo_mappings_repo_id_idx": { + "name": "env_repo_mappings_repo_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_repository_mappings_environment_id_environments_id_fk": { + "name": "environment_repository_mappings_environment_id_environments_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_repository_mappings_repository_id_repositories_id_fk": { + "name": "environment_repository_mappings_repository_id_repositories_id_fk", + "tableFrom": "environment_repository_mappings", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "env_repo_mappings_unique": { + "name": "env_repo_mappings_unique", + "nullsNotDistinct": false, + "columns": ["environment_id", "repository_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_snapshots": { + "name": "environment_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_snapshots_environment_id_idx": { + "name": "environment_snapshots_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_snapshots_env_provider_unique": { + "name": "environment_snapshots_env_provider_unique", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environment_snapshots\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_snapshots_environment_id_environments_id_fk": { + "name": "environment_snapshots_environment_id_environments_id_fk", + "tableFrom": "environment_snapshots", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_updated_by_user_id": { + "name": "last_updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_name_unique": { + "name": "environment_variables_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_users_id_fk": { + "name": "environment_variables_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_created_by_user_id_users_id_fk": { + "name": "environment_variables_created_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "environment_variables_last_updated_by_user_id_users_id_fk": { + "name": "environment_variables_last_updated_by_user_id_users_id_fk", + "tableFrom": "environment_variables", + "tableTo": "users", + "columnsFrom": ["last_updated_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_eval": { + "name": "is_eval", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "declarative_source": { + "name": "declarative_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verification_task_id": { + "name": "verification_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_error": { + "name": "verification_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_expires_at": { + "name": "snapshot_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_status": { + "name": "snapshot_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environments_user_id_idx": { + "name": "environments_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_created_by_user_id_idx": { + "name": "environments_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_snapshot_expires_at_idx": { + "name": "environments_snapshot_expires_at_idx", + "columns": [ + { + "expression": "snapshot_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_name_unique": { + "name": "environments_name_unique", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environments_user_id_users_id_fk": { + "name": "environments_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environments_created_by_user_id_users_id_fk": { + "name": "environments_created_by_user_id_users_id_fk", + "tableFrom": "environments", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_conversations": { + "name": "fast_agent_conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_reply_channel_id": { + "name": "current_reply_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_thread_id": { + "name": "current_reply_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_reply_service_url": { + "name": "current_reply_service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_target_verified": { + "name": "reply_target_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "compatibility_messages": { + "name": "compatibility_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "opencode_session_id": { + "name": "opencode_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "legacy_conversation_ids": { + "name": "legacy_conversation_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::uuid[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_conversations_identity_unique": { + "name": "fast_agent_conversations_identity_unique", + "columns": [ + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_user_idx": { + "name": "fast_agent_conversations_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_conversations_legacy_ids_idx": { + "name": "fast_agent_conversations_legacy_ids_idx", + "columns": [ + { + "expression": "legacy_conversation_ids", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_conversations_user_id_users_id_fk": { + "name": "fast_agent_conversations_user_id_users_id_fk", + "tableFrom": "fast_agent_conversations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_memory_events": { + "name": "fast_agent_memory_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "memory": { + "name": "memory", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_memory_events_status_created_idx": { + "name": "fast_agent_memory_events_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_memory_events_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_memory_events", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fast_agent_memory_events_conversation_unique": { + "name": "fast_agent_memory_events_conversation_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_messages": { + "name": "fast_agent_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "turn_seq": { + "name": "turn_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_session_id": { + "name": "native_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "native_message_id": { + "name": "native_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_messages_conversation_event_unique": { + "name": "fast_agent_messages_conversation_event_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_messages_conversation_order_idx": { + "name": "fast_agent_messages_conversation_order_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_pr_feedback_deliveries": { + "name": "fast_agent_pr_feedback_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_pr_feedback_deliveries_identity_unique": { + "name": "fast_agent_pr_feedback_deliveries_identity_unique", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_pr_feedback_deliveries_task_idx": { + "name": "fast_agent_pr_feedback_deliveries_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk": { + "name": "fast_agent_pr_feedback_deliveries_task_id_tasks_id_fk", + "tableFrom": "fast_agent_pr_feedback_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fast_agent_provider_messages": { + "name": "fast_agent_provider_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fast_agent_provider_messages_route_unique": { + "name": "fast_agent_provider_messages_route_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_conversation_idx": { + "name": "fast_agent_provider_messages_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_agent_provider_messages_thread_idx": { + "name": "fast_agent_provider_messages_thread_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk": { + "name": "fast_agent_provider_messages_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "fast_agent_provider_messages", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_agent_provider_messages_provider_check": { + "name": "fast_agent_provider_messages_provider_check", + "value": "\"fast_agent_provider_messages\".\"provider\" in ('discord', 'teams')" + } + }, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "members_count": { + "name": "members_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_installations_account_login_idx": { + "name": "github_installations_account_login_idx", + "columns": [ + { + "expression": "account_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_installations_deployment_installation_unique": { + "name": "github_installations_deployment_installation_unique", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_installed_by_user_id_users_id_fk": { + "name": "github_installations_installed_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pending_installations": { + "name": "github_pending_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_pending_installations_requested_by_user_id_idx": { + "name": "github_pending_installations_requested_by_user_id_idx", + "columns": [ + { + "expression": "requested_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pending_installations_user_id_users_id_fk": { + "name": "github_pending_installations_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_pending_installations_requested_by_user_id_users_id_fk": { + "name": "github_pending_installations_requested_by_user_id_users_id_fk", + "tableFrom": "github_pending_installations", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_user_mappings": { + "name": "github_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "github_user_mappings_github_login_idx": { + "name": "github_user_mappings_github_login_idx", + "columns": [ + { + "expression": "github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_user_mappings_user_id_idx": { + "name": "github_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_user_mappings_user_id_users_id_fk": { + "name": "github_user_mappings_user_id_users_id_fk", + "tableFrom": "github_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_user_mappings_unique": { + "name": "github_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["github_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique": { + "name": "invites_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_created_at_idx": { + "name": "invites_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_invited_by_user_id_users_id_fk": { + "name": "invites_invited_by_user_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": ["invited_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.license_usage_observations": { + "name": "license_usage_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active_users": { + "name": "active_users", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "license_usage_observations_pending_idx": { + "name": "license_usage_observations_pending_idx", + "columns": [ + { + "expression": "delivered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_pending_selections": { + "name": "linear_pending_selections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'awaiting_workspace'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "selected_repo": { + "name": "selected_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_options": { + "name": "workspace_options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linear_pending_selections_expires_at_idx": { + "name": "linear_pending_selections_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linear_pending_selections_step_idx": { + "name": "linear_pending_selections_step_idx", + "columns": [ + { + "expression": "step", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_pending_selections_user_id_users_id_fk": { + "name": "linear_pending_selections_user_id_users_id_fk", + "tableFrom": "linear_pending_selections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "linear_pending_selections_session_id_unique": { + "name": "linear_pending_selections_session_id_unique", + "nullsNotDistinct": false, + "columns": ["session_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_inference_usage_events": { + "name": "task_inference_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode'" + }, + "usage_type": { + "name": "usage_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inference'" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_tokens": { + "name": "context_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_micro_usd": { + "name": "cost_micro_usd", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_source": { + "name": "cost_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pricing_metadata": { + "name": "pricing_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "message_completed_at": { + "name": "message_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_inference_usage_events_session_message_unique": { + "name": "task_inference_usage_events_session_message_unique", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_event_key_unique": { + "name": "task_inference_usage_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_task_id_idx": { + "name": "task_inference_usage_events_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_run_id_idx": { + "name": "task_inference_usage_events_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_user_id_idx": { + "name": "task_inference_usage_events_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_environment_id_idx": { + "name": "task_inference_usage_events_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_provider_model_idx": { + "name": "task_inference_usage_events_provider_model_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_inference_usage_events_created_at_idx": { + "name": "task_inference_usage_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_inference_usage_events_task_id_tasks_id_fk": { + "name": "task_inference_usage_events_task_id_tasks_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_inference_usage_events_run_id_task_runs_id_fk": { + "name": "task_inference_usage_events_run_id_task_runs_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_user_id_users_id_fk": { + "name": "task_inference_usage_events_user_id_users_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_inference_usage_events_environment_id_environments_id_fk": { + "name": "task_inference_usage_events_environment_id_environments_id_fk", + "tableFrom": "task_inference_usage_events", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_connections": { + "name": "mcp_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "auth_config": { + "name": "auth_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_status": { + "name": "auth_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_connections_user_id_idx": { + "name": "mcp_connections_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_connections_role_idx": { + "name": "mcp_connections_role_idx", + "columns": [ + { + "expression": "mcp_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_connections_user_id_users_id_fk": { + "name": "mcp_connections_user_id_users_id_fk", + "tableFrom": "mcp_connections", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_connections_user_mcp_id_unique": { + "name": "mcp_connections_user_mcp_id_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "mcp_id", "connection_role"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_replays": { + "name": "mcp_oauth_replays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_id": { + "name": "mcp_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_role": { + "name": "connection_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "redirect_to": { + "name": "redirect_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_replays_connection_id_idx": { + "name": "mcp_oauth_replays_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_user_id_idx": { + "name": "mcp_oauth_replays_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_replays_expires_at_idx": { + "name": "mcp_oauth_replays_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_replays_connection_id_mcp_connections_id_fk": { + "name": "mcp_oauth_replays_connection_id_mcp_connections_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_replays_user_id_users_id_fk": { + "name": "mcp_oauth_replays_user_id_users_id_fk", + "tableFrom": "mcp_oauth_replays", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_replays_token_unique": { + "name": "mcp_oauth_replays_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microsoft_auth_user_mappings": { + "name": "microsoft_auth_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_tenant_id": { + "name": "microsoft_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microsoft_aad_object_id": { + "name": "microsoft_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "microsoft_auth_user_mappings_user_id_idx": { + "name": "microsoft_auth_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_account_id_idx": { + "name": "microsoft_auth_user_mappings_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_auth_account_idx": { + "name": "microsoft_auth_user_mappings_auth_account_idx", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "microsoft_auth_user_mappings_aad_object_unique": { + "name": "microsoft_auth_user_mappings_aad_object_unique", + "columns": [ + { + "expression": "microsoft_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "microsoft_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "microsoft_auth_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "microsoft_auth_user_mappings_user_id_auth_users_id_fk": { + "name": "microsoft_auth_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "microsoft_auth_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notion_directory_users": { + "name": "notion_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notion_user_id": { + "name": "notion_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "notion_directory_users_unique": { + "name": "notion_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["notion_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_state": { + "name": "oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replay_token": { + "name": "replay_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauth_state_connection_id_idx": { + "name": "oauth_state_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_replay_token_idx": { + "name": "oauth_state_replay_token_idx", + "columns": [ + { + "expression": "replay_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_state_expires_at_idx": { + "name": "oauth_state_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_state_connection_id_mcp_connections_id_fk": { + "name": "oauth_state_connection_id_mcp_connections_id_fk", + "tableFrom": "oauth_state", + "tableTo": "mcp_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_auto_preferences": { + "name": "pr_review_auto_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled_by_user_id": { + "name": "enabled_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_destination_key": { + "name": "source_destination_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_auto_preferences_identity_unique": { + "name": "pr_review_auto_preferences_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_auto_preferences_repository_idx": { + "name": "pr_review_auto_preferences_repository_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_auto_preferences_repository_id_repositories_id_fk": { + "name": "pr_review_auto_preferences_repository_id_repositories_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_enabled_by_user_id_users_id_fk": { + "name": "pr_review_auto_preferences_enabled_by_user_id_users_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "users", + "columnsFrom": ["enabled_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_auto_preferences_source_task_id_tasks_id_fk": { + "name": "pr_review_auto_preferences_source_task_id_tasks_id_fk", + "tableFrom": "pr_review_auto_preferences", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_cycles": { + "name": "pr_review_cycles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cycle_id": { + "name": "cycle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pr_review_cycles_source_unique": { + "name": "pr_review_cycles_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cycle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_event_deliveries": { + "name": "pr_review_event_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_event_deliveries_event_task_unique": { + "name": "pr_review_event_deliveries_event_task_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_event_deliveries_due_idx": { + "name": "pr_review_event_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_event_deliveries_event_id_pr_review_events_id_fk": { + "name": "pr_review_event_deliveries_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_event_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_event_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_event_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_event_deliveries_status_check": { + "name": "pr_review_event_deliveries_status_check", + "value": "\"pr_review_event_deliveries\".\"status\" in ('pending', 'processing', 'delivered', 'suppressed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_events": { + "name": "pr_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "batch_kind": { + "name": "batch_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_head_sha": { + "name": "review_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "superseded": { + "name": "superseded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "observed_at": { + "name": "observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_events_source_unique": { + "name": "pr_review_events_source_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_events_pr_idx": { + "name": "pr_review_events_pr_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_events_batch_kind_check": { + "name": "pr_review_events_batch_kind_check", + "value": "\"pr_review_events\".\"batch_kind\" in ('human', 'roomote')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_deliveries": { + "name": "pr_review_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_unit_id": { + "name": "notification_unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination_kind": { + "name": "destination_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_key": { + "name": "destination_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deferrals": { + "name": "deferrals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "route_provider": { + "name": "route_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_workspace_id": { + "name": "route_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_channel_id": { + "name": "route_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_thread_id": { + "name": "route_thread_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follow_up_prompt": { + "name": "follow_up_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_task_id": { + "name": "target_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_claimed_at": { + "name": "action_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dispatch_key": { + "name": "dispatch_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dispatched_run_id": { + "name": "dispatched_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_deliveries_destination_unique": { + "name": "pr_review_notification_deliveries_destination_unique", + "columns": [ + { + "expression": "notification_unit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_dispatch_key_unique": { + "name": "pr_review_notification_deliveries_dispatch_key_unique", + "columns": [ + { + "expression": "dispatch_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_due_idx": { + "name": "pr_review_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_deliveries_destination_idx": { + "name": "pr_review_notification_deliveries_destination_idx", + "columns": [ + { + "expression": "destination_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_deliveries_notification_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["notification_unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_target_task_id_tasks_id_fk": { + "name": "pr_review_notification_deliveries_target_task_id_tasks_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "tasks", + "columnsFrom": ["target_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pr_review_notification_deliveries_acting_user_id_users_id_fk": { + "name": "pr_review_notification_deliveries_acting_user_id_users_id_fk", + "tableFrom": "pr_review_notification_deliveries", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_deliveries_destination_kind_check": { + "name": "pr_review_notification_deliveries_destination_kind_check", + "value": "\"pr_review_notification_deliveries\".\"destination_kind\" in ('fast_conversation', 'task')" + }, + "pr_review_notification_deliveries_status_check": { + "name": "pr_review_notification_deliveries_status_check", + "value": "\"pr_review_notification_deliveries\".\"status\" in ('pending', 'claimed', 'prepared', 'prompt_posting', 'awaiting_user_action', 'auto_dispatch_pending', 'completed', 'suppressed', 'dismissed')" + } + }, + "isRLSEnabled": false + }, + "public.pr_review_notification_unit_events": { + "name": "pr_review_notification_unit_events", + "schema": "", + "columns": { + "unit_id": { + "name": "unit_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attached_at": { + "name": "attached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_unit_events_event_unique": { + "name": "pr_review_notification_unit_events_event_unique", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk": { + "name": "pr_review_notification_unit_events_unit_id_pr_review_notification_units_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_notification_units", + "columnsFrom": ["unit_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pr_review_notification_unit_events_event_id_pr_review_events_id_fk": { + "name": "pr_review_notification_unit_events_event_id_pr_review_events_id_fk", + "tableFrom": "pr_review_notification_unit_events", + "tableTo": "pr_review_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pr_review_notification_unit_events_pk": { + "name": "pr_review_notification_unit_events_pk", + "columns": ["unit_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pr_review_notification_units": { + "name": "pr_review_notification_units", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_identity_key": { + "name": "repository_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "head_identity_key": { + "name": "head_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_kind": { + "name": "episode_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "episode_id": { + "name": "episode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due_at": { + "name": "due_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sealed_at": { + "name": "sealed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pr_review_notification_units_identity_unique": { + "name": "pr_review_notification_units_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pr_review_notification_units_open_head_idx": { + "name": "pr_review_notification_units_open_head_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sealed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pr_review_notification_units_repository_id_repositories_id_fk": { + "name": "pr_review_notification_units_repository_id_repositories_id_fk", + "tableFrom": "pr_review_notification_units", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pr_review_notification_units_episode_kind_check": { + "name": "pr_review_notification_units_episode_kind_check", + "value": "\"pr_review_notification_units\".\"episode_kind\" in ('roomote_cycle', 'human', 'automated', 'ci')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_facts": { + "name": "pull_request_facts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_pull_request_id": { + "name": "external_pull_request_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_files": { + "name": "changed_files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "changed_file_count": { + "name": "changed_file_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "files_capped": { + "name": "files_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reviews_capped": { + "name": "reviews_capped", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reviews": { + "name": "reviews", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enriched_at": { + "name": "enriched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enriched_for_updated_at": { + "name": "enriched_for_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_failed_at": { + "name": "enrichment_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at_remote": { + "name": "created_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at_remote": { + "name": "updated_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "closed_at_remote": { + "name": "closed_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "merged_at_remote": { + "name": "merged_at_remote", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_facts_deployment_repo_pr_unique": { + "name": "pull_request_facts_deployment_repo_pr_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_created_idx": { + "name": "pull_request_facts_deployment_created_idx", + "columns": [ + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_repo_created_idx": { + "name": "pull_request_facts_deployment_repo_created_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_state_created_idx": { + "name": "pull_request_facts_deployment_state_created_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_author_created_idx": { + "name": "pull_request_facts_deployment_author_created_idx", + "columns": [ + { + "expression": "author_login", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_facts_deployment_updated_idx": { + "name": "pull_request_facts_deployment_updated_idx", + "columns": [ + { + "expression": "updated_at_remote", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_facts_repository_id_repositories_id_fk": { + "name": "pull_request_facts_repository_id_repositories_id_fk", + "tableFrom": "pull_request_facts", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pull_request_facts_source_control_provider_check": { + "name": "pull_request_facts_source_control_provider_check", + "value": "\"pull_request_facts\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.pull_request_sync_states": { + "name": "pull_request_sync_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_incremental_updated_at": { + "name": "last_incremental_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_successful_sync_at": { + "name": "last_successful_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_attempted_sync_at": { + "name": "last_attempted_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pull_request_sync_states_repo_unique": { + "name": "pull_request_sync_states_repo_unique", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_deployment_updated_idx": { + "name": "pull_request_sync_states_deployment_updated_idx", + "columns": [ + { + "expression": "last_successful_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pull_request_sync_states_cooldown_idx": { + "name": "pull_request_sync_states_cooldown_idx", + "columns": [ + { + "expression": "cooldown_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pull_request_sync_states_repository_id_repositories_id_fk": { + "name": "pull_request_sync_states_repository_id_repositories_id_fk", + "tableFrom": "pull_request_sync_states", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private": { + "name": "private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositories_source_control_provider_idx": { + "name": "repositories_source_control_provider_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_installation_id_idx": { + "name": "repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_full_name_idx": { + "name": "repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_idx": { + "name": "repositories_provider_host_full_name_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_active_installation_idx": { + "name": "repositories_deployment_active_installation_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_deployment_github_repo_unique": { + "name": "repositories_deployment_github_repo_unique", + "columns": [ + { + "expression": "github_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_external_repo_unique": { + "name": "repositories_provider_host_external_repo_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_provider_host_full_name_unique": { + "name": "repositories_provider_host_full_name_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"host\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_installation_id_github_installations_id_fk": { + "name": "repositories_installation_id_github_installations_id_fk", + "tableFrom": "repositories", + "tableTo": "github_installations", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repositories_linked_by_user_id_users_id_fk": { + "name": "repositories_linked_by_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": ["linked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "repositories_source_control_provider_check": { + "name": "repositories_source_control_provider_check", + "value": "\"repositories\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + }, + "repositories_github_shape_check": { + "name": "repositories_github_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'github' OR (\"repositories\".\"installation_id\" IS NOT NULL AND \"repositories\".\"github_repo_id\" IS NOT NULL)" + }, + "repositories_gitlab_shape_check": { + "name": "repositories_gitlab_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitlab' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_gitea_shape_check": { + "name": "repositories_gitea_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'gitea' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_ado_shape_check": { + "name": "repositories_ado_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'ado' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + }, + "repositories_bitbucket_shape_check": { + "name": "repositories_bitbucket_shape_check", + "value": "\"repositories\".\"source_control_provider\" != 'bitbucket' OR \"repositories\".\"external_repo_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.repository_automation_signals": { + "name": "repository_automation_signals", + "schema": "", + "columns": { + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signals_version": { + "name": "signals_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "collected_at": { + "name": "collected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "repository_automation_signals_collected_idx": { + "name": "repository_automation_signals_collected_idx", + "columns": [ + { + "expression": "collected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_automation_signals_repository_id_repositories_id_fk": { + "name": "repository_automation_signals_repository_id_repositories_id_fk", + "tableFrom": "repository_automation_signals", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "repository_automation_signals_repository_id_signals_version_pk": { + "name": "repository_automation_signals_repository_id_signals_version_pk", + "columns": ["repository_id", "signals_version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_oidc_targets": { + "name": "sandbox_oidc_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "compute_provider": { + "name": "compute_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compute_provider_id": { + "name": "compute_provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_file": { + "name": "token_file", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aws_role_arn": { + "name": "aws_role_arn", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aws_region": { + "name": "aws_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_at": { + "name": "refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_oidc_targets_environment_id_idx": { + "name": "sandbox_oidc_targets_environment_id_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_run_id_idx": { + "name": "sandbox_oidc_targets_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_refresh_at_idx": { + "name": "sandbox_oidc_targets_refresh_at_idx", + "columns": [ + { + "expression": "refresh_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_oidc_targets_provider_target_file_unique": { + "name": "sandbox_oidc_targets_provider_target_file_unique", + "columns": [ + { + "expression": "compute_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "compute_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_file", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sandbox_oidc_targets_environment_id_environments_id_fk": { + "name": "sandbox_oidc_targets_environment_id_environments_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "environments", + "columnsFrom": ["environment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sandbox_oidc_targets_run_id_task_runs_id_fk": { + "name": "sandbox_oidc_targets_run_id_task_runs_id_fk", + "tableFrom": "sandbox_oidc_targets", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sandbox_oidc_targets_owner_required": { + "name": "sandbox_oidc_targets_owner_required", + "value": "run_id IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.setup_qualification_blocks": { + "name": "setup_qualification_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'blocked'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_login": { + "name": "github_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_account_type": { + "name": "github_account_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_blocked_at": { + "name": "first_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_blocked_at": { + "name": "last_blocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_user_id": { + "name": "lifted_by_admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifted_by_admin_email": { + "name": "lifted_by_admin_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "setup_qualification_blocks_deployment_user_reason_unique": { + "name": "setup_qualification_blocks_deployment_user_reason_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_deployment_status_idx": { + "name": "setup_qualification_blocks_deployment_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "setup_qualification_blocks_user_status_idx": { + "name": "setup_qualification_blocks_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "setup_qualification_blocks_user_id_users_id_fk": { + "name": "setup_qualification_blocks_user_id_users_id_fk", + "tableFrom": "setup_qualification_blocks", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_auth_tokens": { + "name": "slack_auth_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_text": { + "name": "original_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_auth_tokens_expires_at_idx": { + "name": "slack_auth_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_auth_tokens_token_unique": { + "name": "slack_auth_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_conversation_messages": { + "name": "slack_conversation_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_slack_user_id": { + "name": "subject_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_slack_user_id": { + "name": "sender_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_at": { + "name": "message_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_conversation_messages_deployment_user_message_at_idx": { + "name": "slack_conversation_messages_deployment_user_message_at_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_deployment_user_thread_idx": { + "name": "slack_conversation_messages_deployment_user_thread_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "thread_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_task_id_idx": { + "name": "slack_conversation_messages_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_run_id_idx": { + "name": "slack_conversation_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_conversation_messages_team_channel_message_unique": { + "name": "slack_conversation_messages_team_channel_message_unique", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slack_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_conversation_messages_subject_user_id_users_id_fk": { + "name": "slack_conversation_messages_subject_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_conversation_messages_sender_user_id_users_id_fk": { + "name": "slack_conversation_messages_sender_user_id_users_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_task_id_tasks_id_fk": { + "name": "slack_conversation_messages_task_id_tasks_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "slack_conversation_messages_run_id_task_runs_id_fk": { + "name": "slack_conversation_messages_run_id_task_runs_id_fk", + "tableFrom": "slack_conversation_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_directory_users": { + "name": "slack_directory_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_app_user": { + "name": "is_app_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "profile_updated_at": { + "name": "profile_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_directory_users_team_id_idx": { + "name": "slack_directory_users_team_id_idx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_directory_users_unique": { + "name": "slack_directory_users_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_fast_integration_calls": { + "name": "slack_fast_integration_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fast_agent_conversation_id": { + "name": "fast_agent_conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_channel": { + "name": "slack_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_message_ts": { + "name": "slack_message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "arguments": { + "name": "arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result_preview": { + "name": "result_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_fast_integration_calls_conversation_idx": { + "name": "slack_fast_integration_calls_conversation_idx", + "columns": [ + { + "expression": "fast_agent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_user_idx": { + "name": "slack_fast_integration_calls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_fast_integration_calls_status_idx": { + "name": "slack_fast_integration_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk": { + "name": "slack_fast_integration_calls_fast_agent_conversation_id_fast_agent_conversations_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "fast_agent_conversations", + "columnsFrom": ["fast_agent_conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_fast_integration_calls_user_id_users_id_fk": { + "name": "slack_fast_integration_calls_user_id_users_id_fk", + "tableFrom": "slack_fast_integration_calls", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installation_channels": { + "name": "slack_installation_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_installation_id": { + "name": "slack_installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installation_channels_installation_id_idx": { + "name": "slack_installation_channels_installation_id_idx", + "columns": [ + { + "expression": "slack_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installation_channels_slack_installation_id_slack_installations_id_fk": { + "name": "slack_installation_channels_slack_installation_id_slack_installations_id_fk", + "tableFrom": "slack_installation_channels", + "tableTo": "slack_installations", + "columnsFrom": ["slack_installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installation_channels_unique": { + "name": "slack_installation_channels_unique", + "nullsNotDistinct": false, + "columns": ["slack_installation_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_installations": { + "name": "slack_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_domain": { + "name": "team_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_id": { + "name": "enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enterprise_name": { + "name": "enterprise_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_access_token": { + "name": "bot_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_access_token": { + "name": "user_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bot'" + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_count_snapshot": { + "name": "member_count_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_count_snapshot_at": { + "name": "member_count_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_installations_bot_user_id_idx": { + "name": "slack_installations_bot_user_id_idx", + "columns": [ + { + "expression": "bot_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_installations_active_idx": { + "name": "slack_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_installations_installed_by_user_id_users_id_fk": { + "name": "slack_installations_installed_by_user_id_users_id_fk", + "tableFrom": "slack_installations", + "tableTo": "users", + "columnsFrom": ["installed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_installations_team_id_unique": { + "name": "slack_installations_team_id_unique", + "nullsNotDistinct": false, + "columns": ["team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_user_mappings": { + "name": "slack_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slack_user_mappings_user_id_idx": { + "name": "slack_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_user_mappings_user_id_users_id_fk": { + "name": "slack_user_mappings_user_id_users_id_fk", + "tableFrom": "slack_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "slack_user_mappings_unique": { + "name": "slack_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["slack_user_id", "slack_team_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source_control_user_mappings": { + "name": "source_control_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "auth_account_id": { + "name": "auth_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "source_control_user_mappings_auth_account_unique": { + "name": "source_control_user_mappings_auth_account_unique", + "columns": [ + { + "expression": "auth_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_user_provider_host_idx": { + "name": "source_control_user_mappings_user_provider_host_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_control_user_mappings_provider_identity_unique": { + "name": "source_control_user_mappings_provider_identity_unique", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "host", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_control_user_mappings_auth_account_id_auth_accounts_id_fk": { + "name": "source_control_user_mappings_auth_account_id_auth_accounts_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_accounts", + "columnsFrom": ["auth_account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "source_control_user_mappings_user_id_auth_users_id_fk": { + "name": "source_control_user_mappings_user_id_auth_users_id_fk", + "tableFrom": "source_control_user_mappings", + "tableTo": "auth_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_artifacts": { + "name": "task_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded": { + "name": "uploaded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_artifacts_task_id_idx": { + "name": "task_artifacts_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_run_id_idx": { + "name": "task_artifacts_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_uploaded_idx": { + "name": "task_artifacts_uploaded_idx", + "columns": [ + { + "expression": "uploaded", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_created_at_idx": { + "name": "task_artifacts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_artifacts_path_idx": { + "name": "task_artifacts_path_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_artifacts_task_id_tasks_id_fk": { + "name": "task_artifacts_task_id_tasks_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_artifacts_run_id_task_runs_id_fk": { + "name": "task_artifacts_run_id_task_runs_id_fk", + "tableFrom": "task_artifacts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_artifacts_task_id_path_version_unique": { + "name": "task_artifacts_task_id_path_version_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "path", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_messages": { + "name": "task_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ts": { + "name": "ts", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protocol": { + "name": "protocol", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_messages_task_id_ts_idx": { + "name": "task_messages_task_id_ts_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_run_id_idx": { + "name": "task_messages_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_messages_created_at_idx": { + "name": "task_messages_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_messages_run_id_task_runs_id_fk": { + "name": "task_messages_run_id_task_runs_id_fk", + "tableFrom": "task_messages", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_task_id_tasks_id_fk": { + "name": "task_messages_task_id_tasks_id_fk", + "tableFrom": "task_messages", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_messages_user_id_users_id_fk": { + "name": "task_messages_user_id_users_id_fk", + "tableFrom": "task_messages", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_messages_task_protocol_ts_event_type_unique": { + "name": "task_messages_task_protocol_ts_event_type_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "protocol", "ts", "event_type"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pins": { + "name": "task_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pins_deployment_user_task_unique": { + "name": "task_pins_deployment_user_task_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_deployment_user_updated_at_idx": { + "name": "task_pins_deployment_user_updated_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pins_task_id_idx": { + "name": "task_pins_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pins_task_id_tasks_id_fk": { + "name": "task_pins_task_id_tasks_id_fk", + "tableFrom": "task_pins", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pins_user_id_users_id_fk": { + "name": "task_pins_user_id_users_id_fk", + "tableFrom": "task_pins", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_platform_issue_reports": { + "name": "task_platform_issue_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_message_id": { + "name": "task_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "slack_posted_at": { + "name": "slack_posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_platform_issue_reports_created_at_idx": { + "name": "task_platform_issue_reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_id_created_at_idx": { + "name": "task_platform_issue_reports_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_run_id_created_at_idx": { + "name": "task_platform_issue_reports_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_platform_issue_reports_task_message_id_unique": { + "name": "task_platform_issue_reports_task_message_id_unique", + "columns": [ + { + "expression": "task_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_platform_issue_reports_task_id_tasks_id_fk": { + "name": "task_platform_issue_reports_task_id_tasks_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_run_id_task_runs_id_fk": { + "name": "task_platform_issue_reports_run_id_task_runs_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_platform_issue_reports_task_message_id_task_messages_id_fk": { + "name": "task_platform_issue_reports_task_message_id_task_messages_id_fk", + "tableFrom": "task_platform_issue_reports", + "tableTo": "task_messages", + "columnsFrom": ["task_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_pull_requests": { + "name": "task_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_control_provider": { + "name": "source_control_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_sha": { + "name": "pr_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_ref": { + "name": "pr_base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_sha": { + "name": "pr_base_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_reaction_id": { + "name": "github_reaction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_check_run_id": { + "name": "github_check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "github_review_comment_id": { + "name": "github_review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_by_roomote": { + "name": "created_by_roomote", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mergeability_status": { + "name": "mergeability_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "conflict_detected_at": { + "name": "conflict_detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notification_claimed_at": { + "name": "conflict_notification_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "conflict_notified_at": { + "name": "conflict_notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auto_handle_feedback_by_user_id": { + "name": "auto_handle_feedback_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_pull_requests_task_id_idx": { + "name": "task_pull_requests_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_repository_id_idx": { + "name": "task_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_provider_repository_pr_number_idx": { + "name": "task_pull_requests_provider_repository_pr_number_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_pull_requests_mergeability_lookup_idx": { + "name": "task_pull_requests_mergeability_lookup_idx", + "columns": [ + { + "expression": "source_control_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by_roomote", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_base_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_pull_requests_task_id_tasks_id_fk": { + "name": "task_pull_requests_task_id_tasks_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_pull_requests_repository_id_repositories_id_fk": { + "name": "task_pull_requests_repository_id_repositories_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk": { + "name": "task_pull_requests_auto_handle_feedback_by_user_id_users_id_fk", + "tableFrom": "task_pull_requests", + "tableTo": "users", + "columnsFrom": ["auto_handle_feedback_by_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_pull_requests_task_pr_unique": { + "name": "task_pull_requests_task_pr_unique", + "nullsNotDistinct": false, + "columns": ["task_id", "pr_url"] + } + }, + "policies": {}, + "checkConstraints": { + "task_pull_requests_source_control_provider_check": { + "name": "task_pull_requests_source_control_provider_check", + "value": "\"task_pull_requests\".\"source_control_provider\" in ('github', 'gitlab', 'gitea', 'ado', 'bitbucket')" + } + }, + "isRLSEnabled": false + }, + "public.task_run_events": { + "name": "task_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_run_events_run_id_created_at_idx": { + "name": "task_run_events_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_task_id_created_at_idx": { + "name": "task_run_events_task_id_created_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_created_at_idx": { + "name": "task_run_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_run_events_source_created_at_idx": { + "name": "task_run_events_source_created_at_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_run_events_run_id_task_runs_id_fk": { + "name": "task_run_events_run_id_task_runs_id_fk", + "tableFrom": "task_run_events", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_run_events_task_id_tasks_id_fk": { + "name": "task_run_events_task_id_tasks_id_fk", + "tableFrom": "task_run_events", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_runs": { + "name": "task_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "task_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fresh'" + }, + "source_run_id": { + "name": "source_run_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "acting_user_id": { + "name": "acting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queue_scope": { + "name": "queue_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_phase": { + "name": "task_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "fast_agent_session_id": { + "name": "fast_agent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "((payload ->> 'fastAgentSessionId')::uuid)", + "type": "stored" + } + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log": { + "name": "log", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_cmd_id": { + "name": "sandbox_cmd_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domain": { + "name": "machine_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "machine_domains": { + "name": "machine_domains", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "initial_paths": { + "name": "initial_paths", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_port_name": { + "name": "primary_port_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sandbox_server_url": { + "name": "sandbox_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proxy_ports": { + "name": "proxy_ports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "worker_release_tag": { + "name": "worker_release_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "worker_commit": { + "name": "worker_commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_vcpus": { + "name": "configured_vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "configured_cpu_cores": { + "name": "configured_cpu_cores", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "configured_memory_mib": { + "name": "configured_memory_mib", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "snapshot_requested_at": { + "name": "snapshot_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_created_at": { + "name": "snapshot_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "snapshot_failed_at": { + "name": "snapshot_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "keepalive_ms": { + "name": "keepalive_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sleep_at": { + "name": "sleep_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sleep_requested_at": { + "name": "sleep_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "worker_heartbeat_at": { + "name": "worker_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_id": { + "name": "source_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_value": { + "name": "auth_bypass_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_bypass_header_name": { + "name": "auth_bypass_header_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dequeued_at": { + "name": "dequeued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_started_at": { + "name": "provision_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "provision_ready_at": { + "name": "provision_ready_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "setup_completed_at": { + "name": "setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "environment_setup_state": { + "name": "environment_setup_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment_setup_completed_at": { + "name": "environment_setup_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "harness_started_at": { + "name": "harness_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "runtime_task_started_at": { + "name": "runtime_task_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_assistant_output_at": { + "name": "first_assistant_output_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "task_runs_task_id_idx": { + "name": "task_runs_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_fast_agent_session_id_idx": { + "name": "task_runs_fast_agent_session_id_idx", + "columns": [ + { + "expression": "fast_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_queue_scope_idx": { + "name": "task_runs_queue_scope_idx", + "columns": [ + { + "expression": "queue_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_acting_user_id_idx": { + "name": "task_runs_acting_user_id_idx", + "columns": [ + { + "expression": "acting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_snapshot_id_idx": { + "name": "task_runs_snapshot_id_idx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_at_idx": { + "name": "task_runs_sleep_at_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_worker_heartbeat_at_idx": { + "name": "task_runs_worker_heartbeat_at_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_due_v2_idx": { + "name": "task_runs_sleep_check_due_v2_idx", + "columns": [ + { + "expression": "sleep_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_stale_worker_v2_idx": { + "name": "task_runs_sleep_check_stale_worker_v2_idx", + "columns": [ + { + "expression": "worker_heartbeat_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"worker_heartbeat_at\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_sleep_check_active_v2_idx": { + "name": "task_runs_sleep_check_active_v2_idx", + "columns": [ + { + "expression": "vendor", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"task_runs\".\"status\" IN ('running', 'idle') AND \"task_runs\".\"machine_id\" IS NOT NULL AND \"task_runs\".\"sleep_requested_at\" IS NULL AND \"task_runs\".\"snapshot_id\" IS NULL AND \"task_runs\".\"snapshot_requested_at\" IS NULL AND \"task_runs\".\"vendor\" IN ('modal', 'daytona', 'e2b', 'docker', 'blaxel', 'box', 'roomote', 'azure')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_snapshot_id_idx": { + "name": "task_runs_source_snapshot_id_idx", + "columns": [ + { + "expression": "source_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_source_run_id_idx": { + "name": "task_runs_source_run_id_idx", + "columns": [ + { + "expression": "source_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_discord_source_event_unique": { + "name": "task_runs_discord_source_event_unique", + "columns": [ + { + "expression": "(\"payload\"->>'communicationSourceEventId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'communicationProvider' = 'discord' AND \"task_runs\".\"payload\"->>'communicationSourceEventId' IS NOT NULL AND \"task_runs\".\"canceled_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_launch_idempotency_key_unique": { + "name": "task_runs_launch_idempotency_key_unique", + "columns": [ + { + "expression": "(\"payload\"->>'launchIdempotencyKey')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"task_runs\".\"payload\"->>'launchIdempotencyKey' IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_runs_first_assistant_output_at_idx": { + "name": "task_runs_first_assistant_output_at_idx", + "columns": [ + { + "expression": "first_assistant_output_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_runs_task_id_tasks_id_fk": { + "name": "task_runs_task_id_tasks_id_fk", + "tableFrom": "task_runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_runs_source_run_id_task_runs_id_fk": { + "name": "task_runs_source_run_id_task_runs_id_fk", + "tableFrom": "task_runs", + "tableTo": "task_runs", + "columnsFrom": ["source_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_runs_acting_user_id_users_id_fk": { + "name": "task_runs_acting_user_id_users_id_fk", + "tableFrom": "task_runs", + "tableTo": "users", + "columnsFrom": ["acting_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "task_runs_kind_check": { + "name": "task_runs_kind_check", + "value": "\"task_runs\".\"kind\" in ('fresh', 'resume')" + }, + "task_runs_harness_check": { + "name": "task_runs_harness_check", + "value": "\"task_runs\".\"harness\" in ('opencode-server')" + } + }, + "isRLSEnabled": false + }, + "public.task_slack_reply_details": { + "name": "task_slack_reply_details", + "schema": "", + "columns": { + "detail_id": { + "name": "detail_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings": { + "name": "findings", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_slack_reply_details_task_id_idx": { + "name": "task_slack_reply_details_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_slack_reply_details_deployment_task_detail_unique": { + "name": "task_slack_reply_details_deployment_task_detail_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detail_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_slack_reply_details_task_id_tasks_id_fk": { + "name": "task_slack_reply_details_task_id_tasks_id_fk", + "tableFrom": "task_slack_reply_details", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_start_parallel_counts": { + "name": "task_start_parallel_counts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload_kind": { + "name": "payload_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parallel_count": { + "name": "parallel_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_window_seconds": { + "name": "activity_window_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_start_parallel_counts_run_id_unique": { + "name": "task_start_parallel_counts_run_id_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_task_id_started_at_idx": { + "name": "task_start_parallel_counts_task_id_started_at_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_start_parallel_counts_started_at_idx": { + "name": "task_start_parallel_counts_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_start_parallel_counts_task_id_tasks_id_fk": { + "name": "task_start_parallel_counts_task_id_tasks_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_start_parallel_counts_run_id_task_runs_id_fk": { + "name": "task_start_parallel_counts_run_id_task_runs_id_fk", + "tableFrom": "task_start_parallel_counts", + "tableTo": "task_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow": { + "name": "workflow", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'visible'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "initiator_kind": { + "name": "initiator_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiator_user_id": { + "name": "initiator_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_automation": { + "name": "initiator_automation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_external_id": { + "name": "actor_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_display_name": { + "name": "actor_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_kind": { + "name": "commit_author_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_user_id": { + "name": "commit_author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_login": { + "name": "commit_author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_author_external_id": { + "name": "commit_author_external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_assignee_login": { + "name": "pr_assignee_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_session_id": { + "name": "linear_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_issue_id": { + "name": "linear_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "harness": { + "name": "harness", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'opencode-server'" + }, + "harness_session_id": { + "name": "harness_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_provider": { + "name": "model_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_edited_by_user_at": { + "name": "title_edited_by_user_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "llm_title_checkpoint": { + "name": "llm_title_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_objective": { + "name": "goal_objective", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_status": { + "name": "goal_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_max_continuations": { + "name": "goal_max_continuations", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "goal_continuations_used": { + "name": "goal_continuations_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocked_reason": { + "name": "goal_blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_completed_at": { + "name": "goal_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "goal_last_continuation_id": { + "name": "goal_last_continuation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_continuation_ids": { + "name": "goal_continuation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_generation_ids": { + "name": "goal_generation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "goal_blocker_candidate_reason": { + "name": "goal_blocker_candidate_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_blocker_candidate_count": { + "name": "goal_blocker_candidate_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "goal_blocker_last_continuation_used": { + "name": "goal_blocker_last_continuation_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "draft_prompt": { + "name": "draft_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_work_kind": { + "name": "requested_work_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "requested_work_kind_source": { + "name": "requested_work_kind_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system_default'" + }, + "requested_work_kind_confidence": { + "name": "requested_work_kind_confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "harness_instructions": { + "name": "harness_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compute_duration_ms": { + "name": "compute_duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "activity_at": { + "name": "activity_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_initiator_user_id_idx": { + "name": "tasks_initiator_user_id_idx", + "columns": [ + { + "expression": "initiator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_initiator_automation_idx": { + "name": "tasks_initiator_automation_idx", + "columns": [ + { + "expression": "initiator_automation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_workflow_idx": { + "name": "tasks_workflow_idx", + "columns": [ + { + "expression": "workflow", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_visibility_activity_at_idx": { + "name": "tasks_visibility_activity_at_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_harness_session_id_idx": { + "name": "tasks_harness_session_id_idx", + "columns": [ + { + "expression": "harness_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_timestamp_idx": { + "name": "tasks_timestamp_idx", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_deployment_activity_at_idx": { + "name": "tasks_deployment_activity_at_idx", + "columns": [ + { + "expression": "activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_initiator_user_id_users_id_fk": { + "name": "tasks_initiator_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["initiator_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_initiator_automation_automations_key_fk": { + "name": "tasks_initiator_automation_automations_key_fk", + "tableFrom": "tasks", + "tableTo": "automations", + "columnsFrom": ["initiator_automation"], + "columnsTo": ["key"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_commit_author_user_id_users_id_fk": { + "name": "tasks_commit_author_user_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["commit_author_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "tasks_initiator_shape_check": { + "name": "tasks_initiator_shape_check", + "value": "(\"tasks\".\"initiator_kind\" = 'user' AND \"tasks\".\"initiator_automation\" IS NULL AND (\"tasks\".\"initiator_user_id\" IS NOT NULL OR \"tasks\".\"actor_external_id\" IS NOT NULL)) OR (\"tasks\".\"initiator_kind\" = 'automation' AND \"tasks\".\"initiator_automation\" IS NOT NULL AND \"tasks\".\"initiator_user_id\" IS NULL)" + }, + "tasks_workflow_check": { + "name": "tasks_workflow_check", + "value": "\"tasks\".\"workflow\" in ('standard', 'pr_review', 'pr_conflict_resolve', 'scan', 'mcp_recommendations', 'setup_onboarding', 'env_snapshot', 'eval')" + }, + "tasks_surface_check": { + "name": "tasks_surface_check", + "value": "\"tasks\".\"surface\" in ('web', 'api', 'slack', 'teams', 'telegram', 'discord', 'linear', 'github', 'gitlab', 'gitea', 'ado', 'bitbucket', 'system')" + }, + "tasks_trigger_check": { + "name": "tasks_trigger_check", + "value": "\"tasks\".\"trigger\" in ('message', 'webhook', 'schedule', 'manual')" + }, + "tasks_visibility_check": { + "name": "tasks_visibility_check", + "value": "\"tasks\".\"visibility\" in ('visible', 'hidden')" + }, + "tasks_state_check": { + "name": "tasks_state_check", + "value": "\"tasks\".\"state\" in ('active', 'completed', 'failed', 'canceled')" + }, + "tasks_goal_status_check": { + "name": "tasks_goal_status_check", + "value": "\"tasks\".\"goal_status\" IS NULL OR \"tasks\".\"goal_status\" in ('active', 'complete', 'blocked', 'budget_limited')" + }, + "tasks_goal_continuations_check": { + "name": "tasks_goal_continuations_check", + "value": "\"tasks\".\"goal_continuations_used\" >= 0 AND (\"tasks\".\"goal_max_continuations\" IS NULL OR \"tasks\".\"goal_max_continuations\" > 0)" + }, + "tasks_goal_blocker_candidate_count_check": { + "name": "tasks_goal_blocker_candidate_count_check", + "value": "\"tasks\".\"goal_blocker_candidate_count\" >= 0" + }, + "tasks_harness_check": { + "name": "tasks_harness_check", + "value": "\"tasks\".\"harness\" in ('opencode-server')" + }, + "tasks_requested_work_kind_check": { + "name": "tasks_requested_work_kind_check", + "value": "\"tasks\".\"requested_work_kind\" in ('question', 'plan', 'implement', 'unknown')" + }, + "tasks_requested_work_kind_source_check": { + "name": "tasks_requested_work_kind_source_check", + "value": "\"tasks\".\"requested_work_kind_source\" in ('explicit_bootstrap', 'task_tool', 'llm_classifier', 'inherited', 'system_default')" + }, + "tasks_commit_author_kind_check": { + "name": "tasks_commit_author_kind_check", + "value": "\"tasks\".\"commit_author_kind\" IS NULL OR \"tasks\".\"commit_author_kind\" in ('roomote', 'user', 'external')" + } + }, + "isRLSEnabled": false + }, + "public.teams_installations": { + "name": "teams_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_key": { + "name": "installation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_type": { + "name": "conversation_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_app_id": { + "name": "bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_name": { + "name": "bot_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_installations_tenant_id_idx": { + "name": "teams_installations_tenant_id_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_team_id_idx": { + "name": "teams_installations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_conversation_id_idx": { + "name": "teams_installations_conversation_id_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_installations_active_idx": { + "name": "teams_installations_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_installations_installation_key_unique": { + "name": "teams_installations_installation_key_unique", + "nullsNotDistinct": false, + "columns": ["installation_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams_user_mappings": { + "name": "teams_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "teams_user_id": { + "name": "teams_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_tenant_id": { + "name": "teams_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "teams_aad_object_id": { + "name": "teams_aad_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_user_mappings_aad_object_idx": { + "name": "teams_user_mappings_aad_object_idx", + "columns": [ + { + "expression": "teams_aad_object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "teams_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_user_mappings_user_id_idx": { + "name": "teams_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_user_mappings_user_id_users_id_fk": { + "name": "teams_user_mappings_user_id_users_id_fk", + "tableFrom": "teams_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_user_mappings_unique": { + "name": "teams_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["teams_user_id", "teams_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telegram_user_mappings": { + "name": "telegram_user_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "telegram_user_id": { + "name": "telegram_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "telegram_username": { + "name": "telegram_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "telegram_user_mappings_user_id_idx": { + "name": "telegram_user_mappings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "telegram_user_mappings_user_id_users_id_fk": { + "name": "telegram_user_mappings_user_id_users_id_fk", + "tableFrom": "telegram_user_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "telegram_user_mappings_unique": { + "name": "telegram_user_mappings_unique", + "nullsNotDistinct": false, + "columns": ["telegram_user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_messages": { + "name": "tracked_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thread_ts": { + "name": "thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "work_item_id": { + "name": "work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tracked_messages_kind_dedupe_key_unique": { + "name": "tracked_messages_kind_dedupe_key_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_work_item_id_idx": { + "name": "tracked_messages_work_item_id_idx", + "columns": [ + { + "expression": "work_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_channel_message_idx": { + "name": "tracked_messages_channel_message_idx", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_ts", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracked_messages_automation_channel_posted_idx": { + "name": "tracked_messages_automation_channel_posted_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "posted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_messages_work_item_id_work_items_id_fk": { + "name": "tracked_messages_work_item_id_work_items_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "work_items", + "columnsFrom": ["work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_automation_key_automations_key_fk": { + "name": "tracked_messages_automation_key_automations_key_fk", + "tableFrom": "tracked_messages", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracked_messages_created_by_user_id_users_id_fk": { + "name": "tracked_messages_created_by_user_id_users_id_fk", + "tableFrom": "tracked_messages", + "tableTo": "users", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_api_keys_user_id_idx": { + "name": "user_api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_api_keys_user_deployment_provider_unique": { + "name": "user_api_keys_user_deployment_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_users_id_fk": { + "name": "user_api_keys_user_id_users_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "analytics_id": { + "name": "analytics_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_consented_at": { + "name": "cookie_consented_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by_invite_id": { + "name": "invited_by_invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_analytics_id_unique_idx": { + "name": "users_analytics_id_unique_idx", + "columns": [ + { + "expression": "analytics_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "succeeded_at": { + "name": "succeeded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhooks_provider_delivery_id_unique": { + "name": "webhooks_provider_delivery_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_event_idx": { + "name": "webhooks_event_idx", + "columns": [ + { + "expression": "event", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhooks_created_at_idx": { + "name": "webhooks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhooks_status_exclusive": { + "name": "webhooks_status_exclusive", + "value": "(\n (succeeded_at IS NOT NULL)::int +\n (failed_at IS NOT NULL)::int\n ) <= 1" + } + }, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_key": { + "name": "automation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "selected_by_user_id": { + "name": "selected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_work_item_id": { + "name": "source_work_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_prompt": { + "name": "execution_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_context": { + "name": "investigation_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_kind": { + "name": "action_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "repository_ids": { + "name": "repository_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "target_repository_full_name": { + "name": "target_repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_environment_id": { + "name": "target_environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_readiness": { + "name": "workspace_readiness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "readiness_message": { + "name": "readiness_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "launch_claimed_at": { + "name": "launch_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launched_task_id": { + "name": "launched_task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failed_at": { + "name": "failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "launch_error": { + "name": "launch_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_source_task_idx": { + "name": "work_items_source_task_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_kind_status_idx": { + "name": "work_items_kind_status_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_automation_key_fingerprint_idx": { + "name": "work_items_automation_key_fingerprint_idx", + "columns": [ + { + "expression": "automation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_fingerprint_idx": { + "name": "work_items_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_launched_task_id_idx": { + "name": "work_items_launched_task_id_idx", + "columns": [ + { + "expression": "launched_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "work_items_source_task_kind_sort_order_unique": { + "name": "work_items_source_task_kind_sort_order_unique", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "work_items_automation_key_automations_key_fk": { + "name": "work_items_automation_key_automations_key_fk", + "tableFrom": "work_items", + "tableTo": "automations", + "columnsFrom": ["automation_key"], + "columnsTo": ["key"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_task_id_tasks_id_fk": { + "name": "work_items_source_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["source_task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "work_items_selected_by_user_id_users_id_fk": { + "name": "work_items_selected_by_user_id_users_id_fk", + "tableFrom": "work_items", + "tableTo": "users", + "columnsFrom": ["selected_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_source_work_item_id_work_items_id_fk": { + "name": "work_items_source_work_item_id_work_items_id_fk", + "tableFrom": "work_items", + "tableTo": "work_items", + "columnsFrom": ["source_work_item_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_target_environment_id_environments_id_fk": { + "name": "work_items_target_environment_id_environments_id_fk", + "tableFrom": "work_items", + "tableTo": "environments", + "columnsFrom": ["target_environment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "work_items_launched_task_id_tasks_id_fk": { + "name": "work_items_launched_task_id_tasks_id_fk", + "tableFrom": "work_items", + "tableTo": "tasks", + "columnsFrom": ["launched_task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 228f1e8f0..9169311e1 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -442,6 +442,13 @@ "when": 1787776112381, "tag": "0062_neat_lady_deathstrike", "breakpoints": true + }, + { + "idx": 63, + "version": "7", + "when": 1787897902620, + "tag": "0063_organic_garia", + "breakpoints": true } ] } diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index e8ca0e6fe..68ec520d8 100644 --- a/packages/db/src/lib/model-runtime-config.test.ts +++ b/packages/db/src/lib/model-runtime-config.test.ts @@ -71,8 +71,11 @@ vi.mock('../schema', () => ({ })); import { + invalidateBrainEnabledCache, + isBrainEnabled, isBrainProviderConfigured, resetBrainProviderConfiguredCache, + resolveBrainEnabledState, resolveEffectiveModelRuntimeEnv, resolveModelProviderEnvValue, resolveSandboxModelRuntimeEnv, @@ -1371,3 +1374,77 @@ describe('isBrainProviderConfigured', () => { ).toBeLessThanOrEqual(1); }); }); + +describe('isBrainEnabled', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + invalidateBrainEnabledCache(); + resetBrainProviderConfiguredCache(); + mockDecryptSecrets.mockImplementation(async (value) => value); + mockEnvironmentVariablesFindMany.mockResolvedValue([]); + mockDeploymentSettingsFindFirst.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + invalidateBrainEnabledCache(); + resetBrainProviderConfiguredCache(); + }); + + it('is off with no stored choice and no legacy key', async () => { + await expect(resolveBrainEnabledState()).resolves.toEqual({ + enabled: false, + fromLegacyKey: true, + }); + }); + + it('falls back to the legacy explicit Brain key when no choice is stored', async () => { + vi.stubEnv('R_BRAIN_OPENROUTER_API_KEY', 'sk-or-brain'); + + await expect(resolveBrainEnabledState()).resolves.toEqual({ + enabled: true, + fromLegacyKey: true, + }); + }); + + it('treats a missing brainEnabled column value as no stored choice', async () => { + // Rows written before the column existed select as null. + mockDeploymentSettingsFindFirst.mockResolvedValue({ brainEnabled: null }); + vi.stubEnv('R_BRAIN_OPENAI_API_KEY', 'sk-brain'); + + await expect(isBrainEnabled()).resolves.toBe(true); + }); + + it('lets an explicit stored choice win over the legacy key in both directions', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ brainEnabled: false }); + vi.stubEnv('R_BRAIN_OPENAI_API_KEY', 'sk-brain'); + + await expect(resolveBrainEnabledState()).resolves.toEqual({ + enabled: false, + fromLegacyKey: false, + }); + + invalidateBrainEnabledCache(); + mockDeploymentSettingsFindFirst.mockResolvedValue({ brainEnabled: true }); + vi.unstubAllEnvs(); + + await expect(resolveBrainEnabledState()).resolves.toEqual({ + enabled: true, + fromLegacyKey: false, + }); + }); + + it('caches the answer until invalidated', async () => { + mockDeploymentSettingsFindFirst.mockResolvedValue({ brainEnabled: true }); + + await expect(isBrainEnabled()).resolves.toBe(true); + await expect(isBrainEnabled()).resolves.toBe(true); + expect(mockDeploymentSettingsFindFirst).toHaveBeenCalledTimes(1); + + mockDeploymentSettingsFindFirst.mockResolvedValue({ brainEnabled: false }); + invalidateBrainEnabledCache(); + + await expect(isBrainEnabled()).resolves.toBe(false); + }); +}); diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts index 56b0c34d1..99cc05a39 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -246,10 +246,10 @@ export function resetBrainProviderConfiguredCache(): void { * Whether an operator explicitly enabled the Brain by configuring a * brain-specific provider key. * - * This is the activation predicate for everything user-visible: delivering - * the gbrain MCP server to sandboxes, listing the Brain as a fast-agent - * integration, resolving Brain connections, and accepting task memories. It - * is deliberately narrower than the Brain's inference-provider resolution, + * This is the legacy activation signal, kept as the fallback inside + * `resolveBrainEnabledState` for deployments that opted in before the + * `brainEnabled` Settings toggle existed. New code gates on `isBrainEnabled`. + * It is deliberately narrower than the Brain's inference-provider resolution, * whose general-key fallback exists so an already-enabled Brain can bill * through the deployment's regular provider key; counting that fallback (or * template-generated plumbing) as activation would turn the Brain on for @@ -276,6 +276,82 @@ export async function isBrainProviderConfigured(): Promise { return value; } +export type BrainEnabledState = { + enabled: boolean; + /** + * True when no explicit choice is stored and the legacy activation signal + * (an explicit R_BRAIN_* provider key) decided the answer. + */ + fromLegacyKey: boolean; +}; + +let brainEnabledCache: { + value: BrainEnabledState; + expiresAtMs: number; +} | null = null; + +/** Drop the cached answer, so the next call re-reads settings. */ +export function invalidateBrainEnabledCache(): void { + brainEnabledCache = null; +} + +/** + * Whether the Brain is on for this deployment, with its provenance. This is + * the activation predicate for everything user-visible: delivering the gbrain + * MCP server to sandboxes, listing the Brain as a fast-agent integration, + * resolving Brain connections, and accepting task memories. + * + * The stored Settings toggle wins when set (either way). A null/missing value + * falls back to `isBrainProviderConfigured()` so deployments that opted in + * with an explicit R_BRAIN_* key before the toggle existed stay enabled + * without a backfill. Cached like the legacy predicate and for the same + * reason: it fronts per-event paths, and the answer only changes when an + * admin edits Settings. + */ +export async function resolveBrainEnabledState(): Promise { + const cached = brainEnabledCache; + + if (cached && cached.expiresAtMs > Date.now()) { + return cached.value; + } + + const deployment = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, DEFAULT_DEPLOYMENT_ID), + columns: { brainEnabled: true }, + }); + const stored = deployment?.brainEnabled ?? null; + const value: BrainEnabledState = + stored === null + ? { enabled: await isBrainProviderConfigured(), fromLegacyKey: true } + : { enabled: stored, fromLegacyKey: false }; + + brainEnabledCache = { + value, + expiresAtMs: Date.now() + BRAIN_PROVIDER_CONFIGURED_CACHE_TTL_MS, + }; + + return value; +} + +export async function isBrainEnabled(): Promise { + return (await resolveBrainEnabledState()).enabled; +} + +/** Persist an explicit Brain on/off choice and drop the cached answer. */ +export async function setBrainEnabled(value: boolean): Promise { + const now = new Date(); + + await db + .insert(deploymentSettings) + .values({ id: DEFAULT_DEPLOYMENT_ID, brainEnabled: value, updatedAt: now }) + .onConflictDoUpdate({ + target: deploymentSettings.id, + set: { brainEnabled: value, updatedAt: now }, + }); + + invalidateBrainEnabledCache(); +} + type ModelRuntimeEnvOptions = { runtimeEnv?: Partial>; deploymentEnvVars?: Record; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ec14663c2..b6e9981cb 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -184,6 +184,12 @@ export const deploymentSettings = pgTable('deployment_settings', { 'runtime_compute_config', ).$type(), accessPolicy: jsonb('access_policy').$type(), + // Whether the Brain (Memory) is on for this deployment. Deliberately + // nullable with no default: null means "no explicit choice", and readers + // fall back to the legacy activation signal (an explicit R_BRAIN_* provider + // key) so deployments enabled before this toggle existed stay enabled + // without a backfill. + brainEnabled: boolean('brain_enabled'), // Signed Roomote license key (RMLK1..) raising the // deployment's seat limit above the free tier; null for unlicensed // deployments. Verified at read time, never trusted as stored. diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 62793cbc8..67d44f39f 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -715,8 +715,10 @@ export function isRoomoteCloudEnabled( * signal means "a Brain could be wired here", never "an operator turned the * Brain on". Activation — everything user-visible, from delivering the * gbrain MCP server to agents to running ingestion — additionally requires - * an explicit R_BRAIN_* provider key and lives in isBrainProviderConfigured - * (@roomote/db), which also reads Settings. + * the Brain to be enabled: the `brainEnabled` Settings toggle, falling back + * to an explicit R_BRAIN_* provider key for deployments that opted in + * before the toggle existed. That predicate lives in isBrainEnabled + * (@roomote/db). * * Not R_GBRAIN_URL, which every compose file defaults to a service address * whether or not that service runs. Keying on a defaulted value made this @@ -726,8 +728,8 @@ export function isRoomoteCloudEnabled( * The split exists because this gates the cheap, synchronous paths that only * need to know a Brain might exist, above all the outbox insert inside the * run-completion transaction, which must not do a database lookup of its - * own. Enqueuing memories for a Brain that has no key yet is intentional: - * the drainer holds them until one is configured, so turning the Brain on + * own. Enqueuing memories for a Brain that is not enabled yet is + * intentional: the drainer holds them until it is, so turning the Brain on * later picks up the history rather than starting from that moment. */ export function isBrainConfigured(env: { diff --git a/packages/sdk/src/server/lib/brain-clients.ts b/packages/sdk/src/server/lib/brain-clients.ts index 650763aa8..9205e596f 100644 --- a/packages/sdk/src/server/lib/brain-clients.ts +++ b/packages/sdk/src/server/lib/brain-clients.ts @@ -19,7 +19,7 @@ import { and, db, eq, - isBrainProviderConfigured, + isBrainEnabled, isNull, mcpConnections, resetBrainIngestionState, @@ -238,10 +238,11 @@ export async function mintGbrainAccessToken( export async function resolveBrainConnection( role: 'agent' | 'ingest' | 'maintenance', ): Promise<{ baseUrl: string; token: string } | null> { - // Explicit R_BRAIN_* provider key only: the gateway token and R_GBRAIN_URL - // are template-generated plumbing on some platforms, so neither can carry - // the operator's intent to turn the Brain on. - if (!(await isBrainProviderConfigured())) { + // The Settings toggle (with its legacy R_BRAIN_* key fallback) is the one + // activation signal: the gateway token and R_GBRAIN_URL are + // template-generated plumbing on some platforms, so neither can carry the + // operator's intent to turn the Brain on. + if (!(await isBrainEnabled())) { return null; } diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index bc742e8a2..eaa42a9f2 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -31,7 +31,7 @@ const { mockDesc, mockFindCustomServers, mockFindConnectionFirst, - mockIsBrainProviderConfigured, + mockIsBrainEnabled, } = vi.hoisted(() => { const mockOrderBy = vi.fn(); const mockWhere = vi.fn(() => ({ @@ -76,9 +76,7 @@ const { mockFindConnectionFirst: vi.fn<(...args: unknown[]) => Promise>( async () => undefined, ), - mockIsBrainProviderConfigured: vi.fn<() => Promise>( - async () => false, - ), + mockIsBrainEnabled: vi.fn<() => Promise>(async () => false), }; }); @@ -128,7 +126,7 @@ vi.mock('@roomote/db/server', () => ({ isNull: mockIsNull, isNotNull: vi.fn((column: unknown) => ({ type: 'isNotNull', column })), inArray: mockInArray, - isBrainProviderConfigured: mockIsBrainProviderConfigured, + isBrainEnabled: mockIsBrainEnabled, })); vi.mock('@roomote/db/encryption', () => ({ @@ -235,7 +233,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { vi.clearAllMocks(); mockEnv.R_CURATED_INTEGRATIONS_DISABLED = false; mockEnv.R_GBRAIN_URL = undefined; - mockIsBrainProviderConfigured.mockResolvedValue(false); + mockIsBrainEnabled.mockResolvedValue(false); mockFindTaskRun.mockResolvedValue({ actingUserId: null, }); @@ -286,7 +284,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { it('delivers the Brain when an explicit Brain provider key is configured', async () => { mockEnv.R_GBRAIN_URL = 'http://gbrain:8931'; - mockIsBrainProviderConfigured.mockResolvedValue(true); + mockIsBrainEnabled.mockResolvedValue(true); const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', @@ -303,7 +301,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { // neither can mean an operator turned the Brain on. Delivering here would // point every agent's required preflight at a Brain nobody enabled. mockEnv.R_GBRAIN_URL = 'http://gbrain:8931'; - mockIsBrainProviderConfigured.mockResolvedValue(false); + mockIsBrainEnabled.mockResolvedValue(false); const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', @@ -314,7 +312,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { it('never delivers the Brain without an address to proxy to', async () => { mockEnv.R_GBRAIN_URL = undefined; - mockIsBrainProviderConfigured.mockResolvedValue(true); + mockIsBrainEnabled.mockResolvedValue(true); const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 01aad3390..2de03b385 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -15,7 +15,7 @@ import { eq, and, inArray, - isBrainProviderConfigured, + isBrainEnabled, isNull, isNotNull, or, @@ -122,11 +122,7 @@ async function resolveMcpServerConfigs(options: { } } - if ( - Env.R_GBRAIN_URL && - !servers[BRAIN_MCP_ID] && - (await isBrainProviderConfigured()) - ) { + if (Env.R_GBRAIN_URL && !servers[BRAIN_MCP_ID] && (await isBrainEnabled())) { servers[BRAIN_MCP_ID] = { url: `${options.requestOrigin ?? ''}${BRAIN_PROXY_PATH}`, headers: {}, From 853fa956f4ae029741a9cd95d111bc3f8c166092 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:52:05 -0500 Subject: [PATCH 016/158] [Feat] Let Fast attach Slack images to coding tasks (#1767) * feat: pass Slack Fast images to coding tasks * fix: require opt-in for Fast task images * fix: forward Fast follow-up images to tasks --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> Co-authored-by: daniel-lxs --- .../src/run-task/__tests__/run-task.test.ts | 12 +++- .../fast-agent-native-tool-bridge.test.ts | 16 +++++ .../__tests__/fast-agent-prompt.test.ts | 5 ++ .../__tests__/fast-agent-service.test.ts | 64 ++++++++++++++++++- .../fast-agent-task-launcher.test.ts | 28 ++++++++ .../__tests__/fast-agent-tasks.test.ts | 10 ++- .../fast-agent/fast-agent-conversation.ts | 1 + .../fast-agent-native-tool-bridge.ts | 6 +- .../server/fast-agent/fast-agent-prompt.ts | 3 +- .../server/fast-agent/fast-agent-service.ts | 10 ++- .../fast-agent/fast-agent-task-launcher.ts | 12 +++- .../src/server/fast-agent/fast-agent-tasks.ts | 8 ++- .../src/__tests__/slack-notifier.test.ts | 9 +++ packages/slack/src/thread-image-utils.ts | 1 - 14 files changed, 172 insertions(+), 13 deletions(-) diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index 76e42d9bf..fc0d5aacc 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -3233,7 +3233,12 @@ describe('runTask', () => { taskId: 'task-151', payloadKind: TaskPayloadKind.StandardTask, harness: 'opencode-server', - payload: {}, + payload: { + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], + }, result: null, } as never, envVars: {}, @@ -3282,7 +3287,10 @@ describe('runTask', () => { const harnessManager = harnessManagerInstances.at(0); expect(harnessManager?.startNewTask).toHaveBeenCalledWith({ prompt: 'Fix the failing test', - images: undefined, + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], visibleInTranscript: false, }); expect(harnessManager?.initializeWithoutPrompt).not.toHaveBeenCalled(); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts index a9abb10b8..40b466ca4 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-native-tool-bridge.test.ts @@ -80,6 +80,10 @@ describe('Fast native OpenCode tool bridge', () => { join(toolsDirectory, 'launch_task.js'), 'utf8', ); + const sendTaskMessageSource = await readFile( + join(toolsDirectory, 'send_task_message.js'), + 'utf8', + ); const showWidgetSource = await readFile( join(toolsDirectory, 'show_widget.js'), 'utf8', @@ -112,6 +116,11 @@ describe('Fast native OpenCode tool bridge', () => { expect(replySource).toContain('Launchable follow-ups'); expect(launchTaskSource).toContain('model: z.string().min(1)'); expect(launchTaskSource).toContain('deployment-enabled model ID'); + expect(launchTaskSource).toContain('includeImages: z.boolean().optional()'); + expect(launchTaskSource).toContain( + 'Current-turn images are attached only when includeImages is true', + ); + expect(launchTaskSource).toContain('defaults to false'); expect(launchTaskSource).toContain( 'Brief user-facing description of the work now underway', ); @@ -125,6 +134,13 @@ describe('Fast native OpenCode tool bridge', () => { expect(launchTaskSource).toContain( 'to run against all active repositories', ); + expect(sendTaskMessageSource).toContain( + 'includeImages: z.boolean().optional()', + ); + expect(sendTaskMessageSource).toContain( + 'Current-turn images are attached only when includeImages is true', + ); + expect(sendTaskMessageSource).toContain('defaults to false'); expect(showWidgetSource).toContain('invoke("show_widget"'); expect(showWidgetSource).toContain('textFallback: z.string().max(4000)'); expect(showWidgetSource).toContain( diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts index e9f074fef..eecc9bd85 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-prompt.test.ts @@ -127,6 +127,11 @@ describe('buildFastAgentSystemPrompt', () => { 'Call it immediately, before an acknowledgement or other user-visible response', ); expect(prompt).toContain('kickoffMessage'); + expect(prompt).toContain('"includeImages"'); + expect(prompt).toContain('images are not attached by default'); + expect(prompt).toContain( + 'supported images from the active conversation turn are relevant to that instruction', + ); expect(prompt).toContain("describing the user's work now underway"); expect(prompt).toContain( 'The kickoff acknowledges the request, but it is not the only communication expected while longer work continues', diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index f1afd3692..5a3e84534 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -1847,6 +1847,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { prompt: 'Fix checkout.', environmentId: 'env-1', model: 'anthropic/claude-sonnet-5', + includeImages: true, kickoffMessage: 'I’m delegating the checkout fix.', }); expect(result).toEqual( @@ -1859,6 +1860,10 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { const result = await answerFastAgentQuestion({ ...baseParams, question: 'Fix checkout.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/gif;base64,c2NyZWVuc2hvdC0y', + ], adapter, }); @@ -1871,6 +1876,10 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ); expect(launchTask).toHaveBeenCalledWith( expect.objectContaining({ + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/gif;base64,c2NyZWVuc2hvdC0y', + ], model: 'anthropic/claude-sonnet-5', prompt: 'Fix checkout.', }), @@ -1923,11 +1932,16 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }, ); - await answerFastAgentQuestion({ ...baseParams, adapter }); + await answerFastAgentQuestion({ + ...baseParams, + images: ['data:image/png;base64,bm90LWZvcndhcmRlZA=='], + adapter, + }); expect(launchTask).toHaveBeenCalledWith( expect.objectContaining({ environmentId: ALL_REPOSITORIES }), ); + expect(launchTask.mock.calls[0]?.[0]).not.toHaveProperty('images'); }); it.each(['slack', 'discord', 'teams', 'telegram'] as const)( @@ -2225,6 +2239,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { invokeTool(nativeToolNames.sendTaskMessage, { taskId: 'task-1', message: 'Include the failing test.', + includeImages: true, }), ).resolves.toEqual({ success: true }); await invokeTool(nativeToolNames.sendChatReply, { @@ -2240,18 +2255,63 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }), }); - await answerFastAgentQuestion({ ...baseParams, adapter }); + await answerFastAgentQuestion({ + ...baseParams, + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], + adapter, + }); expect(mocks.sendTaskMessage).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1' }), { taskId: 'task-1', message: 'Include the failing test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], }, ); expect(order).toEqual(['steer', 'reply']); }); + it('does not attach current-turn images to a task message without opt-in', async () => { + mocks.getActiveTasks.mockResolvedValue([ + { taskId: 'task-1', title: 'Checkout', status: 'running' }, + ]); + mocks.generateText.mockImplementation( + async (_params, _session, options) => { + await options.onSessionReady('opencode-session-1'); + await invokeTool(nativeToolNames.sendTaskMessage, { + taskId: 'task-1', + message: 'Include the failing test.', + }); + await invokeTool(nativeToolNames.sendChatReply, { + purpose: 'closeout', + message: 'The task was updated.', + }); + return ''; + }, + ); + + await answerFastAgentQuestion({ + ...baseParams, + images: ['data:image/png;base64,bm90LWZvcndhcmRlZA=='], + adapter: callbacks(), + }); + + expect(mocks.sendTaskMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + { + taskId: 'task-1', + message: 'Include the failing test.', + }, + ); + }); + it('still requires an acknowledgement before canceling a task', async () => { mocks.getActiveTasks.mockResolvedValue([ { taskId: 'task-1', title: 'Checkout', status: 'running' }, diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts index ee400820e..54c629590 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-task-launcher.test.ts @@ -116,6 +116,9 @@ describe('createFastAgentSlackTaskLauncher', () => { taskId: 'task-1', taskUrl: 'https://roomote.example/task/task-1', }); + expect( + mocks.enqueueTask.mock.calls[0]?.[0]?.task.payload, + ).not.toHaveProperty('images'); expect(order).toEqual(['kickoff', 'queued']); }); @@ -163,6 +166,31 @@ describe('createFastAgentSlackTaskLauncher', () => { expect(task.payload).not.toHaveProperty('environmentId'); }); + it('retains multiple Fast turn images in the child task payload', async () => { + const images = [ + 'data:image/png;base64,cG5nLWJ5dGVz', + 'data:image/webp;base64,d2VicC1ieXRlcw==', + ]; + const launchTask = createFastAgentSlackTaskLauncher({ + userId: 'user-1', + teamId: 'T123', + channelId: 'C123', + threadTs: '100.001', + }); + + await launchTask({ + prompt: 'Implement the UI shown in these screenshots', + images, + environmentId: null, + parentSessionId: '11111111-1111-4111-8111-111111111111', + postKickoff: vi.fn(), + }); + + expect(mocks.enqueueTask.mock.calls[0]?.[0]?.task.payload.images).toEqual( + images, + ); + }); + it('runs afterKickoff inside the launch gate', async () => { const order: string[] = []; const afterKickoff = vi.fn(async () => { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts index d0f8974b1..2b09adb3d 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tasks.test.ts @@ -5,7 +5,7 @@ describe('fast-agent task operations', () => { vi.unstubAllGlobals(); }); - it('steers messages to active tasks through a reverse-proxy pathname', async () => { + it('steers messages with images through a reverse-proxy pathname', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true }), { status: 200, @@ -23,6 +23,10 @@ describe('fast-agent task operations', () => { { taskId: 'task-42', message: 'Also add a test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], }, ); @@ -36,6 +40,10 @@ describe('fast-agent task operations', () => { }), body: JSON.stringify({ message: 'Also add a test.', + images: [ + 'data:image/png;base64,c2NyZWVuc2hvdC0x', + 'data:image/webp;base64,c2NyZWVuc2hvdC0y', + ], senderMode: 'fast_agent', }), }), diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index 0c6c4eafa..3bf9bb36d 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -53,6 +53,7 @@ export type FastAgentReaction = { export type LaunchFastAgentTask = (params: { prompt: string; + images?: string[]; environmentId: string | null; model?: string | null; parentSessionId: string; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 4e87c67ec..d0f85b63e 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -281,11 +281,12 @@ import { z } from "zod" import { invoke } from "../roomote-fast-tool-bridge.js" export default { - description: "Delegate new repository or workspace execution work to a Roomote task, optionally using an exact deployment-enabled model ID from the system prompt.", + description: "Delegate new repository or workspace execution work to a Roomote task, optionally using an exact deployment-enabled model ID. Current-turn images are attached only when includeImages is true.", args: { prompt: z.string().min(1).describe("Complete task instruction"), environmentId: z.string().nullable().optional().describe(${JSON.stringify(`Exact environment ID from the system prompt; omit, pass null, or pass "${ALL_REPOSITORIES}" to run against all active repositories`)}), model: z.string().min(1).nullable().optional().describe("Exact deployment-enabled model ID; omit or pass null to use the deployment default"), + includeImages: z.boolean().optional().describe("Set true to attach supported images from the active conversation turn; defaults to false"), kickoffMessage: z.string().min(1).describe("Brief user-facing description of the work now underway; do not mention delegation, launching, or queue state"), }, execute: (args, context) => invoke("launch_task", args, context), @@ -297,10 +298,11 @@ import { z } from "zod" import { invoke } from "../roomote-fast-tool-bridge.js" export default { - description: "Send a new instruction to an active or resumable task delegated by this Fast conversation.", + description: "Send a new instruction to an active or resumable task delegated by this Fast conversation. Current-turn images are attached only when includeImages is true.", args: { taskId: z.string().nullable().optional(), message: z.string().min(1), + includeImages: z.boolean().optional().describe("Set true to attach supported images from the active conversation turn; defaults to false"), }, execute: (args, context) => invoke("send_task_message", args, context), } diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts index 72a0e9467..8dea4e604 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-prompt.ts @@ -175,6 +175,7 @@ ${formatIntegrationsForPrompt(availableIntegrations)} - An acknowledgement or progress update does not end the turn. Continue using native tools, then post a closeout or clarification. - Before calling a deployment MCP tool other than Roomote custom automation management, or canceling a task on a human-authored turn, first post a brief acknowledgement. The runtime rejects those calls until an acknowledgement or progress update has been delivered. Platform events are exempt. Sending a task message is also exempt so steering is not delayed behind a user-visible reply. - "launch_task" behaves like a normal tool. Do not send a separate acknowledgement before it. Include a brief "kickoffMessage" describing the user's work now underway; the runtime automatically posts that kickoff and task link as a progress artifact for each launch. The kickoff acknowledges the request, but it is not the only communication expected while longer work continues. +- Set "includeImages" on "launch_task" to true only when supported images from the active conversation turn are relevant to the coding task. Omit it otherwise; images are not attached by default. - If the answer is immediate, call the closeout tool directly. ${reactionGuidance} - Prefer one direct closeout over an acknowledgement followed immediately by the same answer. @@ -219,7 +220,7 @@ ${reactionGuidance} - Use "launch_task" for new independent repository or workspace work when external inspection, editing, execution, or validation is required, regardless of whether the message is phrased as a question, request, or declarative feedback. Existing active tasks do not block a new independent task. - You may launch multiple independent tasks in one turn. Each successful launch posts its own kickoff automatically, and the turn remains open for more tools. - Set "model" on "launch_task" only to an exact ID from Available Delegated Task Models when a specific model is useful or requested. Omit it to use the deployment default. Never invent or abbreviate model IDs. -- Use "send_task_message" when an active or resumable task is listed above and the user clearly gives that task a new instruction. Call it immediately, before an acknowledgement or other user-visible response, so the instruction reaches the task without an extra inference round. A resumable settled task continues under the same task identity. Set "taskId" when needed; with exactly one listed task, omit it or use null. Afterward, post a concise closeout confirming the outcome when useful. +- Use "send_task_message" when an active or resumable task is listed above and the user clearly gives that task a new instruction. Call it immediately, before an acknowledgement or other user-visible response, so the instruction reaches the task without an extra inference round. Set "includeImages" to true only when supported images from the active conversation turn are relevant to that instruction; omit it otherwise. A resumable settled task continues under the same task identity. Set "taskId" when needed; with exactly one listed task, omit it or use null. Afterward, post a concise closeout confirming the outcome when useful. - Use \`roomote_manage_tasks\` to inspect tasks in this deployment. Use "get_summary" for current status and failures, "get_messages" for transcript details, and "get_compute_logs" for runtime output when supported. Keep using "launch_task", "send_task_message", or "cancel_task" for task changes so Fast conversation kickoff and follow-up behavior is preserved. - Use \`roomote_get_chat_message_context\` or \`roomote_get_chat_channel_messages\` for additional chat context. Pass the target channel or message reference required by the native tool schema. Slack channel history defaults to the previous 24 hours when \`oldest\` is omitted. - Never send conversational acknowledgements to a task. "Okay", "cool", "thanks", status questions, and similar conversation are addressed to you. Use a user-visible chat tool. diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 3bfd80abb..4f8781bb0 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -216,11 +216,13 @@ const launchTaskArgsSchema = z.object({ prompt: z.string().trim().min(1), environmentId: z.string().trim().min(1).nullable().optional(), model: z.string().trim().min(1).nullable().optional(), + includeImages: z.boolean().optional().default(false), kickoffMessage: z.string().trim().min(1), }); const taskMessageArgsSchema = z.object({ taskId: z.string().trim().min(1).nullable().optional(), message: z.string().trim().min(1), + includeImages: z.boolean().optional().default(false), }); const taskIdArgsSchema = z.object({ taskId: z.string().trim().min(1).nullable().optional(), @@ -1599,6 +1601,7 @@ export async function answerFastAgentQuestion({ args.prompt, args.environmentId ?? null, args.model ?? null, + args.includeImages, ])}`; if (completedTaskActions.has(signature)) { return { @@ -1633,6 +1636,7 @@ export async function answerFastAgentQuestion({ throwIfTurnCancelled(); const result = await adapter.launchTask({ prompt: args.prompt, + ...(args.includeImages && images.length > 0 ? { images } : {}), environmentId: args.environmentId ?? null, model: args.model ?? null, parentSessionId: session.id, @@ -1665,7 +1669,11 @@ export async function answerFastAgentQuestion({ throwIfTurnCancelled(); const result = await sendFastAgentTaskMessage( { userId, apiBaseUrl }, - { taskId: target.taskId, message: args.message }, + { + taskId: target.taskId, + message: args.message, + ...(args.includeImages && images.length > 0 ? { images } : {}), + }, ); return result; } diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts index ccbf50880..ca8b5bba1 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-task-launcher.ts @@ -46,17 +46,27 @@ export function createFastAgentTaskLauncher( ): LaunchFastAgentTask { return async ({ prompt, + images, environmentId, model, parentSessionId, postKickoff, }) => { - const task = await params.buildTask({ + const builtTask = await params.buildTask({ prompt, environmentId, model, parentSessionId, }); + const task = images?.length + ? { + ...builtTask, + payload: { + ...builtTask.payload, + images, + }, + } + : builtTask; let taskUrl: string | undefined; let preparedTaskRun: { id: number; taskId: string } | undefined; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts index 99b7dd30f..e2151d8ba 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-tasks.ts @@ -188,13 +188,17 @@ async function callFastAgentTaskApi({ export async function sendFastAgentTaskMessage( context: FastAgentTaskApiContext, - params: { taskId: string; message: string }, + params: { taskId: string; message: string; images?: string[] }, ): Promise { return callFastAgentTaskApi({ ...context, method: 'POST', path: `${FAST_AGENT_TASKS_API_PATH}/${params.taskId}/steer_message`, - body: { message: params.message, senderMode: 'fast_agent' }, + body: { + message: params.message, + ...(params.images?.length ? { images: params.images } : {}), + senderMode: 'fast_agent', + }, }); } diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index f3f57152a..a000f642a 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -1168,6 +1168,14 @@ describe('SlackNotifier', () => { filetype: 'svg', }; + const misleadingFilename: SlackFile = { + ...smallImage, + id: 'F5', + name: 'document.png', + mimetype: 'application/pdf', + filetype: 'pdf', + }; + getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new TextEncoder().encode('fake-image').buffer, @@ -1178,6 +1186,7 @@ describe('SlackNotifier', () => { largeImage, textFile, svgFile, + misleadingFilename, ]); expect(getGlobalWithFetch().fetch).toHaveBeenCalledTimes(1); diff --git a/packages/slack/src/thread-image-utils.ts b/packages/slack/src/thread-image-utils.ts index f9d3bd404..0f23f2179 100644 --- a/packages/slack/src/thread-image-utils.ts +++ b/packages/slack/src/thread-image-utils.ts @@ -14,7 +14,6 @@ export const MAX_THREAD_ATTACHMENT_FILES = 20; export function isSlackImageFile(file: SlackFile): boolean { return ( isRoomoteImageAttachment({ - filename: file.name, mimeType: file.mimetype, }) && file.size < MAX_SLACK_IMAGE_FILE_SIZE_BYTES ); From 8ae821a53aad25df3259bd1fd6e9ac7485fb1a25 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:00:35 +0100 Subject: [PATCH 017/158] [Improve] Show Session orchestration in Costs by Type (#1775) * feat: classify session costs by type * fix: preserve legacy analytics type filters --------- Co-authored-by: Roomote --- .../analytics/AnalyticsFilterBar.tsx | 2 +- .../lib/server/analytics/cost-rows.test.ts | 148 +++++++++++++++++- .../web/src/lib/server/analytics/cost-rows.ts | 45 +++++- .../lib/server/analytics/dimensions.test.ts | 34 +++- .../src/lib/server/analytics/dimensions.ts | 15 +- apps/web/src/lib/server/analytics/index.ts | 4 +- apps/web/src/types/analytics.ts | 2 +- 7 files changed, 234 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx index c2dcda2d3..152b42906 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx @@ -36,7 +36,7 @@ const ANALYTICS_DIMENSION_PLURAL_LABELS: Record = { status: 'Statuses', repo: 'Repos', author: 'Authors', - taskType: 'Task Types', + taskType: 'Types', provider: 'Providers', model: 'Models', }; diff --git a/apps/web/src/lib/server/analytics/cost-rows.test.ts b/apps/web/src/lib/server/analytics/cost-rows.test.ts index dc294293e..6393af80f 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.test.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.test.ts @@ -2,6 +2,8 @@ import { db, environmentFactory, environments, + fastAgentConversations, + fastAgentMessages, inArray, llmUsageEvents, runFactory, @@ -26,6 +28,7 @@ describe('getCostAnalyticsRows', () => { const taskIds: string[] = []; const environmentIds: string[] = []; const userIds: string[] = []; + const fastSessionIds: string[] = []; afterEach(async () => { if (usageEventIds.length > 0) { @@ -38,6 +41,12 @@ describe('getCostAnalyticsRows', () => { await db.delete(tasks).where(inArray(tasks.id, taskIds)); taskIds.length = 0; } + if (fastSessionIds.length > 0) { + await db + .delete(fastAgentConversations) + .where(inArray(fastAgentConversations.id, fastSessionIds)); + fastSessionIds.length = 0; + } if (environmentIds.length > 0) { await db .delete(environments) @@ -200,6 +209,143 @@ describe('getCostAnalyticsRows', () => { ]), ); }); + + it('classifies Session orchestration separately without double counting delegated task costs', async () => { + const user = await userFactory.create(); + userIds.push(user.id); + const currentNativeSessionId = `native-current-${crypto.randomUUID()}`; + const previousNativeSessionId = `native-previous-${crypto.randomUUID()}`; + const [session] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: `workspace-${crypto.randomUUID()}`, + conversationId: `conversation-${crypto.randomUUID()}`, + openCodeSessionId: currentNativeSessionId, + title: 'Session cost attribution', + }) + .returning(); + fastSessionIds.push(session!.id); + await db.insert(fastAgentMessages).values([ + { + conversationId: session!.id, + eventId: `event-${crypto.randomUUID()}`, + turnId: 'turn-1', + turnSeq: 0, + ts: 1, + eventType: 'roomote_runtime.assistant_message', + role: 'assistant', + nativeSessionId: previousNativeSessionId, + }, + { + conversationId: session!.id, + eventId: `event-${crypto.randomUUID()}`, + turnId: 'turn-1', + turnSeq: 1, + ts: 2, + eventType: 'roomote_runtime.assistant_message', + role: 'assistant', + nativeSessionId: previousNativeSessionId, + }, + ]); + const task = await taskFactory.create({ initiatorUserId: user.id }); + taskIds.push(task.id); + const run = await runFactory.create({ + taskId: task.id, + actingUserId: user.id, + payloadKind: TaskPayloadKind.StandardTask, + payload: { + repo: 'roomote/test', + description: 'Delegated Session task', + fastAgentSessionId: session!.id, + }, + }); + const insertedEvents = await db + .insert(llmUsageEvents) + .values([ + { + eventKey: `fast-previous-${crypto.randomUUID()}`, + source: 'fast_agent', + userId: user.id, + harnessSessionId: previousNativeSessionId, + messageId: `message-${crypto.randomUUID()}`, + costSource: 'opencode_message', + costMicroUsd: 1_000, + }, + { + eventKey: `fast-current-${crypto.randomUUID()}`, + source: 'fast_agent', + userId: user.id, + harnessSessionId: currentNativeSessionId, + messageId: `message-${crypto.randomUUID()}`, + costSource: 'opencode_message', + costMicroUsd: 2_000, + }, + { + eventKey: `delegated-run-${crypto.randomUUID()}`, + taskId: task.id, + runId: run.id, + costSource: 'opencode_message', + costMicroUsd: 3_000, + }, + { + eventKey: `delegated-task-${crypto.randomUUID()}`, + taskId: task.id, + costSource: 'opencode_message', + costMicroUsd: 4_000, + }, + { + eventKey: `unattributed-${crypto.randomUUID()}`, + costSource: 'missing', + costMicroUsd: 5_000, + }, + ]) + .returning({ id: llmUsageEvents.id }); + usageEventIds.push(...insertedEvents.map((event) => event.id)); + + const rows = await getCostAnalyticsRows( + {} as UserAuthSuccess, + 'all', + new Date(), + ); + const insertedRows = rows.filter((row) => + insertedEvents.some((event) => event.id === row.id), + ); + const rowsById = new Map(insertedRows.map((row) => [row.id, row])); + const sessionRows = insertedEvents + .slice(0, 2) + .map((event) => rowsById.get(event.id)!); + const delegatedTaskRows = insertedEvents + .slice(2, 4) + .map((event) => rowsById.get(event.id)!); + const unattributedRow = rowsById.get(insertedEvents[4]!.id)!; + + expect(insertedRows).toHaveLength(5); + expect(insertedRows.reduce((sum, row) => sum + row.value, 0)).toBe(0.015); + expect(sessionRows.reduce((sum, row) => sum + row.value, 0)).toBe(0.003); + expect(sessionRows.map((row) => row.dimensions.taskType?.label)).toEqual([ + 'Session', + 'Session', + ]); + expect(sessionRows.map((row) => row.details.values.taskTitle)).toEqual([ + 'Session', + 'Session', + ]); + expect(delegatedTaskRows.reduce((sum, row) => sum + row.value, 0)).toBe( + 0.007, + ); + expect( + delegatedTaskRows.map((row) => row.dimensions.taskType?.label), + ).toEqual(['Manual Task', 'Manual Task']); + expect(unattributedRow.dimensions.taskType?.label).toBe( + 'Non-task inference', + ); + for (const row of insertedRows) { + expect(row.dimensions).not.toHaveProperty('session'); + expect(row.details.links?.session).toBeUndefined(); + } + }); }); describe('aggregateCostAnalyticsRowsByTask', () => { @@ -225,7 +371,7 @@ describe('aggregateCostAnalyticsRowsByTask', () => { id, values: { date: timestamp, - taskType: taskId ? 'Manual' : 'Non-task inference', + taskType: taskId ? 'Manual Task' : 'Non-task inference', project: 'Roomote', source: 'opencode', provider: 'openai', diff --git a/apps/web/src/lib/server/analytics/cost-rows.ts b/apps/web/src/lib/server/analytics/cost-rows.ts index fe9c0d034..16a3a3252 100644 --- a/apps/web/src/lib/server/analytics/cost-rows.ts +++ b/apps/web/src/lib/server/analytics/cost-rows.ts @@ -4,6 +4,8 @@ import { taskRuns, taskPullRequests, environments, + fastAgentConversations, + fastAgentMessages, llmUsageEvents, and, eq, @@ -109,6 +111,7 @@ export async function getCostAnalyticsRows( costMicroUsd: llmUsageEvents.costMicroUsd, taskId: llmUsageEvents.taskId, runId: llmUsageEvents.runId, + harnessSessionId: llmUsageEvents.harnessSessionId, userId: llmUsageEvents.userId, taskUserId: tasks.initiatorUserId, providerId: llmUsageEvents.providerId, @@ -161,6 +164,39 @@ export async function getCostAnalyticsRows( const environmentNameById = new Map( environmentRows.map((environment) => [environment.id, environment.name]), ); + const nativeSessionIds = [ + ...new Set( + usageRows + .filter((row) => !row.taskId) + .map((row) => row.harnessSessionId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const nativeMessageSessionRows = + nativeSessionIds.length === 0 + ? [] + : await db + .select({ + nativeSessionId: fastAgentMessages.nativeSessionId, + }) + .from(fastAgentMessages) + .where(inArray(fastAgentMessages.nativeSessionId, nativeSessionIds)); + const currentNativeSessionRows = + nativeSessionIds.length === 0 + ? [] + : await db + .select({ + nativeSessionId: fastAgentConversations.openCodeSessionId, + }) + .from(fastAgentConversations) + .where( + inArray(fastAgentConversations.openCodeSessionId, nativeSessionIds), + ); + const fastNativeSessionIds = new Set( + [...nativeMessageSessionRows, ...currentNativeSessionRows] + .map((row) => row.nativeSessionId) + .filter((id): id is string => Boolean(id)), + ); const pullRequestRows = await db .select({ taskId: taskPullRequests.taskId, @@ -204,13 +240,17 @@ export async function getCostAnalyticsRows( return usageRows.map((row) => { const isTask = Boolean(row.taskId); + const isSession = + !isTask && fastNativeSessionIds.has(row.harnessSessionId ?? ''); const taskType = isTask ? getTaskTypeDimensionValue({ initiatorKind: row.initiatorKind, initiatorAutomation: row.initiatorAutomation, actorDisplayName: row.actorDisplayName, }) - : createLabelBackedDimensionValue('Non-task inference'); + : createLabelBackedDimensionValue( + isSession ? 'Session' : 'Non-task inference', + ); const attributedUserId = row.userId ?? row.taskUserId; const userDimension = isTask && row.initiatorKind === 'automation' @@ -231,7 +271,6 @@ export async function getCostAnalyticsRows( (row.runEnvironmentId ? (environmentNameById.get(row.runEnvironmentId) ?? NO_PROJECT_LABEL) : NO_PROJECT_LABEL); - return { id: row.id, timestamp, @@ -255,7 +294,7 @@ export async function getCostAnalyticsRows( provider, model, cost: cost.toFixed(2), - taskTitle: row.taskTitle ?? 'Non-task inference', + taskTitle: row.taskTitle ?? taskType.label, }, links: row.taskId ? { task: `/task/${row.taskId}` } : undefined, }, diff --git a/apps/web/src/lib/server/analytics/dimensions.test.ts b/apps/web/src/lib/server/analytics/dimensions.test.ts index acd3ff1f1..a0eaa3ef2 100644 --- a/apps/web/src/lib/server/analytics/dimensions.test.ts +++ b/apps/web/src/lib/server/analytics/dimensions.test.ts @@ -1,4 +1,7 @@ -import { getTaskInitiatorDimensionValue } from './dimensions'; +import { + getTaskInitiatorDimensionValue, + getTaskTypeDimensionValue, +} from './dimensions'; describe('getTaskInitiatorDimensionValue', () => { it('groups fallback Linear session identities under a stable label', () => { @@ -40,3 +43,32 @@ describe('getTaskInitiatorDimensionValue', () => { }); }); }); + +describe('getTaskTypeDimensionValue', () => { + it.each([ + [ + { initiatorKind: 'user' as const, initiatorAutomation: null }, + { key: 'Manual', label: 'Manual Task' }, + ], + [ + { initiatorKind: null, initiatorAutomation: null }, + { key: 'Unknown', label: 'Unknown Task' }, + ], + [ + { + initiatorKind: 'automation' as const, + initiatorAutomation: 'automation', + }, + { key: 'automation:automation', label: 'Automation' }, + ], + [ + { + initiatorKind: 'automation' as const, + initiatorAutomation: 'pr_review', + }, + { key: 'automation:pr_review', label: 'PR Review Task' }, + ], + ])('formats %o as %o', (task, dimension) => { + expect(getTaskTypeDimensionValue(task)).toEqual(dimension); + }); +}); diff --git a/apps/web/src/lib/server/analytics/dimensions.ts b/apps/web/src/lib/server/analytics/dimensions.ts index 6ae2e948e..9601a9d60 100644 --- a/apps/web/src/lib/server/analytics/dimensions.ts +++ b/apps/web/src/lib/server/analytics/dimensions.ts @@ -369,20 +369,21 @@ export function getTaskTypeDimensionValue(task: { actorDisplayName?: string | null; }) { if (!task.initiatorKind) { - return createLabelBackedDimensionValue('Unknown'); + return createDimensionValue('Unknown', 'Unknown Task'); } if (task.initiatorKind === 'automation') { const key = task.initiatorAutomation ?? 'unknown'; + const label = task.initiatorAutomation + ? formatAutomationLabel(task.initiatorAutomation, { + actorDisplayName: task.actorDisplayName, + }) + : 'Unknown'; return createDimensionValue( `automation:${key}`, - task.initiatorAutomation - ? formatAutomationLabel(task.initiatorAutomation, { - actorDisplayName: task.actorDisplayName, - }) - : 'Unknown', + label === 'Automation' ? label : `${label} Task`, ); } - return createLabelBackedDimensionValue('Manual'); + return createDimensionValue('Manual', 'Manual Task'); } diff --git a/apps/web/src/lib/server/analytics/index.ts b/apps/web/src/lib/server/analytics/index.ts index 456650d9d..11251b269 100644 --- a/apps/web/src/lib/server/analytics/index.ts +++ b/apps/web/src/lib/server/analytics/index.ts @@ -59,7 +59,7 @@ function getAnalyticsDetailsColumns( { key: 'user', label: 'User' }, { key: 'project', label: 'Environment' }, { key: 'source', label: 'Source' }, - { key: 'taskType', label: 'Task Type' }, + { key: 'taskType', label: 'Type' }, { key: 'taskTitle', label: 'Task Title' }, { key: 'task', label: 'Task Link' }, ]; @@ -87,7 +87,7 @@ function getAnalyticsDetailsColumns( return [ { key: 'date', label: 'Date' }, { key: 'user', label: 'User' }, - { key: 'taskType', label: 'Task Type' }, + { key: 'taskType', label: 'Type' }, { key: 'project', label: 'Environment' }, { key: 'source', label: 'Source' }, { key: 'provider', label: 'Provider' }, diff --git a/apps/web/src/types/analytics.ts b/apps/web/src/types/analytics.ts index 50f8406e1..3031bccb9 100644 --- a/apps/web/src/types/analytics.ts +++ b/apps/web/src/types/analytics.ts @@ -283,7 +283,7 @@ export const ANALYTICS_DIMENSION_LABELS: Record = { status: 'Status', repo: 'Repo', author: 'Author', - taskType: 'Task Type', + taskType: 'Type', provider: 'Provider', model: 'Model', }; From 044298ff3385c0a612986010e9ccbc08e7c123af Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:28:53 -0500 Subject: [PATCH 018/158] [Fix] Gitea pull requests miss linked user assignments (#1507) Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> Co-authored-by: daniel-lxs --- .../server/__tests__/commit-author.test.ts | 68 ++++++++ .../src/server/__tests__/enqueue-task.test.ts | 157 ++++++++++++++++-- .../resolveStandardTaskSurface.test.ts | 43 ++++- .../src/server/cloud-agent-workflow.ts | 69 +++++++- .../cloud-agents/src/server/commit-author.ts | 2 +- .../cloud-agents/src/server/task-run-queue.ts | 40 ++++- .../requestUserInputGuidance.test.ts | 42 +++++ .../src/server/workflows/standardTask.ts | 5 +- .../__tests__/source-control-provider.test.ts | 121 ++++++++++++++ .../db/src/lib/source-control-provider.ts | 57 ++++++- .../source-control-pull-requests.test.ts | 5 + ...urce-control-pull-request-branch-lookup.ts | 5 + .../source-control-pull-requests.ts | 19 ++- packages/types/src/task-runs.ts | 3 +- 14 files changed, 598 insertions(+), 38 deletions(-) diff --git a/packages/cloud-agents/src/server/__tests__/commit-author.test.ts b/packages/cloud-agents/src/server/__tests__/commit-author.test.ts index c79ef3787..58aa4b7a3 100644 --- a/packages/cloud-agents/src/server/__tests__/commit-author.test.ts +++ b/packages/cloud-agents/src/server/__tests__/commit-author.test.ts @@ -1,5 +1,8 @@ +import type { DatabaseOrTransaction } from '@roomote/db/server'; + import { DEFAULT_ROOMOTE_COMMIT_AUTHOR, + resolveRunCommitAuthor, resolvePublicGitAuthor, type ResolvedTaskCommitAuthor, } from '../commit-author'; @@ -39,3 +42,68 @@ describe('resolvePublicGitAuthor', () => { }); }); }); + +describe('resolveRunCommitAuthor', () => { + it('uses the host-scoped provider identity as the PR assignee', async () => { + const findUser = vi.fn().mockResolvedValue({ + id: 'user-1', + name: 'Mona Lisa', + }); + const findSourceControlMapping = vi.fn().mockResolvedValue({ + externalAccountId: '42', + username: 'monalisa', + displayName: 'Mona Lisa', + }); + const tx = { + query: { + users: { findFirst: findUser }, + sourceControlUserMappings: { findFirst: findSourceControlMapping }, + }, + } as unknown as DatabaseOrTransaction; + + const result = await resolveRunCommitAuthor( + tx, + { taskId: 'task-1', actingUserId: 'user-1' }, + { provider: 'gitea', host: 'gitea.example.com' }, + ); + + expect(result).toMatchObject({ + publicDisplayName: '@monalisa', + githubLogin: null, + prAssigneeLogin: 'monalisa', + }); + expect(findSourceControlMapping).toHaveBeenCalledOnce(); + }); + + it('does not expose username-based assignees for unsupported providers', async () => { + const tx = { + query: { + users: { + findFirst: vi.fn().mockResolvedValue({ + id: 'user-1', + name: 'Mona Lisa', + }), + }, + sourceControlUserMappings: { + findFirst: vi.fn().mockResolvedValue({ + externalAccountId: '42', + username: 'monalisa', + displayName: 'Mona Lisa', + }), + }, + }, + } as unknown as DatabaseOrTransaction; + + const result = await resolveRunCommitAuthor( + tx, + { taskId: 'task-1', actingUserId: 'user-1' }, + { provider: 'gitlab', host: 'gitlab.example.com' }, + ); + + expect(result).toMatchObject({ + publicDisplayName: '@monalisa', + githubLogin: null, + prAssigneeLogin: null, + }); + }); +}); 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 9f9b9467f..1ef3e4a7a 100644 --- a/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts +++ b/packages/cloud-agents/src/server/__tests__/enqueue-task.test.ts @@ -59,6 +59,7 @@ import { shouldCaptureTaskCreatedEvent, type FreshTaskLaunch, } from '../task-run-queue'; +import { resolveAggregateSourceControl } from '../cloud-agent-workflow'; import { LLM_TITLE_LOCKED_CHECKPOINT } from '../llm-task-title'; import { applyTaskModelSelectionToRun } from '../task-model-selection'; import { getPrSha } from '../workflows/utils'; @@ -2112,10 +2113,11 @@ describe('enqueueTask source-control provider stamping', () => { } }); - it('stamps gitlab on an environment-workspace launch for a gitlab-only deployment', async () => { + it('stamps the provider and host on a homogeneous environment-workspace launch', async () => { const userId = await createUser(); const repository = await repositoryFactory.create({ - sourceControlProvider: 'gitlab', + sourceControlProvider: 'gitea', + host: 'gitea.example.com', linkedByUserId: userId, fullName: 'group/project', isActive: true, @@ -2125,7 +2127,7 @@ describe('enqueueTask source-control provider stamping', () => { const environment = await environmentFactory.create({ createdByUserId: userId, config: { - name: 'GitLab environment', + name: 'Gitea environment', repositories: [{ repository: 'group/project' }], }, }); @@ -2140,11 +2142,10 @@ describe('enqueueTask source-control provider stamping', () => { task: standardTaskInput({ payload: { // environmentId makes this an environment workspace regardless of - // repo, so the provider must resolve via the environment-repository - // mapping (this repo is intentionally not in the repositories table). - repo: 'unmapped/repo', + // repo. The web UI uses the aggregate sentinel for these launches. + repo: ALL_REPOSITORIES, environmentId: environment.id, - description: 'Work in the gitlab environment', + description: 'Work in the Gitea environment', }, }), initiator: { kind: 'user', userId }, @@ -2160,11 +2161,64 @@ describe('enqueueTask source-control provider stamping', () => { expect( (persistedRun!.payload as { sourceControlProvider?: string }) .sourceControlProvider, - ).toBe('gitlab'); - expect( - (persistedRun!.payload as { repositoryProviders?: unknown }) - .repositoryProviders, - ).toBeUndefined(); + ).toBe('gitea'); + expect(persistedRun!.payload.sourceControlHost).toBe('gitea.example.com'); + expect(resolveAggregateSourceControl(persistedRun!.payload)).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + expect(persistedRun!.payload.repositoryProviders).toEqual({ + 'group/project': 'gitea', + }); + }); + + it('clears attribution for incomplete environment repository coverage', async () => { + const userId = await createUser(); + const repository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/environment-api', + isActive: true, + }); + createdRepositoryIds.push(repository.id); + + const environment = await environmentFactory.create({ + createdByUserId: userId, + config: { + name: 'Incomplete Gitea environment', + repositories: [ + { repository: 'group/environment-api' }, + { repository: 'group/environment-web' }, + ], + }, + }); + createdEnvironmentIds.push(environment.id); + + await db.insert(environmentRepositoryMappings).values({ + environmentId: environment.id, + repositoryId: repository.id, + }); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + environmentId: environment.id, + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + description: 'Work in an incompletely mapped environment', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload.sourceControlProvider).toBeUndefined(); + expect(run.payload.sourceControlHost).toBeUndefined(); + expect(resolveAggregateSourceControl(run.payload)).toBeUndefined(); }); it('stamps a provider map and the first repository provider for a mixed environment', async () => { @@ -2274,6 +2328,85 @@ describe('enqueueTask source-control provider stamping', () => { }); }); + it('stamps complete provider coverage for homogeneous selected repositories', async () => { + const userId = await createUser(); + const apiRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/homogeneous-api', + isActive: true, + }); + const webRepository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + host: 'gitea.example.com', + linkedByUserId: userId, + fullName: 'group/homogeneous-web', + isActive: true, + }); + createdRepositoryIds.push(apiRepository.id, webRepository.id); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: [ + 'group/homogeneous-api', + 'group/homogeneous-web', + ], + description: 'Work across homogeneous repositories', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload).toMatchObject({ + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + repositoryProviders: { + 'group/homogeneous-api': 'gitea', + 'group/homogeneous-web': 'gitea', + }, + }); + expect(resolveAggregateSourceControl(run.payload)).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('does not stamp a provider for incomplete selected repository coverage', async () => { + const userId = await createUser(); + const repository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + linkedByUserId: userId, + fullName: 'group/resolved-api', + isActive: true, + }); + createdRepositoryIds.push(repository.id); + + const run = await launchFresh({ + task: standardTaskInput({ + payload: { + repo: ALL_REPOSITORIES, + selectedRepositories: ['group/resolved-api', 'group/missing-web'], + description: 'Work across an incomplete repository selection', + }, + }), + initiator: { kind: 'user', userId }, + workflow: 'standard', + surface: 'web', + trigger: 'manual', + }); + + expect(run.payload.repositoryProviders).toEqual({ + 'group/resolved-api': 'gitea', + }); + expect(run.payload.sourceControlProvider).toBeUndefined(); + }); + it('re-stamps a PR launch after auto-resolving a mixed environment', async () => { const userId = await createUser(); const primaryRepository = await repositoryFactory.create({ diff --git a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts index 4e8086e46..af72f050e 100644 --- a/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts +++ b/packages/cloud-agents/src/server/__tests__/resolveStandardTaskSurface.test.ts @@ -1,4 +1,45 @@ -import { resolveStandardTaskSurface } from '../cloud-agent-workflow'; +import { + resolveAggregateSourceControl, + resolveStandardTaskSurface, +} from '../cloud-agent-workflow'; + +describe('resolveAggregateSourceControl', () => { + it('preserves stamped provider and host for a homogeneous aggregate workspace', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitea', + sourceControlHost: 'gitea.example.com', + }), + ).toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('fails closed for a mixed aggregate workspace', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitlab', + repositoryProviders: { + 'group/api': 'gitlab', + 'shared/app': 'gitea', + }, + }), + ).toBeUndefined(); + }); + + it('fails closed when a selected aggregate mapping is incomplete', () => { + expect( + resolveAggregateSourceControl({ + sourceControlProvider: 'gitea', + selectedRepositories: ['shared/api', 'shared/web'], + repositoryProviders: { + 'shared/api': 'gitea', + }, + }), + ).toBeUndefined(); + }); +}); describe('resolveStandardTaskSurface', () => { it('prefers Slack channel payload bindings', () => { diff --git a/packages/cloud-agents/src/server/cloud-agent-workflow.ts b/packages/cloud-agents/src/server/cloud-agent-workflow.ts index 81a133b6f..5d714da3b 100644 --- a/packages/cloud-agents/src/server/cloud-agent-workflow.ts +++ b/packages/cloud-agents/src/server/cloud-agent-workflow.ts @@ -1,4 +1,5 @@ import { + ALL_REPOSITORIES, type TaskSpec, type TaskSurface, TaskPayloadKind, @@ -16,10 +17,11 @@ import { getSlackTeamDomainFromTaskPayload, getSlackTeamIdFromTaskPayload, getSlackThreadTsFromTaskPayload, - resolveSourceControlProviderFromPayload, + resolveSourceControlHostFromPayload, } from '@roomote/types'; import { type TaskRun, + type RepositorySourceControl, db, eq, tasks, @@ -27,6 +29,7 @@ import { DEFAULT_CONFLICT_RESOLVER_LABEL, getDeploymentPrAction, getReviewCodeAutomationSettings, + resolveRepositorySourceControl, resolveTelegramRuntimeCredentials, } from '@roomote/db/server'; import { Env } from '@roomote/env'; @@ -109,6 +112,51 @@ export function resolveStandardTaskSurface({ } } +export function resolveAggregateSourceControl({ + sourceControlProvider, + sourceControlHost, + repositoryProviders, + selectedRepositories, +}: Pick< + TaskSpec['payload'], + | 'sourceControlProvider' + | 'sourceControlHost' + | 'repositoryProviders' + | 'selectedRepositories' +>): RepositorySourceControl | undefined { + if (!sourceControlProvider) { + return undefined; + } + + const providers = repositoryProviders + ? new Set(Object.values(repositoryProviders)) + : null; + const selectedRepositoryNames = selectedRepositories + ? [...new Set(selectedRepositories)] + : []; + const hasCompleteSelection = + selectedRepositoryNames.length === 0 || + (Object.keys(repositoryProviders ?? {}).length === + selectedRepositoryNames.length && + selectedRepositoryNames.every((repository) => + Object.hasOwn(repositoryProviders ?? {}, repository), + )); + + if ( + !hasCompleteSelection || + (providers && + (providers.size !== 1 || !providers.has(sourceControlProvider))) + ) { + return undefined; + } + + const host = resolveSourceControlHostFromPayload({ sourceControlHost }); + return { + provider: sourceControlProvider, + ...(host ? { host } : {}), + }; +} + export async function generatePrompt({ taskRun, taskSpec, @@ -153,9 +201,18 @@ export async function generatePrompt({ surface: true, }, }); - const commitAuthor = taskRow - ? await resolveRunCommitAuthor(db, taskRun) - : DEFAULT_ROOMOTE_COMMIT_AUTHOR; + const targetSourceControl = + taskSpec.payload.repo === ALL_REPOSITORIES + ? resolveAggregateSourceControl(taskSpec.payload) + : await resolveRepositorySourceControl( + db, + taskSpec.payload.repo, + resolveSourceControlHostFromPayload(taskSpec.payload), + ); + const commitAuthor = + taskRow && targetSourceControl + ? await resolveRunCommitAuthor(db, taskRun, targetSourceControl) + : DEFAULT_ROOMOTE_COMMIT_AUTHOR; const { conflictResolverFrequency, conflictResolverLabel, @@ -415,9 +472,7 @@ export async function generatePrompt({ codeReviewsEnabled, codeReviewReviewOnCommit, codeReviewReviewDraftPrs, - sourceControlProvider: resolveSourceControlProviderFromPayload( - taskSpec.payload, - ), + sourceControlProvider: targetSourceControl?.provider, prAction, }); diff --git a/packages/cloud-agents/src/server/commit-author.ts b/packages/cloud-agents/src/server/commit-author.ts index f5041d089..d31b47869 100644 --- a/packages/cloud-agents/src/server/commit-author.ts +++ b/packages/cloud-agents/src/server/commit-author.ts @@ -363,7 +363,7 @@ export async function resolveRunCommitAuthor( displayName, publicDisplayName: username ? `@${username}` : null, githubLogin: null, - prAssigneeLogin: null, + prAssigneeLogin: sourceControl.provider === 'gitea' ? username : null, gitAuthor: { name: displayName, email: commitEmail, diff --git a/packages/cloud-agents/src/server/task-run-queue.ts b/packages/cloud-agents/src/server/task-run-queue.ts index 28553bb41..a54be98d2 100644 --- a/packages/cloud-agents/src/server/task-run-queue.ts +++ b/packages/cloud-agents/src/server/task-run-queue.ts @@ -69,6 +69,7 @@ import { recordSnapshotResumeEvent, resolveDefaultComputeProvider, resolveWorkspaceRepositoryProviders, + resolveWorkspaceSourceControlHost, sql, } from '@roomote/db/server'; import { type Redis, getRedis } from '@roomote/redis'; @@ -2042,14 +2043,43 @@ async function stampWorkspaceSourceControlProviders( payload: FreshTask['payload'], workspace: ReturnType, ): Promise { - const repositoryProviders = await resolveWorkspaceRepositoryProviders( - db, - workspace, - ); + const [repositoryProviders, workspaceHost] = await Promise.all([ + resolveWorkspaceRepositoryProviders(db, workspace), + resolveWorkspaceSourceControlHost(db, workspace), + ]); + const isAggregateWorkspace = + workspace.type === 'repository_set' || + workspace.type === 'all_repositories'; + const requiresCompleteCoverage = + isAggregateWorkspace || workspace.type === 'environment'; + const expectedRepositoryCount = + workspace.type === 'repository_set' + ? new Set(workspace.repositories).size + : undefined; + + if (requiresCompleteCoverage) { + payload.repositoryProviders = repositoryProviders; + } + + if ( + requiresCompleteCoverage && + (Object.keys(repositoryProviders).length === 0 || + (expectedRepositoryCount !== undefined && + Object.keys(repositoryProviders).length !== expectedRepositoryCount)) + ) { + payload.sourceControlProvider = undefined; + payload.sourceControlHost = undefined; + return; + } + const providers = Object.values(repositoryProviders); const spansProviders = new Set(providers).size > 1; - if (spansProviders) { + if (requiresCompleteCoverage && !spansProviders) { + payload.sourceControlHost = workspaceHost; + } + + if (spansProviders && !requiresCompleteCoverage) { payload.repositoryProviders = repositoryProviders; } diff --git a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts index 6714452a1..7bfd6073c 100644 --- a/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts +++ b/packages/cloud-agents/src/server/workflows/__tests__/requestUserInputGuidance.test.ts @@ -258,6 +258,48 @@ describe('request_user_input guidance in workflow prompts', () => { ); }); + it('uses the task provider label for linked assignee instructions', () => { + const { harnessInstructions } = standardTask({ + description: 'Implement a repository change', + repo: 'Roomote/example-app', + taskRunUrl: 'https://example.com/task/123', + sourceControlProvider: 'gitea', + attribution: { + ...matchedUserAttributionWithAssignee, + githubLogin: null, + publicDisplayName: '@monalisa', + prAssigneeLogin: 'monalisa', + }, + }); + + expect(harnessInstructions).toContain( + "because the creating user has linked Gitea login `monalisa`, the delegated PR-delivery skill must pass `assignees: ['monalisa']`", + ); + expect(harnessInstructions).not.toContain('linked GitHub login `monalisa`'); + }); + + it('preserves provider-aware attribution and assignment for all-repository tasks', () => { + const { harnessInstructions } = standardTask({ + description: 'Implement a repository change', + repo: ALL_REPOSITORIES, + repoFullNames: ['shared/api', 'shared/web'], + taskRunUrl: 'https://example.com/task/123', + sourceControlProvider: 'gitea', + attribution: { + ...matchedUserAttributionWithAssignee, + githubLogin: null, + publicDisplayName: '@monalisa', + prAssigneeLogin: 'monalisa', + }, + }); + + expect(harnessInstructions).toContain('Opened on behalf of Jane Doe.'); + expect(harnessInstructions).toContain( + "because the creating user has linked Gitea login `monalisa`, the delegated PR-delivery skill must pass `assignees: ['monalisa']`", + ); + expect(harnessInstructions).not.toContain('linked GitHub login `monalisa`'); + }); + it('uses a Slack conversation link for Slack-launched PR follow-up instructions when thread metadata is available', () => { const { harnessInstructions } = standardTask({ description: 'Implement a repository change', diff --git a/packages/cloud-agents/src/server/workflows/standardTask.ts b/packages/cloud-agents/src/server/workflows/standardTask.ts index 12943db0f..0dcda3fac 100644 --- a/packages/cloud-agents/src/server/workflows/standardTask.ts +++ b/packages/cloud-agents/src/server/workflows/standardTask.ts @@ -186,8 +186,11 @@ export function standardTask({ ); } if (attribution.prAssigneeLogin) { + const providerLabel = sourceControlProvider + ? getSourceControlProviderLabel(sourceControlProvider) + : 'GitHub'; delegatedPrMetadataInstructions.push( - `For this run, because the creating user has linked GitHub login \`${attribution.prAssigneeLogin}\`, the delegated PR-delivery skill must pass \`assignees: ['${attribution.prAssigneeLogin}']\` in its \`mcp__roomote__manage_source_control\` calls so the created or refreshed pull request is assigned to that user when the provider supports it.`, + `For this run, because the creating user has linked ${providerLabel} login \`${attribution.prAssigneeLogin}\`, the delegated PR-delivery skill must pass \`assignees: ['${attribution.prAssigneeLogin}']\` in its \`mcp__roomote__manage_source_control\` calls so the created or refreshed pull request is assigned to that user when the provider supports it.`, ); } } 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 fb294f457..e4f120470 100644 --- a/packages/db/src/lib/__tests__/source-control-provider.test.ts +++ b/packages/db/src/lib/__tests__/source-control-provider.test.ts @@ -1,6 +1,7 @@ // pnpm --filter @roomote/db exec vitest run src/lib/__tests__/source-control-provider.test.ts import type { DatabaseOrTransaction } from '../../db'; import { + resolveRepositorySourceControl, resolveWorkspaceRepositoryProviders, resolveWorkspaceSourceControlHost, resolveWorkspaceSourceControlProvider, @@ -53,6 +54,82 @@ const dbOrTx = { }, } as unknown as DatabaseOrTransaction; +describe('resolveRepositorySourceControl', () => { + beforeEach(() => { + mockRows = []; + mockWhere.mockReset(); + }); + + it('resolves the target repository instead of another workspace provider', async () => { + mockRows = [ + { + fullName: 'gitlab-org/api', + host: 'gitlab.example.com', + isActive: true, + sourceControlProvider: 'gitlab', + }, + { + fullName: 'gitea-org/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'gitea-org/app'), + ).resolves.toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); + + it('fails closed when the target repository is ambiguous across hosts', async () => { + mockRows = [ + { + fullName: 'shared/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/app', + host: 'github.com', + isActive: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'shared/app'), + ).resolves.toBeUndefined(); + }); + + it('uses the target host to resolve same-name repositories exactly', async () => { + mockRows = [ + { + fullName: 'shared/app', + host: 'gitea.example.com', + isActive: true, + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/app', + host: 'github.com', + isActive: true, + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveRepositorySourceControl(dbOrTx, 'shared/app', 'gitea.example.com'), + ).resolves.toEqual({ + provider: 'gitea', + host: 'gitea.example.com', + }); + }); +}); + describe('resolveWorkspaceSourceControlProvider', () => { beforeEach(() => { mockRows = []; @@ -105,6 +182,24 @@ describe('resolveWorkspaceSourceControlProvider', () => { }); }); + it('returns no providers when environment mapping coverage is incomplete', async () => { + mockEnvironmentRepositories = ['group/web', 'octo/api']; + mockRows = [ + { + fullName: 'group/web', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + ]; + + await expect( + resolveWorkspaceRepositoryProviders(dbOrTx, { + type: 'environment', + environmentId: 'env-1', + }), + ).resolves.toEqual({}); + }); + it('resolves the provider from a single repository workspace', async () => { mockRows = [ { @@ -165,6 +260,32 @@ describe('resolveWorkspaceSourceControlProvider', () => { ).resolves.toBeUndefined(); }); + it('returns undefined when all-repository resolution is incomplete', async () => { + mockRows = [ + { + fullName: 'shared/api', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/web', + host: 'gitea.example.com', + sourceControlProvider: 'gitea', + }, + { + fullName: 'shared/web', + host: 'github.com', + sourceControlProvider: 'github', + }, + ]; + + await expect( + resolveWorkspaceSourceControlProvider(dbOrTx, { + type: 'all_repositories', + }), + ).resolves.toBeUndefined(); + }); + it('returns undefined when no repository rows match', async () => { await expect( resolveWorkspaceSourceControlProvider(dbOrTx, { diff --git a/packages/db/src/lib/source-control-provider.ts b/packages/db/src/lib/source-control-provider.ts index c4ba63774..de0daab56 100644 --- a/packages/db/src/lib/source-control-provider.ts +++ b/packages/db/src/lib/source-control-provider.ts @@ -29,6 +29,11 @@ type RepositoryProviderRow = { sourceControlProvider: SourceControlProvider; }; +export type RepositorySourceControl = { + provider: SourceControlProvider; + host?: string; +}; + function selectRepositoryRows( rows: RepositoryProviderRow[], repositoryOrder: string[], @@ -123,6 +128,32 @@ async function resolveProvidersByFullNames( return toRepositoryProviderMap(rows, fullNames, sourceControlHost); } +/** Resolve the provider and host for one exact repository, or fail closed. */ +export async function resolveRepositorySourceControl( + dbOrTx: DatabaseOrTransaction, + fullName: string, + sourceControlHost?: string, +): Promise { + const rows = await dbOrTx + .select({ + fullName: repositories.fullName, + host: repositories.host, + isActive: repositories.isActive, + sourceControlProvider: repositories.sourceControlProvider, + }) + .from(repositories) + .where(eq(repositories.fullName, fullName)); + const selected = selectRepositoryRows(rows, [fullName], sourceControlHost); + const repository = selected?.[0]; + + return repository + ? { + provider: repository.sourceControlProvider, + ...(repository.host ? { host: repository.host } : {}), + } + : undefined; +} + async function resolveEnvironmentProviders( dbOrTx: DatabaseOrTransaction, environmentId: string, @@ -159,10 +190,18 @@ async function resolveEnvironmentProviders( asc(environmentRepositoryMappings.id), ); - return toRepositoryProviderMap( - rows, - environment.config.repositories.map((repository) => repository.repository), - ); + const repositoryNames = [ + ...new Set( + environment.config.repositories.map( + (repository) => repository.repository, + ), + ), + ]; + const providers = toRepositoryProviderMap(rows, repositoryNames); + + return Object.keys(providers).length === repositoryNames.length + ? providers + : {}; } async function resolveAllRepositoriesProviders( @@ -179,10 +218,12 @@ async function resolveAllRepositoriesProviders( .where(eq(repositories.isActive, true)) .orderBy(asc(repositories.createdAt), asc(repositories.id)); - return toRepositoryProviderMap( - rows, - rows.map((row) => row.fullName), - ); + const repositoryNames = [...new Set(rows.map((row) => row.fullName))]; + const providers = toRepositoryProviderMap(rows, repositoryNames); + + return Object.keys(providers).length === repositoryNames.length + ? providers + : {}; } /** Resolve repository full names to providers in workspace order. */ 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 5e652b246..60cfb0e17 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 @@ -2136,6 +2136,7 @@ Done.`, draft: false, head: { ref: 'feature/x' }, base: { ref: 'develop' }, + assignees: [{ login: 'existing-reviewer' }], }, ]), ) @@ -2157,11 +2158,15 @@ Done.`, ...baseInput, repositoryFullName: 'acme/tools', sourceControlProvider: 'gitea' as const, + assignees: ['monalisa'], }, fetchImpl, }); expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({ method: 'PATCH' }); + expect( + JSON.parse((fetchImpl.mock.calls[1]?.[1] as { body: string }).body), + ).toMatchObject({ assignees: ['existing-reviewer', 'monalisa'] }); expect(result).toMatchObject({ action: 'updated', targetBranch: 'develop', diff --git a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts index 7c121bd1c..d642c4c8d 100644 --- a/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts +++ b/packages/sdk/src/server/lib/pull-requests/source-control-pull-request-branch-lookup.ts @@ -39,6 +39,11 @@ export const giteaPullRequestSchema = z draft: z.boolean().optional(), head: z.object({ ref: z.string().optional() }).optional(), base: z.object({ ref: z.string().optional() }).optional(), + assignees: z + .array( + z.object({ login: z.string().nullable().optional() }).passthrough(), + ) + .optional(), }) .passthrough(); const giteaPullRequestListSchema = z.array(giteaPullRequestSchema); 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 e66f5bf2c..b0f8f78e3 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 @@ -866,6 +866,17 @@ async function createOrUpdateGiteaPullRequest({ : createDraft, 'gitea', ); + const assignees = + input.assignees.length > 0 + ? [ + ...new Set([ + ...(existing?.assignees + ?.map((assignee) => assignee.login) + .filter((login): login is string => Boolean(login)) ?? []), + ...input.assignees, + ]), + ] + : []; const pullRequest = existing ? await requestJson({ fetchImpl, @@ -878,7 +889,11 @@ async function createOrUpdateGiteaPullRequest({ {}, ), tokenHeader: { name: 'Authorization', value: `token ${token}` }, - body: { title, body: input.body }, + body: { + title, + body: input.body, + ...(assignees.length > 0 ? { assignees } : {}), + }, schema: giteaPullRequestSchema, }) : await requestJson({ @@ -897,7 +912,7 @@ async function createOrUpdateGiteaPullRequest({ head: input.sourceBranch, title, body: input.body, - ...(input.assignees.length > 0 ? { assignees: input.assignees } : {}), + ...(assignees.length > 0 ? { assignees } : {}), }, schema: giteaPullRequestSchema, }); diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index c88409654..609484c28 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -904,7 +904,8 @@ const sharedTaskPayloadSchema = z.object({ /** * Source-control provider keyed by repository full name for workspaces that - * span multiple providers. Single-provider payloads omit this field. + * span multiple providers. Aggregate selections also include this map when + * homogeneous so downstream attribution can verify complete coverage. */ repositoryProviders: z.record(sourceControlProviderSchema).optional(), From fa988b5bd3d3ce7d66ea91bf75212fd2da310311 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:00:59 -0400 Subject: [PATCH 019/158] [Feat] Make Sessions the primary workspace for Roomote work (#1708) Co-authored-by: Roomote Co-authored-by: Bruno Bergher Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../__tests__/channel-auto-start.test.ts | 57 +- .../discord/__tests__/fast-agent.test.ts | 59 + .../handlers/discord/__tests__/index.test.ts | 238 +- .../handlers/discord/channel-auto-start.ts | 6 +- apps/api/src/handlers/discord/fast-agent.ts | 15 +- apps/api/src/handlers/discord/index.ts | 42 +- .../api/src/handlers/fast-agent-entry.test.ts | 35 - apps/api/src/handlers/fast-agent-entry.ts | 20 - .../channel-auto-start-unlinked.test.ts | 1 - .../handlers/slack/events/message-entry.ts | 12 +- .../slack/helpers/user-mapping.test.ts | 4 - .../handlers/slack/helpers/user-mapping.ts | 11 +- .../__tests__/sessions-reconcile.test.ts | 211 + apps/bullmq/src/scheduled-jobs/index.ts | 1 + .../src/scheduled-jobs/sessions-reconcile.ts | 393 + apps/bullmq/src/scheduler.ts | 7 + apps/bullmq/src/types.ts | 1 + apps/docs/fast-sessions.mdx | 79 +- apps/docs/personal-settings.mdx | 5 - .../docs/providers/communications/discord.mdx | 6 +- apps/docs/providers/communications/slack.mdx | 6 +- apps/docs/tasks.mdx | 25 +- .../(authenticated)/analytics/Analytics.tsx | 10 +- .../analytics/AnalyticsDetailsDialog.tsx | 2 + .../analytics/AnalyticsDimensionIcons.ts | 3 + .../analytics/AnalyticsFilterBar.tsx | 2 + .../analytics/AnalyticsShell.tsx | 4 + .../(authenticated)/home/Home.client.test.tsx | 406 +- .../web/src/app/(authenticated)/home/Home.tsx | 250 +- .../sessions/FastSessionCard.tsx | 102 - .../(authenticated)/sessions/SessionCard.tsx | 91 + .../sessions/SessionsFilters.tsx | 204 +- .../src/app/(authenticated)/sessions/page.tsx | 155 +- .../src/app/(authenticated)/tasks/Tasks.tsx | 10 +- .../src/app/(authenticated)/tasks/page.tsx | 2 + .../src/app/(sandbox)/SandboxInfoPanel.tsx | 55 + .../FastSessionTranscript.client.test.tsx | 118 +- .../[sessionId]/FastSessionTranscript.tsx | 33 +- .../NestedTaskSidePanel.client.test.tsx | 93 + .../[sessionId]/NestedTaskSidePanel.tsx | 128 + .../[sessionId]/SessionReadTracker.tsx | 21 + .../sessions/[sessionId]/SessionTaskCards.tsx | 176 + .../SessionWorkspace.client.test.tsx | 231 +- .../sessions/[sessionId]/SessionWorkspace.tsx | 508 +- .../sessions/[sessionId]/page.test.tsx | 244 +- .../(sandbox)/sessions/[sessionId]/page.tsx | 102 +- .../[sessionId]/session-task-panel-context.ts | 11 + .../task/[taskId]/Header.client.test.tsx | 68 +- .../app/(sandbox)/task/[taskId]/Header.tsx | 97 +- .../task/[taskId]/TaskSessionReadTracker.tsx | 9 + .../messages/acp/AcpGroupedToolMessage.tsx | 89 +- .../[taskId]/messages/acp/AcpMessageItem.tsx | 21 + .../[taskId]/messages/acp/AcpToolDetails.tsx | 109 +- .../[taskId]/messages/acp/AcpToolMessage.tsx | 87 +- .../messages/acp/AcpTranscriptBlocks.tsx | 8 + .../acp/DelegatedTaskCard.client.test.tsx | 115 + .../messages/acp/DelegatedTaskCard.tsx | 68 + ...GroupedToolMessage.anchors.client.test.tsx | 13 +- .../AcpGroupedToolMessage.client.test.tsx | 9 +- .../__tests__/AcpToolDetails.client.test.tsx | 98 +- .../__tests__/AcpToolMessage.client.test.tsx | 31 +- .../tool-call-grouping.client.test.ts | 76 +- .../tool-presentation.client.test.ts | 198 + .../[taskId]/messages/acp/activity-groups.ts | 80 +- .../[taskId]/messages/acp/delegated-task.ts | 50 + .../[taskId]/messages/acp/render-blocks.ts | 232 +- .../messages/acp/tool-detail-visibility.ts | 20 +- .../task/[taskId]/messages/acp/tool-icons.ts | 67 + .../messages/acp/tool-presentation-policy.ts | 140 + .../messages/acp/tool-presentation.ts | 350 + .../[taskId]/sidebar-panels/TaskInfoPanel.tsx | 533 +- apps/web/src/app/layout.tsx | 3 + .../ai-elements/message.stories.tsx | 407 +- .../ai-elements/tool.client.test.tsx | 73 +- apps/web/src/components/ai-elements/tool.tsx | 46 +- .../layout/CommandPalette.client.test.tsx | 21 +- .../src/components/layout/CommandPalette.tsx | 28 +- apps/web/src/components/layout/RouteTitle.tsx | 2 +- .../navbar/NavbarDrawer.client.test.tsx | 2 +- .../layout/navigation-items.test.ts | 8 +- .../src/components/layout/navigation-items.ts | 11 +- .../layout/side-nav/SideNav.client.test.tsx | 6 +- .../sessions/SessionStatusBadge.tsx | 25 + .../components/sessions/session-surfaces.ts | 61 + .../settings/UserPreferencesSection.test.tsx | 81 +- .../settings/UserPreferencesSection.tsx | 33 - .../components/system/custom/icons/index.ts | 1 + .../system/custom/icons/roomote-r.tsx | 28 + .../src/components/system/primitives/icons.ts | 2 +- apps/web/src/hooks/task-runs/index.ts | 1 - .../src/hooks/task-runs/useRouteHomeTask.ts | 31 - apps/web/src/hooks/useMarkSessionRead.ts | 34 + .../usePersonalPreferences.client.test.tsx | 1 - apps/web/src/hooks/usePersonalPreferences.ts | 10 - apps/web/src/hooks/useRecentSessions.ts | 65 + apps/web/src/lib/formatters.ts | 11 + apps/web/src/lib/server/analytics/index.ts | 14 + .../lib/server/analytics/session-rows.test.ts | 146 + .../src/lib/server/analytics/session-rows.ts | 94 + apps/web/src/lib/server/auth-context.test.ts | 8 +- apps/web/src/lib/server/fast-sessions.test.ts | 120 +- apps/web/src/lib/server/fast-sessions.ts | 146 +- apps/web/src/lib/server/sessions.test.ts | 242 + apps/web/src/lib/server/sessions.ts | 783 + apps/web/src/lib/telemetry/normalize-path.ts | 7 +- .../src/trpc/commands/fast-sessions/index.ts | 27 +- .../trpc/commands/feature-flags/index.test.ts | 8 +- apps/web/src/trpc/commands/filters/index.ts | 8 +- .../src/trpc/commands/preferences/index.ts | 7 - .../preferences/personal-preferences.test.ts | 39 +- .../src/trpc/commands/sessions/index.test.ts | 58 + apps/web/src/trpc/commands/sessions/index.ts | 110 + .../src/trpc/commands/task-runs/index.test.ts | 7 +- apps/web/src/trpc/commands/task-runs/index.ts | 49 +- .../commands/tasks/__tests__/delete.test.ts | 109 +- apps/web/src/trpc/commands/tasks/delete.ts | 40 + apps/web/src/trpc/routers/_app.ts | 108 +- apps/web/src/types/analytics.ts | 34 +- apps/web/src/types/preferences.ts | 2 - .../__tests__/slack-live-task-stream.test.ts | 18 +- .../src/callbacks/slack-live-task-stream.ts | 10 +- .../runtime-envelope-subscription.test.ts | 2 +- .../run-task/subscribe-harness-callbacks.ts | 4 +- .../src/server/__tests__/enqueue-task.test.ts | 57 +- .../__tests__/fast-agent-service.test.ts | 21 + .../fast-agent-task-launcher.test.ts | 46 +- .../__tests__/fast-agent-tool-policy.test.ts | 29 + .../server/fast-agent/fast-agent-constants.ts | 5 + .../fast-agent-conversation-repository.ts | 53 + .../server/fast-agent/fast-agent-service.ts | 73 +- .../fast-agent/fast-agent-task-launcher.ts | 1 + .../src/server/fast-agent/fast-agent-title.ts | 59 +- .../fast-agent/fast-agent-tool-policy.ts | 27 +- .../src/server/non-task-provider-usage.ts | 4 + .../cloud-agents/src/server/task-run-queue.ts | 33 +- packages/db/drizzle/0064_deep_vengeance.sql | 89 + .../0065_sessions_responding_until.sql | 1 + packages/db/drizzle/meta/0064_snapshot.json | 13896 +++++++++++++++ packages/db/drizzle/meta/0065_snapshot.json | 13902 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 14 + .../src/__tests__/schema-constraints.test.ts | 136 + packages/db/src/fixtures/factories/index.ts | 1 + .../src/fixtures/factories/session.factory.ts | 36 + .../db/src/lib/__tests__/sessions.test.ts | 532 + packages/db/src/lib/llm-usage.ts | 19 +- packages/db/src/lib/sessions.ts | 520 + packages/db/src/lib/sync-task-state.ts | 3 + packages/db/src/schema.ts | 285 + packages/db/src/server.ts | 16 + packages/db/src/types.ts | 30 + .../src/__tests__/config.test.ts | 19 +- .../evaluateFlagFromMetadata.test.ts | 165 +- packages/feature-flags/src/evaluator.ts | 3 + .../src/server/deployment.test.ts | 100 + .../feature-flags/src/server/deployment.ts | 66 + packages/feature-flags/src/server/index.ts | 4 + packages/feature-flags/src/types.ts | 2 +- packages/sdk/src/server/routers/task-runs.ts | 6 + .../fast-agent-live-task-launcher.test.ts | 51 +- .../__tests__/settle-live-task-card.test.ts | 2 +- packages/slack/src/client.ts | 2 +- .../src/fast-agent-live-task-launcher.ts | 34 +- packages/slack/src/live-task-card-blocks.ts | 18 +- packages/slack/src/settle-live-task-card.ts | 6 +- packages/types/src/acp.ts | 21 + packages/types/src/fast-agent-tool-catalog.ts | 73 + packages/types/src/index.ts | 2 + packages/types/src/sessions.ts | 15 + 168 files changed, 37672 insertions(+), 2759 deletions(-) delete mode 100644 apps/api/src/handlers/fast-agent-entry.test.ts create mode 100644 apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts create mode 100644 apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts delete mode 100644 apps/web/src/app/(authenticated)/sessions/FastSessionCard.tsx create mode 100644 apps/web/src/app/(authenticated)/sessions/SessionCard.tsx create mode 100644 apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx create mode 100644 apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/TaskSessionReadTracker.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.client.test.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/DelegatedTaskCard.tsx create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts create mode 100644 apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts create mode 100644 apps/web/src/components/sessions/SessionStatusBadge.tsx create mode 100644 apps/web/src/components/sessions/session-surfaces.ts create mode 100644 apps/web/src/components/system/custom/icons/roomote-r.tsx delete mode 100644 apps/web/src/hooks/task-runs/useRouteHomeTask.ts create mode 100644 apps/web/src/hooks/useMarkSessionRead.ts create mode 100644 apps/web/src/hooks/useRecentSessions.ts create mode 100644 apps/web/src/lib/server/analytics/session-rows.test.ts create mode 100644 apps/web/src/lib/server/analytics/session-rows.ts create mode 100644 apps/web/src/lib/server/sessions.test.ts create mode 100644 apps/web/src/lib/server/sessions.ts create mode 100644 apps/web/src/trpc/commands/sessions/index.test.ts create mode 100644 apps/web/src/trpc/commands/sessions/index.ts create mode 100644 packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-tool-policy.test.ts create mode 100644 packages/db/drizzle/0064_deep_vengeance.sql create mode 100644 packages/db/drizzle/0065_sessions_responding_until.sql create mode 100644 packages/db/drizzle/meta/0064_snapshot.json create mode 100644 packages/db/drizzle/meta/0065_snapshot.json create mode 100644 packages/db/src/fixtures/factories/session.factory.ts create mode 100644 packages/db/src/lib/__tests__/sessions.test.ts create mode 100644 packages/db/src/lib/sessions.ts create mode 100644 packages/feature-flags/src/server/deployment.test.ts create mode 100644 packages/feature-flags/src/server/deployment.ts create mode 100644 packages/types/src/fast-agent-tool-catalog.ts create mode 100644 packages/types/src/sessions.ts diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 598a722e2..fcdee832e 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ createDirectMessage: vi.fn(), postMessage: vi.fn(), addReaction: vi.fn(), - hasFastDefault: vi.fn(), processFast: vi.fn(), })); @@ -41,10 +40,6 @@ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUserId, })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../shared/channel-launch-gate.js') @@ -120,6 +115,24 @@ function messagePayload(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so launch-path tests +// use an attachment-only human message (no text for Fast mode to answer). +function attachmentOnlyPayload(overrides: Record = {}) { + return messagePayload({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + function gatewayEvent(payload: Record): DiscordGatewayEvent { return { eventId: String(payload.id), @@ -204,13 +217,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { mocks.createDirectMessage.mockResolvedValue({ id: 'dm-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-message-1' }); mocks.addReaction.mockResolvedValue(undefined); - mocks.hasFastDefault.mockResolvedValue(false); mocks.processFast.mockResolvedValue(undefined); }); - it('routes a linked user default to Fast mode before channel auto-start launch', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - + it('routes a linked-human text message to Fast mode before channel auto-start launch', async () => { await expect(runHandler({})).resolves.toBe(true); await flushBackgroundWork(); @@ -266,6 +276,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { runHandler({ payload: messagePayload({ content: '', + author: { id: 'alert-bot', username: 'alerts', bot: true }, message_snapshots: [ { message: { @@ -315,8 +326,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('launches a linked-human message with instructions as the prompt prefix', async () => { - await expect(runHandler({})).resolves.toBe(true); + it('launches a linked-human attachment message with instructions as the prompt prefix', async () => { + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -347,7 +360,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('forwards message_reference into startNewDiscordTask for reply launches', async () => { await expect( runHandler({ - payload: messagePayload({ + payload: attachmentOnlyPayload({ type: 19, message_reference: { message_id: 'parent-message-1', @@ -472,7 +485,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'skip', reason: 'not an incident' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.evaluateGate).toHaveBeenCalledWith( @@ -507,7 +522,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'error', reason: 'provider unavailable' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).not.toHaveBeenCalled(); @@ -521,7 +538,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('replies when task startup throws', async () => { mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.postMessage).toHaveBeenCalledWith({ @@ -590,7 +609,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('never lets a reaction failure abort the launch', async () => { mocks.addReaction.mockRejectedValue(new Error('rate limited')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).toHaveBeenCalledTimes(1); @@ -603,7 +624,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('releases the routing lock when the launch fails', async () => { mocks.startNewTask.mockRejectedValue(new Error('boom')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.redis.del).toHaveBeenCalledWith( diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index df5e2127b..c39793c95 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -278,6 +278,65 @@ describe('processDiscordFastAgentMessage', () => { }, ); + it('anchors the thread and replies on an explicit anchor message (reaction summons)', async () => { + const provider = { + createThreadFromMessage: vi.fn().mockResolvedValue({ + channelId: 'reacted-1', + parentChannelId: 'channel-1', + name: 'Investigate this', + kind: 'thread', + messageId: 'reacted-1', + }), + editMessage: vi.fn().mockResolvedValue(undefined), + }; + mocks.answerQuestion.mockResolvedValueOnce('A quick answer'); + + await processDiscordFastAgentMessage({ + event: { eventId: 'synthetic-1' } as never, + question: 'Investigate this', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }, + metadata: { + communicationChannelId: 'channel-1', + communicationMessageId: 'reacted-1', + communicationAnchorMessageId: 'reacted-1', + communicationGuildId: 'guild-1', + } as never, + conversationId: 'reacted-1', + anchorMessageId: 'reacted-1', + }); + + // The synthesized message id ('source-1' from getDiscordMessageCreate) is + // not a real Discord message; the reacted-on message anchors everything. + expect(provider.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'reacted-1', + name: 'Investigate this', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ currentMessageId: 'reacted-1' }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: expect.objectContaining({ + channelId: 'reacted-1', + isThread: true, + }), + replyToMessageId: 'reacted-1', + }), + ); + }); + it('continues an existing guild thread without creating another thread', async () => { const provider = { createThreadFromMessage: vi.fn(), diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 0b93a629e..d71a1d310 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ suggestionReaction: vi.fn(), getTaskUrl: vi.fn(), getChannel: vi.fn(), + getMessage: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(), createDirectMessage: vi.fn(), @@ -60,7 +61,6 @@ const mocks = vi.hoisted(() => ({ startGoal: vi.fn(), acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), - hasFastDefault: vi.fn(), hasFastSession: vi.fn(), findFastReplySession: vi.fn(), isFastProviderMessage: vi.fn(), @@ -190,10 +190,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ .mockResolvedValue({ id: 'fast-session-1' }), })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - import { discord, discordGatewayEventProcessingTimeout } from '../index.js'; import { discordApiEventLeaseRenewal } from '../event-gate.js'; @@ -202,6 +198,7 @@ app.route('/api/internal/discord', discord); const provider = { getChannel: mocks.getChannel, + getMessage: mocks.getMessage, addReaction: mocks.addReaction, removeReaction: mocks.removeReaction, createDirectMessage: mocks.createDirectMessage, @@ -236,6 +233,24 @@ function message(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so task-orchestration +// tests use attachment-only messages (no text for Fast mode to answer). +function attachmentMessage(overrides: Record = {}) { + return message({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + async function postEvent(body: unknown, secret = 'gateway-secret') { return app.request('http://localhost/api/internal/discord/events/process', { method: 'POST', @@ -291,6 +306,7 @@ describe('Discord Gateway event handler', () => { mocks.findCompletedRun.mockResolvedValue(null); mocks.findAutomationReportRun.mockResolvedValue(null); mocks.findSourceRun.mockResolvedValue(null); + mocks.getMessage.mockResolvedValue(null); mocks.removeReaction.mockResolvedValue(undefined); mocks.processAttachments.mockResolvedValue({ images: [], @@ -306,7 +322,6 @@ describe('Discord Gateway event handler', () => { vi.fn().mockResolvedValue(undefined), ); mocks.answerFast.mockResolvedValue('A quick answer'); - mocks.hasFastDefault.mockResolvedValue(false); mocks.hasFastSession.mockResolvedValue(false); mocks.findFastReplySession.mockResolvedValue(null); mocks.isFastProviderMessage.mockResolvedValue(false); @@ -382,10 +397,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'use API instead', channel: { id: 'thread-1', type: 11, @@ -404,7 +418,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.handleRoutingReply).toHaveBeenCalledWith( expect.objectContaining({ pendingRouteId: 'pending-route-1', - queuedMessage: expect.objectContaining({ text: 'use API instead' }), + queuedMessage: expect.objectContaining({ text: 'Image: context.png' }), }), ); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -420,7 +434,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('turns a configured reaction into a thread task entry', async () => { + it('routes a configured reaction into the fast agent in a thread anchored on the reacted-on message', async () => { mocks.callViaEmojiConfig.mockResolvedValue({ emoji: 'white_check_mark', prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', @@ -431,6 +445,14 @@ describe('Discord Gateway event handler', () => { type: 0, guildId: 'guild-1', }); + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-1', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); const response = await postEvent({ eventId: 'channel-1:message-1:discord-user-1:white_check_mark', @@ -449,28 +471,85 @@ describe('Discord Gateway event handler', () => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); expect(mocks.channelAutoStart).not.toHaveBeenCalled(); - expect(mocks.addReaction).toHaveBeenCalledWith({ + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-1', - name: '👀', }); - expect(mocks.startNewTask).toHaveBeenCalledWith( + // The fast thread anchors on the real reacted-on message, not the + // synthesized event id. + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-1', + name: expect.stringContaining('Act on this'), + }); + expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ - requesterDiscordUserId: 'discord-user-1', - launchOwnerUserId: 'roomote-user-1', - queuedMessage: expect.objectContaining({ - text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', - }), - metadata: expect.objectContaining({ - communicationMessageId: 'message-1', - communicationAnchorMessageId: 'message-1', + question: + 'Act on this\n\nAdditional instructions:\nPrioritize safety.\n\nMessage to act on:\nDeploys are failing on main', + userId: 'roomote-user-1', + currentMessageId: 'message-1', + conversation: expect.objectContaining({ + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'message-1', }), + }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'message-1', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-1', + text: expect.stringContaining('A quick answer'), }), ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.queueMessage).not.toHaveBeenCalled(); + }); + + it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => { + mocks.callViaEmojiConfig.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this', + }); + mocks.getChannel.mockResolvedValue({ + id: 'channel-1', + name: 'general', + type: 0, + guildId: 'guild-1', + }); + mocks.getMessage.mockRejectedValue(new Error('rate limited')); + + const response = await postEvent({ + eventId: 'channel-1:message-1:discord-user-1:white_check_mark', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ question: 'Act on this' }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('starts an exactly tracked suggestion before configured emoji routing', async () => { @@ -726,8 +805,8 @@ describe('Discord Gateway event handler', () => { }, ); - it('launches a linked DM request through the Discord task orchestrator', async () => { - const response = await postEvent(envelope(message())); + it('launches a linked DM attachment request through the Discord task orchestrator', async () => { + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.completeEvent).toHaveBeenCalledWith({ @@ -747,7 +826,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, queuedMessage: expect.objectContaining({ provider: 'discord', - text: 'Fix the flaky tests', + text: 'Image: context.png', userId: 'roomote-user-1', }), metadata: { @@ -762,8 +841,6 @@ describe('Discord Gateway event handler', () => { }); it('routes an ordinary linked DM message through Fast mode when the user default is enabled', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - const response = await postEvent(envelope(message())); expect(response.status).toBe(200); @@ -798,7 +875,6 @@ describe('Discord Gateway event handler', () => { }); it('starts a new guild-channel Fast conversation in an anchored thread', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.getChannel.mockResolvedValue({ id: 'channel-1', name: 'general', @@ -851,7 +927,6 @@ describe('Discord Gateway event handler', () => { }); it('passes the model-authored Fast kickoff through the Discord enqueue gate', async () => { - mocks.hasFastDefault.mockResolvedValue(true); const postKickoff = vi.fn().mockResolvedValue(undefined); mocks.startNewTask.mockImplementation( async (input: { @@ -910,7 +985,6 @@ describe('Discord Gateway event handler', () => { }); it('serializes complete Fast turns before the next Discord message enters the agent', async () => { - mocks.hasFastDefault.mockResolvedValue(true); let grantSecondLock!: (release: () => Promise) => void; const secondLock = new Promise<() => Promise>((resolve) => { grantSecondLock = resolve; @@ -982,7 +1056,6 @@ describe('Discord Gateway event handler', () => { }); it('gives defaulted Discord Fast mode the active task for thread continuation', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.findActiveRun.mockResolvedValue({ id: 23, taskId: 'task-23', @@ -1006,11 +1079,11 @@ describe('Discord Gateway event handler', () => { }); const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> can you check if this issue already exists?', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'message-parent', @@ -1047,11 +1120,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: 'Could you expand on the migration note?', message_reference: { message_id: 'announcer-root', channel_id: 'channel-1', @@ -1099,11 +1171,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1117,7 +1189,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.queueMessage).toHaveBeenCalledWith( 'discord', 11, - expect.objectContaining({ text: 'follow up on the first report' }), + expect.objectContaining({ text: 'Image: context.png' }), ); expect(mocks.findActiveRun).not.toHaveBeenCalled(); }); @@ -1148,11 +1220,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1173,7 +1245,7 @@ describe('Discord Gateway event handler', () => { it('still launches when the initial eyes reaction fails', async () => { mocks.addReaction.mockRejectedValueOnce(new Error('rate limited')); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -1230,7 +1302,7 @@ describe('Discord Gateway event handler', () => { ); }); - it('queues an ordinary message in an active Discord task thread with full thread context', async () => { + it('queues an attachment-only message in an active Discord task thread with full thread context', async () => { mocks.getChannel.mockResolvedValue({ id: 'thread-1', guildId: 'guild-1', @@ -1246,10 +1318,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Also fix the type error', }), ), ); @@ -1260,7 +1331,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', }), }), ); @@ -1269,7 +1340,7 @@ describe('Discord Gateway event handler', () => { taskId: 'task-23', provider: 'discord', message: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -1278,7 +1349,7 @@ describe('Discord Gateway event handler', () => { 'discord', 23, expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), turnPolicy: { reactionsAllowed: true }, }), @@ -1309,10 +1380,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'what about that earlier note?', message_reference: { message_id: 'earlier-1', channel_id: 'thread-1', @@ -1328,7 +1398,7 @@ describe('Discord Gateway event handler', () => { replyToMessageId: 'earlier-1', replyToChannelId: 'thread-1', queuedMessage: expect.objectContaining({ - text: 'what about that earlier note?', + text: 'Image: context.png', }), }), ); @@ -1361,10 +1431,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'yes fix those', }), ), ); @@ -1384,7 +1453,7 @@ describe('Discord Gateway event handler', () => { mocks.findSourceRun.mockResolvedValue({ id: 23, taskId: 'task-23' }); mocks.getTaskUrl.mockReturnValue('https://roomote.example/task/task-23'); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.queueMessage).not.toHaveBeenCalled(); @@ -2334,10 +2403,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'discussion-thread', guild_id: 'guild-1', - content: '<@bot-1> investigate the flaky build', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ), @@ -2376,10 +2445,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2390,7 +2458,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', }), }), ); @@ -2413,7 +2481,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, }, queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -2440,10 +2508,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2507,10 +2574,10 @@ describe('Discord Gateway event handler', () => { }, ); const originalEvent = envelope( - message({ + attachmentMessage({ channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> fix this', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ); @@ -2560,7 +2627,7 @@ describe('Discord Gateway event handler', () => { requesterDiscordUserId: 'discord-user-1', launchOwnerUserId: 'roomote-user-1', queuedMessage: expect.objectContaining({ - text: 'fix this', + text: 'Image: context.png', ts: 'message-1', userId: 'roomote-user-1', }), @@ -2577,7 +2644,7 @@ describe('Discord Gateway event handler', () => { }); it('restores the pending request and link code when continuation fails', async () => { - const originalEvent = envelope(message()); + const originalEvent = envelope(attachmentMessage()); mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); @@ -2722,27 +2789,40 @@ describe('Discord Gateway event handler', () => { }, }; + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-target', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); + const response = await postEvent( envelope(interaction, 'INTERACTION_CREATE'), ); expect(response.status).toBe(200); - expect(mocks.startNewTask).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - communicationMessageId: 'message-target', - communicationAnchorMessageId: 'message-target', - }), - replyToMessageId: 'message-target', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-target', - }), - ); - expect(mocks.addReaction).toHaveBeenCalledWith({ + // The replayed reaction summon enters the fast agent anchored on the + // reacted-on message, matching direct reaction entry. + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-target', - name: '👀', }); + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'channel-1', + messageId: 'message-target', + }), + ); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ + question: + 'Act on this\n\nMessage to act on:\nDeploys are failing on main', + currentMessageId: 'message-target', + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('requires /link in a DM without consuming the one-shot code', async () => { diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2fe6de4a6..6523501ed 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -24,7 +24,6 @@ import { } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { checkAutoStartChannelCache } from '../shared/auto-start-cache.js'; import { CHANNEL_AUTO_START_FAILURE_MESSAGE, @@ -279,10 +278,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { getDiscordMessageContent(message), botUserId, ); - if ( - defaultFastQuestion && - (await hasCommunicationsFastModeDefault(mappedUserId)) - ) { + if (defaultFastQuestion) { void processDiscordFastAgentMessage({ event, question: defaultFastQuestion, diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 023ac8e0a..863a5eb0f 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -85,14 +85,23 @@ export async function processDiscordFastAgentMessage(input: { metadata: ReturnType; conversationId: string; createAnchoredThread?: boolean; + /** + * The real Discord message replies and anchored threads attach to. Defaults + * to the inbound message's own id; reaction summons pass the reacted-on + * message because their synthesized message id is not a real Discord + * message. + */ + anchorMessageId?: string; interaction?: DiscordInteractionReplyContext; activeTasks?: { taskId: string }[]; }): Promise { const message = getDiscordMessageCreate(input.event); + const anchorMessageId = input.anchorMessageId ?? message?.id; let channel = input.channel; let metadata = input.metadata; if ( message && + anchorMessageId && input.createAnchoredThread !== false && !channel.isDirectMessage && !channel.isThread && @@ -100,7 +109,7 @@ export async function processDiscordFastAgentMessage(input: { ) { const thread = await input.provider.createThreadFromMessage({ channelId: channel.channelId, - messageId: message.id, + messageId: anchorMessageId, name: buildCommunicationTaskThreadName(input.question), }); channel = { @@ -179,7 +188,7 @@ export async function processDiscordFastAgentMessage(input: { applicationId: input.applicationId, channel, ...(input.interaction ? { interaction: input.interaction } : {}), - ...(message ? { replyToMessageId: message.id } : {}), + ...(anchorMessageId ? { replyToMessageId: anchorMessageId } : {}), text: textWithFooter, }); await recordFastAgentConversationMessageBestEffort({ @@ -218,7 +227,7 @@ export async function processDiscordFastAgentMessage(input: { userId: input.senderUserId, apiBaseUrl, conversation, - currentMessageId: message?.id ?? input.interaction?.interaction.id, + currentMessageId: anchorMessageId ?? input.interaction?.interaction.id, signal: releaseFastAgentLock.signal, senderDisplayName: input.interaction?.interaction.member?.nick ?? diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index e8a7b8cd8..2657563bb 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -46,7 +46,6 @@ import { } from '@roomote/sdk/server'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { @@ -745,12 +744,11 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + // Fast mode is unconditional for ordinary linked-human messages, including + // reaction summons: a configured emoji synthesizes a bot mention that enters + // the fast agent, matching Slack's call-roomote-via-emoji flow. const defaultFastMessage = - message != null && - command == null && - (await hasCommunicationsFastModeDefault(senderUserId)) - ? message - : null; + message != null && command == null ? message : null; if (command?.name === 'goal') { if (!command.objective) { @@ -811,6 +809,11 @@ async function processDiscordGatewayEvent( conversationId: repliedFastSession?.conversation.conversationId ?? channel.channelId, ...(repliedFastSession ? { createAnchoredThread: false } : {}), + // A reaction summon's synthesized message id is not a real Discord + // message; anchor replies on the reacted-on message instead. + ...(reactionTarget + ? { anchorMessageId: reactionTarget.messageId } + : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastContinued: true }; @@ -823,19 +826,42 @@ async function processDiscordGatewayEvent( ) : ''; if (defaultFastMessage && defaultFastQuestion) { + let fastQuestion = defaultFastQuestion; + if (reactionTarget) { + // Match Slack's emoji summon: inline the reacted-on message so the fast + // agent sees what it was asked to act on even without thread history. + try { + const targetMessage = await resolved.provider.getMessage({ + channelId: reactionTarget.channelId, + messageId: reactionTarget.messageId, + }); + if (targetMessage?.text) { + fastQuestion = `${defaultFastQuestion}\n\nMessage to act on:\n${targetMessage.text}`; + } + } catch (error) { + apiLogger.warn( + `[discord] Could not resolve emoji summon target ${reactionTarget.channelId}:${reactionTarget.messageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } await processDiscordFastAgentMessage({ event, - question: defaultFastQuestion, + question: fastQuestion, sender, senderUserId, provider: resolved.provider, applicationId: resolved.applicationId, channel, metadata, + // A reaction summon anchors its fast conversation (and any created + // thread) on the reacted-on message, mirroring Slack threading under + // the reacted-on message; the synthesized message id is not a real + // Discord message. conversationId: getDiscordFastConversationId( channel, - defaultFastMessage.id, + reactionTarget?.messageId ?? defaultFastMessage.id, ), + ...(reactionTarget ? { anchorMessageId: reactionTarget.messageId } : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastDefaulted: true }; diff --git a/apps/api/src/handlers/fast-agent-entry.test.ts b/apps/api/src/handlers/fast-agent-entry.test.ts deleted file mode 100644 index a8d9c61f4..000000000 --- a/apps/api/src/handlers/fast-agent-entry.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -const mocks = vi.hoisted(() => ({ - findUser: vi.fn(), -})); - -vi.mock('@roomote/db/server', () => ({ - db: { query: { users: { findFirst: mocks.findUser } } }, - eq: vi.fn(), - users: { id: 'users.id' }, -})); - -import { hasCommunicationsFastModeDefault } from './fast-agent-entry'; - -describe('hasCommunicationsFastModeDefault', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns the stored preference', async () => { - mocks.findUser.mockResolvedValue({ - metadata: { communications_fast_mode_default: true }, - }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - true, - ); - }); - - it('returns false when the stored preference is not enabled', async () => { - mocks.findUser.mockResolvedValue({ metadata: {} }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - false, - ); - }); -}); diff --git a/apps/api/src/handlers/fast-agent-entry.ts b/apps/api/src/handlers/fast-agent-entry.ts index a87bb5f88..bb7ad4585 100644 --- a/apps/api/src/handlers/fast-agent-entry.ts +++ b/apps/api/src/handlers/fast-agent-entry.ts @@ -1,5 +1,3 @@ -import { db, eq, users } from '@roomote/db/server'; - type FastAgentEntryMode = 'explicit' | 'default'; export function resolveFastAgentEntryMode(params: { @@ -12,21 +10,3 @@ export function resolveFastAgentEntryMode(params: { return params.userDefaultEnabled ? 'default' : null; } - -export async function hasCommunicationsFastModeDefault( - userId: string, -): Promise { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { metadata: true }, - }); - const metadata = user?.metadata; - - return ( - typeof metadata === 'object' && - metadata !== null && - !Array.isArray(metadata) && - (metadata as Record).communications_fast_mode_default === - true - ); -} diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 438ae3530..4f7cacf02 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -150,7 +150,6 @@ describe('channel auto-start unlinked author', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); const { handleMessageOrAppMentionEvent } = diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index f459d5d25..8b3548ec4 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -1248,11 +1248,9 @@ async function maybeHandleChannelAutoStart(params: { explicitInvocation: isBareFastCommandInvocation( channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, ), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation( - channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, - ), + userDefaultEnabled: !isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ), }) : null; @@ -1733,9 +1731,7 @@ async function handleSlackEntryEvent(params: { const authoredEventText = event.authoredText ?? event.text; const fastAgentEntryMode = resolveFastAgentEntryMode({ explicitInvocation: isFastCommandInvocation(authoredEventText), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(authoredEventText), + userDefaultEnabled: !isRemovedEvalCommandInvocation(authoredEventText), }); if (fastAgentEntryMode) { diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index f339fbb5b..b66f5fb43 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,7 +24,6 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', - metadata: 'users.metadata', }, })); @@ -52,7 +51,6 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -68,7 +66,6 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, - communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); @@ -85,7 +82,6 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), - userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 5537804bc..4aa738842 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,9 +8,7 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: - | (SlackUserMapping & { communicationsFastModeDefault: boolean }) - | null; + activeMapping: SlackUserMapping | null; hasInactiveMapping: boolean; }; @@ -28,7 +26,6 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, - userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -62,12 +59,6 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - communicationsFastModeDefault: - typeof row.userMetadata === 'object' && - row.userMetadata !== null && - !Array.isArray(row.userMetadata) && - (row.userMetadata as Record) - .communications_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts new file mode 100644 index 000000000..040a492fb --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -0,0 +1,211 @@ +import { + db, + eq, + fastAgentConversations, + inArray, + sessionBackfillState, + sessionFactory, + sessionTasks, + sessions, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { sessionsReconcileJob } from '../sessions-reconcile'; + +const BACKFILL_KEY = 'unified-sessions-v1'; + +describe('sessionsReconcileJob', () => { + it('backfills Fast conversations and visible tasks idempotently', async () => { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + }); + + it('adopts orphan Fast conversations during steady-state reconciliation', async () => { + // Complete (or advance) the one-time backfill first so the next run takes + // the steady-state reconciliation path. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + }); + + it('resumes a backfill parked in the legacy fast_tasks phase', async () => { + await db + .insert(sessionBackfillState) + .values({ key: BACKFILL_KEY, phase: 'fast_tasks' }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: 'fast_tasks', + cursorCreatedAt: null, + cursorId: null, + completedAt: null, + }, + }); + const user = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + expect(state?.completedAt).not.toBeNull(); + }); + + it('continues past a poisoned row during steady-state reconciliation', async () => { + // Ensure the backfill is complete so the steady-state path runs. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + // A surface value the sessions check constraint rejects makes + // ensureSessionForFastConversation throw for this row only. + const [poisoned] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'bogus' as never, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const [healthy] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await expect(sessionsReconcileJob()).resolves.toBeUndefined(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, healthy!.id)), + ).resolves.toHaveLength(1); + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, poisoned!.id)), + ).resolves.toHaveLength(0); + + // A failed adoption must NOT advance the reconcile watermark, so the + // failed row stays inside the next run's scan window instead of being + // stranded past the cutoff once the failure clears. + const watermarkBefore = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, poisoned!.id)); + await sessionsReconcileJob(); + const watermarkAfter = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + expect(watermarkAfter?.cursorCreatedAt?.getTime() ?? 0).toBeGreaterThan( + watermarkBefore?.cursorCreatedAt?.getTime() ?? 0, + ); + }); + + it('drains an over-batch orphan backlog across runs without stranding rows', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + // 101 orphans: one full batch plus one. A full batch must NOT advance + // the watermark, so the next run still sees (and adopts) the remainder. + const user = await userFactory.create(); + const rows = await db + .insert(fastAgentConversations) + .values( + Array.from({ length: 101 }, () => ({ + userId: user.id, + surface: 'web' as const, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + })), + ) + .returning({ id: fastAgentConversations.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const ids = rows.map((row) => row.id); + const adopted = await db + .select({ id: sessions.fastConversationId }) + .from(sessions) + .where(inArray(sessions.fastConversationId, ids)); + expect(adopted).toHaveLength(101); + }); + + it('heals sessions wedged active on an expired responding lease', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const wedged = await sessionFactory.create({ + cachedStatus: 'active', + respondingUntil: new Date(Date.now() - 60_000), + // Old activity keeps it clear of the recent-activity refresh window. + activityAt: 100, + }); + + await sessionsReconcileJob(); + + const [healed] = await db + .select({ cachedStatus: sessions.cachedStatus }) + .from(sessions) + .where(eq(sessions.id, wedged.id)); + expect(healed?.cachedStatus).toBe('ready'); + + await db.delete(sessions).where(eq(sessions.id, wedged.id)); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts index 6be114ec8..a2b2061f0 100644 --- a/apps/bullmq/src/scheduled-jobs/index.ts +++ b/apps/bullmq/src/scheduled-jobs/index.ts @@ -9,3 +9,4 @@ export { standbyRetentionJob } from './standby-retention'; export { prReviewNotificationDispatchJob } from './pr-review-notification-dispatch'; export { brainOutboxDrainJob, brainCollectorsJob } from './brain-outbox-drain'; export { brainMaintenanceJob } from './brain-maintenance'; +export { sessionsReconcileJob } from './sessions-reconcile'; diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts new file mode 100644 index 000000000..52a2ec63a --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -0,0 +1,393 @@ +import { + and, + db, + desc, + ensureSessionForFastConversation, + ensureSessionForTask, + eq, + fastAgentConversations, + gt, + inArray, + isNull, + lt, + or, + sessionBackfillState, + sessions, + sessionTasks, + sql, + taskRuns, + tasks, + touchSessionActivity, +} from '@roomote/db/server'; +const LOG_PREFIX = '[sessions]'; +const BACKFILL_KEY = 'unified-sessions-v1'; +/** + * Steady-state reconcile watermark, stored as a second state row: its + * cursorCreatedAt marks the scan-start time of the last orphan pass that + * completed with ZERO failures. Advancing only on clean passes means a + * transient outage keeps failed rows inside the scan window until they + * actually converge, instead of stranding them past the cutoff forever. + */ +const RECONCILE_KEY = 'unified-sessions-reconcile-v1'; +const RECONCILE_CURSOR_ID = 'watermark'; +const BATCH_SIZE = 100; +/** Slack subtracted from the last-run watermark when bounding orphan scans. */ +const ORPHAN_SCAN_SLACK_MS = 60 * 60 * 1000; + +type Cursor = { createdAt: Date; id: string } | null; + +function afterCursor( + createdAt: TCreatedAt, + id: TId, + cursor: Cursor, +) { + return cursor + ? or( + gt(createdAt as never, cursor.createdAt), + and( + eq(createdAt as never, cursor.createdAt), + gt(id as never, cursor.id), + ), + ) + : undefined; +} + +async function updateState(input: { + phase: 'fast_conversations' | 'tasks' | 'participants'; + cursor?: Cursor; + completed?: boolean; +}) { + await db + .insert(sessionBackfillState) + .values({ + key: BACKFILL_KEY, + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function backfillFastConversations(cursor: Cursor): Promise { + const rows = await db + .select({ + id: fastAgentConversations.id, + createdAt: fastAgentConversations.createdAt, + }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + afterCursor( + fastAgentConversations.createdAt, + fastAgentConversations.id, + cursor, + ), + ), + ) + .orderBy(fastAgentConversations.createdAt, fastAgentConversations.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, row.id), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} backfill failed for fast conversation ${row.id}`, + error, + ); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'fast_conversations' : 'tasks', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill fast conversations`, { + processed: rows.length, + }); + return rows.length < BATCH_SIZE; +} + +async function backfillTasks(cursor: Cursor): Promise { + const rows = await db + .select({ id: tasks.id, createdAt: tasks.createdAt }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + afterCursor(tasks.createdAt, tasks.id, cursor), + ), + ) + .orderBy(tasks.createdAt, tasks.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + const latestFastRun = await db.query.taskRuns.findFirst({ + where: and( + eq(taskRuns.taskId, row.id), + sql`${taskRuns.fastAgentSessionId} IS NOT NULL`, + ), + columns: { fastAgentSessionId: true }, + orderBy: desc(taskRuns.id), + }); + await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: row.id, + fastConversationId: latestFastRun?.fastAgentSessionId ?? null, + origin: 'backfill', + }), + ); + } catch (error) { + console.error(`${LOG_PREFIX} backfill failed for task ${row.id}`, error); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'tasks' : 'participants', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill tasks`, { processed: rows.length }); + return rows.length < BATCH_SIZE; +} + +async function backfillParticipants(): Promise { + await db.execute(sql` + INSERT INTO session_participants (session_id, user_id, role) + SELECT DISTINCT s.id, fam.metadata->>'userId', 'member' + FROM sessions s + JOIN fast_agent_messages fam ON fam.conversation_id = s.fast_conversation_id + JOIN users u ON u.id = fam.metadata->>'userId' AND u.deleted_at IS NULL + WHERE fam.metadata->>'userId' IS NOT NULL + ON CONFLICT (session_id, user_id) DO NOTHING + `); + await updateState({ phase: 'participants', completed: true }); + console.info(`${LOG_PREFIX} backfill participants complete`); +} + +async function reconcileRecentSessions(watermark: Date | null): Promise { + // Bound the steady-state orphan scans to rows created since the last + // fully-successful pass (with slack) so they stop scanning entire tables + // every run. A null watermark (first run, or no clean pass yet) scans + // unbounded. + const cutoff = watermark + ? new Date(watermark.getTime() - ORPHAN_SCAN_SLACK_MS) + : null; + const scanStartedAt = new Date(); + let orphanFailures = 0; + + // Fast conversations without a session row (e.g. created before this + // release finished its backfill) are adopted here so the unified list + // converges without another full backfill. + const orphanConversations = await db + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + cutoff ? gt(fastAgentConversations.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(fastAgentConversations.updatedAt)) + .limit(BATCH_SIZE); + + for (const conversation of orphanConversations) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation.id), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for fast conversation ${conversation.id}`, + error, + ); + } + } + + const orphanTasks = await db + .select({ id: tasks.id }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + cutoff ? gt(tasks.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(tasks.activityAt)) + .limit(BATCH_SIZE); + + for (const task of orphanTasks) { + try { + await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for task ${task.id}`, + error, + ); + } + } + + const recent = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where(eq(sessions.visibility, 'visible')) + .orderBy(desc(sessions.activityAt)) + .limit(BATCH_SIZE); + for (const session of recent) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} refresh failed for session ${session.id}`, + error, + ); + } + } + + // Sessions stuck 'active'/'needs_input' on an expired (or missing) lease + // may be older than the top-100-by-activity window; heal them explicitly + // so wedged sessions converge regardless of recency. + const expiredLeases = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where( + and( + eq(sessions.visibility, 'visible'), + inArray(sessions.cachedStatus, ['active', 'needs_input']), + or( + isNull(sessions.respondingUntil), + lt(sessions.respondingUntil, new Date()), + ), + ), + ) + .limit(BATCH_SIZE); + for (const session of expiredLeases) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} lease heal failed for session ${session.id}`, + error, + ); + } + } + + // Advance the watermark only when this pass definitely drained the + // backlog: zero adoption failures AND neither scan returned a full batch + // (a full batch means older rows may remain beyond the LIMIT). Otherwise + // the next run rescans the same window until it converges. Failures in + // the touch/heal loops don't affect orphan scanning. + const sawFullBatch = + orphanConversations.length === BATCH_SIZE || + orphanTasks.length === BATCH_SIZE; + if (orphanFailures === 0 && !sawFullBatch) { + await db + .insert(sessionBackfillState) + .values({ + key: RECONCILE_KEY, + phase: 'participants', + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + completedAt: null, + lastRunAt: scanStartedAt, + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + lastRunAt: scanStartedAt, + updatedAt: new Date(), + }, + }); + } else { + console.warn( + `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed, fullBatch=${sawFullBatch}`, + ); + } + + console.info(`${LOG_PREFIX} reconciliation`, { + orphanFastConversations: orphanConversations.length, + orphanVisibleTasks: orphanTasks.length, + refreshedSessions: recent.length, + healedExpiredLeases: expiredLeases.length, + }); +} + +export async function sessionsReconcileJob(): Promise { + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + if (state?.completedAt) { + const reconcileState = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, RECONCILE_KEY), + }); + await reconcileRecentSessions(reconcileState?.cursorCreatedAt ?? null); + return; + } + + const phase = state?.phase ?? 'fast_conversations'; + const cursor = + state?.cursorCreatedAt && state.cursorId + ? { createdAt: state.cursorCreatedAt, id: state.cursorId } + : null; + + if (phase === 'fast_conversations') { + const complete = await backfillFastConversations(cursor); + if (!complete) return; + } + // 'fast_tasks' is the pre-rename name of the tasks phase; deployments that + // ran an earlier build of this branch may still be parked there. + if ( + phase === 'fast_conversations' || + phase === 'fast_tasks' || + phase === 'tasks' + ) { + const complete = await backfillTasks( + phase === 'fast_tasks' || phase === 'tasks' ? cursor : null, + ); + if (!complete) return; + } + await backfillParticipants(); +} diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts index 360d37127..44b34dfa5 100644 --- a/apps/bullmq/src/scheduler.ts +++ b/apps/bullmq/src/scheduler.ts @@ -35,6 +35,7 @@ import { brainOutboxDrainJob, brainCollectorsJob, brainMaintenanceJob, + sessionsReconcileJob, } from './scheduled-jobs'; const QUEUE_NAME = 'scheduled-jobs'; @@ -225,6 +226,10 @@ async function createJobs(queue: Queue): Promise { { pattern: '0 7 * * *' }, ); + await queue.upsertJobScheduler(ScheduledJobName.SessionsReconcile, { + every: 60 * 1000, + }); + const schedulers = await queue.getJobSchedulers(); console.log('[createJobs] getJobSchedulers ->', schedulers); } @@ -266,6 +271,8 @@ const runJobs = async (job: ScheduledJob): Promise => { return brainCollectorsJob(); case ScheduledJobName.BrainMaintenance: return brainMaintenanceJob(); + case ScheduledJobName.SessionsReconcile: + return sessionsReconcileJob(); case ScheduledJobName.CustomAutomations: await customAutomationsJob(); return; diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts index 6393c98a6..9e3730035 100644 --- a/apps/bullmq/src/types.ts +++ b/apps/bullmq/src/types.ts @@ -18,6 +18,7 @@ export enum ScheduledJobName { BrainOutboxDrain = 'BrainOutboxDrain', BrainCollectors = 'BrainCollectors', BrainMaintenance = 'BrainMaintenance', + SessionsReconcile = 'SessionsReconcile', } /** diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 9fbfd2b5e..ee44179eb 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,32 +1,41 @@ --- -title: Fast sessions -icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast session transcript. +title: Sessions +icon: messages-square +description: Follow a conversation and every execution it delegates from one continuous Roomote workspace. --- -Fast is Roomote's conversational orchestrator: it answers directly when it can -and delegates execution work into tasks when needed. A Fast session persists -across Slack, Discord, Microsoft Teams, Telegram, an automation, or the web -dashboard. +Sessions are the primary way to follow work in Roomote. A Session keeps the +conversation, delegated executions, review activity, artifacts, pull requests, +cost, and unread state together, whether it started in chat, source control, an +automation, the API, or the web dashboard. Fast is the conversational +orchestrator inside a Session: it answers directly when it can and delegates +execution work when needed across Slack, Discord, Microsoft Teams, Telegram, +automations, and the web dashboard. -## Start a Fast session from the dashboard +## Start a Session from the dashboard -On the home page, open the workspace selector next to the prompt box and choose -**Fast**. Your prompt starts a Fast session instead of a sandbox task, and -Roomote takes you straight to the session view, where the response streams in -as it is produced. +On the home page, leave the workspace selector on **Auto** to start a +conversation. Roomote answers directly when it can and delegates execution +when the request needs a repository workspace. Selecting an environment or +repository starts the execution directly, but Roomote still creates the +owning Session and opens it with that execution selected. -Use Fast when you want an answer, a decision, or a delegation rather than a -full sandbox run. Fast can still launch tasks on your behalf; delegated tasks -appear in the transcript with links to their task pages. +You do not need to choose a separate conversation mode. The Session grows from +conversation to execution to review without changing identity. ## The session view -A session's transcript shows prompts, replies, and the tool activity behind -them, rendered with the same transcript view as tasks, with a generated title -that updates as the session evolves. The view updates in real time while -a turn is running, so you can watch tool calls complete and replies land -without refreshing. +A Session timeline shows prompts, replies, and delegated execution activity. +Execution cards show their status, workspace, pull requests, artifacts, latest +error, and cost. Select a card to open the lightweight details panel, or choose +**Open full workspace** for terminal, logs, diff, and preview tools. + +The Sessions page supports list and board views, filters, search, pins, recent +Sessions, and unread indicators. **Ready** is not a terminal state: you can +reply or start another execution in the same Session later. + +The transcript renders prompts, replies, and tool activity in real time with a +generated title that updates as the Session evolves. Fast sessions can also render presentational widgets such as status cards, tables, and plans directly in the transcript. Widget HTML is sanitized and @@ -35,15 +44,21 @@ fallback. ## Reply to a session -Every session has a reply box at the bottom of the transcript; follow-ups -continue the same session with full context. For sessions that live -on another surface, such as a Slack thread, Roomote's answer is posted back -into the originating thread with a quoted copy of your web message, so the -session stays in one place for everyone following it there. Fast replies -across Slack, Discord, Microsoft Teams, and Telegram carry a "Reply or use the -web app" footer linking to the session view. Slack and Discord can also resume -Fast directly from chat. Microsoft Teams replies to Fast session and automation -messages also continue the same session after Roomote verifies the tenant, -installation, conversation, and linked user. Telegram currently uses the -session view for Fast follow-ups because its inbound webhook route does not yet -carry Fast session identity. +Conversational Sessions have a reply box at the bottom of the transcript; +follow-ups continue the same conversation with full context. For conversations +that live on another surface, Roomote posts the answer back into the originating +thread with a quoted copy of your web message, so the conversation stays in one +place for everyone following it there. + +Fast replies across Slack, Discord, Microsoft Teams, and Telegram link back to +the Session view. Slack and Discord can also resume Fast directly from chat. +Microsoft Teams replies continue the same Session after Roomote verifies the +tenant, installation, conversation, and linked user. Telegram currently uses +the Session view for Fast follow-ups because its inbound webhook route does not +yet carry Fast Session identity. + +## Execution access + +Session participants can see timeline summaries. Full execution details keep +the existing task permissions, so joining a shared channel does not grant +access to logs, terminals, diffs, previews, or private artifacts. diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 02f976dee..8af762abb 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,11 +64,6 @@ Personal Settings also include app preferences such as: - **Mind Reader Mode** to expand LLM thoughts by default in task conversations; you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view -- **Fast response mode** to select Fast by default for new homepage prompts and - use Fast responses by default for messages sent from your linked Slack and - Discord accounts. An explicit homepage workspace choice takes precedence. - The chat preference does not apply to GitHub, Teams, or Telegram. You can - still use `!fast` explicitly in Slack whether the preference is on or off. Most teammates only need profile, linked accounts, and theme settings. diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 3c8b773ec..a3857c3af 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -120,9 +120,9 @@ under **Settings > Automations**, the same way you would pick a Slack channel. current one - use `/goal objective:` to keep working toward an objective across multiple turns in an active task thread or DM; this does not create a new task -- enable **Fast response mode** under **Settings > Personal** to send ordinary - Discord DMs, mentions, and eligible thread replies from your linked account - through the fast orchestrator +- ordinary Discord DMs, mentions, and eligible thread replies from your linked + account are always answered in Fast mode, which can delegate repository work + into tasks - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 113c832c0..7e5aa0671 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -181,9 +181,9 @@ Mention the app and use `!fast ` to ask the fast orchestrator a question or delegate work into a task. For example: `@Roomote !fast summarize this thread` or `@Roomote !fast fix the failing CI job`. -Enable **Fast response mode** under **Settings > Personal** to send ordinary -messages from your linked Slack and Discord accounts through the fast -orchestrator without an explicit command. +**Fast response mode** is always on: ordinary messages from your linked Slack +and Discord accounts go through the fast orchestrator, which can delegate work +into tasks. `!fast` remains available for an explicit Fast request. Fast can read a bounded history from the current Slack channel, use MCP servers and user-scoped integrations that you are allowed to access, and delegate diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index ce1107bea..6ae7279d5 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -4,9 +4,10 @@ icon: clipboard-check description: Inspect the transcript, logs, diffs, previews, and follow-up path before you trust the result. --- -A task is a single unit of Roomote work. It may start from chat, source -control, Linear, or the web dashboard, but the task view gives your team one -shared place to inspect what happened and decide what should happen next. +A task is one independently controllable execution inside a Session. It may +start from chat, source control, Linear, the API, or the web dashboard. The +task workspace remains the place to inspect operational details such as logs, +terminal output, diffs, previews, retries, and artifacts. Use the task view as the handoff point between Roomote and your normal review process. A task is complete only when the evidence is clear enough for a @@ -24,19 +25,17 @@ Before you dive into details, check the basics: - whether the end state matches the kind of outcome you wanted: answer, plan, patch, branch, or PR -## Task board +## Sessions and the task board -Use the board view on the Tasks page to scan shared work by lifecycle. Roomote -places tasks in **Active**, **Needs input**, **Blocked / failed**, or **Done** +Use the board view on the Sessions page to scan shared work by lifecycle. +Roomote places Sessions in **Active**, **Needs input**, **Blocked**, or **Ready** from their current task, goal, and run state, so your team does not need to maintain a separate status field. -Each card shows who started the task, participant avatars, recent activity, and -available workspace or pull-request context. The Done column keeps the six most -recent completed tasks so finished work does not overwhelm active work. Board -and list choices remain in the URL so views are shareable. Roomote also restores -the most recently selected layout from browser storage when you return; if -browser storage is unavailable, the Tasks page falls back to list view. +Each Session card shows its owner and participants, recent activity, delegated +execution count, workspace or pull-request context, aggregate cost, and unread +state. Use the **Tasks** scope when you only want Sessions containing execution +work. Board and list choices remain in the URL so views are shareable. ## Recover from a failed start @@ -50,6 +49,8 @@ reattach any files the new task needs. The task view gives you the working context for a run: +- a header breadcrumb linking back to the owning Session (when you opened the + workspace from a filtered Sessions view, browser Back returns to that view) - conversation history and Roomote updates - inline widgets for structured tables, status cards, plans, and other presentational results an agent chooses to show diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx index 47013ad37..f3451106b 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx @@ -56,6 +56,8 @@ const analyticsFilterKeys = [ 'taskType', 'provider', 'model', + 'ownerKind', + 'hasExecution', ] as const; type SelectedAnalyticsSegment = { @@ -65,7 +67,11 @@ type SelectedAnalyticsSegment = { seriesLabel: string; }; -const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = ['tasks', 'pullRequests']; +const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = [ + 'tasks', + 'sessions', + 'pullRequests', +]; function parseAnalyticsObject( value: string | null, @@ -75,7 +81,7 @@ function parseAnalyticsObject( return value as AnalyticsObject; } - return allowedObjects[0] ?? analyticsObjects[0]; + return allowedObjects[0] ?? analyticsObjects[0] ?? 'tasks'; } function getFiltersFromSearchParams( diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx index 7f0acd1fd..c0513d34c 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx @@ -42,12 +42,14 @@ type AnalyticsDetailsDialogProps = { }; const DIALOG_WIDTH_BY_OBJECT: Record = { + sessions: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', tasks: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', pullRequests: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', costs: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', }; const TABLE_MIN_WIDTH_BY_OBJECT: Record = { + sessions: 'min-w-[900px] md:min-w-[1040px]', tasks: 'min-w-[980px] md:min-w-[1100px]', pullRequests: 'min-w-[1140px] md:min-w-[1220px]', costs: 'min-w-[1140px] md:min-w-[1220px]', diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts index 0e440a02d..35a5b9c57 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts @@ -10,6 +10,7 @@ import { GitPullRequest, RadioTower, VectorSquare, + Rows4, } from '@/components/system'; export const ANALYTICS_DIMENSION_ICONS: Record< @@ -25,4 +26,6 @@ export const ANALYTICS_DIMENSION_ICONS: Record< taskType: Bot, provider: Cpu, model: Brain, + ownerKind: Bot, + hasExecution: Rows4, }; diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx index 152b42906..e01eaa501 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx @@ -39,6 +39,8 @@ const ANALYTICS_DIMENSION_PLURAL_LABELS: Record = { taskType: 'Types', provider: 'Providers', model: 'Models', + ownerKind: 'Owner kinds', + hasExecution: 'Execution states', }; type AnalyticsFilterBarProps = { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx index 2311c1ac7..f5caa6d23 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx @@ -16,6 +16,8 @@ type AnalyticsShellItemId = AnalyticsObject; export function getAnalyticsHref(itemId: AnalyticsShellItemId) { switch (itemId) { + case 'sessions': + return '/analytics?object=sessions'; case 'tasks': return '/analytics'; case 'pullRequests': @@ -26,6 +28,7 @@ export function getAnalyticsHref(itemId: AnalyticsShellItemId) { } const ANALYTICS_SHELL_ITEMS = [ + { id: 'sessions', label: 'Sessions', icon: ChartColumnIncreasing }, { id: 'tasks', label: 'Tasks', icon: ChartColumnIncreasing }, { id: 'costs', label: 'Costs', icon: CircleDollarSign }, ] as const satisfies Array<{ @@ -35,6 +38,7 @@ const ANALYTICS_SHELL_ITEMS = [ }>; const ANALYTICS_DESCRIPTIONS: Record = { + sessions: 'Track Session activity by owner, status, and source.', pullRequests: 'Track pull request activity by user, status, repository, and author.', tasks: 'Track task activity by user, environment, source, and task type.', diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index a49ad66e2..5c2b000e2 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -7,7 +7,6 @@ import { } from '@testing-library/react'; import { ALL_REPOSITORIES, FAST_EXECUTION } from '@roomote/types'; -import type { RoutingDecision } from '@roomote/cloud-agents/server'; import type { PromptInputMessage } from '@/components/ai-elements'; import { AUTO_WORKSPACE_VALUE } from '@/components/tasks/constants'; @@ -19,8 +18,6 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; -let currentCommunicationsFastModeDefault = false; -let currentPersonalPreferencesLoading = false; const { mockPush, @@ -31,8 +28,6 @@ const { mockUseCreateStandardTaskRun, mockCreateStandardTaskRun, mockUseLaunchTaskModels, - mockUseRouteHomeTask, - mockRouteHomeTask, mockPreparePromptAttachments, mockStartFastSession, } = vi.hoisted(() => ({ @@ -44,8 +39,6 @@ const { mockUseCreateStandardTaskRun: vi.fn(), mockCreateStandardTaskRun: vi.fn(), mockUseLaunchTaskModels: vi.fn(), - mockUseRouteHomeTask: vi.fn(), - mockRouteHomeTask: vi.fn(), mockPreparePromptAttachments: vi.fn(), mockStartFastSession: vi.fn(), })); @@ -95,23 +88,8 @@ vi.mock('@/hooks/environments', () => ({ }), })); -vi.mock('@/hooks/usePersonalPreferences', () => ({ - usePersonalPreferences: () => ({ - preferences: { - colorTheme: 'system', - mindReaderMode: false, - narrationMode: false, - communicationsFastModeDefault: currentCommunicationsFastModeDefault, - }, - isLoading: currentPersonalPreferencesLoading, - isUpdating: false, - setPreferences: vi.fn(), - }), -})); - vi.mock('@/hooks/task-runs', () => ({ useCreateStandardTaskRun: mockUseCreateStandardTaskRun, - useRouteHomeTask: mockUseRouteHomeTask, useStartFastSession: () => ({ isPending: false, mutateAsync: mockStartFastSession, @@ -133,17 +111,6 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: mockUseLaunchTaskModels, })); -vi.mock('@/components/system', async () => { - const actual = await vi.importActual( - '@/components/system', - ); - - return { - ...actual, - Loader2: (props: React.ComponentProps<'svg'>) => , - }; -}); - vi.mock('@/lib', () => ({ processImageFiles: mockProcessImageFiles, })); @@ -179,13 +146,11 @@ vi.mock('@/components/tasks', async () => { ...actual, SelectWorkspace: ({ allowAuto, - allowFast, autoSelectDefaultWorkspace, onInvalidWorkspaceReset, allowBranchSelection, }: { allowAuto?: boolean; - allowFast?: boolean; autoSelectDefaultWorkspace?: boolean; onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; @@ -234,18 +199,16 @@ vi.mock('@/components/tasks', async () => { > Use auto workspace - {allowFast && ( - - )} + + + + + updateParams((params) => { + if (id && id !== 'all') { + params.set('user', id); + } else { + params.delete('user'); + } + }) + } + onRepositoryChange={(value) => + updateParams((params) => { + if (value) params.set('repository', value); + else params.delete('repository'); + }) + } + onPullRequestChange={(value) => + updateParams((params) => { + if (value) params.set('pullRequest', value); + else params.delete('pullRequest'); + }) + } + onModelChange={(value) => + updateParams((params) => { + if (value) params.set('model', value); + else params.delete('model'); + }) + } + onTimePeriodChange={(period) => + updateParams((params) => { + if (period === 'all') { + params.delete('period'); + } else { + params.set('period', String(period)); + } + }) + } + showRepository + showPullRequest + showModel + showTaskType={false} + /> + ); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index 8a794905c..b92747bf2 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -1,78 +1,143 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { + getSessionStatusLabel, + SESSION_STATUSES, + type SessionStatus, +} from '@roomote/types'; + import { parseTimePeriodParam } from '@/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessions } from '@/lib/server/fast-sessions'; +import { getSessions, type SessionScope } from '@/lib/server/sessions'; import { Empty, EmptyDescription, EmptyHeader } from '@/components/system'; -import { FastSessionCard } from './FastSessionCard'; import { SessionsFilters } from './SessionsFilters'; +import { SessionCard } from './SessionCard'; export default async function SessionsPage({ searchParams, }: { - searchParams?: Promise<{ before?: string; user?: string; period?: string }>; + searchParams?: Promise<{ + before?: string; + user?: string; + period?: string; + scope?: string; + status?: string; + view?: string; + q?: string; + repository?: string; + pullRequest?: string; + source?: string; + model?: string; + }>; }) { - const [authorizedUser, { before, user, period } = {}] = await Promise.all([ + const [authorizedUser, params = {}] = await Promise.all([ authorize(), searchParams, ]); if (!authorizedUser.success) { notFound(); } + const { before, user, period, q } = params; + const scope = ['all', 'tasks', 'reviews', 'automations'].includes( + params.scope ?? '', + ) + ? (params.scope as SessionScope) + : 'all'; + const status = (SESSION_STATUSES as readonly string[]).includes( + params.status ?? '', + ) + ? (params.status as SessionStatus) + : undefined; + const view = params.view === 'board' ? 'board' : 'list'; const timePeriod = parseTimePeriodParam(period ?? null, 'all'); - const { sessions, nextCursor } = await getFastSessions(authorizedUser, { + const result = await getSessions(authorizedUser, { before, - filterUserId: user ?? null, - timePeriod, + user, + period: timePeriod, + scope, + status, + q, + repository: params.repository, + pullRequest: params.pullRequest, + source: params.source, + model: params.model, }); - const olderParams = new URLSearchParams(); - if (nextCursor) olderParams.set('before', nextCursor); - if (user) olderParams.set('user', user); - if (timePeriod !== 'all') olderParams.set('period', String(timePeriod)); + Object.entries(params).forEach(([key, value]) => { + if (value && key !== 'before') olderParams.set(key, value); + }); + if (result.nextCursor) olderParams.set('before', result.nextCursor); + const columns = SESSION_STATUSES; return (
-
- -
+
- -
-
- {sessions.length === 0 ? ( - - - No sessions yet. - - - ) : ( -
- {sessions.map((session) => ( - - ))} - {nextCursor ? ( -
- - Show older sessions - +
+ {result.sessions.length === 0 ? ( + + + No sessions found. + + + ) : view === 'board' ? ( +
+ {columns.map((column) => ( +
+

+ {getSessionStatusLabel(column)} +

+
+ {result.sessions + .filter((session) => + column === 'ready' + ? !session.cachedStatus || + session.cachedStatus === column + : session.cachedStatus === column, + ) + .map((session) => ( + + ))}
- ) : null} -
- )} -
-
+ + ))} +
+ ) : ( +
+ {result.sessions.map((session) => ( + + ))} +
+ )} + {result.nextCursor ? ( +
+ + Show older sessions + +
+ ) : null} +
); } diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index b7e41924c..645172511 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -5,8 +5,6 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; -import { ALL_REPOSITORIES } from '@roomote/types'; - import { type Filter, type TimePeriodFilter, @@ -14,7 +12,11 @@ import { parseTimePeriodParam, } from '@/types'; -import { DEFAULT_VISIBLE_TASK_WORKFLOWS, getTaskCategoryById } from '@/lib'; +import { + DEFAULT_VISIBLE_TASK_WORKFLOWS, + formatRepositoryName, + getTaskCategoryById, +} from '@/lib'; import { cn } from '@/lib/utils'; import { useAuthorizedUser } from '@/hooks/useUser'; @@ -320,7 +322,7 @@ export const Tasks = () => { const pullRequestLabel = pullRequest === HAS_PULL_REQUEST_FILTER_VALUE ? 'Has PR' - : pullRequest.replace(ALL_REPOSITORIES, 'All Repositories'); + : formatRepositoryName(pullRequest); result.push({ type: 'pullRequest', diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 32b5c5f48..cbc48735e 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -6,6 +6,8 @@ import { toast } from 'sonner'; import { Tasks } from './Tasks'; +// Sessions is the primary workspace; this page is intentionally unlinked from +// the primary nav but stays fully functional for direct URLs and deep links. export default function Page() { const searchParams = useSearchParams(); const error = searchParams.get('error'); diff --git a/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx new file mode 100644 index 000000000..967f94001 --- /dev/null +++ b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +import { SandboxSidePanelHeader } from './SandboxSidePanelHeader'; + +export function SandboxInfoPanel({ + title, + onClose, + closeLabel, + header, + children, +}: { + title: string; + onClose: () => void; + closeLabel?: string; + header?: ReactNode; + children: ReactNode; +}) { + return ( + <> + {header ?? ( + + )} +
+
{children}
+
+ + ); +} + +export function SandboxInfoRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +export function SandboxInfoTable({ children }: { children: ReactNode }) { + return ( + + {children} +
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 5d8930f6d..f2e1e1624 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -9,9 +9,16 @@ import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { FastSessionTranscript } from './FastSessionTranscript'; -const { replyMutate, preparePromptAttachments } = vi.hoisted(() => ({ - replyMutate: vi.fn(), - preparePromptAttachments: vi.fn(), +const { replyMutate, preparePromptAttachments, openTaskPanel, narrationState } = + vi.hoisted(() => ({ + replyMutate: vi.fn(), + preparePromptAttachments: vi.fn(), + openTaskPanel: vi.fn(), + narrationState: { enabled: false }, + })); + +vi.mock('@/hooks/useNarrationMode', () => ({ + useNarrationMode: () => ({ enabled: narrationState.enabled }), })); vi.mock('@/trpc/client', () => ({ @@ -38,6 +45,24 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./session-task-panel-context', () => ({ + useOpenSessionTaskPanel: () => openTaskPanel, +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + onOpen, + }: { + taskId: string; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + class FakeEventSource { static instances: FakeEventSource[] = []; listeners = new Map void>>(); @@ -71,6 +96,8 @@ beforeEach(() => { preparePromptAttachments.mockImplementation(({ text }: { text: string }) => Promise.resolve({ text }), ); + narrationState.enabled = false; + openTaskPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -207,7 +234,9 @@ describe('FastSessionTranscript', () => { />, ); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Starting')).toBeInTheDocument(); + expect(screen.getByText('Coding Task')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); expect(FakeEventSource.instances).toHaveLength(1); expect(FakeEventSource.instances[0]!.url).toBe( '/api/sessions/session-1/stream', @@ -219,7 +248,8 @@ describe('FastSessionTranscript', () => { }); }); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Started')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { @@ -279,6 +309,69 @@ describe('FastSessionTranscript', () => { ); }); + it('keeps a launched child task visible in narration mode and opens its panel', () => { + narrationState.enabled = true; + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Delegated task/ })); + + expect(openTaskPanel).toHaveBeenCalledWith('child-1'); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { />, ); - const activityToggle = screen.getByRole('button', { - name: /Worked for/, - }); - expect(screen.queryByText('launch_task')).not.toBeInTheDocument(); - - fireEvent.click(activityToggle); - - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect( + screen.getByRole('button', { name: /Started Coding Task Completed/ }), + ).toBeInTheDocument(); expect(screen.getByText('I started the checkout fix.')).toBeInTheDocument(); }); @@ -467,11 +555,11 @@ describe('FastSessionTranscript', () => { , ); - expect(screen.getByText('Session')).toBeInTheDocument(); + expect(screen.getByText('New session')).toBeInTheDocument(); act(() => { FakeEventSource.instances[0]!.emit('session', { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..401d66bf8 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { ACP_ENVELOPE_EVENT_TYPES, getImageUrisFromContentBlocks, @@ -24,6 +31,8 @@ import { type SessionPromptSubmission, } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; +import { useNarrationMode } from '@/hooks/useNarrationMode'; import { AcpTranscriptBlockList, @@ -56,11 +65,13 @@ export function FastSessionTranscript({ hasOlderMessages, canReply, initialTitle = null, - fallbackTitle = 'Session', + fallbackTitle = 'New session', sessionModel = null, sessionReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + headerExtras, + timelineExtras, }: { sessionId: string; initialMessages: FastSessionMessage[]; @@ -72,8 +83,13 @@ export function FastSessionTranscript({ sessionReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + headerExtras?: ReactNode; + timelineExtras?: ReactNode; }) { const trpcClient = useTRPCClient(); + const openTaskPanel = useOpenSessionTaskPanel(); + const { enabled: narrationModeEnabled } = useNarrationMode(); + const displayMode = narrationModeEnabled ? 'narration' : 'default'; const [serverMessages, setServerMessages] = useState< Map >( @@ -169,11 +185,12 @@ export function FastSessionTranscript({ const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({ messages: uiMessages, artifacts: [], - displayMode: 'default', + displayMode, initialPrompt: null, shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -254,11 +271,15 @@ export function FastSessionTranscript({ ); return ( - - + +

{title ?? fallbackTitle}

+ {headerExtras}
@@ -267,10 +288,12 @@ export function FastSessionTranscript({ Older messages in this session are not shown.

) : null} + {timelineExtras}
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx new file mode 100644 index 000000000..6e663264b --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { RunStatus } from '@roomote/types'; + +const useTaskSessionMock = vi.fn(); + +vi.mock('../../task/[taskId]/hooks/use-task-session', () => ({ + useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/use-task-message-envelopes', () => ({ + useTaskMessageEnvelopes: () => ({ + data: [], + isPending: false, + isSuccess: true, + isError: false, + }), +})); + +vi.mock('../../task/[taskId]/hooks/ArtifactLinkProvider', () => ({ + ArtifactLinkProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('../../task/[taskId]/hooks/HistoricalSandboxProvider', () => ({ + HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/hooks/SandboxProvider', () => ({ + SandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/Messages', () => ({ + Messages: () =>
Child transcript
, +})); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ + title, + actions, + }: { + title: string; + actions: ReactNode; + }) => ( +
+ {title} + {actions} +
+ ), +})); + +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; + +describe('NestedTaskSidePanel', () => { + beforeEach(() => { + useTaskSessionMock.mockReturnValue({ + taskId: 'child-1', + task: { title: 'Fix checkout' }, + taskRun: { + id: 42, + harness: 'opencode-server', + status: RunStatus.Running, + taskPhase: 'running', + sandboxServerUrl: 'http://sandbox.test', + }, + artifacts: [], + prompt: null, + token: 'token', + refreshConnection: vi.fn(), + sessionState: 'interactive', + isSessionLoading: false, + }); + }); + + it('renders the focused live transcript and full-task navigation without task chrome', () => { + render(); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Go to task/ })).toHaveAttribute( + 'href', + '/task/child-1', + ); + expect(screen.queryByText('Task actions')).not.toBeInTheDocument(); + expect(useTaskSessionMock).toHaveBeenCalledWith('child-1', { + refetchInterval: 2_000, + }); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx new file mode 100644 index 000000000..5298aac4c --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,128 @@ +'use client'; + +import Link from 'next/link'; + +import { DEFAULT_CODING_HARNESS, type TaskPhase } from '@roomote/types'; + +import { + Button, + ErrorState, + ExternalLink, + Skeleton, +} from '@/components/system'; +import { FramedSurface } from '@/components/layout'; + +import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; +import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; +import { SandboxProvider } from '../../task/[taskId]/hooks/SandboxProvider'; +import { useTaskMessageEnvelopes } from '../../task/[taskId]/hooks/use-task-message-envelopes'; +import { + useTaskSession, + type TaskSession, +} from '../../task/[taskId]/hooks/use-task-session'; +import { Messages } from '../../task/[taskId]/Messages'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; + +function NestedTaskTranscript({ session }: { session: TaskSession }) { + const history = useTaskMessageEnvelopes(session.taskId); + + if (session.isSessionLoading) { + return ( +
+ + + +
+ ); + } + + if ( + session.sessionState === 'error' || + session.sessionState === 'not-found' + ) { + return ; + } + + if (!session.taskRun) { + return ; + } + + const transcript = ( + + + + ); + + if ( + session.sessionState === 'historical' || + session.sessionState === 'resuming' || + session.sessionState === 'boot-failed' + ) { + return ( + + {transcript} + + ); + } + + return ( + + {transcript} + + ); +} + +export function NestedTaskSidePanel({ + taskId, + onClose, +}: { + taskId: string; + onClose: () => void; +}) { + const session = useTaskSession(taskId, { refetchInterval: 2_000 }); + const title = session.task?.title?.trim() || 'Task'; + + return ( + + + + Go to task + + + + } + /> +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx new file mode 100644 index 000000000..58717a7c4 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; +import { useRecentSessions } from '@/hooks/useRecentSessions'; +import { useTelemetry } from '@/hooks/useTelemetry'; + +export function SessionReadTracker({ sessionId }: { sessionId: string }) { + const { recordVisit } = useRecentSessions(); + const { capture } = useTelemetry(); + + useMarkSessionRead(sessionId); + + useEffect(() => { + recordVisit(sessionId); + capture('session_opened', { surface: 'web', outcome: 'opened' }); + }, [capture, recordVisit, sessionId]); + + return null; +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx new file mode 100644 index 000000000..edd3fdede --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { formatInferenceCost, formatRepositoryName } from '@/lib'; +import { + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +export type SessionTaskSummary = { + taskId: string; + title: string; + workflow: string; + state: string; + repositoryName: string | null; + latestOutput: string | null; + inferenceCostMicroUsd: number; + canAccessDetails?: boolean; + latestRun: { + id: number; + status: string; + taskPhase: string | null; + error: string | null; + result: unknown; + } | null; + artifacts: Array<{ + id: string; + path: string; + artifactType: string; + }>; + pullRequests: Array<{ + id: string; + url: string; + number: number | null; + title: string | null; + repository: string | null; + status: string | null; + }>; +}; + +export function SessionTaskCards({ + sessionId, + tasks, +}: { + sessionId: string; + tasks: SessionTaskSummary[]; +}) { + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const cancel = useMutation(trpc.taskRuns.cancel.mutationOptions()); + const retry = useMutation(trpc.taskRuns.retryFailedStart.mutationOptions()); + + if (tasks.length === 0) return null; + + const selectTask = (taskId: string) => { + const params = new URLSearchParams(searchParams); + params.set('task', taskId); + router.replace(`/sessions/${sessionId}?${params.toString()}`); + }; + + return ( +
+

+ Executions +

+
+ {tasks.map((task) => ( + + +
+ + {task.title} + + + {task.state} + +
+
+ +

+ {task.repositoryName + ? formatRepositoryName(task.repositoryName) + : task.workflow} +

+ {task.latestRun?.error ? ( +

+ {task.latestRun.error} +

+ ) : null} + {task.latestOutput ? ( +

{task.latestOutput}

+ ) : null} + {task.inferenceCostMicroUsd > 0 ? ( +

+ ${formatInferenceCost(task.inferenceCostMicroUsd)} inference +

+ ) : null} + {task.canAccessDetails === false ? ( +

Execution details require task access.

+ ) : null} +
+ + {task.canAccessDetails === false ? null : task.state === + 'active' ? ( + + ) : task.state === 'failed' ? ( + + ) : null} + {task.canAccessDetails === false ? null : ( + <> + + + + )} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 99029b02a..df1b51ca5 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -1,23 +1,95 @@ import { useState, type ReactNode } from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; -const { useMediaQueryMock } = vi.hoisted(() => ({ - useMediaQueryMock: vi.fn(), -})); +const { useMediaQueryMock, sessionQueryState, fastTaskQueryState } = vi.hoisted( + () => ({ + useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, + fastTaskQueryState: { data: null as unknown }, + }), +); vi.mock('usehooks-ts', () => ({ useMediaQuery: useMediaQueryMock, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { models: [{ id: 'model-1', displayName: 'Model One' }] }, }), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sessions: { + byId: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['sessions', 'byId', input.sessionId], + queryFn: async () => sessionQueryState.data, + ...options, + }), + }, + }, + fastSessions: { + tasks: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['fastSessions', 'tasks', input.sessionId], + queryFn: async () => fastTaskQueryState.data, + ...options, + }), + }, + }, + }), +})); + +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ taskId }: { taskId: string }) => ( +
Nested panel {taskId}
+ ), +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + prompt, + onOpen, + }: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + const session: SessionInfo = { id: 'session-1', ownerName: 'Test User', @@ -25,8 +97,11 @@ const session: SessionInfo = { ownerImageUrl: null, surface: 'slack', model: 'model-1', + reasoningEffort: null, inferenceCostMicroUsd: 1_000_000, createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'needs_input', + tasks: [], }; function SandboxLayoutProvider({ children }: { children: ReactNode }) { @@ -45,7 +120,21 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { ); } -function renderWorkspace({ isMobile }: { isMobile: boolean }) { +function renderWorkspace({ + isMobile, + children =
Session transcript
, + sessionOverride, + queriedTasks, + queriedFastTasks, +}: { + isMobile: boolean; + children?: ReactNode; + sessionOverride?: Partial; + queriedTasks?: SessionInfo['tasks']; + queriedFastTasks?: Array< + Pick + >; +}) { useMediaQueryMock.mockReturnValue(!isMobile); let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = null; @@ -65,12 +154,22 @@ function renderWorkspace({ isMobile }: { isMobile: boolean }) { value: vi.fn().mockReturnValue(mediaQuery), }); + const initialSession = { ...session, ...sessionOverride }; + sessionQueryState.data = { + ...initialSession, + tasks: queriedTasks ?? initialSession.tasks, + }; + fastTaskQueryState.data = queriedFastTasks ?? initialSession.taskCards ?? []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const result = render( - - -
Session transcript
-
-
, + + + {children} + + , ); return { @@ -84,6 +183,16 @@ function renderWorkspace({ isMobile }: { isMobile: boolean }) { }; } +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + + return ( + + ); +} + describe('SessionWorkspace', () => { it('matches the task sidebar replacement behavior and controls on mobile', () => { renderWorkspace({ isMobile: true }); @@ -99,25 +208,9 @@ describe('SessionWorkspace', () => { expect(screen.queryByText('Session transcript')).not.toBeInTheDocument(); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); - const table = screen.getByRole('table'); - const panel = table.parentElement!.parentElement!; - - expect(panel).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); - expect(panel.parentElement).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); + expect(screen.getByText('needs input')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Close session info' }), ).toBeNull(); @@ -137,7 +230,7 @@ describe('SessionWorkspace', () => { fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); }); @@ -153,6 +246,86 @@ describe('SessionWorkspace', () => { ).toBeInTheDocument(); }); + it('disables the Tasks panel button until the session has a task', () => { + renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Tasks' })).toBeDisabled(); + }); + + it('lists session tasks with delegated task cards', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { + tasks: [ + { + taskId: 'task-1', + title: 'Update homepage background', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }, + ], + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'View coding task: Update homepage background', + }), + ); + + expect(screen.getByText('Nested panel task-1')).toBeInTheDocument(); + }); + + it('enables and populates the Tasks panel from refreshed session tasks', async () => { + const delegatedTask = { + taskId: 'task-2', + title: 'Refreshed coding task', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }; + renderWorkspace({ + isMobile: false, + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [delegatedTask], + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect( + screen.getByRole('button', { + name: 'View coding task: Refreshed coding task', + }), + ).toBeInTheDocument(); + }); + + it('opens delegated tasks in the existing session side-panel slot', () => { + renderWorkspace({ isMobile: false, children: }); + + fireEvent.click(screen.getByRole('button', { name: 'Open child' })); + + expect(screen.getByText('Nested panel child-1')).toBeInTheDocument(); + }); + it('collapses the right rail when the viewport changes from desktop to mobile', () => { const { resizeToMobile } = renderWorkspace({ isMobile: false }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index ab63de71b..3b96ed536 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,22 +1,59 @@ 'use client'; -import { useState, type ReactNode } from 'react'; -import { formatDistanceToNow } from 'date-fns'; +import Link from 'next/link'; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { getReasoningEffortLabel, type ReasoningEffort } from '@roomote/types'; -import { formatInferenceCost, getUserDisplayName } from '@/lib'; +import { + formatInferenceCost, + formatRepositoryName, + getUserDisplayName, +} from '@/lib'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; +import { + getSessionSurfaceBrandIcon, + getSessionSurfaceLabel, +} from '@/components/sessions/session-surfaces'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; -import { WorkspaceSurface } from '@/components/layout'; +import { useTRPC } from '@/trpc/client'; +import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { ArrowLeftFromLine, Avatar, BasicTooltip, + BrandIcon, + Brain, Button, + Calendar, DollarSign, + Globe, Info, + Slack, + X, + Rows4, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@/components/system'; +import type { SessionTaskSummary } from './SessionTaskCards'; import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../SandboxInfoPanel'; import { ResponsiveWorkspacePanels, SandboxSideActions, @@ -25,6 +62,9 @@ import { useResponsiveSandboxSidebar, useSandboxLayout, } from '../../use-sandbox-layout'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { OpenSessionTaskPanelContext } from './session-task-panel-context'; +import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; export type SessionInfo = { id: string; @@ -34,25 +74,151 @@ export type SessionInfo = { surface: string; /** Effective model for the session's turns (stored override or default). */ model: string | null; + reasoningEffort: ReasoningEffort | null; inferenceCostMicroUsd: number; createdAt: Date; + status: string | null; + tasks: SessionTaskSummary[]; + taskSource?: 'unified' | 'fast'; + taskCards?: Array>; }; -const SURFACE_LABELS: Record = { - slack: 'Slack', - discord: 'Discord', - teams: 'Microsoft Teams', - telegram: 'Telegram', - automation: 'Automation', - web: 'Web', -}; +function SessionTaskPanel({ + sessionId, + task, + tasks, + onSelect, + onClose, +}: { + sessionId: string; + task: SessionTaskSummary; + tasks: SessionTaskSummary[]; + onSelect: (taskId: string) => void; + onClose: () => void; +}) { + return ( + <> +
+

Execution details

+ + + +
+
+ {tasks.length > 1 ? ( + + ) : null} +
+

{task.title}

+

{task.state}

+ {task.repositoryName ? ( +

+ {formatRepositoryName(task.repositoryName)} +

+ ) : null} +
+ {task.canAccessDetails === false ? ( +

+ Execution details require task access. +

+ ) : null} + {task.latestRun?.error ? ( +
+ {task.latestRun.error} +
+ ) : null} + {task.pullRequests.length ? ( +
+

Pull requests

+ {task.pullRequests.map((pullRequest) => ( + + {pullRequest.repository}#{pullRequest.number} + + ))} +
+ ) : null} + {task.artifacts.length ? ( +
+

Artifacts

+ {task.artifacts.map((artifact) => ( + + {artifact.path} + + ))} +
+ ) : null} + {task.canAccessDetails === false ? null : ( + + )} +
+ + ); +} -function InfoRow({ label, children }: { label: string; children: ReactNode }) { +function SessionTasksPanel({ + tasks, + onOpenTask, + onClose, +}: { + tasks: Array>; + onOpenTask: (taskId: string) => void; + onClose: () => void; +}) { return ( - - {label} - {children} - + + +
+ {tasks.map((task) => ( + + ))} +
+
); } @@ -73,54 +239,96 @@ function SessionInfoPanel({ ? (modelData?.models.find(({ id }) => id === session.model)?.displayName ?? session.model) : null; + const modelAndReasoningLabel = [ + modelLabel ?? 'Default model', + session.reasoningEffort + ? getReasoningEffortLabel(session.reasoningEffort) + : null, + ] + .filter(Boolean) + .join(' • '); const inferenceCostLabel = formatInferenceCost(session.inferenceCostMicroUsd); + const surfaceLabel = getSessionSurfaceLabel(session.surface); + const surfaceBrandIcon = getSessionSurfaceBrandIcon(session.surface); return ( -
- + -
- - - - - - {ownerDisplayName} - - - {modelLabel ?? 'Default model'} - - - - {inferenceCostLabel} + > + + + + + {ownerDisplayName} + + + + + + {modelAndReasoningLabel} + + + + + + {inferenceCostLabel} + + + + + + + {session.createdAt.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + })} - - - - - {formatDistanceToNow(session.createdAt, { addSuffix: true })} - - - - - {SURFACE_LABELS[session.surface] ?? session.surface} - - -
-
-
+ + + + + {session.surface === 'slack' ? ( + + ) : surfaceBrandIcon ? ( + + ) : ( + + )} + {surfaceLabel} + + + {session.status ? ( + + + + ) : null} + + + ); } +type WorkspacePanel = + | { kind: 'info' } + | { kind: 'tasks' } + | { kind: 'nested'; taskId: string }; + export function SessionWorkspace({ session, children, @@ -128,55 +336,153 @@ export function SessionWorkspace({ session: SessionInfo; children: ReactNode; }) { - const [isInfoOpen, setIsInfoOpen] = useState(false); + // Exactly one side panel can be active: the discriminated union makes an + // impossible combination unrepresentable. The URL's ?task= selection is the + // fourth panel and always wins over `panel` when both are set. + const [panel, setPanel] = useState(null); + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isFastTaskSource = session.taskSource === 'fast'; + const { data: currentSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: session.id }, + { + enabled: !isFastTaskSource, + // Settled sessions poll slowly; only visibly-running work needs the + // fast cadence. TanStack pauses both while the tab is unfocused. + refetchInterval: (query) => + query.state.data?.status === 'active' || + query.state.data?.status === 'needs_input' + ? 2_000 + : 30_000, + }, + ), + ); + const { data: currentFastTasks } = useQuery( + trpc.fastSessions.tasks.queryOptions( + { sessionId: session.id }, + { + enabled: isFastTaskSource, + refetchInterval: 2_000, + }, + ), + ); + const sessionTasks = currentSession?.tasks ?? session.tasks; + const taskCards = isFastTaskSource + ? (currentFastTasks ?? session.taskCards ?? session.tasks) + : sessionTasks; + const selectedTaskId = searchParams.get('task'); + const selectedTask = sessionTasks.find( + (task) => task.taskId === selectedTaskId, + ); + const panelOpen = panel !== null || Boolean(selectedTask); + + const selectTask = useCallback( + (taskId: string | null) => { + const params = new URLSearchParams(searchParams); + if (taskId) params.set('task', taskId); + else params.delete('task'); + const query = params.toString(); + router.replace(`/sessions/${session.id}${query ? `?${query}` : ''}`); + }, + [router, searchParams, session.id], + ); + + // Default a single-task session to its task panel once, on mount only — an + // explicit close or panel choice must never be fought by a re-select. + const didAutoSelect = useRef(false); + useEffect(() => { + if (didAutoSelect.current) return; + didAutoSelect.current = true; + if (!selectedTaskId && session.tasks.length === 1) { + selectTask(session.tasks[0]!.taskId); + } + }, [selectTask, selectedTaskId, session.tasks]); + + const openTaskPanel = useCallback( + (taskId: string) => { + setPanel({ kind: 'nested', taskId }); + selectTask(null); + }, + [selectTask], + ); + const closePanel = () => { + setPanel(null); + selectTask(null); + }; + const togglePanel = (kind: 'info' | 'tasks') => { + setPanel((previous) => (previous?.kind === kind ? null : { kind })); + selectTask(null); + }; + const panelContent = selectedTask ? ( + + ) : panel?.kind === 'nested' ? ( + + ) : panel?.kind === 'tasks' ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); useResponsiveSandboxSidebar(session.id); return ( - - setIsInfoOpen(false)} - > - setIsInfoOpen((previous) => !previous)} - /> - - {!isSidebarVisible && !isInfoOpen ? ( - - - - ) : null} - - } - > - setIsInfoOpen(false)} - /> + + + + togglePanel('info')} + /> + togglePanel('tasks')} + /> + + {!isSidebarVisible && !panelOpen ? ( + + + + ) : null} + } - /> - + > + + + ); } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 2d429ed07..0aae6fa7f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -1,21 +1,42 @@ import type { ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -const { authorizeMock, getFastSessionByIdMock, transcriptMock } = vi.hoisted( - () => ({ - authorizeMock: vi.fn(), - getFastSessionByIdMock: vi.fn(), - transcriptMock: vi.fn( - ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( -
{footer}
- ), +const { + authorizeMock, + getFastSessionByIdMock, + getFastSessionTasksMock, + getSessionByIdCommandMock, + transcriptMock, + sessionWorkspaceMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + getFastSessionByIdMock: vi.fn(), + getFastSessionTasksMock: vi.fn(), + getSessionByIdCommandMock: vi.fn(), + transcriptMock: vi.fn( + ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( +
{footer}
), - }), -); + ), + sessionWorkspaceMock: vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+ )), +})); vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + notFound: () => { + throw new Error('NEXT_NOT_FOUND'); + }, +})); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, + getFastSessionTasks: getFastSessionTasksMock, +})); +vi.mock('@/trpc/commands/sessions', () => ({ + getSessionByIdCommand: getSessionByIdCommandMock, })); vi.mock('../../use-sandbox-layout', () => ({ useResponsiveSandboxSidebar: vi.fn(), @@ -36,10 +57,25 @@ vi.mock('@/components/layout', () => ({ vi.mock('./FastSessionTranscript', () => ({ FastSessionTranscript: transcriptMock, })); +vi.mock('./SessionWorkspace', () => ({ + SessionWorkspace: sessionWorkspaceMock, +})); +vi.mock('./SessionReadTracker', () => ({ + SessionReadTracker: () => null, +})); +vi.mock('./SessionTaskCards', () => ({ + SessionTaskCards: () =>
, +})); import SessionDetailPage from './page'; -describe('Fast session detail page', () => { +describe('Session detail page', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionByIdCommandMock.mockResolvedValue(null); + getFastSessionTasksMock.mockResolvedValue([]); + }); + it('uses the shared task workspace and renders supported session data', async () => { authorizeMock.mockResolvedValue({ success: true, @@ -47,7 +83,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-1', + id: '6a1f8f1e-0000-4000-8000-000000000001', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -99,7 +135,9 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-1' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', + }), }), ); @@ -109,7 +147,7 @@ describe('Fast session detail page', () => { expect(html).not.toContain('OpenCode workspace details unavailable'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-1', + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', canReply: true, fallbackTitle: 'Question', initialMessages: expect.arrayContaining([ @@ -127,7 +165,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-2', + id: '6a1f8f1e-0000-4000-8000-000000000003', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -147,17 +185,187 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-2' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', + }), }), ); expect(html).not.toContain('b3b0a53e-6dab-4bb8-b3a5-111111111111'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-2', + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', canReply: true, initialTitle: 'Rotate the API keys', - fallbackTitle: 'Session', + fallbackTitle: 'New session', + }), + undefined, + ); + }); + + it('resolves the unified session first and renders its Fast transcript', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + title: 'Session title', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'slack', + fastConversationId: '6a1f8f1e-0000-4000-8000-000000000005', + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'active', + tasks: [ + { + taskId: 'task-1', + title: 'Delegated task', + }, + ], + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000002', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000002', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).not.toHaveBeenCalled(); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + status: 'active', + tasks: [expect.objectContaining({ taskId: 'task-1' })], + }), + }), + undefined, + ); + expect(transcriptMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + canReply: true, + initialTitle: 'Session title', + fallbackTitle: 'Session title', + }), + undefined, + ); + }); + + it('renders a task-only workspace for unified sessions without a Fast conversation', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000004', + title: 'Task-only session', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'web', + fastConversationId: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'completed', + tasks: [ + { + taskId: 'task-2', + title: 'Delegated task', + }, + ], + }); + + const html = renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000004', + }), + }), + ); + + expect(getFastSessionByIdMock).not.toHaveBeenCalled(); + expect(transcriptMock).not.toHaveBeenCalled(); + expect(html).toContain('Task-only session'); + }); + + it('falls back to the Fast conversation lookup when no session row exists', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + userId: 'user-1', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + getFastSessionTasksMock.mockResolvedValue([ + { taskId: 'task-1', title: 'Delegated task' }, + ]); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + taskSource: 'fast', + taskCards: [expect.objectContaining({ taskId: 'task-1' })], + }), }), undefined, ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..3f4cfb550 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from 'next/navigation'; +import { z } from 'zod'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; import { @@ -8,10 +9,18 @@ import { } from '@roomote/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessionById } from '@/lib/server/fast-sessions'; +import { + getFastSessionById, + getFastSessionTasks, +} from '@/lib/server/fast-sessions'; +import { getSessionByIdCommand } from '@/trpc/commands/sessions'; +import { WorkspaceHeader } from '@/components/layout'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; import { FastSessionTranscript } from './FastSessionTranscript'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { SessionTaskCards } from './SessionTaskCards'; +import { SessionReadTracker } from './SessionReadTracker'; export default async function SessionDetailPage({ params, @@ -25,12 +34,24 @@ export default async function SessionDetailPage({ if (!authorizedUser.success) { notFound(); } - - const session = await getFastSessionById(authorizedUser, sessionId); - if (!session) { + // Both lookup columns are uuid; a garbage route param would otherwise throw + // 22P02 in Postgres instead of 404ing. + if (!z.string().uuid().safeParse(sessionId).success) { notFound(); } + // Old links may carry a fast-conversation id whose session row hasn't been + // backfilled yet; getSessionByIdCommand falls back by fastConversationId, + // and the fast lookup below covers a conversation with no session row. + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); + const session = unifiedSession?.fastConversationId + ? await getFastSessionById( + authorizedUser, + unifiedSession.fastConversationId, + ) + : unifiedSession + ? null + : await getFastSessionById(authorizedUser, sessionId); // The chip's "default" must reflect what Fast actually runs with: the // deployment's orchestration model, not the task launch default. const modelEnv: Record = @@ -44,6 +65,72 @@ export default async function SessionDetailPage({ ? (rawDefaultEffort as ReasoningEffort) : null; + if (unifiedSession) { + const sessionInfo: SessionInfo = { + id: unifiedSession.id, + ownerName: unifiedSession.ownerName, + ownerEmail: unifiedSession.ownerEmail, + ownerImageUrl: unifiedSession.ownerImageUrl, + surface: unifiedSession.sourceSurface, + model: session?.model ?? defaultModelId, + reasoningEffort: session?.reasoningEffort ?? defaultReasoningEffort, + inferenceCostMicroUsd: unifiedSession.inferenceCostMicroUsd, + createdAt: unifiedSession.createdAt, + status: unifiedSession.status, + tasks: unifiedSession.tasks, + }; + const taskCards = ( + + ); + + return ( + + +
+ {session ? ( + + } + timelineExtras={taskCards} + /> + ) : ( + <> + +

+ {unifiedSession.title} +

+ +
+
+
{taskCards}
+
+ + )} +
+
+ ); + } + if (!session) { + notFound(); + } + const sessionInfo: SessionInfo = { id: session.id, ownerName: session.ownerName, @@ -51,15 +138,20 @@ export default async function SessionDetailPage({ ownerImageUrl: session.ownerImageUrl, surface: session.surface, model: session.model ?? defaultModelId, + reasoningEffort: session.reasoningEffort ?? defaultReasoningEffort, inferenceCostMicroUsd: session.inferenceCostMicroUsd, createdAt: session.createdAt, + status: null, + tasks: [], + taskSource: 'fast', + taskCards: (await getFastSessionTasks(authorizedUser, session.id)) ?? [], }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', ); const fallbackTitle = getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || - 'Session'; + 'New session'; return ( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts new file mode 100644 index 000000000..8fd19b8cf --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export const OpenSessionTaskPanelContext = createContext< + ((taskId: string) => void) | null +>(null); + +export function useOpenSessionTaskPanel() { + return useContext(OpenSessionTaskPanelContext); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx index 8dd0401c4..76c2ee383 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx @@ -1,12 +1,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const { useSandboxLayoutMock, useTRPCMock, updateTitleMutationMock } = - vi.hoisted(() => ({ - useSandboxLayoutMock: vi.fn(), - useTRPCMock: vi.fn(), - updateTitleMutationMock: vi.fn(async () => undefined), - })); +const { + useSandboxLayoutMock, + useTRPCMock, + updateTitleMutationMock, + parentSessionQueryMock, +} = vi.hoisted(() => ({ + useSandboxLayoutMock: vi.fn(), + useTRPCMock: vi.fn(), + updateTitleMutationMock: vi.fn(async () => undefined), + parentSessionQueryMock: vi.fn(), +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: useSandboxLayoutMock, @@ -16,6 +21,10 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('./TaskSessionReadTracker', () => ({ + TaskSessionReadTracker: () => null, +})); + vi.mock('@/components/sandbox', () => ({ WorkspaceBadge: ({ environmentId, @@ -78,6 +87,10 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + parentSessionQueryMock.mockResolvedValue({ + sessionId: 'session-1', + title: 'Parent Session', + }); useSandboxLayoutMock.mockReturnValue({ isSidebarVisible: true, @@ -93,6 +106,18 @@ describe('Header', () => { ], }, }, + sessions: { + forTask: { + queryOptions: ( + _input: { taskId: string }, + options?: { enabled?: boolean }, + ) => ({ + queryKey: ['sessions.forTask'], + queryFn: parentSessionQueryMock, + enabled: options?.enabled, + }), + }, + }, tasks: { updateTitle: { mutationOptions: () => ({ @@ -142,6 +167,37 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); + it('always queries the parent session and renders its links', async () => { + renderHeader(); + + expect( + await screen.findByRole('link', { name: 'Parent Session' }), + ).toHaveAttribute('href', '/sessions/session-1?task=task-123'); + expect(screen.getByRole('link', { name: /Go to session/ })).toHaveAttribute( + 'href', + '/sessions/session-1?task=task-123', + ); + expect(parentSessionQueryMock).toHaveBeenCalled(); + }); + + it('links to the Fast session when the task has no unified session', async () => { + parentSessionQueryMock.mockResolvedValue(null); + + renderHeader({ + taskRun: { + payload: { + environmentId: 'env-1', + fastAgentSessionId: '00000000-0000-4000-8000-000000000001', + }, + harness: 'opencode-server', + } as never, + }); + + expect( + await screen.findByRole('link', { name: /Go to session/ }), + ).toHaveAttribute('href', '/sessions/00000000-0000-4000-8000-000000000001'); + }); + it('refreshes task lists after renaming a task', async () => { const { queryClient } = renderHeader(); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 282df1030..d9d40eedb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -1,17 +1,26 @@ 'use client'; import { useEffect, useState, type KeyboardEvent } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeftFromLine, Button, + ExternalLink, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from '@/components/system'; import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; @@ -20,6 +29,7 @@ import { useTRPC } from '@/trpc/client'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; +import { TaskSessionReadTracker } from './TaskSessionReadTracker'; interface HeaderProps { session: TaskSession; @@ -28,15 +38,24 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); + const { data: parentSession } = useQuery( + trpc.sessions.forTask.queryOptions({ taskId }), + ); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; + const sessionHref = parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null; const badges = [ (environmentId || repo) && ( @@ -150,21 +169,65 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { }; const title = task?.title || 'Untitled task'; + const returnTo = searchParams?.get('returnTo'); + const safeReturnTo = + returnTo?.startsWith('/sessions') && !returnTo.startsWith('//') + ? returnTo + : '/sessions'; return ( <> - -

- {title} -

+ {parentSession ? ( + + ) : null} + + {parentSession ? ( + + + + + Sessions + + + + + + + {parentSession.title} + + + + + + + {title} + + + + + ) : ( +

+ {title} +

+ )} {badges.length > 0 && (
{badges.map((badge, index) => ( @@ -174,6 +237,14 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { ))}
)} + {sessionHref ? ( + + ) : null} {!isSidebarVisible && ( + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx index 2edeaacfa..f30360a05 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx @@ -142,16 +142,19 @@ function buildGroup(): GroupedToolCallRenderBlock { describe('AcpGroupedToolMessage anchors', () => { it('keeps per-item anchors mounted even when tool content is collapsed', () => { + // The compact grouped layout renders anchors as standalone hidden divs + // (no collapsed ToolContent container anymore); scroll targets must stay + // in the DOM while per-item detail stays unmounted. render(); - const collapsedContent = screen.getByTestId('collapsed-tool-content'); - expect(document.getElementById('msg-101')).toBeTruthy(); expect(document.getElementById('msg-102')).toBeTruthy(); - expect(collapsedContent.querySelector('#msg-101')).toBeNull(); - expect(collapsedContent.querySelector('#msg-102')).toBeNull(); + expect(document.getElementById('msg-101')).toHaveAttribute( + 'aria-hidden', + 'true', + ); - // Subheadings live inside ToolContent and should not be mounted in collapsed mode. + // Subheadings only mount with expanded tool detail. expect(screen.queryByText('file_b.txt')).toBeNull(); expect(screen.queryByText('file_c.txt')).toBeNull(); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx index 6985d2161..98869452d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx @@ -120,15 +120,12 @@ describe('AcpGroupedToolMessage', () => { codeBlockSpy.mockClear(); }); - it('renders grouped header and per-file sections', () => { + it('keeps grouped read rows compact when no item has expandable details', () => { render(); expect(screen.getByText('Exploring 2 files')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt')).toBeInTheDocument(); - expect(screen.getByText('file_b.txt')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt').className).toContain('truncate'); - expect(screen.getByText('file_b.txt').className).toContain('truncate'); - + expect(screen.queryByText('file_a.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('file_b.txt')).not.toBeInTheDocument(); expect(codeBlockSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx index 78779d639..a2e9d2bd7 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx @@ -281,7 +281,7 @@ describe('AcpToolDetails', () => { }); it.each(['search', 'query'])( - 'adds the sanitized Hippocampus %s query to the existing result YAML', + 'renders the sanitized Memory %s input before the result YAML', (toolName) => { const result = { matches: [{ title: 'Existing result', score: 0.98 }], @@ -308,20 +308,14 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'matches:', - ' - title: Existing result', - ' score: 0.98', - 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', + ['matches:', ' - title: Existing result', ' score: 0.98'].join( + '\n', + ), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }, ); @@ -349,19 +343,12 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'delivered: true', - 'taskId: task-1', - 'message: Review RooCodeInc/Roomote and use password=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'message: Review RooCodeInc/Roomote and use password=[redacted]', + ['delivered: true', 'taskId: task-1'].join('\n'), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }); @@ -389,6 +376,61 @@ describe('AcpToolDetails', () => { expect(toolInputSpy).not.toHaveBeenCalled(); }); + it('keeps colliding input and result fields separate', () => { + render( + ), + text: JSON.stringify({ query: 'result value', matches: 2 }), + }} + />, + ); + + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: requested value', + ['query: result value', 'matches: 2'].join('\n'), + ]); + }); + + it('keeps input visible when a truncated result is no longer valid JSON', () => { + render( + ), + text: '{"matches":[\n... output truncated ...\n]}', + }} + />, + ); + + expect(codeBlockSpy.mock.calls[0]?.[0].code).toBe('query: large result'); + expect(codeBlockSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + language: 'yaml', + code: expect.stringContaining('output truncated'), + }), + ); + }); + it('hides expanded details for Roomote Slack lifecycle tools', () => { const { container } = render( { expect(toolDetailsSpy).not.toHaveBeenCalled(); }); - it('renders the gbrain MCP server as Hippocampus', () => { + it('renders the gbrain MCP server as Memory', () => { render( { expect.objectContaining({ action: 'Used', object: 'Query', - suffix: 'Hippocampus', + suffix: 'Memory', + }), + ); + }); + + it('uses the known MCP integration’s brand icon', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: mcpIntegrationIconFor('sentry'), + suffix: 'Sentry', }), ); }); @@ -387,7 +410,7 @@ describe('AcpToolMessage', () => { expect(toolHeaderSpy).toHaveBeenCalledWith( expect.objectContaining({ - icon: Eye, + icon: FileIcon, collapsible: false, }), ); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts index 29b7fd169..fe9a68221 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts @@ -820,7 +820,7 @@ describe('buildAcpRenderBlocks', () => { kind: 'tool_group', action: 'Used', objectSummary: '2 get issue calls', - displayKind: 'tool', + displayKind: 'generic', }); }); @@ -2014,4 +2014,78 @@ describe('buildAcpRenderBlocks', () => { }, }); }); + + it('keeps multiple delegated tasks as standalone cards when requested', () => { + const delegatedTask = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'launch_task', + kind: 'tool', + mcp: false, + payload: { + toolName: 'launch_task', + output: JSON.stringify({ success: true, taskId: id }), + }, + }); + + const entries = buildAcpRenderBlocks( + [delegatedTask('child-1', 1), delegatedTask('child-2', 2)], + { keepDelegatedTasksVisible: true }, + ); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent widget previews standalone', () => { + const widget = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'show_widget', + kind: 'mcp', + toolName: 'show_widget', + text: JSON.stringify({ + success: true, + shown: true, + html: `

${id}

`, + height: 240, + }), + }); + + const entries = buildAcpRenderBlocks([ + widget('widget-1', 1), + widget('widget-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent visual-proof uploads standalone', () => { + const proof = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'manage_artifacts', + kind: 'mcp', + toolName: 'manage_artifacts', + text: JSON.stringify({ + success: true, + artifactId: id, + artifactType: 'visual-proof', + viewUrl: `https://example.com/task/task-1/artifacts/${id}.png`, + rawUrl: `https://example.com/${id}.png`, + }), + }); + + const entries = buildAcpRenderBlocks([ + proof('proof-1', 1), + proof('proof-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts new file mode 100644 index 000000000..7cc739ba9 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -0,0 +1,198 @@ +import type { AcpToolResultPayload } from '@roomote/types'; + +import { resolveToolPresentation } from '../tool-presentation'; +import { resolveToolPresentationPolicy } from '../tool-presentation-policy'; +import type { AcpToolResultUiMessage } from '../types'; + +function toolData( + overrides: Partial = {}, +): AcpToolResultPayload { + return { + toolCallId: 'call-1', + kind: 'tool', + title: 'custom_tool', + isExecute: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: null, + exitCode: null, + output: '{}', + status: 'completed', + ...overrides, + }; +} + +function toolMessage( + overrides: Partial = {}, +): AcpToolResultUiMessage { + const data = toolData(overrides); + return { + id: 'message-1', + ts: 1, + role: 'tool', + partial: false, + sessionId: 'session-1', + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: data.output, + data, + }; +} + +describe('tool presentation resolver', () => { + it.each([ + [{ kind: 'execute', isExecute: true }, 'execute', 'terminal'], + [{ kind: 'read' }, 'read', 'file'], + [{ toolName: 'spill_grep' }, 'search', 'search'], + [{ toolName: 'list_skills' }, 'list', 'folder'], + [{ toolName: 'launch_task' }, 'task', 'task'], + [{ toolName: 'save_memory' }, 'memory', 'memory'], + [{ toolName: 'show_widget' }, 'widget', 'widget'], + ] as const)('classifies %o as %s', (overrides, category, iconKey) => { + expect(resolveToolPresentation(toolData(overrides))).toMatchObject({ + category, + iconKey, + }); + }); + + it.each([ + ['manage_custom_automations', 'task'], + ['get_about_me', 'roomote'], + ['describe_video', 'video'], + ['manage_goal', 'target'], + ['manage_tasks', 'list-checks'], + ['manage_source_control', 'pull-request'], + ['manage_environments', 'environment'], + ['save_task_memory', 'memory'], + ['request_environment_variables', 'terminal'], + ['report_platform_issue', 'alert'], + ['submit_automation_work_items', 'task'], + ['list_chat_channels', 'messages'], + ['get_chat_channel_messages', 'messages'], + ['get_chat_message_context', 'messages'], + ] as const)('uses the %s icon for %s', (toolName, iconKey) => { + expect(resolveToolPresentation(toolData({ toolName }))).toMatchObject({ + iconKey, + }); + }); + + it('uses Memory as the provider label without changing canonical identity', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'gbrain', + mcpToolName: 'query', + serverName: 'gbrain', + toolName: 'query', + }), + ), + ).toMatchObject({ + category: 'memory', + providerLabel: 'Memory', + identity: { serverName: 'gbrain', toolName: 'query' }, + }); + }); + + it('uses a known MCP integration’s catalog label and icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'search_issues', + serverName: 'sentry', + toolName: 'search_issues', + }), + ), + ).toMatchObject({ + integrationIcon: 'sentry', + providerLabel: 'Sentry', + }); + }); + + it('keeps explicit tool icons ahead of an MCP integration icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'manage_goal', + serverName: 'sentry', + toolName: 'manage_goal', + }), + ), + ).toMatchObject({ iconKey: 'target', integrationIcon: undefined }); + }); + + it('uses meaningful receipt language for consequential task actions', () => { + expect( + resolveToolPresentation(toolData({ toolName: 'launch_task' })), + ).toMatchObject({ verb: 'Started', object: 'Coding Task' }); + expect( + resolveToolPresentation( + toolData({ toolName: 'launch_task', status: 'failed' }), + ), + ).toMatchObject({ verb: 'Failed to Start', object: 'Coding Task' }); + }); + + it('sanitizes native fallback titles without using them for identity', () => { + expect( + resolveToolPresentation( + toolData({ + title: 'Read /sandbox/repos/RooCodeInc/Roomote/apps/web/package.json', + toolName: null, + }), + ), + ).toMatchObject({ + displayName: 'Read RooCodeInc/Roomote/apps/web/package.json', + object: 'Read RooCodeInc/Roomote/apps/web/package.json', + identity: { toolName: null }, + groupKey: 'kind:tool', + }); + }); +}); + +describe('tool presentation policy', () => { + it('keeps consequential receipts outside collapsed activity', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'save_memory', kind: 'memory' }), + ).activityMode, + ).toBe('keep-visible'); + }); + + it('keeps delegated task cards visible in narration mode only on card-enabled surfaces', () => { + const message = toolMessage({ + toolName: 'launch_task', + kind: 'task', + output: JSON.stringify({ success: true, taskId: 'task-1' }), + }); + + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: true, + displayMode: 'narration', + }), + ).toMatchObject({ + renderAs: 'delegated-task-card', + rowVisibility: 'visible', + activityMode: 'keep-visible', + }); + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: false, + }).renderAs, + ).toBe('row'); + }); + + it('keeps ordinary exploration hidden in narration mode', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'read_file', kind: 'read' }), + { displayMode: 'narration' }, + ).rowVisibility, + ).toBe('hidden'); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index c899e451e..d3f69f225 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -8,8 +8,7 @@ import type { AcpUiMessage, } from './types'; import type { AcpRenderBlock } from './render-blocks'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; -import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -21,10 +20,6 @@ const COLLAPSIBLE_ACP_MESSAGE_KIND_SET = new Set( COLLAPSIBLE_ACP_MESSAGE_KINDS, ); -const MANAGE_ARTIFACTS_TOOL_NAME = 'manage_artifacts'; -const SHOW_WIDGET_TOOL_NAME = 'show_widget'; -const ROOMOTE_MCP_SERVER_NAME = 'roomote'; - export interface AcpActivityGroupRenderBlock { kind: 'activity_group'; id: string; @@ -42,6 +37,7 @@ interface BuildAcpActivityRenderBlocksOptions { displayMode?: 'default' | 'narration'; hasLeadingTextBoundary?: boolean; collapseLeadingActivity?: boolean; + keepDelegatedTasksVisible?: boolean; } function isToolMessage( @@ -92,48 +88,6 @@ function isActivityBoundaryBlock(block: AcpRenderBlock): boolean { return isTextBoundaryBlock(block) || isProgressBoundaryBlock(block); } -function getToolName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.toolName ?? msg.data.mcpToolName; - const normalized = rawName?.trim().toLowerCase(); - - return normalized && normalized.length > 0 ? normalized : null; -} - -function getServerName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.serverName ?? msg.data.mcpServerName; - const normalized = rawName?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function isArtifactToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - artifacts: readonly TaskArtifact[] | null | undefined, -): boolean { - const toolName = getToolName(msg); - const serverName = getServerName(msg); - - if (toolName === MANAGE_ARTIFACTS_TOOL_NAME) { - return true; - } - - if ( - toolName === SHOW_WIDGET_TOOL_NAME && - serverName === ROOMOTE_MCP_SERVER_NAME - ) { - return true; - } - - if (resolveShowWidgetForToolMessage(msg) !== null) { - return true; - } - - return resolveVisualProofMediaForToolMessage(msg, artifacts).length > 0; -} - function isLivePartialBlock(block: AcpRenderBlock): boolean { if (block.kind === 'tool_group') { return block.items.some( @@ -152,6 +106,7 @@ function isLivePartialBlock(block: AcpRenderBlock): boolean { export function isActivityCollapsibleBlock( block: AcpRenderBlock, artifacts?: readonly TaskArtifact[] | null, + keepDelegatedTasksVisible = false, ): boolean { // Keep in-flight reasoning/tool rows outside default-closed groups so current // activity stays visible without a manual expand. @@ -160,8 +115,12 @@ export function isActivityCollapsibleBlock( } if (block.kind === 'tool_group') { - return !block.items.some((item) => - isArtifactToolMessage(item.msg, artifacts), + return !block.items.some( + (item) => + resolveToolPresentationPolicy(item.msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'keep-visible', ); } @@ -175,8 +134,13 @@ export function isActivityCollapsibleBlock( return false; } - if (isToolMessage(msg) && isArtifactToolMessage(msg, artifacts)) { - return false; + if (isToolMessage(msg)) { + return ( + resolveToolPresentationPolicy(msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'collapsible' + ); } return true; @@ -215,7 +179,11 @@ export function buildAcpActivityRenderBlocks( if ( !hasLeftTextBoundary || - !isActivityCollapsibleBlock(current, options.artifacts) + !isActivityCollapsibleBlock( + current, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { groupedBlocks.push(current); hasLeftTextBoundary = false; @@ -228,7 +196,11 @@ export function buildAcpActivityRenderBlocks( while ( activityEnd < blocks.length && - isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts) + isActivityCollapsibleBlock( + blocks[activityEnd]!, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { activityEnd += 1; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts new file mode 100644 index 000000000..488e97bc8 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -0,0 +1,50 @@ +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface DelegatedTaskDetails { + taskId: string; + prompt: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function getDelegatedTaskDetails( + msg: ToolMessage, +): DelegatedTaskDetails | null { + const toolName = (msg.data.toolName ?? msg.data.mcpToolName) + ?.trim() + .toLowerCase(); + + if (msg.kind !== 'tool_result' || toolName !== 'launch_task') { + return null; + } + + try { + const parsed = asRecord(JSON.parse(msg.data.output)); + const result = asRecord(parsed?.result) ?? asRecord(parsed?.data) ?? parsed; + const taskId = result?.taskId; + + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const rawInput = asRecord( + (msg.data as unknown as Record).rawInput, + ); + const args = asRecord(rawInput?.arguments); + const prompt = args?.prompt; + + return { + taskId, + prompt: + typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts index 3cedf9ef0..9c8289e11 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts @@ -4,47 +4,24 @@ import { normalizeTranscriptUserText, } from '@roomote/types'; -import { - isInternalDebugToolCallMessage, - shouldHideAcpMessage, -} from '../../message-visibility'; +import { shouldHideAcpMessage } from '../../message-visibility'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage, AcpUiMessage, } from './types'; +import { isSubagentToolMessage, isSubagentToolPayload } from './subagent-tool'; import { - isSubagentSpawnRowMessage, - isSubagentToolMessage, - isSubagentToolPayload, -} from './subagent-tool'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; - -export type ExplorationStepKind = 'list' | 'read' | 'search'; - -export type GroupedToolDisplayKind = - | ExplorationStepKind - | 'execute' - | 'edit' - | 'tool'; - -const EXPLORATION_TOOL_NAMES: Record> = { - search: new Set(['search', 'search_file', 'search_files']), - list: new Set(['glob', 'list', 'list_dir', 'list_directory', 'list_files']), - read: new Set(['read', 'read_file']), -}; + resolveToolPresentation, + summarizeToolGroup, + type ToolPresentationCategory, +} from './tool-presentation'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; -const STEP_KIND_ORDER: ExplorationStepKind[] = ['search', 'list', 'read']; +type ExplorationStepKind = 'list' | 'read' | 'search'; -const STEP_KIND_LABELS: Record< - ExplorationStepKind, - { singular: string; plural: string } -> = { - search: { singular: 'search', plural: 'searches' }, - list: { singular: 'listing', plural: 'listings' }, - read: { singular: 'file', plural: 'files' }, -}; +export type GroupedToolDisplayKind = ToolPresentationCategory; const STEP_KIND_DATA_KEYS: Record = { search: [ @@ -113,6 +90,7 @@ interface BuildAcpRenderBlocksOptions { initialPrompt?: Pick | null; shouldHideFirstMessage?: boolean; showInternalMessages?: boolean; + keepDelegatedTasksVisible?: boolean; suppressedMessageIds?: ReadonlySet; } @@ -287,45 +265,6 @@ function extractLabelFromToolData( return extractStringByKeys(argumentsRecord as Record, keys); } -function isExecuteToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): boolean { - const data = msg.data as unknown as Record; - return ( - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isExecute === true - ); -} - -function resolveExplorationStepKind( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): ExplorationStepKind | null { - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - - for (const stepKind of STEP_KIND_ORDER) { - if (toolName && EXPLORATION_TOOL_NAMES[stepKind].has(toolName)) { - return stepKind; - } - } - - if (msg.data.kind === 'search') { - return 'search'; - } - - if (msg.data.kind === 'list') { - return 'list'; - } - - if (msg.data.kind === 'read') { - return 'read'; - } - - return null; -} - /** * Stable identity for consecutive same-type collapsing. Different tools never * share a key, even when both are MCP exploration-style helpers. @@ -338,49 +277,14 @@ function resolveToolGroupKey( return null; } - if (isExecuteToolMessage(msg)) { - return 'execute'; - } - - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - const serverName = (msg.data.serverName ?? msg.data.mcpServerName ?? '') - .trim() - .toLowerCase(); - - if (toolName) { - return serverName ? `mcp:${serverName}:${toolName}` : `tool:${toolName}`; - } - - const kind = (msg.data.kind ?? '').trim().toLowerCase(); - - if (kind && kind !== 'mcp') { - return `kind:${kind}`; - } - - return null; + return resolveToolPresentation(msg.data, msg.partial).groupKey; } function resolveGroupedToolDisplayKind( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - groupKey: string, + _groupKey: string, ): GroupedToolDisplayKind { - if (groupKey === 'execute' || isExecuteToolMessage(msg)) { - return 'execute'; - } - - const explorationStep = resolveExplorationStepKind(msg); - - if (explorationStep) { - return explorationStep; - } - - if (msg.data.kind === 'edit') { - return 'edit'; - } - - return 'tool'; + return resolveToolPresentation(msg.data, msg.partial).category; } function isSettledToolMessage( @@ -393,10 +297,6 @@ function isSettledToolMessage( return msg.data.status === 'completed' || msg.data.status === 'failed'; } -function formatGenericToolLabel(value: string): string { - return value.split(/[_-]+/).filter(Boolean).join(' ').toLowerCase(); -} - const TITLE_PREFIX_RE = /^(?:search|read|list|find|run|using|used|ran|running)\s+(.+)$/i; @@ -435,53 +335,14 @@ function extractObjectLabel( function summarizeSameTypeGroup( items: GroupedToolCallItem[], displayKind: GroupedToolDisplayKind, - groupKey: string, + _groupKey: string, ): { action: string; objectSummary: string } { - const count = items.length; - - if (displayKind === 'execute') { - return { - action: 'Ran', - objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, - }; - } - - if ( - displayKind === 'search' || - displayKind === 'list' || - displayKind === 'read' - ) { - const labels = STEP_KIND_LABELS[displayKind]; - return { - action: 'Exploring', - objectSummary: `${count} ${count === 1 ? labels.singular : labels.plural}`, - }; - } - - if (displayKind === 'edit') { - return { - action: 'Edited', - objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, - }; - } - - const toolNameMatch = /^(?:mcp:[^:]+:|tool:)(.+)$/.exec(groupKey); - const toolLabel = toolNameMatch?.[1] - ? formatGenericToolLabel(toolNameMatch[1]) - : null; - - if (toolLabel) { - return { - action: 'Used', - objectSummary: - count === 1 ? `1 ${toolLabel}` : `${count} ${toolLabel} calls`, - }; - } - - return { - action: 'Used', - objectSummary: `${count} ${count === 1 ? 'tool' : 'tools'}`, - }; + const presentation = resolveToolPresentation(items[0]!.msg.data); + return summarizeToolGroup( + displayKind, + items.length, + presentation.displayName, + ); } function buildGroupedToolItem( @@ -680,12 +541,6 @@ function resolveMessageRenderState( options: BuildAcpRenderBlocksOptions, hideCurrentFirstUserPrompt: boolean, ): MessageRenderState { - const shouldShowInternalMessageInNarration = - options.showInternalMessages === true && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)); - const shouldShowWidgetInNarration = - isToolMessage(msg) && resolveShowWidgetForToolMessage(msg) !== null; - if (options.suppressedMessageIds?.has(msg.id)) { return { visibility: 'hidden', @@ -700,32 +555,18 @@ function resolveMessageRenderState( }; } - if ( - options.showInternalMessages === false && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)) && - // Spawn rows render inline even without debug UI. Keyed on the stable - // payload shape, never on live-only activity data: activity does not - // survive a transcript rebuild, and a row that vanishes on refresh reads - // as a lost subagent. - !isSubagentSpawnRowMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; - } - - if ( - options.displayMode === 'narration' && - isToolMessage(msg) && - !shouldShowInternalMessageInNarration && - !shouldShowWidgetInNarration && - !isSubagentToolMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; + if (isToolMessage(msg)) { + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + if (policy.rowVisibility !== 'visible') { + return { + visibility: 'hidden', + behavior: policy.hiddenBehavior, + }; + } } if (isEmptyCompletedTextMessage(msg)) { @@ -757,9 +598,16 @@ function resolveMessageRenderState( }; } + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + return { visibility: 'render', - groupKey: resolveToolGroupKey(msg), + groupKey: + policy.groupingMode === 'standalone' ? null : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts index da53510ec..8fffccbd8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts @@ -1,5 +1,5 @@ -import { isInternalDebugToolCallMessage } from '../../message-visibility'; import { isSubagentToolPayload } from './subagent-tool'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; @@ -69,21 +69,9 @@ export function hidesExpandedToolResult( msg: AcpToolUiMessage, options?: ToolDetailVisibilityOptions, ): boolean { - const data = msg.data as unknown as Record; - - if (isSubagentToolPayload(msg.data)) { - if (options?.showSubagentPayload === true) { - return false; - } - - return ( - getSubagentPrompt(msg) === null && getSubagentLastMessage(msg) === null - ); - } - return ( - isInternalDebugToolCallMessage(msg) || - msg.data.kind === 'read' || - data.isRead === true + resolveToolPresentationPolicy(msg, { + showInternalMessages: options?.showSubagentPayload === true, + }).detailMode !== 'expandable' ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts new file mode 100644 index 000000000..d61e59431 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -0,0 +1,67 @@ +import { createElement, forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + +import { + type LucideIcon, + Brain, + BrandIcon, + Bot, + FileIcon, + FolderIcon, + GalleryVerticalEnd, + GitPullRequest, + HardDriveUpload, + ListChecks, + MessageSquareText, + MessagesSquare, + RoomoteR, + Search, + SquarePen, + Target, + Terminal, + TriangleAlert, + VectorSquare, + Video, + Wrench, + Zap, +} from '@/components/system'; + +import type { ToolIconKey } from './tool-presentation'; + +export function toolIconForKey(key: ToolIconKey): LucideIcon { + if (key === 'terminal') return Terminal; + if (key === 'file') return FileIcon; + if (key === 'folder') return FolderIcon; + if (key === 'search') return Search; + if (key === 'edit') return SquarePen; + if (key === 'bot') return Bot; + if (key === 'task') return Zap; + if (key === 'message') return MessageSquareText; + if (key === 'memory') return Brain; + if (key === 'artifact') return HardDriveUpload; + if (key === 'widget') return GalleryVerticalEnd; + if (key === 'roomote') return RoomoteR; + if (key === 'video') return Video; + if (key === 'target') return Target; + if (key === 'list-checks') return ListChecks; + if (key === 'pull-request') return GitPullRequest; + if (key === 'environment') return VectorSquare; + if (key === 'alert') return TriangleAlert; + if (key === 'messages') return MessagesSquare; + return Wrench; +} + +const mcpIntegrationIconCache = new Map(); + +export function mcpIntegrationIconFor(icon: string): LucideIcon { + const existing = mcpIntegrationIconCache.get(icon); + if (existing) return existing; + + const McpIntegrationIcon = forwardRef( + ({ className }, _ref) => + createElement(BrandIcon, { icon, name: '', className }), + ); + McpIntegrationIcon.displayName = `McpIntegrationIcon(${icon})`; + mcpIntegrationIconCache.set(icon, McpIntegrationIcon); + return McpIntegrationIcon; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts new file mode 100644 index 000000000..699a853f1 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts @@ -0,0 +1,140 @@ +import type { TaskArtifact } from '@/types'; + +import { + isInternalDebugToolCallMessage, + shouldHideAcpMessage, +} from '../../message-visibility'; +import { getDelegatedTaskDetails } from './delegated-task'; +import { + isSubagentSpawnRowMessage, + isSubagentToolMessage, +} from './subagent-tool'; +import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { resolveToolPresentation } from './tool-presentation'; +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; +import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface ToolPresentationPolicyOptions { + artifacts?: readonly TaskArtifact[] | null; + delegatedTaskCardsEnabled?: boolean; + displayMode?: 'default' | 'narration'; + showInternalMessages?: boolean; +} + +interface ResolvedToolPolicy { + rowVisibility: 'visible' | 'hidden' | 'debug-only'; + hiddenBehavior: 'boundary' | 'transparent'; + detailMode: 'none' | 'expandable' | 'preview'; + activityMode: 'collapsible' | 'keep-visible'; + renderAs: 'row' | 'delegated-task-card'; + groupingMode: 'groupable' | 'standalone'; +} + +const CONSEQUENTIAL_RECEIPTS = new Set([ + 'launch_task', + 'cancel_task', + 'retry_task_start', + 'send_task_message', + 'save_memory', +]); + +export function resolveToolPresentationPolicy( + msg: ToolMessage, + options: ToolPresentationPolicyOptions = {}, +): ResolvedToolPolicy { + const presentation = resolveToolPresentation(msg.data, msg.partial); + const delegatedTask = getDelegatedTaskDetails(msg); + const renderAs = + options.delegatedTaskCardsEnabled && delegatedTask + ? 'delegated-task-card' + : 'row'; + const isInternal = + isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg); + const showWidget = resolveShowWidgetForToolMessage(msg) !== null; + const visualProof = + resolveVisualProofMediaForToolMessage(msg, options.artifacts).length > 0; + const isArtifact = presentation.category === 'artifact'; + const hasPreview = showWidget || visualProof; + const isRunning = msg.partial || msg.data.status === 'in_progress'; + const consequentialReceipt = + presentation.identity.toolName !== null && + CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName); + + let rowVisibility: ResolvedToolPolicy['rowVisibility'] = 'visible'; + if (shouldHideAcpMessage(msg)) { + rowVisibility = 'hidden'; + } else if ( + options.showInternalMessages === false && + isInternal && + !isSubagentSpawnRowMessage(msg) + ) { + rowVisibility = 'debug-only'; + } else if ( + options.displayMode === 'narration' && + !hasPreview && + !isSubagentToolMessage(msg) && + renderAs !== 'delegated-task-card' && + !consequentialReceipt && + !(options.showInternalMessages && isInternal) + ) { + rowVisibility = 'hidden'; + } + + const detailMode: ResolvedToolPolicy['detailMode'] = + isSubagentToolMessage(msg) && hasSubagentSummary(msg) + ? 'expandable' + : hasPreview + ? 'preview' + : isInternalDebugToolCallMessage(msg) || + presentation.category === 'read' || + (isSubagentToolMessage(msg) && + !options.showInternalMessages && + !hasSubagentSummary(msg)) + ? 'none' + : 'expandable'; + + return { + rowVisibility, + hiddenBehavior: 'boundary', + detailMode, + activityMode: + isRunning || + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'keep-visible' + : 'collapsible', + renderAs, + groupingMode: + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'standalone' + : 'groupable', + }; +} + +function hasSubagentSummary(msg: ToolMessage): boolean { + const data = msg.data as unknown as Record; + const prompt = data.prompt; + const rawInput = + data.rawInput && + typeof data.rawInput === 'object' && + !Array.isArray(data.rawInput) + ? (data.rawInput as Record) + : null; + const rawPrompt = rawInput?.prompt; + const output = msg.kind === 'tool_result' ? msg.data.output : null; + const activity = data.subagentActivity; + + return Boolean( + (typeof prompt === 'string' && prompt.trim()) || + (typeof rawPrompt === 'string' && rawPrompt.trim()) || + (typeof output === 'string' && output.trim()) || + (activity && typeof activity === 'object'), + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts new file mode 100644 index 000000000..021532e92 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -0,0 +1,350 @@ +import { + getMcpIntegration, + type AcpToolCallPayload, + type AcpToolResultPayload, +} from '@roomote/types'; + +// Direct import: the @/lib barrel drags icon-bearing modules into any test +// that mocks @/components/system. +import { sanitizeSandboxPathString } from '@/lib/sandbox-paths'; + +export type ToolPresentationCategory = + | 'execute' + | 'read' + | 'search' + | 'list' + | 'edit' + | 'subagent' + | 'task' + | 'communication' + | 'memory' + | 'artifact' + | 'widget' + | 'generic'; + +export type ToolIconKey = + | 'terminal' + | 'file' + | 'folder' + | 'search' + | 'edit' + | 'bot' + | 'task' + | 'message' + | 'memory' + | 'artifact' + | 'widget' + | 'roomote' + | 'video' + | 'target' + | 'list-checks' + | 'pull-request' + | 'environment' + | 'alert' + | 'messages' + | 'tool'; + +type ToolPresentationPhase = 'running' | 'completed' | 'failed'; + +type ToolData = AcpToolCallPayload | AcpToolResultPayload; + +interface ResolvedToolPresentation { + identity: { + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + }; + category: ToolPresentationCategory; + displayName: string; + iconKey: ToolIconKey; + integrationIcon?: string; + phase: ToolPresentationPhase; + verb: string; + object?: string; + providerLabel?: string; + groupKey: string | null; +} + +const SEARCH_TOOL_NAMES = new Set([ + 'search', + 'search_file', + 'search_files', + 'spill_grep', +]); +const LIST_TOOL_NAMES = new Set([ + 'glob', + 'list', + 'list_dir', + 'list_directory', + 'list_files', + 'list_skills', +]); +const READ_TOOL_NAMES = new Set([ + 'read', + 'read_file', + 'spill_read', + 'load_skill', +]); +const TASK_TOOL_NAMES = new Set([ + 'launch_task', + 'retry_task_start', + 'cancel_task', + 'send_task_message', +]); +const COMMUNICATION_TOOL_NAMES = new Set([ + 'send_chat_reply', + 'send_chat_reaction', + 'send_chat_reaction_emoji', + 'add_reaction_to_slack_message', + 'post_to_channel', + 'ignore_event', +]); +const TOOL_ICON_OVERRIDES: Readonly>> = { + manage_custom_automations: 'task', + get_about_me: 'roomote', + describe_video: 'video', + manage_goal: 'target', + manage_tasks: 'list-checks', + manage_source_control: 'pull-request', + manage_environments: 'environment', + save_task_memory: 'memory', + request_environment_variables: 'terminal', + report_platform_issue: 'alert', + submit_automation_work_items: 'task', + list_chat_channels: 'messages', + get_chat_channel_messages: 'messages', + get_chat_message_context: 'messages', +}; + +function normalized(value: string | null | undefined): string | null { + const result = value?.trim().toLowerCase(); + return result ? result : null; +} + +function formatToolIdentifier(value: string): string { + if (value.toLowerCase() === 'gbrain') return 'Memory'; + + return value + .replace(/[.]/g, ' ') + .replace(/[-_]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +export function resolveToolPresentation( + data: ToolData, + partial = false, +): ResolvedToolPresentation { + const serverName = normalized(data.serverName ?? data.mcpServerName); + const toolName = normalized(data.toolName ?? data.mcpToolName); + const kind = normalized(data.kind); + const providerKind = data.isMcp ? 'mcp' : 'native'; + const phase: ToolPresentationPhase = + data.status === 'failed' + ? 'failed' + : data.status === 'in_progress' || partial + ? 'running' + : 'completed'; + const category = resolveToolCategory({ + kind, + toolName, + serverName, + isExecute: data.isExecute, + isRead: 'isRead' in data && data.isRead === true, + isSubagentSpawn: data.isSubagentSpawn === true, + }); + const explicitIconKey = toolName ? TOOL_ICON_OVERRIDES[toolName] : undefined; + const integration = + providerKind === 'mcp' && serverName + ? getMcpIntegration(serverName) + : undefined; + const displayName = toolName + ? formatToolIdentifier(toolName) + : sanitizeSandboxPathString(data.title ?? 'Tool'); + const providerLabel = + integration?.name ?? + (serverName ? formatToolIdentifier(serverName) : undefined); + const receipt = resolveReceiptLanguage(toolName, phase); + const verb = receipt?.verb ?? (phase === 'running' ? 'Using' : 'Used'); + const object = receipt?.object ?? displayName; + + return { + identity: { providerKind, serverName, toolName }, + category, + displayName, + iconKey: explicitIconKey ?? categoryIconKey(category), + integrationIcon: explicitIconKey ? undefined : integration?.icon, + phase, + verb, + object, + providerLabel, + groupKey: resolveToolGroupKey({ + category, + providerKind, + serverName, + toolName, + kind, + }), + }; +} + +function resolveToolCategory(input: { + kind: string | null; + toolName: string | null; + serverName: string | null; + isExecute: boolean; + isRead: boolean; + isSubagentSpawn: boolean; +}): ToolPresentationCategory { + if (input.kind === 'subagent' || input.isSubagentSpawn) return 'subagent'; + if ( + input.kind === 'execute' || + input.kind === 'execute_command' || + input.isExecute + ) + return 'execute'; + if ( + input.kind === 'read' || + input.isRead || + (input.toolName && READ_TOOL_NAMES.has(input.toolName)) + ) + return 'read'; + if ( + input.kind === 'search' || + (input.toolName && SEARCH_TOOL_NAMES.has(input.toolName)) + ) + return 'search'; + if ( + input.kind === 'list' || + (input.toolName && LIST_TOOL_NAMES.has(input.toolName)) + ) + return 'list'; + if (input.kind === 'edit') return 'edit'; + if ( + input.kind === 'task' || + (input.toolName && TASK_TOOL_NAMES.has(input.toolName)) + ) + return 'task'; + if ( + input.kind === 'communication' || + (input.toolName && COMMUNICATION_TOOL_NAMES.has(input.toolName)) + ) + return 'communication'; + if ( + input.kind === 'memory' || + input.serverName === 'gbrain' || + input.toolName === 'save_memory' + ) + return 'memory'; + if (input.kind === 'artifact' || input.toolName === 'manage_artifacts') + return 'artifact'; + if (input.kind === 'widget' || input.toolName === 'show_widget') + return 'widget'; + return 'generic'; +} + +function categoryIconKey(category: ToolPresentationCategory): ToolIconKey { + if (category === 'execute') return 'terminal'; + if (category === 'read') return 'file'; + if (category === 'list') return 'folder'; + if (category === 'search') return 'search'; + if (category === 'edit') return 'edit'; + if (category === 'subagent') return 'bot'; + if (category === 'task') return 'task'; + if (category === 'communication') return 'message'; + if (category === 'memory') return 'memory'; + if (category === 'artifact') return 'artifact'; + if (category === 'widget') return 'widget'; + return 'tool'; +} + +function resolveToolGroupKey(input: { + category: ToolPresentationCategory; + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + kind: string | null; +}): string | null { + if (input.category === 'subagent') return null; + if (input.category === 'execute') return 'execute'; + if (input.toolName) { + return input.providerKind === 'mcp' && input.serverName + ? `mcp:${input.serverName}:${input.toolName}` + : `tool:${input.toolName}`; + } + return input.kind && input.kind !== 'mcp' ? `kind:${input.kind}` : null; +} + +function resolveReceiptLanguage( + toolName: string | null, + phase: ToolPresentationPhase, +): { verb: string; object: string } | null { + const byPhase = (running: string, completed: string, failed: string) => + phase === 'running' ? running : phase === 'failed' ? failed : completed; + + if (toolName === 'launch_task') + return { + verb: byPhase('Starting', 'Started', 'Failed to Start'), + object: 'Coding Task', + }; + if (toolName === 'cancel_task') + return { + verb: byPhase('Cancelling', 'Cancelled', 'Failed to Cancel'), + object: 'Task', + }; + if (toolName === 'retry_task_start') + return { + verb: byPhase('Retrying', 'Retried', 'Failed to Retry'), + object: 'Task', + }; + if (toolName === 'send_task_message') + return { + verb: byPhase('Sending', 'Sent', 'Failed to Send'), + object: 'Task Message', + }; + if (toolName === 'save_memory') + return { + verb: byPhase('Saving', 'Saved', 'Failed to Save'), + object: 'Memory', + }; + return null; +} + +export function summarizeToolGroup( + category: ToolPresentationCategory, + count: number, + displayName: string, +): { action: string; objectSummary: string } { + if (category === 'execute') + return { + action: 'Ran', + objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, + }; + if (category === 'search') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'search' : 'searches'}`, + }; + if (category === 'list') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'listing' : 'listings'}`, + }; + if (category === 'read') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + if (category === 'edit') + return { + action: 'Edited', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + + const label = displayName.toLowerCase(); + return { + action: 'Used', + objectSummary: count === 1 ? `1 ${label}` : `${count} ${label} calls`, + }; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index 61d3e12ff..d40f13449 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -52,6 +52,11 @@ import { useTaskSummary, } from '../hooks'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../../SandboxInfoPanel'; import { SidePanelHeader } from './SidePanelHeader'; import { getTaskParticipants } from './task-participants'; @@ -299,296 +304,270 @@ export function TaskInfoPanel({ PRODUCT_NAME; return ( - <> - -
-
- - - - - - - - {participants.length > 0 && ( - - - - - )} - - {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( - - - - - )} - - - - - - - {taskModelLabel && ( - - - - - )} - - - - - - - {showRuntimeRow && ( - - - - - )} - - {(taskRun.pullRequests?.length ?? 0) > 0 ? ( - - - - - ) : taskRun.prRepo && taskRun.prNumber ? ( - - - + + + + + {participants.length > 0 && ( + + + - - ) : null} - - {linkedWorkItems.length > 0 ? ( - - - - - ) : null} - - - - - - - - - - - -
- Creator - - {task.user && task.attributionKind === 'user' ? ( - <> - {task.user.imageUrl ? ( - {taskCreatorDisplayName} - ) : null} - {taskCreatorDisplayName} - - ) : ( - taskCreatorDisplayName - )} -
- Participants - -
- {participants.map((participant) => ( - - - {participant.displayName} - - ))} -
-
- Workspace - - -
- Sandbox Provider - - - - {sandboxProviderLabel} - -
- Model - - - - {taskModelLabel} - {taskRun.payload?.modelRoleOverrides && ( - - Customized - - )} - -
- Inference Cost - - - - {inferenceCostLabel} - -
- Runtime - - - - - {HARNESS_LABELS[effectiveHarness]} - - -
- Pull Requests - -
- {taskRun.pullRequests?.map((pullRequest) => ( - - ))} -
-
- Pull Request - - } + > + +
Creator + {task.user && task.attributionKind === 'user' ? ( + <> + {task.user.imageUrl ? ( + {taskCreatorDisplayName} + ) : null} + {taskCreatorDisplayName} + + ) : ( + taskCreatorDisplayName + )} +
+ Participants + +
+ {participants.map((participant) => ( + + -
- Linked Work - -
- {linkedWorkItems.map((item, index) => ( - - ))} -
-
- Started At - - - - - {formatStartedAt(taskRun.startedAt)} - + {participant.displayName} -
- Started From - - - {startedFrom.brandIcon ? ( - startedFrom.brandIcon === 'slack' ? ( - - ) : ( - - ) - ) : ( - - )} - {startedFrom.label} - -
- - {taskRunError && ( -
-
-

Last Error

- + ))}
-

- {taskRunError} -

-
- )} + + + )} + + {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( + + Workspace + + + + + )} + + + + Sandbox Provider + + + + + {sandboxProviderLabel} + + + + + {taskModelLabel && ( + + Model + + + + {taskModelLabel} + {taskRun.payload?.modelRoleOverrides && ( + + Customized + + )} + + + + )} + + + + + {inferenceCostLabel} + + + + {showRuntimeRow && ( + + Runtime + + + + + {HARNESS_LABELS[effectiveHarness]} + + + + + )} + + {(taskRun.pullRequests?.length ?? 0) > 0 ? ( + + + Pull Requests + + +
+ {taskRun.pullRequests?.map((pullRequest) => ( + + ))} +
+ + + ) : taskRun.prRepo && taskRun.prNumber ? ( + + + Pull Request + + + + + + ) : null} - {summaryEnabled && ( -
-
-

Summary

+ {linkedWorkItems.length > 0 ? ( + + + Linked Work + + +
+ {linkedWorkItems.map((item, index) => ( + + ))}
+ + + ) : null} - {isLoadingSummary ? ( -
- - Generating... -
- ) : summary ? ( - <> - {isSummaryStale && ( -
- New messages since last summarized. - -
- )} -
- - {summary} - -
- - ) : summaryErrorMessage ? ( -
-

{summaryErrorMessage}

+ + + + + {formatStartedAt(taskRun.startedAt)} + + + + + + + {startedFrom.brandIcon ? ( + startedFrom.brandIcon === 'slack' ? ( + + ) : ( + + ) + ) : ( + + )} + {startedFrom.label} + + + + + {taskRunError && ( +
+
+

Last Error

+ +
+

+ {taskRunError} +

+
+ )} + + {summaryEnabled && ( +
+
+

Summary

+
+ + {isLoadingSummary ? ( +
+ + Generating... +
+ ) : summary ? ( + <> + {isSummaryStale && ( +
+ New messages since last summarized.
- ) : null} + )} +
+ + {summary} + +
+ + ) : summaryErrorMessage ? ( +
+

{summaryErrorMessage}

+
- )} + ) : null}
-
- + )} + ); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 60b6c110b..394c2e116 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -118,6 +118,9 @@ export default async function RootLayout({ return ( + {/* Must run synchronously before first paint: App Router queues + inline beforeInteractive Scripts until client bootstrap, which + flashes the wrong theme. */} ', title: 'Status', - textFallback: 'Status is available in the web transcript.', + textFallback: 'Status: all systems operational.', }); expect(result).toMatchObject({ success: true, @@ -911,12 +911,12 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { await expect( answerFastAgentQuestion({ ...baseParams, adapter }), - ).resolves.toBe('Status is available in the web transcript.'); + ).resolves.toBe('Status: all systems operational.'); expect(adapter.postReply).toHaveBeenCalledTimes(1); expect(adapter.postReply).toHaveBeenCalledWith({ purpose: 'progress', - message: 'Status is available in the web transcript.', + message: 'Status: all systems operational.', }); const toolResult = mocks.upsertMessage.mock.calls .map(([input]) => input.message) @@ -938,7 +938,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { success: true, shown: true, html: '

Safe

', - textFallback: 'Status is available in the web transcript.', + textFallback: 'Status: all systems operational.', }); }); diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts index 216e23450..85d906877 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-native-tool-bridge.ts @@ -314,14 +314,14 @@ import { invoke } from "../roomote-fast-tool-bridge.js" export default { description: ${JSON.stringify( - `Render presentational HTML in the web transcript. ${SHOW_WIDGET_THEME_GUIDANCE} ${SHOW_WIDGET_FIXED_CANVAS_GUIDANCE} On Slack or Discord, textFallback is posted instead; use request_user_input for questions.`, + `Render presentational HTML in the web transcript. ${SHOW_WIDGET_THEME_GUIDANCE} ${SHOW_WIDGET_FIXED_CANVAS_GUIDANCE} On Slack or Discord, textFallback is posted as a chat preview with a link to open the rendered widget; use request_user_input for questions.`, )}, args: { html: z.string().min(1).max(${SHOW_WIDGET_MAX_HTML_CHARS}).describe("Compact semantic HTML that fully fits the fixed canvas; avoid long prose, large lists, and dense data"), title: z.string().max(${SHOW_WIDGET_MAX_TITLE_CHARS}).optional(), css: z.string().max(${SHOW_WIDGET_MAX_CSS_CHARS}).optional().describe("Optional CSS using --rw-* theme variables; do not mask overflow with clipping or scroll containers"), height: z.number().finite().optional().describe(${JSON.stringify(SHOW_WIDGET_HEIGHT_DESCRIPTION)}), - textFallback: z.string().max(${SHOW_WIDGET_MAX_TEXT_FALLBACK_CHARS}).optional(), + textFallback: z.string().max(${SHOW_WIDGET_MAX_TEXT_FALLBACK_CHARS}).optional().describe("Optional chat preview shown on Slack or Discord with a link to open the rendered widget"), }, execute: (args, context) => invoke("show_widget", args, context), } From 050cd1d0345cc9d8927752518ef13b85bdc8d3ef Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:48:50 +0000 Subject: [PATCH 104/158] [Improve] Show Fast activity in Slack agent sessions (#1881) * feat: show Fast activity in Slack agent sessions * fix: use Fast conversation title for Slack sessions * fix: preserve Fast session titles in Slack status * fix: avoid unconfirmed Slack session renames * fix: sync generated Fast titles to Slack sessions --------- Co-authored-by: @daniel-lxs <57051444+daniel-lxs@users.noreply.github.com> --- .../slack/events/fast-agent-reaction.test.ts | 9 + .../slack/events/fast-agent-reaction.ts | 7 + .../src/handlers/slack/events/fast-agent.ts | 7 + ...fast-agent-conversation-repository.test.ts | 22 ++ .../__tests__/fast-agent-service.test.ts | 26 +- .../__tests__/fast-agent-title.test.ts | 3 +- .../fast-agent-conversation-repository.ts | 2 + .../fast-agent/fast-agent-conversation.ts | 7 + .../server/fast-agent/fast-agent-service.ts | 17 +- .../server/fast-agent/fast-agent-session.ts | 1 + .../src/server/fast-agent/fast-agent-title.ts | 21 +- .../src/server/lib/fast-agent-parent-event.ts | 7 + .../lib/fast-agent-surface-reply.test.ts | 12 + .../server/lib/fast-agent-surface-reply.ts | 9 +- .../fast-agent-session-activity.test.ts | 242 ++++++++++++++++++ .../src/__tests__/slack-notifier.test.ts | 58 +++++ .../slack/src/fast-agent-session-activity.ts | 107 ++++++++ packages/slack/src/index.ts | 1 + packages/slack/src/slack-notifier.ts | 75 ++++++ 19 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 packages/slack/src/__tests__/fast-agent-session-activity.test.ts create mode 100644 packages/slack/src/fast-agent-session-activity.ts diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts index d2bffcc63..6a3825d9f 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ acquireLock: vi.fn(), answerQuestion: vi.fn(), + createActivity: vi.fn(() => ({ start: vi.fn(), settle: vi.fn() })), findSession: vi.fn(), getActiveTasks: vi.fn(), lookupUser: vi.fn(), @@ -35,6 +36,7 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('@roomote/slack', () => ({ buildSlackThreadReplyFooterBlock: vi.fn(() => ({ type: 'context' })), createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), + createFastAgentSlackSessionActivity: mocks.createActivity, getSlackThreadReplyFooterMessageTs: vi.fn(async () => null), withSlackThreadReplyFooterLock: vi.fn( async ({ fn }: { fn: () => Promise }) => fn(), @@ -64,6 +66,7 @@ describe('Fast Slack reaction input', () => { mocks.findSession.mockResolvedValue({ id: 'session-1', userId: 'user-1', + title: 'Investigate Slack agent status', conversation: { surface: 'slack', workspaceId: 'T1', @@ -102,6 +105,12 @@ describe('Fast Slack reaction input', () => { ).resolves.toBe(true); await vi.waitFor(() => expect(mocks.answerQuestion).toHaveBeenCalledOnce()); + expect(mocks.createActivity).toHaveBeenCalledWith({ + slack: expect.anything(), + channel: 'C1', + threadTs: '100.000', + title: 'Investigate Slack agent status', + }); expect(mocks.findSession).toHaveBeenCalledWith({ provider: 'slack', workspaceId: 'T1', diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts index 0d91efb29..7ffe5a976 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts @@ -18,6 +18,7 @@ import { import { buildSlackThreadReplyFooterBlock, createFastAgentSlackLiveTaskLauncher, + createFastAgentSlackSessionActivity, getSlackThreadReplyFooterMessageTs, type SlackReactionAddedEvent, withSlackThreadReplyFooterLock, @@ -98,6 +99,12 @@ async function processFastAgentReaction(params: { platformEventVisibility: 'optional', platformEventTranscriptPayload: { externalInput: reactionInput }, adapter: { + activity: createFastAgentSlackSessionActivity({ + slack: context.slack, + channel: event.item.channel, + threadTs, + title: session.title, + }), resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ userId: session.userId, diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 616db53a4..83d43638b 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -12,6 +12,7 @@ import { } from '@roomote/communication'; import { buildSlackThreadReplyFooterBlock, + createFastAgentSlackSessionActivity, getSlackThreadReplyFooterMessageTs, withSlackThreadReplyFooterLock, resolveCurrentSlackMessageFiles, @@ -229,6 +230,12 @@ export async function processFastAgentMessage(params: { hasOtherHumanParticipant && !directedAtRoomote, adapter: { + activity: createFastAgentSlackSessionActivity({ + slack, + channel: event.channel, + threadTs: threadId, + title: session.title, + }), resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ userId, diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts index 8f14e9327..751079404 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-conversation-repository.test.ts @@ -130,6 +130,28 @@ describe('Fast conversation repository', () => { expect(rows).toEqual([{ id: sessions[0]!.id }]); }); + it('returns the persisted Fast conversation title', async () => { + const user = await createUser(); + const session = await fastAgentConversationRepository.getOrCreate({ + userId: user.id, + conversation: slackConversation, + }); + await db + .update(fastAgentConversations) + .set({ title: 'Investigate Slack agent status' }) + .where(eq(fastAgentConversations.id, session.id)); + + await expect( + fastAgentConversationRepository.findById({ id: session.id }), + ).resolves.toMatchObject({ title: 'Investigate Slack agent status' }); + await expect( + fastAgentConversationRepository.getOrCreate({ + userId: user.id, + conversation: slackConversation, + }), + ).resolves.toMatchObject({ title: 'Investigate Slack agent status' }); + }); + it('keeps identity stable while updating the current reply destination', async () => { const user = await createUser(); const discordConversation = { diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index ebf6a8962..fe86688cc 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -496,6 +496,24 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }); }); + it('starts and settles surface activity around a successful turn', async () => { + const activity = { + start: vi.fn(), + settle: vi.fn().mockResolvedValue(undefined), + }; + + await answerFastAgentQuestion({ + ...baseParams, + adapter: callbacks({ activity }), + }); + + expect(activity.start).toHaveBeenCalledOnce(); + expect(activity.settle).toHaveBeenCalledOnce(); + expect(activity.start.mock.invocationCallOrder[0]).toBeLessThan( + activity.settle.mock.invocationCallOrder[0]!, + ); + }); + it('measures receipt to delivery and excludes assistant persistence', async () => { vi.useFakeTimers(); vi.setSystemTime(1_000); @@ -3883,13 +3901,19 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { it('rethrows native prompt failures for platform event retry', async () => { mocks.generateText.mockRejectedValue(new Error('OpenCode unavailable')); + const activity = { + start: vi.fn(), + settle: vi.fn().mockResolvedValue(undefined), + }; await expect( answerFastAgentQuestion({ ...baseParams, turnSource: 'platform_event', - adapter: callbacks(), + adapter: callbacks({ activity }), }), ).rejects.toThrow('OpenCode unavailable'); + expect(activity.start).toHaveBeenCalledOnce(); + expect(activity.settle).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-title.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-title.test.ts index 823211aac..1e13478f4 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-title.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-title.test.ts @@ -119,7 +119,7 @@ describe('refreshFastAgentSessionTitle', () => { }); generateLlmTaskTitle.mockResolvedValue('Rotate the API keys'); - await refreshFastAgentSessionTitle({ + const refreshedTitle = await refreshFastAgentSessionTitle({ sessionId: conversation.id, userId: user.id, }); @@ -133,6 +133,7 @@ describe('refreshFastAgentSessionTitle', () => { expect(updated?.title).toBe('Rotate the API keys'); expect(updated?.llmTitleCheckpoint).toBe(1); expect(session?.title).toBe('Rotate the API keys'); + expect(refreshedTitle).toBe('Rotate the API keys'); expect(session?.llmTitleCheckpoint).toBe(1); expect(generateLlmTaskTitle).toHaveBeenCalledWith({ userId: user.id, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts index c5f8a3ab3..917147a6a 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation-repository.ts @@ -29,6 +29,7 @@ import type { FastAgentConversation } from './fast-agent-conversation'; export type FastAgentConversationRecord = { id: string; userId: string; + title: string | null; conversation: FastAgentConversation; /** * Durable visible history for cold starts and provider retries. OpenCode, @@ -296,6 +297,7 @@ async function loadConversationRecord( return { id: record.id, userId: record.userId, + title: record.title, conversation, compatibilityMessages: record.compatibilityMessages as ModelMessage[], openCodeSessionId: record.openCodeSessionId, diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts index 83561465b..12d926c9f 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-conversation.ts @@ -105,6 +105,12 @@ export type RetryFastAgentTaskStart = () => Promise< { success: true; runId: number } | { success: false; error: string } >; +export type FastAgentTurnActivity = { + start: () => void; + settle: () => Promise; + updateTitle?: (title: string | null) => void; +}; + export type FastAgentMcpServerConfig = { url: string; headers: Record; @@ -120,6 +126,7 @@ export type FastAgentTurnAdapter = { reply: FastAgentReply, ) => Promise; postReaction?: (reaction: FastAgentReaction) => Promise; + activity?: FastAgentTurnActivity; retryTaskStart?: RetryFastAgentTaskStart; resolveMcpServerConfigs?: () => Promise< Record diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index 016e83525..b5d36d086 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -1147,6 +1147,14 @@ export async function answerFastAgentQuestion({ return true; }; + try { + adapter.activity?.start(); + } catch (error) { + console.warn( + `[Fast Agent] Failed to start surface activity: ${formatErrorForLog(error)}`, + ); + } + try { if (!platformEvent) { turnVisibleMessages.push( @@ -1234,7 +1242,9 @@ export async function answerFastAgentQuestion({ platformEvent ? false : userMessageResult?.initialHumanTurn, ); if (!platformEvent) { - void refreshFastAgentSessionTitle({ sessionId: session.id, userId }); + void refreshFastAgentSessionTitle({ sessionId: session.id, userId }).then( + (title) => adapter.activity?.updateTitle?.(title), + ); } const sessionActiveTasks = await getActiveFastAgentTasks(session.id); const resolvedActiveTasks = [ @@ -2562,6 +2572,11 @@ export async function answerFastAgentQuestion({ }); } } + await adapter.activity?.settle().catch((error) => { + console.warn( + `[Fast Agent] Failed to settle surface activity: ${formatErrorForLog(error)}`, + ); + }); diagnostics.finish(); } } diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts index ed019cbcb..d488db67d 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-session.ts @@ -20,6 +20,7 @@ import type { type FastAgentSessionRecord = { id: string; + title: string | null; compatibilityMessages: ModelMessage[]; openCodeSessionId: string | null; created: boolean; diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts index 0e33c5b56..f03002006 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-title.ts @@ -175,7 +175,7 @@ export async function refreshFastAgentSessionTitle({ }: { sessionId: string; userId: string; -}): Promise { +}): Promise { try { const conversation = await db.query.fastAgentConversations.findFirst({ where: eq(fastAgentConversations.id, sessionId), @@ -186,8 +186,11 @@ export async function refreshFastAgentSessionTitle({ llmTitleCheckpoint: true, }, }); - if (!conversation || conversation.titleEditedByUserAt) { - return; + if (!conversation) { + return null; + } + if (conversation.titleEditedByUserAt) { + return conversation.title; } const rows = await db @@ -230,7 +233,7 @@ export async function refreshFastAgentSessionTitle({ checkpoint <= conversation.llmTitleCheckpoint || messages.length === 0 ) { - return; + return conversation.title; } const title = await generateLlmTaskTitle({ @@ -239,10 +242,10 @@ export async function refreshFastAgentSessionTitle({ messages, }); if (isFallbackTaskTitle(title)) { - return; + return conversation.title; } - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { // Re-read the conversation title under a row lock: the pre-generation // snapshot may be stale by now, and the session guard below must match // the title the session was actually seeded/synced from. @@ -251,7 +254,7 @@ export async function refreshFastAgentSessionTitle({ .from(fastAgentConversations) .where(eq(fastAgentConversations.id, sessionId)) .for('update'); - if (!current) return; + if (!current) return conversation.title; const [updatedConversation] = await tx .update(fastAgentConversations) @@ -264,7 +267,7 @@ export async function refreshFastAgentSessionTitle({ ), ) .returning({ id: fastAgentConversations.id }); - if (!updatedConversation) return; + if (!updatedConversation) return current.title; // Keep the unified Session's title in step with the generated // conversation title, but never clobber a manual Session rename: only @@ -293,10 +296,12 @@ export async function refreshFastAgentSessionTitle({ inArray(sessions.title, [...previousTitleCandidates]), ), ); + return title; }); } catch (error) { console.error( `[Fast Agent] Failed to refresh session title session=${sessionId}: ${formatErrorForLog(error)}`, ); + return null; } } diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 1fe5446b4..45e6643cb 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -29,6 +29,7 @@ import { Env, getArtifactSigningKey } from '@roomote/env'; import { buildSlackPrReviewActionBlocks, createFastAgentSlackLiveTaskLauncher, + createFastAgentSlackSessionActivity, postSlackThreadMessageWithFooterText, resolveSlackReactionNames, SlackNotifier, @@ -544,6 +545,12 @@ async function createSlackFastAgentParentTurn( userId: session.userId, conversation, adapter: { + activity: createFastAgentSlackSessionActivity({ + slack, + channel: conversation.replyTarget.channelId, + threadTs: conversation.replyTarget.threadId, + title: session.title, + }), launchTask: createFastAgentSlackLiveTaskLauncher({ slack, userId: session.userId, diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts index 5a7d0979a..e4fe2d915 100644 --- a/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ telegramPostMessage: vi.fn(), telegramEditMessage: vi.fn(), findTeamsConversationRoute: vi.fn(), + createActivity: vi.fn(() => ({ start: vi.fn(), settle: vi.fn() })), slackPostThreadMessage: vi.fn(), slackUpdateMessage: vi.fn(), })); @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@roomote/slack', () => ({ buildSlackThreadReplyFooterBlock: vi.fn(() => ({ type: 'context' })), createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), + createFastAgentSlackSessionActivity: mocks.createActivity, getSlackThreadReplyFooterMessageTs: vi.fn(async () => null), postSlackThreadMessageWithFooterText: mocks.slackPostThreadMessage, withSlackThreadReplyFooterLock: vi.fn( @@ -54,6 +56,7 @@ import { buildFastAgentSurfaceReplyDelivery } from './fast-agent-surface-reply'; async function createConversation(input: { userId: string; surface: 'web' | 'automation' | 'slack' | 'teams' | 'telegram'; + title?: string; replyTarget?: { channelId: string; threadId?: string }; }) { const [conversation] = await db @@ -63,6 +66,7 @@ async function createConversation(input: { surface: input.surface, workspaceId: `workspace-${input.surface}-${Date.now()}`, conversationId: `conversation-${input.surface}-${Date.now()}`, + title: input.title ?? null, currentReplyChannelId: input.replyTarget?.channelId ?? null, currentReplyThreadId: input.replyTarget?.threadId ?? null, }) @@ -202,6 +206,7 @@ describe('buildFastAgentSurfaceReplyDelivery', () => { const conversation = await createConversation({ userId: user.id, surface: 'slack', + title: 'Investigate Slack agent status', replyTarget: { channelId: 'C456', threadId: '1700000000.000200' }, }); await db.insert(slackInstallations).values({ @@ -230,6 +235,13 @@ describe('buildFastAgentSurfaceReplyDelivery', () => { message: 'Updated reply', }); + expect(mocks.createActivity).toHaveBeenCalledWith({ + slack: expect.anything(), + channel: 'C456', + threadTs: '1700000000.000200', + title: 'Investigate Slack agent status', + }); + await expect( db.query.fastAgentProviderMessages.findFirst({ where: and( diff --git a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts index 0df419b59..52b6ce687 100644 --- a/packages/sdk/src/server/lib/fast-agent-surface-reply.ts +++ b/packages/sdk/src/server/lib/fast-agent-surface-reply.ts @@ -25,6 +25,7 @@ import { } from '@roomote/communication'; import { createFastAgentSlackLiveTaskLauncher, + createFastAgentSlackSessionActivity, getSlackThreadReplyFooterMessageTs, postSlackThreadMessageWithFooterText, withSlackThreadReplyFooterLock, @@ -111,7 +112,7 @@ export type FastAgentSurfaceReplyDelivery = { conversation: FastAgentConversation; adapter: Pick< FastAgentTurnAdapter, - 'launchTask' | 'postReply' | 'replaceReply' + 'activity' | 'launchTask' | 'postReply' | 'replaceReply' >; }; @@ -226,6 +227,12 @@ export async function buildFastAgentSurfaceReplyDelivery(params: { return { conversation, adapter: { + activity: createFastAgentSlackSessionActivity({ + slack, + channel: conversation.replyTarget.channelId, + threadTs: conversation.replyTarget.threadId, + title: session.title, + }), launchTask: createFastAgentSlackLiveTaskLauncher({ slack, userId: params.userId, diff --git a/packages/slack/src/__tests__/fast-agent-session-activity.test.ts b/packages/slack/src/__tests__/fast-agent-session-activity.test.ts new file mode 100644 index 000000000..8c1b9d432 --- /dev/null +++ b/packages/slack/src/__tests__/fast-agent-session-activity.test.ts @@ -0,0 +1,242 @@ +import { + createFastAgentSlackSessionActivity, + FAST_AGENT_SLACK_PROCESSING_DELAY_MS, +} from '../fast-agent-session-activity'; + +describe('createFastAgentSlackSessionActivity', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('skips Slack activity when the turn settles before the delay', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi.fn(); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + }); + + activity.start(); + await activity.settle(); + await vi.runAllTimersAsync(); + + expect(setAgentSessionStatus).not.toHaveBeenCalled(); + expect(renameAgentSession).not.toHaveBeenCalled(); + }); + + it('creates an untitled session without sending a fallback title', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: ' ', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + await activity.settle(); + + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(1, { + channel: 'C123', + threadTs: '100.001', + status: 'processing', + }); + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(2, { + channel: 'C123', + threadTs: '100.001', + status: 'active', + }); + expect(renameAgentSession).not.toHaveBeenCalled(); + }); + + it('does not rename when Slack already has the Fast title', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + title: 'Investigate Slack agent status', + }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + await activity.settle(); + + expect(renameAgentSession).not.toHaveBeenCalled(); + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(2, { + channel: 'C123', + threadTs: '100.001', + status: 'active', + title: 'Investigate Slack agent status', + }); + }); + + it('does not rename when Slack omits the current title', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + await activity.settle(); + + expect(renameAgentSession).not.toHaveBeenCalled(); + }); + + it('bounds a persisted title to Slack’s 200-character limit', async () => { + vi.useFakeTimers(); + const persistedTitle = `Status ${'detail'.repeat(40)}`; + const title = persistedTitle.slice(0, 200); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: true, title }) + .mockResolvedValueOnce({ ok: true, title }); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: persistedTitle, + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + await activity.settle(); + + expect(setAgentSessionStatus).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title }), + ); + expect(setAgentSessionStatus).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ title }), + ); + expect(renameAgentSession).not.toHaveBeenCalled(); + }); + + it('renames an untitled session when its Fast title is generated', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: true, title: 'Slack default title' }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn().mockResolvedValue(true); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + activity.updateTitle?.('Generated Fast title'); + await activity.settle(); + + expect(renameAgentSession).toHaveBeenCalledWith({ + channel: 'C123', + threadTs: '100.001', + title: 'Generated Fast title', + }); + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(2, { + channel: 'C123', + threadTs: '100.001', + status: 'active', + title: 'Generated Fast title', + }); + }); + + it('renames an existing session before active cleanup when the title changed', async () => { + vi.useFakeTimers(); + let resolveRename!: (value: boolean) => void; + const rename = new Promise((resolve) => { + resolveRename = resolve; + }); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: true, title: 'Old title' }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn().mockReturnValue(rename); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + const settling = activity.settle(); + + expect(renameAgentSession).toHaveBeenCalledWith({ + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }); + expect(setAgentSessionStatus).toHaveBeenCalledTimes(1); + + resolveRename(true); + await settling; + + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(2, { + channel: 'C123', + threadTs: '100.001', + status: 'active', + title: 'Investigate Slack agent status', + }); + }); + + it('attempts active cleanup when processing is rejected', async () => { + vi.useFakeTimers(); + const setAgentSessionStatus = vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true }); + const renameAgentSession = vi.fn(); + const activity = createFastAgentSlackSessionActivity({ + slack: { renameAgentSession, setAgentSessionStatus }, + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }); + + activity.start(); + await vi.advanceTimersByTimeAsync(FAST_AGENT_SLACK_PROCESSING_DELAY_MS); + await activity.settle(); + + expect( + setAgentSessionStatus.mock.calls.map(([input]) => input.status), + ).toEqual(['processing', 'active']); + expect(renameAgentSession).not.toHaveBeenCalled(); + expect(setAgentSessionStatus).toHaveBeenNthCalledWith(2, { + channel: 'C123', + threadTs: '100.001', + status: 'active', + title: 'Investigate Slack agent status', + }); + }); +}); diff --git a/packages/slack/src/__tests__/slack-notifier.test.ts b/packages/slack/src/__tests__/slack-notifier.test.ts index a000f642a..ca2c02bf8 100644 --- a/packages/slack/src/__tests__/slack-notifier.test.ts +++ b/packages/slack/src/__tests__/slack-notifier.test.ts @@ -51,6 +51,64 @@ describe('SlackNotifier', () => { process.env.SLACK_API_BASE_URL = originalBaseUrl; }); + describe('setAgentSessionStatus', () => { + it('sets a titled agent session status through the Web API', async () => { + apiCallMock.mockResolvedValue({ + ok: true, + title: 'Investigate Slack agent status', + }); + + await expect( + notifier.setAgentSessionStatus({ + channel: 'C123', + threadTs: '100.001', + status: 'processing', + title: 'Investigate Slack agent status', + }), + ).resolves.toEqual({ + ok: true, + title: 'Investigate Slack agent status', + }); + + expect(apiCallMock).toHaveBeenCalledWith('agents.sessions.setStatus', { + channel_id: 'C123', + thread_ts: '100.001', + status: 'processing', + title: 'Investigate Slack agent status', + }); + }); + + it('treats Slack status rejections as best-effort failures', async () => { + apiCallMock.mockResolvedValue({ ok: false, error: 'feature_disabled' }); + + await expect( + notifier.setAgentSessionStatus({ + channel: 'C123', + threadTs: '100.001', + status: 'active', + }), + ).resolves.toEqual({ ok: false }); + }); + + it('renames an existing agent session through the Web API', async () => { + apiCallMock.mockResolvedValue({ ok: true }); + + await expect( + notifier.renameAgentSession({ + channel: 'C123', + threadTs: '100.001', + title: 'Investigate Slack agent status', + }), + ).resolves.toBe(true); + + expect(apiCallMock).toHaveBeenCalledWith('agents.sessions.rename', { + channel_id: 'C123', + thread_ts: '100.001', + title: 'Investigate Slack agent status', + }); + }); + }); + describe('getDirectMessageUserId', () => { it('returns the user for a one-to-one direct message', async () => { getGlobalWithFetch().fetch = vi.fn().mockResolvedValue({ diff --git a/packages/slack/src/fast-agent-session-activity.ts b/packages/slack/src/fast-agent-session-activity.ts new file mode 100644 index 000000000..54ebeec55 --- /dev/null +++ b/packages/slack/src/fast-agent-session-activity.ts @@ -0,0 +1,107 @@ +import type { FastAgentTurnActivity } from '@roomote/cloud-agents/server'; +import type { SlackNotifier } from './slack-notifier'; + +export const FAST_AGENT_SLACK_PROCESSING_DELAY_MS = 300; +const SLACK_AGENT_SESSION_TITLE_MAX_CHARS = 200; + +function normalizeSessionTitle(title: string | null | undefined) { + return title?.trim() + ? title.slice(0, SLACK_AGENT_SESSION_TITLE_MAX_CHARS) + : undefined; +} + +export function createFastAgentSlackSessionActivity({ + slack, + channel, + threadTs, + title, + delayMs = FAST_AGENT_SLACK_PROCESSING_DELAY_MS, +}: { + slack: Pick; + channel: string; + threadTs: string; + title?: string | null; + delayMs?: number; +}): FastAgentTurnActivity { + let sessionTitle = normalizeSessionTitle(title); + let slackTitle: string | undefined; + let processingTimer: ReturnType | undefined; + let processingUpdate: Promise | undefined; + let titleUpdate = Promise.resolve(); + let settled = false; + + const syncTitle = () => { + titleUpdate = titleUpdate.then(async () => { + if ( + !sessionTitle || + slackTitle === undefined || + slackTitle === sessionTitle + ) { + return; + } + if ( + await slack.renameAgentSession({ + channel, + threadTs, + title: sessionTitle, + }) + ) { + slackTitle = sessionTitle; + } + }); + return titleUpdate; + }; + + return { + start() { + if (processingTimer || processingUpdate || settled) return; + + processingTimer = setTimeout(() => { + processingTimer = undefined; + processingUpdate = (async () => { + const response = await slack.setAgentSessionStatus({ + channel, + threadTs, + status: 'processing', + ...(sessionTitle ? { title: sessionTitle } : {}), + }); + if (response.ok) { + slackTitle = response.title; + // Slack ignores setStatus.title after creation, so rename only + // when its response proves an existing session has another title. + await syncTitle(); + } + })(); + }, delayMs); + processingTimer.unref?.(); + }, + async settle() { + if (settled) return; + settled = true; + + if (processingTimer) { + clearTimeout(processingTimer); + processingTimer = undefined; + } + if (!processingUpdate) return; + + try { + await processingUpdate; + await syncTitle(); + } finally { + await slack.setAgentSessionStatus({ + channel, + threadTs, + status: 'active', + ...(sessionTitle ? { title: sessionTitle } : {}), + }); + } + }, + updateTitle(title) { + sessionTitle = normalizeSessionTitle(title); + if (processingUpdate) { + void processingUpdate.then(syncTitle); + } + }, + }; +} diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index afe3d7513..dccd95b82 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -3,6 +3,7 @@ export * from './communication-provider'; export * from './drain-slack-messages'; export * from './emoji-preferences'; export * from './fast-agent-live-task-launcher'; +export * from './fast-agent-session-activity'; export * from './fetch-task-data'; export * from './find-active-slack-task-run'; export * from './find-completed-slack-task-run-with-snapshot'; diff --git a/packages/slack/src/slack-notifier.ts b/packages/slack/src/slack-notifier.ts index 87ad27f4d..23dcbf008 100644 --- a/packages/slack/src/slack-notifier.ts +++ b/packages/slack/src/slack-notifier.ts @@ -236,6 +236,81 @@ export class SlackNotifier { return this.client; } + public async setAgentSessionStatus({ + channel, + threadTs, + status, + title, + }: { + channel: string; + threadTs: string; + status: 'active' | 'processing' | 'suspended' | 'closed'; + title?: string; + }): Promise<{ ok: boolean; title?: string }> { + try { + const response = await this.getClient().apiCall( + 'agents.sessions.setStatus', + { + channel_id: channel, + thread_ts: threadTs, + status, + ...(title ? { title } : {}), + }, + ); + if (response.ok) { + const responseTitle = (response as { title?: unknown }).title; + return { + ok: true, + ...(typeof responseTitle === 'string' + ? { title: responseTitle } + : {}), + }; + } + + console.warn( + `[setAgentSessionStatus] Slack rejected status=${status} channel=${channel} thread=${threadTs} error=${response.error ?? 'unknown_error'}`, + ); + return { ok: false }; + } catch (error) { + console.warn( + `[setAgentSessionStatus] Slack status=${status} failed for channel=${channel} thread=${threadTs}: ${error instanceof Error ? error.message : String(error)}`, + ); + return { ok: false }; + } + } + + public async renameAgentSession({ + channel, + threadTs, + title, + }: { + channel: string; + threadTs: string; + title: string; + }): Promise { + try { + const response = await this.getClient().apiCall( + 'agents.sessions.rename', + { + channel_id: channel, + thread_ts: threadTs, + title, + }, + ); + if (response.ok) return true; + + console.warn( + `[renameAgentSession] Slack rejected channel=${channel} thread=${threadTs} error=${response.error ?? 'unknown_error'}`, + ); + return false; + } catch (error) { + console.warn( + `[renameAgentSession] Slack rename failed for channel=${channel} thread=${threadTs}: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + private getChannelDiscovery(): SlackChannelDiscovery { if (!this.channelDiscovery) { this.channelDiscovery = new SlackChannelDiscovery(this.token); From 0a48451ddbfc13bf1818cd83c6866d508e542b7e Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:47:07 -0400 Subject: [PATCH 105/158] fix: hide dismissed PR review action offers (#1878) Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../FastSessionTranscript.client.test.tsx | 9 ++- .../[sessionId]/FastSessionTranscript.tsx | 17 ++--- .../[taskId]/messages/acp/AcpTextMessage.tsx | 24 +++--- .../__tests__/AcpTextMessage.client.test.tsx | 9 ++- .../pr-review-action-offer.test.tsx | 36 +++++++++ .../ai-elements/pr-review-action-offer.tsx | 73 +++++++++++-------- 6 files changed, 112 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/components/ai-elements/pr-review-action-offer.test.tsx diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 83dc0b73a..eb665dd01 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -425,14 +425,19 @@ describe('FastSessionTranscript', () => { ).toBeInTheDocument(); }); - it('renders retired and late-click states without actionable controls', async () => { + it('hides dismissed offers and renders late-click states without controls', async () => { const { rerender } = render( , ); - expect(screen.getByText('Review action dismissed.')).toBeInTheDocument(); + expect( + screen.queryByText('Would you like me to resolve these issues?'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('pr-review-action-offer'), + ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Resolve these issues' }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index ec0d196a3..0ca20d24b 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -405,18 +405,15 @@ export function FastSessionTranscript({ /> {pendingResponseAfter !== null ? : null} {reviewOffers.map((offer) => ( -
-

{offer.question}

- - handleReviewAction(offer.deliveryId, choice) - } - /> -
+ offer={offer} + showQuestion + onAction={(choice) => + handleReviewAction(offer.deliveryId, choice) + } + /> ))} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx index b26d235c9..dbd604109 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx @@ -98,18 +98,18 @@ function PrReviewNotificationActions({ msg }: { msg: AcpUiMessage }) { if (!offer) return null; return ( -
- { - const result = - await trpcClient.sandboxSession.handlePrReviewNotificationAction.mutate( - { deliveryId: offer.deliveryId, choice }, - ); - return result.status; - }} - /> -
+ { + const result = + await trpcClient.sandboxSession.handlePrReviewNotificationAction.mutate( + { deliveryId: offer.deliveryId, choice }, + ); + return result.status; + }} + /> ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx index 77d7e5a8b..c33b0bb7d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx @@ -166,10 +166,15 @@ describe('AcpTextMessage', () => { ).toBeVisible(); }); - it('renders a persisted retired offer without controls', () => { + it('does not render a persisted dismissed offer', () => { render(); - expect(screen.getByText('Review action dismissed.')).toBeVisible(); + expect( + screen.queryByTestId('pr-review-notification-actions'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Review action dismissed.'), + ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Resolve these issues' }), ).not.toBeInTheDocument(); diff --git a/apps/web/src/components/ai-elements/pr-review-action-offer.test.tsx b/apps/web/src/components/ai-elements/pr-review-action-offer.test.tsx new file mode 100644 index 000000000..67f54ef34 --- /dev/null +++ b/apps/web/src/components/ai-elements/pr-review-action-offer.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { PrReviewActionOffer } from './pr-review-action-offer'; + +const offer = { + deliveryId: '11111111-1111-4111-8111-111111111111', + question: 'Would you like me to resolve these issues?', + status: 'pending' as const, +}; + +describe('PrReviewActionOffer', () => { + it('does not render an offer with dismissed state', () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('removes the offer container after dismissal', async () => { + const onAction = vi.fn().mockResolvedValue('dismissed'); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + await waitFor(() => { + expect( + screen.queryByTestId('pr-review-action-offer'), + ).not.toBeInTheDocument(); + }); + expect(onAction).toHaveBeenCalledWith('dismiss'); + }); +}); diff --git a/apps/web/src/components/ai-elements/pr-review-action-offer.tsx b/apps/web/src/components/ai-elements/pr-review-action-offer.tsx index b91b199e8..5efba7473 100644 --- a/apps/web/src/components/ai-elements/pr-review-action-offer.tsx +++ b/apps/web/src/components/ai-elements/pr-review-action-offer.tsx @@ -7,39 +7,37 @@ import { } from '@roomote/types'; import { Button } from '@/components/system'; +import { cn } from '@/lib/utils'; const STATUS_TEXT: Record< - Exclude, + Exclude, string > = { resolved: 'Resolving the current review issues.', auto_resolved: 'Auto-resolve is enabled for this pull request.', - dismissed: 'Review action dismissed.', stale: 'This offer was already handled or has expired.', }; export function PrReviewActionOffer({ offer, onAction, + className, + showQuestion = false, + testId = 'pr-review-action-offer', }: { offer: PrReviewActionOfferData; onAction: ( choice: PrReviewActionChoice, ) => Promise; + className?: string; + showQuestion?: boolean; + testId?: string; }) { const [status, setStatus] = useState(offer.status); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => setStatus(offer.status), [offer.status]); - if (status !== 'pending') { - return ( -

- {STATUS_TEXT[status]} -

- ); - } - const submit = async (choice: PrReviewActionChoice) => { if (isSubmitting) return; setIsSubmitting(true); @@ -50,27 +48,42 @@ export function PrReviewActionOffer({ } }; + if (status === 'dismissed') return null; + return ( -
- - - +
+ {showQuestion ?

{offer.question}

: null} + {status === 'pending' ? ( +
+ + + +
+ ) : ( +

+ {STATUS_TEXT[status]} +

+ )}
); } From e7b402a180855c13bc822ed06f23bcbd33d02210 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:19:44 -0400 Subject: [PATCH 106/158] [Feat] Render HTML artifacts in the viewer (#1883) * feat: render HTML artifacts in viewer * fix: reset artifact preview mode on selection --------- Co-authored-by: @mrubens <2600+mrubens@users.noreply.github.com> --- .../ArtifactViewerContent.client.test.tsx | 167 ++++++++++++++++++ .../tasks/ArtifactViewerContent.tsx | 83 +++++++-- .../artifacts/__tests__/by-path.test.ts | 29 +++ .../src/trpc/commands/artifacts/by-path.ts | 16 +- 4 files changed, 279 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx index 2c2268df2..6029957ca 100644 --- a/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx +++ b/apps/web/src/components/tasks/ArtifactViewerContent.client.test.tsx @@ -224,6 +224,173 @@ import { import { toast } from 'sonner'; describe('ArtifactViewerContent', () => { + it.each([ + { + label: 'normalized content type', + path: 'reports/preview.bin', + contentType: 'TEXT/HTML; charset=UTF-8', + }, + { + label: 'path extension', + path: 'reports/preview.XHTML', + contentType: 'application/octet-stream', + }, + ])('detects HTML from $label', ({ path, contentType }) => { + render( + HTML preview', + }} + />, + ); + + expect(screen.getByTitle(`Preview of ${path}`)).toBeInTheDocument(); + }); + + it('renders HTML in a fully locked-down iframe by default', () => { + const content = '

Safe preview

'; + + render( + , + ); + + const preview = screen.getByTitle('Preview of reports/preview.html'); + expect(preview).toHaveAttribute('srcdoc', content); + expect(preview).toHaveAttribute('sandbox', ''); + expect(preview).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Code')).toBeInTheDocument(); + }); + + it('switches an HTML artifact between preview and code', () => { + const content = '
HTML source
'; + + render( + , + ); + + fireEvent.click(screen.getByLabelText('Code')); + + expect( + screen.queryByTitle('Preview of reports/preview.htm'), + ).not.toBeInTheDocument(); + expect(screen.getByText(content)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Preview')); + + expect( + screen.getByTitle('Preview of reports/preview.htm'), + ).toBeInTheDocument(); + }); + + it('resets HTML artifacts to preview when the path or version changes', () => { + const createHtmlArtifact = (path: string, version: number) => ({ + id: 'artifact-html', + taskId: 'task-1', + path, + version, + artifactType: 'general' as const, + contentType: 'text/html', + size: 128, + createdAt: new Date('2026-05-22T00:00:00.000Z'), + downloadUrl: 'https://example.test/preview.html', + content: `
${path} v${version}
`, + }); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByLabelText('Code')); + rerender( + , + ); + + expect( + screen.getByTitle('Preview of reports/second.html'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Code')); + rerender( + , + ); + + expect( + screen.getByTitle('Preview of reports/second.html'), + ).toBeInTheDocument(); + }); + + it('keeps non-HTML text artifacts in the existing code view', () => { + render( + , + ); + + expect(screen.getByText('Plain text content')).toBeInTheDocument(); + expect(screen.queryByText('Preview')).not.toBeInTheDocument(); + expect(screen.queryByText('Code')).not.toBeInTheDocument(); + expect(screen.queryByTitle(/Preview of/)).not.toBeInTheDocument(); + }); + it('does not render the internal artifact type in the toolbar', () => { render( = { xml: 'xml', html: 'html', htm: 'html', + xhtml: 'html', css: 'css', scss: 'scss', less: 'less', @@ -115,6 +116,20 @@ function getLanguageFromPath(path: string): BundledLanguage { return extensionToLanguage[ext] ?? ('plaintext' as BundledLanguage); } +function isHtmlArtifact(contentType: string, path: string): boolean { + const normalizedContentType = + contentType.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + const extension = path.split('.').pop()?.toLowerCase(); + + return ( + normalizedContentType === 'text/html' || + normalizedContentType === 'application/xhtml+xml' || + extension === 'html' || + extension === 'htm' || + extension === 'xhtml' + ); +} + /** * Build the prompt for a "Build this plan" task that implements a plan artifact. * @@ -205,6 +220,10 @@ export function ArtifactViewerContent({ prevLatestVersionRef.current = undefined; }, [artifact?.path]); + useEffect(() => { + setIsRaw(false); + }, [artifact?.path, artifact?.version]); + const latestVersion = versions[0]?.version; useEffect(() => { if (!artifact || !onVersionChange || !latestVersion) return; @@ -225,17 +244,26 @@ export function ArtifactViewerContent({ ); } + const isHTML = isHtmlArtifact(artifact.contentType, artifact.path); const isMarkdown = - artifact.contentType.includes('markdown') || artifact.path.endsWith('.md'); + !isHTML && + (artifact.contentType.includes('markdown') || + artifact.path.endsWith('.md')); const isImage = artifact.contentType.startsWith('image/'); const isVideo = artifact.contentType.startsWith('video/'); const isPDF = artifact.contentType === 'application/pdf'; const isText = - !isMarkdown && !isImage && !isVideo && !isPDF && !!artifact.content; + !isHTML && + !isMarkdown && + !isImage && + !isVideo && + !isPDF && + !!artifact.content; const language = getLanguageFromPath(artifact.path); const canRender = isText || + (isHTML && artifact.content) || (isMarkdown && artifact.content) || ((isImage || isVideo || isPDF) && artifact.downloadUrl); @@ -411,6 +439,27 @@ export function ArtifactViewerContent({ />
)} + {canRender && isHTML && ( +
+ + + +
+ )}
)} @@ -418,7 +467,7 @@ export function ArtifactViewerContent({
)} - {((isMarkdown && isRaw) || isText) && artifact.content && ( -
- -
+ {isHTML && !isRaw && artifact.content && ( +