diff --git a/.env.production.example b/.env.production.example index b1354fd8f..041299408 100644 --- a/.env.production.example +++ b/.env.production.example @@ -143,6 +143,9 @@ DEFAULT_COMPUTE_PROVIDER=docker # GITLAB_WEBHOOK_SIGNING_TOKEN= # Optional integrations. +# Set to true to prevent curated integrations from being configured or used. +# Existing connections remain stored and become available again once unset. +# R_CURATED_INTEGRATIONS_DISABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/.github/ISSUE_TEMPLATE/cookbook-recipe.yml b/.github/ISSUE_TEMPLATE/cookbook-recipe.yml new file mode 100644 index 000000000..8f12a7dba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/cookbook-recipe.yml @@ -0,0 +1,103 @@ +name: Cookbook recipe +description: Propose a reusable Roomote workflow for the public Cookbook. +title: "[Cookbook]: " +labels: ["documentation", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Use this form to propose a complete, reusable Roomote workflow for the Cookbook. + Start with the [contributing recipes guide](https://docs.roomote.dev/cookbook/template), + paste the completed recipe below, and keep credentials, customer data, and + private operational details out of the example. + - type: checkboxes + id: preflight + attributes: + label: Preflight + options: + - label: I searched the existing Cookbook and docs for a duplicate or closely related recipe. + required: true + - label: I removed secrets, tokens, private keys, customer data, and private repository details. + required: true + - label: I stated provider support, permission boundaries, and other limitations plainly. + required: true + - type: textarea + id: recipe + attributes: + label: Recipe + description: Paste the completed Markdown recipe from the contributing recipes guide. + placeholder: | + --- + title: Outcome-first recipe title + description: One sentence explaining the finished system and why it matters. + icon: lucide-icon-name + contributor: Name or team + contributor_url: https://example.com/contributor + contributor_company: Company + contributor_company_url: https://example.com/company + --- + + ## Overview + + Explain the recurring problem and the outcome this recipe creates. + + - **Trigger**: Trigger type + - **Setup time**: About N minutes + - **Requires**: Product access, integrations, permissions + - **Serves**: Teams or roles + - **Cooked By**: Name or team from Company + + ## Ingredients + + - Ingredient + + ## Steps + + 1. First step. + + ## Paste-ready configuration + + ```text + Configuration or prompt + ``` + + ## Variations + + - Variation + validations: + required: true + - type: textarea + id: validation + attributes: + label: Validation and limitations + description: Explain what you tested, which providers or integrations it requires, and any known limitations. + validations: + required: true + - type: input + id: contributor + attributes: + label: Contributor + description: Name the person or team contributing this recipe. + validations: + required: true + - type: input + id: contributor_url + attributes: + label: Contributor URL + description: Link to the contributor's public profile or website, if available. + validations: + required: false + - type: input + id: contributor_company + attributes: + label: Contributor company + description: Name of the contributor's company, if applicable. + validations: + required: false + - type: input + id: contributor_company_url + attributes: + label: Contributor company URL + description: Link to the contributor's company website, if available. + validations: + required: false diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index abfb42436..ab96df0f9 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -217,6 +217,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Verify a failed image pull preserves deployment metadata + run: bash deploy/host/tests/upgrade-failed-pull.sh - name: Restore an encrypted bundle onto empty volumes run: bash deploy/host/tests/backup-restore.integration.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..d568c003e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,44 @@ +name: Docs + +on: + push: + branches: [main, develop] + paths: + - "apps/docs/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/actions/setup-environment/**" + - ".github/workflows/docs.yml" + pull_request: + branches: [main, develop] + paths: + - "apps/docs/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - ".github/actions/setup-environment/**" + - ".github/workflows/docs.yml" + +permissions: + contents: read + +env: + NODE_VERSION: 24.13.1 + PNPM_VERSION: 10.29.3 + +jobs: + docs: + name: Validate docs + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Setup environment + uses: ./.github/actions/setup-environment + with: + frozen-lockfile: 'true' + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + - name: Check generated Cookbook index + run: pnpm --filter @roomote/docs check-cookbook-index + - name: Validate docs and links + run: pnpm --filter @roomote/docs check diff --git a/CHANGELOG.md b/CHANGELOG.md index 85736fb7d..ca6363986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.31.0 (2026-08-03) + +This release expands Amazon Bedrock and deployment controls, refreshes Automations and self-hosted licensing, and adds copyable Cookbook recipes for common workflows. + +### Highlights + +- Use native Amazon Bedrock Runtime models alongside Mantle, including regional inference profiles and supported reasoning settings. +- Find and manage custom and built-in automations through a redesigned, filterable Automations experience. +- Disable curated integrations through a deployment policy without deleting saved credentials or affecting other provider types. +- Copy ready-to-use Cookbook recipes for outage triage, support investigations, CI fixes, product updates, and scheduled maintenance. + +### Minor changes + +- Redesign Automations settings around scannable Custom, Enabled, and Available sections, with permalinked configuration dialogs, responsive custom automation controls, and category and provider-aware filtering. +- Let deployment operators disable the curated integrations catalog and its existing connections through an environment policy without deleting saved credentials or affecting other provider types. +- Let operators use native Amazon Bedrock Runtime models alongside Mantle, including regional inference profiles and supported reasoning settings, without changing existing Mantle configurations. +- Add direct Roomote Cloud purchase and renewal paths to self-hosted License settings, including low-seat and upcoming-expiry guidance for administrators. +- Show useful, dismissible product tips alongside task startup progress so users can discover Roomote capabilities while an environment boots. +- Add a copyable Roomote Cookbook with ready-to-use recipes for vendor outage triage, support investigations, CI failure fixes, product update newsletters, and scheduled maintenance. + +### Patch changes + +- Keep Azure Container Apps sandboxes suspended until Roomote deliberately wakes them, recover cleanly from leftover workers, and stop retrying runs whose sandboxes were deleted. Thanks to @tebieshi for contributing this improvement. +- Stop ChatGPT subscription connections from waiting forever on expired or blocked device codes, explain why authorization stopped, and offer a clean restart. Thanks to @daniel-lxs for contributing this improvement. +- Recommend Claude Sonnet 5 at medium reasoning for code review when operators apply supported provider presets, while retaining Opus for planning. +- Keep Discord task requests through account linking, focus onboarding on the required personal account connection, and preserve automation reply threads when tasks resume. +- Keep the Users settings invite list focused on links that still have uses remaining while retaining consumed invite records. +- Add a direct Personal settings shortcut to the signed-in user menu. +- Make ChatGPT, xAI, and GitHub Copilot device connections handle expiry, rate limits, restarts, and stale polling consistently, with clearer terminal errors across all three providers. Thanks to @daniel-lxs for contributing this improvement. +- Give sandbox providers more time to finish rate-limited starts before aborting, and offer a retry when a task start fails before producing output. +- Show terminal command output again in the web task view, with collapsible details and a copy button for easier inspection. +- Restore the previous deployment metadata and controller after a failed self-hosted image pull so operators can retry upgrades without breaking the next backup. +- Make UI proof reject obvious visual defects elsewhere in captured frames instead of accepting evidence that only satisfies the focal claim. + ## 0.30.2 (2026-08-03) This patch improves GitHub App recovery and Discord automation follow-ups, refreshes the DeepSeek recommendation, and retires the current experimental toggles. diff --git a/README.md b/README.md index 9455053b4..910ebfb28 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,8 @@ copy-pasting. or bring API keys from any supported provider. Switch models per task. No markup on tokens. - **Read every line.** The full source is here. Audit it, extend it, fork it. -- **No per-seat SaaS pricing.** Free for up to 10 users. Need more? Email - [help@roomote.dev](mailto:help@roomote.dev) for a license. +- **No per-seat SaaS pricing.** Free for up to 10 users. Need more? [Buy a + self-hosted license on Roomote Cloud](https://cloud.roomote.dev/sign-up). --- @@ -213,7 +213,9 @@ Need SSO, custom SLAs, or dedicated support? Get in touch: It is source-available under the [Fair Core License 1.0](LICENSE) (FCL-1.0-ALv2). You can read, modify, and self-host the code. Free for up to 10 users. Larger deployments need a license. Email -[help@roomote.dev](mailto:help@roomote.dev). After the license period, the code +[help@roomote.dev](mailto:help@roomote.dev) for teams larger than 100 users, or +[buy a self-hosted license on Roomote Cloud](https://cloud.roomote.dev/sign-up). +After the license period, the code converts to Apache 2.0. **How is this different from Cursor / Copilot / Claude Code?** @@ -244,7 +246,8 @@ GitHub, GitLab, Gitea, Azure DevOps, and Bitbucket Cloud. Connect one or many. Self-hosting is free for up to 10 registered users. You pay your own model provider for tokens (or use the models bundled with your ChatGPT subscription). Cloud starts at $49/mo, depending on total user count. -For larger teams, licenses are available by emailing +For 11–100 users, [buy a self-hosted license on Roomote +Cloud](https://cloud.roomote.dev/sign-up). For larger teams, email [help@roomote.dev](mailto:help@roomote.dev). **I already pay for ChatGPT Plus. Do I need an API key too?** @@ -281,7 +284,10 @@ Report vulnerabilities privately. See [SECURITY.md](SECURITY.md). ## License [Fair Core License 1.0 (FCL-1.0-ALv2)](LICENSE). Free for up to 10 users. -Licenses for larger deployments are available by emailing -[help@roomote.dev](mailto:help@roomote.dev). The license key functionality may -not be disabled or circumvented. [TRADEMARKS.md](TRADEMARKS.md) covers trademark -usage. +Licenses for 11–100 users are available through [Roomote +Cloud](https://cloud.roomote.dev/sign-up); larger teams can email +[help@roomote.dev](mailto:help@roomote.dev). Purchased licenses report their +current user count for subscription billing. Refresh purchased keys annually in +the Roomote Cloud portal, then replace the key in Settings or `R_LICENSE_KEY`. +The license key functionality may not be disabled or circumvented. +[TRADEMARKS.md](TRADEMARKS.md) covers trademark usage. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 48d5b16d5..9d49a38d1 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -943,15 +943,25 @@ Roomote is licensed under the Fair Core License 1.0 (FCL-1.0-ALv2, see registered user account counts toward the limit, whichever sign-in path or surface it uses. Removing a user frees their seat. -To go beyond 10 users, obtain a license key from the Roomote maintainers and -apply it in either of these ways: +To go beyond 10 users, [buy a self-hosted license on Roomote +Cloud](https://cloud.roomote.dev/sign-up) and apply its key in either of these +ways: - Enter it in **Settings → Users → License** as an admin, or - Set `R_LICENSE_KEY` in the deployment environment (for example `.env.production` / Compose). When set, the env var takes precedence over any key stored in Settings. -Keys are verified offline; your deployment never phones home. +Purchased keys are issued annually. While the subscription is active, refresh +the key in the Roomote Cloud portal and replace the value in Settings or +`R_LICENSE_KEY` before its displayed expiry date. Keys are verified offline; +usage reporting does not activate or validate them. + +Licensed deployments report the license ID and current registered-user count to +Roomote's Ping service for subscription billing, even when anonymous analytics +is disabled. Other instance statistics remain controlled by the anonymous +analytics setting. Contact [help@roomote.dev](mailto:help@roomote.dev) for teams +larger than 100 users. When a deployment is at its seat limit, existing users are unaffected — only new sign-ups are blocked until a seat is freed (Settings → Users → remove a 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 6418f000b..54c57fc0f 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 @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ scard: vi.fn(), ttl: vi.fn(), set: vi.fn(), + eval: vi.fn(), get: vi.fn(), del: vi.fn(), }, @@ -158,6 +159,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { mocks.redis.scard.mockResolvedValue(1); // Routing lock + nudge dedupe acquire successfully. mocks.redis.set.mockResolvedValue('OK'); + mocks.redis.eval.mockResolvedValue(1); mocks.redis.get.mockResolvedValue(null); mocks.redis.del.mockResolvedValue(1); mocks.getBackgroundAgentSettings.mockResolvedValue( @@ -304,6 +306,15 @@ describe('maybeHandleDiscordChannelAutoStart', () => { expect(mocks.postMessage.mock.calls[0]?.[0]?.text).toMatch( /\[Settings → Personal → Linked Accounts\]\([^)]+\/settings\/personal\)/, ); + expect(mocks.redis.eval).toHaveBeenCalledWith( + expect.stringContaining('current.receivedAt > ARGV[1]'), + 1, + 'discord:pending_account_link_task:discord-user-1', + '2026-07-17T15:00:00.000Z', + 'message-1', + expect.any(String), + String(10 * 60), + ); expect(mocks.startNewTask).not.toHaveBeenCalled(); expect(mocks.addReaction).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index d6bba85f3..eeda20be2 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -43,7 +43,9 @@ const mocks = vi.hoisted(() => ({ attachOutOfBand: vi.fn(), releaseOutOfBand: vi.fn(), redisSet: vi.fn(), + redisEval: vi.fn(), redisGet: vi.fn(), + redisGetdel: vi.fn(), redisDel: vi.fn(), buildContinuation: vi.fn(), releaseContinuation: vi.fn(), @@ -59,7 +61,9 @@ vi.mock('@roomote/redis', async (importOriginal) => { ...actual, getRedis: () => ({ set: mocks.redisSet, + eval: mocks.redisEval, get: mocks.redisGet, + getdel: mocks.redisGetdel, del: mocks.redisDel, }), }; @@ -257,7 +261,9 @@ describe('Discord Gateway event handler', () => { mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-msg-1' }); mocks.redisSet.mockResolvedValue('OK'); + mocks.redisEval.mockResolvedValue(1); mocks.redisGet.mockResolvedValue(null); + mocks.redisGetdel.mockResolvedValue(null); mocks.redisDel.mockResolvedValue(1); mocks.component.mockResolvedValue('handled'); mocks.channelAutoStart.mockResolvedValue(false); @@ -1793,6 +1799,183 @@ describe('Discord Gateway event handler', () => { ); }); + it('continues the most recent task request after linking', async () => { + mocks.findMappedUserId.mockResolvedValueOnce(null); + mocks.getChannel.mockImplementation(async (channelId: string) => + channelId === 'dm-1' + ? { id: 'dm-1', name: 'Direct message', type: 1 } + : { + id: 'channel-1', + guildId: 'guild-1', + name: 'general', + type: 0, + }, + ); + const originalEvent = envelope( + message({ + channel_id: 'channel-1', + guild_id: 'guild-1', + content: '<@bot-1> fix this', + mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], + }), + ); + + const unlinkedResponse = await postEvent(originalEvent); + + expect(unlinkedResponse.status).toBe(200); + const pendingTaskCall = mocks.redisEval.mock.calls.find( + ([, , key]) => key === 'discord:pending_account_link_task:discord-user-1', + ); + expect(pendingTaskCall?.slice(1, 6)).toEqual([ + 1, + 'discord:pending_account_link_task:discord-user-1', + originalEvent.receivedAt, + originalEvent.eventId, + expect.any(String), + ]); + expect(pendingTaskCall?.[6]).toBe(String(10 * 60)); + expect(JSON.parse(pendingTaskCall?.[5] as string)).toMatchObject( + originalEvent, + ); + + mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); + mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); + mocks.redisGetdel.mockResolvedValue(pendingTaskCall?.[5]); + const interaction = { + id: 'interaction-link', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { + name: 'link', + type: 1, + options: [{ name: 'code', type: 3, value: 'link-abcdefghijklmnop' }], + }, + }; + + const linkedResponse = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(linkedResponse.status).toBe(200); + expect(mocks.startNewTask).toHaveBeenCalledWith( + expect.objectContaining({ + requesterDiscordUserId: 'discord-user-1', + launchOwnerUserId: 'roomote-user-1', + queuedMessage: expect.objectContaining({ + text: 'fix this', + ts: 'message-1', + userId: 'roomote-user-1', + }), + channel: expect.objectContaining({ channelId: 'channel-1' }), + }), + ); + expect(mocks.reply).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: expect.stringContaining( + 'I also picked up your most recent task request.', + ), + }), + ); + }); + + it('restores the pending request and link code when continuation fails', async () => { + const originalEvent = envelope(message()); + mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); + mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); + mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); + mocks.startNewTask.mockRejectedValue(new Error('launch failed')); + const interaction = { + id: 'interaction-link', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { + name: 'link', + type: 1, + options: [{ name: 'code', type: 3, value: 'link-abcdefghijklmnop' }], + }, + }; + + const response = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(response.status).toBe(500); + expect(mocks.redisEval).toHaveBeenCalledWith( + expect.stringContaining('current.receivedAt > ARGV[1]'), + 1, + 'discord:pending_account_link_task:discord-user-1', + originalEvent.receivedAt, + originalEvent.eventId, + JSON.stringify(originalEvent), + String(10 * 60), + ); + expect(mocks.restoreLinkCode).toHaveBeenCalledWith( + 'link-abcdefghijklmnop', + 'roomote-user-1', + ); + }); + + it('replays a pending configured-channel request after linking', async () => { + const originalEvent = envelope( + message({ + channel_id: 'channel-1', + guild_id: 'guild-1', + content: 'A new bug report', + }), + ); + mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); + mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); + mocks.channelAutoStart.mockResolvedValue(true); + mocks.getChannel.mockImplementation(async (channelId: string) => + channelId === 'dm-1' + ? { id: 'dm-1', name: 'Direct message', type: 1 } + : { + id: 'channel-1', + guildId: 'guild-1', + name: 'bugs', + type: 0, + }, + ); + const interaction = { + id: 'interaction-link', + application_id: 'app-1', + type: 2, + token: 'interaction-token', + channel_id: 'dm-1', + user: { id: 'discord-user-1', username: 'matt' }, + data: { + name: 'link', + type: 1, + options: [{ name: 'code', type: 3, value: 'link-abcdefghijklmnop' }], + }, + }; + + const response = await postEvent( + envelope(interaction, 'INTERACTION_CREATE'), + ); + + expect(response.status).toBe(200); + expect(mocks.channelAutoStart).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining(originalEvent), + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: expect.stringContaining( + 'I also picked up your most recent task request.', + ), + }), + ); + }); + it('requires /link in a DM without consuming the one-shot code', async () => { mocks.getChannel.mockResolvedValue({ id: 'channel-1', diff --git a/apps/api/src/handlers/discord/__tests__/pending-account-link-task.test.ts b/apps/api/src/handlers/discord/__tests__/pending-account-link-task.test.ts new file mode 100644 index 000000000..a5bc803a1 --- /dev/null +++ b/apps/api/src/handlers/discord/__tests__/pending-account-link-task.test.ts @@ -0,0 +1,94 @@ +import type { DiscordGatewayEvent } from '@roomote/communication/discord-event'; + +const mocks = vi.hoisted(() => ({ + eval: vi.fn(), + getdel: vi.fn(), +})); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => mocks, +})); + +import { + claimPendingDiscordAccountLinkTask, + rememberPendingDiscordAccountLinkTask, +} from '../pending-account-link-task.js'; + +function event(id: string, receivedAt: string): DiscordGatewayEvent { + return { + eventId: id, + eventType: 'MESSAGE_CREATE', + payload: { + id, + channel_id: 'dm-1', + content: id, + author: { id: 'discord-user-1', username: 'matt' }, + mentions: [], + attachments: [], + }, + receivedAt, + }; +} + +describe('pending Discord account-link tasks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses an atomic timestamp comparison so an older completion cannot replace a newer request', async () => { + const stored = new Map(); + mocks.eval.mockImplementation( + async ( + script: string, + keyCount: number, + key: string, + receivedAt: string, + eventId: string, + serialized: string, + ttl: string, + ) => { + expect(script).toContain("redis.call('GET', KEYS[1])"); + expect(script).toContain('current.receivedAt > ARGV[1]'); + expect(script).toContain('current.eventId >= ARGV[2]'); + expect(keyCount).toBe(1); + expect(ttl).toBe(String(10 * 60)); + + const current = stored.get(key); + if ( + current && + (current.receivedAt > receivedAt || + (current.receivedAt === receivedAt && + BigInt(current.eventId) >= BigInt(eventId))) + ) { + return 0; + } + stored.set(key, JSON.parse(serialized) as DiscordGatewayEvent); + return 1; + }, + ); + const newer = event('200', '2026-08-03T12:00:01.000Z'); + const older = event('100', '2026-08-03T12:00:00.000Z'); + + await rememberPendingDiscordAccountLinkTask({ + discordUserId: 'discord-user-1', + event: newer, + }); + await rememberPendingDiscordAccountLinkTask({ + discordUserId: 'discord-user-1', + event: older, + }); + + expect( + stored.get('discord:pending_account_link_task:discord-user-1'), + ).toEqual(newer); + }); + + it('claims and parses the stored event', async () => { + const pending = event('200', '2026-08-03T12:00:01.000Z'); + mocks.getdel.mockResolvedValue(JSON.stringify(pending)); + + await expect( + claimPendingDiscordAccountLinkTask('discord-user-1'), + ).resolves.toEqual(pending); + }); +}); diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index c19164b42..2cff01b55 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -29,6 +29,7 @@ import { releaseAccountLinkDmSlot, } from './account-link.js'; import { processDiscordAttachments } from './attachments.js'; +import { rememberPendingDiscordAccountLinkTask } from './pending-account-link-task.js'; import { startNewDiscordTask } from './task-orchestration.js'; import { discordMetadataForChannel, @@ -214,6 +215,14 @@ export async function maybeHandleDiscordChannelAutoStart(input: { const mappedUserId = await findDiscordMappedUserId(message.author.id); if (!mappedUserId) { + await rememberPendingDiscordAccountLinkTask({ + discordUserId: message.author.id, + event, + }).catch((error) => { + apiLogger.warn( + `[DiscordChannelAutoStart] Failed to remember pending task for unlinked user ${message.author.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); await sendLinkNudgeBestEffort({ provider, discordUserId: message.author.id, diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index a01f26cfd..8730ed824 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -58,6 +58,10 @@ import { } from './auth.js'; import { handleDiscordComponentInteraction } from './callback-actions.js'; import { maybeHandleDiscordChannelAutoStart } from './channel-auto-start.js'; +import { + claimPendingDiscordAccountLinkTask, + rememberPendingDiscordAccountLinkTask, +} from './pending-account-link-task.js'; import { claimDiscordApiEvent, completeDiscordApiEvent, @@ -346,6 +350,28 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { await restoreDiscordLinkCode(command.code!, linkedUserId); throw error; } + const pendingTask = await claimPendingDiscordAccountLinkTask( + sender.id, + ).catch((error) => { + apiLogger.warn( + `[discord] Failed to claim pending task after linking user ${sender.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + }); + if (pendingTask) { + try { + await processDiscordGatewayEvent(pendingTask); + } catch (error) { + await Promise.allSettled([ + rememberPendingDiscordAccountLinkTask({ + discordUserId: sender.id, + event: pendingTask, + }), + restoreDiscordLinkCode(command.code!, linkedUserId), + ]); + throw error; + } + } await replyToDiscordEvent({ provider: resolved.provider, applicationId: resolved.applicationId, @@ -353,6 +379,9 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { interaction: interactionReplyContext(event), text: [ '✅ Linked! This Discord account is now connected to your Roomote account. Tasks you start here are attributed to you.', + ...(pendingTask + ? ['I also picked up your most recent task request.'] + : []), DISCORD_HELP_MESSAGE, ].join('\n\n'), ephemeral: true, @@ -453,6 +482,15 @@ async function processDiscordGatewayEvent(event: DiscordGatewayEvent) { }; } + await rememberPendingDiscordAccountLinkTask({ + discordUserId: sender.id, + event, + }).catch((error) => { + apiLogger.warn( + `[discord] Failed to remember pending task for unlinked user ${sender.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + await promptDiscordAccountLink({ provider: resolved.provider, applicationId: resolved.applicationId, diff --git a/apps/api/src/handlers/discord/pending-account-link-task.ts b/apps/api/src/handlers/discord/pending-account-link-task.ts new file mode 100644 index 000000000..ec613d938 --- /dev/null +++ b/apps/api/src/handlers/discord/pending-account-link-task.ts @@ -0,0 +1,69 @@ +import { + parseDiscordGatewayEvent, + type DiscordGatewayEvent, +} from '@roomote/communication/discord-event'; +import { getRedis } from '@roomote/redis'; + +const PENDING_ACCOUNT_LINK_TASK_PREFIX = 'discord:pending_account_link_task:'; +const PENDING_ACCOUNT_LINK_TASK_TTL_SECONDS = 10 * 60; + +const REMEMBER_PENDING_ACCOUNT_LINK_TASK_SCRIPT = ` +local raw = redis.call('GET', KEYS[1]) +if raw then + local ok, current = pcall(cjson.decode, raw) + if ok and type(current) == 'table' and type(current.receivedAt) == 'string' and type(current.eventId) == 'string' then + local currentIsNewer = current.receivedAt > ARGV[1] + if current.receivedAt == ARGV[1] then + local currentIdIsNumeric = string.match(current.eventId, '^%d+$') ~= nil + local incomingIdIsNumeric = string.match(ARGV[2], '^%d+$') ~= nil + if currentIdIsNumeric and incomingIdIsNumeric and string.len(current.eventId) ~= string.len(ARGV[2]) then + currentIsNewer = string.len(current.eventId) > string.len(ARGV[2]) + else + currentIsNewer = current.eventId >= ARGV[2] + end + end + if currentIsNewer then + return 0 + end + end +end +redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[4]) +return 1 +`; + +function pendingAccountLinkTaskKey(discordUserId: string): string { + return `${PENDING_ACCOUNT_LINK_TASK_PREFIX}${discordUserId}`; +} + +function parsePendingAccountLinkTask(value: string | null) { + if (!value) return null; + try { + const parsed = parseDiscordGatewayEvent(JSON.parse(value)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +export async function rememberPendingDiscordAccountLinkTask(input: { + discordUserId: string; + event: DiscordGatewayEvent; +}): Promise { + await getRedis().eval( + REMEMBER_PENDING_ACCOUNT_LINK_TASK_SCRIPT, + 1, + pendingAccountLinkTaskKey(input.discordUserId), + input.event.receivedAt, + input.event.eventId, + JSON.stringify(input.event), + PENDING_ACCOUNT_LINK_TASK_TTL_SECONDS.toString(), + ); +} + +export async function claimPendingDiscordAccountLinkTask( + discordUserId: string, +): Promise { + return parsePendingAccountLinkTask( + await getRedis().getdel(pendingAccountLinkTaskKey(discordUserId)), + ); +} diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index d05d701dd..e913f057d 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -115,6 +115,7 @@ describe('inference gateway', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); mockFindTaskRun.mockResolvedValue({ id: 42 }); mockGetGitHubCopilotAccessToken.mockResolvedValue(null); mockGetFreshXaiAccessToken.mockResolvedValue(null); @@ -431,6 +432,33 @@ describe('inference gateway', () => { expect(url).toBe('https://open.bigmodel.cn/api/paas/v4/chat/completions'); }); + it('preserves persisted Z.AI region precedence with a runtime API key', async () => { + vi.stubEnv('ZAI_API_KEY', 'runtime-provider-key'); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + return nameList.includes('ZAI_REGION') + ? 'china' + : 'runtime-provider-key'; + }, + ); + + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/zai/chat/completions', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://open.bigmodel.cn/api/paas/v4/chat/completions'); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith( + ['ZAI_REGION'], + {}, + ); + }); + it('proxies Z.AI Coding Plan to its international coding endpoint', async () => { const fetchMock = stubUpstreamFetch(); const response = await postMessages( @@ -662,6 +690,135 @@ describe('inference gateway', () => { ); }); + it('proxies native Bedrock Converse requests with bearer authentication', async () => { + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + return nameList.includes('AWS_REGION') + ? 'eu-west-1' + : 'provider-secret-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/amazon-bedrock/model/eu.anthropic.claude-sonnet-5/converse', + ); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://bedrock-runtime.eu-west-1.amazonaws.com/model/eu.anthropic.claude-sonnet-5/converse', + ); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer provider-secret-key', + ); + }); + + it('uses the persisted Bedrock region when the API key is persisted', async () => { + vi.stubEnv('AWS_REGION', 'us-west-2'); + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', ''); + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async ( + names: string | readonly string[], + options?: { runtimeEnv?: Record }, + ) => { + const nameList = typeof names === 'string' ? [names] : names; + + if (nameList.includes('AWS_REGION')) { + return options?.runtimeEnv ? 'eu-west-1' : 'us-west-2'; + } + + return 'provider-secret-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/amazon-bedrock/model/eu.anthropic.claude-sonnet-5/converse', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe( + 'https://bedrock-runtime.eu-west-1.amazonaws.com/model/eu.anthropic.claude-sonnet-5/converse', + ); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith( + ['AWS_REGION'], + { runtimeEnv: {} }, + ); + }); + + it('uses the runtime Bedrock region when the API key is configured at runtime', async () => { + vi.stubEnv('AWS_REGION', 'us-west-2'); + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', 'runtime-provider-key'); + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async ( + names: string | readonly string[], + options?: { runtimeEnv?: Record }, + ) => { + const nameList = typeof names === 'string' ? [names] : names; + + if (nameList.includes('AWS_REGION')) { + return options?.runtimeEnv ? 'eu-west-1' : 'us-west-2'; + } + + return 'runtime-provider-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/amazon-bedrock/model/us.anthropic.claude-sonnet-5/converse', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe( + 'https://bedrock-runtime.us-west-2.amazonaws.com/model/us.anthropic.claude-sonnet-5/converse', + ); + expect( + mockResolveModelProviderEnvValue.mock.calls.some(([names]) => + (typeof names === 'string' ? [names] : names).includes('AWS_REGION'), + ), + ).toBe(false); + }); + + it('uses the default Bedrock region when a runtime API key has no runtime region', async () => { + vi.stubEnv('AWS_REGION', ''); + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', 'runtime-provider-key'); + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + return nameList.includes('AWS_REGION') + ? 'eu-west-1' + : 'runtime-provider-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/amazon-bedrock/model/anthropic.claude-sonnet-5/converse', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe( + 'https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-sonnet-5/converse', + ); + expect( + mockResolveModelProviderEnvValue.mock.calls.some(([names]) => + (typeof names === 'string' ? [names] : names).includes('AWS_REGION'), + ), + ).toBe(false); + }); + it('proxies Bedrock Mantle OpenAI Responses requests', async () => { const fetchMock = stubUpstreamFetch(); const response = await postMessages( diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index 3df354a8e..27e8808c7 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -54,9 +54,21 @@ export async function resolveGatewayUpstream( return resolveXaiUpstream(provider, upstreamPath, search); } + const requiresSourceCoupledRegion = + provider.region?.envVarName === 'AWS_REGION' && + provider.envVarNames.includes('AWS_BEARER_TOKEN_BEDROCK'); + const hasRuntimeApiKey = + requiresSourceCoupledRegion && + provider.envVarNames.some((envVarName) => process.env[envVarName]?.trim()); const [apiKey, upstreamBaseUrl] = await Promise.all([ resolveModelProviderEnvValue(provider.envVarNames), - resolveProviderUpstreamBaseUrl(provider), + resolveProviderUpstreamBaseUrl(provider, { + regionSource: requiresSourceCoupledRegion + ? hasRuntimeApiKey + ? 'runtime' + : 'persisted' + : undefined, + }), ]); if (!apiKey && !provider.optionalApiKey) { @@ -236,6 +248,7 @@ function hasTraversalOrEncodedSlash(upstreamPath: string): boolean { */ async function resolveProviderUpstreamBaseUrl( provider: InferenceGatewayProvider, + options: { regionSource?: 'runtime' | 'persisted' } = {}, ): Promise { if (provider.upstreamBaseUrlEnvVarName) { const configuredBaseUrl = await resolveModelProviderEnvValue([ @@ -281,8 +294,12 @@ async function resolveProviderUpstreamBaseUrl( } const region = - (await resolveModelProviderEnvValue([provider.region.envVarName])) ?? - provider.region.default; + (options.regionSource === 'runtime' + ? process.env[provider.region.envVarName]?.trim() || undefined + : await resolveModelProviderEnvValue( + [provider.region.envVarName], + options.regionSource === 'persisted' ? { runtimeEnv: {} } : {}, + )) ?? provider.region.default; // Providers with discrete regional hosts select a base outright; the // `{region}` template and its cloud-region pattern do not apply to them. diff --git a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts index 0325079b0..ca119b35e 100644 --- a/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts +++ b/apps/api/src/handlers/linear/__tests__/linear-active-run-priority.test.ts @@ -16,6 +16,13 @@ const { createLinearAgentRunMock } = vi.hoisted(() => ({ .mockResolvedValue({ status: 'ok', runId: 77, taskId: 'task-77' }), })); +const envState = vi.hoisted(() => ({ + R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret', + R_APP_URL: 'https://app.roomote.example', + PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', + R_CURATED_INTEGRATIONS_DISABLED: false, +})); + const { findLinearDeploymentMcpConnectionByIdentityMock, findLinearUserMcpConnectionByIdentityMock, @@ -40,11 +47,7 @@ vi.mock('@roomote/env', async (importOriginal) => { return { ...actual, - Env: { - R_LINEAR_WEBHOOK_SECRET: 'test-linear-secret', - R_APP_URL: 'https://app.roomote.example', - PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.example', - }, + Env: envState, }; }); @@ -276,6 +279,24 @@ describe('CLO-1133: active task run takes priority over routing confirmation and app = new Hono(); app.route('/linear', linear); vi.clearAllMocks(); + envState.R_CURATED_INTEGRATIONS_DISABLED = false; + }); + + it('acknowledges without processing when curated integrations are disabled', async () => { + envState.R_CURATED_INTEGRATIONS_DISABLED = true; + const { rawBody, headers } = createSignedRequest(makePayload()); + + const response = await app.request('/linear', { + method: 'POST', + headers, + body: rawBody, + }); + + expect(response.status).toBe(204); + expect(createLinearAgentRunMock).not.toHaveBeenCalled(); + expect( + findLinearDeploymentMcpConnectionByIdentityMock, + ).not.toHaveBeenCalled(); }); it('delivers free-text reply to active task run even when routing confirmation key exists in Redis', async () => { diff --git a/apps/api/src/handlers/linear/index.ts b/apps/api/src/handlers/linear/index.ts index 88dbe8533..f6a7cea4d 100644 --- a/apps/api/src/handlers/linear/index.ts +++ b/apps/api/src/handlers/linear/index.ts @@ -12,7 +12,7 @@ import { PRODUCT_NAME, restoreSnapshotResumeVisiblePromptFields, } from '@roomote/types'; -import { Env } from '@roomote/env'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { type RoutingDebugInfo, enqueueTask, @@ -254,6 +254,10 @@ export const linear = new Hono(); * The handler must emit a "thought" activity within 10 seconds to acknowledge receipt. */ linear.post('/', async (c) => { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return c.body(null, 204); + } + const headers = c.req.header(); const rawBody = await c.req.text(); diff --git a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts index 8f7fd6347..aaf6ada5c 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts @@ -304,6 +304,47 @@ describe('maybeSendCommunicationThreadReply (Discord)', () => { expect(discordCreateThreadFromMessageMock).not.toHaveBeenCalled(); }); + it('recovers a missing automation thread from its saved root message', async () => { + getTaskAutomationInitiatorKeyMock.mockResolvedValue('custom_automation'); + + await maybeSendCommunicationThreadReply({ + taskRun: { + ...discordTaskRun, + payload: { + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationMessageId: 'report-root', + }, + }, + parsedBody: { text: 'Follow-up result', images: [] }, + }); + + expect(discordPostMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'report-root' }), + ); + expect(discordCreateThreadFromMessageMock).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'report-root', + name: 'Follow-up result', + }); + expect( + sqlMock.mock.calls.map(([strings, ...values]) => ({ + text: Array.from(strings as string[]).join('?'), + values, + })), + ).toContainEqual( + expect.objectContaining({ + text: expect.stringContaining("communicationThreadId' IS NULL"), + values: expect.arrayContaining(['report-root']), + }), + ); + expect(sqlMock.mock.calls.flatMap((call) => call.slice(1))).toContainEqual( + expect.stringContaining( + '"communicationThreadId":"automation-thread-1","discordTaskThread":true', + ), + ); + }); + it('attaches to the investigating opener when only a root message id is present', async () => { const response = await maybeSendCommunicationThreadReply({ taskRun: { diff --git a/apps/api/src/handlers/mcp/communication-thread-replies.ts b/apps/api/src/handlers/mcp/communication-thread-replies.ts index 399b3eacf..a3c5de2bd 100644 --- a/apps/api/src/handlers/mcp/communication-thread-replies.ts +++ b/apps/api/src/handlers/mcp/communication-thread-replies.ts @@ -237,6 +237,15 @@ async function bindLateCommunicationReportThread(params: { } : {}), }); + const unboundReportCondition = discordThread + ? sql`( + ${taskRuns.payload}->>'communicationMessageId' IS NULL + OR ( + ${taskRuns.payload}->>'communicationMessageId' = ${params.messageId} + AND ${taskRuns.payload}->>'communicationThreadId' IS NULL + ) + )` + : sql`${taskRuns.payload}->>'communicationMessageId' IS NULL`; await db.transaction(async (tx) => { const boundRuns = await tx .update(taskRuns) @@ -244,10 +253,7 @@ async function bindLateCommunicationReportThread(params: { payload: sql`coalesce(${taskRuns.payload}, '{}'::jsonb) || ${patch}::jsonb`, }) .where( - and( - eq(taskRuns.taskId, params.taskRun.taskId), - sql`${taskRuns.payload}->>'communicationMessageId' IS NULL`, - ), + and(eq(taskRuns.taskId, params.taskRun.taskId), unboundReportCondition), ) .returning({ id: taskRuns.id }); @@ -695,8 +701,8 @@ async function sendDiscordThreadReply(params: { await bindLateCommunicationReportThread({ taskRun: params.taskRun, provider: 'discord', - messageId: reply.messageId, - ...(!threadId && !messageId + messageId: messageId ?? reply.messageId, + ...(!threadId ? { discordProvider: provider, ...(text ? { discordThreadName: text } : {}), diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index 9ede9cd0b..b71b3b316 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -1,4 +1,5 @@ -import { Hono } from 'hono'; +import { Hono, type MiddlewareHandler } from 'hono'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import { isNativeMcpIntegration, MCP_INTEGRATIONS } from '@roomote/types'; import type { Variables } from '../../types'; @@ -19,6 +20,21 @@ import { vercelMcp } from './vercel'; export const mcp = new Hono<{ Variables: Variables }>(); +const requireCuratedIntegrations: MiddlewareHandler<{ + Variables: Variables; +}> = async (c, next) => { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return c.notFound(); + } + + await next(); +}; + +for (const integration of MCP_INTEGRATIONS) { + mcp.use(`/${integration.id}`, requireCuratedIntegrations); + mcp.use(`/${integration.id}/*`, requireCuratedIntegrations); +} + mcp.route('/asana', asanaMcp); mcp.route('/grafana', grafanaMcp); mcp.route('/linear', linearMcp); diff --git a/apps/api/src/handlers/mcp/routing.ts b/apps/api/src/handlers/mcp/routing.ts index 5b41be4f3..3210a0e86 100644 --- a/apps/api/src/handlers/mcp/routing.ts +++ b/apps/api/src/handlers/mcp/routing.ts @@ -1,4 +1,5 @@ -import { Hono } from 'hono'; +import { Hono, type MiddlewareHandler } from 'hono'; +import { Env, areCuratedIntegrationsDisabled } from '@roomote/env'; import type { Variables } from '../../types'; @@ -15,7 +16,19 @@ import { roomoteMcp } from './roomote'; */ export const mcpRouting = new Hono<{ Variables: Variables }>(); +const requireCuratedIntegrations: MiddlewareHandler<{ + Variables: Variables; +}> = async (c, next) => { + if (areCuratedIntegrationsDisabled(Env.R_CURATED_INTEGRATIONS_DISABLED)) { + return c.notFound(); + } + + await next(); +}; + mcpRouting.route('/roomote', roomoteMcp); +mcpRouting.use('/linear', requireCuratedIntegrations); +mcpRouting.use('/linear/*', requireCuratedIntegrations); mcpRouting.route( '/linear', createLinearMcp({ diff --git a/apps/bullmq/src/jobs/snapshot.test.ts b/apps/bullmq/src/jobs/snapshot.test.ts index 198d05f0f..8de73eed4 100644 --- a/apps/bullmq/src/jobs/snapshot.test.ts +++ b/apps/bullmq/src/jobs/snapshot.test.ts @@ -33,7 +33,7 @@ const { setFn, updateFn, } = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint; ESLint's own rule is offloaded and reports this directive as unused, which is a false positive type AnyMock = Mock<(...args: any[]) => any>; const andFn: AnyMock = vi.fn(() => 'and-condition'); diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts index 5e32b0f21..1ec24397c 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sleep-check.test.ts @@ -35,14 +35,14 @@ const { selectFn, inArrayFn, } = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint; ESLint's own rule is offloaded and reports this directive as unused, which is a false positive type AnyMock = Mock<(...args: any[]) => any>; const returningFn: AnyMock = vi.fn(() => Promise.resolve([])); const updateWhereFn: AnyMock = vi.fn(() => { const result = Promise.resolve([]); // Support both .returning() (optimistic lock) and .catch() (rollback) call patterns. - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint (see above) (result as any).returning = returningFn; return result; }); @@ -109,6 +109,18 @@ vi.mock('@roomote/compute-providers', () => ({ mockGetComputeProviderCapabilities(...args) ?? { supportsSnapshots: true, }, + // Mirrors the real adapter error: sleep-check finalizes runs only on a + // definitive 404 from this class. + AzureDataPlaneError: class AzureDataPlaneError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string, + ) { + super(message); + this.name = 'AzureDataPlaneError'; + } + }, })); vi.mock('@roomote/sdk/server', () => ({ @@ -336,7 +348,7 @@ describe('sleepCheckJob', () => { ); updateWhereFn.mockImplementation(() => { const result = Promise.resolve([]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint (see above) (result as any).returning = returningFn; return result; }); @@ -894,6 +906,107 @@ describe('sleepCheckJob', () => { ); }); + it('finalizes a run whose azure instance no longer exists instead of retrying forever', async () => { + const mockJob = { + id: 100, + machineId: 'sb-gone', + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Running, + taskPhase: 'waiting_for_prompt', + vendor: 'azure', + snapshotId: null, + sleepRequestedAt: null, + snapshotRequestedAt: null, + sleepAt: new Date(Date.now() + 30 * 60 * 1_000), + }; + + mockJobQueries({ hardLimitJobs: [mockJob] }); + const { AzureDataPlaneError } = await import('@roomote/compute-providers'); + mockGetInstanceStatus.mockRejectedValue( + new AzureDataPlaneError('Requested document not found.', 404), + ); + + await sleepCheckJob(); + + expect(mockGetInstanceStatus).toHaveBeenCalledWith({ + instanceId: 'sb-gone', + }); + expect(mockFinishRun).toHaveBeenCalledWith({ + id: 100, + status: RunStatus.Failed, + error: expect.stringContaining('no longer exists'), + }); + // No claim-clearing rollback: the generic retry path must not run, or + // the run would be re-evaluated (and fail identically) every minute. + expect(setFn).not.toHaveBeenCalledWith({ + sleepRequestedAt: null, + snapshotRequestedAt: null, + }); + expect(mockDestroyInstance).not.toHaveBeenCalled(); + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); + + it('completes an idle run whose azure instance no longer exists', async () => { + mockJobQueries({ + dueJobs: [ + { + id: 42, + machineId: 'sb-gone-idle', + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Idle, + vendor: 'azure', + snapshotId: null, + sleepRequestedAt: null, + snapshotRequestedAt: null, + }, + ], + }); + const { AzureDataPlaneError } = await import('@roomote/compute-providers'); + mockGetInstanceStatus.mockRejectedValue( + new AzureDataPlaneError('Requested document not found.', 404), + ); + returningFn.mockResolvedValue([{ id: 42 }]); + + await sleepCheckJob(); + + expect(mockFinishRun).toHaveBeenCalledWith({ + id: 42, + status: RunStatus.Completed, + error: 'Instance sb-gone-idle no longer exists.', + }); + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); + + it('keeps retrying on transient provider errors instead of finalizing', async () => { + const mockJob = { + id: 101, + machineId: 'sb-flaky', + payloadKind: TaskPayloadKind.StandardTask, + status: RunStatus.Running, + taskPhase: 'waiting_for_prompt', + vendor: 'azure', + snapshotId: null, + sleepRequestedAt: null, + snapshotRequestedAt: null, + sleepAt: new Date(Date.now() + 30 * 60 * 1_000), + }; + + mockJobQueries({ hardLimitJobs: [mockJob] }); + const { AzureDataPlaneError } = await import('@roomote/compute-providers'); + mockGetInstanceStatus.mockRejectedValue( + new AzureDataPlaneError('Upstream timeout.', 502), + ); + + await sleepCheckJob(); + + // Transient error: generic catch clears claims so the next tick retries. + expect(mockFinishRun).not.toHaveBeenCalled(); + expect(setFn).toHaveBeenLastCalledWith({ + sleepRequestedAt: null, + snapshotRequestedAt: null, + }); + }); + it('skips job when optimistic lock fails', async () => { const mockJob = { id: 77, diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/standby-retention.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/standby-retention.test.ts new file mode 100644 index 000000000..10e8a7582 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/standby-retention.test.ts @@ -0,0 +1,233 @@ +import type { Mock } from 'vitest'; + +const { + mockCreateComputeProviderClient, + mockGetInstanceStatus, + mockEnterStandby, + mockDestroyInstance, + mockResolveComputeProviderEnvValues, + selectResultsQueue, + selectFn, + updateFn, +} = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint; ESLint's own rule is offloaded and reports this directive as unused, which is a false positive + type AnyMock = Mock<(...args: any[]) => any>; + + // Each db.select(...) call consumes one entry; entries resolve in call + // order. getCandidates chains .orderBy(...), the other queries await the + // where-result directly, so both shapes must resolve to the same rows. + const selectResultsQueue: { current: unknown[][] } = { current: [] }; + + const makeQueryResult = (rows: unknown[]) => { + const result = Promise.resolve(rows); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- suppressed for oxlint (see above) + (result as any).orderBy = () => Promise.resolve(rows); + return result; + }; + + const whereFn: AnyMock = vi.fn(() => + makeQueryResult(selectResultsQueue.current.shift() ?? []), + ); + const fromFn: AnyMock = vi.fn(() => ({ where: whereFn })); + const selectFn: AnyMock = vi.fn(() => ({ from: fromFn })); + + const updateWhereFn: AnyMock = vi.fn(() => Promise.resolve([])); + const setFn: AnyMock = vi.fn(() => ({ where: updateWhereFn })); + const updateFn: AnyMock = vi.fn(() => ({ set: setFn })); + + return { + mockCreateComputeProviderClient: vi.fn() as AnyMock, + mockGetInstanceStatus: vi.fn() as AnyMock, + mockEnterStandby: vi.fn() as AnyMock, + mockDestroyInstance: vi.fn() as AnyMock, + mockResolveComputeProviderEnvValues: vi.fn() as AnyMock, + selectResultsQueue, + selectFn, + updateFn, + }; +}); + +vi.mock('@roomote/db/server', () => ({ + db: { + select: selectFn, + update: updateFn, + }, + taskRuns: { + id: 'id', + taskId: 'taskId', + vendor: 'vendor', + status: 'status', + machineId: 'machineId', + snapshotId: 'snapshotId', + snapshotCreatedAt: 'snapshotCreatedAt', + sourceSnapshotId: 'sourceSnapshotId', + }, + and: vi.fn(), + desc: vi.fn(), + eq: vi.fn(), + inArray: vi.fn(), + isNotNull: vi.fn(), + or: vi.fn(), + resolveComputeProviderEnvValues: mockResolveComputeProviderEnvValues, +})); + +vi.mock('@roomote/compute-providers', () => ({ + createComputeProviderClient: mockCreateComputeProviderClient, +})); + +// Import after mocks are set up. +import { standbyRetentionJob } from '../standby-retention'; + +/** + * standbyRetentionJob iterates docker, blaxel, azure. Each provider's + * eviction pass runs two selects (candidates, protected handles); azure + * additionally runs the orphan re-suspend pass (candidates, protected + * handles, in-use handles). Queue entries in consumption order. + */ +function queueSelectResults({ + azureReSuspendCandidates = [], + azureReSuspendProtected = [], + azureReSuspendInUse = [], +}: { + azureReSuspendCandidates?: unknown[]; + azureReSuspendProtected?: unknown[]; + azureReSuspendInUse?: unknown[]; +}) { + selectResultsQueue.current = [ + [], // docker eviction candidates + [], // docker eviction protected + [], // blaxel eviction candidates + [], // blaxel eviction protected + [], // azure eviction candidates + [], // azure eviction protected + azureReSuspendCandidates, + azureReSuspendProtected, + azureReSuspendInUse, + ]; +} + +describe('standbyRetentionJob orphan re-suspend', () => { + let logSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + selectResultsQueue.current = []; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + mockResolveComputeProviderEnvValues.mockResolvedValue({}); + mockCreateComputeProviderClient.mockReturnValue({ + destroyInstance: mockDestroyInstance, + getInstanceStatus: mockGetInstanceStatus, + enterStandby: mockEnterStandby, + }); + mockGetInstanceStatus.mockResolvedValue({ status: 'running' }); + mockEnterStandby.mockResolvedValue({ resumeHandle: 'sb-1' }); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('re-suspends a retained azure standby found Running with no managing run', async () => { + queueSelectResults({ + azureReSuspendCandidates: [ + { + runId: 32, + taskId: 'task-1', + provider: 'azure', + handle: 'sb-1', + createdAt: new Date(), + }, + ], + }); + + await standbyRetentionJob(); + + expect(mockGetInstanceStatus).toHaveBeenCalledWith({ + instanceId: 'sb-1', + }); + expect(mockEnterStandby).toHaveBeenCalledWith({ instanceId: 'sb-1' }); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('re-suspended orphaned azure standby sb-1'), + ); + }); + + it('skips retained handles owned by an active wake session', async () => { + queueSelectResults({ + azureReSuspendCandidates: [ + { + runId: 32, + taskId: 'task-1', + provider: 'azure', + handle: 'sb-1', + createdAt: new Date(), + }, + ], + azureReSuspendInUse: [{ machineId: 'sb-1', snapshotId: null }], + }); + + await standbyRetentionJob(); + + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); + + it('skips handles protected by an active resume run', async () => { + queueSelectResults({ + azureReSuspendCandidates: [ + { + runId: 32, + taskId: 'task-1', + provider: 'azure', + handle: 'sb-1', + createdAt: new Date(), + }, + ], + azureReSuspendProtected: [{ handle: 'sb-1' }], + }); + + await standbyRetentionJob(); + + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); + + it('skips handles that are not live instances (genuine snapshot ids)', async () => { + queueSelectResults({ + azureReSuspendCandidates: [ + { + runId: 32, + taskId: 'task-1', + provider: 'azure', + handle: 'snap-1', + createdAt: new Date(), + }, + ], + }); + mockGetInstanceStatus.mockRejectedValue(new Error('not found')); + + await standbyRetentionJob(); + + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); + + it('does not touch retained instances that are already suspended', async () => { + queueSelectResults({ + azureReSuspendCandidates: [ + { + runId: 32, + taskId: 'task-1', + provider: 'azure', + handle: 'sb-1', + createdAt: new Date(), + }, + ], + }); + mockGetInstanceStatus.mockResolvedValue({ status: 'stopped' }); + + await standbyRetentionJob(); + + expect(mockEnterStandby).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts index 0ac7585ef..31167ca45 100644 --- a/apps/bullmq/src/scheduled-jobs/index.ts +++ b/apps/bullmq/src/scheduled-jobs/index.ts @@ -3,5 +3,6 @@ export { sleepCheckJob } from './sleep-check'; export { refreshSnapshotsJob } from './refresh-snapshots'; export { pullRequestAnalyticsSyncJob } from './pull-request-analytics-sync'; export { instancePingJob } from './instance-ping'; +export { licenseUsageSyncJob } from './license-usage-sync'; export { webhookCleanupJob } from './webhook-cleanup'; export { standbyRetentionJob } from './standby-retention'; diff --git a/apps/bullmq/src/scheduled-jobs/instance-ping.test.ts b/apps/bullmq/src/scheduled-jobs/instance-ping.test.ts new file mode 100644 index 000000000..51fa2b156 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/instance-ping.test.ts @@ -0,0 +1,82 @@ +const mocks = vi.hoisted(() => ({ + checkLatestVersion: vi.fn(), + collectInstanceReportStats: vi.fn(), + collectLicensedUserCount: vi.fn(), + getDeploymentLicenseState: vi.fn(), + isAnonymousAnalyticsEnabled: vi.fn(), + isTelemetryEnvAllowed: vi.fn(), + recordLatestKnownVersion: vi.fn(), + sendInstanceReport: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + collectInstanceReportStats: mocks.collectInstanceReportStats, + collectLicensedUserCount: mocks.collectLicensedUserCount, + getDeploymentLicenseState: mocks.getDeploymentLicenseState, + recordLatestKnownVersion: mocks.recordLatestKnownVersion, +})); + +vi.mock('@roomote/telemetry/server', () => ({ + checkLatestVersion: mocks.checkLatestVersion, + isAnonymousAnalyticsEnabled: mocks.isAnonymousAnalyticsEnabled, + isTelemetryEnvAllowed: mocks.isTelemetryEnvAllowed, + sendInstanceReport: mocks.sendInstanceReport, +})); + +import { instancePingJob } from './instance-ping'; + +describe('instancePingJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isTelemetryEnvAllowed.mockReturnValue(true); + mocks.checkLatestVersion.mockResolvedValue(null); + mocks.getDeploymentLicenseState.mockResolvedValue({ + status: 'unlicensed', + seatLimit: 10, + }); + mocks.isAnonymousAnalyticsEnabled.mockResolvedValue(true); + mocks.collectInstanceReportStats.mockResolvedValue({ users: { total: 7 } }); + mocks.collectLicensedUserCount.mockResolvedValue({ users: { total: 7 } }); + mocks.sendInstanceReport.mockResolvedValue(true); + }); + + it('sends the full instance report when anonymous analytics is enabled', async () => { + const report = { users: { total: 7 }, tasks: { total: 12 } }; + mocks.collectInstanceReportStats.mockResolvedValue(report); + + await instancePingJob(); + + expect(mocks.collectInstanceReportStats).toHaveBeenCalledOnce(); + expect(mocks.collectLicensedUserCount).not.toHaveBeenCalled(); + expect(mocks.sendInstanceReport).toHaveBeenCalledWith(report); + }); + + it('does not send licensed usage to Ping after analytics opt-out', async () => { + mocks.isAnonymousAnalyticsEnabled.mockResolvedValue(false); + mocks.getDeploymentLicenseState.mockResolvedValue({ + status: 'valid', + seatLimit: 100, + licenseId: 'lic_sh_123', + licensee: 'Engineering', + maxSeats: 100, + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2027-01-01T00:00:00Z'), + }); + + await instancePingJob(); + + expect(mocks.collectInstanceReportStats).not.toHaveBeenCalled(); + expect(mocks.collectLicensedUserCount).not.toHaveBeenCalled(); + expect(mocks.sendInstanceReport).not.toHaveBeenCalled(); + }); + + it('skips the report after opt-out when there is no valid license', async () => { + mocks.isAnonymousAnalyticsEnabled.mockResolvedValue(false); + + await instancePingJob(); + + expect(mocks.collectInstanceReportStats).not.toHaveBeenCalled(); + expect(mocks.collectLicensedUserCount).not.toHaveBeenCalled(); + expect(mocks.sendInstanceReport).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/instance-ping.ts b/apps/bullmq/src/scheduled-jobs/instance-ping.ts index 4d97a1292..b21fd39bd 100644 --- a/apps/bullmq/src/scheduled-jobs/instance-ping.ts +++ b/apps/bullmq/src/scheduled-jobs/instance-ping.ts @@ -16,7 +16,7 @@ const LOG_PREFIX = '[instancePing]'; * 1. Version check against the Ping service (mandatory; carries only the * anonymous instance id + running version). The result is stored for the * in-app "update available" notice. - * 2. Anonymous instance stats report (covered by the admin opt-out). + * 2. Anonymous instance stats report, honoring the admin opt-out. * * Sends nothing at all in environments where telemetry is not allowed * (non-production / no RELEASE_VERSION, unless force-enabled with an explicit diff --git a/apps/bullmq/src/scheduled-jobs/license-usage-sync.test.ts b/apps/bullmq/src/scheduled-jobs/license-usage-sync.test.ts new file mode 100644 index 000000000..bd13d514b --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/license-usage-sync.test.ts @@ -0,0 +1,67 @@ +const mocks = vi.hoisted(() => ({ + listPendingLicenseUsageObservations: vi.fn(), + markLicenseUsageObservationAttempt: vi.fn(), + markLicenseUsageObservationDelivered: vi.fn(), + recordDailyLicenseUsageObservation: vi.fn(), + syncLicenseWithCloud: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + listPendingLicenseUsageObservations: + mocks.listPendingLicenseUsageObservations, + markLicenseUsageObservationAttempt: mocks.markLicenseUsageObservationAttempt, + markLicenseUsageObservationDelivered: + mocks.markLicenseUsageObservationDelivered, + recordDailyLicenseUsageObservation: mocks.recordDailyLicenseUsageObservation, +})); + +vi.mock('@roomote/telemetry/server', () => ({ + syncLicenseWithCloud: mocks.syncLicenseWithCloud, +})); + +import { licenseUsageSyncJob } from './license-usage-sync'; + +describe('licenseUsageSyncJob', () => { + const observation = { + id: 'license-e2e-observation-1', + observedAt: new Date('2026-08-03T10:00:00.000Z'), + activeUsers: 11, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.listPendingLicenseUsageObservations.mockResolvedValue([observation]); + mocks.markLicenseUsageObservationAttempt.mockResolvedValue(undefined); + mocks.markLicenseUsageObservationDelivered.mockResolvedValue(undefined); + mocks.syncLicenseWithCloud.mockResolvedValue({ status: 'synced' }); + }); + + it('keeps a failed observation pending and delivers the same event on retry', async () => { + mocks.syncLicenseWithCloud + .mockResolvedValueOnce({ status: 'failed' }) + .mockResolvedValueOnce({ status: 'synced' }); + + await expect(licenseUsageSyncJob()).rejects.toThrow( + 'License usage delivery failed', + ); + + expect(mocks.markLicenseUsageObservationAttempt).toHaveBeenCalledWith( + observation.id, + ); + expect(mocks.syncLicenseWithCloud).toHaveBeenCalledWith({ + eventId: observation.id, + observedAt: observation.observedAt, + activeUsers: observation.activeUsers, + }); + expect(mocks.markLicenseUsageObservationDelivered).not.toHaveBeenCalled(); + + await expect(licenseUsageSyncJob()).resolves.toBeUndefined(); + + expect(mocks.markLicenseUsageObservationAttempt).toHaveBeenCalledTimes(2); + expect(mocks.syncLicenseWithCloud).toHaveBeenCalledTimes(2); + expect(mocks.markLicenseUsageObservationDelivered).toHaveBeenCalledTimes(1); + expect(mocks.markLicenseUsageObservationDelivered).toHaveBeenCalledWith( + observation.id, + ); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/license-usage-sync.ts b/apps/bullmq/src/scheduled-jobs/license-usage-sync.ts new file mode 100644 index 000000000..878caf159 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/license-usage-sync.ts @@ -0,0 +1,45 @@ +import { + listPendingLicenseUsageObservations, + markLicenseUsageObservationAttempt, + markLicenseUsageObservationDelivered, + recordDailyLicenseUsageObservation, +} from '@roomote/db/server'; +import { syncLicenseWithCloud } from '@roomote/telemetry/server'; + +/** Drains durable self-hosted license usage observations to Roomote Cloud. */ +export async function licenseUsageSyncJob(options?: { + heartbeat?: boolean; +}): Promise { + if (options?.heartbeat) { + await recordDailyLicenseUsageObservation(); + } + + let transientFailure = false; + for (const observation of await listPendingLicenseUsageObservations()) { + await markLicenseUsageObservationAttempt(observation.id); + const result = await syncLicenseWithCloud({ + eventId: observation.id, + observedAt: observation.observedAt, + activeUsers: observation.activeUsers, + }); + + if (result.status === 'synced' || result.status === 'not_licensed') { + await markLicenseUsageObservationDelivered(observation.id); + continue; + } + + if (result.status === 'license_in_use' || result.status === 'rejected') { + console.warn( + `[license-usage] observation ${observation.id} was rejected (${result.status})`, + ); + await markLicenseUsageObservationDelivered(observation.id); + continue; + } + + transientFailure = true; + } + + if (transientFailure) { + throw new Error('License usage delivery failed'); + } +} diff --git a/apps/bullmq/src/scheduled-jobs/sleep-check.ts b/apps/bullmq/src/scheduled-jobs/sleep-check.ts index 0cec98f68..2f9362d54 100644 --- a/apps/bullmq/src/scheduled-jobs/sleep-check.ts +++ b/apps/bullmq/src/scheduled-jobs/sleep-check.ts @@ -31,6 +31,7 @@ import { syncTaskStateFromRuns, } from '@roomote/db/server'; import { + AzureDataPlaneError, createComputeProviderClient, type ComputeProviderClient, } from '@roomote/compute-providers'; @@ -445,6 +446,16 @@ export const sleepCheckJob = async () => { failed += result.failed; } } catch (error) { + if (isInstanceNotFoundError(error)) { + // The instance is gone for good — every future evaluation would fail + // the same way (observed as an endless hard_limit retry loop on azure + // runs whose sandboxes were deleted out-of-band). Finalize the run + // instead of retrying forever. + failed += 1; + await finalizeRunForMissingInstance(preferredJob, fallbackPath); + continue; + } + await db .update(taskRuns) .set({ @@ -884,6 +895,62 @@ async function resolveSweptJobFinalStatus( return job?.cancelRequestedAt ? RunStatus.Canceled : RunStatus.Failed; } +/** + * Whether the error definitively means the provider instance no longer + * exists (as opposed to a transient API failure). Only definitive not-found + * errors may finalize runs; anything else must keep retrying. Azure throws + * AzureDataPlaneError 404 for deleted sandboxes; other providers' not-found + * shapes can extend this classifier as needed. + */ +function isInstanceNotFoundError(error: unknown): boolean { + return error instanceof AzureDataPlaneError && error.status === 404; +} + +/** + * Finalize a candidate whose provider instance is gone. Mirrors the + * not-running branch of the timed/heartbeat handlers: idle runs complete + * without a snapshot; anything else fails (or cancels after a stop request). + */ +async function finalizeRunForMissingInstance( + job: SleepCheckJob, + path: SleepCheckPath, +): Promise { + const details = { + path, + decision: 'instance_not_found', + ...buildSleepCheckDetails(job), + }; + + if (job.status === RunStatus.Idle) { + await recordSleepCheckEvent( + job, + 'decision', + `${describeSleepCheckPath(path)} found that idle instance ${job.machineId} no longer exists; completing task run #${job.id} without a snapshot.`, + details, + ); + await completeIdleJobWithoutSnapshot( + job, + `Instance ${job.machineId} no longer exists.`, + details, + ); + return; + } + + const finalStatus = await resolveSweptJobFinalStatus(job.id); + + await recordSleepCheckEvent( + job, + finalStatus === RunStatus.Canceled ? 'decision' : 'failed', + `${describeSleepCheckPath(path)} found that instance ${job.machineId} no longer exists; finalizing task run #${job.id} as ${finalStatus}.`, + details, + ); + await finishRun({ + id: job.id, + status: finalStatus, + error: `${describeSleepCheckPath(path)} found that instance ${job.machineId} no longer exists`, + }); +} + async function handleTimedSleepCandidate(params: { job: SleepCheckJob; path: 'due_sleep' | 'hard_limit'; diff --git a/apps/bullmq/src/scheduled-jobs/standby-retention.ts b/apps/bullmq/src/scheduled-jobs/standby-retention.ts index 5f28419b8..ef5b0e470 100644 --- a/apps/bullmq/src/scheduled-jobs/standby-retention.ts +++ b/apps/bullmq/src/scheduled-jobs/standby-retention.ts @@ -5,6 +5,7 @@ import { eq, inArray, isNotNull, + or, resolveComputeProviderEnvValues, taskRuns, } from '@roomote/db/server'; @@ -15,6 +16,10 @@ const LOG_PREFIX = '[standbyRetention]'; const MS_PER_HOUR = 60 * 60 * 1_000; const STANDBY_PROVIDERS = ['docker', 'blaxel', 'azure'] as const; +// Providers whose retained handles are live instances that can (and must) be +// re-suspended when found Running with no managing run. +const RE_SUSPEND_PROVIDERS: readonly StandbyProvider[] = ['azure']; + type StandbyProvider = (typeof STANDBY_PROVIDERS)[number]; type StandbyCandidate = { @@ -107,6 +112,43 @@ async function createClient(provider: StandbyProvider) { }); } +/** + * Handles referenced by any active run — as its machine (a live wake session + * owns the sandbox) or as its retained standby handle. Re-suspending one of + * these would kill a live session, so the orphan sweep must skip them. + */ +async function getInUseHandles( + provider: StandbyProvider, + handles: string[], +): Promise> { + if (handles.length === 0) return new Set(); + + const rows = await db + .select({ + machineId: taskRuns.machineId, + snapshotId: taskRuns.snapshotId, + }) + .from(taskRuns) + .where( + and( + eq(taskRuns.vendor, provider), + inArray(taskRuns.status, activeRunStatuses as readonly RunStatus[]), + or( + inArray(taskRuns.machineId, handles), + inArray(taskRuns.snapshotId, handles), + ), + ), + ); + + return new Set( + rows.flatMap((row) => + [row.machineId, row.snapshotId].flatMap((handle) => + handle === null ? [] : [handle], + ), + ), + ); +} + async function getProtectedHandles(provider: StandbyProvider) { const rows = await db .select({ handle: taskRuns.sourceSnapshotId }) @@ -210,6 +252,76 @@ async function enforceProviderRetention( return removed; } +/** + * Re-suspend retained standby instances found Running. A retained instance + * has no living run: its worker token was finalized at suspend time, so any + * out-of-band wake (traffic on legacy OnDemand ports, portal actions, a wake + * that resumed the sandbox before bootstrap failed) leaves it idling and + * billing with no Roomote run to manage it. Sleep-check deliberately + * excludes retained runs (its candidacy requires snapshotId IS NULL), so + * this sweep is the only re-suspend path. + * + * Wake-race note: a deliberate wake stamps the resume run's sourceSnapshotId + * with the handle at enqueue time, which getProtectedHandles covers, so the + * window for stopping a sandbox mid-wake is limited to the wake's own + * resume+launch seconds. A lost race fails that wake attempt cleanly and the + * user retries; the next sweep re-heals the orphan either way. + */ +async function reSuspendOrphanedStandbys( + provider: StandbyProvider, +): Promise { + const candidates = await getCandidates(provider); + if (candidates.length === 0) return 0; + + const protectedHandles = await getProtectedHandles(provider); + const inUseHandles = await getInUseHandles( + provider, + candidates.map((candidate) => candidate.handle), + ); + const client = await createClient(provider); + + if (!client.enterStandby) return 0; + + let resuspended = 0; + + for (const candidate of candidates) { + if ( + protectedHandles.has(candidate.handle) || + inUseHandles.has(candidate.handle) + ) { + continue; + } + + let status: string; + try { + const result = await client.getInstanceStatus({ + instanceId: candidate.handle, + }); + status = result.status; + } catch { + // Not a live instance (a genuine snapshot id, or already deleted) — + // the retention eviction policy owns its lifecycle. + continue; + } + + if (status !== 'running') continue; + + try { + await client.enterStandby({ instanceId: candidate.handle }); + resuspended += 1; + console.log( + `${LOG_PREFIX} re-suspended orphaned ${provider} standby ${candidate.handle} from task run #${candidate.runId}`, + ); + } catch (error) { + console.error( + `${LOG_PREFIX} failed to re-suspend ${provider} standby ${candidate.handle}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return resuspended; +} + export async function standbyRetentionJob( now: Date = new Date(), ): Promise { @@ -217,12 +329,18 @@ export async function standbyRetentionJob( STANDBY_PROVIDERS.map(async (provider) => ({ provider, removed: await enforceProviderRetention(provider, now), + resuspended: RE_SUSPEND_PROVIDERS.includes(provider) + ? await reSuspendOrphanedStandbys(provider) + : 0, })), ); console.log( `${LOG_PREFIX} completed ${results - .map(({ provider, removed }) => `${provider}=${removed}`) + .map( + ({ provider, removed, resuspended }) => + `${provider}(removed=${removed},resuspended=${resuspended})`, + ) .join(' ')}`, ); } diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts index 85e01a955..68070f36b 100644 --- a/apps/bullmq/src/scheduler.ts +++ b/apps/bullmq/src/scheduler.ts @@ -27,6 +27,7 @@ import { refreshSnapshotsJob, pullRequestAnalyticsSyncJob, instancePingJob, + licenseUsageSyncJob, webhookCleanupJob, standbyRetentionJob, } from './scheduled-jobs'; @@ -161,6 +162,24 @@ async function createJobs(queue: Queue): Promise { { every: 24 * 60 * 60 * 1000 }, // Every 24 hours. ); + await queue.upsertJobScheduler( + ScheduledJobName.LicenseUsageSync, + { every: 15 * 60 * 1000 }, // Drain durable usage observations promptly. + ); + + await queue.upsertJobScheduler( + ScheduledJobName.LicenseUsageHeartbeat, + { every: 24 * 60 * 60 * 1000 }, // Daily billing liveness observation. + ); + + // An environment-provided license has no Settings mutation to trigger an + // activation request, so establish or renew its Cloud lease at startup. + await queue.add( + ScheduledJobName.LicenseUsageHeartbeat, + { reason: 'scheduler-startup' }, + { jobId: `license-usage-startup-${Date.now()}` }, + ); + await queue.upsertJobScheduler( ScheduledJobName.WebhookCleanup, { every: 24 * 60 * 60 * 1000 }, // Every 24 hours. @@ -189,6 +208,10 @@ const runJobs = async (job: ScheduledJob): Promise => { return pullRequestAnalyticsSyncJob(job.data ?? {}); case ScheduledJobName.InstancePing: return instancePingJob(); + case ScheduledJobName.LicenseUsageSync: + return licenseUsageSyncJob(); + case ScheduledJobName.LicenseUsageHeartbeat: + return licenseUsageSyncJob({ heartbeat: true }); case ScheduledJobName.WebhookCleanup: return webhookCleanupJob(); case ScheduledJobName.StandbyRetention: diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts index 9a0e1f163..c63a24cfd 100644 --- a/apps/bullmq/src/types.ts +++ b/apps/bullmq/src/types.ts @@ -9,6 +9,8 @@ export enum ScheduledJobName { SleepCheck = 'SleepCheck', PullRequestAnalyticsSync = 'PullRequestAnalyticsSync', InstancePing = 'InstancePing', + LicenseUsageSync = 'LicenseUsageSync', + LicenseUsageHeartbeat = 'LicenseUsageHeartbeat', WebhookCleanup = 'WebhookCleanup', StandbyRetention = 'StandbyRetention', CustomAutomations = 'custom_automations', diff --git a/apps/controller/src/compute-providers/spawn-azure-worker.ts b/apps/controller/src/compute-providers/spawn-azure-worker.ts index d95aae643..8876db48e 100644 --- a/apps/controller/src/compute-providers/spawn-azure-worker.ts +++ b/apps/controller/src/compute-providers/spawn-azure-worker.ts @@ -31,6 +31,10 @@ import { updateTaskRunMachine, } from '../utils'; import { resolveTaskSandboxMemoryMiB } from './task-sandbox-resources'; +import { + COMPUTE_BOOTSTRAP_TIMEOUT_MS, + COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, +} from './timeouts'; const AZURE_LAUNCH_OUTPUT_TEXT_LIMIT = 500; @@ -344,8 +348,8 @@ export async function spawnAzureWorker( tags: azureTags, timeoutMs: azureTimeoutMs, localTarballPath, - createInstanceTimeoutMs: 180_000, - bootstrapTimeoutMs: 120_000, + createInstanceTimeoutMs: COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, + bootstrapTimeoutMs: COMPUTE_BOOTSTRAP_TIMEOUT_MS, computeClient, onMutation: recordMutation, ...launchOptions, @@ -399,6 +403,29 @@ export async function spawnAzureWorker( )}`, ); + if ( + launchOptions.launchMode === 'task_standby' || + launchOptions.launchMode === 'task_snapshot' + ) { + // Suspend/resume and snapshot/restore revive previously frozen worker + // processes whose runs were finalized while they were away — dead + // tokens, and they hold the sandbox-server port, which kills the + // incoming worker's boot. Reap them before launch. pkill exits 1 when + // nothing matches; that is the common case and not an error. + await computeClient + .runCommand({ + instanceId: machine.machineId, + cmd: 'pkill', + args: ['-f', '/sandbox/worker/dist/worker.js'], + signal: AbortSignal.timeout(15_000), + }) + .catch((error) => { + console.warn( + `[spawnAzureWorker] Pre-launch worker reaper failed for task run #${taskRun.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + await recordMutation({ provider: 'azure', operation: 'run_command', diff --git a/apps/controller/src/compute-providers/spawn-blaxel-worker.ts b/apps/controller/src/compute-providers/spawn-blaxel-worker.ts index 1efeaf209..b0af22fec 100644 --- a/apps/controller/src/compute-providers/spawn-blaxel-worker.ts +++ b/apps/controller/src/compute-providers/spawn-blaxel-worker.ts @@ -27,6 +27,10 @@ import { updateTaskRunMachine, } from '../utils'; import { resolveTaskSandboxMemoryMiB } from './task-sandbox-resources'; +import { + COMPUTE_BOOTSTRAP_TIMEOUT_MS, + COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, +} from './timeouts'; export async function spawnBlaxelWorker( taskRun: TaskRun, @@ -120,8 +124,8 @@ export async function spawnBlaxelWorker( tags: config.blaxelTags, timeoutMs: config.blaxelTimeoutMs, localTarballPath: config.localTarballPath, - createInstanceTimeoutMs: 180_000, - bootstrapTimeoutMs: 120_000, + createInstanceTimeoutMs: COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, + bootstrapTimeoutMs: COMPUTE_BOOTSTRAP_TIMEOUT_MS, computeClient, onMutation: recordMutation, ...launchOptions, diff --git a/apps/controller/src/compute-providers/spawn-daytona-worker.ts b/apps/controller/src/compute-providers/spawn-daytona-worker.ts index 055e020aa..152e56d34 100644 --- a/apps/controller/src/compute-providers/spawn-daytona-worker.ts +++ b/apps/controller/src/compute-providers/spawn-daytona-worker.ts @@ -28,6 +28,10 @@ import { updateTaskRunMachine, } from '../utils'; import { resolveTaskSandboxMemoryMiB } from './task-sandbox-resources'; +import { + COMPUTE_BOOTSTRAP_TIMEOUT_MS, + COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, +} from './timeouts'; const DAYTONA_LAUNCH_OUTPUT_TEXT_LIMIT = 500; @@ -255,8 +259,8 @@ export async function spawnDaytonaWorker( tags: daytonaTags, timeoutMs: daytonaTimeoutMs, localTarballPath, - createInstanceTimeoutMs: 180_000, - bootstrapTimeoutMs: 120_000, + createInstanceTimeoutMs: COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, + bootstrapTimeoutMs: COMPUTE_BOOTSTRAP_TIMEOUT_MS, computeClient, onMutation: recordMutation, ...launchOptions, diff --git a/apps/controller/src/compute-providers/spawn-e2b-worker.ts b/apps/controller/src/compute-providers/spawn-e2b-worker.ts index 65fc9c7bd..afdf2f8b9 100644 --- a/apps/controller/src/compute-providers/spawn-e2b-worker.ts +++ b/apps/controller/src/compute-providers/spawn-e2b-worker.ts @@ -27,6 +27,10 @@ import { shouldEnableAuthBypassForTaskRun, updateTaskRunMachine, } from '../utils'; +import { + COMPUTE_BOOTSTRAP_TIMEOUT_MS, + COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, +} from './timeouts'; const E2B_LAUNCH_OUTPUT_TEXT_LIMIT = 500; @@ -249,8 +253,8 @@ export async function spawnE2bWorker( tags: e2bTags, timeoutMs: e2bTimeoutMs, localTarballPath, - createInstanceTimeoutMs: 180_000, - bootstrapTimeoutMs: 120_000, + createInstanceTimeoutMs: COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, + bootstrapTimeoutMs: COMPUTE_BOOTSTRAP_TIMEOUT_MS, computeClient, onMutation: recordMutation, ...launchOptions, diff --git a/apps/controller/src/compute-providers/spawn-modal-worker.ts b/apps/controller/src/compute-providers/spawn-modal-worker.ts index c1a08c969..bd30374d0 100644 --- a/apps/controller/src/compute-providers/spawn-modal-worker.ts +++ b/apps/controller/src/compute-providers/spawn-modal-worker.ts @@ -31,6 +31,10 @@ import { updateTaskRunMachine, } from '../utils'; import { resolveTaskSandboxMemoryMiB } from './task-sandbox-resources'; +import { + COMPUTE_BOOTSTRAP_TIMEOUT_MS, + COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, +} from './timeouts'; const MODAL_LAUNCH_OUTPUT_TEXT_LIMIT = 500; @@ -367,8 +371,8 @@ export async function spawnModalWorker( tags: modalTags, timeoutMs: modalTimeoutMs, localTarballPath, - createInstanceTimeoutMs: 180_000, - bootstrapTimeoutMs: 120_000, + createInstanceTimeoutMs: COMPUTE_CREATE_INSTANCE_TIMEOUT_MS, + bootstrapTimeoutMs: COMPUTE_BOOTSTRAP_TIMEOUT_MS, computeClient, onMutation: recordMutation, ...launchOptions, diff --git a/apps/controller/src/compute-providers/timeouts.ts b/apps/controller/src/compute-providers/timeouts.ts new file mode 100644 index 000000000..455663887 --- /dev/null +++ b/apps/controller/src/compute-providers/timeouts.ts @@ -0,0 +1,29 @@ +/** + * Shared sandbox-provisioning deadlines for every compute provider. + * + * These were duplicated as literals in each spawn-*-worker module, so the + * creation deadline could only be tuned five times or not at all. + */ + +/** + * How long to wait for a provider to hand back a running instance. + * + * Raised from three minutes after that deadline was observed manufacturing + * false failures: under sandbox-creation rate limiting, creation routinely + * outlasts three minutes, the run is marked `failed`, and then the sandbox + * comes up anyway and the agent posts its work minutes later — leaving the + * user an error banner on top of a finished task whose thread is dead + * (roomote nightly, 2026-08-03: run failed at 3m00s, assistant output + * arrived 12 minutes later; 4 of 42 failed runs that week did the same). + * + * Waiting longer is the cheaper mistake: a slow start still shows the + * startup sequence, while a premature abort throws away real work. + */ +export const COMPUTE_CREATE_INSTANCE_TIMEOUT_MS = 10 * 60_000; + +/** + * How long to wait for a created instance to finish bootstrapping. Unchanged + * — bootstrap runs after the provider has already given us the machine, so + * it is not subject to the creation-queue contention above. + */ +export const COMPUTE_BOOTSTRAP_TIMEOUT_MS = 2 * 60_000; diff --git a/apps/docs/README.md b/apps/docs/README.md index dcaaea11e..b7098508c 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -20,10 +20,10 @@ and assets all live in this directory and do not depend on `@roomote/web`. ## Local development -Install the Mintlify CLI (`mint`) globally, then run the dev server: +The docs scripts download a pinned Mintlify CLI automatically, so no global +install is required. Run the dev server with: ```bash -npm install -g mint pnpm --filter @roomote/docs dev ``` diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index eeff3f4bd..1dbb83c80 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -12,6 +12,10 @@ or a communications provider when an automation depends on those services. Automations are most useful when your team already trusts the normal task review flow and wants Roomote to keep an eye on repeated work. + + Unsure of how Automations can help? Get inspiration from the [Cookbook](/cookbook). + + ## Before you turn them on Make sure the basics are in place: @@ -78,11 +82,9 @@ Create arbitrary scheduled agent runs with: Admins can also choose **Custom schedule** and enter either a standard five-field cron expression or a natural-language schedule such as “weekdays at -9am.” Roomote previews the interpreted schedule before saving. A schedule that -names a day but no time runs at 3am local, matching the daily and weekly -cadences, and Roomote asks for clarification rather than guessing when the -recurrence itself is ambiguous. Custom schedules do not support seconds or -cron macros. +9am.” Roomote previews the interpreted schedule before saving and asks for +clarification rather than guessing when the recurrence itself is ambiguous. +Custom schedules do not support seconds or cron macros. On each due tick, Roomote launches a normal task with that prompt in that environment. When a report destination is set, the run is anchored to that @@ -93,9 +95,12 @@ chatter in between. Without a destination, the run happens silently and its results appear only in the task view. Use **Run now** on an enabled automation to test it immediately. -Daily and weekly custom automations use the same local-hour window as the -other scheduled automations (around 3am in the workspace timezone). Cap is -25 custom automations per deployment. +The deployment cap is 25 custom automations. + +See [Schedule maintenance](/cookbook/scheduled-housekeeping) +for three copyable custom automation prompts, or +[draft a product-updates newsletter](/cookbook/product-updates-newsletter) with +a custom MCP server and a natural-language schedule. The **Scheduling timezone** setting is available on both the Automations and Deployment settings pages. It applies to all scheduled automations and to @@ -140,6 +145,10 @@ Start with a low-risk channel first. Auto-response can feel noisy if the channel mixes casual discussion with requests that should become Roomote tasks. +See how channel instructions and launch criteria work together in the +[vendor outage triage](/cookbook/vendor-outage-triage) and +[support channel](/cookbook/support-channel) recipes. + ## Manager automations The manager section controls the shared Manager Channel plus recurring @@ -179,10 +188,10 @@ runs) and posts digests there. You cannot pick an existing Telegram thread — Roomote owns the recurring topic. When you select Teams, digests go to the primary Teams conversation captured for the deployment. -Cards also show capability badges for what each automation supports today: -the chat surfaces it can report to and the source-control providers it works -with. Triage Dependabot Alerts and Triage CodeQL Alerts are GitHub-only by -nature. CI Failure Triage supports GitHub Actions, GitLab Pipelines, Azure +Cards also show a plain provider-support line for what each automation supports +today: the chat surfaces it can report to and the source-control providers it +works with. Triage Dependabot Alerts and Triage CodeQL Alerts are GitHub-only +by nature. CI Failure Triage supports GitHub Actions, GitLab Pipelines, Azure DevOps builds, Bitbucket Pipelines, and Gitea Actions. Triage Issues supports GitHub, GitLab, and Gitea issues. Security Auditor, Code Quality Auditor, Weekly Manager @@ -232,6 +241,9 @@ For Gitea, repository webhook resync includes the `workflow_run` event so Actions completions reach Roomote; host matching uses the deployment `GITEA_BASE_URL`. +Follow the [CI failure auto-fix recipe](/cookbook/ci-failure-auto-fix) for a +copyable noise-control configuration. + ## Add instructions to reduce noise Several automations include an **Additional instructions** field. Use it to diff --git a/apps/docs/cookbook/ci-failure-auto-fix.mdx b/apps/docs/cookbook/ci-failure-auto-fix.mdx new file mode 100644 index 000000000..93c459e67 --- /dev/null +++ b/apps/docs/cookbook/ci-failure-auto-fix.mdx @@ -0,0 +1,49 @@ +--- +title: Fix CI failures +description: Keep the build green by having Roomote verify and fix CI breakages automatically. +contributor: Matt Rubens +contributor_url: https://github.com/mrubens +contributor_company: Roomote +contributor_company_url: https://roomote.dev +--- + +## Overview + +When CI fails on your default branch, Roomote reacts immediately. It checks +that the failure is real, reproduces the failing job inside the repository's +environment, finds the root cause, opens a PR with the fix, and posts one +summary to the Manager Channel. The team finds a green PR waiting instead of a +red branch. This is a built-in automation, so the recipe is one toggle plus +clear instructions. + +- **Trigger**: Webhook +- **Setup time**: About 10 minutes +- **Requires**: Admin access, source control, Manager Channel +- **Serves**: Engineers +- **Cooked By**: [Matt Rubens](https://github.com/mrubens) from [Roomote](https://roomote.dev) + +## Ingredients + +- [CI Failure Triage](/automations#manager-automations) +- A Manager Channel destination configured under **Automation output** +- An environment for every repository you want triaged. Repositories outside an environment are skipped. + +## Steps + +1. Set the Manager Channel under **Settings > Automations > Automation output**. +2. Enable **CI Failure Triage**. +3. Confirm each repository you care about belongs to a configured environment. +5. Use **Run now** to test the configuration, or wait for the next default-branch failure. + +## Variations + +- Keep experimental repositories out of environments to exclude them from triage. +- Route the summary to a dedicated `#ci-triage` channel instead of the shared Manager Channel. + +CI Failure Triage reacts only to failures on the default branch. It supports +GitHub Actions, GitLab Pipelines, Azure DevOps builds, Bitbucket Pipelines, and +Gitea Actions. GitLab requires Pipeline Hooks on the webhook, and Bitbucket +requires the Pipelines OAuth scope. + +**Pairs well with:** [scheduled housekeeping](/cookbook/scheduled-housekeeping) · +[vendor outage triage](/cookbook/vendor-outage-triage) diff --git a/apps/docs/cookbook/index.mdx b/apps/docs/cookbook/index.mdx new file mode 100644 index 000000000..d2172a276 --- /dev/null +++ b/apps/docs/cookbook/index.mdx @@ -0,0 +1,27 @@ +--- +title: Cookbook +icon: cooking-pot +description: Copyable recipes for turning Roomote automations, skills, and integrations into standing team systems. +--- + +Roomote's abilities go way beyond ad hoc prompt → PR interactions. With integrations and automations, you can put many of your team's workflows on autopilot, from investigation to resolution and beyond. + +This cookbook collects proven recipes to help you boost your team's productivity +and the quality of your output. + +{/* cookbook-recipes:start */} +| Recipe | Use to | +| --- | --- | +| [Draft product updates](/cookbook/product-updates-newsletter) | Turn recent product work into a customer-ready draft | +| [Evaluate outage impact](/cookbook/vendor-outage-triage) | Filter vendor status noise by comparing each incident with your real code, regions, and feature usage. | +| [Fix CI failures](/cookbook/ci-failure-auto-fix) | Keep the build green by having Roomote verify and fix CI breakages automatically. | +| [Schedule maintenance](/cookbook/scheduled-housekeeping) | Turn flaky-test scans, feature-flag audits, and dependency reviews into recurring Roomote work. | +| [Triage customer issues](/cookbook/support-channel) | Give support escalations a repeatable path through production evidence, data, and code. | +{/* cookbook-recipes:end */} + +## Contribute a recipe + +Have a Roomote workflow other teams could reuse? Start with +[contributing recipes](/cookbook/template), fill in every section, and open a +[Cookbook recipe issue](https://github.com/RooCodeInc/Roomote/issues/new?template=cookbook-recipe.yml) +on the Roomote repository. diff --git a/apps/docs/cookbook/product-updates-newsletter.mdx b/apps/docs/cookbook/product-updates-newsletter.mdx new file mode 100644 index 000000000..982015f05 --- /dev/null +++ b/apps/docs/cookbook/product-updates-newsletter.mdx @@ -0,0 +1,73 @@ +--- +title: Draft product updates +description: Turn recent product work into a customer-ready draft +contributor: Bruno Bergher +contributor_url: https://github.com/brunobergher +contributor_company: Roomote +contributor_company_url: https://roomote.dev +--- + +## Overview + +Product-update emails are chronically late because someone has to remember what +shipped, translate commit-speak into customer language, and then fight the +email tool. This automation does all three: every week it reads merged PRs +across your product repositories, writes the newsletter, and creates a draft in +Resend through a custom MCP server, ready for review. + +It's how we actually generate the ones we send every Friday. + +- **Trigger**: Schedule +- **Setup time**: About 30 minutes +- **Requires**: Admin access, custom Resend MCP server +- **Serves**: PMs, Founders, Marketing +- **Cooked By**: [Bruno Bergher](https://github.com/brunobergher) from [Roomote](https://roomote.dev) + +## Ingredients + +- A [custom automation](/automations#custom-automations) +- An environment covering the product repositories +- The Resend MCP server configured under **Settings > Integrations**, with a Resend API key +- A report destination such as `#product-updates`, so the draft lands in a replyable thread + +## Steps + +1. Add the Resend MCP server and confirm its tools are available in the environment. +2. Create a custom automation named **Product Updates Newsletter**. Choose **Custom schedule**, enter `Mondays at 9am`, and select the environment. +3. Paste the prompt below. +4. Set the report destination to `#product-updates`. +5. Use **Run now** to generate the first draft, then calibrate its tone by replying in the thread. + +## Automation prompt + +```text +Write this week's product-updates newsletter. + +1. Collect the PRs merged to the default branch in the last 7 days across the + product repositories. +2. Keep only customer-visible changes: features, improvements, and fixes a + user would notice. Skip refactors, internal tooling, and dependency bumps. +3. Write the newsletter: + - a one-line subject + - a two-sentence introduction + - one short section per change: what is new, why it matters, how to try it + Use plain language. Do not include ticket numbers or internal codenames. +4. Using the Resend MCP server, create a DRAFT broadcast with this content for + the "Product updates" audience. Do not send it. +5. Post the draft content and the Resend draft link in this thread for review. + +If nothing customer-visible shipped this week, say so in the thread and skip +the draft. Do not pad the newsletter. +``` + +## Variations + +- Without Resend, remove step 4 and post ready-to-paste Markdown for any email tool. +- Run monthly with `first Monday of the month at 9am`. +- Use separate automations for customer notes and a technical internal changelog. + +This recipe showcases custom MCP servers, natural-language scheduling, and +replyable automation threads. Review the draft before sending it; the automation +is deliberately instructed to create a draft, not send a broadcast. + +**Pairs well with:** [scheduled housekeeping](/cookbook/scheduled-housekeeping) diff --git a/apps/docs/cookbook/scheduled-housekeeping.mdx b/apps/docs/cookbook/scheduled-housekeeping.mdx new file mode 100644 index 000000000..ba7d5f47e --- /dev/null +++ b/apps/docs/cookbook/scheduled-housekeeping.mdx @@ -0,0 +1,83 @@ +--- +title: Schedule maintenance +description: Turn flaky-test scans, feature-flag audits, and dependency reviews into recurring Roomote work. +contributor: Bruno Bergher +contributor_url: https://github.com/brunobergher +contributor_company: Roomote +contributor_company_url: https://roomote.dev +--- + +## Overview + +Every team has maintenance work that only happens when someone feels guilty: +flaky tests, stale feature flags, and aging dependencies. A custom automation +turns each chore into a standing appointment: a prompt, a cadence, and a channel +where results land in a replyable thread. Once you have built one, you will +start spotting more work that fits the pattern. + +- **Trigger**: Schedule +- **Setup time**: About 15 minutes +- **Requires**: Admin access +- **Serves**: Engineers, Leads +- **Cooked By**: [Bruno Bergher](https://github.com/brunobergher) from [Roomote](https://roomote.dev) + + +## Ingredients + +- One [custom automation](/automations#custom-automations) per chore, within the deployment cap of 25 +- One environment per automation +- A report destination such as `#eng-maintenance` + +## Steps + +1. Under **Settings > Automations > Custom automations**, create an automation. +2. Pick a prompt below, choose a cadence, and select the environment. Natural-language schedules such as `weekdays at 9am` are supported under **Custom schedule**. +3. Set a report destination so results become a conversation rather than a log line. +4. Use **Run now** to check the first report, then let the schedule take over. +5. Reply in the thread to steer a run, for example `Open the PR for item 2`, without changing the automation. + +## Prompt: flaky tests + +Run weekly: + +```text +Find flaky tests. Look at recent CI runs for tests that failed and then passed +with no related code change. For each suspect, read the test and identify the +likely cause: timing, shared state, network, or ordering. Report the top 3 with +evidence. If one has an obvious, safe fix, open a PR for that one only. +``` + +## Prompt: stale feature flags + +Run weekly or monthly: + +```text +Audit feature flags. List every flag defined in the codebase. Flag the ones +that are fully rolled out and effectively always on, referenced nowhere, or +whose code paths are dead. Report which can be removed and what removing them +simplifies. Open a cleanup PR for at most one clearly dead flag per run. +``` + +## Prompt: dependency review + +Run weekly: + +```text +Review our dependencies for updates worth taking: security patches first, +then major versions of core frameworks. For each candidate, summarize what +changed upstream and the migration risk for our codebase specifically. Do not +open PRs. End with a ranked shortlist of updates worth scheduling this sprint. +``` + +## Variations + +- Create a Monday briefing that summarizes open PRs, stale branches, and work waiting on review. +- Compare `apps/docs` with customer-visible changes shipped in the last week and report documentation drift. +- Use a model override for heavyweight audits and the deployment default for cheaper checks. + +Custom automations also support five-field cron expressions, **Run now**, and +replyable result threads. Without a report destination, runs are silent and +their results appear only in the task view. + +**Pairs well with:** [CI failure auto-fix](/cookbook/ci-failure-auto-fix) · +[product-updates newsletter](/cookbook/product-updates-newsletter) diff --git a/apps/docs/cookbook/support-channel.mdx b/apps/docs/cookbook/support-channel.mdx new file mode 100644 index 000000000..9dcf8d3ac --- /dev/null +++ b/apps/docs/cookbook/support-channel.mdx @@ -0,0 +1,111 @@ +--- +title: Triage customer issues +description: Give support escalations a repeatable path through production evidence, data, and code. +contributor: John Stearns +contributor_company: Roo Vet +contributor_company_url: https://roo.vet +--- + +## Overview + +Customer-facing teams post escalations into an operations channel, often from a +ticketing system. A custom skill teaches Roomote your triage procedure: check +logs, inspect data, read code, and classify the problem as a product bug, a +data-state issue, or expected behavior. Roomote answers in the thread with +evidence, and a PR when the resolution is a code fix. Engineers only see what +survives triage. + +- **Trigger**: Channel event +- **Setup time**: About 45 minutes +- **Requires**: Admin access, Slack or Discord, observability or data integration +- **Serves**: Support, Engineers +- **Cooked By**: John Stearns from [Roo Vet](https://roo.vet) + +## Ingredients + +- A channel receiving support escalations, such as `#support-inbound`, with Roomote invited +- [Auto-respond channels](/automations#channel-automations), or a ticketing workflow that mentions Roomote +- A [custom skill](/skills#when-to-use-a-custom-skill), enabled for the product environment +- Observability and data integrations such as [Sentry](/integrations/sentry), [Grafana](/integrations/grafana), [Better Stack](/integrations/better-stack), [Supabase](/integrations/supabase), [Neon](/integrations/neon), or [Snowflake](/integrations/snowflake), or a custom MCP server for your stack +- An environment for the product repositories, with read-only production credentials where integrations do not cover the required system + +## Steps + +1. Connect the integrations your triage needs under **Settings > Integrations**. +2. Under **Settings > Skills**, add the custom skill below and enable it for the environment your product runs in. +3. Add the escalation channel under **Settings > Automations > Auto-respond to channels** and paste the launch criteria. +4. Repost a resolved historical ticket and compare Roomote's triage with what actually happened. +5. Tighten the skill with what you learn. The skill is the control you will keep tuning. + +## Custom skill + +Create a custom skill with these field values. The Roomote UI stores the slug, +description, and content separately. + +**Slug** + +```text +support-investigation +``` + +**Description** + +```text +Use when a customer issue or support escalation needs triage: classify it as a +product bug, a data-state problem, or expected behavior, then resolve it or +prepare the escalation. +``` + +**Content** + +```markdown +When a support escalation arrives: + +1. Restate the problem: which customer, which feature, what they expected, + and what actually happened. +2. Check errors first: search Sentry for exceptions matching the feature and + time window. Note anything correlated. +3. Check the data: query the production database with read-only access to + inspect the customer's relevant records. Never write to production. +4. Read the code path involved, then classify: + - Product bug: implement the fix and open a PR; link it in the thread. + - Data-state problem: identify the exact records and describe the + corrective action for an operator. Do not modify data yourself. + - Expected behavior or user error: draft a reply the support agent can + send, citing the relevant docs. +5. Reply in the thread with the classification, evidence (queries run, error + links, and code references), and the PR, proposed data fix, or reply draft. + +Escalate to an engineer only when the fix needs a product decision or a +production data change. +``` + +## Launch criteria + +```text +Launch for messages describing a specific customer-reported problem or support +escalation, usually posted by the ticketing bot or a support teammate. + +Do not launch for general questions, internal discussion, status updates on +tickets already being worked, or messages that only link a ticket without +describing the problem. + +If an earlier message about the same ticket already launched, launch again +only if the customer reports the problem recurring after it was thought +resolved. +``` + +## Variations + +- Ask Roomote to propose, but not run, a data-repair script as a reviewed PR against an operations repository. +- Create separate skills by product area, each with a sharper description so the right one activates. +- On Teams, have the ticketing workflow mention Roomote and name `support-investigation`; auto-respond is not available there today. + +This recipe is most valuable with real log and read-only database access. Grant +scoped, read-only credentials through integrations or environment variables. +The skill says never to write to production; admins should also enforce the +boundary with database roles and MCP tool policies. A prompt is not a security +boundary. + +**Pairs well with:** [vendor outage triage](/cookbook/vendor-outage-triage) · +[CI failure auto-fix](/cookbook/ci-failure-auto-fix) diff --git a/apps/docs/cookbook/template.mdx b/apps/docs/cookbook/template.mdx new file mode 100644 index 000000000..111564f89 --- /dev/null +++ b/apps/docs/cookbook/template.mdx @@ -0,0 +1,74 @@ +--- +title: Contributing recipes +description: Propose a reusable Roomote system for the Cookbook through a repository issue. +icon: plus +contributor: Name or team +contributor_url: https://example.com/contributor +contributor_company: Company +contributor_company_url: https://example.com/company +--- + +Use this skeleton when proposing a Cookbook recipe. Replace every placeholder +and remove the comments before opening a +[Cookbook recipe issue](https://github.com/RooCodeInc/Roomote/issues/new?template=cookbook-recipe.yml). +Do not open a pull request unless a maintainer invites you to. Fill in the +contributor fields in the frontmatter and repeat the contributor in the +Overview metadata. If the recipe comes from another team, use those fields for +the attribution. + +````markdown +--- +title: Outcome-first recipe title +description: One sentence explaining the finished system and why it matters. +icon: lucide-icon-name +contributor: Name or team +contributor_url: https://example.com/contributor +contributor_company: Company +contributor_company_url: https://example.com/company +--- + +## Overview + + + +Overview paragraph. + +- **Trigger**: Trigger type +- **Setup time**: About N minutes +- **Requires**: Product access, integrations, permissions +- **Serves**: Teams or roles +- **Cooked By**: [Name or team](https://example.com/contributor) from [Company](https://example.com/company) + +## Ingredients + + + +- Ingredient + +## Steps + + + +1. First step. + +## Paste-ready configuration + + + +```text +Configuration or prompt +``` + +## Variations + + + +- Variation + + + +**Pairs well with:** [Related recipe](/cookbook/example) +```` + +Before submitting, test every paste block against the current product and make +sure the recipe never relies on a prompt alone to enforce a security boundary. diff --git a/apps/docs/cookbook/vendor-outage-triage.mdx b/apps/docs/cookbook/vendor-outage-triage.mdx new file mode 100644 index 000000000..a2a8627dc --- /dev/null +++ b/apps/docs/cookbook/vendor-outage-triage.mdx @@ -0,0 +1,91 @@ +--- +title: Evaluate outage impact +description: Filter vendor status noise by comparing each incident with your real code, regions, and feature usage. +contributor: Matt Rubens +contributor_url: https://github.com/mrubens +contributor_company: Roomote +contributor_company_url: https://roomote.dev +--- + +## Overview + +Every vendor you depend on publishes status updates, and almost none of them +affect you, until one does. Pipe them into one channel and let Roomote read each +incident against your actual code: which SDKs you call, which regions you run +in, and which features you use. You stop triaging vendor noise by hand and only +hear about the outages that matter. This is how we run it at Roo. + +- **Trigger**: Channel event +- **Setup time**: About 20 minutes +- **Requires**: Admin access, Slack or Discord +- **Serves**: On-call, Support +- **Cooked By**: [Matt Rubens](https://github.com/mrubens) + +## Ingredients + +- A Slack or Discord channel such as `#vendor-status` receiving vendor status + feeds. Most vendors offer Subscribe via Slack, RSS-to-channel, or + email-to-channel. +- [Auto-respond channels](/automations#channel-automations) +- An [environment](/environments) covering the repositories that use these + vendors +- Roomote invited to the channel on Slack, or able to see it on Discord + +## Steps + +1. Create `#vendor-status` and subscribe it to every upstream status page you care about. +2. Invite Roomote to the channel. +3. Under **Settings > Automations > Auto-respond to channels**, add the channel. +4. Paste the launch criteria and instructions below. +5. Post a fake incident and confirm a task launches. Then post a scheduled-maintenance notice and confirm it does not. + +## Launch criteria + +Paste this into the channel's **Launch criteria** field: + +```text +Launch only for messages reporting a new incident, a worsening incident, or an +incident that regressed after being resolved, from one of our upstream vendors. + +Do not launch for scheduled-maintenance announcements, "monitoring" or +"resolved" updates to an incident already investigated, changelog or marketing +posts, or duplicate notifications about the same incident. + +Treat a message as an escalation when it adds affected components, expands +impact, or reports a worse status than before. When unsure whether an incident +could plausibly touch something we depend on in production, launch: a wasted +check is cheaper than a missed outage. +``` + +## Instructions + +Paste this into the channel's **Instructions** field. Roomote gives these +instructions to every task launched from the channel. + +```text +A vendor has posted a status incident. Determine whether it affects us: + +1. Identify the vendor and the affected components, regions, and features + from the message. +2. Search the codebase for how we actually use this vendor: SDKs, API + endpoints, features, and regions we depend on. +3. Compare our real usage against what the incident says is affected. +4. Reply in the thread with a clear verdict: + - "Affects us": which features or code paths, likely user impact, and a + suggested action. + - "Safe to ignore": why our usage is not touched. + +Keep it short. The on-call engineer reads this thread. Do not page anyone. +``` + +## Variations + +- Name the vendors explicitly in the criteria to ignore feeds subscribed to just in case. +- Ask Roomote to describe how to verify recovery whenever the verdict is **Affects us**. +- Use a multi-repository environment so the usage search covers every service. + +Auto-respond channels support Slack and Discord today. On Teams or Telegram, +forward status events to a supported channel or mention Roomote from a workflow. + +**Pairs well with:** [CI failure auto-fix](/cookbook/ci-failure-auto-fix) · +[support channel](/cookbook/support-channel) diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f5498ac0d..c369d0646 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -3,6 +3,17 @@ "theme": "mint", "name": "Roomote Docs", "description": "Documentation for Roomote, the open, self-hostable platform for cloud coding agents.", + "markdown": { + "instructions": [ + "When helping someone deploy Roomote, distinguish self-hosting from contributing to Roomote. Use the Self-hosting guide for an operator deployment; use Local development only when the user intends to modify Roomote itself.", + "Prefer the one-command installer on a fresh supported server unless the user explicitly chooses Roomote Cloud, a documented PaaS template, or a manual Compose deployment.", + "Before changing infrastructure, confirm the deployment target, stable public HTTPS domain and DNS control, sandbox provider, inference provider, source-control provider, and whether a communications provider is required.", + "Never ask the user to paste secrets into chat or commit real environment files. Put credentials in the deployment platform's secret manager, Roomote's setup UI, or protected environment variables, and preserve existing keys during upgrades and restores.", + "Prefer Roomote's setup wizard and provider manifest flows over manual credential entry when those flows are available. Ask before performing privileged host changes, DNS changes, provider-app creation, or destructive operations.", + "A setup is not complete when the services merely start. Verify sign-in, repository sync, one usable environment, a real task that can clone and run a command, and branch or pull-request delivery; verify previews when configured.", + "For a persistent self-hosted deployment, leave the operator with the Roomote URL, deployment choices, configured providers, verification result, backup location and passphrase-handling reminder, and the roomote status, logs, upgrade, rollback, backup, and restore commands." + ] + }, "colors": { "primary": "#B0CD26", "light": "#D6EE26", @@ -31,12 +42,16 @@ "groups": [ { "group": "Getting Started", - "pages": ["index", "self-hosting"] + "pages": ["index", "self-hosting", "homelab"] }, { "group": "Using Roomote", "pages": ["how-roomote-works", "use-cases", "tasks", "file-attachments"] }, + { + "group": "Cookbook", + "pages": ["cookbook/index", "cookbook/template"] + }, { "group": "Provider Configuration", "pages": [ diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 6ee6f6445..2f540d8d5 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -125,6 +125,7 @@ as per-task auth tokens or workspace paths. | `R_INSTANCE_ID` | Optional | Stable anonymous deployment identifier sent with telemetry and version checks. Use a random, non-identifying value when overriding it. | | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | +| `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/docs/homelab.mdx b/apps/docs/homelab.mdx new file mode 100644 index 000000000..6e8c3f823 --- /dev/null +++ b/apps/docs/homelab.mdx @@ -0,0 +1,252 @@ +--- +title: Homelab deployment +icon: house-plug +description: Run Roomote on a Proxmox VM or another Linux server in your homelab. +--- + +Run Roomote on hardware you control with a dedicated Linux VM, private +administration over Tailscale, and a public HTTPS origin for provider callbacks +and task previews. + +This guide uses **Proxmox VE with a Debian or Ubuntu VM** as the worked example. +Proxmox and Tailscale are not Roomote requirements: a bare-metal Linux server, +another hypervisor, WireGuard, or a conventional VPN can provide the same +roles. + + + Use a full virtual machine rather than an LXC container for the default Docker + sandbox provider. Roomote depends on Docker networking, cgroups, packet-filter + rules, and writable-layer quotas that are much easier to operate reliably in + a VM. LXC installations require host-specific privileged-container changes + and are not covered by this guide. + + +## Plan the deployment + +The recommended homelab shape separates private administration from public +application traffic: + +- **Proxmox VM:** runs Roomote, its datastores, and the default Docker task + sandboxes +- **Tailscale:** provides private SSH and administrative access to the VM +- **Public HTTPS origin:** receives source-control and communications-provider + callbacks and serves links generated by Roomote +- **Caddy:** terminates traffic for the application and task preview hostnames + +Roomote supports x86_64 and arm64. The installer requires at least 4 GB of RAM. +For a deployment that runs Docker task sandboxes on the same host, start with: + +| Resource | Recommended starting point | +| --- | --- | +| CPU | 4 vCPU, host CPU type | +| Memory | 8 GB | +| Disk | 60 GB or more on quota-capable storage | +| OS | Debian 12 or later, or Ubuntu 24.04 LTS | + +The default limit for each Docker task is 2 CPUs, 4 GiB of memory, and a 20 GiB +writable layer. Increase the VM resources for concurrent or Docker-heavy tasks, +or use a [hosted sandbox provider](/compute) so task workloads do not compete +with the Roomote services. + +## Create the Proxmox VM + +Create a VM from a Debian or Ubuntu cloud image or installation ISO: + +1. allocate at least 4 vCPU, 8 GB RAM, and 60 GB of disk +2. set the CPU type to `host` and use VirtIO for disk and networking +3. install and enable `qemu-guest-agent` +4. assign a DHCP reservation or static address on the LAN +5. enable SSH and apply operating-system updates + +A bare-metal mini PC, an existing Debian or Ubuntu server, or a VM on another +hypervisor works too. The important boundary is a clean Linux host where Docker +Engine and its networking and storage controls can operate normally. + +## Add private administration with Tailscale + +Install Tailscale on the VM using the +[official Linux instructions](https://tailscale.com/kb/1031/install-linux), then +confirm you can reach the host over its tailnet address before changing router +or firewall rules. Use Tailscale SSH or regular SSH over the tailnet for host +administration. + +Tailscale does not replace Roomote's public application origin. GitHub, Slack, +and other providers must reach callbacks from outside your tailnet. Live task +previews also use per-task hostnames, including wildcard preview DNS in the +normal production configuration. + + + Docker task sandboxes block private, link-local, shared-address, cloud + metadata, and Tailscale ranges by default. A repository hosted only on your + LAN or tailnet may be reachable from the VM but unreachable from a Roomote + task. Give self-hosted GitLab or Gitea a public, worker-reachable hostname + instead of disabling the sandbox network guard. + + +## Choose public ingress + +Choose one stable hostname before completing the setup wizard. Changing it +later also means updating source-control, sign-in, and communications-provider +redirect and webhook URLs. + +| Path | Best for | Tradeoffs | +| --- | --- | --- | +| Public DNS and port forwarding | Public IPv4, router control, and the simplest data path | Requires forwarding ports 80 and 443; dynamic addresses need DDNS | +| Cloudflare Tunnel | CGNAT or networks where inbound ports should stay closed | Requires tunnel and wildcard-hostname configuration | +| Tailscale Funnel | A limited public trial without router changes | Does not provide the wildcard hostname model needed for full live previews | + +### Public DNS and port forwarding + +Create DNS records for the application, preview root, and wildcard previews: + +```text +roomote.example.com A/AAAA +preview.roomote.example.com A/AAAA +*.preview.roomote.example.com A/AAAA +``` + +Forward TCP ports 80 and 443 from the router to the VM, then run: + +```sh +curl -fsSL https://get.roomote.dev | sudo bash -s -- \ + --domain roomote.example.com +``` + +The installer defaults the preview root to `preview.`. Use +`--preview-domain ` when your DNS layout differs. If the public address +changes, configure DDNS for all three records. This path does not work behind +carrier-grade NAT unless the ISP supplies a public address. + +### Cloudflare Tunnel + +Cloudflare Tunnel can publish Roomote without forwarding inbound router ports. +Install Roomote with Caddy's internal TLS mode because Cloudflare terminates the +public certificate: + +```sh +curl -fsSL https://get.roomote.dev | sudo bash -s -- \ + --domain roomote.example.com \ + --tls-mode internal +``` + +Route both the application hostname and preview wildcard through the same Caddy +instance rather than routing directly to individual Roomote containers. Standard +Cloudflare certificates require Roomote's flat preview-hostname layout; follow +the canonical +[Cloudflare Tunnel and flat preview hostname configuration](https://github.com/RooCodeInc/Roomote/blob/main/SELF_HOSTING.md#flat-preview-hostnames) +for the required environment values, tunnel ingress, origin TLS setting, and +**Match SNI to Host** option. Apply those values in `/opt/roomote/.env` and run +`sudo roomote up` before testing or exposing task previews; the install command +above otherwise retains the default nested preview hostname layout. + +### Tailscale Funnel + +Tailscale Funnel can expose a stable tailnet hostname to public callback +providers, but it does not support Roomote's normal wildcard preview hostname +layout. Treat it as a limited dashboard and callback trial, not the default +full deployment path. Use public DNS or Cloudflare Tunnel when users need live +task previews. + +## Let a local agent prepare the host + +Paste the following prompt into a local coding agent running on the VM or one +that can SSH to it. The agent prepares the host and installer; you complete +credential and authorization steps in Roomote's browser wizard. + +```text +Set up a self-hosted Roomote deployment on this homelab server. + +Safety and scope: +- Work only on a dedicated Debian 12+ or Ubuntu 24.04+ VM or bare-metal host. +- Stop if this is an LXC container or if fewer than 4 GB RAM or 40 GB free disk + are available. Warn me and ask before continuing if other production + workloads share the host. +- Explain changes before using sudo. Do not disable the firewall or expose + database, Redis, object-storage, API, or queue-dashboard ports. +- Never ask me to paste model, GitHub, Slack, or other provider secrets into + chat or the shell. Those belong in Roomote's browser setup wizard. + +Process: +1. Report the OS, architecture, CPU count, memory, free disk, default route, + public IPv4 detection result, and current Docker/storage configuration if + Docker is already installed. Do not change anything yet. +2. Ask me to choose one ingress path: public DNS plus ports 80/443, or + Cloudflare Tunnel. If I am behind CGNAT, recommend Cloudflare Tunnel. Ask for + the final application domain and optional preview domain. +3. Ask whether to install Tailscale for private SSH/admin access. If approved, + use Tailscale's official Linux package instructions and confirm access. Do + not treat the tailnet hostname as Roomote's public application origin. +4. For public DNS, tell me the exact app, preview, and wildcard DNS records and + router forwards I must create, then wait for me. Verify public DNS and ports + from outside the LAN where possible. For Cloudflare Tunnel, guide me through + creating the app and wildcard public hostnames without asking me to reveal a + tunnel token, and use Roomote's documented internal-TLS and flat-preview + configuration. +5. Download https://get.roomote.dev to a temporary file, show me the file path + and the exact installer flags you will use, and ask me to inspect it before + approval. Then run it with --domain and, for Cloudflare Tunnel, --tls-mode + internal. Do not write a new installer or manually recreate the Compose + stack. +6. Run `sudo roomote status`. If healthy, tell me to run `sudo roomote + setup-url` in a separate trusted terminal and open the result. Do not print, + capture, or repeat the tokenized URL in agent output or transcripts. Stop + while I complete sign-in, source control, inference, communications, and + environment setup in the browser. +7. After I confirm setup is complete, validate Docker's writable-layer quota + support, a small Roomote task, repository cloning, artifact upload, and a + configured preview. Summarize any failure and the relevant `roomote logs` + output without printing secrets. +``` + +## Complete setup in the browser + +The installer sets up Docker, Compose, generated secrets, Caddy, the Roomote +services, a systemd unit, and the `roomote` host CLI. When it finishes, open the +tokenized setup URL it prints and configure: + +1. the first admin account +2. a source-control provider and repository access +3. a ChatGPT subscription or inference provider +4. Docker or a hosted sandbox provider +5. an optional communications provider such as Slack +6. the first environment and Roomote task + +Keep provider credentials in the setup wizard or your deployment secret store, +not in shell history or an agent conversation. + +## Verify the homelab path + +After setup: + +1. run `roomote status` on the VM and confirm the services are healthy +2. open the public Roomote URL from a device outside the home network +3. confirm the expected repositories appear in Roomote +4. validate the sandbox provider from **Settings > Sandboxes** +5. run a small task and confirm it clones, executes, and uploads an artifact +6. test a configured live preview from outside the home network +7. if configured, mention the Roomote app in Slack and confirm it replies + +Common homelab failures include: + +- **The public URL works on cellular but not on Wi-Fi.** The router may not + support hairpin NAT. Add split-horizon DNS for LAN clients or use a resolver + that returns the correct internal route. +- **An `sslip.io` hostname does not resolve on the LAN.** DNS-rebind protection + may reject names that resolve to private addresses. Use a domain you control + or allow the exact hostname in the local resolver. +- **Certificates never issue.** Confirm public DNS, router forwarding, and host + firewall rules for ports 80 and 443. With a tunnel, use `--tls-mode internal` + and check the tunnel-to-Caddy origin TLS settings. +- **Port forwarding has no effect.** Compare the router WAN address with an + external public-IP check. Different addresses usually indicate CGNAT; use a + tunnel or request a public address from the ISP. +- **A task cannot clone a LAN repository.** Use a public, worker-reachable + source-control hostname. Do not remove the sandbox private-network guard. + +## Back up and update + +Use `roomote backup`, `roomote upgrade`, and `roomote rollback` for supported +day-2 operations. A Proxmox snapshot is useful before host maintenance, but it +does not replace an encrypted Roomote backup stored off the VM with its +passphrase stored separately. See [Self-hosting](/self-hosting#day-2-operations) +for backup, restore, and upgrade behavior. diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 952a68195..4b7cdd590 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -54,7 +54,10 @@ Roomote is released under the Fair Core License 1.0 (FCL-1.0-ALv2) — source-available, with each release converting to Apache-2.0 two years after it is published. A deployment is free for up to 10 registered users; adding more users requires a paid license key (Settings → Users, or the -`R_LICENSE_KEY` environment variable). The license prohibits disabling or +`R_LICENSE_KEY` environment variable). You can [buy a self-hosted license on +Roomote Cloud](https://cloud.roomote.dev/sign-up); purchased keys are refreshed +annually from the Cloud portal, and licensed deployments report their current +user count for subscription billing. The license prohibits disabling or circumventing the license key functionality. See the [LICENSE](https://github.com/RooCodeInc/Roomote/blob/main/LICENSE) file for details. diff --git a/apps/docs/integrations/better-stack.mdx b/apps/docs/integrations/better-stack.mdx index 7778e3eef..62cc38b66 100644 --- a/apps/docs/integrations/better-stack.mdx +++ b/apps/docs/integrations/better-stack.mdx @@ -23,3 +23,7 @@ Admins connect Better Stack once from **Settings > Integrations**. Roomote uses Better Stack as task context for investigation and triage. Code changes, validation, and review still happen through the normal Roomote task and repository workflow. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/integrations/grafana.mdx b/apps/docs/integrations/grafana.mdx index c1147bdd0..32c0c0a91 100644 --- a/apps/docs/integrations/grafana.mdx +++ b/apps/docs/integrations/grafana.mdx @@ -25,3 +25,7 @@ instance URL and a shared service account token. The current Grafana integration is read-only. It gives Roomote monitoring context during a task, but it does not create dashboards, edit alerts, or change Grafana configuration. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index afecc1d97..24fd1d9c9 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -14,6 +14,13 @@ This section does not list provider categories that have their own setup paths: communications providers, source-control providers, inference providers, and sandbox providers. Configure those from the provider-specific docs instead. +Deployment operators can prevent every integration in this curated catalog +from being configured or used by setting +`R_CURATED_INTEGRATIONS_DISABLED=true` and restarting Roomote. Existing +connections remain stored but inactive, so unsetting the value restores +them. This policy does not affect the separate provider categories above or +MCP servers defined on an environment. + ## Connection patterns You will usually see one of these setup models: diff --git a/apps/docs/integrations/neon.mdx b/apps/docs/integrations/neon.mdx index e7236bcf8..08c2a3310 100644 --- a/apps/docs/integrations/neon.mdx +++ b/apps/docs/integrations/neon.mdx @@ -23,3 +23,7 @@ need it. Neon gives Roomote database context inside the task. Keep any repository changes and human review on the normal Roomote and Git workflow. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/integrations/sentry.mdx b/apps/docs/integrations/sentry.mdx index bd4e5c7c9..3d63d153e 100644 --- a/apps/docs/integrations/sentry.mdx +++ b/apps/docs/integrations/sentry.mdx @@ -22,3 +22,7 @@ Admins connect Sentry once from **Settings > Integrations**. Sentry gives Roomote incident and performance context during a task. It can also support scheduled read-only Sentry triage. The final decision, code change, and review still happen in the normal task and repository flow. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/integrations/snowflake.mdx b/apps/docs/integrations/snowflake.mdx index 1490ab1c0..dfc1bc6be 100644 --- a/apps/docs/integrations/snowflake.mdx +++ b/apps/docs/integrations/snowflake.mdx @@ -32,3 +32,7 @@ when the key is encrypted. Snowflake provides shared data warehouse context inside Roomote tasks. Engineering changes and approvals still happen through your normal task and repository review flow. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/integrations/supabase.mdx b/apps/docs/integrations/supabase.mdx index fbc8ec20e..8581ff3c9 100644 --- a/apps/docs/integrations/supabase.mdx +++ b/apps/docs/integrations/supabase.mdx @@ -23,3 +23,7 @@ when they need it. Supabase gives Roomote read-only database context during a task. Code changes, migrations, and review still go through your normal engineering workflow. + +## Recipes using this + +- [Triage customer issues](/cookbook/support-channel) diff --git a/apps/docs/package.json b/apps/docs/package.json index 8723bb0ca..8ce330940 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -3,9 +3,15 @@ "version": "0.0.3", "private": true, "scripts": { - "dev": "mint dev", - "validate": "mint validate", - "check-links": "mint broken-links", - "check": "mint validate && mint broken-links" + "generate-cookbook-index": "node scripts/generate-cookbook-index.mjs", + "check-cookbook-index": "node scripts/generate-cookbook-index.mjs --check", + "dev": "pnpm run generate-cookbook-index && pnpm dlx --package mint@4.2.650 mint dev", + "validate": "pnpm run generate-cookbook-index && pnpm dlx --package mint@4.2.650 mint validate", + "check-links": "pnpm dlx --package mint@4.2.650 mint broken-links", + "check": "pnpm run validate && pnpm run check-links", + "build": "pnpm run generate-cookbook-index && pnpm dlx --package mint@4.2.650 mint validate" + }, + "dependencies": { + "yaml": "^2.9.0" } } diff --git a/apps/docs/providers/inference/amazon-bedrock.mdx b/apps/docs/providers/inference/amazon-bedrock.mdx index f9c2e5214..e58d4da98 100644 --- a/apps/docs/providers/inference/amazon-bedrock.mdx +++ b/apps/docs/providers/inference/amazon-bedrock.mdx @@ -1,12 +1,13 @@ --- title: Amazon Bedrock icon: 'https://unpkg.com/@lobehub/icons-static-svg@1.94.0/icons/bedrock.svg' -description: Connect Roomote to models served through the Amazon Bedrock Mantle API. +description: Connect Roomote to native Amazon Bedrock and Bedrock Mantle models. --- Amazon Bedrock provides managed access to models from multiple model developers -inside AWS. Roomote uses Bedrock's Mantle API-key endpoint, so setup does not -require long-lived IAM access-key and secret-key credentials. +inside AWS. Roomote supports both native Bedrock Runtime models and models +served through Bedrock's Mantle endpoints. Both use a Bedrock API key, so setup +does not require long-lived IAM access-key and secret-key credentials. ## Get a Mantle API key @@ -24,9 +25,20 @@ AWS_BEARER_TOKEN_BEDROCK=... AWS_REGION=us-east-1 ``` -Roomote exposes supported models with the `bedrock-mantle/` prefix. The region -defaults to `us-east-1` when omitted. Enable the models your account and region -can access, then choose the default and role mappings. +Roomote exposes Mantle models with the `bedrock-mantle/` prefix and OpenCode's +native Bedrock provider models with the `amazon-bedrock/` prefix. The region +defaults to `us-east-1` when omitted. Native Claude and other Bedrock Runtime +models can use regional and cross-region inference-profile prefixes, such as +`eu.`, `us.`, or `global.`. OpenAI models selected through the native provider +continue to use Mantle's Responses endpoint automatically. Enable only models +your account and configured region can access, then choose the default and role +mappings. + +Roomote keeps Mantle models as the recommended defaults. Use native Bedrock +models when you need standard Bedrock Runtime behavior or a regional inference +profile that is not available through Mantle. Inference-profile ARNs are not +supported through Roomote's hosted inference gateway; use the plain model or +cross-region profile ID instead. ## Cost behavior @@ -39,7 +51,7 @@ rates on the [Bedrock pricing page](https://aws.amazon.com/bedrock/pricing/). 1. save the Mantle API key and matching region 2. confirm Amazon Bedrock models appear in **Settings > Models** -3. enable a model available in that region and assign it to a role +3. enable a Mantle or native model available in that region and assign it to a role 4. run a small task and confirm usage appears in the task and AWS account ## Common issues @@ -48,5 +60,8 @@ rates on the [Bedrock pricing page](https://aws.amazon.com/bedrock/pricing/). access key, and that it was created in the configured region. - **A model fails although it is listed.** Confirm the model is available to your AWS account in `AWS_REGION` and that any required model access is enabled. +- **A native Anthropic model is denied.** Complete Anthropic's first-time-use + and AWS Marketplace access requirements for Bedrock Runtime. These + requirements do not apply to the same model through Mantle. - **Costs differ from Roomote's estimate.** AWS may apply regional, caching, provisioned-throughput, or model-specific pricing that changes independently. diff --git a/apps/docs/scripts/generate-cookbook-index.mjs b/apps/docs/scripts/generate-cookbook-index.mjs new file mode 100644 index 000000000..dd85ff042 --- /dev/null +++ b/apps/docs/scripts/generate-cookbook-index.mjs @@ -0,0 +1,103 @@ +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const cookbookDirectory = join(scriptDirectory, '..', 'cookbook'); +const indexPath = join(cookbookDirectory, 'index.mdx'); +const tableStart = '{/* cookbook-recipes:start */}'; +const tableEnd = '{/* cookbook-recipes:end */}'; + +const requiredFields = ['title', 'description', 'contributor']; + +function parseFrontmatter(source, fileName) { + const match = source.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/); + + if (!match) { + throw new Error(`Missing frontmatter in ${fileName}`); + } + + const metadata = parse(match[1]); + + for (const field of requiredFields) { + if (typeof metadata[field] !== 'string' || metadata[field].trim() === '') { + throw new Error(`Missing ${field} in ${fileName}`); + } + } + + return metadata; +} + +function escapeTableCell(value) { + return value.replaceAll('|', '\\|').replaceAll('\n', ' ').trim(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function renderLinkedValue(value, url) { + const text = escapeTableCell(value); + return url ? `[${text}](${url})` : text; +} + +async function readRecipe(fileName) { + const source = await readFile(join(cookbookDirectory, fileName), 'utf8'); + const metadata = parseFrontmatter(source, fileName); + const slug = `/cookbook/${basename(fileName, '.mdx')}`; + + return { + title: metadata.title, + description: metadata.description, + contributor: metadata.contributor, + contributorUrl: metadata.contributor_url, + contributorCompany: metadata.contributor_company, + contributorCompanyUrl: metadata.contributor_company_url, + slug, + }; +} + +function renderTable(recipes) { + const rows = recipes + .sort((left, right) => left.title.localeCompare(right.title)) + .map((recipe) => { + return `| [${escapeTableCell(recipe.title)}](${recipe.slug}) | ${escapeTableCell(recipe.description)} |`; + }); + + return ['| Recipe | Use to |', '| --- | --- |', ...rows].join( + '\n', + ); +} + +const recipeFiles = (await readdir(cookbookDirectory)) + .filter((fileName) => fileName.endsWith('.mdx')) + .filter((fileName) => !['index.mdx', 'template.mdx'].includes(fileName)) + .sort(); +const recipes = await Promise.all(recipeFiles.map(readRecipe)); +const indexSource = await readFile(indexPath, 'utf8'); +const tablePattern = new RegExp( + `${escapeRegExp(tableStart)}[\\s\\S]*?${escapeRegExp(tableEnd)}`, +); + +if (!tablePattern.test(indexSource)) { + throw new Error('Cookbook index is missing the generated table markers'); +} + +const generatedTable = `${tableStart}\n${renderTable(recipes)}\n${tableEnd}`; +const nextIndexSource = indexSource.replace(tablePattern, generatedTable); +const checkOnly = process.argv.includes('--check'); + +if (nextIndexSource !== indexSource) { + if (checkOnly) { + console.error( + 'Cookbook index is out of date; run generate-cookbook-index.', + ); + process.exitCode = 1; + } else { + await writeFile(indexPath, nextIndexSource); + console.log(`Updated cookbook index with ${recipes.length} recipes`); + } +} else { + console.log(`Cookbook index is up to date with ${recipes.length} recipes`); +} diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 9eef67544..ccea1110b 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -30,6 +30,10 @@ team needs them. - **Your own server** gives you the most infrastructure control with the one-command installer below. +Running Roomote on Proxmox or another server at home? Follow the +[Homelab deployment guide](/homelab) for VM sizing, Tailscale administration, +public ingress, and a prompt that a local agent can follow. + By the end of setup, you should have a reachable Roomote URL, sign-in, a source-control provider, a ChatGPT subscription or inference provider, at least one environment, and a first reviewable Roomote task. diff --git a/apps/docs/skills.mdx b/apps/docs/skills.mdx index 91318deca..d8be6d2fd 100644 --- a/apps/docs/skills.mdx +++ b/apps/docs/skills.mdx @@ -47,6 +47,9 @@ Good custom skills include: - a database migration review guide - a customer-escalation triage playbook +The [support channel recipe](/cookbook/support-channel) shows a complete custom +skill with launch criteria and a production-safe investigation flow. + If the instruction is only one sentence, it probably belongs in [Agent Guidance](/agent-guidance), [environment guidance](/environments), or the task prompt instead. diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index f4bd0e2ac..f8a87af5b 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -66,15 +66,29 @@ A Roomote deployment is free for up to 10 users. Every registered user account in the deployment counts toward the limit, whichever sign-in path or surface they use. Removed users free their seat. -To add more than 10 users, apply a license key from the Roomote maintainers in -either of these ways: +To add more than 10 users, [buy a self-hosted license on Roomote +Cloud](https://cloud.roomote.dev/sign-up), then apply its key in either of these +ways: - enter it in **Settings > Users > License** as an admin, or - set `R_LICENSE_KEY` on the deployment (for example in Compose / `.env.production`); when that env var is set, it takes precedence over any key stored in Settings -The License section shows the current seat usage, license status, and licensee. -Keys are verified offline; the deployment never contacts a license server. +The License section shows the current seat usage, license status, licensee, and +expiry date. Purchased keys are issued annually and can be refreshed from the +Roomote Cloud portal while the subscription is active. Replace the key in +Settings or `R_LICENSE_KEY` after refreshing it. + +Keys are signature-verified locally, then activated with Roomote Cloud for the +deployment's stable installation ID. A paid seat limit remains active while +the Cloud activation lease is current (up to 72 hours); without it, the +deployment uses the free-seat limit. Roomote Cloud records the current user +count and short-lived peaks for subscription billing, independently of +anonymous analytics. License usage is not sent to the Ping analytics service. + +Each license may be active on one deployment at a time. If you move a +deployment, contact Roomote support to reset the existing activation before +using its license key on the replacement. When all seats are in use, existing users keep working normally — only new sign-ups are blocked until a seat is freed by removing a user or a license key diff --git a/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts b/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts index 9488ecad6..399e46858 100644 --- a/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts +++ b/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts @@ -138,4 +138,43 @@ describe('triggerAutoResume', () => { }), ); }); + + it('preserves Discord reply context when creating a snapshot resume run', async () => { + const resolution = createMockResolvedRequest({ + status: 'resumable', + snapshotId: 'snap-preview-discord', + taskRun: { + ...createMockTaskRun({ + id: 44, + payload: { + repo: 'owner/repo', + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: 'message-1', + }, + }), + port: 3000, + }, + }); + + await triggerAutoResume(resolution, { + userId: 'viewer-user', + tokenType: 'pt', + version: 1, + }); + + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: 'message-1', + }), + }), + }), + ); + }); }); diff --git a/apps/preview-proxy/src/handlers/auto-resume.ts b/apps/preview-proxy/src/handlers/auto-resume.ts index a0327ee2b..cc8e08c51 100644 --- a/apps/preview-proxy/src/handlers/auto-resume.ts +++ b/apps/preview-proxy/src/handlers/auto-resume.ts @@ -2,6 +2,7 @@ import { type TaskPayload, TaskPayloadKind, activeRunStatuses, + populateSnapshotResumeCommunicationMetadata, populateSnapshotResumeSlackMetadata, restoreSnapshotResumeVisiblePromptFields, type PreviewTokenContext, @@ -111,6 +112,9 @@ export async function triggerAutoResume( populateSnapshotResumeSlackMetadata(payload, { sourcePayload: taskRun.payload, }); + populateSnapshotResumeCommunicationMetadata(payload, { + sourcePayload: taskRun.payload, + }); restoreSnapshotResumeVisiblePromptFields(payload, sourcePayload); // Resumes never create tasks and carry no initiator; the resuming human diff --git a/apps/web/src/app/(centered)/layout.tsx b/apps/web/src/app/(centered)/layout.tsx index faaea15d3..6c0cce9e5 100644 --- a/apps/web/src/app/(centered)/layout.tsx +++ b/apps/web/src/app/(centered)/layout.tsx @@ -36,7 +36,7 @@ export default function OnboardingLayout({ surfaceClassName="relative flex items-center justify-center" >
- +
{children}
diff --git a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx index d1d42f39b..d32fdd091 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx @@ -88,6 +88,7 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) { diff --git a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx index 251f69e0e..d4353fe82 100644 --- a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.client.test.tsx @@ -1,11 +1,16 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -const { invalidateQueriesMock, linkedAccountState, toastWarningMock } = - vi.hoisted(() => ({ - invalidateQueriesMock: vi.fn().mockResolvedValue(undefined), - linkedAccountState: { mapping: null as { discordUserId: string } | null }, - toastWarningMock: vi.fn(), - })); +const { + invalidateQueriesMock, + linkedAccountState, + providerSetupState, + toastWarningMock, +} = vi.hoisted(() => ({ + invalidateQueriesMock: vi.fn().mockResolvedValue(undefined), + linkedAccountState: { mapping: null as { discordUserId: string } | null }, + providerSetupState: { satisfied: false }, + toastWarningMock: vi.fn(), +})); vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: toastWarningMock }, @@ -31,7 +36,7 @@ vi.mock('@tanstack/react-query', () => ({ ], runtimeSatisfied: true, savedSatisfied: false, - setupSatisfied: false, + setupSatisfied: providerSetupState.satisfied, discord: { installations: [], }, @@ -73,8 +78,8 @@ vi.mock('@/hooks/linked-accounts', () => ({ useDiscordLinkedAccount: () => ({ data: linkedAccountState }), })); -vi.mock('@/components/settings/DiscordSetupStatus', () => ({ - DiscordSetupStatus: () =>
Connect Discord server
, +vi.mock('@/components/settings/DiscordLinkAccountStep', () => ({ + DiscordLinkAccountStep: () =>
Link Discord account step
, })); vi.mock('./ProviderSetupExperience', () => ({ @@ -89,6 +94,22 @@ import { StepDiscordSetup } from './StepDiscordSetup'; describe('StepDiscordSetup', () => { beforeEach(() => { linkedAccountState.mapping = null; + providerSetupState.satisfied = false; + }); + + it('shows only account linking when Discord is already configured', () => { + providerSetupState.satisfied = true; + + render(); + + expect( + screen.getByRole('heading', { name: 'Link Discord Account' }), + ).toBeInTheDocument(); + expect(screen.getByText('Link Discord account step')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + expect( + screen.queryByRole('button', { name: 'Save and connect Discord' }), + ).not.toBeInTheDocument(); }); it('warns when connection finishing fails and refreshes setup state', async () => { @@ -109,7 +130,10 @@ describe('StepDiscordSetup', () => { expect(invalidateQueriesMock).toHaveBeenCalledWith({ queryKey: ['linkedAccounts.discord'], }); - expect(screen.getByText('Connect Discord server')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Link Discord Account' }), + ).toBeInTheDocument(); + expect(screen.getByText('Link Discord account step')).toBeInTheDocument(); }); it('continues after account linking without requiring a destination', async () => { diff --git a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx index e96e98bb6..d778d54e3 100644 --- a/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepDiscordSetup.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner'; import { useTRPC } from '@/trpc/client'; import { ArrowRight, Button, Spinner } from '@/components/system'; -import { DiscordSetupStatus } from '@/components/settings/DiscordSetupStatus'; +import { DiscordLinkAccountStep } from '@/components/settings/DiscordLinkAccountStep'; import { useDiscordLinkedAccount } from '@/hooks/linked-accounts'; import { @@ -15,6 +15,7 @@ import { getSetupVisibleFields, ProviderSetupExperience, } from './ProviderSetupExperience'; +import { StepTitle } from './StepTitle'; import { SetupFooter } from './SetupFooter'; export function StepDiscordSetup({ @@ -113,8 +114,11 @@ export function StepDiscordSetup({ })) } /> - ) : provider?.discord ? ( - + ) : provider && isConfigured ? ( +
+ + +
) : status.isError ? (

Unable to load Discord setup. Refresh and try again. diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx index 19dafc708..e2af1e84d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/HistoricalContent.client.test.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; -const { isTaskRunAsleepMock } = vi.hoisted(() => ({ +const { isTaskRunAsleepMock, retryFailedStartMutate } = vi.hoisted(() => ({ isTaskRunAsleepMock: vi.fn(() => false), + retryFailedStartMutate: vi.fn(), })); vi.mock('@/components/system', () => ({ @@ -10,8 +11,18 @@ vi.mock('@/components/system', () => ({ ArrowRight: () => , Badge: ({ children }: { children: ReactNode }) => {children}, BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, - Button: ({ children }: { children: ReactNode }) => ( - + Button: ({ + children, + onClick, + disabled, + }: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + ), Check: () => , HelpCircle: () => , @@ -20,6 +31,13 @@ vi.mock('@/components/system', () => ({ X: () => , })); +vi.mock('@/hooks/task-runs', () => ({ + useRetryFailedTaskStart: () => ({ + mutate: retryFailedStartMutate, + isPending: false, + }), +})); + vi.mock('@/components/ai-elements', () => ({ Message: ({ children }: { children: ReactNode }) =>

{children}
, MessageContent: ({ children }: { children: ReactNode }) => ( @@ -174,6 +192,108 @@ describe('HistoricalContent', () => { ).toBeInTheDocument(); }); + it('offers a retry when the server says the start can be relaunched', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + + expect(retryFailedStartMutate).toHaveBeenCalledWith({ + taskId: 'task-123', + runId: 3734, + }); + }); + + it('offers no retry once the late transcript has landed', () => { + // The sandbox came up after the provisioning deadline: the run is + // failed but the agent's work is in the thread, so the server refuses + // the relaunch (it would redo work that already happened) and reports + // canRetryFailedStart: false. + render( + , + ); + + expect( + screen.getByText('Task ended because of an error:'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Retry' }), + ).not.toBeInTheDocument(); + }); + + it('offers no retry when the run carries no server verdict', () => { + render( + , + ); + + expect( + screen.getByText('Task ended because of an error:'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Retry' }), + ).not.toBeInTheDocument(); + }); + it('prefers an explicit footer over the generic task error footer', () => { render( ('smooth'); + const retryFailedStart = useRetryFailedTaskStart(); const taskFailureFooter = useMemo(() => { const displayError = getTaskRunDisplayError(taskRun); @@ -52,8 +54,30 @@ export function HistoricalContent({ session, footer }: HistoricalContentProps) { return null; } - return ; - }, [taskRun]); + // The server decides: `enqueueTaskRelaunch` refuses a run that already + // produced agent output, because relaunching would redo work it did — + // a review it posted would post twice. Reading its verdict rather than + // re-deriving the rules here is what keeps the button from appearing + // where it would only error, or hiding where a retry would have + // worked. + const canRetry = taskRun.canRetryFailedStart === true; + + return ( + + retryFailedStart.mutate({ + taskId: session.taskId, + runId: taskRun.id, + }) + : undefined + } + retryPending={retryFailedStart.isPending} + /> + ); + }, [retryFailedStart, session.taskId, taskRun]); const messagesFooter = useMemo(() => { const onboardingCompletionFooter = onboardingEnvironment ? ( @@ -145,7 +169,15 @@ function WakingUpMessage() { ); } -function TaskFailureMessage({ error }: { error: string }) { +function TaskFailureMessage({ + error, + onRetry, + retryPending = false, +}: { + error: string; + onRetry?: (() => void) | undefined; + retryPending?: boolean; +}) { return ( @@ -156,6 +188,19 @@ function TaskFailureMessage({ error }: { error: string }) {
{error}
+ {onRetry && ( +
+ +
+ )}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx index 913e28711..5b0505f75 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.client.test.tsx @@ -32,10 +32,6 @@ vi.mock('./prompt-input', () => ({ PromptInput: () =>
, })); -vi.mock('./startup', () => ({ - Startup: () =>
, -})); - import { TaskInputStack } from './TaskInputStack'; const baseSession: TaskSession = { @@ -70,7 +66,7 @@ describe('TaskInputStack', () => { }); }); - it('shows the startup surface instead of the prompt input while booting', () => { + it('leaves the input area empty while startup renders in the conversation', () => { render( { />, ); - expect(screen.getByTestId('startup')).toBeInTheDocument(); expect(screen.queryByTestId('prompt-input')).not.toBeInTheDocument(); }); @@ -100,6 +95,5 @@ describe('TaskInputStack', () => { ); expect(screen.getByTestId('prompt-input')).toBeInTheDocument(); - expect(screen.queryByTestId('startup')).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx index b77e6faae..d8f540960 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/LiveContent.tsx @@ -32,6 +32,7 @@ import { ConnectionStatusBanner } from './ErrorFallback'; import { PendingUserInputRequestStateProvider } from './PendingUserInputRequestPanel'; import { TaskInputStack } from './TaskInputStack'; import { OnboardingCompletionMessage } from './OnboardingCompletionMessage'; +import { ProductTips, Startup } from './startup'; interface LiveContentProps { session: TaskSession; @@ -89,6 +90,8 @@ function LiveContentInner({ }, [onTaskPhaseChange, taskPhase]); const asleep = isTaskRunAsleep(session.taskRun); + const bootingTaskRun = + session.sessionState === 'booting' ? session.taskRun : null; const [messagesInitialScrollBehavior, setMessagesInitialScrollBehavior] = useState<'smooth' | 'instant'>('smooth'); @@ -251,11 +254,33 @@ function LiveContentInner({ scrollRef={messagesRef} initialScrollBehavior={messagesInitialScrollBehavior} footer={ - session.onboardingEnvironment ? ( - - ) : undefined + <> + {session.onboardingEnvironment && ( + + )} + {bootingTaskRun && ( + <> + + + + )} + } />
@@ -265,7 +290,6 @@ function LiveContentInner({ promptInputRef={promptInputRef} onFileSearchOpen={handleFileSearchOpen} onCommandSearchOpen={handleCommandSearchOpen} - onBootStatusChange={onBootStatusChange} scrollToBottom={scrollToBottom} /> diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx index 4de01f857..fb9f1e11a 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/TaskInputStack.tsx @@ -10,7 +10,6 @@ import { import { PendingEnvVarRequestPanel } from './PendingEnvVarRequestPanel'; import { PromptInput, type PromptInputHandle } from './prompt-input'; import { QueuedMessages } from './QueuedMessages'; -import { Startup } from './startup'; import { ActiveSubtasksList } from './ActiveSubtasksList'; import { TodoList } from './TodoList'; @@ -19,22 +18,19 @@ export function TaskInputStack({ promptInputRef, onFileSearchOpen, onCommandSearchOpen, - onBootStatusChange, scrollToBottom, }: { session: TaskSession; promptInputRef: { current: PromptInputHandle | null }; onFileSearchOpen: (insertPosition?: number) => void; onCommandSearchOpen: (insertPosition?: number) => void; - onBootStatusChange?: () => void; scrollToBottom: () => void; }) { const { shouldHidePromptInput } = usePendingUserInputRequestState(); const [visibleEnvVarRequestKey, setVisibleEnvVarRequestKey] = useState< string | null >(null); - const bootingTaskRun = - session.sessionState === 'booting' ? session.taskRun : null; + const isBooting = session.sessionState === 'booting'; useEffect(() => { setVisibleEnvVarRequestKey(null); @@ -53,24 +49,7 @@ export function TaskInputStack({ onVisibleRequestKeyChange={setVisibleEnvVarRequestKey} /> - {bootingTaskRun ? ( -
- -
- ) : ( + {!isBooting && (
0; const isExitCodePresent = cmd.exitCode !== undefined; const isPending = status === 'in_progress' || (status === null && !isExitCodePresent); @@ -69,14 +70,14 @@ export const AcpCommandOutputMessage = ({ code={output} language="bash" variant="compact" - collapsible={false} - defaultCollapsed={false} + collapsible={isOutputPresent} + defaultCollapsed={isOutputPresent} forceDark={true} - renderContent={false} - maxHeight={undefined} + renderContent={isOutputPresent} + maxHeight={isOutputPresent ? 400 : undefined} command={command ?? ''} showCommandCopy - showOutputCopy={false} + showOutputCopy={isOutputPresent} > diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx index 4f6f9bd35..ec649cdfb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx @@ -50,7 +50,7 @@ describe('AcpCommandOutputMessage', () => { codeBlockCommandSpy.mockClear(); }); - it('renders command headers without expandable raw output', () => { + it('renders command output blocks as collapsible and collapsed by default', () => { const msg: AcpToolResultUiMessage = { ...baseMsg, text: '$ pnpm test\nPASS src/example.test.ts', @@ -75,17 +75,17 @@ describe('AcpCommandOutputMessage', () => { expect(codeBlockSpy).toHaveBeenCalledTimes(1); expect(codeBlockSpy).toHaveBeenCalledWith( expect.objectContaining({ - collapsible: false, - defaultCollapsed: false, - renderContent: false, - maxHeight: undefined, + collapsible: true, + defaultCollapsed: true, + renderContent: true, + maxHeight: 400, showCommandCopy: true, - showOutputCopy: false, + showOutputCopy: true, }), ); }); - it('keeps command output blocks non-collapsible even when there is no output to show', () => { + it('does not enable collapsible mode when there is no output to show', () => { const msg: AcpToolCallUiMessage = { ...baseMsg, text: ' \n', 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 4af75fca0..da53510ec 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 @@ -84,9 +84,6 @@ export function hidesExpandedToolResult( return ( isInternalDebugToolCallMessage(msg) || msg.data.kind === 'read' || - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isRead === true || - data.isExecute === true + data.isRead === true ); } 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 afca3a427..b28a93452 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 @@ -84,6 +84,7 @@ vi.mock('./hooks', () => ({ vi.mock('./startup', () => ({ Startup: () =>
, + ProductTips: () =>
, SnapshotResumeFailureFooter: () => (
), @@ -208,6 +209,7 @@ describe('SandboxPage', () => { renderPage(); expect(screen.getByTestId('startup')).toBeInTheDocument(); + expect(screen.getByTestId('product-tips')).toBeInTheDocument(); expect(screen.queryByTestId('sandbox-provider')).not.toBeInTheDocument(); }); @@ -323,6 +325,7 @@ describe('SandboxPage', () => { renderPage(); expect(screen.getByTestId('startup')).toBeInTheDocument(); + expect(screen.queryByTestId('product-tips')).not.toBeInTheDocument(); expect(screen.queryByTestId('historical-content')).not.toBeInTheDocument(); expect( screen.queryByTestId('snapshot-resume-failure-footer'), diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index bc7d33fd7..eec5a064f 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -34,7 +34,7 @@ import { useTaskMessageEnvelopes, } from './hooks'; -import { SnapshotResumeFailureFooter, Startup } from './startup'; +import { ProductTips, SnapshotResumeFailureFooter, Startup } from './startup'; import { DraftPromptBanner } from './DraftPromptBanner'; import { Header } from './Header'; import { HistoricalContent } from './HistoricalContent'; @@ -280,13 +280,17 @@ export default function SandboxPage() { return (
- +
+
+ +
+
{session.draftPrompt && ( )} @@ -298,13 +302,18 @@ export default function SandboxPage() { return (
- +
+
+ + +
+
{session.draftPrompt && ( )} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx new file mode 100644 index 000000000..c27d345bd --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.client.test.tsx @@ -0,0 +1,74 @@ +import type { ReactNode } from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +vi.mock('@/components/ai-elements', () => ({ + Message: ({ children }: { children: ReactNode }) =>
{children}
, + MessageContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('@/components/system', () => ({ + ArrowRight: () => null, + BasicTooltip: ({ children }: { children: ReactNode }) => <>{children}, + Button: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), + Lightbulb: () => null, + X: () => null, +})); + +import { + getTipDisplayDurationMs, + PRODUCT_TIPS, + ProductTips, +} from './ProductTips'; + +describe('ProductTips', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(Math, 'random').mockReturnValue(0.99); + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('cycles through the shuffled tips using reading-time durations', () => { + render(); + + act(() => {}); + expect(screen.getByText(PRODUCT_TIPS[0].title)).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(getTipDisplayDurationMs(PRODUCT_TIPS[0])); + }); + + expect(screen.getByText(PRODUCT_TIPS[1].title)).toBeInTheDocument(); + }); + + it('persists dismissal and stays hidden on later mounts', () => { + const { unmount } = render(); + + act(() => {}); + fireEvent.click(screen.getByRole('button', { name: 'Hide product tips' })); + + expect(screen.queryByText(PRODUCT_TIPS[0].title)).not.toBeInTheDocument(); + + unmount(); + render(); + act(() => {}); + + expect( + screen.queryByRole('button', { name: 'Hide product tips' }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx new file mode 100644 index 000000000..514307c33 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/ProductTips.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { + ArrowRight, + BasicTooltip, + Button, + Lightbulb, + X, +} from '@/components/system'; +import { Message, MessageContent } from '@/components/ai-elements'; + +const PRODUCT_TIPS_DISMISSED_STORAGE_KEY = 'roomote:product-tips-dismissed:v1'; + +const MIN_TIP_DURATION_MS = 8_000; +const MAX_TIP_DURATION_MS = 15_000; +const READING_CHARS_PER_SECOND = 10; + +export const PRODUCT_TIPS = [ + { + title: 'Debug production backward', + description: + 'Ask Roomote to inspect a Sentry issue, deployment logs, or Grafana alert, trace it into the codebase, and prepare the smallest verified fix.', + }, + { + title: 'Turn failed CI into a fix', + description: + 'CI Failure Triage can detect a persistent failure on the default branch, reproduce it in the configured environment, and open a fix PR.', + }, + { + title: 'Investigate surprising metrics', + description: + 'Connect PostHog and ask why a metric moved. Roomote can inspect events, experiments, and feature flags, then trace likely causes into the code.', + }, + { + title: 'Debug with real database state', + description: + 'Use read-only Supabase or Neon access to investigate data-dependent bugs, compare the live schema with application assumptions, and plan a safe fix.', + }, + { + title: 'Build directly from the spec', + description: + 'Point Roomote at a Notion spec or Linear issue and ask it to trace affected code, identify missing decisions, implement the change, and link the PR back.', + }, + { + title: 'Turn support into engineering work', + description: + 'Auto-respond in a support or bug channel so new reports become repository-grounded investigations—even when nobody explicitly mentions Roomote.', + }, + { + title: 'Triage issues as they arrive', + description: + 'Roomote can investigate each newly opened issue and post a concrete implementation plan with the relevant code paths before anyone picks it up.', + }, + { + title: 'Keep old PRs mergeable', + description: + 'Label selected pull requests for automatic conflict resolution. Roomote periodically rebases the work, resolves safe conflicts, and updates the branch.', + }, + { + title: 'Audit what just shipped', + description: + 'Run security and code-quality auditors over recently merged PRs to surface high-confidence risks and maintainability problems as follow-up work.', + }, + { + title: 'Schedule repository-aware work', + description: + 'Create recurring tasks such as release-readiness checks, dependency audits, or weekly product reports that can inspect your code and connected tools.', + }, +] as const; + +type ProductTip = (typeof PRODUCT_TIPS)[number]; + +export function getTipDisplayDurationMs(tip: ProductTip): number { + const readingTime = + Math.ceil( + `${tip.title} ${tip.description}`.length / READING_CHARS_PER_SECOND, + ) * 1_000; + + return Math.min( + MAX_TIP_DURATION_MS, + Math.max(MIN_TIP_DURATION_MS, readingTime), + ); +} + +function shuffleTips(): ProductTip[] { + const tips = [...PRODUCT_TIPS]; + + for (let index = tips.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(Math.random() * (index + 1)); + [tips[index], tips[swapIndex]] = [tips[swapIndex]!, tips[index]!]; + } + + return tips; +} + +export function ProductTips() { + const initialized = useRef(false); + const [tips, setTips] = useState([]); + const [tipIndex, setTipIndex] = useState(0); + + useEffect(() => { + if (initialized.current) { + return; + } + + initialized.current = true; + + if ( + window.localStorage.getItem(PRODUCT_TIPS_DISMISSED_STORAGE_KEY) === '1' + ) { + return; + } + + setTips(shuffleTips()); + }, []); + + useEffect(() => { + const tip = tips[tipIndex]; + + if (!tip || tips.length < 2) { + return; + } + + const durationMs = getTipDisplayDurationMs(tip); + + const timer = window.setTimeout(() => { + setTipIndex((currentIndex) => (currentIndex + 1) % tips.length); + }, durationMs); + + return () => { + window.clearTimeout(timer); + }; + }, [tipIndex, tips]); + + const tip = tips[tipIndex]; + + if (!tip) { + return null; + } + + const dismiss = () => { + window.localStorage.setItem(PRODUCT_TIPS_DISMISSED_STORAGE_KEY, '1'); + setTips([]); + }; + + const showNextTip = () => { + setTipIndex((currentIndex) => (currentIndex + 1) % tips.length); + }; + + const durationMs = getTipDisplayDurationMs(tip); + const totalSeconds = Math.ceil(durationMs / 1_000); + + return ( + + +
+ +
+
{tip.title}
+
+ {tip.description} +
+ +
+ + + +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx index a693ec65d..8b930e954 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.client.test.tsx @@ -47,6 +47,7 @@ vi.mock('@/components/system', () => ({ ThumbsDown: () => null, SquareDashedMousePointer: () => null, MessageSquareIcon: () => null, + MessageSquareWarning: () => null, RotateCcw: () => null, })); @@ -182,7 +183,7 @@ describe('StartupSequence', () => { ).toHaveAttribute('href', 'https://example.com/shot.png'); }); - it('uses a flex-bounded scroll container when startup content exceeds the available height', () => { + it('renders startup content inline without its own scroll surface', () => { const { container } = render( { ); const outerContainer = container.firstChild as HTMLElement | null; - const innerContent = outerContainer?.firstChild as HTMLElement | null; - expect(outerContainer).toHaveClass( - 'flex-1', - 'min-h-0', - 'overflow-y-auto', - 'p-4', - ); - expect(innerContent).toHaveClass('mx-auto', 'w-full', 'max-w-4xl', 'px-2'); + expect(outerContainer).toHaveClass('flex', 'flex-col', 'gap-2'); + expect(outerContainer).not.toHaveClass('overflow-y-auto', 'p-4'); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx index ac130aa64..1797ba054 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/StartupMessage.tsx @@ -22,7 +22,7 @@ import { getTaskRunErrorDisplayMessage } from '@/lib/task-run-errors'; import { Message, MessageContent, Shimmer } from '@/components/ai-elements'; import { SandboxLogsTerminal } from '@/components/sandbox'; -import { MessageSquareWarning, RotateCcw } from 'lucide-react'; +import { MessageSquareWarning, RotateCcw } from '@/components/system'; export interface StartupStep { status: RunStatus; @@ -324,32 +324,30 @@ export const StartupSequence = ({ : -1; return ( -
-
- {steps.map((step, index) => ( -
- + {steps.map((step, index) => ( +
+ + {index === logInsertIndex && ( + - {index === logInsertIndex && ( - - )} -
- ))} - -
+ )} +
+ ))} +
); }; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts b/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts index 42f9e2828..77c7bec10 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/index.ts @@ -1 +1,2 @@ export * from './Startup'; +export * from './ProductTips'; diff --git a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts index abf30c14c..b6bc24a51 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/__tests__/route.test.ts @@ -171,6 +171,22 @@ describe('GET /api/mcp-oauth/callback', () => { expect(discoverOAuthEndpointsMock).not.toHaveBeenCalled(); }); + it('rejects a pending callback when integrations become disabled', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:13000', + R_PUBLIC_URL: 'https://customer.example', + R_CURATED_INTEGRATIONS_DISABLED: true, + }); + + const response = await GET(buildRequest('?code=auth-code&state=state-1')); + + expect(response.headers.get('location')).toBe( + 'https://customer.example/settings?mcp=error&reason=callback_failed', + ); + expect(consumeOAuthStateMock).not.toHaveBeenCalled(); + expect(exchangeCodeForTokensMock).not.toHaveBeenCalled(); + }); + it('falls back to R_APP_URL for token exchange redirect_uri when R_PUBLIC_URL is unset', async () => { bootstrapWebRuntimeEnvMock.mockResolvedValue({ R_APP_URL: 'http://localhost:13000', diff --git a/apps/web/src/app/api/mcp-oauth/callback/route.ts b/apps/web/src/app/api/mcp-oauth/callback/route.ts index c8e42bb25..1a3e11550 100644 --- a/apps/web/src/app/api/mcp-oauth/callback/route.ts +++ b/apps/web/src/app/api/mcp-oauth/callback/route.ts @@ -166,6 +166,10 @@ export async function GET(request: NextRequest) { const redirectToResult = (result: McpOAuthResult) => redirectWithMcpResult(webUrl, redirectPath, result, state); + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { + return redirectToResult({ status: 'error', reason: 'callback_failed' }); + } + // Handle OAuth errors if (error) { const reason: McpOAuthErrorReason = diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts index 21eda8459..2c4201233 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/__tests__/route.test.ts @@ -181,6 +181,24 @@ describe('GET /api/mcp-oauth/initiate/[connectionId]', () => { }); }); + it('rejects OAuth before authentication when integrations are disabled', async () => { + bootstrapWebRuntimeEnvMock.mockResolvedValue({ + R_APP_URL: 'http://localhost:13000', + R_PUBLIC_URL: 'https://customer.example', + R_CURATED_INTEGRATIONS_DISABLED: true, + }); + + const response = await GET(buildRequest(), { + params: Promise.resolve({ connectionId: CONNECTION_ID }), + }); + + expect(response.headers.get('location')).toBe( + 'https://customer.example/settings?mcp=error&reason=disabled', + ); + expect(authorizeMock).not.toHaveBeenCalled(); + expect(mcpConnectionsFindFirstMock).not.toHaveBeenCalled(); + }); + it('uses the Linear API OAuth flow instead of Linear MCP OAuth', async () => { getMcpIntegrationOauthEndpointsMock.mockReturnValue({ authorizationEndpoint: 'https://linear.app/oauth/authorize', diff --git a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts index 126234dd9..638f6d120 100644 --- a/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/initiate/[connectionId]/route.ts @@ -160,6 +160,12 @@ export async function GET( DEFAULT_REDIRECT_PATH; const replayToken = requestUrl.searchParams.get('replayToken'); + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { + return NextResponse.redirect( + withMcpQuery(webUrl, redirectPath, 'error', 'disabled'), + ); + } + const authResult = await authorize(); if (!authResult.success) { return NextResponse.redirect( diff --git a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts index b47041d2c..05cb74077 100644 --- a/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts +++ b/apps/web/src/app/api/mcp-oauth/replay/[token]/route.ts @@ -27,6 +27,13 @@ export async function GET( const webEnv = await bootstrapWebRuntimeEnv(); const webUrl = getPublicAppUrl(webEnv); const { token } = await params; + + if (webEnv.R_CURATED_INTEGRATIONS_DISABLED === true) { + return NextResponse.redirect( + new URL('/error?message=Integrations are disabled', webUrl), + ); + } + const replay = await getMcpOauthReplay(token); if (!replay) { diff --git a/apps/web/src/components/layout/UserMenu.client.test.tsx b/apps/web/src/components/layout/UserMenu.client.test.tsx new file mode 100644 index 000000000..07c300b1b --- /dev/null +++ b/apps/web/src/components/layout/UserMenu.client.test.tsx @@ -0,0 +1,87 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; + +const { useQueryMock } = vi.hoisted(() => ({ + useQueryMock: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: useQueryMock, +})); + +vi.mock('@/components/system', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ + children, + ...props + }: ButtonHTMLAttributes & { children: ReactNode }) => ( + + ), + }; +}); + +vi.mock('@/hooks/useUser', () => ({ + useUser: () => ({ + isSignedIn: true, + user: { + name: 'Ada Lovelace', + primaryEmail: 'ada@example.com', + resource: { + imageUrl: null, + primaryEmailAddress: { emailAddress: 'ada@example.com' }, + }, + }, + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + releases: { + status: { + queryOptions: vi.fn(() => ({ queryKey: ['releases.status'] })), + }, + }, + }), +})); + +import { UserMenu } from './UserMenu'; + +describe('UserMenu', () => { + beforeEach(() => { + useQueryMock.mockReturnValue({ data: null }); + }); + + it('links to personal settings from the user summary', () => { + render(); + + const settingsLink = screen.getByRole('link', { + name: 'Personal settings', + }); + + expect(settingsLink).toHaveAttribute('href', '/settings/personal'); + expect( + settingsLink.closest('[data-slot="dropdown-menu-item"]'), + ).toBeInTheDocument(); + }); + + it('hides personal settings while setup is incomplete', () => { + render(); + + expect( + screen.queryByRole('link', { name: 'Personal settings' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx index f355b353e..6f21bb0a9 100644 --- a/apps/web/src/components/layout/UserMenu.tsx +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import Image from 'next/image'; import { Avatar, @@ -15,6 +17,7 @@ import { ExternalLink, Info, LogOut, + Settings, } from '@/components/system'; import { useUser } from '@/hooks/useUser'; @@ -22,6 +25,7 @@ import { authClient } from '@/lib/auth-client'; import { DOCS_BASE_URL } from '@/lib/docs'; import { isParsableProductVersion, toReleaseTag } from '@/lib/product-version'; import { GITHUB_RELEASES_BASE_URL } from '@/lib/release-links'; +import { SETTINGS_PATHS } from '@/lib/settings'; import { PERSONAL_THEME_STORAGE_KEY } from '@/types/preferences'; import { cn } from '@/lib/utils'; import { useTRPC } from '@/trpc/client'; @@ -33,17 +37,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/system'; -import Image from 'next/image'; export const UserMenu = ({ portalContainer, expanded = false, menuSide = 'left', + showPersonalSettings = true, switchOrgRedirectPath, }: { portalContainer?: HTMLElement | null; expanded?: boolean; menuSide?: 'top' | 'right' | 'bottom' | 'left'; + showPersonalSettings?: boolean; switchOrgRedirectPath?: string; } = {}) => { const { isSignedIn, user } = useUser(); @@ -57,6 +62,7 @@ export const UserMenu = ({ expanded={expanded} menuSide={menuSide} portalContainer={portalContainer} + showPersonalSettings={showPersonalSettings} switchOrgRedirectPath={switchOrgRedirectPath} user={user} /> @@ -67,11 +73,13 @@ function SignedInUserMenu({ portalContainer, expanded, menuSide, + showPersonalSettings, user, }: { portalContainer?: HTMLElement | null; expanded: boolean; menuSide: 'top' | 'right' | 'bottom' | 'left'; + showPersonalSettings: boolean; switchOrgRedirectPath?: string; user: NonNullable['user']>; }) { @@ -140,8 +148,8 @@ function SignedInUserMenu({ size="md" alt={userDisplayName} /> -
-
+
+
{userDisplayName}
@@ -149,6 +157,16 @@ function SignedInUserMenu({ {userEmail}
+ {showPersonalSettings ? ( + + + + + + ) : null}
diff --git a/apps/web/src/components/settings/ChatGptConnectDialog.test.tsx b/apps/web/src/components/settings/ChatGptConnectDialog.test.tsx new file mode 100644 index 000000000..a6d1ec621 --- /dev/null +++ b/apps/web/src/components/settings/ChatGptConnectDialog.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, waitFor } from '@testing-library/react'; + +const { pollMock, startResult } = vi.hoisted(() => ({ + pollMock: vi.fn(), + startResult: { + current: { + deviceAuthId: 'dev-1', + userCode: 'ABCD-EFGHI', + verificationUrl: 'https://auth.openai.com/codex/device', + intervalMs: 5, + expiresInMs: 60_000, + }, + }, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock('@/trpc/client', () => { + const queryKey = () => ['stub']; + return { + useTRPC: () => ({ + chatgptSubscription: { + startDeviceAuth: { + mutationOptions: (options: Record) => ({ + mutationKey: ['start'], + ...options, + }), + }, + pollDeviceAuth: { mutationOptions: () => ({ mutationKey: ['poll'] }) }, + status: { queryKey }, + }, + taskModels: { + providerSetup: { queryKey }, + get: { queryKey }, + launchOptions: { queryKey }, + }, + subscriptionUsage: { list: { queryKey } }, + }), + }; +}); + +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), + useMutation: (options: { + mutationKey: string[]; + onSuccess?: (result: unknown) => void; + }) => + options.mutationKey[0] === 'start' + ? { + mutate: () => options.onSuccess?.(startResult.current), + isPending: false, + isError: false, + reset: vi.fn(), + } + : { mutateAsync: pollMock }, +})); + +import { ChatGptConnectDialog } from './ChatGptConnectDialog'; + +describe('ChatGptConnectDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + startResult.current = { + deviceAuthId: 'dev-1', + userCode: 'ABCD-EFGHI', + verificationUrl: 'https://auth.openai.com/codex/device', + intervalMs: 5, + expiresInMs: 60_000, + }; + }); + + it('stops polling and prompts a restart once the device code expires', async () => { + startResult.current = { ...startResult.current, expiresInMs: 30 }; + pollMock.mockResolvedValue({ status: 'pending' }); + + render(); + + await screen.findByText(/authorization code expired/i); + + const callsAtExpiry = pollMock.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(pollMock.mock.calls.length).toBe(callsAtExpiry); + expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument(); + }); + + it('reports an org policy block instead of waiting forever', async () => { + pollMock.mockResolvedValue({ + status: 'failed', + error: 'ChatGPT device authorization was refused (deviceauth_forbidden).', + reason: 'blocked', + }); + + render(); + + await screen.findByText(/deviceauth_forbidden/); + expect( + screen.getByText(/workspace policy blocks the Codex app/i), + ).toBeInTheDocument(); + // The dead code is no longer presented as something to type in. + expect( + screen.queryByText(/Waiting for authorization/i), + ).not.toBeInTheDocument(); + await waitFor(() => expect(pollMock).toHaveBeenCalledTimes(1)); + }); + + it('surfaces a rejected poll instead of leaving the promise unhandled', async () => { + pollMock.mockRejectedValue(new Error('network down')); + + render(); + + await screen.findByText('network down'); + expect( + screen.queryByText(/Waiting for authorization/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/settings/ChatGptConnectDialog.tsx b/apps/web/src/components/settings/ChatGptConnectDialog.tsx index 9434088f1..6604e72c0 100644 --- a/apps/web/src/components/settings/ChatGptConnectDialog.tsx +++ b/apps/web/src/components/settings/ChatGptConnectDialog.tsx @@ -1,11 +1,9 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ExternalLink, Loader2 } from 'lucide-react'; -import { toast } from 'sonner'; import { useTRPC } from '@/trpc/client'; +import { useDeviceCodeFlow } from '@/hooks/useDeviceCodeFlow'; import { Badge, Button, @@ -24,13 +22,9 @@ type DeviceAuthStartResult = { userCode: string; verificationUrl: string; intervalMs: number; + expiresInMs: number; }; -type DevicePollResult = - | { status: 'pending' } - | { status: 'success' } - | { status: 'failed'; error: string }; - type ChatGptConnectDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -49,144 +43,48 @@ export function ChatGptConnectDialog({ onConnected, }: ChatGptConnectDialogProps) { const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [deviceAuth, setDeviceAuth] = useState( - null, - ); - const [polling, setPolling] = useState(false); - const [error, setError] = useState(null); - const pollingRef = useRef(false); - - const startMutation = useMutation( - trpc.chatgptSubscription.startDeviceAuth.mutationOptions({ - onSuccess: (result) => { - setDeviceAuth(result); - setError(null); - }, - onError: (mutationError) => { - setError( - mutationError instanceof Error - ? mutationError.message - : 'Failed to start ChatGPT authorization.', - ); - }, - }), - ); - - const pollMutation = useMutation( - trpc.chatgptSubscription.pollDeviceAuth.mutationOptions({ - onSuccess: async (result: DevicePollResult) => { - if (result.status === 'success') { - toast.success('ChatGPT subscription connected.'); - await queryClient.invalidateQueries({ - queryKey: trpc.taskModels.providerSetup.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: trpc.taskModels.get.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: trpc.taskModels.launchOptions.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: trpc.chatgptSubscription.status.queryKey(), - }); - await queryClient.invalidateQueries({ - queryKey: trpc.subscriptionUsage.list.queryKey(), - }); - await onConnected?.(); - handleOpenChange(false); - } else if (result.status === 'failed') { - setError(result.error); - pollingRef.current = false; - setPolling(false); - } - }, - onError: (mutationError) => { - setError( - mutationError instanceof Error - ? mutationError.message - : 'ChatGPT authorization polling failed.', - ); - pollingRef.current = false; - setPolling(false); - }, - }), - ); - - useEffect(() => { - if (!open) { - pollingRef.current = false; - setPolling(false); - setDeviceAuth(null); - setError(null); - return; - } - - // Only auto-start the device-code flow once per open. Guard on - // startMutation.isError so a failed start does not re-fire on the next - // render (deviceAuth stays null and isPending returns to false, which - // would otherwise loop with no backoff). The user clicks Restart to - // retry explicitly. - if ( - open && - !deviceAuth && - !startMutation.isPending && - !startMutation.isError - ) { - startMutation.mutate(); - } - }, [open, deviceAuth, startMutation]); - useEffect(() => { - if (!open || !deviceAuth || pollingRef.current) { - return; + const { + deviceAuth, + error, + failureReason, + polling, + isStartPending, + handleOpenChange, + restart, + } = useDeviceCodeFlow< + DeviceAuthStartResult, + { + deviceAuthId: string; + userCode: string; } - - pollingRef.current = true; - setPolling(true); - - const poll = async () => { - while (pollingRef.current && deviceAuth) { - await pollMutation.mutateAsync({ - deviceAuthId: deviceAuth.deviceAuthId, - userCode: deviceAuth.userCode, - }); - - if (!pollingRef.current) { - break; - } - - await new Promise((resolve) => - setTimeout(resolve, deviceAuth.intervalMs), - ); - } - }; - - void poll(); - - return () => { - pollingRef.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, deviceAuth]); - - function handleOpenChange(next: boolean) { - pollingRef.current = false; - setPolling(false); - onOpenChange(next); - } - - function handleRestart() { - setDeviceAuth(null); - setError(null); - // Clear the prior failed mutation state so the start effect can fire - // again, then kick off a fresh device-code request. - startMutation.reset(); - startMutation.mutate(); - } - - const userCode = deviceAuth?.userCode; - const verificationUrl = deviceAuth?.verificationUrl; + >({ + open, + onOpenChange, + onConnected, + startMutationOptions: (handlers) => + trpc.chatgptSubscription.startDeviceAuth.mutationOptions(handlers), + pollMutationOptions: () => + trpc.chatgptSubscription.pollDeviceAuth.mutationOptions(), + getPollInput: (auth) => ({ + deviceAuthId: auth.deviceAuthId, + userCode: auth.userCode, + }), + invalidateQueryKeys: () => [ + trpc.taskModels.providerSetup.queryKey(), + trpc.taskModels.get.queryKey(), + trpc.taskModels.launchOptions.queryKey(), + trpc.chatgptSubscription.status.queryKey(), + trpc.subscriptionUsage.list.queryKey(), + ], + successToast: 'ChatGPT subscription connected.', + copy: { + expired: + 'ChatGPT authorization code expired. Restart the connection to get a new code.', + startFailed: 'Failed to start ChatGPT authorization.', + pollFailed: 'ChatGPT authorization polling failed.', + }, + }); return ( @@ -200,7 +98,7 @@ export function ChatGptConnectDialog({
- {startMutation.isPending && !deviceAuth ? ( + {isStartPending && !deviceAuth ? (
Starting authorization… @@ -208,12 +106,19 @@ export function ChatGptConnectDialog({ ) : null} {error ? ( -

- {error} -

+
+

{error}

+ {failureReason === 'blocked' ? ( +

+ This usually means your ChatGPT workspace policy blocks the + Codex app. Ask an admin of that workspace to allow it, then + restart the connection. Waiting will not clear this. +

+ ) : null} +
) : null} - {deviceAuth ? ( + {deviceAuth && !error ? ( <>

@@ -221,13 +126,13 @@ export function ChatGptConnectDialog({

event.currentTarget.select()} /> handleOpenChange(false)} - disabled={startMutation.isPending} + disabled={isStartPending} > Cancel {error ? ( - ) : null} diff --git a/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx index e152c9441..6f39e6445 100644 --- a/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx +++ b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx @@ -21,6 +21,7 @@ import { Popover, PopoverContent, PopoverTrigger, + Skeleton, } from '@/components/system'; const FALLBACK_TIME_ZONES = [ @@ -83,6 +84,10 @@ export function DeploymentTimeZoneSetting() { const effectiveTimeZone = settings.data?.effectiveTimeZone ?? 'UTC'; + if (settings.isPending) { + return ; + } + if (!isEditing) { return (

diff --git a/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx b/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx index db0d63c5f..b225e822d 100644 --- a/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx +++ b/apps/web/src/components/settings/GitHubCopilotConnectDialog.tsx @@ -1,10 +1,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - import { useTRPC } from '@/trpc/client'; +import { useDeviceCodeFlow } from '@/hooks/useDeviceCodeFlow'; import { Button, Dialog, @@ -36,125 +33,36 @@ export function GitHubCopilotConnectDialog({ onConnected?: () => void | Promise; }) { const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [deviceAuth, setDeviceAuth] = useState(null); - const [error, setError] = useState(null); - const pollingRef = useRef(false); - const startMutation = useMutation( - trpc.githubCopilotSubscription.startDeviceAuth.mutationOptions({ - onSuccess: (result) => { - setDeviceAuth(result); - setError(null); + const { deviceAuth, error, isStartPending, handleOpenChange, restart } = + useDeviceCodeFlow({ + open, + onOpenChange, + onConnected, + startMutationOptions: (handlers) => + trpc.githubCopilotSubscription.startDeviceAuth.mutationOptions( + handlers, + ), + pollMutationOptions: () => + trpc.githubCopilotSubscription.pollDeviceAuth.mutationOptions(), + getPollInput: (auth) => ({ deviceCode: auth.deviceCode }), + invalidateQueryKeys: () => [ + trpc.taskModels.providerSetup.queryKey(), + trpc.taskModels.get.queryKey(), + trpc.taskModels.launchOptions.queryKey(), + trpc.githubCopilotSubscription.status.queryKey(), + trpc.subscriptionUsage.list.queryKey(), + ], + successToast: 'GitHub Copilot subscription connected.', + copy: { + expired: 'GitHub authorization code expired. Restart the connection.', + startFailed: 'Failed to start GitHub authorization.', + pollFailed: 'GitHub authorization polling failed.', }, - onError: (mutationError) => setError(mutationError.message), - }), - ); - const pollMutation = useMutation( - trpc.githubCopilotSubscription.pollDeviceAuth.mutationOptions(), - ); - - function close(next: boolean) { - if (!next) pollingRef.current = false; - onOpenChange(next); - } - - useEffect(() => { - if (!open) { - pollingRef.current = false; - setDeviceAuth(null); - setError(null); - return; - } - if (!deviceAuth && !startMutation.isPending && !startMutation.isError) { - startMutation.mutate(); - } - }, [deviceAuth, open, startMutation]); - - useEffect(() => { - if (!open || !deviceAuth || pollingRef.current) return; - pollingRef.current = true; - - const expiresAt = Date.now() + deviceAuth.expiresInMs; - - const poll = async () => { - let intervalMs = deviceAuth.intervalMs; - while (pollingRef.current) { - if (Date.now() >= expiresAt) { - pollingRef.current = false; - setError( - 'GitHub authorization code expired. Restart the connection.', - ); - return; - } - - const result = await pollMutation.mutateAsync({ - deviceCode: deviceAuth.deviceCode, - }); - if (result.status === 'success') { - pollingRef.current = false; - toast.success('GitHub Copilot subscription connected.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.providerSetup.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.get.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.launchOptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.githubCopilotSubscription.status.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.subscriptionUsage.list.queryKey(), - }), - ]); - await onConnected?.(); - close(false); - return; - } - if (result.status === 'failed') { - pollingRef.current = false; - setError(result.error); - return; - } - // slow_down returns the new absolute poll interval, not a delta. - if (result.intervalMs) intervalMs = result.intervalMs; - - const remainingMs = expiresAt - Date.now(); - if (remainingMs <= 0) { - pollingRef.current = false; - setError( - 'GitHub authorization code expired. Restart the connection.', - ); - return; - } - await new Promise((resolve) => - setTimeout(resolve, Math.min(intervalMs, remainingMs)), - ); - } - }; - - void poll().catch((pollError: unknown) => { - pollingRef.current = false; - setError( - pollError instanceof Error - ? pollError.message - : 'GitHub authorization polling failed.', - ); }); - return () => { - pollingRef.current = false; - }; - // The polling lifecycle is intentionally keyed only to the active device - // flow; mutation/query objects are recreated by hooks between renders. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceAuth, open]); return ( -

+ Connect GitHub Copilot @@ -163,7 +71,7 @@ export function GitHubCopilotConnectDialog({ - {deviceAuth ? ( + {deviceAuth && !error ? (

Open GitHub, then enter this one-time code: @@ -188,29 +96,23 @@ export function GitHubCopilotConnectDialog({ Waiting for authorization…

- ) : startMutation.isPending ? ( + ) : isStartPending ? (
) : null} - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} - - {error ? ( - - ) : null} + {error ? : null}
diff --git a/apps/web/src/components/settings/InferenceProviderSection.test.tsx b/apps/web/src/components/settings/InferenceProviderSection.test.tsx index 2b5b493e8..a5eb8fa75 100644 --- a/apps/web/src/components/settings/InferenceProviderSection.test.tsx +++ b/apps/web/src/components/settings/InferenceProviderSection.test.tsx @@ -255,9 +255,9 @@ function buildProviderSetup( id: 'amazon-bedrock' as SetupModelProviderId, label: 'Amazon Bedrock', envVarName: 'AWS_BEARER_TOKEN_BEDROCK', - envVarLabel: 'Mantle API key', + envVarLabel: 'API key', credentialHelp: { - text: 'Paste a key generated from the Bedrock Mantle API-key console. Switch the AWS console to the same region you enter below before generating it.', + text: 'Paste a key generated from the Bedrock API-key console. The same regional key serves native Bedrock and Mantle models.', href: 'https://us-east-1.console.aws.amazon.com/bedrock-mantle/api-keys', linkLabel: 'Open AWS Bedrock API keys', }, @@ -763,13 +763,13 @@ describe('InferenceProviderSection', () => { fireEvent.click( screen.getByRole('button', { - name: 'Edit Amazon Bedrock Mantle API key', + name: 'Edit Amazon Bedrock API key', }), ); - expect( - screen.getByLabelText('New Mantle API key for Amazon Bedrock'), - ).toHaveValue(''); + expect(screen.getByLabelText('New API key for Amazon Bedrock')).toHaveValue( + '', + ); expect(screen.getByLabelText('AWS region for Amazon Bedrock')).toHaveValue( 'us-west-2', ); @@ -799,7 +799,7 @@ describe('InferenceProviderSection', () => { fireEvent.click( screen.getByRole('button', { - name: 'Edit Amazon Bedrock Mantle API key', + name: 'Edit Amazon Bedrock API key', }), ); @@ -895,7 +895,7 @@ describe('InferenceProviderSection', () => { screen.getByRole('combobox', { name: 'Provider to add' }), ).toHaveTextContent('Amazon Bedrock'); expect( - screen.getByLabelText('Mantle API key for Amazon Bedrock'), + screen.getByLabelText('API key for Amazon Bedrock'), ).toBeInTheDocument(); expect( screen.getByRole('link', { name: 'Open AWS Bedrock API keys' }), @@ -905,10 +905,9 @@ describe('InferenceProviderSection', () => { ); await act(async () => { - fireEvent.change( - screen.getByLabelText('Mantle API key for Amazon Bedrock'), - { target: { value: 'bedrock-key' } }, - ); + fireEvent.change(screen.getByLabelText('API key for Amazon Bedrock'), { + target: { value: 'bedrock-key' }, + }); fireEvent.change(screen.getByLabelText('AWS region for Amazon Bedrock'), { target: { value: 'us-west-2' }, }); diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 45563600c..9d2ce8baf 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -12,6 +12,7 @@ import { toast } from 'sonner'; import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; const state = vi.hoisted(() => ({ + integrationsEnabled: true, deploymentEnablements: [] as Array<{ mcpId: string; enabled: boolean }>, oauthReadiness: [{ mcpId: 'linear', status: 'ready' as const }] as Array<{ mcpId: string; @@ -192,6 +193,9 @@ vi.mock('@/hooks/linear', () => ({ })); vi.mock('@/hooks/mcp-connections', () => ({ + useCuratedIntegrationsAvailability: () => ({ + data: { enabled: state.integrationsEnabled }, + }), useDeploymentMcpEnablements: () => ({ data: state.deploymentEnablements, }), @@ -429,6 +433,7 @@ describe('Integrations settings', () => { vi.clearAllMocks(); window.history.replaceState(null, '', '/settings/integrations'); state.deploymentEnablements = []; + state.integrationsEnabled = true; state.oauthReadiness = [{ mcpId: 'linear', status: 'ready' }]; state.userConnections = []; state.mcpTools = null; @@ -855,6 +860,22 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows operator policy instead of integration controls when disabled', () => { + state.integrationsEnabled = false; + + render(); + + expect( + screen.getByText('Integrations disabled by deployment operator'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Connected' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Disable Linear' }), + ).not.toBeInTheDocument(); + }); + it('connects and enables an org-scoped MCP from the integrations page', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index ef159afd2..d1850f2c5 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -21,6 +21,7 @@ import { import { useAsanaConnection, useConnectMcp, + useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, useDeploymentMcpEnablements, @@ -43,6 +44,9 @@ import { } from '@/types'; import { + Alert, + AlertDescription, + AlertTitle, BasicTooltip, Button, Card, @@ -1148,6 +1152,7 @@ export function Integrations() { const disconnectLinear = useDisconnectLinear(); const deploymentEnablements = useDeploymentMcpEnablements(); + const integrationsAvailability = useCuratedIntegrationsAvailability(); const oauthReadiness = useMcpOauthReadiness(); const linearOauthStatus = oauthReadiness.data?.find( (entry) => entry.mcpId === 'linear', @@ -1988,6 +1993,18 @@ export function Integrations() { }); }; + if (integrationsAvailability.data?.enabled === false) { + return ( + + Integrations disabled by deployment operator + + Curated integrations cannot be connected or used on this Roomote + instance. + + + ); + } + return (
{ } }); + it('keeps a fully prefixed Mantle model id in the Amazon Bedrock picker', async () => { + settingsData.current = buildSettingsData(); + providerSetupData.current = buildProviderSetupData({ + connectedProviderIds: ['amazon-bedrock'], + }); + lookupMutateAsyncMock.mockResolvedValue({ + modelId: 'bedrock-mantle/anthropic.claude-opus-4-8', + displayName: 'Claude Opus 4.8', + family: 'Claude', + metadata: null, + }); + vi.useFakeTimers(); + + try { + renderModelSettingsSection(); + + fireEvent.change(screen.getByLabelText('New model slug'), { + target: { value: 'bedrock-mantle/anthropic.claude-opus-4-8' }, + }); + await act(async () => { + vi.advanceTimersByTime(600); + }); + + expect(lookupMutateAsyncMock).toHaveBeenCalledWith({ + modelId: 'bedrock-mantle/anthropic.claude-opus-4-8', + }); + } finally { + vi.useRealTimers(); + } + }); + it('groups openai/ models under ChatGPT when a subscription is connected', () => { settingsData.current = { ...buildSettingsData(), diff --git a/apps/web/src/components/settings/ModelSettingsSection.tsx b/apps/web/src/components/settings/ModelSettingsSection.tsx index 7e4300e27..6dd79ccb2 100644 --- a/apps/web/src/components/settings/ModelSettingsSection.tsx +++ b/apps/web/src/components/settings/ModelSettingsSection.tsx @@ -471,6 +471,10 @@ function composeNewModelId( return ''; } + if (modelSlug.startsWith('bedrock-mantle/')) { + return modelSlug; + } + if (modelSlug.startsWith(`${provider}/`)) { modelSlug = modelSlug.slice(provider.length + 1); } diff --git a/apps/web/src/components/settings/UsersSettings.client.test.tsx b/apps/web/src/components/settings/UsersSettings.client.test.tsx index 63819abd7..ea8e59beb 100644 --- a/apps/web/src/components/settings/UsersSettings.client.test.tsx +++ b/apps/web/src/components/settings/UsersSettings.client.test.tsx @@ -36,6 +36,7 @@ type LicenseSummary = { seatLimit: number; seatsUsed: number; freeSeatLimit: number; + licenseId: string | null; licensee: string | null; expiresAt: Date | null; fromEnv: boolean; @@ -46,6 +47,7 @@ const unlicensedLicense: LicenseSummary = { seatLimit: 10, seatsUsed: 1, freeSeatLimit: 10, + licenseId: null, licensee: null, expiresAt: null, fromEnv: false, @@ -76,6 +78,7 @@ const { mockCreatePasswordResetLink, mockSetLicenseKey, mockClipboardWriteText, + mockCapture, } = vi.hoisted(() => ({ mockAuthorizedUser: { current: { userId: 'user-1', cloudEnabled: false }, @@ -92,6 +95,7 @@ const { seatLimit: 10, seatsUsed: 1, freeSeatLimit: 10, + licenseId: null, licensee: null, expiresAt: null, fromEnv: false, @@ -156,6 +160,7 @@ const { saved: true, })), mockClipboardWriteText: vi.fn(async (_value: string) => undefined), + mockCapture: vi.fn(), })); vi.mock('sonner', () => ({ @@ -167,6 +172,11 @@ vi.mock('sonner', () => ({ vi.mock('@/hooks/useUser', () => ({ useAuthorizedUser: () => mockAuthorizedUser.current, + useUser: () => ({ user: { anonymousAnalyticsEnabled: true } }), +})); + +vi.mock('@/hooks/useTelemetry', () => ({ + useTelemetry: () => ({ capture: mockCapture, enabled: true }), })); vi.mock('@/trpc/client', () => ({ @@ -320,7 +330,7 @@ describe('UsersSettings', () => { }); }); - it('lists only non-revoked invites and removes revoked ones', async () => { + it('lists only open invites and removes revoked or fully used ones', async () => { mockSettingsState.current = { ...mockSettingsState.current, invites: [ @@ -348,6 +358,18 @@ describe('UsersSettings', () => { createdAt: new Date('2026-06-01T00:00:00Z'), usable: false, }, + { + id: 'invite-3', + label: 'Used link', + role: 'member', + maxUses: 2, + usedCount: 2, + acceptedUserCount: 2, + expiresAt: null, + revokedAt: null, + createdAt: new Date('2026-06-15T00:00:00Z'), + usable: false, + }, ], }; @@ -358,7 +380,13 @@ describe('UsersSettings', () => { ).toBeInTheDocument(); expect(screen.getByText(/2 of 5 used/)).toBeInTheDocument(); expect(screen.queryByText('Invite for Old link')).not.toBeInTheDocument(); + expect(screen.queryByText('Invite for Used link')).not.toBeInTheDocument(); expect(screen.queryByText('revoked')).not.toBeInTheDocument(); + expect( + screen.getByText( + 'Invites are removed from the list after fully used, to keep things clean.', + ), + ).toBeInTheDocument(); // The admin invite is badged; the member invite is not. expect( within( @@ -626,6 +654,19 @@ describe('UsersSettings', () => { expect(await screen.findByText('4 of 10 seats used')).toBeInTheDocument(); expect(screen.getByText('Free tier')).toBeInTheDocument(); + const purchaseLink = screen.getByRole('link', { + name: 'Buy a license on Roomote Cloud', + }); + expect(purchaseLink).toHaveAttribute( + 'href', + 'https://cloud.roomote.dev/sign-up?utm_source=self-host&utm_medium=settings_users&utm_campaign=license_purchase', + ); + fireEvent.click(purchaseLink); + expect(mockCapture).toHaveBeenCalledWith('license_purchase_cta_clicked', { + seatsUsed: 4, + seatLimit: 10, + licenseStatus: 'unlicensed', + }); expect( screen.queryByRole('button', { name: 'Remove key' }), ).not.toBeInTheDocument(); @@ -655,6 +696,9 @@ describe('UsersSettings', () => { expect(screen.getByText('Users')).toBeInTheDocument(); expect(screen.queryByText('License')).not.toBeInTheDocument(); expect(screen.queryByLabelText('License key')).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Buy a license on Roomote Cloud' }), + ).not.toBeInTheDocument(); }); it('shows licensed state, at-limit warning, and removes the key', async () => { @@ -665,6 +709,7 @@ describe('UsersSettings', () => { seatLimit: 25, seatsUsed: 25, freeSeatLimit: 10, + licenseId: 'lic_manual', licensee: 'Acme Corp', expiresAt: null, fromEnv: false, @@ -677,6 +722,12 @@ describe('UsersSettings', () => { expect(screen.getByText('25 of 25 seats used')).toBeInTheDocument(); expect(screen.getByText(/Licensed to Acme Corp/)).toBeInTheDocument(); expect(screen.getByText(/All seats are in use/)).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Create invite' }), + ).toBeDisabled(); + + fireEvent.submit(screen.getByLabelText(/Label/).closest('form')!); + expect(mockCreateInvite).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Remove key' })); @@ -696,6 +747,7 @@ describe('UsersSettings', () => { seatLimit: 50, seatsUsed: 4, freeSeatLimit: 10, + licenseId: 'lic_manual', licensee: 'Acme Corp', expiresAt: null, fromEnv: true, @@ -723,4 +775,29 @@ describe('UsersSettings', () => { screen.queryByRole('button', { name: 'Remove key' }), ).not.toBeInTheDocument(); }); + + it('points purchased licenses to the Cloud portal when expiry is near', async () => { + mockSettingsState.current = { + ...mockSettingsState.current, + license: { + status: 'valid', + seatLimit: 100, + seatsUsed: 20, + freeSeatLimit: 10, + licenseId: 'lic_sh_123', + licensee: 'Engineering', + expiresAt: new Date(Date.now() + 20 * 24 * 60 * 60 * 1000), + fromEnv: false, + }, + }; + + renderUsersSettings(); + + expect( + await screen.findByText('This license expires soon.', { exact: false }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Refresh the key in Roomote Cloud' }), + ).toHaveAttribute('href', 'https://cloud.roomote.dev/'); + }); }); diff --git a/apps/web/src/components/settings/UsersSettings.tsx b/apps/web/src/components/settings/UsersSettings.tsx index 3aa58240b..7823837c3 100644 --- a/apps/web/src/components/settings/UsersSettings.tsx +++ b/apps/web/src/components/settings/UsersSettings.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useAuthorizedUser } from '@/hooks/useUser'; +import { useTelemetry } from '@/hooks/useTelemetry'; import { useTRPC } from '@/trpc/client'; import { AlertCircle, @@ -40,6 +41,11 @@ import { import { Section } from '@/components/settings'; import { formatDistanceToNow } from 'date-fns'; +const LICENSE_PURCHASE_URL = + 'https://cloud.roomote.dev/sign-up?utm_source=self-host&utm_medium=settings_users&utm_campaign=license_purchase'; +const LICENSE_PORTAL_URL = 'https://cloud.roomote.dev/'; +const LICENSE_EXPIRY_WARNING_MS = 30 * 24 * 60 * 60 * 1000; + function formatJoinedDate(value: Date | string | null): string { if (!value) { return '—'; @@ -72,6 +78,7 @@ export function UsersSettings() { const trpc = useTRPC(); const queryClient = useQueryClient(); const { userId: currentUserId, cloudEnabled } = useAuthorizedUser(); + const { capture } = useTelemetry(); const settingsQueryKey = trpc.accessPolicy.get.queryKey(); const settingsQuery = useQuery(trpc.accessPolicy.get.queryOptions()); const [label, setLabel] = useState(''); @@ -244,8 +251,17 @@ export function UsersSettings() { license, } = settingsQuery.data; const adminCount = members.filter((member) => member.role === 'admin').length; - const visibleInvites = invites.filter((invite) => invite.revokedAt == null); + const visibleInvites = invites.filter( + (invite) => invite.revokedAt == null && invite.usedCount < invite.maxUses, + ); const seatsRemaining = license.seatLimit - license.seatsUsed; + const isAtSeatCapacity = seatsRemaining <= 0; + const purchasedLicenseExpiringSoon = + license.status === 'valid' && + license.licenseId?.startsWith('lic_sh_') === true && + license.expiresAt != null && + new Date(license.expiresAt).getTime() - Date.now() <= + LICENSE_EXPIRY_WARNING_MS; const licenseBadge = license.status === 'valid' ? ( Licensed @@ -269,10 +285,29 @@ export function UsersSettings() { setLicenseKey.mutate({ licenseKey }); }; + const licensePurchaseButton = ( + + ); + const handleCreate = (event?: FormEvent) => { event?.preventDefault(); - if (createInvite.isPending) { + if (createInvite.isPending || isAtSeatCapacity) { return; } @@ -308,19 +343,43 @@ export function UsersSettings() {

- {seatsRemaining <= 0 ? ( -
+ {seatsRemaining <= 1 ? ( +
+
+ +

+ {seatsRemaining <= 0 + ? 'All seats are in use. New users cannot sign in until a seat is freed or the license limit is raised.' + : 'One seat remaining. Buy a license before inviting more users.'} +

+
+ {licensePurchaseButton} +
+ ) : ( +
+

+ Need more than {license.freeSeatLimit} users? +

+ {licensePurchaseButton} +
+ )} + + {purchasedLicenseExpiringSoon ? ( +

- All seats are in use. New users cannot sign in until a seat is - freed or a license key with more seats is added. + This license expires soon.{' '} + + Refresh the key in Roomote Cloud + {' '} + and replace it here before it expires.

- ) : seatsRemaining === 1 ? ( -

- One seat remaining. Add a license key to raise the limit before - inviting more users. -

) : null} {license.fromEnv ? ( @@ -366,8 +425,8 @@ export function UsersSettings() { )}

Deployments are free for up to {license.freeSeatLimit} users. A - license key from the Roomote maintainers unlocks more seats. You - can also set R_LICENSE_KEY in + license purchased on Roomote Cloud unlocks more seats. Apply it + here or set R_LICENSE_KEY in the deployment environment.

@@ -470,7 +529,10 @@ export function UsersSettings() { disabled={createInvite.isPending} />
- @@ -553,6 +615,10 @@ export function UsersSettings() { })}
) : null} +

+ Invites are removed from the list after fully used, to keep things + clean. +

- -
-

- - {member.name || member.email} - -

-

- {member.email} - {' · Joined '} - {formatJoinedDate(member.createdAt)} -

+
+ +
+

+ + {member.name || member.email} + +

+

+ {member.email} + · + + {'Joined '} + {formatJoinedDate(member.createdAt)} + +

+
+
+
+ {roleLockReason ? ( + + {roleSelect} + + ) : ( + roleSelect + )} + {removeLockReason ? ( + + {removeButton} + + ) : ( + removeButton + )} + {resetLockReason ? ( + + {resetButton} + + ) : ( + resetButton + )}
- {roleLockReason ? ( - - {roleSelect} - - ) : ( - roleSelect - )} - {removeLockReason ? ( - - {removeButton} - - ) : ( - removeButton - )} - {resetLockReason ? ( - - {resetButton} - - ) : ( - resetButton - )}
); })} diff --git a/apps/web/src/components/settings/XaiConnectDialog.tsx b/apps/web/src/components/settings/XaiConnectDialog.tsx index 59504620a..fcadf5504 100644 --- a/apps/web/src/components/settings/XaiConnectDialog.tsx +++ b/apps/web/src/components/settings/XaiConnectDialog.tsx @@ -1,10 +1,7 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; - import { useTRPC } from '@/trpc/client'; +import { useDeviceCodeFlow } from '@/hooks/useDeviceCodeFlow'; import { Button, Dialog, @@ -36,153 +33,35 @@ export function XaiConnectDialog({ onConnected?: () => void | Promise; }) { const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [deviceAuth, setDeviceAuth] = useState(null); - const [error, setError] = useState(null); - /** - * Monotonic generation bumped whenever a poll loop must die (close, restart, - * unmount). A shared boolean is not enough: cancel + reopen can flip the - * flag back to true and revive a stale loop that still holds the previous - * device code. - */ - const pollGenerationRef = useRef(0); - const startMutation = useMutation( - trpc.xaiSubscription.startDeviceAuth.mutationOptions({ - onSuccess: (result) => { - setDeviceAuth(result); - setError(null); + const { deviceAuth, error, isStartPending, handleOpenChange, restart } = + useDeviceCodeFlow({ + open, + onOpenChange, + onConnected, + startMutationOptions: (handlers) => + trpc.xaiSubscription.startDeviceAuth.mutationOptions(handlers), + pollMutationOptions: () => + trpc.xaiSubscription.pollDeviceAuth.mutationOptions(), + getPollInput: (auth) => ({ deviceCode: auth.deviceCode }), + invalidateQueryKeys: () => [ + trpc.taskModels.providerSetup.queryKey(), + trpc.taskModels.get.queryKey(), + trpc.taskModels.launchOptions.queryKey(), + trpc.xaiSubscription.status.queryKey(), + trpc.subscriptionUsage.list.queryKey(), + ], + successToast: 'xAI Grok subscription connected.', + copy: { + expired: + 'xAI device authorization code expired. Restart the connection.', + startFailed: 'Failed to start xAI authorization.', + pollFailed: 'xAI authorization polling failed.', }, - onError: (mutationError) => setError(mutationError.message), - }), - ); - const pollMutation = useMutation( - trpc.xaiSubscription.pollDeviceAuth.mutationOptions(), - ); - - function invalidatePollLoops() { - pollGenerationRef.current += 1; - } - - function close(next: boolean) { - if (!next) { - invalidatePollLoops(); - } - onOpenChange(next); - } - - useEffect(() => { - if (!open) { - invalidatePollLoops(); - setDeviceAuth(null); - setError(null); - return; - } - if (!deviceAuth && !startMutation.isPending && !startMutation.isError) { - startMutation.mutate(); - } - }, [deviceAuth, open, startMutation]); - - useEffect(() => { - if (!open || !deviceAuth) { - return; - } - - const generation = ++pollGenerationRef.current; - const activeDeviceCode = deviceAuth.deviceCode; - const expiresAt = Date.now() + deviceAuth.expiresInMs; - - const poll = async () => { - let intervalMs = deviceAuth.intervalMs; - while (pollGenerationRef.current === generation) { - if (Date.now() >= expiresAt) { - setError( - 'xAI device authorization code expired. Restart the connection.', - ); - return; - } - - const result = await pollMutation.mutateAsync({ - deviceCode: activeDeviceCode, - }); - // Another open/close/restart may have started while we awaited. - if (pollGenerationRef.current !== generation) { - return; - } - if (result.status === 'success') { - toast.success('xAI Grok subscription connected.'); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.providerSetup.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.get.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.taskModels.launchOptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.xaiSubscription.status.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.subscriptionUsage.list.queryKey(), - }), - ]); - if (pollGenerationRef.current !== generation) { - return; - } - await onConnected?.(); - if (pollGenerationRef.current !== generation) { - return; - } - close(false); - return; - } - if (result.status === 'failed') { - setError(result.error); - return; - } - // slow_down returns the new absolute poll interval, not a delta. - if (result.intervalMs) { - intervalMs = result.intervalMs; - } - - const remainingMs = expiresAt - Date.now(); - if (remainingMs <= 0) { - setError( - 'xAI device authorization code expired. Restart the connection.', - ); - return; - } - await new Promise((resolve) => - setTimeout(resolve, Math.min(intervalMs, remainingMs)), - ); - } - }; - - void poll().catch((pollError: unknown) => { - if (pollGenerationRef.current !== generation) { - return; - } - setError( - pollError instanceof Error - ? pollError.message - : 'xAI authorization polling failed.', - ); }); - return () => { - // Invalidate only this effect's generation when deviceAuth/open change. - if (pollGenerationRef.current === generation) { - pollGenerationRef.current += 1; - } - }; - // The polling lifecycle is intentionally keyed only to the active device - // flow; mutation/query objects are recreated by hooks between renders. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [deviceAuth, open]); return ( - + Connect xAI Grok subscription @@ -192,7 +71,7 @@ export function XaiConnectDialog({ - {deviceAuth ? ( + {deviceAuth && !error ? (

Open xAI, then enter this one-time code: @@ -217,30 +96,23 @@ export function XaiConnectDialog({ Waiting for authorization…

- ) : startMutation.isPending ? ( + ) : isStartPending ? (
) : null} - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} - - {error ? ( - - ) : null} + {error ? : null}
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index 8a417aeb1..eede21ef9 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -1,9 +1,40 @@ import type { ReactNode } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; const managerInstructionsPlaceholder = /Optional guidance for which ideas to prioritize or avoid/; const state = vi.hoisted(() => ({ + customAutomationsPending: false, + customAutomations: [] as Array<{ + id: string; + name: string; + prompt: string; + enabled: boolean; + scheduleMode: 'weekly'; + cronExpression: null; + model: null; + environmentId: string; + target: { + provider: 'slack'; + externalRef: string; + metadata?: Record; + }; + lastRunAt: null; + lastSucceededAt: null; + lastFailedAt: null; + lastError: null; + lastLaunchedTaskId: null; + createdByName: string; + createdAt: Date; + updatedAt: Date; + }>, + environments: [] as Array<{ id: string; name: string }>, nextUpdateSettingsResult: null as { success: true; settings: Record; @@ -254,7 +285,10 @@ vi.mock('@tanstack/react-query', () => ({ } if (key1 === 'listCustomAutomations') { - return { isPending: false, data: [] }; + return { + isPending: state.customAutomationsPending, + data: state.customAutomations, + }; } if (queryOptions.queryKey?.[0] === 'taskModels') { @@ -314,7 +348,7 @@ vi.mock('@tanstack/react-query', () => ({ ) { // environments.list and any leftover channel listszheimer if (queryOptions.queryKey?.[0] === 'environments') { - return { isPending: false, data: [] }; + return { isPending: false, data: state.environments }; } } @@ -436,16 +470,24 @@ import { AutomationsSettings } from './AutomationsSettings'; async function openSuggesterCard() { fireEvent.click( - await screen.findByRole('button', { name: 'Expand Suggest Ideas' }), + await screen.findByRole('button', { + name: /(?:Set up|Configure) Suggest Ideas/, + }), ); } async function openReviewerCard() { fireEvent.click( - await screen.findByRole('button', { name: 'Expand Review Code' }), + await screen.findByRole('button', { + name: /(?:Set up|Configure) Review Code/, + }), ); } +function closeAutomationDialog() { + fireEvent.click(screen.getByRole('button', { name: 'Close' })); +} + describe('AutomationsSettings', () => { beforeEach(() => { vi.clearAllMocks(); @@ -459,8 +501,17 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.settings.announcerDiscordChannelId = null; state.settingsQuery.data.settings.platformIssueDiscordChannelId = null; state.settingsQuery.data.settings.managerSlackChannelId = 'C123MANAGER'; + state.settingsQuery.data.slackChannelDisplayNames.managerSlackChannel = + '#roomote-managers'; state.settingsQuery.data.settings.managerDiscordChannelId = null; state.settingsQuery.data.settings.managerStatsFrequency = 'off' as never; + state.settingsQuery.data.settings.channelAutoStartSlackChannels = [ + { + channelId: 'C123BUGS', + instructions: 'Treat each message as a bug report.', + launchMode: 'always_start' as const, + }, + ]; state.settingsQuery.data.settings.sentryTriageFrequency = 'off' as never; state.settingsQuery.data.settings.dependabotTriageFrequency = 'off' as never; @@ -474,6 +525,10 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.settings.reviewCodeInstructions = null; state.settingsQuery.data.reviewer.relayReviewResultsToTask = false; state.settingsQuery.data.reviewer.relayUsers = []; + state.customAutomations = []; + state.customAutomationsPending = false; + state.settingsQuery.isPending = false; + state.environments = []; for (const key of Object.keys( state.settingsQuery.data.resolvedDestinations, )) { @@ -493,7 +548,7 @@ describe('AutomationsSettings', () => { expect( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ).toBeInTheDocument(); expect(screen.queryByText('Beta')).not.toBeInTheDocument(); @@ -529,30 +584,38 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ); + expect( + screen.getByLabelText('Post summaries to this Slack channel'), + ).toBeInTheDocument(); + expect( + screen.getByText('Reports to: not configured — set a Manager Channel.'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Sentry Issues' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Sentry Issues/, + }), ); + expect( + screen.getByLabelText('Post follow-up work to this Slack channel'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Dependabot Alerts' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Dependabot Alerts/, + }), ); expect( - screen.getByLabelText('Post summaries to this Slack channel'), + screen.getByLabelText('Post follow-up work to this Slack channel'), ).toBeInTheDocument(); + expect(screen.getByText('Select a Slack channel')).toBeInTheDocument(); expect( - screen.getAllByLabelText('Post follow-up work to this Slack channel') - .length, - ).toBeGreaterThan(1); - expect( - screen.getAllByText('Select a Slack channel').length, - ).toBeGreaterThan(0); - expect( - screen.getAllByText('Reports to: not configured — set a Manager Channel.') - .length, - ).toBeGreaterThan(1); + screen.getByText('Reports to: not configured — set a Manager Channel.'), + ).toBeInTheDocument(); }); it('shows a saved Discord destination and a provider-neutral placeholder when Discord is connected', async () => { @@ -576,19 +639,22 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ); + expect( + screen.getByText('#automation-reports (Discord)'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Sentry Issues' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Sentry Issues/, + }), ); // The saved Discord channel is the selected destination. - expect( - screen.getByText('#automation-reports (Discord)'), - ).toBeInTheDocument(); // Pickers without a saved value use the provider-neutral placeholder. - expect(screen.getAllByText('Select a channel').length).toBeGreaterThan(0); + expect(screen.getByText('Select a channel')).toBeInTheDocument(); expect( screen.queryByText('Select a Slack channel'), ).not.toBeInTheDocument(); @@ -611,6 +677,11 @@ describe('AutomationsSettings', () => { render(); + fireEvent.click( + await screen.findByRole('button', { + name: /(?:Set up|Configure) Automation output/, + }), + ); const destination = await screen.findByRole('button', { name: /#automation-reports \(Discord\)/, }); @@ -642,7 +713,7 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Alert on Config Errors', + name: /(?:Set up|Configure) Alert on Config Errors/, }), ); @@ -658,7 +729,7 @@ describe('AutomationsSettings', () => { render(); const expandButton = await screen.findByRole('button', { - name: 'Expand Auto-respond to channels', + name: /(?:Set up|Configure) Auto-respond to channels/, }); fireEvent.click(expandButton); @@ -693,43 +764,160 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); }); - it('shows exception-only capability badges from the shared descriptors', async () => { + it('shows provider support as plain text instead of badges', async () => { + render(); + + await screen.findByText('Triage Dependabot Alerts'); + const providerSupport = screen.getAllByText('GitHub only')[0]!; + expect(providerSupport.tagName).toBe('P'); + expect(providerSupport).toHaveClass('text-sm', 'text-foreground'); + }); + + it('groups built-in automations into Enabled and Available sections', async () => { + render(); + + expect(await screen.findByText('Enabled')).toBeInTheDocument(); + expect(screen.getByText('Available')).toBeInTheDocument(); + expect(screen.queryByText('Source Code automations')).toBeNull(); + expect(screen.queryByText('Meta automations')).toBeNull(); + }); + + it('filters available automations by category and provider-aware search', async () => { + render(); + + const categoryFilter = await screen.findByRole('combobox', { + name: 'Filter available automations by category', + }); + expect(categoryFilter).toHaveTextContent('All'); + + fireEvent.change( + screen.getByRole('textbox', { name: 'Search available automations' }), + { target: { value: 'Discord' } }, + ); + + expect(screen.getByText('Auto-respond to channels')).toBeInTheDocument(); + expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { name: 'Clear automation filters' }), + ); + expect(screen.getByText('Review Code')).toBeInTheDocument(); + + fireEvent.click(categoryFilter); + fireEvent.click(await screen.findByRole('option', { name: 'Operations' })); + expect(screen.getByText('Triage Sentry Issues')).toBeInTheDocument(); + expect(screen.queryByText('Review Code')).not.toBeInTheDocument(); + }); + + it('shows independent structural skeletons for custom and built-in automations', () => { + state.customAutomationsPending = true; + state.settingsQuery.isPending = true; + render(); - // Dependabot and CodeQL stay GitHub-only; Issue Fixer supports - // GitHub/GitLab/Gitea, and manager stats is provider-neutral now and - // shows no source-control badge. - expect((await screen.findAllByText('GitHub only')).length).toBe(2); - // Full chat coverage for the suggester (no limited-comms badge); the other - // manager automations already cover all communication providers. - expect(screen.queryByText('Slack only')).toBeNull(); - expect(screen.queryByText('Slack · Discord · Telegram only')).toBeNull(); - expect(screen.queryByText(/Telegram only$/)).toBeNull(); - // conflict_resolver supports Gitea alongside GitHub, GitLab, and Azure - // DevOps. Bitbucket remains excluded because it has no conflict signal or - // label-based opt-in. ci_failure_triage covers all five SCM providers, so - // it no longer shows a limited-SCM badge. expect( - screen.getAllByText('GitHub · GitLab · Azure DevOps · Gitea only').length, - ).toBe(1); + screen + .getByTestId('custom-automations-skeleton') + .querySelectorAll('[data-slot="skeleton"]'), + ).toHaveLength(6); expect( - screen.queryByText( - 'GitHub · GitLab · Azure DevOps · Bitbucket Cloud only', - ), - ).toBeNull(); - // Full coverage shows nothing — absence of a warning is the signal. - expect(screen.queryByText('All chat channels')).toBeNull(); - expect(screen.queryByText('All source control')).toBeNull(); + screen + .getByTestId('built-in-automations-skeleton') + .querySelectorAll('[data-slot="skeleton"]'), + ).toHaveLength(17); }); - it('renders the Source Code and Meta automation sections', async () => { + it('uses plain text empty states for built-in and custom automations', async () => { + state.settingsQuery.data.settings.channelAutoStartSlackChannels = []; + state.settingsQuery.data.settings.managerSlackChannelId = null as never; + state.settingsQuery.data.slackChannelDisplayNames.managerSlackChannel = + null as never; + + render(); + + const builtInEmptyState = await screen.findByText( + 'No built-in automations enabled yet.', + ); + expect(builtInEmptyState.tagName).toBe('P'); + expect(builtInEmptyState).toHaveClass('text-sm', 'text-muted-foreground'); + const customEmptyState = screen.getByText( + 'No custom automations created yet.', + ); + expect(customEmptyState.tagName).toBe('P'); + expect(customEmptyState).toHaveClass('text-sm', 'text-muted-foreground'); + }); + + it('opens a built-in automation modal from its existing hash permalink', async () => { + window.location.hash = '#reviewer'; + render(); expect( - await screen.findByText('Source Code automations'), + await screen.findByRole('dialog', { name: 'Review Code' }), ).toBeInTheDocument(); - expect(screen.getByText('Meta automations')).toBeInTheDocument(); - expect(screen.queryByText('Other automations')).toBeNull(); + }); + + it('renders custom automations as a compact control list and honors their permalinks', async () => { + state.environments = [{ id: 'env-1', name: 'Production' }]; + state.customAutomations = [ + { + id: 'automation-1', + name: 'Weekly flaky-test scan', + prompt: 'Find flaky tests.', + enabled: true, + scheduleMode: 'weekly', + cronExpression: null, + model: null, + environmentId: 'env-1', + target: { provider: 'slack', externalRef: 'C123MANAGER' }, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByName: 'Ada', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + render(); + + expect( + await screen.findByRole('switch', { + name: 'Toggle Weekly flaky-test scan', + }), + ).toBeChecked(); + expect( + screen.getByText( + 'Weekly · Production · slack:#roomote-managers · Created by Ada', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Configure Weekly flaky-test scan', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete Weekly flaky-test scan' }), + ).toBeInTheDocument(); + + act(() => { + window.location.hash = '#custom-automation-automation-1'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect( + await screen.findByRole('dialog', { name: 'Edit custom automation' }), + ).toBeInTheDocument(); + expect(screen.getByText('Schedule')).toBeInTheDocument(); + expect(screen.getByText('Destination')).toBeInTheDocument(); + expect(screen.getByText('Channel')).toBeInTheDocument(); + expect(screen.queryByText('Cadence')).not.toBeInTheDocument(); + expect(screen.queryByText('Frequency')).not.toBeInTheDocument(); + expect(screen.queryByText('Destination provider')).not.toBeInTheDocument(); + expect( + screen.queryByText( + 'Configure what runs, when it runs, and where the result is sent.', + ), + ).not.toBeInTheDocument(); }); it('reflects the reviewer all-author setting in the review scope copy', async () => { diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx index 3fcc30d10..e054eff12 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -71,21 +71,20 @@ import { AlertCircle, AlertDescription, AlertTitle, - Badge, BasicTooltip, BellElectric, BrandIcon, Button, Card, - CardContent, CardHeader, CardTitle, ChartColumnIncreasing, Check, - ChevronRight, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, GitMergeConflict, GitPullRequest, Info, @@ -94,7 +93,9 @@ import { Lightbulb, Megaphone, Play, + Plus, RefreshCcw, + Search, Select, SelectContent, SelectItem, @@ -106,10 +107,12 @@ import { SquarePen, Slack, Spinner, + Settings2, Switch, Textarea, TriangleAlert, Users, + X, } from '@/components/system'; type FieldErrors = Partial< @@ -227,12 +230,26 @@ type AutomationDefinition = { label: string; description: string; icon: ComponentType<{ className?: string }>; + category: AutomationCategory; + searchTerms?: string[]; /** Compact label for the chat surfaces the automation can report to. */ commsBadge?: string; /** Compact label for the source-control providers the automation supports. */ scmBadge?: string; }; +type AutomationCategory = 'source-code' | 'communication' | 'operations'; + +const AUTOMATION_CATEGORY_OPTIONS: Array<{ + value: AutomationCategory | 'all'; + label: string; +}> = [ + { value: 'all', label: 'All' }, + { value: 'source-code', label: 'Source code' }, + { value: 'communication', label: 'Communication' }, + { value: 'operations', label: 'Operations' }, +]; + /** * Where an automation's next run will report, as resolved server-side through * the destination waterfall (own target -> Manager Channel -> primary @@ -334,12 +351,12 @@ const TRIGGERABLE_AUTOMATION_SCHEDULE_LABELS = { */ function getAutomationCapabilityBadges( automationKey: BackgroundAutomationKey, -): Pick { +): Pick { const descriptor = getTriggerableBackgroundAutomationDescriptorByKey(automationKey); if (!descriptor) { - return {}; + return { searchTerms: [] }; } const comms: readonly CommunicationProvider[] = @@ -363,6 +380,10 @@ function getAutomationCapabilityBadges( : undefined; return { + searchTerms: [ + ...comms.map(getCommunicationProviderDisplayName), + ...scm.map(getSourceControlProviderLabel), + ], ...(commsBadge ? { commsBadge } : {}), ...(scmBadge ? { scmBadge } : {}), }; @@ -385,6 +406,7 @@ function getAutomationDefinition( label: descriptor.label, description: TRIGGERABLE_AUTOMATION_DESCRIPTIONS[automationKey], icon, + category: automationKey === 'sentry_triage' ? 'operations' : 'source-code', ...getAutomationCapabilityBadges(automationKey), }; } @@ -449,6 +471,7 @@ const SCHEDULE_ONLY_AUTOMATION_DEFINITIONS = Object.fromEntries( description: SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS[automation.id].description, icon: SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS[automation.id].icon, + category: 'source-code', ...getAutomationCapabilityBadges(automation.automationKey), }, ]), @@ -474,6 +497,8 @@ const AUTOMATION_DEFINITIONS: Record = { description: 'Start tasks from selected Slack or Discord channels, each with its own custom instructions.', icon: MessagesSquare, + category: 'communication', + searchTerms: ['Slack', 'Discord'], }, managerChannel: { id: 'managerChannel', @@ -481,6 +506,8 @@ const AUTOMATION_DEFINITIONS: Record = { description: 'Shared Slack or Discord channel for manager-facing Roomote asks, summaries, and alerts.', icon: Users, + category: 'communication', + searchTerms: ['Slack', 'Discord'], }, managerStats: { ...getAutomationDefinition( @@ -488,6 +515,7 @@ const AUTOMATION_DEFINITIONS: Record = { 'manager_stats', ChartColumnIncreasing, ), + category: 'communication', }, sentryTriage: { ...getAutomationDefinition('sentryTriage', 'sentry_triage', SentryIcon), @@ -508,6 +536,8 @@ const AUTOMATION_DEFINITIONS: Record = { label: 'Review Code', description: `Review PRs automatically and on-demand.`, icon: GitPullRequest, + category: 'source-code', + searchTerms: sourceControlProviders.map(getSourceControlProviderLabel), }, conflictResolver: { ...getAutomationDefinition( @@ -518,9 +548,11 @@ const AUTOMATION_DEFINITIONS: Record = { }, suggester: { ...getAutomationDefinition('suggester', 'suggester', Lightbulb), + category: 'communication', }, announcer: { ...getAutomationDefinition('announcer', 'announcer', Megaphone), + category: 'communication', }, platformIssueAlerts: { id: 'platformIssueAlerts', @@ -528,10 +560,18 @@ const AUTOMATION_DEFINITIONS: Record = { description: 'Alert on Slack or Discord when a task runs into admin-fixable issues.', icon: BellElectric, + category: 'operations', + searchTerms: ['Slack', 'Discord'], }, }; const HASH_ALIAS_TO_AUTOMATION_ID: Record = { + ...Object.fromEntries( + Object.keys(AUTOMATION_DEFINITIONS).map((automationId) => [ + automationId.toLowerCase(), + automationId, + ]), + ), 'auto-respond-channels': 'channelAutoStart', autorespondchannels: 'channelAutoStart', 'auto-start-tasks': 'channelAutoStart', @@ -1169,8 +1209,12 @@ function shouldShowChannelAutoStartWarning(params: { function LoadingSkeleton() { return ( -
- {Array.from({ length: 6 }).map((_, index) => ( +
+ + {Array.from({ length: 4 }).map((_, index) => (
@@ -1427,6 +1471,7 @@ function AutomationCard({ isOpen, onOpenChange, iconEnabled, + isAvailableMatch = true, runAction, debugSection, footer, @@ -1438,6 +1483,7 @@ function AutomationCard({ isOpen: boolean; onOpenChange: (open: boolean) => void; iconEnabled: boolean; + isAvailableMatch?: boolean; runAction?: React.ReactNode; debugSection?: React.ReactNode; footer?: React.ReactNode; @@ -1447,25 +1493,63 @@ function AutomationCard({ }) { const Icon = automation.icon; const open = !disabled && (alwaysOpen || isOpen); - const content = ( - <> - - {children} - {debugSection} - - {footer ? ( -
{footer}
- ) : null} - - ); + const actionLabel = iconEnabled + ? `Configure ${automation.label}` + : `Set up ${automation.label}`; + + if (!iconEnabled && !isAvailableMatch) { + return null; + } return (
- + +
+
+
+
+ +
+
+
+ {automation.label} + {automation.commsBadge || automation.scmBadge ? ( +

+ {[automation.commsBadge, automation.scmBadge] + .filter(Boolean) + .join(' · ')} +

+ ) : null} +

+ {automation.description} +

+
+
+
+ {runAction && iconEnabled && !disabled ? runAction : null} + + + +
+
+
+ + + { if (!disabled) { @@ -1473,134 +1557,28 @@ function AutomationCard({ } }} > - - -
- {alwaysOpen ? ( -
- -
- - - -

- {automation.description} -

-
-
- ) : disabled ? ( -
- - - - -
- - - -

- {automation.description} -

-
-
- ) : ( - -
- - - - -
- - - -

- {automation.description} -

-
-
-
- )} - {runAction && !disabled ? ( -
{runAction}
- ) : null} -
-
- {alwaysOpen && !disabled ? ( - content - ) : !disabled ? ( - - {content} - - ) : null} -
-
+ + + {automation.label} + {automation.description} + +
+ {children} + {debugSection} + {footer ?
{footer}
: null} +
+
+
); } -function AutomationTitle({ automation }: { automation: AutomationDefinition }) { - return ( - - {automation.label} - {automation.commsBadge ? ( - - {automation.commsBadge} - - ) : null} - {automation.scmBadge ? ( - - {automation.scmBadge} - - ) : null} - - ); -} - function ScheduledAutomationCard({ automation, isOpen, onOpenChange, iconEnabled, + isAvailableMatch, disabled = false, debugSection, runTooltip, @@ -1622,6 +1600,7 @@ function ScheduledAutomationCard({ isOpen: boolean; onOpenChange: (open: boolean) => void; iconEnabled: boolean; + isAvailableMatch: boolean; disabled?: boolean; debugSection?: React.ReactNode; runTooltip: string; @@ -1645,6 +1624,7 @@ function ScheduledAutomationCard({ isOpen={isOpen} onOpenChange={onOpenChange} iconEnabled={iconEnabled} + isAvailableMatch={isAvailableMatch} disabled={disabled} debugSection={debugSection} runAction={ @@ -1721,6 +1701,10 @@ export function AutomationsSettings() { const [isEditingManagerChannel, setIsEditingManagerChannel] = useState(false); const [isEnteringCustomManagerChannel, setIsEnteringCustomManagerChannel] = useState(false); + const [availableCategory, setAvailableCategory] = useState< + AutomationCategory | 'all' + >('all'); + const [availableSearch, setAvailableSearch] = useState(''); const formStateRef = useRef(null); const savedStateRef = useRef(null); const didApplyInitialHashRef = useRef(false); @@ -2116,6 +2100,13 @@ export function AutomationsSettings() { } return next; }); + + if (typeof window !== 'undefined') { + const nextUrl = open + ? `${window.location.pathname}${window.location.search}#${automationId}` + : `${window.location.pathname}${window.location.search}`; + window.history.replaceState(null, '', nextUrl); + } }, [], ); @@ -2598,6 +2589,28 @@ export function AutomationsSettings() { announcer: announcerIsEnabled, platformIssueAlerts: isPlatformIssueAlertsEnabled(formState), } satisfies Record; + const normalizedAvailableSearch = availableSearch.trim().toLowerCase(); + const availableAutomationMatches = new Set( + Object.values(AUTOMATION_DEFINITIONS) + .filter( + (automation) => + !iconEnabled[automation.id] && + (availableCategory === 'all' || + automation.category === availableCategory) && + (!normalizedAvailableSearch || + [ + automation.label, + automation.description, + ...(automation.searchTerms ?? []), + ] + .join(' ') + .toLowerCase() + .includes(normalizedAvailableSearch)), + ) + .map((automation) => automation.id), + ); + const hasAvailableFilters = + availableCategory !== 'all' || Boolean(normalizedAvailableSearch); const isAutomationSaving = (automationId: AutomationId) => updateMutation.isPending && savingAutomation === automationId; @@ -2711,17 +2724,86 @@ export function AutomationsSettings() { ) : null} + + {settingsQuery.isPending || !formState ? ( ) : (
-
-

- Source Code automations +
+

+ Enabled

- + {Object.values(iconEnabled).some(Boolean) ? null : ( +

+ No built-in automations enabled yet. +

+ )} +
+

+ Available +

+
+ +
+ + + setAvailableSearch(event.currentTarget.value) + } + placeholder="Search" + aria-label="Search available automations" + className="h-8 w-36 pl-8 text-sm" + /> +
+ {hasAvailableFilters ? ( + + + + ) : null} +
+
+ {availableAutomationMatches.size === 0 ? ( +

+ No available automations match these filters. +

+ ) : null} setAutomationOpen('reviewer', open)} iconEnabled={iconEnabled.reviewer} @@ -2870,6 +2952,9 @@ export function AutomationsSettings() { setAutomationOpen(automation.id, open) @@ -2946,6 +3031,9 @@ export function AutomationsSettings() { setAutomationOpen('conflictResolver', open) @@ -3152,6 +3240,9 @@ export function AutomationsSettings() { setAutomationOpen(automation.id, open) @@ -3264,6 +3355,9 @@ export function AutomationsSettings() { setAutomationOpen('dependabotTriage', open) @@ -3339,6 +3433,7 @@ export function AutomationsSettings() { setAutomationOpen('codeqlTriage', open)} iconEnabled={iconEnabled.codeqlTriage} @@ -3429,6 +3524,9 @@ export function AutomationsSettings() { setAutomationOpen(automation.id, open) @@ -3539,14 +3637,11 @@ export function AutomationsSettings() { ); })} - - -

- Channel automations -

- setAutomationOpen('channelAutoStart', open) @@ -3600,807 +3695,822 @@ export function AutomationsSettings() { /> -

- Automations for Roomote Managers -

+ setAutomationOpen('managerChannel', open)} + iconEnabled={iconEnabled.managerChannel} + > +
+ {showManagerChannelForm ? ( + <> + +

+ Make sure the Roomote app is added to the channel. +

+
+
+ { - if (value === CLEAR_MANAGER_CHANNEL_SELECT_VALUE) { setIsEnteringCustomManagerChannel(false); setFormState((prev) => prev ? { ...prev, - managerSlackChannel: '', + managerSlackChannel: selectedChannel.label, managerDiscordChannel: '', } : prev, ); - return; - } - - if (value === CUSTOM_MANAGER_CHANNEL_SELECT_VALUE) { + }} + disabled={managerChannelSelectionDisabled} + > + + + {managerChannelSelectLabel} + + + + {managerChannelHasValue ? ( + <> + + Clear selection + + + + ) : null} + {slackChannelsQuery.isPending || + discordChannelsQuery.isPending ? ( + + Loading channels... + + ) : slackChannelsQuery.isError || + discordChannelsQuery.isError ? ( + + Could not load channels. Try refreshing. + + ) : managerChannelOptions.length > 0 || + managerDiscordChannelOptions.length > 0 ? ( + [ + ...managerChannelOptions, + ...managerDiscordChannelOptions, + ].map((channel) => ( + + {channel.label} + + )) + ) : ( + + No channels found. + + )} + + + Private or manual channel + + + + {managerChannelOptions.length > 0 || + managerDiscordChannelOptions.length > 0 ? ( + + ) : null} +
+ {showCustomManagerChannelInput ? ( + { setIsEnteringCustomManagerChannel(true); - setFormState((prev) => - prev && selectedManagerChannelOption - ? { - ...prev, - managerSlackChannel: '', - managerDiscordChannel: '', - } - : prev, - ); - return; - } - - if ( - value.startsWith(DISCORD_DESTINATION_OPTION_PREFIX) - ) { - setIsEnteringCustomManagerChannel(false); setFormState((prev) => prev ? { ...prev, - managerSlackChannel: '', - managerDiscordChannel: value.slice( - DISCORD_DESTINATION_OPTION_PREFIX.length, - ), + managerSlackChannel: event.target.value, + managerDiscordChannel: '', } : prev, ); - return; - } - - const selectedChannel = managerChannelOptions.find( - (channel) => channel.id === value, - ); - - if (!selectedChannel) { - return; + }} + placeholder="Enter a private channel name or Slack channel ID" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> + ) : null} +
+

+ Private channels may not appear in the list. Use the + manual option to paste a private channel name or raw Slack + channel ID. +

+ {showManagerSlackChannelWarning ? ( + + ) : null} + {fieldErrors.managerSlackChannel || + fieldErrors.managerDiscordChannel ? ( +

+ {fieldErrors.managerSlackChannel ?? + fieldErrors.managerDiscordChannel} +

+ ) : null} + {showManagerChannelMigrationNote ? ( + + + Some older automations still point at different Slack + channels. Pick the shared Manager Channel here to + migrate future manager-facing posts onto one + destination. + + + ) : null} +
+ saveAgent('managerChannel')} + onReset={() => { + resetAgent('managerChannel'); + if (managerChannelConfigured) { + setIsEditingManagerChannel(false); } - - setIsEnteringCustomManagerChannel(false); - setFormState((prev) => - prev - ? { - ...prev, - managerSlackChannel: selectedChannel.label, - managerDiscordChannel: '', - } - : prev, - ); }} - disabled={managerChannelSelectionDisabled} - > - - - {managerChannelSelectLabel} - - - - {managerChannelHasValue ? ( - <> - - Clear selection - - - - ) : null} - {slackChannelsQuery.isPending || - discordChannelsQuery.isPending ? ( - - Loading channels... - - ) : slackChannelsQuery.isError || - discordChannelsQuery.isError ? ( - - Could not load channels. Try refreshing. - - ) : managerChannelOptions.length > 0 || - managerDiscordChannelOptions.length > 0 ? ( - [ - ...managerChannelOptions, - ...managerDiscordChannelOptions, - ].map((channel) => ( - - {channel.label} - - )) - ) : ( - - No channels found. - - )} - - - Private or manual channel - - - - {managerChannelOptions.length > 0 || - managerDiscordChannelOptions.length > 0 ? ( + /> + {managerChannelConfigured && + isEditingManagerChannel && + !isDirty.managerChannel ? ( ) : null}
- {showCustomManagerChannelInput ? ( - { - setIsEnteringCustomManagerChannel(true); - setFormState((prev) => - prev - ? { - ...prev, - managerSlackChannel: event.target.value, - managerDiscordChannel: '', - } - : prev, - ); - }} - placeholder="Enter a private channel name or Slack channel ID" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - /> - ) : null} -
-

- Private channels may not appear in the list. Use the manual - option to paste a private channel name or raw Slack channel - ID. + + ) : ( +

+ Posting manager-facing updates to{' '} +

- {showManagerSlackChannelWarning ? ( - - ) : null} - {fieldErrors.managerSlackChannel || - fieldErrors.managerDiscordChannel ? ( -

- {fieldErrors.managerSlackChannel ?? - fieldErrors.managerDiscordChannel} -

- ) : null} - {showManagerChannelMigrationNote ? ( - - - Some older automations still point at different Slack - channels. Pick the shared Manager Channel here to - migrate future manager-facing posts onto one - destination. - - - ) : null} -
- saveAgent('managerChannel')} - onReset={() => { - resetAgent('managerChannel'); - if (managerChannelConfigured) { - setIsEditingManagerChannel(false); - } - }} - /> - {managerChannelConfigured && - isEditingManagerChannel && - !isDirty.managerChannel ? ( - - ) : null} -
- - ) : ( -

- Posting manager-facing updates to{' '} + )} +

+ + + setAutomationOpen('managerStats', open)} + iconEnabled={iconEnabled.managerStats} + debugSection={renderDebugRunsSection('managerStats')} + runAction={ + -

- )} -

-
- - setAutomationOpen('managerStats', open)} - iconEnabled={iconEnabled.managerStats} - debugSection={renderDebugRunsSection('managerStats')} - runAction={ - - - - } - footer={ - saveAgent('managerStats')} - onReset={() => resetAgent('managerStats')} - /> - } - > -
-
- - setFormState((prev) => - prev - ? { - ...prev, - managerStatsFrequency: enabled ? 'weekly' : 'off', - } - : prev, - ) + + } + footer={ + saveAgent('managerStats')} + onReset={() => resetAgent('managerStats')} /> - + } + > +
+
+ + setFormState((prev) => + prev + ? { + ...prev, + managerStatsFrequency: enabled ? 'weekly' : 'off', + } + : prev, + ) + } + /> + +
+ + {managerStatsIsEnabled ? ( +
+ {renderSlackDestinationField({ + field: 'managerStatsSlackChannel', + inputId: 'manager-stats-slack-channel', + label: 'Post summaries to this Slack channel', + helperText: + 'Choose where Roomote should post the Friday manager digest.', + savedChannelId: + settingsQuery.data?.settings + .managerStatsSlackChannelId ?? null, + savedDiscordChannelId: + settingsQuery.data?.settings + .managerStatsDiscordChannelId ?? null, + warningChannelId: + slackChannelAccessWarnings.managerStatsSlackChannel, + })} + +

+ Posts a weekly summary on Fridays. +

+
+ ) : null}
+ - {managerStatsIsEnabled ? ( -
- {renderSlackDestinationField({ - field: 'managerStatsSlackChannel', - inputId: 'manager-stats-slack-channel', - label: 'Post summaries to this Slack channel', - helperText: - 'Choose where Roomote should post the Friday manager digest.', - savedChannelId: - settingsQuery.data?.settings.managerStatsSlackChannelId ?? - null, - savedDiscordChannelId: - settingsQuery.data?.settings - .managerStatsDiscordChannelId ?? null, - warningChannelId: - slackChannelAccessWarnings.managerStatsSlackChannel, - })} - -

- Posts a weekly summary on Fridays. -

-
- ) : null} -
- - - setAutomationOpen('sentryTriage', open)} - iconEnabled={iconEnabled.sentryTriage} - debugSection={renderDebugRunsSection('sentryTriage')} - runAction={ - - - - } - footer={ - saveAgent('sentryTriage')} - onReset={() => resetAgent('sentryTriage')} - /> - } - > -
- { + const frequency = value as SentryTriageFrequency; + + if ( + !canSelectSentryTriageFrequency({ + sentryConnected: sentryConnected, + frequency, + }) + ) { + toast.error( + 'Configure Sentry in Settings > Integrations before enabling Triage Sentry Issues.', + ); + return; + } - setFormState((prev) => - prev - ? { - ...prev, - sentryTriageFrequency: frequency, - } - : prev, - ); - }} - > - + prev + ? { + ...prev, + sentryTriageFrequency: frequency, + } + : prev, + ); + }} > - - - - {SENTRY_TRIAGE_FREQUENCY_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {!sentryConnected ? ( - - - Connect Sentry first - -
- - Connect the workspace Sentry integration before enabling - scheduled Sentry triage. - - -
-
-
- ) : null} - - {sentryTriageIsEnabled ? ( -
- {renderSlackDestinationField({ - field: 'sentryTriageSlackChannel', - inputId: 'sentry-triage-slack-channel', - label: 'Post follow-up work to this Slack channel', - helperText: - 'Choose where Roomote should post actionable Sentry follow-up work.', - savedChannelId: - settingsQuery.data?.settings.sentryTriageSlackChannelId ?? - null, - savedDiscordChannelId: - settingsQuery.data?.settings - .sentryTriageDiscordChannelId ?? null, - warningChannelId: - slackChannelAccessWarnings.sentryTriageSlackChannel, - })} - -

- Requires Sentry to be configured in Settings > - Integrations. -

+ + + + + {SENTRY_TRIAGE_FREQUENCY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + {!sentryConnected ? ( + + + Connect Sentry first + +
+ + Connect the workspace Sentry integration before + enabling scheduled Sentry triage. + + +
+
+
+ ) : null} + + {sentryTriageIsEnabled ? ( +
+ {renderSlackDestinationField({ + field: 'sentryTriageSlackChannel', + inputId: 'sentry-triage-slack-channel', + label: 'Post follow-up work to this Slack channel', + helperText: + 'Choose where Roomote should post actionable Sentry follow-up work.', + savedChannelId: + settingsQuery.data?.settings + .sentryTriageSlackChannelId ?? null, + savedDiscordChannelId: + settingsQuery.data?.settings + .sentryTriageDiscordChannelId ?? null, + warningChannelId: + slackChannelAccessWarnings.sentryTriageSlackChannel, + })} -
- -