Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 200 additions & 1 deletion tests/unit/api/routers/runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
});
});
});
});
56 changes: 56 additions & 0 deletions web/src/components/runs/retry-run-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="inline-flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
retryMutation.mutate();
}}
disabled={retryMutation.isPending}
title="Retry run"
>
<RefreshCw className="h-4 w-4" />
</Button>
{retryMutation.isError && (
<span
className="text-xs text-destructive"
title={
retryMutation.error instanceof Error ? retryMutation.error.message : 'Retry failed'
}
>
Failed
</span>
)}
</span>
);
}
7 changes: 6 additions & 1 deletion web/src/components/runs/runs-table.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -41,12 +42,13 @@ export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTabl
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">Iterations</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground">PR</th>
<th className="px-4 py-3 text-center font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{runs.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
No runs found
</td>
</tr>
Expand Down Expand Up @@ -91,6 +93,9 @@ export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTabl
'-'
)}
</td>
<td className="px-4 py-3 text-center">
<RetryRunButton runId={run.id} status={run.status} />
</td>
</tr>
))}
</tbody>
Expand Down
Loading