mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
* refactor: complete Phase 2 Query Keys Standardization Standardize query keys across all features following vertical slice architecture, ensuring they mirror backend API structure exactly with no backward compatibility. Key Changes: - Refactor all query key factories to follow consistent patterns - Move progress feature from knowledge/progress to top-level /features/progress - Create shared query patterns for consistency (DISABLED_QUERY_KEY, STALE_TIMES) - Remove all hardcoded stale times and disabled keys - Update all imports after progress feature relocation Query Key Factories Standardized: - projectKeys: removed task-related keys (tasks, taskCounts) - taskKeys: added dual nature support (global via lists(), project-scoped via byProject()) - knowledgeKeys: removed redundant methods (details, summary) - progressKeys: new top-level feature with consistent factory - documentKeys: full factory pattern with versions support - mcpKeys: complete with health endpoint Shared Patterns Implementation: - STALE_TIMES: instant (0), realtime (3s), frequent (5s), normal (30s), rare (5m), static (∞) - DISABLED_QUERY_KEY: consistent disabled query pattern across all features - Removed unused createQueryOptions helper Testing: - Added comprehensive tests for progress hooks - Updated all test mocks to include new STALE_TIMES values - All 81 feature tests passing Documentation: - Created QUERY_PATTERNS.md guide for future implementations - Clear patterns, examples, and migration checklist Breaking Changes: - Progress imports moved from knowledge/progress to progress - Query key structure changes (cache will reset) - No backward compatibility maintained Co-Authored-By: Claude <noreply@anthropic.com> * fix: establish single source of truth for tags in metadata - Remove ambiguous top-level tags field from KnowledgeItem interface - Update all UI components to use metadata.tags exclusively - Fix mutations to correctly update tags in metadata object - Remove duplicate tags field from backend KnowledgeSummaryService - Fix test setup issue with QueryClient instance in knowledge tests - Add TODO comments for filter-blind optimistic updates (Phase 3) This eliminates the ambiguity identified in Phase 2 where both item.tags and metadata.tags existed, establishing metadata.tags as the single source of truth across the entire stack. * fix: comprehensive progress hooks improvements - Integrate useSmartPolling for all polling queries - Fix memory leaks from uncleaned timeouts - Replace string-based error checking with status codes - Remove TypeScript any usage with proper types - Fix unstable dependencies with sorted JSON serialization - Add staleTime to document queries for consistency * feat: implement flexible assignee system for dynamic agents - Changed assignee from restricted enum to flexible string type - Renamed "AI IDE Agent" to "Coding Agent" for clarity - Enhanced ComboBox with Radix UI best practices: - Full ARIA compliance (roles, labels, keyboard nav) - Performance optimizations (memoization, useCallback) - Improved UX (auto-scroll, keyboard shortcuts) - Fixed event bubbling preventing unintended modal opens - Updated MCP server docs to reflect flexible assignee capability - Removed unnecessary UI elements (arrows, helper text) - Styled ComboBox to match priority selector aesthetic This allows external MCP clients to create and assign custom sub-agents dynamically, supporting advanced agent orchestration workflows. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete Phase 2 summariesPrefix usage for cache consistency - Fix all knowledgeKeys.summaries() calls to use summariesPrefix() for operations targeting multiple summary caches - Update cancelQueries, getQueriesData, setQueriesData, invalidateQueries, and refetchQueries calls - Fix critical cache invalidation bug where filtered summaries weren't being cleared - Update test expectations to match new factory patterns - Address CodeRabbit review feedback on cache stability issues This completes the Phase 2 Query Keys Standardization work documented in PRPs/local/frontend-state-management-refactor.md 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: update MCP task tools documentation for Coding Agent rename Update task assignee documentation from "AI IDE Agent" to "Coding Agent" to match frontend changes for consistency across the system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: implement assignee filtering in MCP find_tasks function Add missing implementation for filter_by="assignee" that was documented but not coded. The filter now properly passes the assignee parameter to the backend API, matching the existing pattern used for status filtering. Fixes documentation/implementation mismatch identified by CodeRabbit. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Phase 2 cleanup - address review comments and improve code quality Changes made: - Reduced smart polling interval from 60s to 5s for background tabs (better responsiveness) - Fixed cache coherence bug in knowledge queries (missing limit parameter) - Standardized "Coding Agent" naming (was inconsistently "AI IDE Agent") - Improved task queries with 2s polling, type safety, and proper invalidation - Enhanced combobox accessibility with proper ARIA attributes and IDs - Delegated useCrawlProgressPolling to useActiveOperations (removed duplication) - Added exact: true to progress query removals (prevents sibling removal) - Fixed invalid Tailwind class ml-4.5 to ml-4 All changes align with Phase 2 query key standardization goals and improve overall code quality, accessibility, and performance. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
/**
|
|
* Progress Query Hooks
|
|
* Handles polling for operation progress with TanStack Query
|
|
*/
|
|
|
|
import { type UseQueryResult, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
import { APIServiceError } from "../../shared/errors";
|
|
import { DISABLED_QUERY_KEY, STALE_TIMES } from "../../shared/queryPatterns";
|
|
import { useSmartPolling } from "../../ui/hooks";
|
|
import { progressService } from "../services";
|
|
import type { ActiveOperationsResponse, ProgressResponse, ProgressStatus } from "../types";
|
|
|
|
// Query keys factory
|
|
export const progressKeys = {
|
|
all: ["progress"] as const,
|
|
lists: () => [...progressKeys.all, "list"] as const,
|
|
detail: (id: string) => [...progressKeys.all, "detail", id] as const,
|
|
active: () => [...progressKeys.all, "active"] as const,
|
|
};
|
|
|
|
// Terminal states that should stop polling
|
|
const TERMINAL_STATES: ProgressStatus[] = ["completed", "error", "failed", "cancelled"];
|
|
|
|
/**
|
|
* Poll for operation progress
|
|
* Automatically stops polling when operation completes or fails
|
|
*/
|
|
export function useOperationProgress(
|
|
progressId: string | null,
|
|
options?: {
|
|
onComplete?: (data: ProgressResponse) => void;
|
|
onError?: (error: string) => void;
|
|
pollingInterval?: number;
|
|
},
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
const hasCalledComplete = useRef(false);
|
|
const hasCalledError = useRef(false);
|
|
const consecutiveNotFound = useRef(0);
|
|
const { refetchInterval: smartInterval } = useSmartPolling(options?.pollingInterval ?? 1000);
|
|
|
|
// Reset refs when progressId changes
|
|
useEffect(() => {
|
|
hasCalledComplete.current = false;
|
|
hasCalledError.current = false;
|
|
consecutiveNotFound.current = 0;
|
|
}, [progressId]);
|
|
|
|
const query = useQuery<ProgressResponse | null>({
|
|
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
|
|
queryFn: async () => {
|
|
if (!progressId) throw new Error("No progress ID");
|
|
|
|
try {
|
|
const data = await progressService.getProgress(progressId);
|
|
consecutiveNotFound.current = 0; // Reset counter on success
|
|
return data;
|
|
} catch (error: unknown) {
|
|
// Handle 404 errors specially - check status code first, then message as fallback
|
|
const isNotFound =
|
|
(error instanceof APIServiceError && error.statusCode === 404) ||
|
|
(error as { status?: number })?.status === 404 ||
|
|
(error as { response?: { status?: number } })?.response?.status === 404 ||
|
|
(error instanceof Error && /not found/i.test(error.message));
|
|
|
|
if (isNotFound) {
|
|
consecutiveNotFound.current++;
|
|
|
|
// After 5 consecutive 404s, assume the operation is gone
|
|
if (consecutiveNotFound.current >= 5) {
|
|
throw new Error("Operation no longer exists");
|
|
}
|
|
|
|
// Return null to keep polling a bit longer
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
},
|
|
enabled: !!progressId,
|
|
refetchInterval: (query) => {
|
|
const data = query.state.data as ProgressResponse | null | undefined;
|
|
|
|
// Only stop polling when we have actual data and it's in a terminal state
|
|
if (data && TERMINAL_STATES.includes(data.status)) {
|
|
return false;
|
|
}
|
|
|
|
// Keep polling on undefined (initial), null (transient 404), or active operations
|
|
// Use smart interval that pauses when tab is hidden
|
|
return smartInterval;
|
|
},
|
|
retry: false, // Don't retry on error
|
|
staleTime: STALE_TIMES.instant, // Always fresh for real-time progress
|
|
});
|
|
|
|
// Handle completion and error callbacks
|
|
useEffect(() => {
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
if (!query.data) return;
|
|
|
|
const status = query.data.status;
|
|
|
|
// Handle completion
|
|
if (status === "completed" && !hasCalledComplete.current) {
|
|
hasCalledComplete.current = true;
|
|
options?.onComplete?.(query.data);
|
|
|
|
// Clean up the query after completion
|
|
timers.push(
|
|
setTimeout(() => {
|
|
if (progressId) {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}
|
|
}, 2000),
|
|
);
|
|
}
|
|
|
|
// Handle cancellation
|
|
if (status === "cancelled" && !hasCalledError.current) {
|
|
hasCalledError.current = true;
|
|
options?.onError?.(query.data.error || "Operation was cancelled");
|
|
|
|
// Clean up the query after cancellation
|
|
timers.push(
|
|
setTimeout(() => {
|
|
if (progressId) {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}
|
|
}, 2000),
|
|
);
|
|
}
|
|
|
|
// Handle errors
|
|
if ((status === "error" || status === "failed") && !hasCalledError.current) {
|
|
hasCalledError.current = true;
|
|
options?.onError?.(query.data.error || "Operation failed");
|
|
|
|
// Clean up the query after error
|
|
timers.push(
|
|
setTimeout(() => {
|
|
if (progressId) {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}
|
|
}, 5000),
|
|
);
|
|
}
|
|
|
|
// Cleanup function to clear all timeouts
|
|
return () => {
|
|
timers.forEach(clearTimeout);
|
|
};
|
|
}, [query.data?.status, progressId, queryClient, options, query.data]);
|
|
|
|
// Forward query errors (e.g., "Operation no longer exists") to onError callback
|
|
useEffect(() => {
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
if (!query.error || hasCalledError.current) return;
|
|
|
|
hasCalledError.current = true;
|
|
const errorMessage = query.error instanceof Error ? query.error.message : String(query.error);
|
|
options?.onError?.(errorMessage);
|
|
|
|
// Clean up the query after error
|
|
timers.push(
|
|
setTimeout(() => {
|
|
if (progressId) {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}
|
|
}, 5000),
|
|
);
|
|
|
|
// Cleanup function to clear timeouts
|
|
return () => {
|
|
timers.forEach(clearTimeout);
|
|
};
|
|
}, [query.error, progressId, queryClient, options]);
|
|
|
|
return {
|
|
data: query.data,
|
|
isLoading: query.isLoading,
|
|
error: query.error,
|
|
isComplete: query.data?.status === "completed",
|
|
isFailed: query.data?.status === "error" || query.data?.status === "failed",
|
|
isActive: query.data ? !TERMINAL_STATES.includes(query.data.status) : false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get all active operations
|
|
* Useful for showing a global progress indicator
|
|
* @param enabled - Whether to enable polling (default: false)
|
|
*/
|
|
export function useActiveOperations(enabled = false) {
|
|
const { refetchInterval } = useSmartPolling(5000);
|
|
|
|
return useQuery<ActiveOperationsResponse>({
|
|
queryKey: progressKeys.active(),
|
|
queryFn: () => progressService.listActiveOperations(),
|
|
enabled,
|
|
refetchInterval: enabled ? refetchInterval : false, // Only poll when explicitly enabled, pause when hidden
|
|
staleTime: STALE_TIMES.realtime, // Near real-time for active operations
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hook for polling all crawl operations
|
|
* Used in the CrawlingProgress component
|
|
* Delegates to useActiveOperations for consistency
|
|
*/
|
|
export function useCrawlProgressPolling() {
|
|
const { data, isLoading } = useActiveOperations(true); // Always enabled for crawling progress
|
|
|
|
return {
|
|
activeOperations: data?.operations || [],
|
|
isLoading,
|
|
totalCount: data?.count || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook to manage multiple progress operations
|
|
* Useful for the crawling tab that shows multiple operations
|
|
*/
|
|
export function useMultipleOperations(
|
|
progressIds: string[],
|
|
options?: {
|
|
onComplete?: (progressId: string, data: ProgressResponse) => void;
|
|
onError?: (progressId: string, error: string) => void;
|
|
},
|
|
) {
|
|
const queryClient = useQueryClient();
|
|
const completedIds = useRef(new Set<string>());
|
|
const errorIds = useRef(new Set<string>());
|
|
// Track consecutive 404s per operation
|
|
const notFoundCounts = useRef<Map<string, number>>(new Map());
|
|
const { refetchInterval: smartInterval } = useSmartPolling(1000);
|
|
|
|
// Reset tracking sets when progress IDs change
|
|
// Use sorted JSON stringification for stable dependency that handles reordering
|
|
const progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
|
|
useEffect(() => {
|
|
completedIds.current.clear();
|
|
errorIds.current.clear();
|
|
notFoundCounts.current.clear();
|
|
}, [progressIdsKey]); // Stable dependency across reorderings
|
|
|
|
const queries = useQueries({
|
|
queries: progressIds.map((progressId) => ({
|
|
queryKey: progressKeys.detail(progressId),
|
|
queryFn: async (): Promise<ProgressResponse | null> => {
|
|
try {
|
|
const data = await progressService.getProgress(progressId);
|
|
notFoundCounts.current.set(progressId, 0); // Reset counter on success
|
|
return data;
|
|
} catch (error: unknown) {
|
|
// Handle 404 errors specially for resilience - check status code first
|
|
const isNotFound =
|
|
(error instanceof APIServiceError && error.statusCode === 404) ||
|
|
(error as { status?: number })?.status === 404 ||
|
|
(error as { response?: { status?: number } })?.response?.status === 404 ||
|
|
(error instanceof Error && /not found/i.test(error.message));
|
|
|
|
if (isNotFound) {
|
|
const currentCount = (notFoundCounts.current.get(progressId) || 0) + 1;
|
|
notFoundCounts.current.set(progressId, currentCount);
|
|
|
|
// After 5 consecutive 404s, assume the operation is gone
|
|
if (currentCount >= 5) {
|
|
throw new Error("Operation no longer exists");
|
|
}
|
|
|
|
// Return null to keep polling a bit longer
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
},
|
|
refetchInterval: (query: { state: { data: ProgressResponse | null | undefined } }) => {
|
|
const data = query.state.data;
|
|
|
|
// Only stop polling when we have actual data and it's in a terminal state
|
|
if (data && TERMINAL_STATES.includes(data.status)) {
|
|
return false;
|
|
}
|
|
|
|
// Keep polling on undefined (initial), null (transient 404), or active operations
|
|
// Use smart interval that pauses when tab is hidden
|
|
return smartInterval;
|
|
},
|
|
retry: false,
|
|
staleTime: STALE_TIMES.instant, // Always fresh for real-time progress
|
|
})),
|
|
}) as UseQueryResult<ProgressResponse | null, Error>[];
|
|
|
|
// Handle callbacks for each operation
|
|
useEffect(() => {
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
queries.forEach((query, index) => {
|
|
const progressId = progressIds[index];
|
|
if (!query.data || !progressId) return;
|
|
|
|
const data = query.data as ProgressResponse | null;
|
|
if (!data) return;
|
|
|
|
const status = data.status;
|
|
|
|
// Handle completion
|
|
if (status === "completed" && !completedIds.current.has(progressId)) {
|
|
completedIds.current.add(progressId);
|
|
options?.onComplete?.(progressId, data);
|
|
|
|
// Clean up after completion
|
|
timers.push(
|
|
setTimeout(() => {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}, 2000),
|
|
);
|
|
}
|
|
|
|
// Handle errors
|
|
if ((status === "error" || status === "failed") && !errorIds.current.has(progressId)) {
|
|
errorIds.current.add(progressId);
|
|
options?.onError?.(progressId, data.error || "Operation failed");
|
|
|
|
// Clean up after error
|
|
timers.push(
|
|
setTimeout(() => {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}, 5000),
|
|
);
|
|
}
|
|
});
|
|
|
|
// Cleanup function to clear all timeouts
|
|
return () => {
|
|
timers.forEach(clearTimeout);
|
|
};
|
|
}, [queries, progressIds, queryClient, options]);
|
|
|
|
// Forward query errors (e.g., 404s after threshold) to onError callback
|
|
useEffect(() => {
|
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
queries.forEach((query, index) => {
|
|
const progressId = progressIds[index];
|
|
if (!query.error || !progressId || errorIds.current.has(progressId)) return;
|
|
|
|
errorIds.current.add(progressId);
|
|
const errorMessage = query.error instanceof Error ? query.error.message : String(query.error);
|
|
options?.onError?.(progressId, errorMessage);
|
|
|
|
// Clean up after error
|
|
timers.push(
|
|
setTimeout(() => {
|
|
queryClient.removeQueries({ queryKey: progressKeys.detail(progressId), exact: true });
|
|
}, 5000),
|
|
);
|
|
});
|
|
|
|
// Cleanup function to clear all timeouts
|
|
return () => {
|
|
timers.forEach(clearTimeout);
|
|
};
|
|
}, [queries, progressIds, queryClient, options]);
|
|
|
|
return queries.map((query, index) => {
|
|
const data = query.data as ProgressResponse | null;
|
|
return {
|
|
progressId: progressIds[index],
|
|
data,
|
|
isLoading: query.isLoading,
|
|
error: query.error,
|
|
isComplete: data?.status === "completed",
|
|
isFailed: data?.status === "error" || data?.status === "failed",
|
|
isActive: data ? !TERMINAL_STATES.includes(data.status) : false,
|
|
};
|
|
});
|
|
}
|