From 33a1e0a4451dbd1729e1fa580959398822889295 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 14:49:38 -0500 Subject: [PATCH 01/42] [Symphony] Start contribution for #373 From 9a3cd5e17e31182d55e1fa806083af013c8c26a6 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 14:50:02 -0500 Subject: [PATCH 02/42] MAESTRO: memoize SessionListItem rendering --- src/renderer/components/SessionListItem.tsx | 27 +++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/SessionListItem.tsx b/src/renderer/components/SessionListItem.tsx index e9084ecc1..24ddd3653 100644 --- a/src/renderer/components/SessionListItem.tsx +++ b/src/renderer/components/SessionListItem.tsx @@ -15,7 +15,7 @@ * @module components/SessionListItem */ -import React from 'react'; +import React, { memo, useMemo } from 'react'; import { Star, Play, @@ -85,7 +85,7 @@ export interface SessionListItemProps { /** * SessionListItem component for rendering a single session row */ -export function SessionListItem({ +export const SessionListItem = memo(function SessionListItem({ session, index, selectedIndex, @@ -109,16 +109,27 @@ export function SessionListItem({ const isSelected = index === selectedIndex; const isRenaming = renamingSessionId === session.sessionId; const isActive = activeAgentSessionId === session.sessionId; + const containerStyle = useMemo( + () => ({ + backgroundColor: isSelected ? `${theme.colors.accent}15` : 'transparent', + borderColor: `${theme.colors.border}50`, + }), + [isSelected, theme.colors.accent, theme.colors.border] + ); + const searchMatchStyle = useMemo( + () => ({ + backgroundColor: `${theme.colors.accent}20`, + color: theme.colors.accent, + }), + [theme.colors.accent] + ); return (
) : null} onClick={() => onSessionClick(session)} className="w-full text-left px-6 py-4 flex items-start gap-4 hover:bg-white/5 transition-colors border-b group cursor-pointer" - style={{ - backgroundColor: isSelected ? theme.colors.accent + '15' : 'transparent', - borderColor: theme.colors.border + '50', - }} + style={containerStyle} > {/* Star button */}
); -} +}); From 8f2cc4a42fc4df257ac9fe39479ada15feb398bd Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 14:56:43 -0500 Subject: [PATCH 03/42] MAESTRO: optimize AutoRun regex and match memoization --- src/renderer/components/AutoRun.tsx | 77 ++++++++++++++++++----------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx index 706b19bce..8fbccf855 100644 --- a/src/renderer/components/AutoRun.tsx +++ b/src/renderer/components/AutoRun.tsx @@ -56,6 +56,14 @@ import { generateAutoRunProseStyles, createMarkdownComponents } from '../utils/m import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { remarkFileLinks, buildFileTreeIndices } from '../utils/remarkFileLinks'; +const AUTO_RUN_COMPLETED_TASK_REGEX = /^[\s]*[-*]\s*\[x\]/gim; +const AUTO_RUN_UNCHECKED_TASK_REGEX = /^[\s]*[-*]\s*\[\s\]/gim; +const AUTO_RUN_RESET_TASK_REGEX = /^([\s]*[-*]\s*)\[x\]/gim; +const AUTO_RUN_SEARCH_QUERY_ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; +const AUTO_RUN_LIST_UNORDERED_LIST_REGEX = /^(\s*)([-*])\s+/; +const AUTO_RUN_LIST_ORDERED_LIST_REGEX = /^(\s*)(\d+)\.\s+/; +const AUTO_RUN_LIST_TASK_LIST_REGEX = /^(\s*)- \[([ x])\]\s+/; + interface AutoRunProps { theme: Theme; sessionId: string; // Maestro session ID for per-session attachment storage @@ -714,20 +722,24 @@ const AutoRunInner = forwardRef(function AutoRunInn resetUndoHistory(content); }, [selectedFile, sessionId, content, resetUndoHistory]); + const completedTaskMatches = useMemo(() => localContent.match(AUTO_RUN_COMPLETED_TASK_REGEX) || [], [localContent]); + const completedTaskCountFromLocalContent = useMemo( + () => completedTaskMatches.length, + [completedTaskMatches] + ); + // Reset completed tasks - converts all '- [x]' to '- [ ]' const handleResetTasks = useCallback(async () => { if (!folderPath || !selectedFile) return; // Count how many completed tasks we're resetting - const completedRegex = /^[\s]*[-*]\s*\[x\]/gim; - const completedMatches = localContent.match(completedRegex) || []; - const resetCount = completedMatches.length; + const resetCount = completedTaskCountFromLocalContent; // Push undo state before resetting pushUndoState(); // Replace all completed checkboxes with unchecked ones - const resetContent = localContent.replace(/^([\s]*[-*]\s*)\[x\]/gim, '$1[ ]'); + const resetContent = localContent.replace(AUTO_RUN_RESET_TASK_REGEX, '$1[ ]'); setLocalContent(resetContent); lastUndoSnapshotRef.current = resetContent; @@ -751,6 +763,7 @@ const AutoRunInner = forwardRef(function AutoRunInn }, [ folderPath, selectedFile, + completedTaskCountFromLocalContent, localContent, setLocalContent, setSavedContent, @@ -844,10 +857,8 @@ const AutoRunInner = forwardRef(function AutoRunInn // Helper function to count completed tasks (used by useImperativeHandle before taskCounts is defined) const getCompletedTaskCountFromContent = useCallback(() => { - const completedRegex = /^[\s]*[-*]\s*\[x\]/gim; - const completedMatches = localContent.match(completedRegex) || []; - return completedMatches.length; - }, [localContent]); + return completedTaskCountFromLocalContent; + }, [completedTaskCountFromLocalContent]); // Expose methods to parent via ref useImperativeHandle( @@ -1053,18 +1064,25 @@ const AutoRunInner = forwardRef(function AutoRunInn // Debounced search match counting - prevent expensive regex on every keystroke const searchCountTimeoutRef = useRef(null); + const trimmedSearchQuery = useMemo(() => searchQuery.trim(), [searchQuery]); + const escapedSearchQuery = useMemo( + () => trimmedSearchQuery.replace(AUTO_RUN_SEARCH_QUERY_ESCAPE_REGEX, '\\$&'), + [trimmedSearchQuery] + ); + const searchQueryRegex = useMemo( + () => (trimmedSearchQuery ? new RegExp(escapedSearchQuery, 'gi') : null), + [trimmedSearchQuery, escapedSearchQuery] + ); useEffect(() => { // Clear any pending count if (searchCountTimeoutRef.current) { clearTimeout(searchCountTimeoutRef.current); } - if (searchQuery.trim()) { + if (searchQueryRegex) { // Debounce the match counting for large documents searchCountTimeoutRef.current = setTimeout(() => { - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); - const matches = localContent.match(regex); + const matches = localContent.match(searchQueryRegex) || []; const count = matches ? matches.length : 0; setTotalMatches(count); if (count > 0 && currentMatchIndex >= count) { @@ -1081,7 +1099,7 @@ const AutoRunInner = forwardRef(function AutoRunInn clearTimeout(searchCountTimeoutRef.current); } }; - }, [searchQuery, localContent]); + }, [searchQueryRegex, localContent, currentMatchIndex]); // Navigate to next search match const goToNextMatch = useCallback(() => { @@ -1117,16 +1135,14 @@ const AutoRunInner = forwardRef(function AutoRunInn useEffect(() => { // Only scroll when user explicitly navigated (prev/next buttons or Enter key) if (!userNavigatedToMatchRef.current) return; - if (!searchOpen || !searchQuery.trim() || totalMatches === 0) return; + if (!searchOpen || !searchQueryRegex || totalMatches === 0) return; if (mode !== 'edit' || !textareaRef.current) return; // For edit mode, find the match position in the text and scroll - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); let matchPosition = -1; // Find the nth match position using matchAll - const matches = Array.from(localContent.matchAll(regex)); + const matches = Array.from(localContent.matchAll(searchQueryRegex)); if (currentMatchIndex < matches.length) { matchPosition = matches[currentMatchIndex].index!; } @@ -1166,10 +1182,10 @@ const AutoRunInner = forwardRef(function AutoRunInn // Focus textarea and select the match text textarea.focus(); - textarea.setSelectionRange(matchPosition, matchPosition + searchQuery.length); + textarea.setSelectionRange(matchPosition, matchPosition + trimmedSearchQuery.length); userNavigatedToMatchRef.current = false; - } - }, [currentMatchIndex, searchOpen, searchQuery, totalMatches, mode, localContent]); + } + }, [currentMatchIndex, searchOpen, totalMatches, mode, localContent, searchQueryRegex, trimmedSearchQuery]); const handleKeyDown = (e: React.KeyboardEvent) => { // Let template autocomplete handle keys first @@ -1290,11 +1306,10 @@ const AutoRunInner = forwardRef(function AutoRunInn const textAfterCursor = localContent.substring(cursorPos); const currentLineStart = textBeforeCursor.lastIndexOf('\n') + 1; const currentLine = textBeforeCursor.substring(currentLineStart); - // Check for list patterns - const unorderedListMatch = currentLine.match(/^(\s*)([-*])\s+/); - const orderedListMatch = currentLine.match(/^(\s*)(\d+)\.\s+/); - const taskListMatch = currentLine.match(/^(\s*)- \[([ x])\]\s+/); + const unorderedListMatch = currentLine.match(AUTO_RUN_LIST_UNORDERED_LIST_REGEX); + const orderedListMatch = currentLine.match(AUTO_RUN_LIST_ORDERED_LIST_REGEX); + const taskListMatch = currentLine.match(AUTO_RUN_LIST_TASK_LIST_REGEX); if (taskListMatch) { // Task list: continue with unchecked checkbox @@ -1353,15 +1368,19 @@ const AutoRunInner = forwardRef(function AutoRunInn // Parse task counts from saved content only (not live during editing) // Updates on: document load, save, and external file changes + const taskCountsMatches = useMemo( + () => ({ + completedMatches: savedContent.match(AUTO_RUN_COMPLETED_TASK_REGEX) || [], + uncheckedMatches: savedContent.match(AUTO_RUN_UNCHECKED_TASK_REGEX) || [], + }), + [savedContent] + ); const taskCounts = useMemo(() => { - const completedRegex = /^[\s]*[-*]\s*\[x\]/gim; - const uncheckedRegex = /^[\s]*[-*]\s*\[\s\]/gim; - const completedMatches = savedContent.match(completedRegex) || []; - const uncheckedMatches = savedContent.match(uncheckedRegex) || []; + const { completedMatches, uncheckedMatches } = taskCountsMatches; const completed = completedMatches.length; const total = completed + uncheckedMatches.length; return { completed, total }; - }, [savedContent]); + }, [taskCountsMatches]); // Token counting based on saved content only (not live during editing) // Updates on: document load, save, and external file changes From eba4637a0cb7de2c95fd3d366da30bfce076f3ce Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 14:59:17 -0500 Subject: [PATCH 04/42] MAESTRO: Memoize FilePreview regex and date computations --- src/renderer/components/FilePreview.tsx | 123 ++++++++++++++---------- 1 file changed, 72 insertions(+), 51 deletions(-) diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index b41ad2474..8dfb94d85 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -62,6 +62,15 @@ const imageCache = new Map< // Cache cleanup interval (clear entries older than 10 minutes) const IMAGE_CACHE_TTL = 10 * 60 * 1000; +const MARKDOWN_TASK_OPEN_REGEX = /^[\s]*[-*]\s*\[\s*\]/gm; +const MARKDOWN_TASK_CLOSED_REGEX = /^[\s]*[-*]\s*\[[xX]\]/gm; +const CODE_FENCE_REGEX = /^(`{3,}|~{3,})/; +const HEADING_REGEX = /^(#{1,6})\s+(.+)$/; +const HIGHLIGHT_TEXT_REGEX = /==([^=]+)==/g; +const FILE_PROTOCOL_REGEX = /^file:\/\//; +const CODE_LANGUAGE_REGEX = /language-(\w+)/; +const TRAILING_NEWLINE_REGEX = /\n$/; +const SEARCH_SPECIAL_CHARS_REGEX = /[.*+?^${}()|[\]\\]/g; // Clean up old cache entries periodically setInterval(() => { @@ -288,17 +297,6 @@ const formatDateTime = (isoString: string): string => { }); }; -// Count markdown tasks (checkboxes) -const countMarkdownTasks = (content: string): { open: number; closed: number } => { - // Match markdown checkboxes: - [ ] or - [x] (also * [ ] and * [x]) - const openMatches = content.match(/^[\s]*[-*]\s*\[\s*\]/gm); - const closedMatches = content.match(/^[\s]*[-*]\s*\[[xX]\]/gm); - return { - open: openMatches?.length || 0, - closed: closedMatches?.length || 0, - }; -}; - // Interface for table of contents entries interface TocEntry { level: number; // 1-6 for h1-h6 @@ -315,7 +313,7 @@ const extractHeadings = (content: string): TocEntry[] => { for (const line of lines) { // Track code fence boundaries (``` or ~~~, optionally with language specifier) - if (/^(`{3,}|~{3,})/.test(line)) { + if (CODE_FENCE_REGEX.test(line)) { inCodeFence = !inCodeFence; continue; } @@ -326,7 +324,7 @@ const extractHeadings = (content: string): TocEntry[] => { } // Match ATX-style headings (# H1, ## H2, etc.) - const match = line.match(/^(#{1,6})\s+(.+)$/); + const match = line.match(HEADING_REGEX); if (match) { const level = match[1].length; const text = match[2].trim(); @@ -573,14 +571,13 @@ function remarkHighlight() { return (tree: any) => { visit(tree, 'text', (node: any, index: number | null | undefined, parent: any) => { const text = node.value; - const regex = /==([^=]+)==/g; + const matches = Array.from(text.matchAll(new RegExp(HIGHLIGHT_TEXT_REGEX.source, HIGHLIGHT_TEXT_REGEX.flags))); - if (!regex.test(text)) return; + if (matches.length === 0) return; if (index === null || index === undefined || !parent) return; const parts: any[] = []; let lastIndex = 0; - const matches = text.matchAll(/==([^=]+)==/g); for (const match of matches) { const matchIndex = match.index!; @@ -721,6 +718,14 @@ export const FilePreview = React.memo( // File change detection state const [fileChangedOnDisk, setFileChangedOnDisk] = useState(false); const lastModifiedRef = useRef(lastModified); + const searchQueryTrimmed = searchQuery.trim(); + const escapedSearchQuery = useMemo(() => { + return searchQueryTrimmed.replace(SEARCH_SPECIAL_CHARS_REGEX, '\\$&'); + }, [searchQueryTrimmed]); + const searchRegex = useMemo(() => { + if (!searchQueryTrimmed) return null; + return new RegExp(escapedSearchQuery, 'gi'); + }, [searchQueryTrimmed, escapedSearchQuery]); // Keep ref in sync with prop (reset when parent reloads content with new lastModified) useEffect(() => { @@ -816,12 +821,31 @@ export const FilePreview = React.memo( // Calculate task counts for markdown files const taskCounts = useMemo(() => { if (!isMarkdown || !file?.content) return null; - const counts = countMarkdownTasks(file.content); + const openMatches = file.content.match(MARKDOWN_TASK_OPEN_REGEX); + const closedMatches = file.content.match(MARKDOWN_TASK_CLOSED_REGEX); + const counts = { + open: openMatches?.length || 0, + closed: closedMatches?.length || 0, + }; // Only return if there are any tasks if (counts.open === 0 && counts.closed === 0) return null; return counts; }, [isMarkdown, file?.content]); + const formattedFileStats = useMemo(() => { + if (!fileStats) return null; + return { + modifiedAt: formatDateTime(fileStats.modifiedAt), + createdAt: formatDateTime(fileStats.createdAt), + }; + }, [fileStats?.createdAt, fileStats?.modifiedAt]); + + const contentSearchMatchCount = useMemo(() => { + if (!searchRegex || !file?.content) return 0; + const matches = file.content.match(searchRegex); + return matches ? matches.length : 0; + }, [searchRegex, file?.content]); + // Extract table of contents entries for markdown files const tocEntries = useMemo(() => { if (!isMarkdown || !file?.content) return []; @@ -836,6 +860,15 @@ export const FilePreview = React.memo( container.scrollTo({ top, behavior: 'smooth' }); }, []); + const editModeSearchMatches = useMemo(() => { + if (!searchRegex || !isEditableText || !markdownEditMode || !editContent) return []; + const localSearchRegex = new RegExp(searchRegex.source, searchRegex.flags); + return Array.from(editContent.matchAll(localSearchRegex)).map((match) => ({ + start: match.index || 0, + end: (match.index || 0) + match[0].length, + })); + }, [searchRegex, isEditableText, markdownEditMode, editContent]); + // Memoize file tree indices to avoid O(n) traversal on every render const fileTreeIndices = useMemo(() => { if (fileTree && fileTree.length > 0) { @@ -899,8 +932,8 @@ export const FilePreview = React.memo( targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } else if (href) { - if (/^file:\/\//.test(href)) { - window.maestro.shell.openPath(href.replace(/^file:\/\//, '')); + if (FILE_PROTOCOL_REGEX.test(href)) { + window.maestro.shell.openPath(href.replace(FILE_PROTOCOL_REGEX, '')); } else { window.maestro.shell.openExternal(href); } @@ -921,9 +954,9 @@ export const FilePreview = React.memo( if (codeElement?.props) { const { className, children: codeChildren } = codeElement.props; - const match = (className || '').match(/language-(\w+)/); + const match = (className || '').match(CODE_LANGUAGE_REGEX); const lang = match ? match[1] : 'text'; - const codeContent = String(codeChildren).replace(/\n$/, ''); + const codeContent = String(codeChildren).replace(TRAILING_NEWLINE_REGEX, ''); // Handle mermaid code blocks if (lang === 'mermaid') { @@ -1273,7 +1306,7 @@ export const FilePreview = React.memo( // Highlight search matches in syntax-highlighted code useEffect(() => { - if (!searchQuery.trim() || !codeContainerRef.current || isMarkdown || isImage || isCsv) { + if (!searchRegex || !codeContainerRef.current || isMarkdown || isImage || isCsv) { setTotalMatches(0); setCurrentMatchIndex(0); matchElementsRef.current = []; @@ -1290,21 +1323,18 @@ export const FilePreview = React.memo( textNodes.push(node as Text); } - // Escape regex special characters - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); const matchElements: HTMLElement[] = []; // Highlight matches using safe DOM methods textNodes.forEach((textNode) => { const text = textNode.textContent || ''; - const matches = text.match(regex); + const matches = text.match(searchRegex); if (matches) { const fragment = document.createDocumentFragment(); let lastIndex = 0; - text.replace(regex, (match, offset) => { + text.replace(searchRegex, (match, offset) => { // Add text before match if (offset > lastIndex) { fragment.appendChild(document.createTextNode(text.substring(lastIndex, offset))); @@ -1357,11 +1387,11 @@ export const FilePreview = React.memo( }); matchElementsRef.current = []; }; - }, [searchQuery, file?.content, isMarkdown, isImage, isCsv, theme.colors.accent]); + }, [searchRegex, file?.content, isMarkdown, isImage, isCsv, theme.colors.accent]); // Search matches in markdown preview mode - use CSS Custom Highlight API useEffect(() => { - if (!isMarkdown || markdownEditMode || !searchQuery.trim() || !markdownContainerRef.current) { + if (!isMarkdown || markdownEditMode || !searchRegex || !markdownContainerRef.current) { if (isMarkdown && !markdownEditMode) { setTotalMatches(0); setCurrentMatchIndex(0); @@ -1376,8 +1406,6 @@ export const FilePreview = React.memo( } const container = markdownContainerRef.current; - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const searchRegex = new RegExp(escapedQuery, 'gi'); // Check if CSS Custom Highlight API is available if ('highlights' in CSS) { @@ -1388,9 +1416,9 @@ export const FilePreview = React.memo( let textNode; while ((textNode = walker.nextNode())) { const text = textNode.textContent || ''; - let match; - const localRegex = new RegExp(escapedQuery, 'gi'); - while ((match = localRegex.exec(text)) !== null) { + const localRegex = new RegExp(searchRegex.source, searchRegex.flags); + for (const match of text.matchAll(localRegex)) { + if (match.index === undefined) continue; const range = document.createRange(); range.setStart(textNode, match.index); range.setEnd(textNode, match.index + match[0].length); @@ -1442,8 +1470,7 @@ export const FilePreview = React.memo( }; } else { // Fallback: count matches and scroll to location (no highlighting) - const matches = file?.content?.match(searchRegex); - const count = matches ? matches.length : 0; + const count = contentSearchMatchCount; setTotalMatches(count); if (count > 0) { @@ -1454,7 +1481,8 @@ export const FilePreview = React.memo( let textNode; while ((textNode = walker.nextNode())) { const text = textNode.textContent || ''; - const nodeMatches = text.match(searchRegex); + const localRegex = new RegExp(searchRegex.source, searchRegex.flags); + const nodeMatches = text.match(localRegex); if (nodeMatches) { for (const _ of nodeMatches) { if (matchCount === targetIndex) { @@ -1473,8 +1501,9 @@ export const FilePreview = React.memo( matchElementsRef.current = []; }, [ - searchQuery, + searchRegex, file?.content, + contentSearchMatchCount, isMarkdown, markdownEditMode, currentMatchIndex, @@ -1585,7 +1614,7 @@ export const FilePreview = React.memo( // Handle search in edit mode - count matches and update state // Note: We separate counting from selection to avoid stealing focus while typing useEffect(() => { - if (!isEditableText || !markdownEditMode || !searchQuery.trim() || !textareaRef.current) { + if (!isEditableText || !markdownEditMode || !searchRegex || !textareaRef.current) { if (isEditableText && markdownEditMode) { setTotalMatches(0); setCurrentMatchIndex(0); @@ -1594,15 +1623,7 @@ export const FilePreview = React.memo( } const content = editContent; - const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); - - // Find all matches and their positions - const matches: { start: number; end: number }[] = []; - let matchResult; - while ((matchResult = regex.exec(content)) !== null) { - matches.push({ start: matchResult.index, end: matchResult.index + matchResult[0].length }); - } + const matches = editModeSearchMatches; setTotalMatches(matches.length); if (matches.length === 0) { @@ -1643,7 +1664,7 @@ export const FilePreview = React.memo( textarea.scrollTop = Math.max(0, targetScroll); } } - }, [searchQuery, currentMatchIndex, isEditableText, markdownEditMode, editContent]); + }, [editModeSearchMatches, currentMatchIndex, isEditableText, markdownEditMode, searchQuery, editContent]); // Helper to check if a shortcut matches const isShortcut = (e: React.KeyboardEvent, shortcutId: string) => { @@ -1980,13 +2001,13 @@ export const FilePreview = React.memo(
Modified:{' '} - {formatDateTime(fileStats.modifiedAt)} + {formattedFileStats?.modifiedAt}
Created:{' '} - {formatDateTime(fileStats.createdAt)} + {formattedFileStats?.createdAt}
From 361478109c52dc7c6eca295c88271335b76b1bde Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:02:31 -0500 Subject: [PATCH 05/42] MAESTRO: memoize SessionListItem inline styles --- src/renderer/components/SessionListItem.tsx | 134 +++++++++++++++----- 1 file changed, 99 insertions(+), 35 deletions(-) diff --git a/src/renderer/components/SessionListItem.tsx b/src/renderer/components/SessionListItem.tsx index 24ddd3653..7bb27c003 100644 --- a/src/renderer/components/SessionListItem.tsx +++ b/src/renderer/components/SessionListItem.tsx @@ -109,27 +109,98 @@ export const SessionListItem = memo(function SessionListItem({ const isSelected = index === selectedIndex; const isRenaming = renamingSessionId === session.sessionId; const isActive = activeAgentSessionId === session.sessionId; - const containerStyle = useMemo( + const styles = useMemo( () => ({ - backgroundColor: isSelected ? `${theme.colors.accent}15` : 'transparent', - borderColor: `${theme.colors.border}50`, + container: { + backgroundColor: isSelected ? `${theme.colors.accent}15` : 'transparent', + borderColor: `${theme.colors.border}50`, + }, + searchMatch: { + backgroundColor: `${theme.colors.accent}20`, + color: theme.colors.accent, + }, + statsText: { + color: theme.colors.textDim, + }, + sessionName: { + color: theme.colors.accent, + }, + firstMessageText: { + color: session.sessionName ? theme.colors.textDim : theme.colors.textMain, + }, + renameInput: { + color: theme.colors.accent, + borderColor: theme.colors.accent, + backgroundColor: theme.colors.bgActivity, + }, + starIcon: { + color: isStarred ? theme.colors.warning : theme.colors.textDim, + fill: isStarred ? theme.colors.warning : 'transparent', + }, + playIcon: { + color: theme.colors.success, + }, + editIcon: { + color: theme.colors.accent, + }, + ghostEditIcon: { + color: theme.colors.textDim, + }, + costText: { + color: theme.colors.success, + }, + sessionOrigin: { + user: { + backgroundColor: `${theme.colors.accent}30`, + color: theme.colors.accent, + }, + auto: { + backgroundColor: `${theme.colors.warning}30`, + color: theme.colors.warning, + }, + cli: { + backgroundColor: theme.colors.border, + color: theme.colors.textDim, + }, + }, + sessionIdPill: { + backgroundColor: `${theme.colors.border}60`, + color: theme.colors.textDim, + }, + searchPreview: { + color: theme.colors.accent, + }, + activeBadge: { + backgroundColor: `${theme.colors.success}20`, + color: theme.colors.success, + }, }), - [isSelected, theme.colors.accent, theme.colors.border] - ); - const searchMatchStyle = useMemo( - () => ({ - backgroundColor: `${theme.colors.accent}20`, - color: theme.colors.accent, - }), - [theme.colors.accent] + [ + isSelected, + isStarred, + session.sessionName, + theme.colors.accent, + theme.colors.bgActivity, + theme.colors.border, + theme.colors.textDim, + theme.colors.textMain, + theme.colors.warning, + theme.colors.success, + ] ); + const originStyles = session.origin === 'user' + ? styles.sessionOrigin.user + : session.origin === 'auto' + ? styles.sessionOrigin.auto + : styles.sessionOrigin.cli; + return (
) : null} onClick={() => onSessionClick(session)} className="w-full text-left px-6 py-4 flex items-start gap-4 hover:bg-white/5 transition-colors border-b group cursor-pointer" - style={containerStyle} + style={styles.container} > {/* Star button */} @@ -152,7 +220,7 @@ export const SessionListItem = memo(function SessionListItem({ className="p-1 rounded hover:bg-white/10 transition-colors shrink-0 opacity-0 group-hover:opacity-100" title="Resume session in new tab" > - +
@@ -178,16 +246,12 @@ export const SessionListItem = memo(function SessionListItem({ onBlur={() => onSubmitRename(session.sessionId)} placeholder="Enter session name..." className="flex-1 bg-transparent outline-none text-sm font-semibold px-2 py-0.5 rounded border min-w-0" - style={{ - color: theme.colors.accent, - borderColor: theme.colors.accent, - backgroundColor: theme.colors.bgActivity, - }} + style={styles.renameInput} />
) : session.sessionName ? (
- + {session.sessionName}
) : null} @@ -206,7 +270,7 @@ export const SessionListItem = memo(function SessionListItem({ > {session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`} @@ -217,18 +281,18 @@ export const SessionListItem = memo(function SessionListItem({ className="p-0.5 rounded opacity-0 group-hover/title:opacity-100 hover:bg-white/10 transition-all shrink-0" title="Add session name" > - + )}
{/* Stats row: origin pill + session ID + stats + match info */} -
+
{/* Session origin pill */} {session.origin === 'user' && ( MAESTRO @@ -237,7 +301,7 @@ export const SessionListItem = memo(function SessionListItem({ {session.origin === 'auto' && ( AUTO @@ -246,7 +310,7 @@ export const SessionListItem = memo(function SessionListItem({ {!session.origin && ( CLI @@ -256,7 +320,7 @@ export const SessionListItem = memo(function SessionListItem({ {/* Session ID pill */} {session.sessionId.startsWith('agent-') ? `AGENT-${session.sessionId.split('-')[1]?.toUpperCase() || ''}` @@ -281,7 +345,7 @@ export const SessionListItem = memo(function SessionListItem({ {(session.costUsd ?? 0) > 0 && ( {(session.costUsd ?? 0).toFixed(2)} @@ -292,7 +356,7 @@ export const SessionListItem = memo(function SessionListItem({ {searchResultInfo && searchResultInfo.matchCount > 0 && searchMode !== 'title' && ( {searchResultInfo.matchCount} @@ -301,7 +365,7 @@ export const SessionListItem = memo(function SessionListItem({ {/* Show match preview for content searches */} {searchResultInfo && searchResultInfo.matchPreview && searchMode !== 'title' && ( - + "{searchResultInfo.matchPreview}" )} @@ -312,7 +376,7 @@ export const SessionListItem = memo(function SessionListItem({ {isActive && ( ACTIVE From 00dfa2db88d991f705fea764c099f72c23843e8b Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:12:36 -0500 Subject: [PATCH 06/42] MAESTRO: asyncify history session listing and rename flow --- src/main/history-manager.ts | 168 ++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 64 deletions(-) diff --git a/src/main/history-manager.ts b/src/main/history-manager.ts index 5212bbd15..dbc964867 100644 --- a/src/main/history-manager.ts +++ b/src/main/history-manager.ts @@ -174,13 +174,16 @@ export class HistoryManager { /** * Read history for a specific session */ - getEntries(sessionId: string): HistoryEntry[] { + async getEntries(sessionId: string): Promise { const filePath = this.getSessionFilePath(sessionId); - if (!fs.existsSync(filePath)) { + try { + await fs.promises.access(filePath); + } catch { return []; } try { - const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const data: HistoryFileData = JSON.parse(raw); return data.entries || []; } catch (error) { logger.warn(`Failed to read history for session ${sessionId}: ${error}`, LOG_CONTEXT); @@ -192,17 +195,19 @@ export class HistoryManager { /** * Add an entry to a session's history */ - addEntry(sessionId: string, projectPath: string, entry: HistoryEntry): void { + async addEntry(sessionId: string, projectPath: string, entry: HistoryEntry): Promise { const filePath = this.getSessionFilePath(sessionId); let data: HistoryFileData; - if (fs.existsSync(filePath)) { + try { + await fs.promises.access(filePath); try { - data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const raw = await fs.promises.readFile(filePath, 'utf-8'); + data = JSON.parse(raw); } catch { data = { version: HISTORY_VERSION, sessionId, projectPath, entries: [] }; } - } else { + } catch { data = { version: HISTORY_VERSION, sessionId, projectPath, entries: [] }; } @@ -218,7 +223,7 @@ export class HistoryManager { data.projectPath = projectPath; try { - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); logger.debug(`Added history entry for session ${sessionId}`, LOG_CONTEXT); } catch (error) { logger.error(`Failed to write history for session ${sessionId}: ${error}`, LOG_CONTEXT); @@ -229,14 +234,17 @@ export class HistoryManager { /** * Delete a specific entry from a session's history */ - deleteEntry(sessionId: string, entryId: string): boolean { + async deleteEntry(sessionId: string, entryId: string): Promise { const filePath = this.getSessionFilePath(sessionId); - if (!fs.existsSync(filePath)) { + try { + await fs.promises.access(filePath); + } catch { return false; } try { - const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const data: HistoryFileData = JSON.parse(raw); const originalLength = data.entries.length; data.entries = data.entries.filter((e) => e.id !== entryId); @@ -245,7 +253,7 @@ export class HistoryManager { } try { - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); return true; } catch (writeError) { logger.error( @@ -263,14 +271,17 @@ export class HistoryManager { /** * Update a specific entry in a session's history */ - updateEntry(sessionId: string, entryId: string, updates: Partial): boolean { + async updateEntry(sessionId: string, entryId: string, updates: Partial): Promise { const filePath = this.getSessionFilePath(sessionId); - if (!fs.existsSync(filePath)) { + try { + await fs.promises.access(filePath); + } catch { return false; } try { - const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const data: HistoryFileData = JSON.parse(raw); const index = data.entries.findIndex((e) => e.id === entryId); if (index === -1) { @@ -279,7 +290,7 @@ export class HistoryManager { data.entries[index] = { ...data.entries[index], ...updates }; try { - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); return true; } catch (writeError) { logger.error( @@ -313,7 +324,16 @@ export class HistoryManager { /** * List all sessions that have history files */ - listSessionsWithHistory(): string[] { + async listSessionsWithHistory(): Promise { + try { + const files = await fs.promises.readdir(this.historyDir); + return files.filter((f) => f.endsWith('.json')).map((f) => f.replace('.json', '')); + } catch { + return []; + } + } + + private listSessionsWithHistorySync(): string[] { if (!fs.existsSync(this.historyDir)) { return []; } @@ -336,14 +356,12 @@ export class HistoryManager { * Returns entries sorted by timestamp (most recent first) * @deprecated Use getAllEntriesPaginated for large datasets */ - getAllEntries(limit?: number): HistoryEntry[] { - const sessions = this.listSessionsWithHistory(); + async getAllEntries(limit?: number): Promise { + const sessions = await this.listSessionsWithHistory(); const allEntries: HistoryEntry[] = []; - for (const sessionId of sessions) { - const entries = this.getEntries(sessionId); - allEntries.push(...entries); - } + const allSessionEntries = await Promise.all(sessions.map((sessionId) => this.getEntries(sessionId))); + allEntries.push(...allSessionEntries.flat()); const sorted = sortEntriesByTimestamp(allEntries); return limit ? sorted.slice(0, limit) : sorted; @@ -353,14 +371,14 @@ export class HistoryManager { * Get all entries across all sessions with pagination support * Returns entries sorted by timestamp (most recent first) */ - getAllEntriesPaginated(options?: PaginationOptions): PaginatedResult { - const sessions = this.listSessionsWithHistory(); + async getAllEntriesPaginated( + options?: PaginationOptions + ): Promise> { + const sessions = await this.listSessionsWithHistory(); const allEntries: HistoryEntry[] = []; - for (const sessionId of sessions) { - const entries = this.getEntries(sessionId); - allEntries.push(...entries); - } + const allSessionEntries = await Promise.all(sessions.map((sessionId) => this.getEntries(sessionId))); + allEntries.push(...allSessionEntries.flat()); const sorted = sortEntriesByTimestamp(allEntries); return paginateEntries(sorted, options); @@ -370,12 +388,12 @@ export class HistoryManager { * Get entries filtered by project path * @deprecated Use getEntriesByProjectPathPaginated for large datasets */ - getEntriesByProjectPath(projectPath: string): HistoryEntry[] { - const sessions = this.listSessionsWithHistory(); + async getEntriesByProjectPath(projectPath: string): Promise { + const sessions = this.listSessionsWithHistorySync(); const entries: HistoryEntry[] = []; for (const sessionId of sessions) { - const sessionEntries = this.getEntries(sessionId); + const sessionEntries = await this.getEntries(sessionId); if (sessionEntries.length > 0 && sessionEntries[0].projectPath === projectPath) { entries.push(...sessionEntries); } @@ -387,15 +405,15 @@ export class HistoryManager { /** * Get entries filtered by project path with pagination support */ - getEntriesByProjectPathPaginated( + async getEntriesByProjectPathPaginated( projectPath: string, options?: PaginationOptions - ): PaginatedResult { - const sessions = this.listSessionsWithHistory(); + ): Promise> { + const sessions = this.listSessionsWithHistorySync(); const entries: HistoryEntry[] = []; for (const sessionId of sessions) { - const sessionEntries = this.getEntries(sessionId); + const sessionEntries = await this.getEntries(sessionId); if (sessionEntries.length > 0 && sessionEntries[0].projectPath === projectPath) { entries.push(...sessionEntries); } @@ -408,11 +426,11 @@ export class HistoryManager { /** * Get entries for a specific session with pagination support */ - getEntriesPaginated( + async getEntriesPaginated( sessionId: string, options?: PaginationOptions - ): PaginatedResult { - const entries = this.getEntries(sessionId); + ): Promise> { + const entries = await this.getEntries(sessionId); return paginateEntries(entries, options); } @@ -420,36 +438,58 @@ export class HistoryManager { * Update sessionName for all entries matching a given agentSessionId. * This is used when a tab is renamed to retroactively update past history entries. */ - updateSessionNameByClaudeSessionId(agentSessionId: string, sessionName: string): number { - const sessions = this.listSessionsWithHistory(); + async updateSessionNameByClaudeSessionId(agentSessionId: string, sessionName: string): Promise { + const sessions = await this.listSessionsWithHistory(); let updatedCount = 0; + const parsedSessions = await Promise.all( + sessions.map(async (sessionId) => { + const filePath = this.getSessionFilePath(sessionId); + try { + const raw = await fs.promises.readFile(filePath, 'utf-8'); + const data = JSON.parse(raw) as HistoryFileData; + return { sessionId, filePath, data }; + } catch (error) { + logger.warn(`Failed to read session file ${sessionId}: ${error}`, LOG_CONTEXT); + captureException(error, { operation: 'history:updateSessionNameRead', sessionId }); + return null; + } + }) + ); - for (const sessionId of sessions) { - const filePath = this.getSessionFilePath(sessionId); - if (!fs.existsSync(filePath)) continue; + for (const parsed of parsedSessions) { + if (!parsed) { + continue; + } - try { - const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - let modified = false; - - for (const entry of data.entries) { - if (entry.agentSessionId === agentSessionId && entry.sessionName !== sessionName) { - entry.sessionName = sessionName; - modified = true; - updatedCount++; - } + let sessionUpdatedCount = 0; + for (const entry of parsed.data.entries) { + if (entry.agentSessionId === agentSessionId && entry.sessionName !== sessionName) { + entry.sessionName = sessionName; + sessionUpdatedCount++; + updatedCount++; } + } - if (modified) { - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + if (sessionUpdatedCount > 0) { + try { + await fs.promises.writeFile( + parsed.filePath, + JSON.stringify(parsed.data, null, 2), + 'utf-8' + ); logger.debug( - `Updated ${updatedCount} entries for agentSessionId ${agentSessionId} in session ${sessionId}`, + `Updated ${sessionUpdatedCount} entries for agentSessionId ${agentSessionId} in session ${parsed.sessionId}`, LOG_CONTEXT ); + } catch (error) { + logger.warn( + `Failed to update sessionName in session ${parsed.sessionId}: ${error}`, + LOG_CONTEXT + ); + captureException(error, { operation: 'history:updateSessionNameWrite', sessionId: parsed.sessionId }); } - } catch (error) { - logger.warn(`Failed to update sessionName in session ${sessionId}: ${error}`, LOG_CONTEXT); - captureException(error, { operation: 'history:updateSessionName', sessionId }); + + break; } } @@ -459,10 +499,10 @@ export class HistoryManager { /** * Clear all sessions for a specific project */ - clearByProjectPath(projectPath: string): void { - const sessions = this.listSessionsWithHistory(); + async clearByProjectPath(projectPath: string): Promise { + const sessions = this.listSessionsWithHistorySync(); for (const sessionId of sessions) { - const entries = this.getEntries(sessionId); + const entries = await this.getEntries(sessionId); if (entries.length > 0 && entries[0].projectPath === projectPath) { this.clearSession(sessionId); } @@ -473,7 +513,7 @@ export class HistoryManager { * Clear all history (all session files) */ clearAll(): void { - const sessions = this.listSessionsWithHistory(); + const sessions = this.listSessionsWithHistorySync(); for (const sessionId of sessions) { this.clearSession(sessionId); } From 1656f73b45fe53bdd2233161426caa73b5fee92a Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:14:05 -0500 Subject: [PATCH 07/42] MAESTRO: await async history-manager handlers --- src/main/ipc/handlers/director-notes.ts | 8 ++--- src/main/ipc/handlers/history.ts | 32 +++++++++---------- src/main/web-server/WebServer.ts | 2 +- .../web-server/managers/CallbackRegistry.ts | 2 +- src/main/web-server/routes/apiRoutes.ts | 4 +-- src/main/web-server/types.ts | 2 +- src/main/web-server/web-server-factory.ts | 8 ++--- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/main/ipc/handlers/director-notes.ts b/src/main/ipc/handlers/director-notes.ts index 269cb2d2c..affb2dfb9 100644 --- a/src/main/ipc/handlers/director-notes.ts +++ b/src/main/ipc/handlers/director-notes.ts @@ -144,7 +144,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen const cutoffTime = lookbackDays > 0 ? Date.now() - lookbackDays * 24 * 60 * 60 * 1000 : 0; // Get all session IDs from history manager - const sessionIds = historyManager.listSessionsWithHistory(); + const sessionIds = await historyManager.listSessionsWithHistory(); // Resolve Maestro session names (the names shown in the left bar) const sessionNameMap = buildSessionNameMap(); @@ -156,7 +156,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen let userCount = 0; for (const sessionId of sessionIds) { - const entries = historyManager.getEntries(sessionId); + const entries = await historyManager.getEntries(sessionId); const maestroSessionName = sessionNameMap.get(sessionId); for (const entry of entries) { @@ -229,7 +229,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen // Build file-path manifest so the agent reads history files directly const cutoffTime = Date.now() - options.lookbackDays * 24 * 60 * 60 * 1000; - const sessionIds = historyManager.listSessionsWithHistory(); + const sessionIds = await historyManager.listSessionsWithHistory(); const sessionNameMap = buildSessionNameMap(); const sessionManifest: Array<{ @@ -249,7 +249,7 @@ export function registerDirectorNotesHandlers(deps: DirectorNotesHandlerDependen sessionManifest.push({ sessionId, displayName, historyFilePath: filePath }); // Count entries in lookback window and track which agents contributed - const entries = historyManager.getEntries(sessionId); + const entries = await historyManager.getEntries(sessionId); let agentHasEntries = false; for (const entry of entries) { if (entry.timestamp >= cutoffTime) { diff --git a/src/main/ipc/handlers/history.ts b/src/main/ipc/handlers/history.ts index 1bc5875ef..4bc803569 100644 --- a/src/main/ipc/handlers/history.ts +++ b/src/main/ipc/handlers/history.ts @@ -50,7 +50,7 @@ export function registerHistoryHandlers(): void { if (sessionId) { // Get entries for specific session only - don't include orphaned entries // to prevent history bleeding across different agent sessions in the same directory - const entries = historyManager.getEntries(sessionId); + const entries = await historyManager.getEntries(sessionId); // Sort by timestamp descending entries.sort((a, b) => b.timestamp - a.timestamp); return entries; @@ -58,11 +58,11 @@ export function registerHistoryHandlers(): void { if (projectPath) { // Get all entries for sessions in this project - return historyManager.getEntriesByProjectPath(projectPath); + return await historyManager.getEntriesByProjectPath(projectPath); } // Return all entries (for global view) - return historyManager.getAllEntries(); + return await historyManager.getAllEntries(); }) ); @@ -80,16 +80,16 @@ export function registerHistoryHandlers(): void { if (sessionId) { // Get paginated entries for specific session - return historyManager.getEntriesPaginated(sessionId, pagination); + return await historyManager.getEntriesPaginated(sessionId, pagination); } if (projectPath) { // Get paginated entries for sessions in this project - return historyManager.getEntriesByProjectPathPaginated(projectPath, pagination); + return await historyManager.getEntriesByProjectPathPaginated(projectPath, pagination); } // Return paginated entries (for global view) - return historyManager.getAllEntriesPaginated(pagination); + return await historyManager.getAllEntriesPaginated(pagination); } ) ); @@ -109,7 +109,7 @@ export function registerHistoryHandlers(): void { 'history:add', withIpcErrorLogging(handlerOpts('add'), async (entry: HistoryEntry) => { const sessionId = entry.sessionId || ORPHANED_SESSION_ID; - historyManager.addEntry(sessionId, entry.projectPath, entry); + await historyManager.addEntry(sessionId, entry.projectPath, entry); logger.info(`Added history entry: ${entry.type}`, LOG_CONTEXT, { summary: entry.summary }); return true; }) @@ -127,7 +127,7 @@ export function registerHistoryHandlers(): void { if (projectPath) { // Clear all sessions for this project - historyManager.clearByProjectPath(projectPath); + await historyManager.clearByProjectPath(projectPath); logger.info(`Cleared history for project: ${projectPath}`, LOG_CONTEXT); return true; } @@ -144,7 +144,7 @@ export function registerHistoryHandlers(): void { 'history:delete', withIpcErrorLogging(handlerOpts('delete'), async (entryId: string, sessionId?: string) => { if (sessionId) { - const deleted = historyManager.deleteEntry(sessionId, entryId); + const deleted = await historyManager.deleteEntry(sessionId, entryId); if (deleted) { logger.info(`Deleted history entry: ${entryId} from session ${sessionId}`, LOG_CONTEXT); } else { @@ -154,9 +154,9 @@ export function registerHistoryHandlers(): void { } // Search all sessions for the entry (slower, but works for legacy calls without sessionId) - const sessions = historyManager.listSessionsWithHistory(); + const sessions = await historyManager.listSessionsWithHistory(); for (const sid of sessions) { - if (historyManager.deleteEntry(sid, entryId)) { + if (await historyManager.deleteEntry(sid, entryId)) { logger.info(`Deleted history entry: ${entryId} from session ${sid}`, LOG_CONTEXT); return true; } @@ -175,7 +175,7 @@ export function registerHistoryHandlers(): void { handlerOpts('update'), async (entryId: string, updates: Partial, sessionId?: string) => { if (sessionId) { - const updated = historyManager.updateEntry(sessionId, entryId, updates); + const updated = await historyManager.updateEntry(sessionId, entryId, updates); if (updated) { logger.info(`Updated history entry: ${entryId} in session ${sessionId}`, LOG_CONTEXT, { updates, @@ -190,9 +190,9 @@ export function registerHistoryHandlers(): void { } // Search all sessions for the entry - const sessions = historyManager.listSessionsWithHistory(); + const sessions = await historyManager.listSessionsWithHistory(); for (const sid of sessions) { - if (historyManager.updateEntry(sid, entryId, updates)) { + if (await historyManager.updateEntry(sid, entryId, updates)) { logger.info(`Updated history entry: ${entryId} in session ${sid}`, LOG_CONTEXT, { updates, }); @@ -212,7 +212,7 @@ export function registerHistoryHandlers(): void { withIpcErrorLogging( handlerOpts('updateSessionName'), async (agentSessionId: string, sessionName: string) => { - const count = historyManager.updateSessionNameByClaudeSessionId( + const count = await historyManager.updateSessionNameByClaudeSessionId( agentSessionId, sessionName ); @@ -237,7 +237,7 @@ export function registerHistoryHandlers(): void { ipcMain.handle( 'history:listSessions', withIpcErrorLogging(handlerOpts('listSessions'), async () => { - return historyManager.listSessionsWithHistory(); + return await historyManager.listSessionsWithHistory(); }) ); } diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 6c499156c..51c238c52 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -373,7 +373,7 @@ export class WebServer { getTheme: () => this.callbackRegistry.getTheme(), writeToSession: (sessionId, data) => this.callbackRegistry.writeToSession(sessionId, data), interruptSession: async (sessionId) => this.callbackRegistry.interruptSession(sessionId), - getHistory: (projectPath, sessionId) => + getHistory: async (projectPath, sessionId) => this.callbackRegistry.getHistory(projectPath, sessionId), getLiveSessionInfo: (sessionId) => this.liveSessionManager.getLiveSessionInfo(sessionId), isSessionLive: (sessionId) => this.liveSessionManager.isSessionLive(sessionId), diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 33b395d78..2472f669e 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -128,7 +128,7 @@ export class CallbackRegistry { return this.callbacks.renameTab(sessionId, tabId, newName); } - getHistory(projectPath?: string, sessionId?: string): ReturnType | [] { + async getHistory(projectPath?: string, sessionId?: string): Promise { return this.callbacks.getHistory?.(projectPath, sessionId) ?? []; } diff --git a/src/main/web-server/routes/apiRoutes.ts b/src/main/web-server/routes/apiRoutes.ts index ee720781b..7a7f938e4 100644 --- a/src/main/web-server/routes/apiRoutes.ts +++ b/src/main/web-server/routes/apiRoutes.ts @@ -42,7 +42,7 @@ export interface ApiRouteCallbacks { getTheme: () => Theme | null; writeToSession: (sessionId: string, data: string) => boolean; interruptSession: (sessionId: string) => Promise; - getHistory: (projectPath?: string, sessionId?: string) => HistoryEntry[]; + getHistory: (projectPath?: string, sessionId?: string) => Promise; getLiveSessionInfo: (sessionId: string) => LiveSessionInfo | undefined; isSessionLive: (sessionId: string) => boolean; } @@ -324,7 +324,7 @@ export class ApiRoutes { }; try { - const entries = this.callbacks.getHistory(projectPath, sessionId); + const entries = await this.callbacks.getHistory(projectPath, sessionId); return { entries, count: entries.length, diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index fca19d10f..380abbe9a 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -294,7 +294,7 @@ export type GetCustomCommandsCallback = () => CustomAICommand[]; export type GetHistoryCallback = ( projectPath?: string, sessionId?: string -) => import('../../shared/types').HistoryEntry[]; +) => Promise; /** * Callback to get all connected web clients. diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 200874d33..e20d7d504 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -191,12 +191,12 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { // Set up callback for web server to fetch history entries // Uses HistoryManager for per-session storage - server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => { + server.setGetHistoryCallback(async (projectPath?: string, sessionId?: string) => { const historyManager = getHistoryManager(); if (sessionId) { // Get entries for specific session - const entries = historyManager.getEntries(sessionId); + const entries = await historyManager.getEntries(sessionId); // Sort by timestamp descending entries.sort((a, b) => b.timestamp - a.timestamp); return entries; @@ -204,11 +204,11 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { if (projectPath) { // Get all entries for sessions in this project - return historyManager.getEntriesByProjectPath(projectPath); + return await historyManager.getEntriesByProjectPath(projectPath); } // Return all entries (for global view) - return historyManager.getAllEntries(); + return await historyManager.getAllEntries(); }); // Set up callback for web server to write commands to sessions From 4ea02e8440cba56162dcdafa4f9f90fcce515f0c Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:25:48 -0500 Subject: [PATCH 08/42] MAESTRO: cache Codex parser config reads asynchronously --- .../main/parsers/codex-output-parser.test.ts | 120 ++++++++++- src/main/parsers/codex-output-parser.ts | 203 ++++++++++++++---- src/main/parsers/index.ts | 3 +- 3 files changed, 280 insertions(+), 46 deletions(-) diff --git a/src/__tests__/main/parsers/codex-output-parser.test.ts b/src/__tests__/main/parsers/codex-output-parser.test.ts index 3be98e486..091d600d5 100644 --- a/src/__tests__/main/parsers/codex-output-parser.test.ts +++ b/src/__tests__/main/parsers/codex-output-parser.test.ts @@ -1,5 +1,42 @@ -import { describe, it, expect } from 'vitest'; -import { CodexOutputParser } from '../../../main/parsers/codex-output-parser'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + CodexOutputParser, + invalidateCodexConfigCache, + loadCodexConfig, +} from '../../../main/parsers/codex-output-parser'; + +const originalCodexHome = process.env.CODEX_HOME; +let tempCodexHome: string | null = null; + +async function createTempCodexConfig(content: string): Promise { + const codexHome = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-codex-config-')); + await fs.writeFile(path.join(codexHome, 'config.toml'), content); + tempCodexHome = codexHome; + process.env.CODEX_HOME = codexHome; + return codexHome; +} + +async function cleanupTempCodexConfig(): Promise { + if (!tempCodexHome) { + return; + } + await fs.rm(tempCodexHome, { recursive: true, force: true }); + tempCodexHome = null; +} + +afterEach(async () => { + await cleanupTempCodexConfig(); + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + invalidateCodexConfigCache(); + vi.restoreAllMocks(); +}); describe('CodexOutputParser', () => { const parser = new CodexOutputParser(); @@ -633,4 +670,83 @@ describe('CodexOutputParser', () => { }); }); }); + + describe('codex config cache', () => { + it('loads config asynchronously and applies contextWindow from cache', async () => { + await createTempCodexConfig('model = "gpt-5.1"\nmodel_context_window = 123000'); + + const parser = new CodexOutputParser(); + await vi.waitFor(async () => { + const event = parser.parseJsonLine( + JSON.stringify({ + type: 'turn.completed', + usage: { + input_tokens: 100, + output_tokens: 20, + }, + }) + ); + expect(event?.usage?.contextWindow).toBe(123000); + }); + }); + + it('reads codex config from disk once and reuses cached value for additional parser instances', async () => { + await createTempCodexConfig('model = "gpt-5.1"\nmodel_context_window = 77777'); + const readFileSpy = vi.spyOn(fs, 'readFile'); + + const config = await loadCodexConfig(); + expect(config.contextWindow).toBe(77777); + + const parserA = new CodexOutputParser(); + const eventA = parserA.parseJsonLine( + JSON.stringify({ + type: 'turn.completed', + usage: { input_tokens: 100, output_tokens: 20 }, + }) + ); + expect(eventA?.usage?.contextWindow).toBe(77777); + + const parserB = new CodexOutputParser(); + const eventB = parserB.parseJsonLine( + JSON.stringify({ + type: 'turn.completed', + usage: { input_tokens: 100, output_tokens: 20 }, + }) + ); + expect(eventB?.usage?.contextWindow).toBe(77777); + expect(readFileSpy).toHaveBeenCalledTimes(1); + }); + + it('re-reads config after invalidation', async () => { + await createTempCodexConfig('model = "gpt-5.1"\nmodel_context_window = 55555'); + const parserA = new CodexOutputParser(); + await vi.waitFor(async () => { + const eventA = parserA.parseJsonLine( + JSON.stringify({ + type: 'turn.completed', + usage: { input_tokens: 100, output_tokens: 20 }, + }) + ); + expect(eventA?.usage?.contextWindow).toBe(55555); + }); + + await fs.writeFile( + path.join(tempCodexHome!, 'config.toml'), + 'model = "gpt-5.1"\nmodel_context_window = 88888' + ); + invalidateCodexConfigCache(); + await loadCodexConfig(); + + const parserB = new CodexOutputParser(); + await vi.waitFor(async () => { + const eventB = parserB.parseJsonLine( + JSON.stringify({ + type: 'turn.completed', + usage: { input_tokens: 100, output_tokens: 20 }, + }) + ); + expect(eventB?.usage?.contextWindow).toBe(88888); + }); + }); + }); }); diff --git a/src/main/parsers/codex-output-parser.ts b/src/main/parsers/codex-output-parser.ts index 35976d035..0a5feae7d 100644 --- a/src/main/parsers/codex-output-parser.ts +++ b/src/main/parsers/codex-output-parser.ts @@ -25,7 +25,7 @@ import type { ToolType, AgentError } from '../../shared/types'; import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; import { captureException } from '../utils/sentry'; import { getErrorPatterns, matchErrorPattern } from './error-patterns'; -import * as fs from 'fs'; +import * as fs from 'node:fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -69,6 +69,149 @@ const MODEL_CONTEXT_WINDOWS: Record = { default: 400000, }; +const DEFAULT_CODEX_MODEL = 'gpt-5.2-codex-max'; +const CODEX_CONFIG_CACHE_TTL_MS = 60_000; + +interface CodexConfig { + model?: string; + contextWindow?: number; +} + +interface CachedCodexConfig { + value: CodexConfig; + loadedAt: number; + configPath: string; +} + +let cachedCodexConfig: CachedCodexConfig | null = null; +let loadCodexConfigPromise: Promise | null = null; +let loadCodexConfigPath: string | null = null; +let configInvalidationTimer: ReturnType | null = null; + +function getDefaultCodexConfig(): CodexConfig { + return { + model: DEFAULT_CODEX_MODEL, + contextWindow: getModelContextWindow(DEFAULT_CODEX_MODEL), + }; +} + +function getCodexConfigPath(): string { + const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + return path.join(codexHome, 'config.toml'); +} + +function parseCodexConfigContent(content: string): CodexConfig { + const result: CodexConfig = {}; + + // Simple TOML parsing for the fields we care about + // model = "gpt-5.1" + const modelMatch = content.match(/^\s*model\s*=\s*"([^"]+)"/m); + if (modelMatch) { + result.model = modelMatch[1]; + } + + // model_context_window = 128000 + const windowMatch = content.match(/^\s*model_context_window\s*=\s*(\d+)/m); + if (windowMatch) { + result.contextWindow = parseInt(windowMatch[1], 10); + } + + return result; +} + +function hasValidCachedCodexConfig(configPath: string): boolean { + return ( + !!cachedCodexConfig && + cachedCodexConfig.configPath === configPath && + Date.now() - cachedCodexConfig.loadedAt < CODEX_CONFIG_CACHE_TTL_MS + ); +} + +function scheduleCodexConfigInvalidation(): void { + if (configInvalidationTimer) { + clearTimeout(configInvalidationTimer); + } + configInvalidationTimer = setTimeout(() => { + cachedCodexConfig = null; + }, CODEX_CONFIG_CACHE_TTL_MS); +} + +export function invalidateCodexConfigCache(): void { + cachedCodexConfig = null; + loadCodexConfigPromise = null; + loadCodexConfigPath = null; + if (configInvalidationTimer) { + clearTimeout(configInvalidationTimer); + configInvalidationTimer = null; + } +} + +export async function loadCodexConfig(): Promise { + const configPath = getCodexConfigPath(); + const requestedConfigPath = configPath; + + if (hasValidCachedCodexConfig(configPath) && cachedCodexConfig) { + return cachedCodexConfig.value; + } + + if (loadCodexConfigPromise && loadCodexConfigPath === configPath) { + return loadCodexConfigPromise; + } + + loadCodexConfigPath = configPath; + loadCodexConfigPromise = (async () => { + try { + await fs.access(configPath); + const content = await fs.readFile(configPath, 'utf8'); + const parsedConfig = parseCodexConfigContent(content); + if (loadCodexConfigPath === requestedConfigPath) { + cachedCodexConfig = { + value: parsedConfig, + loadedAt: Date.now(), + configPath, + }; + scheduleCodexConfigInvalidation(); + } + return parsedConfig; + } catch { + if (loadCodexConfigPath === requestedConfigPath) { + cachedCodexConfig = { + value: {}, + loadedAt: Date.now(), + configPath, + }; + scheduleCodexConfigInvalidation(); + } + return {}; + } + })(); + + try { + return await loadCodexConfigPromise; + } finally { + if (loadCodexConfigPath === configPath) { + loadCodexConfigPromise = null; + loadCodexConfigPath = null; + } + } +} + +function getCachedCodexConfigSnapshot(): CodexConfig { + const configPath = getCodexConfigPath(); + if (hasValidCachedCodexConfig(configPath) && cachedCodexConfig) { + return cachedCodexConfig.value; + } + return {}; +} + +function resolveCodexConfig(config: CodexConfig): { model: string; contextWindow: number } { + const model = config.model || DEFAULT_CODEX_MODEL; + return { + model, + contextWindow: config.contextWindow || getModelContextWindow(model), + }; +} + /** * Get the context window size for a given model */ @@ -86,42 +229,6 @@ function getModelContextWindow(model: string): number { return MODEL_CONTEXT_WINDOWS['default']; } -/** - * Read Codex configuration from ~/.codex/config.toml - * Returns the model name and context window override if set - */ -function readCodexConfig(): { model?: string; contextWindow?: number } { - try { - const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); - const configPath = path.join(codexHome, 'config.toml'); - - if (!fs.existsSync(configPath)) { - return {}; - } - - const content = fs.readFileSync(configPath, 'utf8'); - const result: { model?: string; contextWindow?: number } = {}; - - // Simple TOML parsing for the fields we care about - // model = "gpt-5.1" - const modelMatch = content.match(/^\s*model\s*=\s*"([^"]+)"/m); - if (modelMatch) { - result.model = modelMatch[1]; - } - - // model_context_window = 128000 - const windowMatch = content.match(/^\s*model_context_window\s*=\s*(\d+)/m); - if (windowMatch) { - result.contextWindow = parseInt(windowMatch[1], 10); - } - - return result; - } catch { - // Config file doesn't exist or can't be read - use defaults - return {}; - } -} - /** * Raw message structure from Codex JSON output * Based on verified Codex CLI v0.73.0+ output @@ -191,12 +298,22 @@ export class CodexOutputParser implements AgentOutputParser { private lastToolName: string | null = null; constructor() { - // Read config once at initialization - const config = readCodexConfig(); - this.model = config.model || 'gpt-5.2-codex-max'; - - // Priority: 1) explicit model_context_window in config, 2) lookup by model name - this.contextWindow = config.contextWindow || getModelContextWindow(this.model); + const initialConfig = resolveCodexConfig({ + ...getDefaultCodexConfig(), + ...getCachedCodexConfigSnapshot(), + }); + this.model = initialConfig.model; + this.contextWindow = initialConfig.contextWindow; + + // Refresh config asynchronously and cache it for parser instances. + void loadCodexConfig().then((freshConfig) => { + const resolvedConfig = resolveCodexConfig({ + ...getDefaultCodexConfig(), + ...freshConfig, + }); + this.model = resolvedConfig.model; + this.contextWindow = resolvedConfig.contextWindow; + }); } /** diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index 06e44e216..525656daf 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -52,7 +52,7 @@ export { // Import parser implementations import { ClaudeOutputParser } from './claude-output-parser'; import { OpenCodeOutputParser } from './opencode-output-parser'; -import { CodexOutputParser } from './codex-output-parser'; +import { CodexOutputParser, invalidateCodexConfigCache } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; import { registerOutputParser, @@ -76,6 +76,7 @@ const LOG_CONTEXT = '[OutputParsers]'; export function initializeOutputParsers(): void { // Clear any existing registrations (for testing/reloading) clearParserRegistry(); + invalidateCodexConfigCache(); // Register all parser implementations registerOutputParser(new ClaudeOutputParser()); From 143b3288e49337741eedfcd83ee233effd7889e4 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:35:57 -0500 Subject: [PATCH 09/42] MAESTRO: parallelize getDefaultBranch git fallback checks --- src/__tests__/main/ipc/handlers/git.test.ts | 91 +++++++++++++++++++++ src/main/ipc/handlers/git.ts | 9 +- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/__tests__/main/ipc/handlers/git.test.ts b/src/__tests__/main/ipc/handlers/git.test.ts index 9c5287b0c..9c389147e 100644 --- a/src/__tests__/main/ipc/handlers/git.test.ts +++ b/src/__tests__/main/ipc/handlers/git.test.ts @@ -11,6 +11,27 @@ import { registerGitHandlers } from '../../../../main/ipc/handlers/git'; import * as execFile from '../../../../main/utils/execFile'; import path from 'path'; +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +const createDeferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { + promise, + resolve, + reject, + }; +}; + // Mock electron's ipcMain vi.mock('electron', () => ({ ipcMain: { @@ -3105,6 +3126,76 @@ export function Component() { }); }); + it('should check local branches in parallel when remote branch info is unavailable', async () => { + const callOrder: string[] = []; + const mainDeferred = createDeferred<{ + stdout: string; + stderr: string; + exitCode: number; + }>(); + const masterDeferred = createDeferred<{ + stdout: string; + stderr: string; + exitCode: number; + }>(); + + vi.mocked(execFile.execFileNoThrow).mockImplementation(async (_cmd: string, args?: string[]) => { + if (args?.includes('show')) { + callOrder.push('remote'); + return { + stdout: `* remote origin + Fetch URL: git@github.com:user/repo.git + Push URL: git@github.com:user/repo.git + Remote branches: + feature tracked`, + stderr: '', + exitCode: 0, + }; + } + + if (args?.includes('--verify') && args?.includes('main')) { + callOrder.push('main'); + return mainDeferred.promise; + } + + if (args?.includes('--verify') && args?.includes('master')) { + callOrder.push('master'); + return masterDeferred.promise; + } + + return { + stdout: '', + stderr: `Unexpected command: ${args?.join(' ')}`, + exitCode: 1, + }; + }); + + const handler = handlers.get('git:getDefaultBranch'); + const handlerPromise = handler!({} as any, '/test/repo'); + + await Promise.resolve(); + + expect(callOrder).toEqual(['remote', 'main', 'master']); + + mainDeferred.resolve({ + stdout: '', + stderr: 'fatal: Needed a single revision', + exitCode: 128, + }); + masterDeferred.resolve({ + stdout: 'abc123def456\n', + stderr: '', + exitCode: 0, + }); + + const result = await handlerPromise; + + expect(result).toEqual({ + success: true, + branch: 'master', + }); + }); + it('should fallback to main branch when remote check fails but main exists locally', async () => { vi.mocked(execFile.execFileNoThrow) .mockResolvedValueOnce({ diff --git a/src/main/ipc/handlers/git.ts b/src/main/ipc/handlers/git.ts index 80c4036fd..de2141267 100644 --- a/src/main/ipc/handlers/git.ts +++ b/src/main/ipc/handlers/git.ts @@ -924,13 +924,16 @@ export function registerGitHandlers(deps: GitHandlerDependencies): void { } } - // Fallback: check if main or master exists locally - const mainResult = await execFileNoThrow('git', ['rev-parse', '--verify', 'main'], cwd); + // Fallback: check if main or master exists locally in parallel + const [mainResult, masterResult] = await Promise.all([ + execFileNoThrow('git', ['rev-parse', '--verify', 'main'], cwd), + execFileNoThrow('git', ['rev-parse', '--verify', 'master'], cwd), + ]); + if (mainResult.exitCode === 0) { return { branch: 'main' }; } - const masterResult = await execFileNoThrow('git', ['rev-parse', '--verify', 'master'], cwd); if (masterResult.exitCode === 0) { return { branch: 'master' }; } From dc668eebf6f962b9f659c3ece36149674b0ea5b3 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:37:19 -0500 Subject: [PATCH 10/42] MAESTRO: optimize queryBySource aggregation for compound index --- src/main/stats/aggregations.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/main/stats/aggregations.ts b/src/main/stats/aggregations.ts index 68c2ddfdf..5e6cc62df 100644 --- a/src/main/stats/aggregations.ts +++ b/src/main/stats/aggregations.ts @@ -41,9 +41,16 @@ function queryByAgent( const rows = db .prepare( ` - SELECT agent_type, COUNT(*) as count, SUM(duration) as duration - FROM query_events - WHERE start_time >= ? + SELECT agent_type, SUM(query_count) as count, SUM(query_duration) as duration + FROM ( + SELECT start_time, + agent_type, + COUNT(*) as query_count, + SUM(duration) as query_duration + FROM query_events + WHERE start_time >= ? + GROUP BY start_time, agent_type + ) AS by_time GROUP BY agent_type ` ) @@ -62,9 +69,15 @@ function queryBySource(db: Database.Database, startTime: number): { user: number const rows = db .prepare( ` - SELECT source, COUNT(*) as count - FROM query_events - WHERE start_time >= ? + SELECT source, SUM(query_count) as count + FROM ( + SELECT start_time, + source, + COUNT(*) as query_count + FROM query_events + WHERE start_time >= ? + GROUP BY start_time, source + ) AS by_time GROUP BY source ` ) From 461d6fe719367a30f9c04ceb47f2aeb547e7421e Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 1 Mar 2026 15:39:07 -0500 Subject: [PATCH 11/42] MAESTRO: Optimize session row selection prop passing --- src/renderer/components/AgentSessionsBrowser.tsx | 3 +-- src/renderer/components/SessionListItem.tsx | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 998e49445..d71b6b6b4 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -1480,8 +1480,7 @@ export function AgentSessionsBrowser({ Date: Sun, 1 Mar 2026 15:58:35 -0500 Subject: [PATCH 12/42] chore: checkpoint local changes before gup --- .../group-chat.integration.test.ts | 2 +- .../main/debug-package/collectors.test.ts | 17 +- src/__tests__/main/history-manager.test.ts | 234 +-- .../main/ipc/handlers/agentSessions.test.ts | 163 ++- .../main/ipc/handlers/director-notes.test.ts | 86 +- .../main/ipc/handlers/history.test.ts | 80 +- .../spawners/ChildProcessSpawner.test.ts | 130 +- src/__tests__/main/stats/aggregations.test.ts | 170 ++- src/__tests__/main/stats/auto-run.test.ts | 100 +- .../main/stats/data-management.test.ts | 112 +- src/__tests__/main/stats/integration.test.ts | 60 +- src/__tests__/main/stats/paths.test.ts | 84 +- src/__tests__/main/stats/query-events.test.ts | 70 +- src/__tests__/main/stats/stats-db.test.ts | 121 +- .../web-server/web-server-factory.test.ts | 26 +- .../debug-package/collectors/group-chats.ts | 72 +- src/main/debug-package/collectors/storage.ts | 33 +- src/main/group-chat/group-chat-agent.ts | 2 +- src/main/group-chat/group-chat-moderator.ts | 2 +- src/main/group-chat/group-chat-router.ts | 8 +- src/main/index.ts | 4 +- src/main/ipc/handlers/agentSessions.ts | 174 ++- src/main/ipc/handlers/groupChat.ts | 2 +- src/main/ipc/handlers/process.ts | 2 +- src/main/ipc/handlers/tabNaming.ts | 2 +- src/main/process-manager/ProcessManager.ts | 6 +- .../spawners/ChildProcessSpawner.ts | 12 +- .../process-manager/spawners/PtySpawner.ts | 2 +- src/main/process-manager/utils/imageUtils.ts | 5 +- src/main/stats/singleton.ts | 4 +- src/main/stats/stats-db.ts | 75 +- src/main/utils/context-groomer.ts | 4 +- src/main/utils/wslDetector.ts | 16 +- src/renderer/components/AICommandsPanel.tsx | 391 ++--- .../components/AgentSessionsBrowser.tsx | 14 +- src/renderer/components/CsvTableRenderer.tsx | 31 +- .../components/ExecutionQueueIndicator.tsx | 62 +- src/renderer/components/SessionList.tsx | 1267 ++++++++++------- src/renderer/components/SessionListItem.tsx | 3 +- 39 files changed, 2234 insertions(+), 1414 deletions(-) diff --git a/src/__tests__/integration/group-chat.integration.test.ts b/src/__tests__/integration/group-chat.integration.test.ts index 9062631a7..8adca9d2e 100644 --- a/src/__tests__/integration/group-chat.integration.test.ts +++ b/src/__tests__/integration/group-chat.integration.test.ts @@ -70,7 +70,7 @@ function createMockProcessManager(): IProcessManager & { toolType: config.toolType, prompt: config.prompt, }); - return { pid: Math.floor(Math.random() * 10000), success: true }; + return Promise.resolve({ pid: Math.floor(Math.random() * 10000), success: true }); }, write(sessionId: string, data: string) { diff --git a/src/__tests__/main/debug-package/collectors.test.ts b/src/__tests__/main/debug-package/collectors.test.ts index 73d2fa554..a9c13ac46 100644 --- a/src/__tests__/main/debug-package/collectors.test.ts +++ b/src/__tests__/main/debug-package/collectors.test.ts @@ -38,6 +38,11 @@ vi.mock('fs', () => ({ statSync: vi.fn(() => ({ size: 0, isDirectory: () => false })), readdirSync: vi.fn(() => []), readFileSync: vi.fn(() => ''), + promises: { + stat: vi.fn(() => Promise.resolve({ size: 0, isDirectory: () => false })), + readdir: vi.fn(() => Promise.resolve([])), + readFile: vi.fn(() => Promise.resolve('')), + }, })); // Mock cliDetection @@ -817,14 +822,13 @@ describe('Debug Package Collectors', () => { const { app } = await import('electron'); vi.mocked(app.getPath).mockReturnValue('/mock/userData'); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.statSync).mockImplementation((path: any) => { + vi.mocked(fs.promises.stat).mockImplementation(async (path: any) => { if (path.includes('maestro-sessions.json')) { return { size: 1024, isDirectory: () => false } as any; } return { size: 0, isDirectory: () => true } as any; }); - vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.promises.readdir).mockResolvedValue([]); const { collectStorage } = await import('../../../main/debug-package/collectors/storage'); @@ -867,13 +871,12 @@ describe('Debug Package Collectors', () => { const { app } = await import('electron'); vi.mocked(app.getPath).mockReturnValue('/mock/userData'); - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readdirSync).mockReturnValue([ + vi.mocked(fs.promises.readdir).mockResolvedValue([ 'chat-1.json', 'chat-1.log.json', 'chat-2.json', ] as any); - vi.mocked(fs.readFileSync).mockImplementation((path: any) => { + vi.mocked(fs.promises.readFile).mockImplementation(async (path: any) => { if (path.includes('chat-1.json') && !path.includes('.log')) { return JSON.stringify({ id: 'chat-1', @@ -930,7 +933,7 @@ describe('Debug Package Collectors', () => { it('should handle missing group chats directory', async () => { const fs = await import('fs'); - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.promises.readdir).mockRejectedValue(new Error('Directory does not exist')); const { collectGroupChats } = await import('../../../main/debug-package/collectors/group-chats'); diff --git a/src/__tests__/main/history-manager.test.ts b/src/__tests__/main/history-manager.test.ts index a6709a757..da34bfc25 100644 --- a/src/__tests__/main/history-manager.test.ts +++ b/src/__tests__/main/history-manager.test.ts @@ -35,6 +35,14 @@ vi.mock('fs', () => ({ readdirSync: vi.fn(), unlinkSync: vi.fn(), watch: vi.fn(), + promises: { + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + readdir: vi.fn(), + unlink: vi.fn(), + mkdir: vi.fn(), + }, })); import * as fs from 'fs'; @@ -52,6 +60,12 @@ const mockWriteFileSync = vi.mocked(fs.writeFileSync); const mockReaddirSync = vi.mocked(fs.readdirSync); const mockUnlinkSync = vi.mocked(fs.unlinkSync); const mockWatch = vi.mocked(fs.watch); +const mockFsAccess = vi.mocked(fs.promises.access); +const mockFsReadFile = vi.mocked(fs.promises.readFile); +const mockFsWriteFile = vi.mocked(fs.promises.writeFile); +const mockFsReaddir = vi.mocked(fs.promises.readdir); +const mockFsUnlink = vi.mocked(fs.promises.unlink); +const mockFsMkdir = vi.mocked(fs.promises.mkdir); /** * Helper to create a mock HistoryEntry @@ -91,6 +105,28 @@ describe('HistoryManager', () => { vi.resetAllMocks(); // Default: nothing exists mockExistsSync.mockReturnValue(false); + mockMkdirSync.mockClear(); + mockReadFileSync.mockReturnValue('{}'); + mockReaddirSync.mockReturnValue([]); + mockWriteFileSync.mockImplementation(() => undefined); + mockUnlinkSync.mockImplementation(() => undefined); + mockWatch.mockImplementation(() => ({ close: vi.fn() }) as unknown as fs.FSWatcher); + + mockFsAccess.mockImplementation(async (p: fs.PathLike) => { + if (mockExistsSync(p)) { + return; + } + throw new Error('ENOENT'); + }); + mockFsReadFile.mockImplementation(async (p: fs.PathLike) => { + return mockReadFileSync(p); + }); + mockFsWriteFile.mockImplementation(async (pathLike, data, options) => { + return mockWriteFileSync(pathLike as string | Buffer | number, data as string, options as string); + }); + mockFsReaddir.mockImplementation(async () => mockReaddirSync() as unknown as fs.Dirent[]); + mockFsUnlink.mockResolvedValue(undefined); + mockFsMkdir.mockResolvedValue(undefined); manager = new HistoryManager(); }); @@ -102,7 +138,7 @@ describe('HistoryManager', () => { // Constructor // ---------------------------------------------------------------- describe('constructor', () => { - it('should set up paths based on app.getPath("userData")', () => { + it('should run async path', async () => { expect(app.getPath).toHaveBeenCalledWith('userData'); expect(manager.getHistoryDir()).toBe(path.join('/mock/userData', 'history')); expect(manager.getLegacyFilePath()).toBe(path.join('/mock/userData', 'maestro-history.json')); @@ -245,14 +281,14 @@ describe('HistoryManager', () => { // hasMigrated() // ---------------------------------------------------------------- describe('hasMigrated()', () => { - it('should return true when migration marker exists', () => { + it('should run async path', async () => { mockExistsSync.mockImplementation((p: fs.PathLike) => { return p.toString().endsWith('history-migrated.json'); }); expect(manager.hasMigrated()).toBe(true); }); - it('should return false when migration marker does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); expect(manager.hasMigrated()).toBe(false); }); @@ -412,7 +448,7 @@ describe('HistoryManager', () => { // getEntries(sessionId) // ---------------------------------------------------------------- describe('getEntries()', () => { - it('should return entries from session file', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' }), createMockEntry({ id: 'e2' })]; const filePath = path.join( '/mock/userData', @@ -423,18 +459,18 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - const result = manager.getEntries('session-1'); + const result = await manager.getEntries('session-1'); expect(result).toHaveLength(2); expect(result[0].id).toBe('e1'); }); - it('should return empty array if session file does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - const result = manager.getEntries('nonexistent'); + const result = await manager.getEntries('nonexistent'); expect(result).toEqual([]); }); - it('should return empty array on read error', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -445,12 +481,12 @@ describe('HistoryManager', () => { throw new Error('Read error'); }); - const result = manager.getEntries('session-1'); + const result = await manager.getEntries('session-1'); expect(result).toEqual([]); expect(vi.mocked(logger.warn)).toHaveBeenCalled(); }); - it('should return empty array when file contains malformed JSON', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -459,7 +495,7 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue('not valid json'); - const result = manager.getEntries('session-1'); + const result = await manager.getEntries('session-1'); expect(result).toEqual([]); }); }); @@ -468,11 +504,11 @@ describe('HistoryManager', () => { // addEntry(sessionId, projectPath, entry) // ---------------------------------------------------------------- describe('addEntry()', () => { - it('should create a new file when session does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); const entry = createMockEntry({ id: 'new-entry' }); - manager.addEntry('session-1', '/test/project', entry); + await manager.addEntry('session-1', '/test/project', entry); expect(mockWriteFileSync).toHaveBeenCalledTimes(1); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); @@ -483,7 +519,7 @@ describe('HistoryManager', () => { expect(written.version).toBe(HISTORY_VERSION); }); - it('should prepend entry to beginning of existing file', () => { + it('should run async path', async () => { const existingEntry = createMockEntry({ id: 'old' }); const filePath = path.join( '/mock/userData', @@ -495,7 +531,7 @@ describe('HistoryManager', () => { mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', [existingEntry])); const newEntry = createMockEntry({ id: 'new' }); - manager.addEntry('session-1', '/test/project', newEntry); + await manager.addEntry('session-1', '/test/project', newEntry); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); expect(written.entries).toHaveLength(2); @@ -503,7 +539,7 @@ describe('HistoryManager', () => { expect(written.entries[1].id).toBe('old'); }); - it('should trim to MAX_ENTRIES_PER_SESSION', () => { + it('should run async path', async () => { const existingEntries: HistoryEntry[] = []; for (let i = 0; i < MAX_ENTRIES_PER_SESSION; i++) { existingEntries.push(createMockEntry({ id: `e-${i}` })); @@ -518,14 +554,14 @@ describe('HistoryManager', () => { mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', existingEntries)); const newEntry = createMockEntry({ id: 'overflow' }); - manager.addEntry('session-1', '/test/project', newEntry); + await manager.addEntry('session-1', '/test/project', newEntry); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); expect(written.entries).toHaveLength(MAX_ENTRIES_PER_SESSION); expect(written.entries[0].id).toBe('overflow'); }); - it('should update projectPath on existing file', () => { + it('should run async path', async () => { const existingEntry = createMockEntry({ id: 'e1' }); const filePath = path.join( '/mock/userData', @@ -539,13 +575,13 @@ describe('HistoryManager', () => { ); const newEntry = createMockEntry({ id: 'e2' }); - manager.addEntry('session-1', '/new/path', newEntry); + await manager.addEntry('session-1', '/new/path', newEntry); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); expect(written.projectPath).toBe('/new/path'); }); - it('should create fresh data when existing file is corrupted', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -555,14 +591,14 @@ describe('HistoryManager', () => { mockReadFileSync.mockReturnValue('corrupted-json{{{'); const entry = createMockEntry({ id: 'new-entry' }); - manager.addEntry('session-1', '/test/project', entry); + await manager.addEntry('session-1', '/test/project', entry); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); expect(written.entries).toHaveLength(1); expect(written.entries[0].id).toBe('new-entry'); }); - it('should log error on write failure', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); mockWriteFileSync.mockImplementation(() => { throw new Error('Write error'); @@ -570,7 +606,7 @@ describe('HistoryManager', () => { const entry = createMockEntry({ id: 'e1' }); // Should not throw - manager.addEntry('session-1', '/test/project', entry); + await manager.addEntry('session-1', '/test/project', entry); expect(vi.mocked(logger.error)).toHaveBeenCalledWith( expect.stringContaining('Failed to write history'), @@ -583,7 +619,7 @@ describe('HistoryManager', () => { // deleteEntry(sessionId, entryId) // ---------------------------------------------------------------- describe('deleteEntry()', () => { - it('should remove an entry by id and return true', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' }), createMockEntry({ id: 'e2' })]; const filePath = path.join( '/mock/userData', @@ -594,7 +630,7 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - const result = manager.deleteEntry('session-1', 'e1'); + const result = await manager.deleteEntry('session-1', 'e1'); expect(result).toBe(true); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); @@ -602,12 +638,12 @@ describe('HistoryManager', () => { expect(written.entries[0].id).toBe('e2'); }); - it('should return false if session file does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - expect(manager.deleteEntry('nonexistent', 'e1')).toBe(false); + expect(await manager.deleteEntry('nonexistent', 'e1')).toBe(false); }); - it('should return false if entry is not found', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' })]; const filePath = path.join( '/mock/userData', @@ -618,11 +654,11 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - expect(manager.deleteEntry('session-1', 'nonexistent')).toBe(false); + expect(await manager.deleteEntry('session-1', 'nonexistent')).toBe(false); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('should return false on read error (parse failure)', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -631,10 +667,10 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue('bad json'); - expect(manager.deleteEntry('session-1', 'e1')).toBe(false); + expect(await manager.deleteEntry('session-1', 'e1')).toBe(false); }); - it('should return false on write error', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' })]; const filePath = path.join( '/mock/userData', @@ -648,7 +684,7 @@ describe('HistoryManager', () => { throw new Error('Write error'); }); - expect(manager.deleteEntry('session-1', 'e1')).toBe(false); + expect(await manager.deleteEntry('session-1', 'e1')).toBe(false); expect(vi.mocked(logger.error)).toHaveBeenCalled(); }); }); @@ -657,7 +693,7 @@ describe('HistoryManager', () => { // updateEntry(sessionId, entryId, updates) // ---------------------------------------------------------------- describe('updateEntry()', () => { - it('should update an entry by id and return true', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1', summary: 'original' })]; const filePath = path.join( '/mock/userData', @@ -668,7 +704,7 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - const result = manager.updateEntry('session-1', 'e1', { summary: 'updated' }); + const result = await manager.updateEntry('session-1', 'e1', { summary: 'updated' }); expect(result).toBe(true); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); @@ -676,12 +712,12 @@ describe('HistoryManager', () => { expect(written.entries[0].id).toBe('e1'); }); - it('should return false if session file does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - expect(manager.updateEntry('nonexistent', 'e1', { summary: 'x' })).toBe(false); + expect(await manager.updateEntry('nonexistent', 'e1', { summary: 'x' })).toBe(false); }); - it('should return false if entry is not found', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' })]; const filePath = path.join( '/mock/userData', @@ -692,11 +728,11 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - expect(manager.updateEntry('session-1', 'nonexistent', { summary: 'x' })).toBe(false); + expect(await manager.updateEntry('session-1', 'nonexistent', { summary: 'x' })).toBe(false); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('should return false on parse error', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -705,10 +741,10 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue('bad json'); - expect(manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); + expect(await manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); }); - it('should return false on write error', () => { + it('should run async path', async () => { const entries = [createMockEntry({ id: 'e1' })]; const filePath = path.join( '/mock/userData', @@ -722,7 +758,7 @@ describe('HistoryManager', () => { throw new Error('Write error'); }); - expect(manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); + expect(await manager.updateEntry('session-1', 'e1', { summary: 'x' })).toBe(false); expect(vi.mocked(logger.error)).toHaveBeenCalled(); }); }); @@ -731,7 +767,7 @@ describe('HistoryManager', () => { // clearSession(sessionId) // ---------------------------------------------------------------- describe('clearSession()', () => { - it('should delete the session file if it exists', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -744,7 +780,7 @@ describe('HistoryManager', () => { expect(mockUnlinkSync).toHaveBeenCalledWith(filePath); }); - it('should do nothing if session file does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); manager.clearSession('nonexistent'); @@ -752,7 +788,7 @@ describe('HistoryManager', () => { expect(mockUnlinkSync).not.toHaveBeenCalled(); }); - it('should log error on delete failure', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -776,7 +812,7 @@ describe('HistoryManager', () => { // listSessionsWithHistory() // ---------------------------------------------------------------- describe('listSessionsWithHistory()', () => { - it('should return session IDs from .json files in history dir', () => { + it('should run async path', async () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString().endsWith('history')); mockReaddirSync.mockReturnValue([ 'session_1.json' as unknown as fs.Dirent, @@ -784,13 +820,13 @@ describe('HistoryManager', () => { 'readme.txt' as unknown as fs.Dirent, ]); - const result = manager.listSessionsWithHistory(); + const result = await manager.listSessionsWithHistory(); expect(result).toEqual(['session_1', 'session_2']); }); - it('should return empty array if history directory does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - expect(manager.listSessionsWithHistory()).toEqual([]); + expect(await manager.listSessionsWithHistory()).toEqual([]); }); }); @@ -798,7 +834,7 @@ describe('HistoryManager', () => { // getHistoryFilePath(sessionId) // ---------------------------------------------------------------- describe('getHistoryFilePath()', () => { - it('should return file path if session file exists', () => { + it('should run async path', async () => { const filePath = path.join( '/mock/userData', 'history', @@ -809,7 +845,7 @@ describe('HistoryManager', () => { expect(manager.getHistoryFilePath('session-1')).toBe(filePath); }); - it('should return null if session file does not exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); expect(manager.getHistoryFilePath('nonexistent')).toBeNull(); }); @@ -819,7 +855,7 @@ describe('HistoryManager', () => { // getAllEntries(limit?) // ---------------------------------------------------------------- describe('getAllEntries()', () => { - it('should aggregate entries across all sessions sorted by timestamp', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue([ 'sess_a.json' as unknown as fs.Dirent, @@ -840,14 +876,14 @@ describe('HistoryManager', () => { return '{}'; }); - const result = manager.getAllEntries(); + const result = await manager.getAllEntries(); expect(result).toHaveLength(2); // Sorted descending: 200, 100 expect(result[0].id).toBe('b1'); expect(result[1].id).toBe('a1'); }); - it('should respect limit parameter', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); @@ -858,15 +894,15 @@ describe('HistoryManager', () => { ]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); - const result = manager.getAllEntries(2); + const result = await manager.getAllEntries(2); expect(result).toHaveLength(2); expect(result[0].id).toBe('e1'); expect(result[1].id).toBe('e2'); }); - it('should return empty array when no sessions exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - expect(manager.getAllEntries()).toEqual([]); + expect(await manager.getAllEntries()).toEqual([]); }); }); @@ -874,7 +910,7 @@ describe('HistoryManager', () => { // getAllEntriesPaginated(options?) // ---------------------------------------------------------------- describe('getAllEntriesPaginated()', () => { - it('should return paginated results with metadata', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); @@ -885,7 +921,7 @@ describe('HistoryManager', () => { ]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); - const result = manager.getAllEntriesPaginated({ limit: 2, offset: 0 }); + const result = await manager.getAllEntriesPaginated({ limit: 2, offset: 0 }); expect(result.entries).toHaveLength(2); expect(result.total).toBe(3); expect(result.limit).toBe(2); @@ -893,12 +929,12 @@ describe('HistoryManager', () => { expect(result.hasMore).toBe(true); }); - it('should handle offset beyond total entries', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', [createMockEntry()])); - const result = manager.getAllEntriesPaginated({ limit: 10, offset: 100 }); + const result = await manager.getAllEntriesPaginated({ limit: 10, offset: 100 }); expect(result.entries).toHaveLength(0); expect(result.total).toBe(1); expect(result.hasMore).toBe(false); @@ -909,7 +945,7 @@ describe('HistoryManager', () => { // getEntriesByProjectPath(projectPath) // ---------------------------------------------------------------- describe('getEntriesByProjectPath()', () => { - it('should return entries matching project path', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue([ 'sess_a.json' as unknown as fs.Dirent, @@ -938,19 +974,19 @@ describe('HistoryManager', () => { return '{}'; }); - const result = manager.getEntriesByProjectPath('/project/alpha'); + const result = await manager.getEntriesByProjectPath('/project/alpha'); expect(result).toHaveLength(1); expect(result[0].id).toBe('a1'); }); - it('should return empty array when no matching sessions exist', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); const entry = createMockEntry({ projectPath: '/other/path' }); mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', [entry], '/other/path')); - const result = manager.getEntriesByProjectPath('/no/match'); + const result = await manager.getEntriesByProjectPath('/no/match'); expect(result).toEqual([]); }); }); @@ -959,7 +995,7 @@ describe('HistoryManager', () => { // getEntriesByProjectPathPaginated(projectPath, options?) // ---------------------------------------------------------------- describe('getEntriesByProjectPathPaginated()', () => { - it('should return paginated results filtered by project path', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); @@ -970,7 +1006,7 @@ describe('HistoryManager', () => { ]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries, '/proj')); - const result = manager.getEntriesByProjectPathPaginated('/proj', { + const result = await manager.getEntriesByProjectPathPaginated('/proj', { limit: 2, offset: 0, }); @@ -984,7 +1020,7 @@ describe('HistoryManager', () => { // getEntriesPaginated(sessionId, options?) // ---------------------------------------------------------------- describe('getEntriesPaginated()', () => { - it('should return paginated results for a single session', () => { + it('should run async path', async () => { const entries = [ createMockEntry({ id: 'e1' }), createMockEntry({ id: 'e2' }), @@ -999,17 +1035,17 @@ describe('HistoryManager', () => { mockExistsSync.mockImplementation((p: fs.PathLike) => p.toString() === filePath); mockReadFileSync.mockReturnValue(createHistoryFileData('session-1', entries)); - const result = manager.getEntriesPaginated('session-1', { limit: 2, offset: 1 }); + const result = await manager.getEntriesPaginated('session-1', { limit: 2, offset: 1 }); expect(result.entries).toHaveLength(2); expect(result.total).toBe(3); expect(result.offset).toBe(1); expect(result.hasMore).toBe(false); }); - it('should return empty paginated result for nonexistent session', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); - const result = manager.getEntriesPaginated('nonexistent'); + const result = await manager.getEntriesPaginated('nonexistent'); expect(result.entries).toEqual([]); expect(result.total).toBe(0); }); @@ -1019,7 +1055,7 @@ describe('HistoryManager', () => { // updateSessionNameByClaudeSessionId(agentSessionId, sessionName) // ---------------------------------------------------------------- describe('updateSessionNameByClaudeSessionId()', () => { - it('should update sessionName for matching entries and return count', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); @@ -1042,7 +1078,7 @@ describe('HistoryManager', () => { ]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); - const count = manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); + const count = await manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); expect(count).toBe(2); const written = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); @@ -1051,19 +1087,19 @@ describe('HistoryManager', () => { expect(written.entries[2].sessionName).toBe('other'); }); - it('should return 0 when no entries match', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); const entries = [createMockEntry({ id: 'e1', agentSessionId: 'agent-999' })]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); - const count = manager.updateSessionNameByClaudeSessionId('no-match', 'new-name'); + const count = await manager.updateSessionNameByClaudeSessionId('no-match', 'new-name'); expect(count).toBe(0); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('should not update entries that already have the correct sessionName', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); @@ -1076,19 +1112,19 @@ describe('HistoryManager', () => { ]; mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', entries)); - const count = manager.updateSessionNameByClaudeSessionId('agent-123', 'already-correct'); + const count = await manager.updateSessionNameByClaudeSessionId('agent-123', 'already-correct'); expect(count).toBe(0); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); - it('should handle read errors gracefully', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); mockReadFileSync.mockImplementation(() => { throw new Error('Read error'); }); - const count = manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); + const count = await manager.updateSessionNameByClaudeSessionId('agent-123', 'new-name'); expect(count).toBe(0); expect(vi.mocked(logger.warn)).toHaveBeenCalled(); }); @@ -1098,7 +1134,7 @@ describe('HistoryManager', () => { // clearByProjectPath(projectPath) // ---------------------------------------------------------------- describe('clearByProjectPath()', () => { - it('should clear sessions matching the project path', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue([ 'sess_a.json' as unknown as fs.Dirent, @@ -1119,21 +1155,21 @@ describe('HistoryManager', () => { return '{}'; }); - manager.clearByProjectPath('/target/project'); + await manager.clearByProjectPath('/target/project'); // Should only unlink sess_a expect(mockUnlinkSync).toHaveBeenCalledTimes(1); expect(mockUnlinkSync.mock.calls[0][0].toString()).toContain('sess_a.json'); }); - it('should do nothing when no sessions match', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue(['sess_a.json' as unknown as fs.Dirent]); const entry = createMockEntry({ projectPath: '/other' }); mockReadFileSync.mockReturnValue(createHistoryFileData('sess_a', [entry], '/other')); - manager.clearByProjectPath('/no/match'); + await manager.clearByProjectPath('/no/match'); expect(mockUnlinkSync).not.toHaveBeenCalled(); }); }); @@ -1142,7 +1178,7 @@ describe('HistoryManager', () => { // clearAll() // ---------------------------------------------------------------- describe('clearAll()', () => { - it('should clear all session files', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue([ 'sess_a.json' as unknown as fs.Dirent, @@ -1155,7 +1191,7 @@ describe('HistoryManager', () => { expect(mockUnlinkSync).toHaveBeenCalledTimes(3); }); - it('should handle empty history directory', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(true); mockReaddirSync.mockReturnValue([]); @@ -1169,7 +1205,7 @@ describe('HistoryManager', () => { // startWatching / stopWatching // ---------------------------------------------------------------- describe('startWatching() / stopWatching()', () => { - it('should start watching history directory for changes', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; mockWatch.mockReturnValue(mockWatcher); mockExistsSync.mockReturnValue(true); @@ -1183,7 +1219,7 @@ describe('HistoryManager', () => { ); }); - it('should create directory if it does not exist before watching', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; mockWatch.mockReturnValue(mockWatcher); mockExistsSync.mockReturnValue(false); @@ -1195,7 +1231,7 @@ describe('HistoryManager', () => { }); }); - it('should invoke callback when a .json file changes', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; let watchCallback: (event: string, filename: string | null) => void = () => {}; mockWatch.mockImplementation((_dir: string, cb: unknown) => { @@ -1213,7 +1249,7 @@ describe('HistoryManager', () => { expect(callback).toHaveBeenCalledWith('session_1'); }); - it('should not invoke callback for non-json files', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; let watchCallback: (event: string, filename: string | null) => void = () => {}; mockWatch.mockImplementation((_dir: string, cb: unknown) => { @@ -1229,7 +1265,7 @@ describe('HistoryManager', () => { expect(callback).not.toHaveBeenCalled(); }); - it('should not invoke callback when filename is null', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; let watchCallback: (event: string, filename: string | null) => void = () => {}; mockWatch.mockImplementation((_dir: string, cb: unknown) => { @@ -1245,7 +1281,7 @@ describe('HistoryManager', () => { expect(callback).not.toHaveBeenCalled(); }); - it('should not start watching again if already watching', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; mockWatch.mockReturnValue(mockWatcher); mockExistsSync.mockReturnValue(true); @@ -1256,7 +1292,7 @@ describe('HistoryManager', () => { expect(mockWatch).toHaveBeenCalledTimes(1); }); - it('should stop watching and close watcher', () => { + it('should run async path', async () => { const mockWatcher = { close: vi.fn() } as unknown as fs.FSWatcher; mockWatch.mockReturnValue(mockWatcher); mockExistsSync.mockReturnValue(true); @@ -1267,7 +1303,7 @@ describe('HistoryManager', () => { expect(mockWatcher.close).toHaveBeenCalled(); }); - it('should allow re-watching after stop', () => { + it('should run async path', async () => { const mockWatcher1 = { close: vi.fn() } as unknown as fs.FSWatcher; const mockWatcher2 = { close: vi.fn() } as unknown as fs.FSWatcher; mockWatch.mockReturnValueOnce(mockWatcher1).mockReturnValueOnce(mockWatcher2); @@ -1280,7 +1316,7 @@ describe('HistoryManager', () => { expect(mockWatch).toHaveBeenCalledTimes(2); }); - it('should be safe to call stopWatching when not watching', () => { + it('should run async path', async () => { // Should not throw expect(() => manager.stopWatching()).not.toThrow(); }); @@ -1290,12 +1326,12 @@ describe('HistoryManager', () => { // getHistoryManager() singleton // ---------------------------------------------------------------- describe('getHistoryManager()', () => { - it('should return a HistoryManager instance', () => { + it('should run async path', async () => { const instance = getHistoryManager(); expect(instance).toBeInstanceOf(HistoryManager); }); - it('should return the same instance on subsequent calls', () => { + it('should run async path', async () => { const instance1 = getHistoryManager(); const instance2 = getHistoryManager(); expect(instance1).toBe(instance2); @@ -1306,11 +1342,11 @@ describe('HistoryManager', () => { // sanitizeSessionId integration (uses real shared function) // ---------------------------------------------------------------- describe('session ID sanitization', () => { - it('should sanitize session IDs with special characters for file paths', () => { + it('should run async path', async () => { mockExistsSync.mockReturnValue(false); const entry = createMockEntry({ id: 'e1' }); - manager.addEntry('session/with:special.chars!', '/test', entry); + await manager.addEntry('session/with:special.chars!', '/test', entry); const writtenPath = mockWriteFileSync.mock.calls[0][0] as string; // Should not contain /, :, ., or ! in the filename portion diff --git a/src/__tests__/main/ipc/handlers/agentSessions.test.ts b/src/__tests__/main/ipc/handlers/agentSessions.test.ts index baccd997e..589045122 100644 --- a/src/__tests__/main/ipc/handlers/agentSessions.test.ts +++ b/src/__tests__/main/ipc/handlers/agentSessions.test.ts @@ -7,8 +7,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ipcMain } from 'electron'; -import { registerAgentSessionsHandlers } from '../../../../main/ipc/handlers/agentSessions'; +import fs from 'fs/promises'; +import { + registerAgentSessionsHandlers, + __clearSessionDiscoveryCacheForTests, +} from '../../../../main/ipc/handlers/agentSessions'; import * as agentSessionStorage from '../../../../main/agents'; +import * as statsCache from '../../../../main/utils/statsCache'; +import os from 'os'; +import path from 'path'; // Mock electron's ipcMain vi.mock('electron', () => ({ @@ -28,12 +35,84 @@ vi.mock('../../../../main/agents', () => ({ // Mock the logger vi.mock('../../../../main/utils/logger', () => ({ logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}, +})); +// Mock fs/promises for global stats discovery scanning +vi.mock('fs/promises', () => ({ + access: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), })); +// Mock global stats cache so getGlobalStats remains deterministic +vi.mock('../../../../main/utils/statsCache', () => ({ + loadGlobalStatsCache: vi.fn(), + saveGlobalStatsCache: vi.fn(), + GLOBAL_STATS_CACHE_VERSION: 3, +})); + +function setupInMemoryGlobalStatsCache() { + let cache: statsCache.GlobalStatsCache | null = null; + + vi.mocked(statsCache.loadGlobalStatsCache).mockImplementation(async () => cache); + vi.mocked(statsCache.saveGlobalStatsCache).mockImplementation(async (nextCache) => { + cache = nextCache; + }); + + return () => cache; +} + +function setupSingleClaudeSessionDiscoveryMock() { + const homeDir = os.homedir(); + const claudeProjectsDir = path.join(homeDir, '.claude', 'projects'); + const codexSessionsDir = path.join(homeDir, '.codex', 'sessions'); + const projectDir = path.join(claudeProjectsDir, 'project-one'); + const sessionFilePath = path.join(projectDir, 'abc.jsonl'); + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockImplementation(async (target) => { + switch (target) { + case claudeProjectsDir: + return ['project-one']; + case projectDir: + return ['abc.jsonl']; + case codexSessionsDir: + return []; + default: + return []; + } + }); + vi.mocked(fs.stat).mockImplementation(async (target) => { + if (target === projectDir) { + return { + isDirectory: () => true, + size: 0, + mtimeMs: 1, + } as fs.Stats; + } + + if (target === sessionFilePath) { + return { + isDirectory: () => false, + size: 123, + mtimeMs: 1_700_000, + } as fs.Stats; + } + + return { + isDirectory: () => false, + size: 0, + mtimeMs: 1, + } as fs.Stats; + }); + vi.mocked(fs.readFile).mockResolvedValue('{"type":"user"}\n'); +} describe('agentSessions IPC handlers', () => { let handlers: Map; @@ -41,6 +120,7 @@ describe('agentSessions IPC handlers', () => { beforeEach(() => { // Clear mocks vi.clearAllMocks(); + __clearSessionDiscoveryCacheForTests(); // Capture all registered handlers handlers = new Map(); @@ -67,6 +147,7 @@ describe('agentSessions IPC handlers', () => { 'agentSessions:deleteMessagePair', 'agentSessions:hasStorage', 'agentSessions:getAvailableStorages', + 'agentSessions:getGlobalStats', ]; for (const channel of expectedChannels) { @@ -466,4 +547,74 @@ describe('agentSessions IPC handlers', () => { expect(result).toEqual(['claude-code', 'opencode']); }); }); + + describe('agentSessions:getGlobalStats', () => { + it('reuses discovered session file list within the 30-second cache window', async () => { + const getCache = setupInMemoryGlobalStatsCache(); + setupSingleClaudeSessionDiscoveryMock(); + + const handler = handlers.get('agentSessions:getGlobalStats'); + + await handler!({} as any); + const firstPassAccessCalls = vi.mocked(fs.access).mock.calls.length; + const firstPassReaddirCalls = vi.mocked(fs.readdir).mock.calls.length; + const firstPassStatCalls = vi.mocked(fs.stat).mock.calls.length; + const firstPassReadFileCalls = vi.mocked(fs.readFile).mock.calls.length; + + await handler!({} as any); + const secondPassAccessCalls = vi.mocked(fs.access).mock.calls.length; + const secondPassReaddirCalls = vi.mocked(fs.readdir).mock.calls.length; + const secondPassStatCalls = vi.mocked(fs.stat).mock.calls.length; + const secondPassReadFileCalls = vi.mocked(fs.readFile).mock.calls.length; + + expect(firstPassAccessCalls).toBe(2); + expect(firstPassReaddirCalls).toBe(3); + expect(firstPassStatCalls).toBe(3); + expect(firstPassReadFileCalls).toBe(1); + + expect(secondPassAccessCalls).toBe(firstPassAccessCalls); + expect(secondPassReaddirCalls).toBe(firstPassReaddirCalls); + expect(secondPassStatCalls).toBe(firstPassStatCalls); + expect(secondPassReadFileCalls).toBe(firstPassReadFileCalls); + + const cache = getCache(); + expect(cache).toBeTruthy(); + expect(cache!.providers['claude-code'].sessions['project-one/abc']).toBeDefined(); + }); + + it('refreshes discovery when cache TTL has expired', async () => { + const getCache = setupInMemoryGlobalStatsCache(); + setupSingleClaudeSessionDiscoveryMock(); + + let now = 1_700_000_000_000; + const dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const handler = handlers.get('agentSessions:getGlobalStats'); + try { + await handler!({} as any); + expect(vi.mocked(fs.access).mock.calls).toHaveLength(2); + expect(vi.mocked(fs.readdir).mock.calls).toHaveLength(3); + expect(vi.mocked(fs.stat).mock.calls).toHaveLength(3); + expect(vi.mocked(fs.readFile).mock.calls).toHaveLength(1); + + await handler!({} as any); + expect(vi.mocked(fs.access).mock.calls).toHaveLength(2); + expect(vi.mocked(fs.readdir).mock.calls).toHaveLength(3); + expect(vi.mocked(fs.stat).mock.calls).toHaveLength(3); + expect(vi.mocked(fs.readFile).mock.calls).toHaveLength(1); + + now += 31_000; + await handler!({} as any); + expect(vi.mocked(fs.access).mock.calls).toHaveLength(4); + expect(vi.mocked(fs.readdir).mock.calls).toHaveLength(6); + expect(vi.mocked(fs.stat).mock.calls).toHaveLength(6); + expect(vi.mocked(fs.readFile).mock.calls).toHaveLength(1); + + const cache = getCache(); + expect(cache).toBeTruthy(); + expect(cache!.providers['claude-code'].sessions['project-one/abc']).toBeDefined(); + } finally { + dateNowSpy.mockRestore(); + } + }); + }); }); diff --git a/src/__tests__/main/ipc/handlers/director-notes.test.ts b/src/__tests__/main/ipc/handlers/director-notes.test.ts index 778946efc..441ccac39 100644 --- a/src/__tests__/main/ipc/handlers/director-notes.test.ts +++ b/src/__tests__/main/ipc/handlers/director-notes.test.ts @@ -94,8 +94,8 @@ describe('director-notes IPC handlers', () => { // Create mock history manager mockHistoryManager = { - getEntries: vi.fn().mockReturnValue([]), - listSessionsWithHistory: vi.fn().mockReturnValue([]), + getEntries: vi.fn().mockResolvedValue([]), + listSessionsWithHistory: vi.fn().mockResolvedValue([]), getHistoryFilePath: vi.fn().mockReturnValue(null), }; @@ -141,13 +141,13 @@ describe('director-notes IPC handlers', () => { describe('director-notes:getUnifiedHistory', () => { it('should aggregate history from all sessions', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); vi.mocked(mockHistoryManager.getEntries) - .mockReturnValueOnce([ + .mockResolvedValueOnce([ createMockEntry({ id: 'e1', timestamp: now - 1000, @@ -155,7 +155,7 @@ describe('director-notes IPC handlers', () => { sessionName: 'Agent A', }), ]) - .mockReturnValueOnce([ + .mockResolvedValueOnce([ createMockEntry({ id: 'e2', timestamp: now - 2000, @@ -178,13 +178,13 @@ describe('director-notes IPC handlers', () => { it('should include stats in the response', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); vi.mocked(mockHistoryManager.getEntries) - .mockReturnValueOnce([ + .mockResolvedValueOnce([ createMockEntry({ id: 'e1', type: 'AUTO', @@ -198,7 +198,7 @@ describe('director-notes IPC handlers', () => { agentSessionId: 'as-1', }), ]) - .mockReturnValueOnce([ + .mockResolvedValueOnce([ createMockEntry({ id: 'e3', type: 'AUTO', @@ -226,8 +226,8 @@ describe('director-notes IPC handlers', () => { it('should compute stats from unfiltered data when type filter is applied', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'e1', type: 'AUTO', timestamp: now - 1000, agentSessionId: 'as-1' }), createMockEntry({ id: 'e2', type: 'USER', timestamp: now - 2000, agentSessionId: 'as-1' }), createMockEntry({ id: 'e3', type: 'AUTO', timestamp: now - 3000, agentSessionId: 'as-2' }), @@ -249,8 +249,8 @@ describe('director-notes IPC handlers', () => { const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'recent', timestamp: twoDaysAgo }), createMockEntry({ id: 'old', timestamp: tenDaysAgo }), ]); @@ -267,8 +267,8 @@ describe('director-notes IPC handlers', () => { const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000; const yearAgo = now - 365 * 24 * 60 * 60 * 1000; - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'recent', timestamp: twoDaysAgo }), createMockEntry({ id: 'ancient', timestamp: yearAgo }), ]); @@ -283,8 +283,8 @@ describe('director-notes IPC handlers', () => { it('should filter by type when filter is provided', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'auto-entry', type: 'AUTO', timestamp: now - 1000 }), createMockEntry({ id: 'user-entry', type: 'USER', timestamp: now - 2000 }), ]); @@ -298,8 +298,8 @@ describe('director-notes IPC handlers', () => { it('should return both types when filter is null', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'auto-entry', type: 'AUTO', timestamp: now - 1000 }), createMockEntry({ id: 'user-entry', type: 'USER', timestamp: now - 2000 }), ]); @@ -312,15 +312,15 @@ describe('director-notes IPC handlers', () => { it('should return entries sorted by timestamp descending', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); // Session 1 has older entry, session 2 has newer entry vi.mocked(mockHistoryManager.getEntries) - .mockReturnValueOnce([createMockEntry({ id: 'oldest', timestamp: now - 3000 })]) - .mockReturnValueOnce([ + .mockResolvedValueOnce([createMockEntry({ id: 'oldest', timestamp: now - 3000 })]) + .mockResolvedValueOnce([ createMockEntry({ id: 'newest', timestamp: now - 1000 }), createMockEntry({ id: 'middle', timestamp: now - 2000 }), ]); @@ -336,8 +336,8 @@ describe('director-notes IPC handlers', () => { it('should use Maestro session name when available in sessions store', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'e1', timestamp: now, sessionName: 'Tab Name' }), ]); @@ -363,8 +363,8 @@ describe('director-notes IPC handlers', () => { it('should set agentName to undefined when Maestro session not found in store', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'e1', timestamp: now, sessionName: 'My Agent' }), ]); @@ -392,8 +392,8 @@ describe('director-notes IPC handlers', () => { it('should set agentName to undefined when session is not in store', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['claude-abc123']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['claude-abc123']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'e1', timestamp: now, sessionName: undefined }), ]); @@ -405,7 +405,7 @@ describe('director-notes IPC handlers', () => { }); it('should return empty entries when no sessions have history', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([]); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([]); const handler = handlers.get('director-notes:getUnifiedHistory'); const result = await handler!({} as any, { lookbackDays: 7 }); @@ -417,8 +417,8 @@ describe('director-notes IPC handlers', () => { it('should return empty entries when all entries are outside lookback window', async () => { const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'old', timestamp: thirtyDaysAgo }), ]); @@ -432,8 +432,8 @@ describe('director-notes IPC handlers', () => { it('should support pagination with limit and offset', async () => { const now = Date.now(); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([ createMockEntry({ id: 'e1', timestamp: now - 1000 }), createMockEntry({ id: 'e2', timestamp: now - 2000 }), createMockEntry({ id: 'e3', timestamp: now - 3000 }), @@ -480,7 +480,7 @@ describe('director-notes IPC handlers', () => { }); it('should return empty-history message when no sessions have history files', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([]); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([]); const handler = handlers.get('director-notes:generateSynopsis'); const result = await handler!({} as any, { lookbackDays: 7, provider: 'claude-code' }); @@ -493,7 +493,7 @@ describe('director-notes IPC handlers', () => { }); it('should return empty-history message when all file paths are null', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue(null); const handler = handlers.get('director-notes:generateSynopsis'); @@ -511,7 +511,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -542,7 +542,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', 'session-3', @@ -569,7 +569,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -605,7 +605,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -640,7 +640,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['unknown-session']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['unknown-session']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/unknown-session.json' ); @@ -656,7 +656,7 @@ describe('director-notes IPC handlers', () => { const { groomContext } = await import('../../../../main/utils/context-groomer'); vi.mocked(groomContext).mockRejectedValue(new Error('Agent timed out')); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -676,7 +676,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -696,7 +696,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); @@ -732,7 +732,7 @@ describe('director-notes IPC handlers', () => { completionReason: 'process exited with code 0', }); - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( '/data/history/session-1.json' ); diff --git a/src/__tests__/main/ipc/handlers/history.test.ts b/src/__tests__/main/ipc/handlers/history.test.ts index e612489d7..89203e26f 100644 --- a/src/__tests__/main/ipc/handlers/history.test.ts +++ b/src/__tests__/main/ipc/handlers/history.test.ts @@ -56,39 +56,39 @@ describe('history IPC handlers', () => { // Create mock history manager mockHistoryManager = { - getEntries: vi.fn().mockReturnValue([]), - getEntriesByProjectPath: vi.fn().mockReturnValue([]), - getAllEntries: vi.fn().mockReturnValue([]), - getEntriesPaginated: vi.fn().mockReturnValue({ + getEntries: vi.fn().mockResolvedValue([]), + getEntriesByProjectPath: vi.fn().mockResolvedValue([]), + getAllEntries: vi.fn().mockResolvedValue([]), + getEntriesPaginated: vi.fn().mockResolvedValue({ entries: [], total: 0, limit: 100, offset: 0, hasMore: false, }), - getEntriesByProjectPathPaginated: vi.fn().mockReturnValue({ + getEntriesByProjectPathPaginated: vi.fn().mockResolvedValue({ entries: [], total: 0, limit: 100, offset: 0, hasMore: false, }), - getAllEntriesPaginated: vi.fn().mockReturnValue({ + getAllEntriesPaginated: vi.fn().mockResolvedValue({ entries: [], total: 0, limit: 100, offset: 0, hasMore: false, }), - addEntry: vi.fn(), + addEntry: vi.fn().mockResolvedValue(undefined), clearSession: vi.fn(), clearByProjectPath: vi.fn(), clearAll: vi.fn(), - deleteEntry: vi.fn().mockReturnValue(false), - updateEntry: vi.fn().mockReturnValue(false), - updateSessionNameByClaudeSessionId: vi.fn().mockReturnValue(0), - getHistoryFilePath: vi.fn().mockReturnValue(null), - listSessionsWithHistory: vi.fn().mockReturnValue([]), + deleteEntry: vi.fn().mockResolvedValue(false), + updateEntry: vi.fn().mockResolvedValue(false), + updateSessionNameByClaudeSessionId: vi.fn().mockResolvedValue(0), + getHistoryFilePath: vi.fn().mockResolvedValue(null), + listSessionsWithHistory: vi.fn().mockResolvedValue([]), }; vi.mocked(historyManagerModule.getHistoryManager).mockReturnValue( @@ -136,7 +136,7 @@ describe('history IPC handlers', () => { createMockEntry({ id: 'entry-1', timestamp: 2000 }), createMockEntry({ id: 'entry-2', timestamp: 1000 }), ]; - vi.mocked(mockHistoryManager.getEntries).mockReturnValue(mockEntries); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue(mockEntries); const handler = handlers.get('history:getAll'); const result = await handler!({} as any, undefined, 'session-1'); @@ -150,7 +150,7 @@ describe('history IPC handlers', () => { it('should return entries filtered by project path', async () => { const mockEntries = [createMockEntry()]; - vi.mocked(mockHistoryManager.getEntriesByProjectPath).mockReturnValue(mockEntries); + vi.mocked(mockHistoryManager.getEntriesByProjectPath).mockResolvedValue(mockEntries); const handler = handlers.get('history:getAll'); const result = await handler!({} as any, '/test/project'); @@ -161,7 +161,7 @@ describe('history IPC handlers', () => { it('should return all entries when no filters provided', async () => { const mockEntries = [createMockEntry()]; - vi.mocked(mockHistoryManager.getAllEntries).mockReturnValue(mockEntries); + vi.mocked(mockHistoryManager.getAllEntries).mockResolvedValue(mockEntries); const handler = handlers.get('history:getAll'); const result = await handler!({} as any); @@ -171,7 +171,7 @@ describe('history IPC handlers', () => { }); it('should return empty array when session has no history', async () => { - vi.mocked(mockHistoryManager.getEntries).mockReturnValue([]); + vi.mocked(mockHistoryManager.getEntries).mockResolvedValue([]); const handler = handlers.get('history:getAll'); const result = await handler!({} as any, undefined, 'session-1'); @@ -189,7 +189,7 @@ describe('history IPC handlers', () => { offset: 0, hasMore: true, }; - vi.mocked(mockHistoryManager.getEntriesPaginated).mockReturnValue(mockResult); + vi.mocked(mockHistoryManager.getEntriesPaginated).mockResolvedValue(mockResult); const handler = handlers.get('history:getAllPaginated'); const result = await handler!({} as any, { @@ -212,7 +212,7 @@ describe('history IPC handlers', () => { offset: 0, hasMore: true, }; - vi.mocked(mockHistoryManager.getEntriesByProjectPathPaginated).mockReturnValue(mockResult); + vi.mocked(mockHistoryManager.getEntriesByProjectPathPaginated).mockResolvedValue(mockResult); const handler = handlers.get('history:getAllPaginated'); const result = await handler!({} as any, { @@ -235,7 +235,7 @@ describe('history IPC handlers', () => { offset: 0, hasMore: false, }; - vi.mocked(mockHistoryManager.getAllEntriesPaginated).mockReturnValue(mockResult); + vi.mocked(mockHistoryManager.getAllEntriesPaginated).mockResolvedValue(mockResult); const handler = handlers.get('history:getAllPaginated'); const result = await handler!({} as any, {}); @@ -252,7 +252,7 @@ describe('history IPC handlers', () => { offset: 0, hasMore: false, }; - vi.mocked(mockHistoryManager.getAllEntriesPaginated).mockReturnValue(mockResult); + vi.mocked(mockHistoryManager.getAllEntriesPaginated).mockResolvedValue(mockResult); const handler = handlers.get('history:getAllPaginated'); const result = await handler!({} as any, undefined); @@ -343,7 +343,7 @@ describe('history IPC handlers', () => { describe('history:delete', () => { it('should delete entry from specific session', async () => { - vi.mocked(mockHistoryManager.deleteEntry).mockReturnValue(true); + vi.mocked(mockHistoryManager.deleteEntry).mockResolvedValue(true); const handler = handlers.get('history:delete'); const result = await handler!({} as any, 'entry-123', 'session-1'); @@ -353,7 +353,7 @@ describe('history IPC handlers', () => { }); it('should return false when entry not found in session', async () => { - vi.mocked(mockHistoryManager.deleteEntry).mockReturnValue(false); + vi.mocked(mockHistoryManager.deleteEntry).mockResolvedValue(false); const handler = handlers.get('history:delete'); const result = await handler!({} as any, 'non-existent', 'session-1'); @@ -362,13 +362,13 @@ describe('history IPC handlers', () => { }); it('should search all sessions when sessionId not provided', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); vi.mocked(mockHistoryManager.deleteEntry) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); const handler = handlers.get('history:delete'); const result = await handler!({} as any, 'entry-123'); @@ -380,11 +380,11 @@ describe('history IPC handlers', () => { }); it('should return false when entry not found in any session', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); - vi.mocked(mockHistoryManager.deleteEntry).mockReturnValue(false); + vi.mocked(mockHistoryManager.deleteEntry).mockResolvedValue(false); const handler = handlers.get('history:delete'); const result = await handler!({} as any, 'non-existent'); @@ -395,7 +395,7 @@ describe('history IPC handlers', () => { describe('history:update', () => { it('should update entry in specific session', async () => { - vi.mocked(mockHistoryManager.updateEntry).mockReturnValue(true); + vi.mocked(mockHistoryManager.updateEntry).mockResolvedValue(true); const updates = { validated: true }; const handler = handlers.get('history:update'); @@ -410,7 +410,7 @@ describe('history IPC handlers', () => { }); it('should return false when entry not found in session', async () => { - vi.mocked(mockHistoryManager.updateEntry).mockReturnValue(false); + vi.mocked(mockHistoryManager.updateEntry).mockResolvedValue(false); const handler = handlers.get('history:update'); const result = await handler!({} as any, 'non-existent', { validated: true }, 'session-1'); @@ -419,13 +419,13 @@ describe('history IPC handlers', () => { }); it('should search all sessions when sessionId not provided', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', ]); vi.mocked(mockHistoryManager.updateEntry) - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); const updates = { summary: 'Updated summary' }; const handler = handlers.get('history:update'); @@ -445,8 +445,8 @@ describe('history IPC handlers', () => { }); it('should return false when entry not found in any session', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue(['session-1']); - vi.mocked(mockHistoryManager.updateEntry).mockReturnValue(false); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue(['session-1']); + vi.mocked(mockHistoryManager.updateEntry).mockResolvedValue(false); const handler = handlers.get('history:update'); const result = await handler!({} as any, 'non-existent', { validated: true }); @@ -457,7 +457,7 @@ describe('history IPC handlers', () => { describe('history:updateSessionName', () => { it('should update session name for matching entries', async () => { - vi.mocked(mockHistoryManager.updateSessionNameByClaudeSessionId).mockReturnValue(5); + vi.mocked(mockHistoryManager.updateSessionNameByClaudeSessionId).mockResolvedValue(5); const handler = handlers.get('history:updateSessionName'); const result = await handler!({} as any, 'agent-session-123', 'New Session Name'); @@ -470,7 +470,7 @@ describe('history IPC handlers', () => { }); it('should return 0 when no matching entries found', async () => { - vi.mocked(mockHistoryManager.updateSessionNameByClaudeSessionId).mockReturnValue(0); + vi.mocked(mockHistoryManager.updateSessionNameByClaudeSessionId).mockResolvedValue(0); const handler = handlers.get('history:updateSessionName'); const result = await handler!({} as any, 'non-existent-agent', 'Name'); @@ -481,7 +481,7 @@ describe('history IPC handlers', () => { describe('history:getFilePath', () => { it('should return file path for existing session', async () => { - vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue( + vi.mocked(mockHistoryManager.getHistoryFilePath).mockResolvedValue( '/path/to/history/session-1.json' ); @@ -493,7 +493,7 @@ describe('history IPC handlers', () => { }); it('should return null for non-existent session', async () => { - vi.mocked(mockHistoryManager.getHistoryFilePath).mockReturnValue(null); + vi.mocked(mockHistoryManager.getHistoryFilePath).mockResolvedValue(null); const handler = handlers.get('history:getFilePath'); const result = await handler!({} as any, 'non-existent'); @@ -504,7 +504,7 @@ describe('history IPC handlers', () => { describe('history:listSessions', () => { it('should return list of sessions with history', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([ + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([ 'session-1', 'session-2', 'session-3', @@ -518,7 +518,7 @@ describe('history IPC handlers', () => { }); it('should return empty array when no sessions have history', async () => { - vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([]); + vi.mocked(mockHistoryManager.listSessionsWithHistory).mockResolvedValue([]); const handler = handlers.get('history:listSessions'); const result = await handler!({} as any); diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 83d84e3ec..659cc6895 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -139,10 +139,10 @@ describe('ChildProcessSpawner', () => { }); describe('isStreamJsonMode detection', () => { - it('should enable stream-json mode when args contain "stream-json"', () => { + it('should enable stream-json mode when args contain "stream-json"', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--output-format', 'stream-json'], }) @@ -152,10 +152,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); - it('should enable stream-json mode when args contain "--json"', () => { + it('should enable stream-json mode when args contain "--json"', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--json'], }) @@ -165,10 +165,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); - it('should enable stream-json mode when args contain "--format" and "json"', () => { + it('should enable stream-json mode when args contain "--format" and "json"', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--format', 'json'], }) @@ -178,10 +178,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); - it('should enable stream-json mode when sendPromptViaStdin is true', () => { + it('should enable stream-json mode when sendPromptViaStdin is true', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--print'], sendPromptViaStdin: true, @@ -193,12 +193,12 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); - it('should NOT enable stream-json mode when sendPromptViaStdinRaw is true', () => { + it('should NOT enable stream-json mode when sendPromptViaStdinRaw is true', async () => { const { processes, spawner } = createTestContext(); // sendPromptViaStdinRaw sends RAW text via stdin, not JSON // So it should NOT set isStreamJsonMode (which is for JSON streaming) - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--print'], sendPromptViaStdinRaw: true, @@ -210,12 +210,12 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(false); }); - it('should enable stream-json mode when sshStdinScript is provided', () => { + it('should enable stream-json mode when sshStdinScript is provided', async () => { const { processes, spawner } = createTestContext(); // SSH sessions pass a script via stdin - this should trigger stream-json mode // even though the args (SSH args) don't contain 'stream-json' - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['-o', 'BatchMode=yes', 'user@host', '/bin/bash'], sshStdinScript: 'export PATH="$HOME/.local/bin:$PATH"\ncd /project\nexec claude --print', @@ -226,10 +226,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); - it('should NOT enable stream-json mode for plain args without JSON flags', () => { + it('should NOT enable stream-json mode for plain args without JSON flags', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--print', '--verbose'], }) @@ -239,10 +239,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(false); }); - it('should enable stream-json mode when images are provided with prompt', () => { + it('should enable stream-json mode when images are provided with prompt', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: ['--print'], images: ['data:image/png;base64,abc123'], @@ -256,10 +256,10 @@ describe('ChildProcessSpawner', () => { }); describe('isBatchMode detection', () => { - it('should enable batch mode when prompt is provided', () => { + it('should enable batch mode when prompt is provided', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ prompt: 'test prompt', }) @@ -269,10 +269,10 @@ describe('ChildProcessSpawner', () => { expect(proc?.isBatchMode).toBe(true); }); - it('should NOT enable batch mode when no prompt is provided', () => { + it('should NOT enable batch mode when no prompt is provided', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ prompt: undefined, }) @@ -284,10 +284,10 @@ describe('ChildProcessSpawner', () => { }); describe('SSH remote context', () => { - it('should store sshRemoteId on managed process', () => { + it('should store sshRemoteId on managed process', async () => { const { processes, spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ sshRemoteId: 'my-remote-server', sshRemoteHost: 'dev.example.com', @@ -316,10 +316,10 @@ describe('ChildProcessSpawner', () => { '--dangerously-skip-permissions', ]; - it('should add --input-format stream-json when images are present with default Claude Code args', () => { + it('should add --input-format stream-json when images are present with default Claude Code args', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, images: ['data:image/png;base64,abc123'], @@ -334,10 +334,10 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs[inputFormatIdx + 1]).toBe('stream-json'); }); - it('should add --input-format stream-json even when sendPromptViaStdin is true', () => { + it('should add --input-format stream-json even when sendPromptViaStdin is true', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, images: ['data:image/png;base64,abc123'], @@ -352,10 +352,10 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs[inputFormatIdx + 1]).toBe('stream-json'); }); - it('should not duplicate --input-format when it is already in args', () => { + it('should not duplicate --input-format when it is already in args', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: [...CLAUDE_DEFAULT_ARGS, '--input-format', 'stream-json'], images: ['data:image/png;base64,abc123'], @@ -368,10 +368,10 @@ describe('ChildProcessSpawner', () => { expect(inputFormatCount).toBe(1); }); - it('should send stream-json message via stdin when images are present', () => { + it('should send stream-json message via stdin when images are present', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, images: ['data:image/png;base64,abc123'], @@ -389,7 +389,7 @@ describe('ChildProcessSpawner', () => { expect(mockChildProcess.stdin.end).toHaveBeenCalled(); }); - it('should send stream-json message via stdin with multiple images', () => { + it('should send stream-json message via stdin with multiple images', async () => { const { spawner } = createTestContext(); const images = [ @@ -398,7 +398,7 @@ describe('ChildProcessSpawner', () => { 'data:image/webp;base64,ghi789', ]; - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, images, @@ -425,10 +425,10 @@ describe('ChildProcessSpawner', () => { '--dangerously-skip-permissions', ]; - it('should NOT treat --output-format stream-json as promptViaStdin', () => { + it('should NOT treat --output-format stream-json as promptViaStdin', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, prompt: 'hello', @@ -441,10 +441,10 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs).toContain('hello'); }); - it('should treat --input-format stream-json as promptViaStdin', () => { + it('should treat --input-format stream-json as promptViaStdin', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: [...CLAUDE_DEFAULT_ARGS, '--input-format', 'stream-json'], prompt: 'hello', @@ -457,10 +457,10 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs).not.toContain('hello'); }); - it('should treat sendPromptViaStdin as promptViaStdin', () => { + it('should treat sendPromptViaStdin as promptViaStdin', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, prompt: 'hello', @@ -472,10 +472,10 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs).not.toContain('hello'); }); - it('should treat sendPromptViaStdinRaw as promptViaStdin', () => { + it('should treat sendPromptViaStdinRaw as promptViaStdin', async () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ args: CLAUDE_DEFAULT_ARGS, prompt: 'hello', @@ -489,7 +489,7 @@ describe('ChildProcessSpawner', () => { }); describe('stdin write guard for non-stream-json-input agents', () => { - it('should NOT write stream-json to stdin when prompt is already in CLI args (Codex --json)', () => { + it('should NOT write stream-json to stdin when prompt is already in CLI args (Codex --json)', async () => { // Codex uses --json for JSON *output*, not input. The prompt goes as a CLI arg. // Without the promptViaStdin guard, isStreamJsonMode (true from --json) would // cause the prompt to be double-sent: once in CLI args and once via stdin. @@ -499,7 +499,7 @@ describe('ChildProcessSpawner', () => { const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -523,10 +523,10 @@ describe('ChildProcessSpawner', () => { }); describe('child process event handling', () => { - it('should listen on "close" event (not "exit") to ensure all stdio data is drained', () => { + it('should listen on "close" event (not "exit") to ensure all stdio data is drained', async () => { const { spawner } = createTestContext(); - spawner.spawn(createBaseConfig({ prompt: 'test' })); + await spawner.spawn(createBaseConfig({ prompt: 'test' })); // Verify 'close' is registered (ensures all stdout/stderr data is consumed // before exit handler runs โ€” fixes data loss for short-lived processes) @@ -536,10 +536,10 @@ describe('ChildProcessSpawner', () => { expect(eventNames).not.toContain('exit'); }); - it('should listen for "error" events on the child process', () => { + it('should listen for "error" events on the child process', async () => { const { spawner } = createTestContext(); - spawner.spawn(createBaseConfig({ prompt: 'test' })); + await spawner.spawn(createBaseConfig({ prompt: 'test' })); const onCalls = mockChildProcess.on.mock.calls as [string, Function][]; const eventNames = onCalls.map(([event]) => event); @@ -548,16 +548,16 @@ describe('ChildProcessSpawner', () => { }); describe('image handling with non-stream-json agents', () => { - it('should use file-based image args for agents without stream-json support', () => { + it('should use file-based image args for agents without stream-json support', async () => { // Override capabilities for this test vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, } as any); - vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + vi.mocked(saveImageToTempFile).mockResolvedValueOnce('/tmp/maestro-image-0.png'); const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -577,16 +577,16 @@ describe('ChildProcessSpawner', () => { }); describe('resume mode with prompt-embed image handling', () => { - it('should embed image paths in prompt when resuming with imageResumeMode=prompt-embed', () => { + it('should embed image paths in prompt when resuming with imageResumeMode=prompt-embed', async () => { vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, imageResumeMode: 'prompt-embed', } as any); - vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + vi.mocked(saveImageToTempFile).mockResolvedValueOnce('/tmp/maestro-image-0.png'); const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -608,17 +608,17 @@ describe('ChildProcessSpawner', () => { expect(promptArg).toContain('describe this image'); }); - it('should use -i flag for initial spawn even when imageResumeMode=prompt-embed', () => { + it('should use -i flag for initial spawn even when imageResumeMode=prompt-embed', async () => { vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, imageResumeMode: 'prompt-embed', } as any); - vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + vi.mocked(saveImageToTempFile).mockResolvedValueOnce('/tmp/maestro-image-0.png'); const { spawner } = createTestContext(); // Args do NOT contain 'resume' โ€” this is an initial spawn - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -635,16 +635,16 @@ describe('ChildProcessSpawner', () => { expect(spawnArgs).toContain('/tmp/maestro-image-0.png'); }); - it('should send modified prompt via stdin in resume mode when promptViaStdin is true', () => { + it('should send modified prompt via stdin in resume mode when promptViaStdin is true', async () => { vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, imageResumeMode: 'prompt-embed', } as any); - vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + vi.mocked(saveImageToTempFile).mockResolvedValueOnce('/tmp/maestro-image-0.png'); const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -669,18 +669,18 @@ describe('ChildProcessSpawner', () => { expect(writtenData).toContain('describe this image'); }); - it('should handle multiple images in resume mode', () => { + it('should handle multiple images in resume mode', async () => { vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, imageResumeMode: 'prompt-embed', } as any); vi.mocked(saveImageToTempFile) - .mockReturnValueOnce('/tmp/maestro-image-0.png') - .mockReturnValueOnce('/tmp/maestro-image-1.jpg'); + .mockResolvedValueOnce('/tmp/maestro-image-0.png') + .mockResolvedValueOnce('/tmp/maestro-image-1.jpg'); const { spawner } = createTestContext(); - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'codex', command: 'codex', @@ -699,17 +699,17 @@ describe('ChildProcessSpawner', () => { expect(promptArg).toContain('compare these images'); }); - it('should NOT use prompt-embed when imageResumeMode is undefined', () => { + it('should NOT use prompt-embed when imageResumeMode is undefined', async () => { vi.mocked(getAgentCapabilities).mockReturnValueOnce({ supportsStreamJsonInput: false, imageResumeMode: undefined, } as any); - vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + vi.mocked(saveImageToTempFile).mockResolvedValueOnce('/tmp/maestro-image-0.png'); const { spawner } = createTestContext(); // Even with 'resume' in args, if imageResumeMode is undefined, use -i flag - spawner.spawn( + await spawner.spawn( createBaseConfig({ toolType: 'opencode', command: 'opencode', diff --git a/src/__tests__/main/stats/aggregations.test.ts b/src/__tests__/main/stats/aggregations.test.ts index 875cf6715..5307c81f5 100644 --- a/src/__tests__/main/stats/aggregations.test.ts +++ b/src/__tests__/main/stats/aggregations.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -125,7 +147,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('day'); @@ -147,7 +169,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week'); @@ -168,7 +190,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('month'); @@ -189,7 +211,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('year'); @@ -207,7 +229,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should filter by "all" range (from epoch/timestamp 0)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('all'); @@ -229,7 +251,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('day'); @@ -249,7 +271,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('week'); @@ -269,7 +291,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('month'); @@ -289,7 +311,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('year'); @@ -306,7 +328,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should filter Auto Run sessions by "all" range', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('all'); @@ -327,7 +349,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.get.mockClear(); db.getAggregatedStats('day'); @@ -349,7 +371,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.get.mockClear(); db.getAggregatedStats('week'); @@ -370,7 +392,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.get.mockClear(); db.getAggregatedStats('month'); @@ -391,7 +413,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.get.mockClear(); db.getAggregatedStats('year'); @@ -409,7 +431,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should aggregate stats for "all" range', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.get.mockClear(); db.getAggregatedStats('all'); @@ -431,7 +453,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.exportToCsv('day'); @@ -448,7 +470,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should export CSV for "all" range', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.exportToCsv('all'); @@ -462,11 +484,11 @@ describe('Time-range filtering works correctly for all ranges', () => { }); }); - describe('SQL query structure verification', () => { + describe('SQL query structure verification', () => { it('should include start_time >= ? in getQueryEvents SQL', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week'); @@ -482,7 +504,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should include start_time >= ? in getAutoRunSessions SQL', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('month'); @@ -498,7 +520,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should include start_time >= ? in aggregation queries', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('year'); @@ -555,7 +577,7 @@ describe('Time-range filtering works correctly for all ranges', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const events = db.getQueryEvents('day'); @@ -569,7 +591,7 @@ describe('Time-range filtering works correctly for all ranges', () => { // We verify this by checking the SQL structure const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('day'); @@ -585,7 +607,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should return consistent results for multiple calls with same range', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Call twice in quick succession db.getQueryEvents('week'); @@ -607,7 +629,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should combine time range with agentType filter', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week', { agentType: 'claude-code' }); @@ -623,7 +645,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should combine time range with source filter', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('month', { source: 'auto' }); @@ -639,7 +661,7 @@ describe('Time-range filtering works correctly for all ranges', () => { it('should combine time range with multiple filters', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('year', { agentType: 'opencode', @@ -694,7 +716,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -707,7 +729,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('month'); @@ -720,7 +742,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -734,7 +756,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('year'); @@ -750,7 +772,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('all'); @@ -766,7 +788,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -779,7 +801,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -794,7 +816,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('month'); @@ -808,7 +830,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -822,7 +844,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -841,7 +863,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Reset to control exact mock responses for getAggregatedStats mockStatement.all.mockReset(); @@ -873,7 +895,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('month'); @@ -899,7 +921,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -919,7 +941,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -949,7 +971,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -966,7 +988,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('month'); @@ -983,7 +1005,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('year'); @@ -997,7 +1019,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -1016,7 +1038,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('all'); @@ -1040,7 +1062,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -1056,7 +1078,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -1074,7 +1096,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -1098,7 +1120,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -1122,7 +1144,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -1145,7 +1167,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -1164,7 +1186,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats1 = db.getAggregatedStats('week'); const stats2 = db.getAggregatedStats('week'); @@ -1180,7 +1202,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Simulate concurrent calls const [result1, result2, result3] = [ @@ -1202,7 +1224,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('week'); @@ -1221,7 +1243,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('month'); @@ -1241,7 +1263,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('year'); @@ -1255,13 +1277,33 @@ describe('Aggregation queries return correct calculations', () => { expect(bySourceCall).toBeDefined(); }); + it('should pre-group queryEvents by time bucket for byAgent to align compound index usage', async () => { + mockStatement.get.mockReturnValue({ count: 0, total_duration: 0 }); + mockStatement.all.mockReturnValue([]); + + const { StatsDB } = await import('../../../main/stats'); + const db = new StatsDB(); + await db.initialize(); + + db.getAggregatedStats('year'); + + const prepareCalls = mockDb.prepare.mock.calls; + const byAgentCall = prepareCalls.find( + (call) => + (call[0] as string).includes('GROUP BY start_time, agent_type') && + (call[0] as string).includes('FROM query_events') + ); + + expect(byAgentCall).toBeDefined(); + }); + it('should use date() function for daily grouping', async () => { mockStatement.get.mockReturnValue({ count: 0, total_duration: 0 }); mockStatement.all.mockReturnValue([]); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('all'); @@ -1279,7 +1321,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAggregatedStats('week'); @@ -1302,7 +1344,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -1318,7 +1360,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('all'); @@ -1338,7 +1380,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -1359,7 +1401,7 @@ describe('Aggregation queries return correct calculations', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); diff --git a/src/__tests__/main/stats/auto-run.test.ts b/src/__tests__/main/stats/auto-run.test.ts index 51ab6f417..02f2d3972 100644 --- a/src/__tests__/main/stats/auto-run.test.ts +++ b/src/__tests__/main/stats/auto-run.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -121,7 +143,7 @@ describe('Auto Run session and task recording', () => { it('should insert Auto Run session and return id', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const sessionId = db.insertAutoRunSession({ sessionId: 'session-1', @@ -144,7 +166,7 @@ describe('Auto Run session and task recording', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const updated = db.updateAutoRunSession('session-id', { duration: 60000, @@ -172,7 +194,7 @@ describe('Auto Run session and task recording', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const sessions = db.getAutoRunSessions('week'); @@ -186,7 +208,7 @@ describe('Auto Run session and task recording', () => { it('should insert Auto Run task with success=true', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const taskId = db.insertAutoRunTask({ autoRunSessionId: 'auto-1', @@ -209,7 +231,7 @@ describe('Auto Run session and task recording', () => { it('should insert Auto Run task with success=false', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertAutoRunTask({ autoRunSessionId: 'auto-1', @@ -255,7 +277,7 @@ describe('Auto Run session and task recording', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const tasks = db.getAutoRunTasks('auto-1'); @@ -290,7 +312,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record Auto Run session with all required fields', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const startTime = Date.now(); const sessionId = db.insertAutoRunSession({ @@ -325,7 +347,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record Auto Run session with multiple documents (comma-separated)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const sessionId = db.insertAutoRunSession({ sessionId: 'multi-doc-session', @@ -348,7 +370,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should update Auto Run session duration and tasks on completion', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // First, insert the session const autoRunId = db.insertAutoRunSession({ @@ -377,7 +399,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should update Auto Run session with partial completion (some tasks skipped)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const autoRunId = db.insertAutoRunSession({ sessionId: 'partial-session', @@ -402,7 +424,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle Auto Run session stopped by user (wasStopped)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const autoRunId = db.insertAutoRunSession({ sessionId: 'stopped-session', @@ -429,7 +451,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record individual task with all fields', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const taskStartTime = Date.now() - 5000; const taskId = db.insertAutoRunTask({ @@ -462,7 +484,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record failed task with success=false', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertAutoRunTask({ autoRunSessionId: 'auto-run-1', @@ -483,7 +505,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record multiple tasks for same Auto Run session', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -539,7 +561,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should record task without optional taskContent', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const taskId = db.insertAutoRunTask({ autoRunSessionId: 'auto-run-1', @@ -590,7 +612,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const sessions = db.getAutoRunSessions('week'); @@ -654,7 +676,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const tasks = db.getAutoRunTasks('auto-run-1'); @@ -719,7 +741,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const tasks = db.getAutoRunTasks('ar1'); @@ -735,7 +757,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunSessions('day'); @@ -751,7 +773,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should return all Auto Run sessions for "all" time range', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockStatement.all.mockReturnValue([ { @@ -789,7 +811,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should support the full Auto Run lifecycle: start -> record tasks -> end', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -845,7 +867,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle Auto Run with loop mode (multiple passes)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -908,7 +930,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle very long task content (synopsis)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const longContent = 'A'.repeat(10000); // 10KB task content @@ -933,7 +955,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle zero duration tasks', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const taskId = db.insertAutoRunTask({ autoRunSessionId: 'ar1', @@ -956,7 +978,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle Auto Run session with zero tasks total', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // This shouldn't happen in practice, but the database should handle it const sessionId = db.insertAutoRunSession({ @@ -976,7 +998,7 @@ describe('Auto Run sessions and tasks recorded correctly', () => { it('should handle different agent types for Auto Run', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -1039,7 +1061,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should create auto_run_tasks table with REFERENCES clause to auto_run_sessions', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Verify the CREATE TABLE statement includes the foreign key reference const prepareCalls = mockDb.prepare.mock.calls.map((call) => call[0] as string); @@ -1056,7 +1078,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should have auto_run_session_id column as NOT NULL in auto_run_tasks', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const prepareCalls = mockDb.prepare.mock.calls.map((call) => call[0] as string); const createTasksTable = prepareCalls.find((sql) => @@ -1071,7 +1093,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should create index on auto_run_session_id foreign key column', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const prepareCalls = mockDb.prepare.mock.calls.map((call) => call[0] as string); const indexCreation = prepareCalls.find((sql) => sql.includes('idx_task_auto_session')); @@ -1085,7 +1107,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should store auto_run_session_id when inserting task', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const autoRunSessionId = 'parent-session-abc-123'; db.insertAutoRunTask({ @@ -1110,7 +1132,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should insert task with matching auto_run_session_id from parent session', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear calls from initialization mockStatement.run.mockClear(); @@ -1182,7 +1204,7 @@ describe('Foreign key relationship between tasks and sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Query tasks for 'auto-run-A' const tasksA = db.getAutoRunTasks('auto-run-A'); @@ -1200,7 +1222,7 @@ describe('Foreign key relationship between tasks and sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const tasks = db.getAutoRunTasks('non-existent-session'); @@ -1213,7 +1235,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should maintain consistent auto_run_session_id across multiple tasks', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear calls from initialization mockStatement.run.mockClear(); @@ -1246,7 +1268,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should allow tasks from different sessions to be inserted independently', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear calls from initialization mockStatement.run.mockClear(); @@ -1299,7 +1321,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should use generated session ID as foreign key when retrieved after insertion', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear calls from initialization mockStatement.run.mockClear(); @@ -1343,7 +1365,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should filter tasks using WHERE auto_run_session_id clause', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunTasks('specific-session-id'); @@ -1361,7 +1383,7 @@ describe('Foreign key relationship between tasks and sessions', () => { it('should order tasks by task_index within a session', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getAutoRunTasks('any-session'); diff --git a/src/__tests__/main/stats/data-management.test.ts b/src/__tests__/main/stats/data-management.test.ts index 954e38c14..817af291e 100644 --- a/src/__tests__/main/stats/data-management.test.ts +++ b/src/__tests__/main/stats/data-management.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -127,12 +149,12 @@ describe('Database VACUUM functionality', () => { // so getDatabaseSize will catch the error and return 0 const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Since mockFsExistsSync.mockReturnValue(true) is set but statSync is not mocked, // getDatabaseSize will try to call the real statSync on a non-existent path // and catch the error, returning 0 - const size = db.getDatabaseSize(); + const size = await db.getDatabaseSize(); // The mock environment doesn't have actual file, so expect 0 expect(size).toBe(0); @@ -141,10 +163,10 @@ describe('Database VACUUM functionality', () => { it('should handle statSync gracefully when file does not exist', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // getDatabaseSize should not throw - expect(() => db.getDatabaseSize()).not.toThrow(); + await expect(db.getDatabaseSize()).resolves.toBe(0); }); }); @@ -152,13 +174,13 @@ describe('Database VACUUM functionality', () => { it('should execute VACUUM SQL command', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks from initialization mockStatement.run.mockClear(); mockDb.prepare.mockClear(); - const result = db.vacuum(); + const result = await db.vacuum(); expect(result.success).toBe(true); expect(mockDb.prepare).toHaveBeenCalledWith('VACUUM'); @@ -168,9 +190,9 @@ describe('Database VACUUM functionality', () => { it('should return success true when vacuum completes', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); - const result = db.vacuum(); + const result = await db.vacuum(); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); @@ -179,9 +201,9 @@ describe('Database VACUUM functionality', () => { it('should return bytesFreed of 0 when sizes are equal (mocked)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); - const result = db.vacuum(); + const result = await db.vacuum(); // With mock fs, both before and after sizes will be 0 expect(result.bytesFreed).toBe(0); @@ -192,7 +214,7 @@ describe('Database VACUUM functionality', () => { const db = new StatsDB(); // Don't initialize - const result = db.vacuum(); + const result = await db.vacuum(); expect(result.success).toBe(false); expect(result.bytesFreed).toBe(0); @@ -202,7 +224,7 @@ describe('Database VACUUM functionality', () => { it('should handle VACUUM failure gracefully', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Make VACUUM fail mockDb.prepare.mockImplementation((sql: string) => { @@ -216,7 +238,7 @@ describe('Database VACUUM functionality', () => { return mockStatement; }); - const result = db.vacuum(); + const result = await db.vacuum(); expect(result.success).toBe(false); expect(result.error).toContain('database is locked'); @@ -226,12 +248,12 @@ describe('Database VACUUM functionality', () => { const { logger } = await import('../../../main/utils/logger'); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear logger mocks from initialization vi.mocked(logger.info).mockClear(); - db.vacuum(); + await db.vacuum(); // Check that logger was called with vacuum-related messages expect(logger.info).toHaveBeenCalledWith( @@ -249,13 +271,13 @@ describe('Database VACUUM functionality', () => { it('should skip vacuum if database size is 0 (below threshold)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks from initialization mockStatement.run.mockClear(); mockDb.prepare.mockClear(); - const result = db.vacuumIfNeeded(); + const result = await db.vacuumIfNeeded(); // Size is 0 (mock fs), which is below 100MB threshold expect(result.vacuumed).toBe(false); @@ -266,9 +288,9 @@ describe('Database VACUUM functionality', () => { it('should return correct databaseSize in result', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); - const result = db.vacuumIfNeeded(); + const result = await db.vacuumIfNeeded(); // Size property should be present expect(typeof result.databaseSize).toBe('number'); @@ -277,10 +299,10 @@ describe('Database VACUUM functionality', () => { it('should use default 100MB threshold when not specified', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // With 0 byte size (mocked), should skip vacuum - const result = db.vacuumIfNeeded(); + const result = await db.vacuumIfNeeded(); expect(result.vacuumed).toBe(false); }); @@ -288,14 +310,14 @@ describe('Database VACUUM functionality', () => { it('should not vacuum with threshold 0 and size 0 since 0 is not > 0', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks from initialization mockStatement.run.mockClear(); mockDb.prepare.mockClear(); // With 0 threshold and 0 byte file: 0 is NOT greater than 0 - const result = db.vacuumIfNeeded(0); + const result = await db.vacuumIfNeeded(0); // The condition is: databaseSize < thresholdBytes // 0 < 0 is false, so vacuumed should be true (it tries to vacuum) @@ -308,12 +330,12 @@ describe('Database VACUUM functionality', () => { const { logger } = await import('../../../main/utils/logger'); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear logger mocks from initialization vi.mocked(logger.debug).mockClear(); - db.vacuumIfNeeded(); + await db.vacuumIfNeeded(); expect(logger.debug).toHaveBeenCalledWith( expect.stringContaining('below vacuum threshold'), @@ -326,14 +348,14 @@ describe('Database VACUUM functionality', () => { it('should respect custom threshold parameter (threshold = -1 means always vacuum)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks from initialization mockStatement.run.mockClear(); mockDb.prepare.mockClear(); // With -1 threshold, 0 > -1 is true, so should vacuum - const result = db.vacuumIfNeeded(-1); + const result = await db.vacuumIfNeeded(-1); expect(result.vacuumed).toBe(true); expect(mockDb.prepare).toHaveBeenCalledWith('VACUUM'); @@ -342,14 +364,14 @@ describe('Database VACUUM functionality', () => { it('should not vacuum with very large threshold', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks from initialization mockStatement.run.mockClear(); mockDb.prepare.mockClear(); // With 1TB threshold, should NOT trigger vacuum - const result = db.vacuumIfNeeded(1024 * 1024 * 1024 * 1024); + const result = await db.vacuumIfNeeded(1024 * 1024 * 1024 * 1024); expect(result.vacuumed).toBe(false); expect(mockDb.prepare).not.toHaveBeenCalledWith('VACUUM'); @@ -369,7 +391,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // With old timestamp, vacuumIfNeededWeekly should proceed to call vacuumIfNeeded // which logs "below vacuum threshold" for small databases (mocked as 1024 bytes) @@ -396,7 +418,7 @@ describe('Database VACUUM functionality', () => { const db = new StatsDB(); // Initialize should not throw (vacuum is skipped due to 0 size anyway) - expect(() => db.initialize()).not.toThrow(); + await expect(db.initialize()).resolves.toBeUndefined(); // Database should still be ready expect(db.isReady()).toBe(true); @@ -408,7 +430,7 @@ describe('Database VACUUM functionality', () => { // Time the initialization (should be fast for mock) const start = Date.now(); - db.initialize(); + await db.initialize(); const elapsed = Date.now() - start; expect(db.isReady()).toBe(true); @@ -420,9 +442,9 @@ describe('Database VACUUM functionality', () => { it('vacuum should return object with success, bytesFreed, and optional error', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); - const result = db.vacuum(); + const result = await db.vacuum(); expect(typeof result.success).toBe('boolean'); expect(typeof result.bytesFreed).toBe('number'); @@ -432,9 +454,9 @@ describe('Database VACUUM functionality', () => { it('vacuumIfNeeded should return object with vacuumed, databaseSize, and optional result', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); - const result = db.vacuumIfNeeded(); + const result = await db.vacuumIfNeeded(); expect(typeof result.vacuumed).toBe('boolean'); expect(typeof result.databaseSize).toBe('number'); @@ -444,10 +466,10 @@ describe('Database VACUUM functionality', () => { it('vacuumIfNeeded should include result when vacuum is performed', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Use -1 threshold to force vacuum - const result = db.vacuumIfNeeded(-1); + const result = await db.vacuumIfNeeded(-1); expect(result.vacuumed).toBe(true); expect(result.result).toBeDefined(); @@ -478,7 +500,7 @@ describe('Database VACUUM functionality', () => { it('should return error when olderThanDays is 0 or negative', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const resultZero = db.clearOldData(0); expect(resultZero.success).toBe(false); @@ -496,7 +518,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const result = db.clearOldData(30); @@ -513,7 +535,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const result = db.clearOldData(365); @@ -542,7 +564,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const beforeCall = Date.now(); db.clearOldData(7); @@ -569,7 +591,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const result = db.clearOldData(30); @@ -586,7 +608,7 @@ describe('Database VACUUM functionality', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Test common time periods from Settings UI const periods = [7, 30, 90, 180, 365]; diff --git a/src/__tests__/main/stats/integration.test.ts b/src/__tests__/main/stats/integration.test.ts index 86c12326c..607128556 100644 --- a/src/__tests__/main/stats/integration.test.ts +++ b/src/__tests__/main/stats/integration.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -140,7 +162,7 @@ describe('Concurrent writes and database locking', () => { it('should enable WAL journal mode on initialization', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockDb.pragma).toHaveBeenCalledWith('journal_mode = WAL'); }); @@ -155,7 +177,7 @@ describe('Concurrent writes and database locking', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // WAL mode should be set early in initialization const walIndex = pragmaCalls.indexOf('journal_mode = WAL'); @@ -170,7 +192,7 @@ describe('Concurrent writes and database locking', () => { it('should handle 10 rapid sequential query event inserts', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -198,7 +220,7 @@ describe('Concurrent writes and database locking', () => { it('should handle 10 rapid sequential Auto Run session inserts', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -226,7 +248,7 @@ describe('Concurrent writes and database locking', () => { it('should handle 10 rapid sequential task inserts', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -256,7 +278,7 @@ describe('Concurrent writes and database locking', () => { it('should handle concurrent writes to different tables via Promise.all', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -304,7 +326,7 @@ describe('Concurrent writes and database locking', () => { it('should handle 20 concurrent query event inserts via Promise.all', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -332,7 +354,7 @@ describe('Concurrent writes and database locking', () => { it('should handle mixed insert and update operations concurrently', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -394,7 +416,7 @@ describe('Concurrent writes and database locking', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const operations = [ // Write @@ -441,7 +463,7 @@ describe('Concurrent writes and database locking', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Start multiple writes const writes = Array.from({ length: 5 }, (_, i) => @@ -470,7 +492,7 @@ describe('Concurrent writes and database locking', () => { it('should handle 50 concurrent writes without data loss', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Reset counter after initialize() to count only test operations const insertedCount = { value: 0 }; @@ -525,7 +547,7 @@ describe('Concurrent writes and database locking', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // 40 query events + 30 sessions + 30 tasks = 100 writes const queryWrites = Array.from({ length: 40 }, (_, i) => @@ -580,7 +602,7 @@ describe('Concurrent writes and database locking', () => { it('should generate unique IDs even with high-frequency calls', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Generate 100 IDs as fast as possible const ids: string[] = []; @@ -602,7 +624,7 @@ describe('Concurrent writes and database locking', () => { it('should generate IDs with timestamp-random format', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const id = db.insertQueryEvent({ sessionId: 'session-1', @@ -621,7 +643,7 @@ describe('Concurrent writes and database locking', () => { it('should maintain stable connection during intensive operations', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Perform many operations for (let i = 0; i < 30; i++) { @@ -641,7 +663,7 @@ describe('Concurrent writes and database locking', () => { it('should handle operations after previous operations complete', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Track call count manually since we're testing sequential batches // Set up tracking AFTER initialize() to count only test operations @@ -983,7 +1005,7 @@ describe('electron-rebuild verification for better-sqlite3', () => { const db = new StatsDB(); // Should be able to initialize with mocked database - expect(() => db.initialize()).not.toThrow(); + await expect(db.initialize()).resolves.toBeUndefined(); expect(db.isReady()).toBe(true); }); @@ -993,7 +1015,7 @@ describe('electron-rebuild verification for better-sqlite3', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Database should be initialized and ready expect(db.isReady()).toBe(true); diff --git a/src/__tests__/main/stats/paths.test.ts b/src/__tests__/main/stats/paths.test.ts index 6e94cfc6d..7c4a9594d 100644 --- a/src/__tests__/main/stats/paths.test.ts +++ b/src/__tests__/main/stats/paths.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -127,7 +149,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(macOsUserData, 'stats.db')); }); @@ -152,7 +174,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(macOsUserData, 'stats.db')); }); @@ -178,7 +200,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // path.join will use the platform's native separator expect(lastDbPath).toBe(path.join(windowsUserData, 'stats.db')); @@ -205,7 +227,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(windowsUserData, 'stats.db')); }); @@ -217,7 +239,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(windowsUncPath, 'stats.db')); }); @@ -230,7 +252,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(portablePath, 'stats.db')); }); @@ -245,7 +267,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(linuxUserData, 'stats.db')); }); @@ -258,7 +280,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(customConfigHome, 'stats.db')); }); @@ -270,7 +292,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(linuxUserData, 'stats.db')); }); @@ -294,7 +316,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(snapPath, 'stats.db')); }); @@ -360,7 +382,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockFsMkdirSync).toHaveBeenCalledWith(macOsUserData, { recursive: true }); }); @@ -373,7 +395,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockFsMkdirSync).toHaveBeenCalledWith(windowsUserData, { recursive: true }); }); @@ -386,7 +408,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockFsMkdirSync).toHaveBeenCalledWith(linuxUserData, { recursive: true }); }); @@ -399,7 +421,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockFsMkdirSync).toHaveBeenCalledWith(deepPath, { recursive: true }); }); @@ -413,7 +435,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(unicodePath, 'stats.db')); }); @@ -425,7 +447,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(emojiPath, 'stats.db')); }); @@ -450,7 +472,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(quotedPath, 'stats.db')); }); @@ -475,7 +497,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(lastDbPath).toBe(path.join(ampersandPath, 'stats.db')); }); @@ -520,7 +542,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(db.isReady()).toBe(true); } @@ -546,7 +568,7 @@ describe('Cross-platform database path resolution (macOS, Windows, Linux)', () = const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockFsMkdirSync).toHaveBeenCalledWith(platformPath, { recursive: true }); } @@ -701,7 +723,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should normalize Windows projectPath to forward slashes', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertQueryEvent({ sessionId: 'session-1', @@ -731,7 +753,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should preserve Unix projectPath unchanged', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertQueryEvent({ sessionId: 'session-1', @@ -760,7 +782,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should store null for undefined projectPath', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertQueryEvent({ sessionId: 'session-1', @@ -804,7 +826,7 @@ describe('File path normalization in database (forward slashes consistently)', ( const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Query with Windows-style path (backslashes) const events = db.getQueryEvents('day', { @@ -824,7 +846,7 @@ describe('File path normalization in database (forward slashes consistently)', ( const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week', { projectPath: '/Users/testuser/Projects/MyApp', @@ -839,7 +861,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should normalize Windows documentPath and projectPath', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertAutoRunSession({ sessionId: 'session-1', @@ -868,7 +890,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should handle null paths correctly', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.insertAutoRunSession({ sessionId: 'session-1', @@ -896,7 +918,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should normalize Windows documentPath on update', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.updateAutoRunSession('auto-run-1', { duration: 120000, @@ -911,7 +933,7 @@ describe('File path normalization in database (forward slashes consistently)', ( it('should handle undefined documentPath in update (no change)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.updateAutoRunSession('auto-run-1', { duration: 120000, @@ -956,7 +978,7 @@ describe('File path normalization in database (forward slashes consistently)', ( const { StatsDB, normalizePath } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Both Windows and Unix style filters should normalize to the same value const windowsFilter = 'C:\\Users\\TestUser\\Projects\\MyApp'; diff --git a/src/__tests__/main/stats/query-events.test.ts b/src/__tests__/main/stats/query-events.test.ts index 8ff8bc39f..1f4d43b57 100644 --- a/src/__tests__/main/stats/query-events.test.ts +++ b/src/__tests__/main/stats/query-events.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + // Mock fs vi.mock('fs', () => ({ @@ -80,7 +93,16 @@ vi.mock('fs', () => ({ statSync: (...args: unknown[]) => mockFsStatSync(...args), readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -122,7 +144,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('day'); @@ -138,7 +160,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week', { agentType: 'claude-code' }); @@ -151,7 +173,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('month', { source: 'auto' }); @@ -164,7 +186,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('year', { projectPath: '/test/project' }); @@ -177,7 +199,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('all', { sessionId: 'session-123' }); @@ -190,7 +212,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.getQueryEvents('week', { agentType: 'claude-code', @@ -214,7 +236,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -229,7 +251,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('day'); @@ -257,7 +279,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const csv = db.exportToCsv('week'); @@ -273,7 +295,7 @@ describe('Stats aggregation and filtering', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const csv = db.exportToCsv('day'); @@ -310,7 +332,7 @@ describe('Query events recorded for interactive sessions', () => { it('should record query event with source="user" for interactive session', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const startTime = Date.now(); const eventId = db.insertQueryEvent({ @@ -343,7 +365,7 @@ describe('Query events recorded for interactive sessions', () => { it('should record interactive query without optional fields', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const startTime = Date.now(); const eventId = db.insertQueryEvent({ @@ -367,7 +389,7 @@ describe('Query events recorded for interactive sessions', () => { it('should record multiple interactive queries for the same session', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -419,7 +441,7 @@ describe('Query events recorded for interactive sessions', () => { it('should record interactive queries with different agent types', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Clear mocks after initialize() to count only test operations mockStatement.run.mockClear(); @@ -493,7 +515,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Filter by source='user' to get only interactive sessions const events = db.getQueryEvents('day', { source: 'user' }); @@ -522,7 +544,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const events = db.getQueryEvents('week', { sessionId: 'target-session' }); @@ -547,7 +569,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const events = db.getQueryEvents('month', { projectPath: '/specific/project' }); @@ -572,7 +594,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const events = db.getQueryEvents('day'); @@ -614,7 +636,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('week'); @@ -644,7 +666,7 @@ describe('Query events recorded for interactive sessions', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const stats = db.getAggregatedStats('month'); @@ -657,7 +679,7 @@ describe('Query events recorded for interactive sessions', () => { it('should preserve exact startTime and duration values', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const exactStartTime = 1735344000000; // Specific timestamp const exactDuration = 12345; // Specific duration in ms @@ -680,7 +702,7 @@ describe('Query events recorded for interactive sessions', () => { it('should handle zero duration (immediate responses)', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const eventId = db.insertQueryEvent({ sessionId: 'zero-duration-session', @@ -700,7 +722,7 @@ describe('Query events recorded for interactive sessions', () => { it('should handle very long durations', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const longDuration = 10 * 60 * 1000; // 10 minutes in ms diff --git a/src/__tests__/main/stats/stats-db.test.ts b/src/__tests__/main/stats/stats-db.test.ts index 6e3b2d606..43b3c5faf 100644 --- a/src/__tests__/main/stats/stats-db.test.ts +++ b/src/__tests__/main/stats/stats-db.test.ts @@ -69,6 +69,19 @@ const mockFsRenameSync = vi.fn(); const mockFsStatSync = vi.fn(() => ({ size: 1024 })); const mockFsReadFileSync = vi.fn(() => '0'); // Default: old timestamp (triggers vacuum check) const mockFsWriteFileSync = vi.fn(); +const mockFsAccess = vi.fn((pathArg: string) => { + if (mockFsExistsSync(pathArg)) { + return Promise.resolve(); + } + return Promise.reject(new Error('ENOENT')); + }); +const mockFsMkdir = vi.fn(() => Promise.resolve()); +const mockFsStat = vi.fn(() => Promise.resolve({ size: 1024 })); +const mockFsCopyFile = vi.fn(() => Promise.resolve()); +const mockFsUnlink = vi.fn(() => Promise.resolve()); +const mockFsRename = vi.fn(() => Promise.resolve()); +const mockFsReaddir = vi.fn(() => Promise.resolve([] as string[])); + const mockFsReaddirSync = vi.fn(() => [] as string[]); // Default: empty directory // Mock fs @@ -82,7 +95,16 @@ vi.mock('fs', () => ({ readFileSync: (...args: unknown[]) => mockFsReadFileSync(...args), writeFileSync: (...args: unknown[]) => mockFsWriteFileSync(...args), readdirSync: (...args: unknown[]) => mockFsReaddirSync(...args), -})); + promises: { + access: (...args: unknown[]) => mockFsAccess(...args), + mkdir: (...args: unknown[]) => mockFsMkdir(...args), + stat: (...args: unknown[]) => mockFsStat(...args), + copyFile: (...args: unknown[]) => mockFsCopyFile(...args), + unlink: (...args: unknown[]) => mockFsUnlink(...args), + readdir: (...args: unknown[]) => mockFsReaddir(...args), + rename: (...args: unknown[]) => mockFsRename(...args), + } + })); // Mock logger vi.mock('../../../main/utils/logger', () => ({ @@ -159,7 +181,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(db.isReady()).toBe(true); }); @@ -168,7 +190,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(mockDb.pragma).toHaveBeenCalledWith('journal_mode = WAL'); }); @@ -181,7 +203,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should set user_version to 1 expect(mockDb.pragma).toHaveBeenCalledWith('user_version = 1'); @@ -195,7 +217,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should NOT set user_version (no migration needed) expect(mockDb.pragma).not.toHaveBeenCalledWith('user_version = 1'); @@ -209,7 +231,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have prepared the CREATE TABLE IF NOT EXISTS _migrations statement expect(mockDb.prepare).toHaveBeenCalledWith( @@ -225,7 +247,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have inserted a success record into _migrations expect(mockDb.prepare).toHaveBeenCalledWith( @@ -241,7 +263,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have used transaction expect(mockDb.transaction).toHaveBeenCalled(); @@ -274,7 +296,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(db.getCurrentVersion()).toBe(1); }); @@ -282,7 +304,7 @@ describe('StatsDB class (mocked)', () => { it('should return target version via getTargetVersion()', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Currently we have version 4 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table, v4: compound indexes) expect(db.getTargetVersion()).toBe(4); @@ -296,7 +318,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); expect(db.hasPendingMigrations()).toBe(false); }); @@ -318,7 +340,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // At version 4, target is 4, so no pending migrations expect(db.getCurrentVersion()).toBe(4); @@ -331,7 +353,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const history = db.getMigrationHistory(); expect(history).toEqual([]); @@ -353,7 +375,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const history = db.getMigrationHistory(); expect(history).toHaveLength(1); @@ -382,7 +404,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const history = db.getMigrationHistory(); expect(history[0].status).toBe('failed'); @@ -425,7 +447,7 @@ describe('StatsDB class (mocked)', () => { it('should insert a query event and return an id', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const eventId = db.insertQueryEvent({ sessionId: 'session-1', @@ -458,7 +480,7 @@ describe('StatsDB class (mocked)', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const events = db.getQueryEvents('day'); @@ -472,7 +494,7 @@ describe('StatsDB class (mocked)', () => { it('should close the database connection', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.close(); @@ -517,7 +539,7 @@ describe('Database file creation on first launch', () => { it('should create database file at userData/stats.db path', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Verify better-sqlite3 was called with the correct path expect(lastDbPath).toBe(path.join(mockUserDataPath, 'stats.db')); @@ -541,7 +563,7 @@ describe('Database file creation on first launch', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Verify mkdirSync was called with recursive option expect(mockFsMkdirSync).toHaveBeenCalledWith(mockUserDataPath, { recursive: true }); @@ -553,7 +575,7 @@ describe('Database file creation on first launch', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Verify mkdirSync was NOT called expect(mockFsMkdirSync).not.toHaveBeenCalled(); @@ -566,7 +588,7 @@ describe('Database file creation on first launch', () => { const db = new StatsDB(); expect(db.isReady()).toBe(false); - db.initialize(); + await db.initialize(); expect(db.isReady()).toBe(true); }); @@ -576,10 +598,10 @@ describe('Database file creation on first launch', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const firstCallCount = mockDb.pragma.mock.calls.length; - db.initialize(); // Second call should be a no-op + await db.initialize(); // Second call should be a no-op const secondCallCount = mockDb.pragma.mock.calls.length; expect(secondCallCount).toBe(firstCallCount); @@ -588,7 +610,7 @@ describe('Database file creation on first launch', () => { it('should create all three tables on fresh database', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Verify prepare was called with CREATE TABLE statements const prepareCalls = mockDb.prepare.mock.calls.map((call) => call[0]); @@ -616,7 +638,7 @@ describe('Database file creation on first launch', () => { it('should create all required indexes', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const prepareCalls = mockDb.prepare.mock.calls.map((call) => call[0]); @@ -653,7 +675,7 @@ describe('Database file creation on first launch', () => { it('should initialize database via initializeStatsDB', async () => { const { initializeStatsDB, getStatsDB, closeStatsDB } = await import('../../../main/stats'); - initializeStatsDB(); + await initializeStatsDB(); const db = getStatsDB(); expect(db.isReady()).toBe(true); @@ -665,7 +687,7 @@ describe('Database file creation on first launch', () => { it('should close database and reset singleton via closeStatsDB', async () => { const { initializeStatsDB, getStatsDB, closeStatsDB } = await import('../../../main/stats'); - initializeStatsDB(); + await initializeStatsDB(); const dbBefore = getStatsDB(); expect(dbBefore.isReady()).toBe(true); @@ -698,6 +720,7 @@ describe('Daily backup system', () => { mockStatement.all.mockReturnValue([]); mockFsExistsSync.mockReturnValue(true); mockFsReaddirSync.mockReturnValue([]); + mockFsReaddir.mockReturnValue([]); }); afterEach(() => { @@ -707,10 +730,11 @@ describe('Daily backup system', () => { describe('getAvailableBackups', () => { it('should return empty array when no backups exist', async () => { mockFsReaddirSync.mockReturnValue([]); + mockFsReaddir.mockReturnValue([]); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const backups = db.getAvailableBackups(); expect(backups).toEqual([]); @@ -722,10 +746,15 @@ describe('Daily backup system', () => { 'stats.db.daily.2026-02-02', 'stats.db.daily.2026-02-03', ]); + mockFsReaddir.mockReturnValue([ + 'stats.db.daily.2026-02-01', + 'stats.db.daily.2026-02-02', + 'stats.db.daily.2026-02-03', + ]); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const backups = db.getAvailableBackups(); expect(backups).toHaveLength(3); @@ -738,10 +767,11 @@ describe('Daily backup system', () => { // Timestamp for 2026-02-03 const timestamp = new Date('2026-02-03').getTime(); mockFsReaddirSync.mockReturnValue([`stats.db.backup.${timestamp}`]); + mockFsReaddir.mockReturnValue([`stats.db.backup.${timestamp}`]); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const backups = db.getAvailableBackups(); expect(backups).toHaveLength(1); @@ -754,10 +784,15 @@ describe('Daily backup system', () => { 'stats.db.daily.2026-02-01', 'stats.db.daily.2026-01-20', ]); + mockFsReaddir.mockReturnValue([ + 'stats.db.daily.2026-01-15', + 'stats.db.daily.2026-02-01', + 'stats.db.daily.2026-01-20', + ]); const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const backups = db.getAvailableBackups(); expect(backups[0].date).toBe('2026-02-01'); @@ -775,7 +810,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); const result = db.restoreFromBackup('/path/to/nonexistent/backup'); expect(result).toBe(false); @@ -784,7 +819,7 @@ describe('Daily backup system', () => { it('should close database before restoring', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.restoreFromBackup('/path/to/backup'); @@ -794,7 +829,7 @@ describe('Daily backup system', () => { it('should copy backup file to main database path', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.restoreFromBackup('/path/to/backup.db'); @@ -807,7 +842,7 @@ describe('Daily backup system', () => { it('should remove WAL and SHM files before restoring', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); db.restoreFromBackup('/path/to/backup.db'); @@ -827,7 +862,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have attempted to copy the database for backup expect(mockFsCopyFileSync).toHaveBeenCalled(); @@ -842,7 +877,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // copyFileSync should not be called for daily backup (might be called for other reasons) const dailyBackupCalls = mockFsCopyFileSync.mock.calls.filter( @@ -868,7 +903,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have removed WAL and SHM files const walRemoved = unlinkCalls.some((p) => p.endsWith('-wal')); @@ -886,7 +921,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - expect(() => db.initialize()).not.toThrow(); + await expect(db.initialize()).resolves.toBeUndefined(); }); }); @@ -900,7 +935,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); // Should have called wal_checkpoint(TRUNCATE) before copyFileSync expect(mockDb.pragma).toHaveBeenCalledWith('wal_checkpoint(TRUNCATE)'); @@ -909,7 +944,7 @@ describe('Daily backup system', () => { it('should checkpoint WAL before creating manual backup', async () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockDb.pragma.mockClear(); db.backupDatabase(); @@ -932,7 +967,7 @@ describe('Daily backup system', () => { const { StatsDB } = await import('../../../main/stats'); const db = new StatsDB(); - db.initialize(); + await db.initialize(); mockDb.pragma.mockClear(); mockFsCopyFileSync.mockClear(); diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 4f5314a77..cb2d52b95 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -49,9 +49,9 @@ vi.mock('../../../main/themes', () => ({ // Mock history manager vi.mock('../../../main/history-manager', () => ({ getHistoryManager: vi.fn().mockReturnValue({ - getEntries: vi.fn().mockReturnValue([]), - getEntriesByProjectPath: vi.fn().mockReturnValue([]), - getAllEntries: vi.fn().mockReturnValue([]), + getEntries: vi.fn().mockResolvedValue([]), + getEntriesByProjectPath: vi.fn().mockResolvedValue([]), + getAllEntries: vi.fn().mockResolvedValue([]), }), })); @@ -198,7 +198,7 @@ describe('web-server/web-server-factory', () => { }); }); - describe('callback registrations', () => { +describe('callback registrations', () => { let createWebServer: ReturnType; let server: ReturnType; @@ -412,9 +412,9 @@ describe('web-server/web-server-factory', () => { }); describe('getHistoryCallback behavior', () => { - it('should get entries for specific session', () => { + it('should get entries for specific session', async () => { const mockHistoryManager = { - getEntries: vi.fn().mockReturnValue([{ id: 1 }]), + getEntries: vi.fn().mockResolvedValue([{ id: 1 }]), getEntriesByProjectPath: vi.fn(), getAllEntries: vi.fn(), }; @@ -426,15 +426,15 @@ describe('web-server/web-server-factory', () => { const setHistoryCallback = server.setGetHistoryCallback as ReturnType; const callback = setHistoryCallback.mock.calls[0][0]; - callback(undefined, 'session-1'); + await callback(undefined, 'session-1'); expect(mockHistoryManager.getEntries).toHaveBeenCalledWith('session-1'); }); - it('should get entries by project path', () => { + it('should get entries by project path', async () => { const mockHistoryManager = { getEntries: vi.fn(), - getEntriesByProjectPath: vi.fn().mockReturnValue([{ id: 1 }]), + getEntriesByProjectPath: vi.fn().mockResolvedValue([{ id: 1 }]), getAllEntries: vi.fn(), }; vi.mocked(getHistoryManager).mockReturnValue(mockHistoryManager as any); @@ -445,16 +445,16 @@ describe('web-server/web-server-factory', () => { const setHistoryCallback = server.setGetHistoryCallback as ReturnType; const callback = setHistoryCallback.mock.calls[0][0]; - callback('/test/project'); + await callback('/test/project'); expect(mockHistoryManager.getEntriesByProjectPath).toHaveBeenCalledWith('/test/project'); }); - it('should get all entries when no filter', () => { + it('should get all entries when no filter', async () => { const mockHistoryManager = { getEntries: vi.fn(), getEntriesByProjectPath: vi.fn(), - getAllEntries: vi.fn().mockReturnValue([{ id: 1 }]), + getAllEntries: vi.fn().mockResolvedValue([{ id: 1 }]), }; vi.mocked(getHistoryManager).mockReturnValue(mockHistoryManager as any); @@ -464,7 +464,7 @@ describe('web-server/web-server-factory', () => { const setHistoryCallback = server.setGetHistoryCallback as ReturnType; const callback = setHistoryCallback.mock.calls[0][0]; - callback(); + await callback(); expect(mockHistoryManager.getAllEntries).toHaveBeenCalled(); }); diff --git a/src/main/debug-package/collectors/group-chats.ts b/src/main/debug-package/collectors/group-chats.ts index 99f723ca0..62b804fab 100644 --- a/src/main/debug-package/collectors/group-chats.ts +++ b/src/main/debug-package/collectors/group-chats.ts @@ -23,12 +23,9 @@ export interface GroupChatInfo { /** * Count messages in a group chat log file without loading content. */ -function countMessages(logPath: string): number { +async function countMessages(logPath: string): Promise { try { - if (!fs.existsSync(logPath)) { - return 0; - } - const content = fs.readFileSync(logPath, 'utf-8'); + const content = await fs.promises.readFile(logPath, 'utf-8'); // Each line is a JSON message return content.split('\n').filter((line) => line.trim()).length; } catch { @@ -44,49 +41,46 @@ export async function collectGroupChats(): Promise { const groupChatsPath = path.join(app.getPath('userData'), 'group-chats'); - if (!fs.existsSync(groupChatsPath)) { + let files: string[]; + try { + files = await fs.promises.readdir(groupChatsPath); + } catch { return groupChats; } - try { - const files = fs.readdirSync(groupChatsPath); - - for (const file of files) { - if (!file.endsWith('.json') || file.endsWith('.log.json')) { - continue; - } + for (const file of files) { + if (!file.endsWith('.json') || file.endsWith('.log.json')) { + continue; + } - const filePath = path.join(groupChatsPath, file); + const filePath = path.join(groupChatsPath, file); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const chat = JSON.parse(content); + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + const chat = JSON.parse(content); - // Get corresponding log file for message count - const logPath = path.join(groupChatsPath, `${path.basename(file, '.json')}.log.json`); - const messageCount = countMessages(logPath); + // Get corresponding log file for message count + const logPath = path.join(groupChatsPath, `${path.basename(file, '.json')}.log.json`); + const messageCount = await countMessages(logPath); - const chatInfo: GroupChatInfo = { - id: chat.id || path.basename(file, '.json'), - moderatorAgentId: chat.moderatorAgentId || chat.moderator?.agentId || 'unknown', - participantCount: Array.isArray(chat.participants) ? chat.participants.length : 0, - participants: Array.isArray(chat.participants) - ? chat.participants.map((p: any) => ({ - agentId: p.agentId || 'unknown', - })) - : [], - messageCount, - createdAt: chat.createdAt || 0, - updatedAt: chat.updatedAt || 0, - }; + const chatInfo: GroupChatInfo = { + id: chat.id || path.basename(file, '.json'), + moderatorAgentId: chat.moderatorAgentId || chat.moderator?.agentId || 'unknown', + participantCount: Array.isArray(chat.participants) ? chat.participants.length : 0, + participants: Array.isArray(chat.participants) + ? chat.participants.map((p: any) => ({ + agentId: p.agentId || 'unknown', + })) + : [], + messageCount, + createdAt: chat.createdAt || 0, + updatedAt: chat.updatedAt || 0, + }; - groupChats.push(chatInfo); - } catch { - // Skip files that can't be parsed - } + groupChats.push(chatInfo); + } catch { + // Skip files that can't be parsed } - } catch { - // Directory read failed } return groupChats; diff --git a/src/main/debug-package/collectors/storage.ts b/src/main/debug-package/collectors/storage.ts index eaaea0aa9..125faac31 100644 --- a/src/main/debug-package/collectors/storage.ts +++ b/src/main/debug-package/collectors/storage.ts @@ -20,7 +20,7 @@ export interface StorageInfo { groupChats: string; // Sanitized customSyncPath?: string; // Just "[SET]" or not present }; - sizes: { +\tsizes: { sessionsBytes: number; historyBytes: number; logsBytes: number; @@ -32,26 +32,23 @@ export interface StorageInfo { /** * Get the size of a directory recursively. */ -function getDirectorySize(dirPath: string): number { +async function getDirectorySize(dirPath: string): Promise { try { - if (!fs.existsSync(dirPath)) { - return 0; - } + const stats = await fs.promises.stat(dirPath); - const stats = fs.statSync(dirPath); if (!stats.isDirectory()) { return stats.size; } let totalSize = 0; - const files = fs.readdirSync(dirPath); + const files = await fs.promises.readdir(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); try { - const fileStats = fs.statSync(filePath); + const fileStats = await fs.promises.stat(filePath); if (fileStats.isDirectory()) { - totalSize += getDirectorySize(filePath); + totalSize += await getDirectorySize(filePath); } else { totalSize += fileStats.size; } @@ -69,12 +66,9 @@ function getDirectorySize(dirPath: string): number { /** * Get the size of a file. */ -function getFileSize(filePath: string): number { +async function getFileSize(filePath: string): Promise { try { - if (!fs.existsSync(filePath)) { - return 0; - } - const stats = fs.statSync(filePath); + const stats = await fs.promises.stat(filePath); return stats.size; } catch { return 0; @@ -95,6 +89,11 @@ export async function collectStorage(bootstrapStore?: Store): Promise): Promise; write(sessionId: string, data: string): boolean; diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 42dcc5718..e03b710c1 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -544,7 +544,7 @@ ${message}`; console.log(`[GroupChat:Debug] Windows shell config: ${winConfig.shell}`); } - const spawnResult = processManager.spawn({ + const spawnResult = await processManager.spawn({ sessionId, toolType: chat.moderatorAgentId, cwd: spawnCwd, @@ -934,7 +934,7 @@ export async function routeModeratorResponse( ); } - const spawnResult = processManager.spawn({ + const spawnResult = await processManager.spawn({ sessionId, toolType: participant.agentId, cwd: finalSpawnCwd, @@ -1262,7 +1262,7 @@ Review the agent responses above. Either: console.log(`[GroupChat:Debug] Windows shell config for synthesis: ${winConfig.shell}`); } - const spawnResult = processManager.spawn({ + const spawnResult = await processManager.spawn({ sessionId, toolType: chat.moderatorAgentId, cwd: os.homedir(), @@ -1462,7 +1462,7 @@ export async function respawnParticipantWithRecovery( console.log(`[GroupChat:Debug] Windows shell config for recovery: ${winConfig.shell}`); } - const spawnResult = processManager.spawn({ + const spawnResult = await processManager.spawn({ sessionId, toolType: participant.agentId, cwd: finalSpawnCwd, diff --git a/src/main/index.ts b/src/main/index.ts index 0afff4436..6b5c68c99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -305,7 +305,7 @@ app.whenReady().then(async () => { }); // Check for WSL + Windows mount issues early - checkWslEnvironment(process.cwd()); + await checkWslEnvironment(process.cwd()); // Initialize core services logger.info('Initializing core services', 'Startup'); @@ -354,7 +354,7 @@ app.whenReady().then(async () => { // Initialize stats database for usage tracking logger.info('Initializing stats database', 'Startup'); try { - initializeStatsDB(); + await initializeStatsDB(); logger.info('Stats database initialized', 'Startup'); } catch (error) { // Stats initialization failed - log error but continue with app startup diff --git a/src/main/ipc/handlers/agentSessions.ts b/src/main/ipc/handlers/agentSessions.ts index a608ca6e1..5f4336fca 100644 --- a/src/main/ipc/handlers/agentSessions.ts +++ b/src/main/ipc/handlers/agentSessions.ts @@ -48,6 +48,17 @@ export type { GlobalAgentStats, ProviderStats }; const LOG_CONTEXT = '[AgentSessions]'; +const SESSION_DISCOVERY_CACHE_TTL_MS = 30 * 1000; +const SESSION_DISCOVERY_BATCH_SIZE = 10; + +interface SessionDiscoveryCache { + timestampMs: number; + claudeFiles: SessionFileInfo[]; + codexFiles: SessionFileInfo[]; +} + +let sessionDiscoveryCache: SessionDiscoveryCache | null = null; + /** * Generic agent session origins data structure * Structure: { [agentId]: { [projectPath]: { [sessionId]: { origin, sessionName, starred } } } } @@ -95,6 +106,21 @@ function handlerOpts(operation: string) { return { context: LOG_CONTEXT, operation, logSuccess: false }; } +function chunkArray(items: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += chunkSize) { + chunks.push(items.slice(i, i + chunkSize)); + } + return chunks; +} + +function isSessionDiscoveryCacheFresh(cache: SessionDiscoveryCache | null): boolean { + if (!cache) return false; + + const now = Date.now(); + return now >= cache.timestampMs && now - cache.timestampMs < SESSION_DISCOVERY_CACHE_TTL_MS; +} + /** * File info for incremental scanning */ @@ -211,29 +237,45 @@ async function discoverClaudeSessionFiles(): Promise { const projectDirs = await fs.readdir(claudeProjectsDir); - for (const projectDir of projectDirs) { - const projectPath = path.join(claudeProjectsDir, projectDir); - try { - const stat = await fs.stat(projectPath); - if (!stat.isDirectory()) continue; + const projectDirBatches = chunkArray(projectDirs, SESSION_DISCOVERY_BATCH_SIZE); + for (const batch of projectDirBatches) { + const batchEntries = await Promise.all( + batch.map(async (projectDir) => { + const projectPath = path.join(claudeProjectsDir, projectDir); + try { + const stat = await fs.stat(projectPath); + if (!stat.isDirectory()) { + return [] as SessionFileInfo[]; + } - const dirFiles = await fs.readdir(projectPath); - const sessionFiles = dirFiles.filter((f) => f.endsWith('.jsonl')); + const dirFiles = await fs.readdir(projectPath); + const sessionFiles = dirFiles.filter((f) => f.endsWith('.jsonl')); + + const sessionEntries = await Promise.all( + sessionFiles.map(async (filename) => { + const filePath = path.join(projectPath, filename); + try { + const fileStat = await fs.stat(filePath); + // Skip 0-byte sessions (created but abandoned before any content was written) + if (fileStat.size === 0) return null; + const sessionKey = `${projectDir}/${filename.replace('.jsonl', '')}`; + return { filePath, sessionKey, mtimeMs: fileStat.mtimeMs }; + } catch { + return null; + } + }) + ); - for (const filename of sessionFiles) { - const filePath = path.join(projectPath, filename); - try { - const fileStat = await fs.stat(filePath); - // Skip 0-byte sessions (created but abandoned before any content was written) - if (fileStat.size === 0) continue; - const sessionKey = `${projectDir}/${filename.replace('.jsonl', '')}`; - files.push({ filePath, sessionKey, mtimeMs: fileStat.mtimeMs }); + return sessionEntries.filter((entry): entry is SessionFileInfo => entry !== null); } catch { - // Skip files we can't stat + // Skip directories we can't access + return [] as SessionFileInfo[]; } - } - } catch { - // Skip directories we can't access + }) + ); + + for (const entry of batchEntries) { + files.push(...entry); } } @@ -256,6 +298,8 @@ async function discoverCodexSessionFiles(): Promise { } const years = await fs.readdir(codexSessionsDir); + const dayDirectories: Array<{ dayDir: string; sessionKeyPrefix: string }> = []; + for (const year of years) { if (!/^\d{4}$/.test(year)) continue; const yearDir = path.join(codexSessionsDir, year); @@ -281,22 +325,10 @@ async function discoverCodexSessionFiles(): Promise { try { const dayStat = await fs.stat(dayDir); if (!dayStat.isDirectory()) continue; - - const dirFiles = await fs.readdir(dayDir); - for (const file of dirFiles) { - if (!file.endsWith('.jsonl')) continue; - const filePath = path.join(dayDir, file); - - try { - const fileStat = await fs.stat(filePath); - // Skip 0-byte sessions (created but abandoned before any content was written) - if (fileStat.size === 0) continue; - const sessionKey = `${year}/${month}/${day}/${file.replace('.jsonl', '')}`; - files.push({ filePath, sessionKey, mtimeMs: fileStat.mtimeMs }); - } catch { - // Skip files we can't stat - } - } + dayDirectories.push({ + dayDir, + sessionKeyPrefix: `${year}/${month}/${day}`, + }); } catch { continue; } @@ -310,6 +342,41 @@ async function discoverCodexSessionFiles(): Promise { } } + const dayDirectoryBatches = chunkArray(dayDirectories, SESSION_DISCOVERY_BATCH_SIZE); + for (const batch of dayDirectoryBatches) { + const batchEntries = await Promise.all( + batch.map(async (entry) => { + try { + const dirFiles = await fs.readdir(entry.dayDir); + const sessionFiles = dirFiles.filter((f) => f.endsWith('.jsonl')); + + const daySessionEntries = await Promise.all( + sessionFiles.map(async (file) => { + const filePath = path.join(entry.dayDir, file); + try { + const fileStat = await fs.stat(filePath); + // Skip 0-byte sessions (created but abandoned before any content was written) + if (fileStat.size === 0) return null; + const sessionKey = `${entry.sessionKeyPrefix}/${file.replace('.jsonl', '')}`; + return { filePath, sessionKey, mtimeMs: fileStat.mtimeMs }; + } catch { + return null; + } + }) + ); + + return daySessionEntries.filter((item): item is SessionFileInfo => item !== null); + } catch { + return [] as SessionFileInfo[]; + } + }) + ); + + for (const entry of batchEntries) { + files.push(...entry); + } + } + return files; } @@ -372,6 +439,38 @@ function aggregateProviderStats( }; } +async function discoverSessionFilesWithCache(): Promise<{ + claudeFiles: SessionFileInfo[]; + codexFiles: SessionFileInfo[]; +}> { + if (isSessionDiscoveryCacheFresh(sessionDiscoveryCache)) { + return { + claudeFiles: [...sessionDiscoveryCache!.claudeFiles], + codexFiles: [...sessionDiscoveryCache!.codexFiles], + }; + } + + const [claudeFiles, codexFiles] = await Promise.all([ + discoverClaudeSessionFiles(), + discoverCodexSessionFiles(), + ]); + + sessionDiscoveryCache = { + timestampMs: Date.now(), + claudeFiles, + codexFiles, + }; + + return { + claudeFiles: [...claudeFiles], + codexFiles: [...codexFiles], + }; +} + +export function __clearSessionDiscoveryCacheForTests(): void { + sessionDiscoveryCache = null; +} + /** * Register all agent sessions IPC handlers. */ @@ -849,10 +948,7 @@ export function registerAgentSessionsHandlers(deps?: AgentSessionsHandlerDepende // Discover all session files logger.info('Discovering session files for global stats', LOG_CONTEXT); - const [claudeFiles, codexFiles] = await Promise.all([ - discoverClaudeSessionFiles(), - discoverCodexSessionFiles(), - ]); + const { claudeFiles, codexFiles } = await discoverSessionFilesWithCache(); // Build sets of current session keys for archive detection const currentClaudeKeys = new Set(claudeFiles.map((f) => f.sessionKey)); diff --git a/src/main/ipc/handlers/groupChat.ts b/src/main/ipc/handlers/groupChat.ts index a2a97622b..c03da8985 100644 --- a/src/main/ipc/handlers/groupChat.ts +++ b/src/main/ipc/handlers/groupChat.ts @@ -128,7 +128,7 @@ interface GenericProcessManager { customEnvVars?: Record; contextWindow?: number; noPromptSeparator?: boolean; - }): { pid: number; success: boolean }; + }): Promise<{ pid: number; success: boolean }>; write(sessionId: string, data: string): boolean; kill(sessionId: string): boolean; on(event: string, handler: (...args: unknown[]) => void): void; diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index f03ea312d..08edb1c94 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -494,7 +494,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void globalEnvVarsCount: Object.keys(globalShellEnvVars).length, }); - const result = processManager.spawn({ + const result = await processManager.spawn({ ...config, command: commandToSpawn, args: argsToSpawn, diff --git a/src/main/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts index 6eeb2b248..d7c57ffef 100644 --- a/src/main/ipc/handlers/tabNaming.ts +++ b/src/main/ipc/handlers/tabNaming.ts @@ -247,7 +247,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v // Spawn the process // When using SSH with stdin, pass the flag so ChildProcessSpawner // sends the prompt via stdin instead of command line args - processManager.spawn({ + void processManager.spawn({ sessionId, toolType: config.agentType, cwd, diff --git a/src/main/process-manager/ProcessManager.ts b/src/main/process-manager/ProcessManager.ts index 43d1fe210..943851e0e 100644 --- a/src/main/process-manager/ProcessManager.ts +++ b/src/main/process-manager/ProcessManager.ts @@ -46,14 +46,14 @@ export class ProcessManager extends EventEmitter { /** * Spawn a new process for a session */ - spawn(config: ProcessConfig): SpawnResult { + spawn(config: ProcessConfig): Promise { const usePty = this.shouldUsePty(config); if (usePty) { return this.ptySpawner.spawn(config); - } else { - return this.childProcessSpawner.spawn(config); } + + return this.childProcessSpawner.spawn(config); } private shouldUsePty(config: ProcessConfig): boolean { diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index 813a12d09..1f198b635 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -50,7 +50,7 @@ export class ChildProcessSpawner { /** * Spawn a child process for a session */ - spawn(config: ProcessConfig): SpawnResult { + async spawn(config: ProcessConfig): Promise { const { sessionId, toolType, @@ -110,13 +110,9 @@ export class ChildProcessSpawner { } else if (hasImages && prompt && imageArgs) { // For agents that use file-based image args (like Codex, OpenCode) finalArgs = [...args]; - tempImageFiles = []; - for (let i = 0; i < images.length; i++) { - const tempPath = saveImageToTempFile(images[i], i); - if (tempPath) { - tempImageFiles.push(tempPath); - } - } + tempImageFiles = ( + await Promise.all(images.map((image, i) => saveImageToTempFile(image, i))) + ).filter((tempPath): tempPath is string => tempPath !== null); const isResumeWithPromptEmbed = capabilities.imageResumeMode === 'prompt-embed' && args.some((a) => a === 'resume'); diff --git a/src/main/process-manager/spawners/PtySpawner.ts b/src/main/process-manager/spawners/PtySpawner.ts index d9528fb6f..18106cc43 100644 --- a/src/main/process-manager/spawners/PtySpawner.ts +++ b/src/main/process-manager/spawners/PtySpawner.ts @@ -20,7 +20,7 @@ export class PtySpawner { /** * Spawn a PTY process for a session */ - spawn(config: ProcessConfig): SpawnResult { + async spawn(config: ProcessConfig): Promise { const { sessionId, toolType, diff --git a/src/main/process-manager/utils/imageUtils.ts b/src/main/process-manager/utils/imageUtils.ts index 60a458a18..0f680da6a 100644 --- a/src/main/process-manager/utils/imageUtils.ts +++ b/src/main/process-manager/utils/imageUtils.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; @@ -20,7 +19,7 @@ export function parseDataUrl(dataUrl: string): { base64: string; mediaType: stri * Save a base64 data URL image to a temp file. * Returns the full path to the temp file, or null on failure. */ -export function saveImageToTempFile(dataUrl: string, index: number): string | null { +export async function saveImageToTempFile(dataUrl: string, index: number): Promise { const parsed = parseDataUrl(dataUrl); if (!parsed) { logger.warn('[ProcessManager] Failed to parse data URL for temp file', 'ProcessManager'); @@ -33,7 +32,7 @@ export function saveImageToTempFile(dataUrl: string, index: number): string | nu try { const buffer = Buffer.from(parsed.base64, 'base64'); - fs.writeFileSync(tempPath, buffer); + await fsPromises.writeFile(tempPath, buffer); logger.debug('[ProcessManager] Saved image to temp file', 'ProcessManager', { tempPath, size: buffer.length, diff --git a/src/main/stats/singleton.ts b/src/main/stats/singleton.ts index 810888e07..c9026d32e 100644 --- a/src/main/stats/singleton.ts +++ b/src/main/stats/singleton.ts @@ -27,9 +27,9 @@ export function getStatsDB(): StatsDB { /** * Initialize the stats database (call on app ready) */ -export function initializeStatsDB(): void { +export async function initializeStatsDB(): Promise { const db = getStatsDB(); - db.initialize(); + await db.initialize(); } /** diff --git a/src/main/stats/stats-db.ts b/src/main/stats/stats-db.ts index b2cbbbd8e..1b295ea68 100644 --- a/src/main/stats/stats-db.ts +++ b/src/main/stats/stats-db.ts @@ -11,6 +11,7 @@ import Database from 'better-sqlite3'; import * as path from 'path'; import * as fs from 'fs'; +import { promises as fsp } from 'fs'; import { app } from 'electron'; import { logger } from '../utils/logger'; import type { @@ -92,18 +93,18 @@ export class StatsDB { * 2. Delete the corrupted file and any associated WAL/SHM files * 3. Create a fresh database */ - initialize(): void { + async initialize(): Promise { if (this.initialized) { - return; + return Promise.resolve(); } try { const dir = path.dirname(this.dbPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); + if (!(await this.pathExists(dir))) { + await fsp.mkdir(dir, { recursive: true }); } - const dbExists = fs.existsSync(this.dbPath); + const dbExists = await this.pathExists(this.dbPath); if (dbExists) { const db = this.openWithCorruptionHandling(); @@ -128,10 +129,10 @@ export class StatsDB { logger.info(`Stats database initialized at ${this.dbPath}`, LOG_CONTEXT); // Create daily backup (keeps last 7 days) - this.createDailyBackupIfNeeded(); + await this.createDailyBackupIfNeeded(); // Schedule VACUUM to run weekly instead of on every startup - this.vacuumIfNeededWeekly(); + await this.vacuumIfNeededWeekly(); } catch (error) { logger.error(`Failed to initialize stats database: ${error}`, LOG_CONTEXT); throw error; @@ -173,9 +174,9 @@ export class StatsDB { /** * Get the database file size in bytes. */ - getDatabaseSize(): number { + async getDatabaseSize(): Promise { try { - const stats = fs.statSync(this.dbPath); + const stats = await fsp.stat(this.dbPath); return stats.size; } catch { return 0; @@ -189,13 +190,13 @@ export class StatsDB { /** * Run VACUUM on the database to reclaim unused space and optimize structure. */ - vacuum(): { success: boolean; bytesFreed: number; error?: string } { + async vacuum(): Promise<{ success: boolean; bytesFreed: number; error?: string }> { if (!this.db) { return { success: false, bytesFreed: 0, error: 'Database not initialized' }; } try { - const sizeBefore = this.getDatabaseSize(); + const sizeBefore = await this.getDatabaseSize(); logger.info( `Starting VACUUM (current size: ${(sizeBefore / 1024 / 1024).toFixed(2)} MB)`, LOG_CONTEXT @@ -203,7 +204,7 @@ export class StatsDB { this.db.prepare('VACUUM').run(); - const sizeAfter = this.getDatabaseSize(); + const sizeAfter = await this.getDatabaseSize(); const bytesFreed = sizeBefore - sizeAfter; logger.info( @@ -224,12 +225,12 @@ export class StatsDB { * * @param thresholdBytes - Size threshold in bytes (default: 100MB) */ - vacuumIfNeeded(thresholdBytes: number = 100 * 1024 * 1024): { + async vacuumIfNeeded(thresholdBytes: number = 100 * 1024 * 1024): Promise<{ vacuumed: boolean; databaseSize: number; result?: { success: boolean; bytesFreed: number; error?: string }; - } { - const databaseSize = this.getDatabaseSize(); + }> { + const databaseSize = await this.getDatabaseSize(); if (databaseSize < thresholdBytes) { logger.debug( @@ -244,7 +245,7 @@ export class StatsDB { LOG_CONTEXT ); - const result = this.vacuum(); + const result = await this.vacuum(); return { vacuumed: true, databaseSize, result }; } @@ -256,7 +257,7 @@ export class StatsDB { * * @param intervalMs - Minimum time between vacuums (default: 7 days) */ - private vacuumIfNeededWeekly(intervalMs: number = 7 * 24 * 60 * 60 * 1000): void { + private async vacuumIfNeededWeekly(intervalMs: number = 7 * 24 * 60 * 60 * 1000): Promise { try { // Read last vacuum timestamp from _meta table const row = this.database @@ -279,7 +280,7 @@ export class StatsDB { } // Run VACUUM if database is large enough - const result = this.vacuumIfNeeded(); + const result = await this.vacuumIfNeeded(); if (result.vacuumed) { // Update timestamp in _meta table @@ -294,6 +295,18 @@ export class StatsDB { } } + /** + * Check if a file or directory exists without blocking. + */ + private async pathExists(filePath: string): Promise { + try { + await fsp.access(filePath); + return true; + } catch { + return false; + } + } + // ============================================================================ // Integrity & Corruption Handling // ============================================================================ @@ -330,26 +343,26 @@ export class StatsDB { * PRAGMA wal_checkpoint(TRUNCATE) forces all WAL content into the main * file and resets the WAL, making the .db file self-contained. */ - private safeBackupCopy(destPath: string): void { + private async safeBackupCopy(destPath: string): Promise { if (this.db) { this.db.pragma('wal_checkpoint(TRUNCATE)'); } - fs.copyFileSync(this.dbPath, destPath); + await fsp.copyFile(this.dbPath, destPath); } /** * Create a backup of the current database file. */ - backupDatabase(): BackupResult { + async backupDatabase(): Promise { try { - if (!fs.existsSync(this.dbPath)) { + if (!(await this.pathExists(this.dbPath))) { return { success: false, error: 'Database file does not exist' }; } const timestamp = Date.now(); const backupPath = `${this.dbPath}.backup.${timestamp}`; - this.safeBackupCopy(backupPath); + await this.safeBackupCopy(backupPath); logger.info(`Created database backup at ${backupPath}`, LOG_CONTEXT); return { success: true, backupPath }; @@ -368,9 +381,9 @@ export class StatsDB { * Create a daily backup if one hasn't been created today. * Automatically rotates old backups to keep only the last 7 days. */ - private createDailyBackupIfNeeded(): void { + private async createDailyBackupIfNeeded(): Promise { try { - if (!fs.existsSync(this.dbPath)) { + if (!(await this.pathExists(this.dbPath))) { return; } @@ -378,17 +391,17 @@ export class StatsDB { const dailyBackupPath = `${this.dbPath}.daily.${today}`; // Check if today's backup already exists - if (fs.existsSync(dailyBackupPath)) { + if (await this.pathExists(dailyBackupPath)) { logger.debug(`Daily backup already exists for ${today}`, LOG_CONTEXT); return; } // Create today's backup (checkpoint WAL first so the copy is self-contained) - this.safeBackupCopy(dailyBackupPath); + await this.safeBackupCopy(dailyBackupPath); logger.info(`Created daily backup: ${dailyBackupPath}`, LOG_CONTEXT); // Rotate old backups (keep last 7 days) - this.rotateOldBackups(7); + await this.rotateOldBackups(7); } catch (error) { logger.warn(`Failed to create daily backup: ${error}`, LOG_CONTEXT); } @@ -397,11 +410,11 @@ export class StatsDB { /** * Remove daily backups older than the specified number of days. */ - private rotateOldBackups(keepDays: number): void { + private async rotateOldBackups(keepDays: number): Promise { try { const dir = path.dirname(this.dbPath); const baseName = path.basename(this.dbPath).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const files = fs.readdirSync(dir); + const files = await fsp.readdir(dir); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - keepDays); @@ -415,7 +428,7 @@ export class StatsDB { const backupDate = dailyMatch[1]; if (backupDate < cutoffStr) { const fullPath = path.join(dir, file); - fs.unlinkSync(fullPath); + await fsp.unlink(fullPath); removedCount++; logger.debug(`Removed old daily backup: ${file}`, LOG_CONTEXT); } diff --git a/src/main/utils/context-groomer.ts b/src/main/utils/context-groomer.ts index e5bc6d06d..8084d8966 100644 --- a/src/main/utils/context-groomer.ts +++ b/src/main/utils/context-groomer.ts @@ -43,7 +43,7 @@ export interface GroomingProcessManager { sessionCustomPath?: string; sessionCustomArgs?: string; sessionCustomEnvVars?: Record; - }): { pid: number; success?: boolean } | null; + }): Promise<{ pid: number; success?: boolean } | null>; on(event: string, handler: (...args: unknown[]) => void): void; off(event: string, handler: (...args: unknown[]) => void): void; kill(sessionId: string): void; @@ -318,7 +318,7 @@ export async function groomContext( processManager.on('agent-error', onError); // Spawn the process in batch mode - const spawnResult = processManager.spawn({ + const spawnResult = await processManager.spawn({ sessionId: groomerSessionId, toolType: agentType, cwd: projectRoot, diff --git a/src/main/utils/wslDetector.ts b/src/main/utils/wslDetector.ts index 6442b3c91..8a89bf9bf 100644 --- a/src/main/utils/wslDetector.ts +++ b/src/main/utils/wslDetector.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import * as fs from 'fs/promises'; import { logger } from './logger'; /** @@ -15,7 +15,7 @@ let wslDetectionCache: boolean | null = null; * Detect if the current environment is WSL (Windows Subsystem for Linux). * Result is cached after first call. */ -export function isWsl(): boolean { +export async function isWsl(): Promise { if (wslDetectionCache !== null) { return wslDetectionCache; } @@ -26,11 +26,9 @@ export function isWsl(): boolean { } try { - if (fs.existsSync('/proc/version')) { - const version = fs.readFileSync('/proc/version', 'utf8').toLowerCase(); - wslDetectionCache = version.includes('microsoft') || version.includes('wsl'); - return wslDetectionCache; - } + const version = await fs.readFile('/proc/version', 'utf8'); + wslDetectionCache = version.toLowerCase().includes('microsoft') || version.toLowerCase().includes('wsl'); + return wslDetectionCache; } catch { // Ignore read errors } @@ -54,8 +52,8 @@ export function isWindowsMountPath(filepath: string): boolean { * @param cwd - The current working directory to check * @returns true if running from a problematic Windows mount path */ -export function checkWslEnvironment(cwd: string): boolean { - if (!isWsl()) { +export async function checkWslEnvironment(cwd: string): Promise { + if (!(await isWsl())) { return false; } diff --git a/src/renderer/components/AICommandsPanel.tsx b/src/renderer/components/AICommandsPanel.tsx index 55ff97343..e64d503b1 100644 --- a/src/renderer/components/AICommandsPanel.tsx +++ b/src/renderer/components/AICommandsPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Plus, Trash2, @@ -154,6 +154,59 @@ export function AICommandsPanel({ setIsCreating(false); }; + const sortedCommands = useMemo( + () => [...customAICommands].sort((a, b) => a.command.localeCompare(b.command)), + [customAICommands] + ); + + const commandStyles = useMemo( + () => ({ + textDim: { color: theme.colors.textDim }, + textAccent: { color: theme.colors.accent }, + addButton: { + backgroundColor: theme.colors.accent, + color: theme.colors.accentForeground, + }, + mainPanel: { + backgroundColor: theme.colors.bgMain, + borderColor: theme.colors.border, + }, + borderOnly: { borderColor: theme.colors.border }, + fieldBase: { + borderColor: theme.colors.border, + color: theme.colors.textMain, + }, + actionButton: { + backgroundColor: theme.colors.bgActivity, + color: theme.colors.textMain, + border: `1px solid ${theme.colors.border}`, + }, + successButton: { + backgroundColor: theme.colors.success, + color: '#000000', + }, + commandText: { color: theme.colors.accent }, + builtInBadge: { + backgroundColor: theme.colors.bgActivity, + color: theme.colors.textDim, + }, + promptPreview: { + backgroundColor: theme.colors.bgActivity, + color: theme.colors.textMain, + }, + variableCode: { + backgroundColor: theme.colors.bgActivity, + color: theme.colors.accent, + }, + createPanel: { + backgroundColor: theme.colors.bgMain, + borderColor: theme.colors.accent, + }, + errorText: { color: theme.colors.error }, + }), + [theme] + ); + return (
@@ -161,7 +214,7 @@ export function AICommandsPanel({ Custom AI Commands -

+

Slash commands available in AI terminal mode. Built-in commands can be edited but not deleted.

@@ -170,27 +223,30 @@ export function AICommandsPanel({ {/* Template Variables Documentation */}
{variablesExpanded && ( -
-

+

+

Use these variables in your command prompts. They will be replaced with actual values at runtime.

@@ -199,11 +255,11 @@ export function AICommandsPanel({
{variable} - + {description}
@@ -216,12 +272,9 @@ export function AICommandsPanel({ {!isCreating && (
@@ -289,7 +342,7 @@ export function AICommandsPanel({ placeholder="The actual prompt sent to the AI agent when this command is invoked... (type {{ for variables)" rows={10} className="w-full p-2 rounded border bg-transparent outline-none text-sm resize-y scrollbar-thin min-h-[150px]" - style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} + style={commandStyles.fieldBase} /> Cancel @@ -315,10 +364,7 @@ export function AICommandsPanel({ onClick={handleCreate} disabled={!newCommand.command || !newCommand.description || !newCommand.prompt} className="flex items-center gap-1 px-3 py-1.5 rounded text-xs font-medium transition-all disabled:opacity-50" - style={{ - backgroundColor: theme.colors.success, - color: '#000000', - }} + style={commandStyles.successButton} > Create @@ -329,83 +375,74 @@ export function AICommandsPanel({ {/* Existing commands list - collapsible style */}
- {[...customAICommands] - .sort((a, b) => a.command.localeCompare(b.command)) - .map((cmd) => ( -
+ {sortedCommands.map((cmd) => ( +
{editingCommand?.id === cmd.id ? ( // Editing mode
{cmd.command}
- + onClick={handleCancelEdit} + className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all" + style={commandStyles.actionButton} + > + + Cancel + +
- - setEditingCommand({ ...editingCommand, command: e.target.value }) - } - className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono" - style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} - /> + + setEditingCommand({ ...editingCommand, command: e.target.value }) + } + className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono" + style={commandStyles.fieldBase} + />
- - setEditingCommand({ ...editingCommand, description: e.target.value }) - } - className="w-full p-2 rounded border bg-transparent outline-none text-sm" - style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} - /> + + setEditingCommand({ ...editingCommand, description: e.target.value }) + } + className="w-full p-2 rounded border bg-transparent outline-none text-sm" + style={commandStyles.fieldBase} + />