From fa8ae149d359ee620d98719da7595f4e75d2a851 Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 10 Jan 2026 15:41:35 -0500 Subject: [PATCH 01/27] feat: enhance worktree listing by scanning external directories - Implemented a new function to scan the .worktrees directory for worktrees that may exist outside of git's management, allowing for better detection of externally created or corrupted worktrees. - Updated the /list endpoint to include discovered worktrees in the response, improving the accuracy of the worktree listing. - Added logging for discovered worktrees to aid in debugging and tracking. - Cleaned up and organized imports in the list.ts file for better maintainability. --- .../server/src/routes/worktree/routes/list.ts | 102 ++++++++++++++++++ apps/ui/src/components/views/board-view.tsx | 32 ++---- .../views/board-view/board-controls.tsx | 2 +- .../views/board-view/board-header.tsx | 45 ++++++-- .../components/kanban-card/summary-dialog.tsx | 1 + .../views/board-view/kanban-board.tsx | 2 +- .../graph-view/components/graph-controls.tsx | 5 +- .../components/graph-filter-controls.tsx | 5 +- .../graph-view/components/graph-legend.tsx | 5 +- .../views/graph-view/components/task-node.tsx | 54 +++++++++- .../views/graph-view/graph-canvas.tsx | 52 ++++++++- .../views/graph-view/graph-view.tsx | 3 +- .../views/graph-view/hooks/use-graph-nodes.ts | 21 +++- apps/ui/src/styles/global.css | 9 +- apps/ui/src/styles/themes/dark.css | 2 +- 15 files changed, 294 insertions(+), 46 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 93d93dad..bc70a341 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -2,18 +2,23 @@ * POST /list endpoint - List all git worktrees * * Returns actual git worktrees from `git worktree list`. + * Also scans .worktrees/ directory to discover worktrees that may have been + * created externally or whose git state was corrupted. * Does NOT include tracked branches - only real worktrees with separate directories. */ import type { Request, Response } from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; +import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, normalizePath } from '../common.js'; import { readAllWorktreeMetadata, type WorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; const execAsync = promisify(exec); +const logger = createLogger('Worktree'); interface WorktreeInfo { path: string; @@ -35,6 +40,87 @@ async function getCurrentBranch(cwd: string): Promise { } } +/** + * Scan the .worktrees directory to discover worktrees that may exist on disk + * but are not registered with git (e.g., created externally or corrupted state). + */ +async function scanWorktreesDirectory( + projectPath: string, + knownWorktreePaths: Set +): Promise> { + const discovered: Array<{ path: string; branch: string }> = []; + const worktreesDir = path.join(projectPath, '.worktrees'); + + try { + // Check if .worktrees directory exists + await secureFs.access(worktreesDir); + } catch { + // .worktrees directory doesn't exist + return discovered; + } + + try { + const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + const normalizedPath = normalizePath(worktreePath); + + // Skip if already known from git worktree list + if (knownWorktreePaths.has(normalizedPath)) continue; + + // Check if this is a valid git repository + const gitPath = path.join(worktreePath, '.git'); + try { + const gitStat = await secureFs.stat(gitPath); + + // Git worktrees have a .git FILE (not directory) that points to the parent repo + // Regular repos have a .git DIRECTORY + if (gitStat.isFile() || gitStat.isDirectory()) { + // Try to get the branch name + const branch = await getCurrentBranch(worktreePath); + if (branch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})` + ); + discovered.push({ + path: normalizedPath, + branch, + }); + } else { + // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + try { + const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const headBranch = headRef.trim(); + if (headBranch && headBranch !== 'HEAD') { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); + } + } catch { + // Can't determine branch, skip this directory + } + } + } + } catch { + // Not a git repo, skip + } + } + } catch (error) { + logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`); + } + + return discovered; +} + export function createListHandler() { return async (req: Request, res: Response): Promise => { try { @@ -116,6 +202,22 @@ export function createListHandler() { } } + // Scan .worktrees directory to discover worktrees that exist on disk + // but are not registered with git (e.g., created externally) + const knownPaths = new Set(worktrees.map((w) => w.path)); + const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths); + + // Add discovered worktrees to the list + for (const discovered of discoveredWorktrees) { + worktrees.push({ + path: discovered.path, + branch: discovered.branch, + isMain: false, + isCurrent: discovered.branch === currentBranch, + hasWorktree: true, + }); + } + // Read all worktree metadata to get PR info const allMetadata = await readAllWorktreeMetadata(projectPath); diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 810d1a91..41932a43 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -40,8 +40,6 @@ import { useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { useWindowState } from '@/hooks/use-window-state'; // Board-view specific imports import { BoardHeader } from './board-view/board-header'; -import { BoardSearchBar } from './board-view/board-search-bar'; -import { BoardControls } from './board-view/board-controls'; import { KanbanBoard } from './board-view/kanban-board'; import { GraphView } from './graph-view'; import { @@ -1155,7 +1153,6 @@ export function BoardView() { > {/* Header */} setShowPlanDialog(true)} isMounted={isMounted} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + isCreatingSpec={isCreatingSpec} + creatingSpecProjectPath={creatingSpecProjectPath} + onShowBoardBackground={() => setShowBoardBackgroundModal(true)} + onShowCompletedModal={() => setShowCompletedModal(true)} + completedCount={completedFeatures.length} + boardViewMode={boardViewMode} + onBoardViewModeChange={setBoardViewMode} /> {/* Worktree Panel - conditionally rendered based on visibility setting */} @@ -1208,26 +1214,6 @@ export function BoardView() { {/* Main Content Area */}
- {/* Search Bar Row */} -
- - - {/* Board Background & Detail Level Controls */} - setShowBoardBackgroundModal(true)} - onShowCompletedModal={() => setShowCompletedModal(true)} - completedCount={completedFeatures.length} - boardViewMode={boardViewMode} - onBoardViewModeChange={setBoardViewMode} - /> -
{/* View Content - Kanban or Graph */} {boardViewMode === 'kanban' ? ( -
+
{/* View Mode Toggle - Kanban / Graph */}
void; onOpenPlanDialog: () => void; isMounted: boolean; + // Search bar props + searchQuery: string; + onSearchChange: (query: string) => void; + isCreatingSpec: boolean; + creatingSpecProjectPath?: string; + // Board controls props + onShowBoardBackground: () => void; + onShowCompletedModal: () => void; + completedCount: number; + boardViewMode: BoardViewMode; + onBoardViewModeChange: (mode: BoardViewMode) => void; } // Shared styles for header control containers @@ -28,7 +40,6 @@ const controlContainerClass = 'flex items-center gap-1.5 px-3 h-8 rounded-md bg-secondary border border-border'; export function BoardHeader({ - projectName, projectPath, maxConcurrency, runningAgentsCount, @@ -37,6 +48,15 @@ export function BoardHeader({ onAutoModeToggle, onOpenPlanDialog, isMounted, + searchQuery, + onSearchChange, + isCreatingSpec, + creatingSpecProjectPath, + onShowBoardBackground, + onShowCompletedModal, + completedCount, + boardViewMode, + onBoardViewModeChange, }: BoardHeaderProps) { const [showAutoModeSettings, setShowAutoModeSettings] = useState(false); const apiKeys = useAppStore((state) => state.apiKeys); @@ -84,9 +104,22 @@ export function BoardHeader({ return (
-
-

Kanban Board

-

{projectName}

+
+ +
{/* Usage Popover - show if either provider is authenticated */} diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx index db9f579d..11e98663 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/summary-dialog.tsx @@ -35,6 +35,7 @@ export function SummaryDialog({ data-testid={`summary-dialog-${feature.id}`} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} > diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 1fd39843..4601a70c 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -99,7 +99,7 @@ export function KanbanBoard({ const { columnWidth, containerStyle } = useResponsiveKanban(columns.length); return ( -
+
-
+
{/* Zoom controls */} diff --git a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx index f1564e81..af7513ea 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx @@ -110,7 +110,10 @@ export function GraphFilterControls({ return ( -
+
{/* Category Filter Dropdown */} diff --git a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx index f7c9cb55..545378ef 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx @@ -44,7 +44,10 @@ const legendItems = [ export function GraphLegend() { return ( -
+
{legendItems.map((item) => { const Icon = item.icon; return ( diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index ac0132a3..f21d7272 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -75,6 +75,24 @@ const priorityConfig = { 3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' }, }; +// Helper function to get border style with opacity (like KanbanCard does) +function getCardBorderStyle( + enabled: boolean, + opacity: number, + borderColor: string +): React.CSSProperties { + if (!enabled) { + return { borderWidth: '0px', borderColor: 'transparent' }; + } + if (opacity !== 100) { + return { + borderWidth: '2px', + borderColor: `color-mix(in oklch, ${borderColor} ${opacity}%, transparent)`, + }; + } + return { borderWidth: '2px' }; +} + export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) { // Handle pipeline statuses by treating them like in_progress const status = data.status || 'backlog'; @@ -91,6 +109,28 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps // Task is stopped if it's in_progress but not actively running const isStopped = data.status === 'in_progress' && !data.isRunning; + // Background/theme settings with defaults + const cardOpacity = data.cardOpacity ?? 100; + const glassmorphism = data.cardGlassmorphism ?? true; + const cardBorderEnabled = data.cardBorderEnabled ?? true; + const cardBorderOpacity = data.cardBorderOpacity ?? 100; + + // Get the border color based on status and error state + const borderColor = data.error + ? 'var(--status-error)' + : config.borderClass.includes('border-border') + ? 'var(--border)' + : config.borderClass.includes('status-in-progress') + ? 'var(--status-in-progress)' + : config.borderClass.includes('status-waiting') + ? 'var(--status-waiting)' + : config.borderClass.includes('status-success') + ? 'var(--status-success)' + : 'var(--border)'; + + // Get computed border style + const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor); + return ( <> {/* Target handle (left side - receives dependencies) */} @@ -109,22 +149,26 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
+ {/* Background layer with opacity control - like KanbanCard */} +
{/* Header with status and actions */}
@@ -301,7 +345,7 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
{/* Content */} -
+
{/* Category */} {data.category} diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 06d32dce..642daaf6 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -15,7 +15,8 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { Feature } from '@/store/app-store'; +import { Feature, useAppStore } from '@/store/app-store'; +import { themeOptions } from '@/config/theme-options'; import { TaskNode, DependencyEdge, @@ -47,6 +48,13 @@ const edgeTypes: any = { dependency: DependencyEdge, }; +interface BackgroundSettings { + cardOpacity: number; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; +} + interface GraphCanvasProps { features: Feature[]; runningAutoTasks: string[]; @@ -56,6 +64,7 @@ interface GraphCanvasProps { nodeActionCallbacks?: NodeActionCallbacks; onCreateDependency?: (sourceId: string, targetId: string) => Promise; backgroundStyle?: React.CSSProperties; + backgroundSettings?: BackgroundSettings; className?: string; } @@ -68,11 +77,42 @@ function GraphCanvasInner({ nodeActionCallbacks, onCreateDependency, backgroundStyle, + backgroundSettings, className, }: GraphCanvasProps) { const [isLocked, setIsLocked] = useState(false); const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR'); + // Determine React Flow color mode based on current theme + const effectiveTheme = useAppStore((state) => state.getEffectiveTheme()); + const [systemColorMode, setSystemColorMode] = useState<'dark' | 'light'>(() => { + if (typeof window === 'undefined') return 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + if (effectiveTheme !== 'system') return; + if (typeof window === 'undefined') return; + + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const update = () => setSystemColorMode(mql.matches ? 'dark' : 'light'); + update(); + + // Safari < 14 fallback + if (mql.addEventListener) { + mql.addEventListener('change', update); + return () => mql.removeEventListener('change', update); + } + // eslint-disable-next-line deprecation/deprecation + mql.addListener(update); + // eslint-disable-next-line deprecation/deprecation + return () => mql.removeListener(update); + }, [effectiveTheme]); + + const themeOption = themeOptions.find((t) => t.value === effectiveTheme); + const colorMode = + effectiveTheme === 'system' ? systemColorMode : themeOption?.isDark ? 'dark' : 'light'; + // Filter state (category, status, and negative toggle are local to graph view) const [selectedCategories, setSelectedCategories] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); @@ -98,6 +138,7 @@ function GraphCanvasInner({ runningAutoTasks, filterResult, actionCallbacks: nodeActionCallbacks, + backgroundSettings, }); // Apply layout @@ -234,6 +275,7 @@ function GraphCanvasInner({ isValidConnection={isValidConnection} nodeTypes={nodeTypes} edgeTypes={edgeTypes} + colorMode={colorMode} fitView fitViewOptions={{ padding: 0.2 }} minZoom={0.1} @@ -256,7 +298,8 @@ function GraphCanvasInner({ nodeStrokeWidth={3} zoomable pannable - className="!bg-popover/90 !border-border rounded-lg shadow-lg" + className="border-border! rounded-lg shadow-lg" + style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }} /> -
+

No matching tasks

diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index fbb33960..34959f91 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -44,7 +44,7 @@ export function GraphView({ const { currentProject } = useAppStore(); // Use the same background hook as the board view - const { backgroundImageStyle } = useBoardBackground({ currentProject }); + const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject }); // Filter features by current worktree (same logic as board view) const filteredFeatures = useMemo(() => { @@ -213,6 +213,7 @@ export function GraphView({ nodeActionCallbacks={nodeActionCallbacks} onCreateDependency={handleCreateDependency} backgroundStyle={backgroundImageStyle} + backgroundSettings={backgroundSettings} className="h-full" />
diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index d9b340a9..3e9e41e0 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -18,6 +18,11 @@ export interface TaskNodeData extends Feature { isMatched?: boolean; isHighlighted?: boolean; isDimmed?: boolean; + // Background/theme settings + cardOpacity?: number; + cardGlassmorphism?: boolean; + cardBorderEnabled?: boolean; + cardBorderOpacity?: number; // Action callbacks onViewLogs?: () => void; onViewDetails?: () => void; @@ -48,11 +53,19 @@ export interface NodeActionCallbacks { onDeleteDependency?: (sourceId: string, targetId: string) => void; } +interface BackgroundSettings { + cardOpacity: number; + cardGlassmorphism: boolean; + cardBorderEnabled: boolean; + cardBorderOpacity: number; +} + interface UseGraphNodesProps { features: Feature[]; runningAutoTasks: string[]; filterResult?: GraphFilterResult; actionCallbacks?: NodeActionCallbacks; + backgroundSettings?: BackgroundSettings; } /** @@ -64,6 +77,7 @@ export function useGraphNodes({ runningAutoTasks, filterResult, actionCallbacks, + backgroundSettings, }: UseGraphNodesProps) { const { nodes, edges } = useMemo(() => { const nodeList: TaskNode[] = []; @@ -102,6 +116,11 @@ export function useGraphNodes({ isMatched, isHighlighted, isDimmed, + // Background/theme settings + cardOpacity: backgroundSettings?.cardOpacity, + cardGlassmorphism: backgroundSettings?.cardGlassmorphism, + cardBorderEnabled: backgroundSettings?.cardBorderEnabled, + cardBorderOpacity: backgroundSettings?.cardBorderOpacity, // Action callbacks (bound to this feature's ID) onViewLogs: actionCallbacks?.onViewLogs ? () => actionCallbacks.onViewLogs!(feature.id) @@ -163,7 +182,7 @@ export function useGraphNodes({ }); return { nodes: nodeList, edges: edgeList }; - }, [features, runningAutoTasks, filterResult, actionCallbacks]); + }, [features, runningAutoTasks, filterResult, actionCallbacks, backgroundSettings]); return { nodes, edges }; } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 70d6a0f6..54a32c4a 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -298,7 +298,14 @@ } } -.light { +/* IMPORTANT: + * Theme classes like `.light` are applied to `:root` (html). + * Some third-party libraries (e.g. React Flow) also add `.light`/`.dark` classes + * to nested containers. If we define CSS variables on `.light` broadly, those + * nested containers will override the app theme and cause "white cards" in dark themes. + * Scoping to `:root.light` ensures only the root theme toggle controls variables. + */ +:root.light { /* Explicit light mode - same as root but ensures it overrides any dark defaults */ --background: oklch(1 0 0); /* White */ --background-50: oklch(1 0 0 / 0.5); diff --git a/apps/ui/src/styles/themes/dark.css b/apps/ui/src/styles/themes/dark.css index 9b23ca8d..5a702c25 100644 --- a/apps/ui/src/styles/themes/dark.css +++ b/apps/ui/src/styles/themes/dark.css @@ -1,6 +1,6 @@ /* Dark Theme */ -.dark { +:root.dark { /* Deep dark backgrounds - zinc-950 family */ --background: oklch(0.04 0 0); /* zinc-950 */ --background-50: oklch(0.04 0 0 / 0.5); /* zinc-950/50 */ From 89a960629a0b680548aa696d1ecbd136e338bc4d Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 02:58:56 +0530 Subject: [PATCH 02/27] fix: Improve E2E test workflow for better backend debugging Enhanced backend server startup in CI: - Track server PID and process status - Save logs to backend.log for debugging - Better error detection with process monitoring - Added cleanup step to kill server process - Print backend logs on test failure Improves reliability of E2E tests by providing better diagnostics when backend fails to start --- .github/workflows/e2e-tests.yml | 88 +++++++++++++++++++++++++++++++-- .gitignore | 6 ++- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index abc5a867..917672b5 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -37,7 +37,14 @@ jobs: git config --global user.email "ci@example.com" - name: Start backend server - run: npm run start --workspace=apps/server & + run: | + echo "Starting backend server..." + # Start server in background and save PID + npm run start --workspace=apps/server > backend.log 2>&1 & + SERVER_PID=$! + echo "Server started with PID: $SERVER_PID" + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + env: PORT: 3008 NODE_ENV: test @@ -53,21 +60,70 @@ jobs: - name: Wait for backend server run: | echo "Waiting for backend server to be ready..." + + # Check if server process is running + if [ -z "$SERVER_PID" ]; then + echo "ERROR: Server PID not found in environment" + cat backend.log 2>/dev/null || echo "No backend log found" + exit 1 + fi + + # Check if process is actually running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process $SERVER_PID is not running!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Recent system logs ===" + dmesg 2>/dev/null | tail -20 || echo "No dmesg available" + exit 1 + fi + + # Wait for health endpoint for i in {1..60}; do if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then echo "Backend server is ready!" - curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "Health check response:" + curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')" exit 0 fi + + # Check if server process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process died during wait!" + echo "=== Backend logs ===" + cat backend.log + exit 1 + fi + echo "Waiting... ($i/60)" sleep 1 done - echo "Backend server failed to start!" - echo "Checking server status..." + + echo "ERROR: Backend server failed to start within 60 seconds!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Process status ===" ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" - echo "Testing health endpoint..." + lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use" + echo "" + echo "=== Health endpoint test ===" curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed" + + # Kill the server process if it's still hanging + if kill -0 $SERVER_PID 2>/dev/null; then + echo "" + echo "Killing stuck server process..." + kill -9 $SERVER_PID 2>/dev/null || true + fi + exit 1 - name: Run E2E tests @@ -81,6 +137,18 @@ jobs: # Keep UI-side login/defaults consistent AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + - name: Print backend logs on failure + if: failure() + run: | + echo "=== E2E Tests Failed - Backend Logs ===" + cat backend.log 2>/dev/null || echo "No backend log found" + echo "" + echo "=== Process status at failure ===" + ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" + netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening" + - name: Upload Playwright report uses: actions/upload-artifact@v4 if: always() @@ -98,3 +166,13 @@ jobs: apps/ui/test-results/ retention-days: 7 if-no-files-found: ignore + + - name: Cleanup - Kill backend server + if: always() + run: | + if [ -n "$SERVER_PID" ]; then + echo "Cleaning up backend server (PID: $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null || true + kill -9 $SERVER_PID 2>/dev/null || true + echo "Backend server cleanup complete" + fi diff --git a/.gitignore b/.gitignore index 91571307..55ba86b2 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,8 @@ docker-compose.override.yml .claude/hans/ pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock + +# Fork-specific workflow files (should never be committed) +DEVELOPMENT_WORKFLOW.md +check-sync.sh \ No newline at end of file From a92457b8715d7b2d772a1d1d1ed4a1df27fc3825 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Sun, 11 Jan 2026 03:22:43 +0530 Subject: [PATCH 03/27] fix: Handle Claude CLI unavailability gracefully in CI - Add try-catch around pty.spawn() to prevent crashes when PTY unavailable - Add unhandledRejection/uncaughtException handlers for graceful degradation - Add checkBackendHealth/waitForBackendHealth utilities for tests - Add data/.api-key and data/credentials.json to .gitignore --- .gitignore | 5 +- apps/server/src/index.ts | 20 +++++++ .../src/services/claude-usage-service.ts | 54 +++++++++++++------ apps/ui/tests/utils/api/client.ts | 39 ++++++++++++++ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 55ba86b2..2904e438 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,7 @@ yarn.lock # Fork-specific workflow files (should never be committed) DEVELOPMENT_WORKFLOW.md -check-sync.sh \ No newline at end of file +check-sync.sh +# API key files +data/.api-key +data/credentials.json diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index caa4dd6a..59cc6f57 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -597,6 +597,26 @@ const startServer = (port: number) => { startServer(PORT); +// Global error handlers to prevent crashes from uncaught errors +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + logger.error('Unhandled Promise Rejection:', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + // Don't exit - log the error and continue running + // This prevents the server from crashing due to unhandled rejections +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { + message: error.message, + stack: error.stack, + }); + // Exit on uncaught exceptions to prevent undefined behavior + // The process is in an unknown state after an uncaught exception + process.exit(1); +}); + // Graceful shutdown process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down...'); diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index 098ce29c..64ace35d 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -2,6 +2,7 @@ import { spawn } from 'child_process'; import * as os from 'os'; import * as pty from 'node-pty'; import { ClaudeUsage } from '../routes/claude/types.js'; +import { createLogger } from '@automaker/utils'; /** * Claude Usage Service @@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js'; * - macOS: Uses 'expect' command for PTY * - Windows/Linux: Uses node-pty for PTY */ +const logger = createLogger('ClaudeUsage'); + export class ClaudeUsageService { private claudeBinary = 'claude'; private timeout = 30000; // 30 second timeout @@ -164,21 +167,40 @@ export class ClaudeUsageService { const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage']; - const ptyProcess = pty.spawn(shell, args, { - name: 'xterm-256color', - cols: 120, - rows: 30, - cwd: workingDirectory, - env: { - ...process.env, - TERM: 'xterm-256color', - } as Record, - }); + let ptyProcess: any = null; + + try { + ptyProcess = pty.spawn(shell, args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }); + } catch (spawnError) { + // pty.spawn() can throw synchronously if the native module fails to load + // or if PTY is not available in the current environment (e.g., containers without /dev/pts) + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + + // Return a user-friendly error instead of crashing + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } const timeoutId = setTimeout(() => { if (!settled) { settled = true; - ptyProcess.kill(); + if (ptyProcess && !ptyProcess.killed) { + ptyProcess.kill(); + } // Don't fail if we have data - return it instead if (output.includes('Current session')) { resolve(output); @@ -188,7 +210,7 @@ export class ClaudeUsageService { } }, this.timeout); - ptyProcess.onData((data) => { + ptyProcess.onData((data: string) => { output += data; // Check if we've seen the usage data (look for "Current session") @@ -196,12 +218,12 @@ export class ClaudeUsageService { hasSeenUsageData = true; // Wait for full output, then send escape to exit setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.kill('SIGTERM'); } }, 2000); @@ -212,14 +234,14 @@ export class ClaudeUsageService { // Fallback: if we see "Esc to cancel" but haven't seen usage data yet if (!hasSeenUsageData && output.includes('Esc to cancel')) { setTimeout(() => { - if (!settled) { + if (!settled && ptyProcess && !ptyProcess.killed) { ptyProcess.write('\x1b'); // Send escape key } }, 3000); } }); - ptyProcess.onExit(({ exitCode }) => { + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { clearTimeout(timeoutId); if (settled) return; settled = true; diff --git a/apps/ui/tests/utils/api/client.ts b/apps/ui/tests/utils/api/client.ts index c3f18074..abe6ef2f 100644 --- a/apps/ui/tests/utils/api/client.ts +++ b/apps/ui/tests/utils/api/client.ts @@ -368,3 +368,42 @@ export async function authenticateForTests(page: Page): Promise { const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests'; return authenticateWithApiKey(page, apiKey); } + +/** + * Check if the backend server is healthy + * Returns true if the server responds with status 200, false otherwise + */ +export async function checkBackendHealth(page: Page, timeout = 5000): Promise { + try { + const response = await page.request.get(`${API_BASE_URL}/api/health`, { + timeout, + }); + return response.ok(); + } catch { + return false; + } +} + +/** + * Wait for the backend to be healthy, with retry logic + * Throws an error if the backend doesn't become healthy within the timeout + */ +export async function waitForBackendHealth( + page: Page, + maxWaitMs = 30000, + checkIntervalMs = 500 +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + if (await checkBackendHealth(page, checkIntervalMs)) { + return; + } + await page.waitForTimeout(checkIntervalMs); + } + + throw new Error( + `Backend did not become healthy within ${maxWaitMs}ms. ` + + `Last health check failed or timed out.` + ); +} From da682e39935bd573f9c14ed6e7c8aeb088db26bf Mon Sep 17 00:00:00 2001 From: webdevcody Date: Sat, 10 Jan 2026 20:07:50 -0500 Subject: [PATCH 04/27] feat: add memory management feature with UI components - Introduced a new MemoryView component for viewing and editing AI memory files. - Updated navigation hooks and keyboard shortcuts to include memory functionality. - Added memory file creation, deletion, and renaming capabilities. - Enhanced the sidebar navigation to support memory as a new section. - Implemented loading and saving of memory files with a markdown editor. - Integrated dialogs for creating, deleting, and renaming memory files. --- .../layout/sidebar/hooks/use-navigation.ts | 8 + apps/ui/src/components/ui/keyboard-map.tsx | 2 + apps/ui/src/components/views/memory-view.tsx | 627 ++++++++++++++++++ apps/ui/src/routes/memory.tsx | 6 + apps/ui/src/store/app-store.ts | 2 + 5 files changed, 645 insertions(+) create mode 100644 apps/ui/src/components/views/memory-view.tsx create mode 100644 apps/ui/src/routes/memory.tsx diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 350bd2f8..2f1a0aa6 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -11,6 +11,7 @@ import { GitPullRequest, Zap, Lightbulb, + Brain, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -26,6 +27,7 @@ interface UseNavigationProps { cycleNextProject: string; spec: string; context: string; + memory: string; profiles: string; board: string; agent: string; @@ -114,6 +116,12 @@ export function useNavigation({ icon: BookOpen, shortcut: shortcuts.context, }, + { + id: 'memory', + label: 'Memory', + icon: Brain, + shortcut: shortcuts.memory, + }, { id: 'profiles', label: 'AI Profiles', diff --git a/apps/ui/src/components/ui/keyboard-map.tsx b/apps/ui/src/components/ui/keyboard-map.tsx index 2e00c1e2..2ee3606b 100644 --- a/apps/ui/src/components/ui/keyboard-map.tsx +++ b/apps/ui/src/components/ui/keyboard-map.tsx @@ -87,6 +87,7 @@ const SHORTCUT_LABELS: Record = { agent: 'Agent Runner', spec: 'Spec Editor', context: 'Context', + memory: 'Memory', settings: 'Settings', profiles: 'AI Profiles', terminal: 'Terminal', @@ -115,6 +116,7 @@ const SHORTCUT_CATEGORIES: Record([]); + const [selectedFile, setSelectedFile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [editedContent, setEditedContent] = useState(''); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [renameFileName, setRenameFileName] = useState(''); + const [isPreviewMode, setIsPreviewMode] = useState(true); + + // Create Memory file modal state + const [isCreateMemoryOpen, setIsCreateMemoryOpen] = useState(false); + const [newMemoryName, setNewMemoryName] = useState(''); + const [newMemoryContent, setNewMemoryContent] = useState(''); + + // Get memory directory path + const getMemoryPath = useCallback(() => { + if (!currentProject) return null; + return `${currentProject.path}/.automaker/memory`; + }, [currentProject]); + + const isMarkdownFile = (filename: string): boolean => { + const ext = filename.toLowerCase().substring(filename.lastIndexOf('.')); + return ext === '.md' || ext === '.markdown'; + }; + + // Load memory files + const loadMemoryFiles = useCallback(async () => { + const memoryPath = getMemoryPath(); + if (!memoryPath) return; + + setIsLoading(true); + try { + const api = getElectronAPI(); + + // Ensure memory directory exists + await api.mkdir(memoryPath); + + // Read directory contents + const result = await api.readdir(memoryPath); + if (result.success && result.entries) { + const files: MemoryFile[] = result.entries + .filter((entry) => entry.isFile && isMarkdownFile(entry.name)) + .map((entry) => ({ + name: entry.name, + path: `${memoryPath}/${entry.name}`, + })); + setMemoryFiles(files); + } + } catch (error) { + logger.error('Failed to load memory files:', error); + } finally { + setIsLoading(false); + } + }, [getMemoryPath]); + + useEffect(() => { + loadMemoryFiles(); + }, [loadMemoryFiles]); + + // Load selected file content + const loadFileContent = useCallback(async (file: MemoryFile) => { + try { + const api = getElectronAPI(); + const result = await api.readFile(file.path); + if (result.success && result.content !== undefined) { + setEditedContent(result.content); + setSelectedFile({ ...file, content: result.content }); + setHasChanges(false); + } + } catch (error) { + logger.error('Failed to load file content:', error); + } + }, []); + + // Select a file + const handleSelectFile = (file: MemoryFile) => { + if (hasChanges) { + // Could add a confirmation dialog here + } + loadFileContent(file); + setIsPreviewMode(true); + }; + + // Save current file + const saveFile = async () => { + if (!selectedFile) return; + + setIsSaving(true); + try { + const api = getElectronAPI(); + await api.writeFile(selectedFile.path, editedContent); + setSelectedFile({ ...selectedFile, content: editedContent }); + setHasChanges(false); + } catch (error) { + logger.error('Failed to save file:', error); + } finally { + setIsSaving(false); + } + }; + + // Handle content change + const handleContentChange = (value: string) => { + setEditedContent(value); + setHasChanges(true); + }; + + // Handle create memory file + const handleCreateMemory = async () => { + const memoryPath = getMemoryPath(); + if (!memoryPath || !newMemoryName.trim()) return; + + try { + const api = getElectronAPI(); + let filename = newMemoryName.trim(); + + // Add .md extension if not provided + if (!filename.includes('.')) { + filename += '.md'; + } + + const filePath = `${memoryPath}/${filename}`; + + // Write memory file + await api.writeFile(filePath, newMemoryContent); + + // Reload files + await loadMemoryFiles(); + + // Reset and close modal + setIsCreateMemoryOpen(false); + setNewMemoryName(''); + setNewMemoryContent(''); + } catch (error) { + logger.error('Failed to create memory file:', error); + setIsCreateMemoryOpen(false); + setNewMemoryName(''); + setNewMemoryContent(''); + } + }; + + // Delete selected file + const handleDeleteFile = async () => { + if (!selectedFile) return; + + try { + const api = getElectronAPI(); + await api.deleteFile(selectedFile.path); + + setIsDeleteDialogOpen(false); + setSelectedFile(null); + setEditedContent(''); + setHasChanges(false); + await loadMemoryFiles(); + } catch (error) { + logger.error('Failed to delete file:', error); + } + }; + + // Rename selected file + const handleRenameFile = async () => { + const memoryPath = getMemoryPath(); + if (!selectedFile || !memoryPath || !renameFileName.trim()) return; + + let newName = renameFileName.trim(); + // Add .md extension if not provided + if (!newName.includes('.')) { + newName += '.md'; + } + + if (newName === selectedFile.name) { + setIsRenameDialogOpen(false); + return; + } + + try { + const api = getElectronAPI(); + const newPath = `${memoryPath}/${newName}`; + + // Check if file with new name already exists + const exists = await api.exists(newPath); + if (exists) { + logger.error('A file with this name already exists'); + return; + } + + // Read current file content + const result = await api.readFile(selectedFile.path); + if (!result.success || result.content === undefined) { + logger.error('Failed to read file for rename'); + return; + } + + // Write to new path + await api.writeFile(newPath, result.content); + + // Delete old file + await api.deleteFile(selectedFile.path); + + setIsRenameDialogOpen(false); + setRenameFileName(''); + + // Reload files and select the renamed file + await loadMemoryFiles(); + + // Update selected file with new name and path + const renamedFile: MemoryFile = { + name: newName, + path: newPath, + content: result.content, + }; + setSelectedFile(renamedFile); + } catch (error) { + logger.error('Failed to rename file:', error); + } + }; + + // Delete file from list (used by dropdown) + const handleDeleteFromList = async (file: MemoryFile) => { + try { + const api = getElectronAPI(); + await api.deleteFile(file.path); + + // Clear selection if this was the selected file + if (selectedFile?.path === file.path) { + setSelectedFile(null); + setEditedContent(''); + setHasChanges(false); + } + + await loadMemoryFiles(); + } catch (error) { + logger.error('Failed to delete file:', error); + } + }; + + if (!currentProject) { + return ( +
+

No project selected

+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Memory Layer

+

+ View and edit AI memory files for this project +

+
+
+
+ + +
+
+ + {/* Main content area with file list and editor */} +
+ {/* Left Panel - File List */} +
+
+

+ Memory Files ({memoryFiles.length}) +

+
+
+ {memoryFiles.length === 0 ? ( +
+ +

+ No memory files yet. +
+ Create a memory file to get started. +

+
+ ) : ( +
+ {memoryFiles.map((file) => ( +
handleSelectFile(file)} + className={cn( + 'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer', + selectedFile?.path === file.path + ? 'bg-primary/20 text-foreground border border-primary/30' + : 'text-muted-foreground hover:bg-accent hover:text-foreground' + )} + data-testid={`memory-file-${file.name}`} + > + +
+ {file.name} +
+ + + + + + { + setRenameFileName(file.name); + setSelectedFile(file); + setIsRenameDialogOpen(true); + }} + data-testid={`rename-memory-file-${file.name}`} + > + + Rename + + handleDeleteFromList(file)} + className="text-red-500 focus:text-red-500" + data-testid={`delete-memory-file-${file.name}`} + > + + Delete + + + +
+ ))} +
+ )} +
+
+ + {/* Right Panel - Editor/Preview */} +
+ {selectedFile ? ( + <> + {/* File toolbar */} +
+
+ + {selectedFile.name} +
+
+ + + +
+
+ + {/* Content area */} +
+ {isPreviewMode ? ( + + {editedContent} + + ) : ( + +