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 4047e6fe9..f2366a3bd 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 @@ -293,7 +293,14 @@ const mutations = vi.hoisted(() => ({ ) => void; } | null, latestCustomTriggerOptions: null as { - onSuccess?: (result: { outcome: 'launched'; taskId: string }) => void; + onSuccess?: ( + result: + | { outcome: 'launched'; taskId: string } + | { outcome: 'queued' } + | { outcome: 'completed' } + | { outcome: 'skipped'; reason: string } + | { outcome: 'failed'; error: string }, + ) => void; } | null, })); @@ -624,8 +631,34 @@ function closeAutomationDialog() { fireEvent.click(screen.getByRole('button', { name: 'Close' })); } +function setRunnableCustomAutomation() { + state.customAutomations = [ + { + id: 'automation-1', + name: 'Daily scan', + prompt: 'Find flaky tests.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + executionMode: 'fast', + environmentId: '__fast__', + target: {}, + 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'), + }, + ]; +} + describe('AutomationsSettings', () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); state.nextUpdateSettingsResult = null; state.customAutomationRunPendingId = null; @@ -1244,6 +1277,9 @@ describe('AutomationsSettings', () => { action: expect.objectContaining({ label: 'View task' }), }), ); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['automations', 'listCustomAutomations'], + }); state.customAutomations.push({ ...state.customAutomations[0]!, @@ -1300,6 +1336,73 @@ describe('AutomationsSettings', () => { ).not.toBeInTheDocument(); }); + it.each([ + { outcome: 'completed' as const }, + { outcome: 'failed' as const, error: 'launch failed' }, + ])('refreshes persisted custom automation state after $outcome', (result) => { + setRunnableCustomAutomation(); + render(); + + act(() => { + mutations.latestCustomTriggerOptions?.onSuccess?.(result); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['automations', 'listCustomAutomations'], + }); + }); + + it.each([ + { outcome: 'launched' as const, taskId: 'task-custom-1' }, + { outcome: 'queued' as const }, + ])('uses bounded follow-up refreshes after $outcome', (result) => { + vi.useFakeTimers(); + setRunnableCustomAutomation(); + const { unmount } = render(); + + try { + act(() => { + mutations.latestCustomTriggerOptions?.onSuccess?.(result); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + + act(() => { + vi.runAllTimers(); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledTimes(8); + } finally { + unmount(); + vi.useRealTimers(); + } + }); + + it('does not schedule follow-up refreshes after unmount', () => { + vi.useFakeTimers(); + setRunnableCustomAutomation(); + const { unmount } = render(); + const onSuccess = mutations.latestCustomTriggerOptions?.onSuccess; + + try { + unmount(); + act(() => { + onSuccess?.({ outcome: 'queued' }); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + + act(() => { + vi.runAllTimers(); + }); + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + it('offers and displays the all-repositories workspace target', async () => { state.customAutomations = [ { diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index deecc9e4e..a718b056e 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -135,6 +135,18 @@ function cadenceLabel(row: CustomAutomationListItem): string { : 'Custom schedule'; } +// Fast runs settle asynchronously, so refresh sparsely through the existing +// ten-minute launch-claim recovery window instead of polling indefinitely. +const RUN_RESULT_REFRESH_DELAYS_MS = [ + 5_000, + 15_000, + 30_000, + 60_000, + 2 * 60_000, + 5 * 60_000, + 10 * 60_000, +]; + function CustomAutomationRunButton({ automation, disabled, @@ -143,9 +155,40 @@ function CustomAutomationRunButton({ disabled: boolean; }) { const trpc = useTRPC(); + const queryClient = useQueryClient(); + const isMountedRef = useRef(true); + const refreshTimeoutsRef = useRef([]); + const invalidate = () => + queryClient.invalidateQueries({ + queryKey: trpc.automations.listCustomAutomations.queryKey(), + }); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + for (const timeout of refreshTimeoutsRef.current) { + window.clearTimeout(timeout); + } + }; + }, []); + const triggerMutation = useMutation({ ...trpc.automations.triggerCustomAutomation.mutationOptions({ onSuccess: (result) => { + void invalidate(); + if ( + isMountedRef.current && + (result.outcome === 'launched' || result.outcome === 'queued') + ) { + for (const timeout of refreshTimeoutsRef.current) { + window.clearTimeout(timeout); + } + refreshTimeoutsRef.current = RUN_RESULT_REFRESH_DELAYS_MS.map( + (delay) => window.setTimeout(() => void invalidate(), delay), + ); + } + switch (result.outcome) { case 'launched': toast.success(`Running ${automation.name} now`, {