import { useLoadBalancedEnvironment } from "../../hooks/useLoadBalancedEnvironment"; import { useLogicalProjectEnvironments } from "../../hooks/useLogicalProjectEnvironments"; import { useScratchProject } from "../../hooks/useScratchProject"; import { useStableCallback } from "../../hooks/useStableCallback"; import { type EnvironmentId, type ProviderDriverKind, ProviderInstanceId, resolveProjectProviderInstanceEnabled, type ThreadContextRecord, } from "@t3tools/contracts"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { toastManager } from "../ui/toast"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { type EnvironmentPresentation, useEnvironmentProviders } from "../../state/environments"; import { useProjects } from "../../state/entities"; import type { DraftSessionState } from "../../composerDraftStore"; interface DraftEnvironmentRoutingInput { readonly draftId: DraftId | null; readonly draftThread: DraftSessionState | null; readonly envLocked: boolean; readonly automaticEnvironment: boolean; readonly logicalProjectEnvironments: ReturnType; readonly environmentById: ReadonlyMap; readonly providersByEnvironment: ReturnType; readonly loadBalancingSettings: { readonly loadBalancingWeights: Readonly>; }; readonly referencedThreadContexts: ReadonlyArray; readonly activeProviderInstanceId: ProviderInstanceId | null; readonly selectedProvider: ProviderDriverKind; readonly composerHasAttachments: boolean; readonly activeProjectKey: string | null; readonly activeProjectIsScratch: boolean; readonly projectGroupingSettings: ReturnType; readonly sendInFlightRef: { readonly current: boolean }; } /** * Which machine an unsent draft runs on: automatic load balancing across the * project's machines, or a manual switch, which may first open a scratch * project on the target machine. */ export function useDraftEnvironmentRouting(input: DraftEnvironmentRoutingInput) { const { draftId, draftThread, envLocked, automaticEnvironment, logicalProjectEnvironments, environmentById, providersByEnvironment, loadBalancingSettings, referencedThreadContexts, activeProviderInstanceId, selectedProvider, composerHasAttachments, activeProjectKey, activeProjectIsScratch, projectGroupingSettings, sendInFlightRef, } = input; const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); const allProjects = useProjects(); const { openScratchProject } = useScratchProject(); const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; const loadBalancingCandidates = needsLoadBalancing ? logicalProjectEnvironments .filter((candidate) => { const environment = environmentById.get(candidate.environmentId); return ( environment?.connection.phase === "connected" && (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && referencedThreadContexts.every( (record) => record.environmentId === candidate.environmentId, ) && providersByEnvironment .get(candidate.environmentId) ?.some( (provider) => (activeProviderInstanceId === null || provider.instanceId === activeProviderInstanceId) && provider.driver === selectedProvider && (environment.serverConfig?.settings ? resolveProjectProviderInstanceEnabled( environment.serverConfig.settings, candidate.projectId, provider.instanceId, ) : provider.enabled) && provider.installed && provider.status !== "error" && provider.auth.status !== "unauthenticated" && provider.availability !== "unavailable", ) ); }) .map((candidate) => candidate.environmentId) : []; const loadBalancing = useLoadBalancedEnvironment( loadBalancingCandidates, loadBalancingSettings.loadBalancingWeights, ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the resolved load-balanced environment into the draft store once the async pick settles useEffect(() => { if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; const target = logicalProjectEnvironments.find( (environment) => environment.environmentId === loadBalancing.environmentId, ); if (!target?.projectId) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), environmentSelection: "auto", loadBalancedEnvironmentId: target.environmentId, }); }, [ needsLoadBalancing, loadBalancing.pending, loadBalancing.environmentId, draftId, logicalProjectEnvironments, sendInFlightRef, setDraftThreadContext, ]); const onAutoEnvironment = () => { if (envLocked || !draftId) return; if (composerHasAttachments) { toastManager.add({ type: "warning", id: "load-balancing-attachments", title: "Keep attachments on this machine", description: "Remove attachments before choosing automatic routing, then attach them on the selected machine.", }); return; } loadBalancing.refresh( logicalProjectEnvironments.map((environment) => environment.environmentId), ); setDraftThreadContext(draftId, { environmentSelection: "auto", loadBalancedEnvironmentId: null, branch: null, worktreePath: null, }); }; const onAutoEnvironmentStable = useStableCallback(onAutoEnvironment); const autoEnvironmentLabel = automaticEnvironment ? draftThread?.loadBalancedEnvironmentId ? "Auto balance" : loadBalancing.pending ? "Checking machines…" : loadBalancing.failed ? "Auto balance unavailable" : "Auto balance" : undefined; // A change in flight belongs to the draft and project it started on. const environmentChangeScope = `${draftId ?? ""}:${activeProjectKey ?? ""}`; const environmentChangeRef = useRef<{ readonly environmentId: EnvironmentId; readonly scope: string; } | null>(null); const environmentChangeScopeRef = useRef(""); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- mirrors the current change scope into a ref that async environment switches compare against useLayoutEffect(() => { environmentChangeScopeRef.current = environmentChangeScope; }); // Leaving the scope, or unmounting, abandons a change still in flight. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- abandons an in-flight environment change when the draft or project scope changes or unmounts useLayoutEffect( () => () => { if (environmentChangeRef.current?.scope === environmentChangeScope) { environmentChangeRef.current = null; } }, [environmentChangeScope], ); const readPendingEnvironmentChange = () => { const pending = environmentChangeRef.current; return pending !== null && pending.scope === environmentChangeScopeRef.current ? pending : null; }; // The busy state belongs to the request that set it, so an older request settling late // (abandoned, or superseded by a newer switch) can never clear a newer one's. const [busyEnvironmentChange, setBusyEnvironmentChange] = useState<{ readonly environmentId: EnvironmentId; readonly scope: string; } | null>(null); const isEnvironmentChanging = busyEnvironmentChange?.scope === environmentChangeScope; const onEnvironmentChange = (nextEnvironmentId: EnvironmentId) => { if (envLocked || !draftId || sendInFlightRef.current) return; const originalDraft = getDraftSession(draftId); if (!originalDraft || originalDraft.promotedTo) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; const request = { environmentId: target.environmentId, scope: environmentChangeScope }; environmentChangeRef.current = request; setBusyEnvironmentChange(null); const retarget = (project: (typeof allProjects)[number]) => { const currentDraft = getDraftSession(draftId); if ( environmentChangeRef.current !== request || environmentChangeScopeRef.current !== request.scope || sendInFlightRef.current || !currentDraft || currentDraft.promotedTo || currentDraft.environmentId !== originalDraft.environmentId || currentDraft.projectId !== originalDraft.projectId ) return; const projectRef = scopeProjectRef(target.environmentId, project.id); if (activeProjectIsScratch) { setLogicalProjectDraftThreadId( deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), projectRef, draftId, { environmentSelection: "manual", loadBalancedEnvironmentId: null }, ); } else { setDraftThreadContext(draftId, { projectRef, environmentSelection: "manual", loadBalancedEnvironmentId: null, }); } }; const finish = () => { if (environmentChangeRef.current === request) environmentChangeRef.current = null; setBusyEnvironmentChange((busy) => (busy === request ? null : busy)); }; if (target.projectId !== null) { const project = allProjects.find( (project) => project.environmentId === target.environmentId && project.id === target.projectId, ); if (project) retarget(project); finish(); return; } setBusyEnvironmentChange(request); void openScratchProject(target.environmentId, "Could not switch machine") .then((project) => { if (project) retarget(project); }) .finally(finish); }; const onEnvironmentChangeStable = useStableCallback(onEnvironmentChange); return { needsLoadBalancing, loadBalancing, onAutoEnvironmentStable, autoEnvironmentLabel, isEnvironmentChanging, readPendingEnvironmentChange, onEnvironmentChangeStable, }; }