diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index efc88bca..3e35e3b9 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -48,6 +48,14 @@ vi.mock('../../../../src/triggers/shared/debug-runner.js', () => ({ triggerDebugAnalysis: (...args: unknown[]) => mockTriggerDebugAnalysis(...args), })); +// Mock triggerManualRun and triggerRetryRun (fire-and-forget) +const mockTriggerManualRun = vi.fn(); +const mockTriggerRetryRun = vi.fn(); +vi.mock('../../../../src/triggers/shared/manual-runner.js', () => ({ + triggerManualRun: (...args: unknown[]) => mockTriggerManualRun(...args), + triggerRetryRun: (...args: unknown[]) => mockTriggerRetryRun(...args), +})); + // Mock config provider const mockFindProjectById = vi.fn(); const mockLoadConfig = vi.fn(); @@ -83,8 +91,10 @@ describe('runsRouter', () => { // Set up DB chain for getById org check mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); - // Default: triggerDebugAnalysis returns a resolved promise (fire-and-forget) + // Default: fire-and-forget mocks return resolved promises mockTriggerDebugAnalysis.mockReturnValue(Promise.resolve()); + mockTriggerManualRun.mockReturnValue(Promise.resolve()); + mockTriggerRetryRun.mockReturnValue(Promise.resolve()); }); describe('list', () => { @@ -501,4 +511,193 @@ describe('runsRouter', () => { }); }); }); + + describe('trigger', () => { + it('fires a manual run and returns triggered:true', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + const result = await caller.trigger({ + projectId: 'p1', + agentType: 'implementation', + cardId: 'card-abc', + }); + + expect(result).toEqual({ triggered: true }); + expect(mockTriggerManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'p1', + agentType: 'implementation', + cardId: 'card-abc', + }), + { id: 'p1', name: 'Test Project' }, + {}, + ); + }); + + it('passes optional fields when provided', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + await caller.trigger({ + projectId: 'p1', + agentType: 'review', + prNumber: 42, + prBranch: 'feature/my-branch', + model: 'claude-opus-4-5', + }); + + expect(mockTriggerManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + prNumber: 42, + prBranch: 'feature/my-branch', + modelOverride: 'claude-opus-4-5', + }), + expect.anything(), + expect.anything(), + ); + }); + + it('throws NOT_FOUND when project does not exist in DB', async () => { + mockDbWhere.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'missing', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'other-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project config not found', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('retry', () => { + it('fires a retry run and returns triggered:true', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + const result = await caller.retry({ runId: RUN_UUID }); + + expect(result).toEqual({ triggered: true }); + expect(mockTriggerRetryRun).toHaveBeenCalledWith( + RUN_UUID, + { id: 'p1', name: 'Test Project' }, + {}, + undefined, + ); + }); + + it('passes model override when provided', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + await caller.retry({ runId: RUN_UUID, model: 'claude-opus-4-5' }); + + expect(mockTriggerRetryRun).toHaveBeenCalledWith( + RUN_UUID, + expect.anything(), + expect.anything(), + 'claude-opus-4-5', + ); + }); + + it('throws NOT_FOUND when run does not exist', async () => { + mockGetRunById.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when org does not match', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws BAD_REQUEST when run has no projectId', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: null, + agentType: 'implementation', + }); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('throws NOT_FOUND when project config not found', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p-missing', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); }); diff --git a/web/src/components/runs/retry-run-button.tsx b/web/src/components/runs/retry-run-button.tsx new file mode 100644 index 00000000..36538846 --- /dev/null +++ b/web/src/components/runs/retry-run-button.tsx @@ -0,0 +1,56 @@ +import { Button } from '@/components/ui/button.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw } from 'lucide-react'; + +interface RetryRunButtonProps { + runId: string; + /** Hide button when status is 'running' */ + status: string; +} + +export function RetryRunButton({ runId, status }: RetryRunButtonProps) { + const queryClient = useQueryClient(); + + const retryMutation = useMutation({ + mutationFn: () => trpcClient.runs.retry.mutate({ runId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.runs.list.queryOptions({}).queryKey }); + queryClient.invalidateQueries({ + queryKey: trpc.runs.getById.queryOptions({ id: runId }).queryKey, + }); + }, + }); + + if (status === 'running') { + return null; + } + + return ( + + + {retryMutation.isError && ( + + Failed + + )} + + ); +} diff --git a/web/src/components/runs/runs-table.tsx b/web/src/components/runs/runs-table.tsx index d0cf5e1b..b931eb58 100644 --- a/web/src/components/runs/runs-table.tsx +++ b/web/src/components/runs/runs-table.tsx @@ -1,6 +1,7 @@ import { formatCost, formatDuration, formatRelativeTime } from '@/lib/utils.js'; import { Link } from '@tanstack/react-router'; import { ExternalLink } from 'lucide-react'; +import { RetryRunButton } from './retry-run-button.js'; import { RunStatusBadge } from './run-status-badge.js'; interface Run { @@ -41,12 +42,13 @@ export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTabl