summaryrefslogblamecommitdiff
path: root/makima/frontend/src/hooks/useDirectives.ts
blob: 8104de09b100d3f740561f2629f408a921be31cb (plain) (tree)




















                                                         
                   
                                  
                    

                    






























































                                                                                  
                                 



                                                                      

                                                          























                                                                             

                                           
                                         


                                 

                                                                             


         







                                                                     
                           






                                                                 





                                                                        

                                                          




                                                               

                             
                     
             



                                                      
                                                           


                                           

                                       

                             

                                                                            


           

                                                                     
                   










































                                                                            
 
                                                                                            


                           


                                             

                              
                                                      
                                         
                                                                                                     
 



























































                                                                          
                                           
                    
                               


                    






                                                  





                                            




                                       
                        
                                 
             

    
import { useState, useEffect, useCallback } from "react";
import {
  type DirectiveSummary,
  type DirectiveWithSteps,
  type CreateDirectiveRequest,
  type UpdateDirectiveRequest,
  type CreateDirectiveStepRequest,
  listDirectives,
  createDirective,
  getDirective,
  updateDirective,
  deleteDirective,
  createDirectiveStep,
  deleteDirectiveStep,
  startDirective,
  pauseDirective,
  advanceDirective,
  completeDirectiveStep,
  failDirectiveStep,
  skipDirectiveStep,
  updateDirectiveGoal,
  cleanupDirective,
  pickUpOrders as pickUpOrdersApi,
  createDirectivePR,
} from "../lib/api";

// =============================================================================
// Stale-while-revalidate cache
//
// Switching between directives in the document-mode sidebar used to feel
// noticeably laggy because every navigation re-fired the GET request and
// blocked the UI on it. We now keep a process-wide cache (mirrors the
// pattern in `useUserSettings.ts`) and use the cache as the immediate
// render value; a fresh fetch fires in the background and notifies all
// subscribed hook instances when it lands.
//
// Mutations (start/pause/etc.) push their result back into the cache so
// successive reads are also instant. Hard `refresh()` calls bypass the
// cache age check and refetch.
// =============================================================================

let listCache: DirectiveSummary[] | null = null;
let listInflight: Promise<DirectiveSummary[]> | null = null;
const listSubscribers = new Set<(d: DirectiveSummary[]) => void>();

const detailCache = new Map<string, DirectiveWithSteps>();
const detailInflight = new Map<string, Promise<DirectiveWithSteps>>();
const detailSubscribers = new Map<string, Set<(d: DirectiveWithSteps) => void>>();

function notifyList(value: DirectiveSummary[]) {
  for (const sub of listSubscribers) sub(value);
}

function notifyDetail(id: string, value: DirectiveWithSteps) {
  const subs = detailSubscribers.get(id);
  if (!subs) return;
  for (const sub of subs) sub(value);
}

function fetchList(): Promise<DirectiveSummary[]> {
  if (listInflight) return listInflight;
  listInflight = listDirectives()
    .then((res) => {
      listCache = res.directives;
      notifyList(res.directives);
      return res.directives;
    })
    .finally(() => {
      listInflight = null;
    });
  return listInflight;
}

function fetchDetail(id: string): Promise<DirectiveWithSteps> {
  const existing = detailInflight.get(id);
  if (existing) return existing;
  const p = getDirective(id)
    .then((d) => {
      detailCache.set(id, d);
      notifyDetail(id, d);
      return d;
    })
    .finally(() => {
      detailInflight.delete(id);
    });
  detailInflight.set(id, p);
  return p;
}

export function useDirectives() {
  const [directives, setDirectives] = useState<DirectiveSummary[]>(
    () => listCache ?? [],
  );
  const [loading, setLoading] = useState<boolean>(listCache === null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let mounted = true;
    const sub = (value: DirectiveSummary[]) => {
      if (!mounted) return;
      setDirectives(value);
      setLoading(false);
    };
    listSubscribers.add(sub);

    // Always kick a background fetch on mount so we don't ship stale data;
    // subscribers see the new value when it lands. UI shows the cached
    // value immediately if there is one.
    fetchList().catch((e) => {
      if (!mounted) return;
      setError(e instanceof Error ? e.message : "Failed to load directives");
      setLoading(false);
    });

    return () => {
      mounted = false;
      listSubscribers.delete(sub);
    };
  }, []);

  const refresh = useCallback(async () => {
    try {
      const res = await listDirectives();
      listCache = res.directives;
      notifyList(res.directives);
      setError(null);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Failed to load directives");
    }
  }, []);

  const create = useCallback(async (req: CreateDirectiveRequest) => {
    const d = await createDirective(req);
    await refresh();
    return d;
  }, [refresh]);

  const remove = useCallback(async (id: string) => {
    await deleteDirective(id);
    detailCache.delete(id);
    await refresh();
  }, [refresh]);

  return { directives, loading, error, refresh, create, remove };
}

