From 5acbbade89d34d0b346f7a2daff0c4ce7015e8fa Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Thu, 29 Oct 2026 09:16:59 -0400 Subject: [PATCH 1/4] [Fix] All-repository automations fail to start after a second GitHub org is installed (#3624) --- .../src/__tests__/task-run-token.test.ts | 51 ++++++++++++++++++- packages/github/src/api.ts | 43 ++++++++++++---- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/github/src/__tests__/task-run-token.test.ts b/packages/github/src/__tests__/task-run-token.test.ts index 84088be18..c95edd282 100644 --- a/packages/github/src/__tests__/task-run-token.test.ts +++ b/packages/github/src/__tests__/task-run-token.test.ts @@ -4,7 +4,9 @@ const { mockFindFirst, mockFindEnvironmentFirst, mockFindMappings, + mockFindInstallation, } = vi.hoisted(() => ({ + mockFindInstallation: vi.fn(), mockCreateGitHubTokenWithMetadata: vi.fn(), mockFindMany: vi.fn(), mockFindFirst: vi.fn(), @@ -28,10 +30,16 @@ vi.mock('@roomote/db/server', () => ({ findFirst: mockFindEnvironmentFirst, }, environmentRepositoryMappings: { findMany: mockFindMappings }, + githubInstallations: { findFirst: mockFindInstallation }, }, }, + asc: vi.fn((value: unknown) => ({ type: 'asc', value })), eq: vi.fn((left: unknown, right: unknown) => ({ type: 'eq', left, right })), - githubInstallations: {}, + githubInstallations: { + id: 'githubInstallations.id', + createdAt: 'githubInstallations.createdAt', + suspendedAt: 'githubInstallations.suspendedAt', + }, githubPendingInstallations: {}, environments: { id: 'environments.id', @@ -494,6 +502,47 @@ describe('createTaskRunGitHubToken', () => { consoleWarnSpy.mockRestore(); }); + it('anchors an all-repository stamp without an environment on the oldest installation', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + mockFindInstallation.mockResolvedValue({ id: 'install-a' }); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: '__all_repositories__', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).resolves.toBe('ghs_test_token'); + expect(mockCreateGitHubTokenWithMetadata).toHaveBeenCalledWith( + { + type: 'installationId', + installationId: 'install-a', + repositoryIds: [401], + }, + undefined, + undefined, + ); + consoleWarnSpy.mockRestore(); + }); + it('keeps failing a spanning stamp without an environment when the task repository is not stamped', async () => { mockFindMany.mockResolvedValue([ { diff --git a/packages/github/src/api.ts b/packages/github/src/api.ts index 6bcab426a..798c6b125 100644 --- a/packages/github/src/api.ts +++ b/packages/github/src/api.ts @@ -29,6 +29,7 @@ import { environmentRepositoryMappings, repositories, and, + asc, eq, isNull, inArray, @@ -288,16 +289,36 @@ export async function resolveTaskRunEnvironmentGitHubRepositories( * (`payload.repo`). Returns null when that repository is not an active GitHub * repository, so callers keep failing closed. */ +async function resolveOldestInstallationId( + rows: Repository[], +): Promise { + const installationIds = [ + ...new Set( + rows + .map((row) => row.installationId) + .filter((id): id is string => Boolean(id)), + ), + ]; + if (installationIds.length === 0) return null; + const oldest = await db.query.githubInstallations.findFirst({ + where: and( + inArray(githubInstallations.id, installationIds), + isNull(githubInstallations.suspendedAt), + ), + orderBy: [asc(githubInstallations.createdAt)], + columns: { id: true }, + }); + return oldest?.id ?? null; +} + async function resolvePrimaryRepositoryInstallationScope( taskRun: TaskRun, stampedRepositories: string[], ): Promise { const primaryRepository = taskRun.payload.repo; - if ( - !primaryRepository || - primaryRepository === ALL_REPOSITORIES || - !stampedRepositories.includes(primaryRepository) - ) { + const allRepositories = + !primaryRepository || primaryRepository === ALL_REPOSITORIES; + if (!allRepositories && !stampedRepositories.includes(primaryRepository)) { return null; } @@ -308,9 +329,13 @@ async function resolvePrimaryRepositoryInstallationScope( inArray(repositories.fullName, stampedRepositories), ), }); - const primaryInstallationId = stampedRows.find( - (row) => row.fullName === primaryRepository, - )?.installationId; + // All-repository runs have no repository of their own: anchor on the + // deployment's oldest active installation, the same one unconnected reads + // use. Repositories on other installations get their own git credentials. + const primaryInstallationId = allRepositories + ? await resolveOldestInstallationId(stampedRows) + : stampedRows.find((row) => row.fullName === primaryRepository) + ?.installationId; if (!primaryInstallationId) { return null; } @@ -325,7 +350,7 @@ async function resolvePrimaryRepositoryInstallationScope( (repository) => !includedRepositories.has(repository), ); console.warn( - `[resolveTaskRunGitHubTokenOptions] Stamped GitHub repositories for task run ${taskRun.id} span multiple installations; minting the ${primaryRepository} installation only. Not reachable with this token: ${excludedRepositories.join(', ')}`, + `[resolveTaskRunGitHubTokenOptions] Stamped GitHub repositories for task run ${taskRun.id} span multiple installations; minting the ${allRepositories ? 'oldest' : primaryRepository} installation only (other installations get git credentials). Not reachable with this token: ${excludedRepositories.join(', ')}`, ); return resolveTokenOptionsForRepositoryNames({ From c97173a7a839c8453561bd80c32800824ed04b13 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:54:44 +0000 Subject: [PATCH 2/4] fix(web): restore sessions multi-selection and retain overview after deletion (#3626) Co-authored-by: Roomote --- apps/docs/fast-sessions.mdx | 9 + .../(authenticated)/sessions/SessionBoard.tsx | 19 +- .../(authenticated)/sessions/SessionCard.tsx | 78 +++-- .../sessions/page.client.test.tsx | 275 +++++++++++++++- .../src/app/(authenticated)/sessions/page.tsx | 190 +++++------ .../SessionDeleteAction.client.test.tsx | 18 ++ .../[sessionId]/SessionDeleteAction.tsx | 3 +- .../components/sessions/SessionSelection.tsx | 296 ++++++++++++++++++ 8 files changed, 746 insertions(+), 142 deletions(-) create mode 100644 apps/web/src/components/sessions/SessionSelection.tsx diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 247da4a23..0c4d65cbf 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -158,6 +158,15 @@ reversible and hides the session and its linked tasks from ordinary lists and search until it is unarchived. Deleting a session uses the existing confirmation and cleanup safeguards. +In the Sessions list or board, choose **Select sessions** to show checkboxes. +Select sessions individually, or choose **All** to select every session you can +manage on the current page. **Delete (n)** confirms and deletes the selection +using the same cleanup safeguards as individual deletion. If some deletions +fail, those sessions stay selected for retry. Exit selection mode to resume +opening sessions normally; changing views, filters, or pages clears selection. +Deleting one or several sessions from either overview keeps you on that view +with its current filters and page. + On desktop, you can keep several task workspaces open beside the session, resize them, and switch between the conversation and task panels without losing your place. On smaller screens, Roomote shows one workspace at a time so the diff --git a/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx index ee42e25c4..89711bb09 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionBoard.tsx @@ -41,6 +41,7 @@ import { import { BasicTooltip, Button, Funnel } from '@/components/system'; import { useSessionStatusMutation } from '@/components/sessions/use-session-status-mutation'; +import { useSessionSelectionActive } from '@/components/sessions/SessionSelection'; import { canDropSessionBoardCard, getSessionBoardDropStatus, @@ -454,6 +455,8 @@ export function SessionBoardCard({ }) { const reducedMotion = useReducedMotion() ?? false; const registry = useContext(SessionBoardCardRegistryContext); + const selecting = useSessionSelectionActive(); + const canDrag = canManage && !selecting; const dnd = useContext(SessionBoardDndContext); const [isFlying, setIsFlying] = useState(false); const dragData = useRef({ @@ -470,7 +473,7 @@ export function SessionBoardCard({ const draggable = useDraggable({ id: sessionId, data: dragData.current, - disabled: !canManage || dnd?.statusMutationPending === true, + disabled: !canDrag || dnd?.statusMutationPending === true, }); useLayoutEffect(() => { @@ -529,14 +532,14 @@ export function SessionBoardCard({
{children}
diff --git a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx index 86fc568a1..56be72304 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionCard.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link'; import { format, formatDistanceToNow, isSameDay, isSameYear } from 'date-fns'; import type { SessionManualStatus } from '@roomote/types'; @@ -21,6 +20,11 @@ import { SessionInferenceCostBreakdown } from '@/components/sessions/SessionInfe import { PrivateSessionIcon } from '@/components/sessions/PrivateSessionIcon'; import { getSessionSurfaceLabel } from '@/components/sessions/session-surfaces'; import { TaskAutomationIcon } from '@/components/tasks/TaskAutomationIcon'; +import { + SessionSelectionControl, + SessionSelectionLink, + SessionSelectionRow, +} from '@/components/sessions/SessionSelection'; import { SessionActions } from '../../(sandbox)/sessions/[sessionId]/SessionDeleteAction'; type SessionCardData = { @@ -108,41 +112,53 @@ export function SessionCard({ const accessibleActivityTimestamp = activityDate.toLocaleString(); return ( -
- + {session.title} - +
- {session.ownerKind === 'automation' ? ( - - ) : ( - - )} - {session.unread && session.ownerUserId === viewerUserId ? ( - - ) : null} + )} + {session.unread && session.ownerUserId === viewerUserId ? ( + + ) : null} +
@@ -230,6 +246,6 @@ export function SessionCard({ />
) : null} -
+ ); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx b/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx index fde8fdb28..b774137df 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.client.test.tsx @@ -1,20 +1,47 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; - -const { getSessionsMock, sessionStatusState } = vi.hoisted(() => ({ - getSessionsMock: vi.fn(), - sessionStatusState: { - current: ['active', 'needs_input', 'blocked', 'ready', 'done'] as Array< - 'active' | 'needs_input' | 'blocked' | 'ready' | 'done' - >, - }, -})); +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; + +const { getSessionsMock, sessionStatusState, remove, refresh, push } = + vi.hoisted(() => ({ + remove: vi.fn(), + refresh: vi.fn(), + push: vi.fn(), + getSessionsMock: vi.fn(), + sessionStatusState: { + current: ['active', 'needs_input', 'blocked', 'ready', 'done'] as Array< + 'active' | 'needs_input' | 'blocked' | 'ready' | 'done' + >, + }, + })); vi.mock('next/navigation', () => ({ usePathname: () => '/sessions', - useRouter: () => ({ replace: vi.fn() }), + useRouter: () => ({ replace: vi.fn(), refresh, push }), useSearchParams: () => new URLSearchParams(), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ sessions: { delete: { mutationOptions: () => ({}) } } }), +})); +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutateAsync: remove }), +})); +vi.mock( + '../../(sandbox)/sessions/[sessionId]/SessionDeleteAction', + async (importOriginal) => ({ + ...(await importOriginal()), + SessionActions: () => ( + + + + ) : ( + + )} + + {children} + { + if (!pending) setConfirming(open); + }} + > + + + + Delete {selected.size} session{selected.size === 1 ? '' : 's'}? + + + {SESSION_DELETION_DESCRIPTION} + + + + + + + + + + ); +} + +export function SessionSelectionRow({ + sessionId, + className, + children, +}: { + sessionId: string; + className: string; + children: ReactNode; +}) { + const selection = useContext(SessionSelectionContext); + return ( +
+ {children} +
+ ); +} + +export function SessionSelectionLink({ + sessionId, + title, + canManage, + href, + className, + children, +}: { + sessionId: string; + title: string; + canManage: boolean; + href: string; + className: string; + children: ReactNode; +}) { + const selection = useContext(SessionSelectionContext); + if (selection?.active) { + return ( +