diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index e7bc333b9..5b0fbb2f6 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -160,6 +160,24 @@ export const tenantKeys = { ] as const, }; +/** + * Every surface that runs a routine (the routine panel's Run now button, + * the shell context menu's Run now item) must refresh the same two reads — + * the routines list and its run history — or one of them goes stale while + * the other doesn't. + */ +export function invalidateRoutineQueries( + queryClient: QueryClient, + tenantId: string, +): void { + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routines(tenantId), + }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routineRunHistories(tenantId), + }); +} + /** * Map a hub GET path onto a stable query key. Unknown paths fall back to a * path-keyed entry so callers cannot accidentally share cache entries. diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index df1ca0cb2..7f1997d6e 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -39,6 +39,7 @@ function actions( openProfile: mock(() => undefined), cycleTheme: mock(() => undefined), signOut: mock(() => undefined), + onRoutineRan: mock((_tenantId: string) => undefined), ...overrides, }; } @@ -183,6 +184,54 @@ describe("shellContextMenuFor: routine", () => { findItem(menu.entries, "open").onSelect(); expect(navigate).toHaveBeenCalledWith("/routines/rt-1"); }); + + test("run-now invalidates routine queries via onRoutineRan once the run starts", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ runId: "run-1" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const onRoutineRan = mock((_tenantId: string) => undefined); + try { + const menu = shellContextMenuFor(target, actions({ onRoutineRan })); + findItem(menu.entries, "run-now").onSelect(); + for (let i = 0; i < 10 && onRoutineRan.mock.calls.length === 0; i++) { + await Promise.resolve(); + } + expect(onRoutineRan).toHaveBeenCalledWith("tenant-1"); + expect(toastMock).toHaveBeenCalledWith("Nightly Digest started"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("run-now does not invalidate routine queries when the run fails to start", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ error: { message: "boom" } }), { + status: 500, + headers: { "content-type": "application/json" }, + }), + ), + ) as unknown as typeof fetch; + const onRoutineRan = mock((_tenantId: string) => undefined); + try { + const menu = shellContextMenuFor(target, actions({ onRoutineRan })); + findItem(menu.entries, "run-now").onSelect(); + for (let i = 0; i < 10 && toastMock.mock.calls.length === 0; i++) { + await Promise.resolve(); + } + expect(toastMock).toHaveBeenCalledWith("Couldn't start the routine"); + expect(onRoutineRan).not.toHaveBeenCalled(); + } finally { + globalThis.fetch = realFetch; + } + }); }); describe("shellContextMenuFor: insights-run", () => { diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index 8d4a6ac3d..52f6d1d20 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -45,6 +45,10 @@ export type ShellContextMenuActions = { readonly openProfile: (subject: ProfileSubject) => void; readonly cycleTheme: () => void; readonly signOut: () => void; + /** Called with the tenant id after a routine's Run now succeeds, so the + * caller can refresh the routines list and run history that a stale + * right-click menu has no other way to know changed. */ + readonly onRoutineRan: (tenantId: string) => void; }; async function copyLink(path: string, label: string): Promise { @@ -152,7 +156,10 @@ function routineMenu( icon: , onSelect: () => { void runRoutineNow(tenantId, target.id).then( - () => toast(`${target.name} started`), + () => { + toast(`${target.name} started`); + actions.onRoutineRan(tenantId); + }, () => toast("Couldn't start the routine"), ); }, diff --git a/apps/web/src/shell/context-menu/shell-context-menu.tsx b/apps/web/src/shell/context-menu/shell-context-menu.tsx index 7f3222743..5465cf1f7 100644 --- a/apps/web/src/shell/context-menu/shell-context-menu.tsx +++ b/apps/web/src/shell/context-menu/shell-context-menu.tsx @@ -9,10 +9,12 @@ import { useContextMenuState, useDocumentContextMenuTrigger, } from "@corbits/context-menu"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { useBench } from "../../bench-context"; import { useNavigate } from "../../navigation"; +import { invalidateRoutineQueries } from "../../query-client"; import { useOpenProfileInCanvas } from "../canvas-availability"; import { shellContextMenuFor } from "./items"; import type { ShellContextMenuActions } from "./items"; @@ -30,6 +32,7 @@ export function ShellContextMenu({ const navigate = useNavigate(); const openProfile = useOpenProfileInCanvas(); const { cycleMode } = useTheme(); + const queryClient = useQueryClient(); const { open, x, y, menu, triggerElement, show, hide } = useContextMenuState(); @@ -39,6 +42,7 @@ export function ShellContextMenu({ openProfile, cycleTheme: cycleMode, signOut: onSignOut, + onRoutineRan: (tenantId) => invalidateRoutineQueries(queryClient, tenantId), }; const resolve = useCallback( @@ -52,7 +56,14 @@ export function ShellContextMenu({ }, // `actions` is a fresh object every render; the values it closes over // are what actually determine the menu, so those are the real deps. - [selectedTenantId, navigate, openProfile, cycleMode, onSignOut], + [ + selectedTenantId, + navigate, + openProfile, + cycleMode, + onSignOut, + queryClient, + ], ); useDocumentContextMenuTrigger({ resolve, onOpen: show }); diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index 34b010527..771d4c077 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -76,7 +76,7 @@ import { slackTriggerOffered, } from "../deployment-capabilities-api"; import { useGranolaPluginConnected } from "../granola-plugin-availability"; -import { tenantKeys } from "../query-client"; +import { invalidateRoutineQueries } from "../query-client"; import { useCanvasColumnRoutine, useCloseCanvas } from "./canvas-availability"; import type { RoutinePanelSubject } from "./canvas-availability"; import { CanvasPaneHeader } from "./canvas-column"; @@ -202,12 +202,7 @@ function RoutineEditorPanel({ const invalidateRoutines = () => { if (tenantId === null) return; - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routines(tenantId), - }); - void queryClient.invalidateQueries({ - queryKey: tenantKeys.routineRunHistories(tenantId), - }); + invalidateRoutineQueries(queryClient, tenantId); }; const [routineId, setRoutineId] = useState(