use-aop-refresh-watcher.ts3.6 KBView on GitHub
/**
 * Watches the background agent refresh started by an AOP change.
 *
 * Changing a conversation's AOP no longer blocks the HTTP response on the refresh — that
 * inline await was holding requests past CloudFront's 60s origin timeout (see
 * apps/server/docs/bug-reports/aop-change-60s-cloudfront-timeout.md). The mutation now
 * returns as soon as `aopId` is durable and hands back the refresh's `runId`.
 *
 * That trade means the conversation's fields keep changing for ~30s (p90 ~90s) after the
 * response, with nothing on screen to explain it. This hook closes that gap: while the
 * run is live the conversation reports "updating fields", and when it reaches a terminal
 * status the conversation queries are invalidated so the refreshed values land.
 *
 * It polls rather than listening: `sseEventBus` exists server-side but its
 * subscription-manager has no importers, so there is no live push channel to the client
 * for executions today. If one is wired up later, this hook is the single place to swap.
 */
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query';
import { isTerminalExecutionStatus } from '@zero/server/schemas';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import { useEffect } from 'react';

const POLL_INTERVAL_MS = 4_000;

/**
 * Stop watching after this long even if the execution never reaches a terminal status.
 * The server closes a stranded row itself (see failRefreshExecution), so this only covers
 * the case where the process handling the refresh died outright — without it the
 * indicator would spin forever. Comfortably above the observed 176s worst case.
 */
const MAX_WATCH_MS = 5 * 60 * 1000;

export function useAopRefreshWatcher(conversationId: string | null | undefined) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const runId = useCedarStore((state) =>
    conversationId ? state.conversations[conversationId]?.aopRefreshRunId : undefined,
  );
  const setAopRefreshRun = useCedarStore((state) => state.setAopRefreshRun);

  // `skipToken` rather than a placeholder input plus `enabled: false`: there is no run to
  // watch, so there is no input to name. A sentinel would also be one `enabled` regression
  // away from actually sending itself to the server.
  const { data } = useQuery({
    ...trpc.agentExecutions.getAgentExecutionsByIds.queryOptions(
      runId ? { runIds: [runId] } : skipToken,
    ),
    refetchInterval: POLL_INTERVAL_MS,
    staleTime: 0,
  });

  // Run finished → drop the indicator and pull the refreshed fields in.
  useEffect(() => {
    if (!runId || !conversationId) return;

    const status = data?.[runId]?.status;
    // Terminal-status list comes from the server's own column union — see
    // db/execution-status.ts. A renamed status is a type error there, not a spinner here.
    if (!isTerminalExecutionStatus(status)) return;

    setAopRefreshRun(conversationId, null);
    void queryClient.invalidateQueries({
      queryKey=[redacted] id: conversationId }),
    });
    void queryClient.invalidateQueries({
      queryKey=[redacted],
    });
  }, [data, runId, conversationId, setAopRefreshRun, queryClient, trpc]);

  // Safety valve — never leave the indicator spinning indefinitely.
  useEffect(() => {
    if (!runId || !conversationId) return;

    const timer = setTimeout(() => setAopRefreshRun(conversationId, null), MAX_WATCH_MS);
    return () => clearTimeout(timer);
  }, [runId, conversationId, setAopRefreshRun]);

  return { isRefreshingFields: !!runId };
}