Files
kandev/apps/web/hooks/use-workflows.ts
T
2026-09-13 16:59:03 +01:00

303 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { listWorkflows } from "@/lib/api";
import type { WorkflowsState } from "@/lib/state/slices";
import {
classifyWorkspaceContextReadError,
isCurrentWorkspaceContext,
retryAfterMilliseconds,
} from "@/lib/state/workspace-context";
import type { AppState } from "@/lib/state/store";
import type { StoreApi } from "zustand";
import { useForegroundRefresh } from "./use-foreground-refresh";
import { generateUUID } from "@/lib/utils";
import type { WorkspaceContextReadState } from "@/lib/state/slices/kanban/types";
const WORKSPACE_CONTEXT_RETRY_DELAYS_MS = [2_000, 5_000] as const;
const NOOP_REFRESH = () => {};
type StoreWorkflow = WorkflowsState["items"][number];
type SetWorkflows = (workflows: StoreWorkflow[]) => void;
function canRetryWorkspaceContext(
readState: WorkspaceContextReadState | undefined,
workspaceId: string | null,
): readState is WorkspaceContextReadState {
return (
document.visibilityState === "visible" &&
workspaceId !== null &&
readState !== undefined &&
readState.workspaceId === workspaceId
);
}
function hasPendingWorkspaceContextRead(readState: WorkspaceContextReadState): boolean {
const requestIds = readState.requestIds;
const pendingCollection = Object.entries(readState.pending).some(
([collection, pending]) =>
pending &&
(requestIds === undefined || requestIds[collection as keyof typeof requestIds] !== null),
);
const pendingSnapshot =
readState.snapshotPending &&
(readState.snapshotRequestId === undefined || readState.snapshotRequestId !== null);
return pendingCollection || pendingSnapshot;
}
function hasTransientWorkspaceContextError(readState: WorkspaceContextReadState): boolean {
return (
Object.values(readState.errors).some((error) => error === "transient") ||
readState.snapshotError === "transient"
);
}
function workspaceContextRetryAfter(readState: WorkspaceContextReadState): number {
return Object.entries(readState.errors).reduce(
(maximum, [collection, error]) => {
if (error !== "transient") return maximum;
return Math.max(
maximum,
readState.retryAfterMs[collection as keyof typeof readState.retryAfterMs] ?? 0,
);
},
readState.snapshotError === "transient" ? (readState.snapshotRetryAfterMs ?? 0) : 0,
);
}
/**
* Fire-and-forget fetch effect. Kept internal so callers that only need to
* populate `state.workflows.items` (e.g. `useEnsureWorkspaceWorkflows`) don't
* also subscribe to the store slice they wrote to — that would re-render the
* caller on every fetch and defeats the "top-level layout" placement.
*/
// eslint-disable-next-line max-params, max-lines-per-function -- the effect is shared by active and passive workflow consumers
function useWorkflowsFetchEffect(
workspaceId: string | null,
enabled: boolean,
requireActiveWorkspace: boolean,
setWorkflows: SetWorkflows,
store: StoreApi<AppState>,
trackRecovery: boolean,
retryVersion: number,
) {
useEffect(() => {
if (!enabled || !workspaceId) return;
let cancelled = false;
const requestId = trackRecovery ? generateUUID() : undefined;
const generation = store.getState().workspaceContextGeneration;
if (trackRecovery && typeof store.getState().setWorkspaceContextRead === "function") {
store
.getState()
.setWorkspaceContextRead(
"workflows",
workspaceId,
generation,
"pending",
undefined,
requestId,
);
}
listWorkflows(workspaceId, { cache: "no-store", includeHidden: true })
.then((response) => {
const state = store.getState();
const staleWorkspaceContext = requireActiveWorkspace
? !isCurrentWorkspaceContext(state, workspaceId, generation)
: state.workspaceContextGeneration !== generation;
if (cancelled || staleWorkspaceContext) {
return;
}
const mapped = response.workflows.map((workflow) => ({
id: workflow.id,
workspaceId: workflow.workspace_id,
name: workflow.name,
description: workflow.description,
prompt: workflow.prompt,
sortOrder: workflow.sort_order ?? 0,
agent_profile_id: workflow.agent_profile_id,
hidden: workflow.hidden,
style: workflow.style,
}));
setWorkflows(mapped);
if (trackRecovery && typeof state.setWorkspaceContextRead === "function") {
state.setWorkspaceContextRead(
"workflows",
workspaceId,
generation,
"success",
undefined,
requestId,
);
}
})
// Do not clear on error — the sidebar mounts on every route, and boot
// hydrates workflows before the refresh fires. Blowing the slice away on
// a network flake would leave the sidebar and board with no workflow IDs
// until another success. The next successful fetch replaces the slice.
.catch((error: unknown) => {
const state = store.getState();
const staleWorkspaceContext = requireActiveWorkspace
? !isCurrentWorkspaceContext(state, workspaceId, generation)
: state.workspaceContextGeneration !== generation;
if (
cancelled ||
staleWorkspaceContext ||
!trackRecovery ||
typeof state.setWorkspaceContextRead !== "function"
)
return;
state.setWorkspaceContextRead(
"workflows",
workspaceId,
generation,
classifyWorkspaceContextReadError(error),
retryAfterMilliseconds(error),
requestId,
);
});
return () => {
cancelled = true;
if (
trackRecovery &&
requestId &&
typeof store.getState().setWorkspaceContextRead === "function"
) {
store
.getState()
.setWorkspaceContextRead(
"workflows",
workspaceId,
generation,
"cancelled",
undefined,
requestId,
);
}
};
}, [
enabled,
requireActiveWorkspace,
retryVersion,
setWorkflows,
store,
trackRecovery,
workspaceId,
]);
}
/**
* Load workflows for the active workspace. Call from a component that stays
* mounted independently of any collapsible section, so `state.workflows.items`
* follows the active workspace even when the sidebar's Tasks section is
* collapsed and its children (which consume workflows) are unmounted.
*/
export function useEnsureWorkspaceWorkflows() {
const store = useAppStoreApi();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const setWorkflows = useAppStore((state) => state.setWorkflows);
const readState = useAppStore((state) => state.workspaceContextRead);
const retryVersion = readState?.retryVersion ?? 0;
const requestRefresh = useAppStore(
(state) => state.requestWorkspaceContextRefresh ?? NOOP_REFRESH,
);
const retryCycleRef = useRef({ key: "", attempts: 0 });
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryTimerKeyRef = useRef<string | null>(null);
const retryTimerTokenRef = useRef(0);
useWorkflowsFetchEffect(workspaceId, true, true, setWorkflows, store, true, retryVersion);
const recoverOnForeground = useCallback(() => {
if (!workspaceId || !readState || readState.workspaceId !== workspaceId) return;
if (
hasPendingWorkspaceContextRead(readState) ||
(!Object.values(readState.errors).some((error) => error === "transient") &&
readState.snapshotError !== "transient")
)
return;
requestRefresh();
}, [readState, requestRefresh, workspaceId]);
useForegroundRefresh(recoverOnForeground, Boolean(workspaceId), workspaceId);
useEffect(() => {
const clearRetryTimer = () => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
retryTimerKeyRef.current = null;
};
if (!canRetryWorkspaceContext(readState, workspaceId)) {
clearRetryTimer();
return;
}
if (
hasPendingWorkspaceContextRead(readState) ||
!hasTransientWorkspaceContextError(readState)
) {
clearRetryTimer();
return;
}
const key = `${workspaceId}:${readState.generation}:${readState.retryCycle}`;
if (retryCycleRef.current.key !== key) {
clearRetryTimer();
retryCycleRef.current = { key, attempts: 0 };
}
if (retryCycleRef.current.attempts >= WORKSPACE_CONTEXT_RETRY_DELAYS_MS.length) return;
if (retryTimerRef.current && retryTimerKeyRef.current === key) return;
if (retryTimerRef.current) clearRetryTimer();
const retryIndex = retryCycleRef.current.attempts;
const delay = Math.max(
WORKSPACE_CONTEXT_RETRY_DELAYS_MS[retryIndex],
workspaceContextRetryAfter(readState),
);
retryTimerKeyRef.current = key;
const timerToken = ++retryTimerTokenRef.current;
retryTimerRef.current = setTimeout(() => {
if (retryTimerKeyRef.current !== key || retryTimerTokenRef.current !== timerToken) return;
retryTimerRef.current = null;
retryTimerKeyRef.current = null;
retryTimerTokenRef.current += 1;
if (document.visibilityState !== "visible") return;
const currentReadState = store.getState().workspaceContextRead;
if (
!canRetryWorkspaceContext(currentReadState, workspaceId) ||
hasPendingWorkspaceContextRead(currentReadState) ||
!hasTransientWorkspaceContextError(currentReadState)
)
return;
retryCycleRef.current.attempts += 1;
requestRefresh(false);
}, delay);
}, [readState, requestRefresh, retryVersion, store, workspaceId]);
useEffect(() => {
const clearWhenHidden = () => {
if (document.visibilityState !== "hidden") return;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerTokenRef.current += 1;
}
retryTimerRef.current = null;
retryTimerKeyRef.current = null;
};
document.addEventListener("visibilitychange", clearWhenHidden);
return () => {
document.removeEventListener("visibilitychange", clearWhenHidden);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
retryTimerKeyRef.current = null;
retryTimerTokenRef.current += 1;
};
}, []);
}
export function useWorkflows(workspaceId: string | null, enabled = true) {
const store = useAppStoreApi();
const workflows = useAppStore((state) => state.workflows.items);
const setWorkflows = useAppStore((state) => state.setWorkflows);
useWorkflowsFetchEffect(workspaceId, enabled, false, setWorkflows, store, false, 0);
return { workflows };
}