export function useDirective(id: string | undefined) {
  const [directive, setDirective] = useState<DirectiveWithSteps | null>(
    () => (id ? detailCache.get(id) ?? null : null),
  );
  const [loading, setLoading] = useState<boolean>(
    id !== undefined && !detailCache.has(id),
  );
  const [error, setError] = useState<string | null>(null);

  // Silently refresh without setting loading state (for polls)
  const silentRefresh = useCallback(async () => {
    if (!id) return;
    try {
      const d = await getDirective(id);
      detailCache.set(id, d);
      notifyDetail(id, d);
      setError(null);
    } catch {
      // Don't overwrite existing data on poll failure
    }
  }, [id]);

  // Full refresh with loading state (for explicit refresh)
  const refresh = useCallback(async () => {
    if (!id) return;
    try {
      setError(null);
      const d = await getDirective(id);
      detailCache.set(id, d);
      notifyDetail(id, d);
    } catch (e) {
      setError(e instanceof Error ? e.message : "Failed to load directive");
    }
  }, [id]);

  // Subscribe to detail updates for this id; render cached value
  // immediately and kick a background fetch if the cache is missing.
  useEffect(() => {
    if (!id) {
      setDirective(null);
      setLoading(false);
      setError(null);
      return;
    }

    let mounted = true;
    const sub = (value: DirectiveWithSteps) => {
      if (!mounted) return;
      setDirective(value);
      setLoading(false);
    };

    let subs = detailSubscribers.get(id);
    if (!subs) {
      subs = new Set();
      detailSubscribers.set(id, subs);
    }
    subs.add(sub);

    const cached = detailCache.get(id);
    if (cached) {
      setDirective(cached);
      setLoading(false);
    } else {
      setDirective(null);
      setLoading(true);
    }

    // Always kick a fresh fetch so polling-driven UIs see updates.
    fetchDetail(id).catch((e) => {
      if (!mounted) return;
      setError(e instanceof Error ? e.message : "Failed to load directive");
      setLoading(false);
    });

    return () => {
      mounted = false;
      subs!.delete(sub);
      if (subs!.size === 0) detailSubscribers.delete(id);
    };
  }, [id]);

  // Auto-poll while directive is active, has an orchestrator task, or has a completion task
  useEffect(() => {
    if (!directive) return;
    const needsPolling =
      directive.status === "active" ||
      directive.orchestratorTaskId != null ||
      directive.completionTaskId != null;
    if (!needsPolling) return;

    const interval = setInterval(silentRefresh, 5000);
    return () => clearInterval(interval);
  }, [directive?.status, directive?.orchestratorTaskId, directive?.completionTaskId, silentRefresh]);

  const update = useCallback(async (req: UpdateDirectiveRequest) => {
    if (!id) return;
    await updateDirective(id, req);
    await refresh();
  }, [id, refresh]);

  const addStep = useCallback(async (req: CreateDirectiveStepRequest) => {
    if (!id) return;
    await createDirectiveStep(id, req);
    await refresh();
  }, [id, refresh]);

  const removeStep = useCallback(async (stepId: string) => {
    if (!id) return;
    await deleteDirectiveStep(id, stepId);
    await refresh();
  }, [id, refresh]);

  const start = useCallback(async () => {
    if (!id) return;
    await startDirective(id);
    await refresh();
  }, [id, refresh]);

  const pause = useCallback(async () => {
    if (!id) return;
    await pauseDirective(id);
    await refresh();
  }, [id, refresh]);

  const advance = useCallback(async () => {
    if (!id) return;
    await advanceDirective(id);
    await refresh();
  }, [id, refresh]);

  const completeStep = useCallback(async (stepId: string) => {
    if (!id) return;
    await completeDirectiveStep(id, stepId);
    await refresh();
  }, [id, refresh]);

  const failStep = useCallback(async (stepId: string) => {
    if (!id) return;
    await failDirectiveStep(id, stepId);
    await refresh();
  }, [id, refresh]);

  const skipStep = useCallback(async (stepId: string) => {
    if (!id) return;
    await skipDirectiveStep(id, stepId);
    await refresh();
  }, [id, refresh]);

  const updateGoal = useCallback(async (goal: string) => {
    if (!id) return;
    await updateDirectiveGoal(id, goal);
    await refresh();
  }, [id, refresh]);

  const cleanup = useCallback(async () => {
    if (!id) return;
    await cleanupDirective(id);
    await refresh();
  }, [id, refresh]);

  const pickUpOrdersFn = useCallback(async () => {
    if (!id) return null;
    const result = await pickUpOrdersApi(id);
    await refresh();
    return result;
  }, [id, refresh]);

  const createPR = useCallback(async () => {
    if (!id) return;
    await createDirectivePR(id);
    await refresh();
  }, [id, refresh]);

  return {
    directive, loading, error, refresh,
    update, addStep, removeStep,
    start, pause, advance,
    completeStep, failStep, skipStep,
    updateGoal, cleanup,
    pickUpOrders: pickUpOrdersFn,
    createPR,
  };
}