import { glob } from 'glob'; import { readFile, readdir, stat, access } from 'fs/promises'; import { constants } from 'fs'; import { join, basename } from 'path'; import type { SessionMetadata, SessionType } from '../types/session.js'; async function fileExists(path: string): Promise { try { await access(path, constants.F_OK); return true; } catch { return false; } } interface SessionData extends SessionMetadata { path: string; isActive: boolean; archived_at?: string | null; workflow_type?: string | null; } interface ScanSessionsResult { active: SessionData[]; archived: SessionData[]; hasReviewData: boolean; } /** * Scan .workflow directory for active and archived sessions * @param workflowDir - Path to .workflow directory * @returns Active and archived sessions */ export async function scanSessions(workflowDir: string): Promise { const result: ScanSessionsResult = { active: [], archived: [], hasReviewData: false }; if (!await fileExists(workflowDir)) { return result; } // Scan active sessions const activeDir = join(workflowDir, 'active'); if (await fileExists(activeDir)) { const activeSessions = await findWfsSessions(activeDir); const activeSessionDataPromises = activeSessions.map(async (sessionName) => { const sessionPath = join(activeDir, sessionName); const sessionData = await readSessionData(sessionPath); if (sessionData) { // Check for review data if (await fileExists(join(sessionPath, '.review'))) { result.hasReviewData = true; } return { ...sessionData, path: sessionPath, isActive: true }; } return null; }); const activeSessionData = (await Promise.all(activeSessionDataPromises)).filter((s): s is SessionData => s !== null); result.active.push(...activeSessionData); } // Scan archived sessions const archivesDir = join(workflowDir, 'archives'); if (await fileExists(archivesDir)) { const archivedSessions = await findWfsSessions(archivesDir); const archivedSessionDataPromises = archivedSessions.map(async (sessionName) => { const sessionPath = join(archivesDir, sessionName); const sessionData = await readSessionData(sessionPath); if (sessionData) { return { ...sessionData, path: sessionPath, isActive: false }; } return null; }); const archivedSessionData = (await Promise.all(archivedSessionDataPromises)).filter((s): s is SessionData => s !== null); result.archived.push(...archivedSessionData); } // Sort by creation date (newest first) result.active.sort((a, b) => new Date(b.created || 0).getTime() - new Date(a.created || 0).getTime()); result.archived.sort((a, b) => { const aDate = a.archived_at || a.created || 0; const bDate = b.archived_at || b.created || 0; return new Date(bDate).getTime() - new Date(aDate).getTime(); }); return result; } /** * Find WFS-* directories in a given path * @param dir - Directory to search * @returns Array of session directory names */ async function findWfsSessions(dir: string): Promise { try { // Use glob for cross-platform pattern matching const sessions = await glob('WFS-*/', { cwd: dir, absolute: false }); // Remove trailing slashes from directory names return sessions.map(s => s.replace(/\/$/, '')); } catch { // Fallback: manual directory listing try { const entries = await readdir(dir, { withFileTypes: true }); return entries .filter(e => e.isDirectory() && e.name.startsWith('WFS-')) .map(e => e.name); } catch { return []; } } } /** * Parse timestamp from session name * Supports formats: WFS-xxx-20251128172537 or WFS-xxx-20251120-170640 * @param sessionName - Session directory name * @returns ISO date string or null */ function parseTimestampFromName(sessionName: string): string | null { // Format: 14-digit timestamp (YYYYMMDDHHmmss) const match14 = sessionName.match(/(\d{14})$/); if (match14) { const ts = match14[1]; return `${ts.slice(0,4)}-${ts.slice(4,6)}-${ts.slice(6,8)}T${ts.slice(8,10)}:${ts.slice(10,12)}:${ts.slice(12,14)}Z`; } // Format: 8-digit date + 6-digit time separated by hyphen (YYYYMMDD-HHmmss) const match8_6 = sessionName.match(/(\d{8})-(\d{6})$/); if (match8_6) { const d = match8_6[1]; const t = match8_6[2]; return `${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}T${t.slice(0,2)}:${t.slice(2,4)}:${t.slice(4,6)}Z`; } return null; } /** * Infer session type from session name pattern * @param sessionName - Session directory name * @returns Inferred type */ function inferTypeFromName(sessionName: string): SessionType { const name = sessionName.toLowerCase(); if (name.includes('-review-') || name.includes('-code-review-')) { return 'review'; } if (name.includes('-test-')) { return 'test'; } if (name.includes('-docs-')) { return 'docs'; } if (name.includes('-tdd-')) { return 'tdd'; } return 'workflow'; } /** * Read session data from workflow-session.json or create minimal from directory * @param sessionPath - Path to session directory * @returns Session data object or null if invalid */ async function readSessionData(sessionPath: string): Promise { const sessionFile = join(sessionPath, 'workflow-session.json'); const sessionName = basename(sessionPath); if (await fileExists(sessionFile)) { try { const data = JSON.parse(await readFile(sessionFile, 'utf8')) as Record; // Multi-level type detection: JSON type > workflow_type > infer from name let type = (data.type as SessionType) || (data.workflow_type as SessionType) || inferTypeFromName(sessionName); // Normalize workflow_type values if (type === 'test_session' as SessionType) type = 'test'; if (type === 'implementation' as SessionType) type = 'workflow'; return { id: (data.session_id as string) || sessionName, type, status: (data.status as 'active' | 'paused' | 'completed' | 'archived') || 'active', project: (data.project as string) || (data.description as string) || '', description: (data.description as string) || (data.project as string) || '', created: (data.created_at as string) || (data.initialized_at as string) || (data.timestamp as string) || '', updated: (data.updated_at as string) || (data.created_at as string) || '', path: sessionPath, isActive: true, archived_at: (data.archived_at as string) || null, workflow_type: (data.workflow_type as string) || null // Keep original for reference }; } catch { // Fall through to minimal session } } // Fallback: create minimal session from directory info // Try to extract timestamp from session name first const timestampFromName = parseTimestampFromName(sessionName); const inferredType = inferTypeFromName(sessionName); try { const stats = await stat(sessionPath); const createdAt = timestampFromName || stats.birthtime.toISOString(); return { id: sessionName, type: inferredType, status: 'active', project: '', description: '', created: createdAt, updated: createdAt, path: sessionPath, isActive: true, archived_at: null, workflow_type: null }; } catch { // Even if stat fails, return with name-extracted data if (timestampFromName) { return { id: sessionName, type: inferredType, status: 'active', project: '', description: '', created: timestampFromName, updated: timestampFromName, path: sessionPath, isActive: true, archived_at: null, workflow_type: null }; } return null; } } /** * Check if session has review data * @param sessionPath - Path to session directory * @returns True if review data exists */ export async function hasReviewData(sessionPath: string): Promise { const reviewDir = join(sessionPath, '.review'); return await fileExists(reviewDir); } /** * Get list of task files in session * @param sessionPath - Path to session directory * @returns Array of task file names */ export async function getTaskFiles(sessionPath: string): Promise { const taskDir = join(sessionPath, '.task'); if (!await fileExists(taskDir)) { return []; } try { return await glob('IMPL-*.json', { cwd: taskDir, absolute: false }); } catch { return []; } }