import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { getApiErrorMessage } from "#/utils/api-error-message"; import { getErrorStatus } from "#/hooks/query/use-settings"; import { useGitSyncStatus, useTriggerGitSync, } from "#/hooks/query/use-git-sync"; import { useAutomationHealth } from "#/hooks/query/use-automation-health"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useAutomationPermissions } from "#/hooks/use-automation-permissions"; import { BackLink } from "#/components/features/automations/detail/back-link"; import { ErrorState } from "#/components/features/automations/error-state"; import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured"; import { GitSyncSkeleton } from "#/components/features/automations/git-sync/git-sync-skeleton"; import { GitSyncNotLocalState } from "#/components/features/automations/git-sync/git-sync-not-local-state"; import { GitSyncUnsupportedState } from "#/components/features/automations/git-sync/git-sync-unsupported-state"; import { GitSyncErrorBanner } from "#/components/features/automations/git-sync/git-sync-error-banner"; import { GitSyncOverviewSection } from "#/components/features/automations/git-sync/git-sync-overview-section"; import { GitSyncConfigForm } from "#/components/features/automations/git-sync/git-sync-config-form"; import type { GitSyncStatus } from "#/types/git-sync"; // While a cycle is running the status is followed closely, so its result // (new commit, dirty count, or error) lands without a page refresh; the idle // cadence exists to notice a cycle the backend's own interval started. const POLL_INTERVAL_MS = 3_000; const IDLE_POLL_INTERVAL_MS = 15_000; // How long to keep following a cycle we triggered but have never seen the // backend report as running -- the fallback for an automation backend that // predates `sync_in_progress`. const POLL_WINDOW_MS = 30_000; type SyncActivity = | { state: "idle" | "succeeded" | "failed" } | { state: "running"; startedAt: string; // What the last outcome was when this cycle began: the cycle has landed // once the backend reports a newer success or failure than these. lastSyncedAt: string | null; lastErrorAt: string | null; }; const runningSince = (status: GitSyncStatus): SyncActivity => ({ state: "running", startedAt: status.sync_started_at ?? new Date().toISOString(), lastSyncedAt: status.last_synced_at, lastErrorAt: status.last_error_at, }); export default function AutomationGitSync() { const { t } = useTranslation("openhands"); const active = useActiveBackend(); // Git sync is an org-level operation (not per-automation), so there is no // creator escape hatch — only users with manage_automations may configure, // check, or trigger a sync. The status read requires view_automations, but // since the page is local-only and local users always have manage, the // distinction only matters for cloud deployments that don't reach this page. const { canManage } = useAutomationPermissions(); const [activity, setActivity] = useState({ state: "idle" }); const isRunning = activity.state === "running"; const { data: healthData, isLoading: isHealthLoading, refetch: refetchHealth, } = useAutomationHealth(); const isBackendHealthy = healthData?.status === "ok"; const isLocalBackend = active.backend.kind === "local"; const { data: status, isLoading, error, refetch, } = useGitSyncStatus({ enabled: isBackendHealthy && isLocalBackend, refetchInterval: isRunning ? POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS, }); // Follow a cycle this page did not trigger -- the periodic loop's, or // another operator's -- so it shows up here too. useEffect(() => { if (!status?.sync_in_progress) return; setActivity((current) => current.state === "running" ? current : runningSince(status), ); }, [status]); // A cycle is done once the backend reports an outcome newer than the one it // started from. That releases the page as soon as the sync really ends, // rather than at the end of a fixed window. useEffect(() => { if (activity.state !== "running" || !status) return; if (status.last_error_at && status.last_error_at !== activity.lastErrorAt) { setActivity({ state: "failed" }); } else if ( status.last_synced_at && status.last_synced_at !== activity.lastSyncedAt ) { setActivity({ state: "succeeded" }); } }, [status, activity]); // Give up on a cycle we never saw the backend confirm. A backend that does // report `sync_in_progress` immediately re-enters the running state above, // so this only bounds the blind case. useEffect(() => { if (!isRunning) return undefined; const timer = setTimeout( () => setActivity({ state: "idle" }), POLL_WINDOW_MS, ); return () => clearTimeout(timer); }, [isRunning, activity]); const triggerMutation = useTriggerGitSync(); if (!isLocalBackend) { return (
); } if (isHealthLoading) { return (
); } if (!isBackendHealthy) { return (
); } if (isLoading) { return (
); } // A missing status replaces the page; a failure with one still cached does // not. `isError` alone is true for a failed background poll too, and this // page polls every few seconds after a sync -- unmounting on one of those // would throw away whatever is half-typed in the config form below. if (!status) { return (
{getErrorStatus(error) === 404 ? ( ) : ( refetch()} /> )}
); } const handleSyncNow = () => { triggerMutation.mutate(undefined, { // No success toast: the trigger only means the cycle was scheduled, and // a toast that fires and disappears can't report how it ends. The // activity row in the card follows it through to its outcome instead -- // including when `triggered` is false, which means a cycle was already // running and is the one to follow. onSuccess: () => setActivity(runningSince(status)), onError: (error) => { const errorStatus = getErrorStatus(error); displayErrorToast( errorStatus === 503 ? t(I18nKey.AUTOMATIONS$GIT_SYNC$SYNC_DISABLED_ERROR) : getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC)), ); }, }); }; return (

{t(I18nKey.AUTOMATIONS$GIT_SYNC$TITLE)}

{t(I18nKey.AUTOMATIONS$GIT_SYNC$SUBTITLE)}

{status.last_error && ( )}
); }