import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; import path from 'node:path'; import type { CleanupHistoryInput, CleanupProcessPlanInput } from '../types.js'; import { defaultHistoryRoots } from '../core/constants.js'; import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; export function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record { const project = resolveProject(input.project); const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); const portFilters = (input.ports || []).map((port) => ({ command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, port, })); return { commands: [ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', ...portFilters.map((item) => item.command), ], notes: [ '先按项目路径筛选,再按端口交叉确认。', '不要批量结束与本次验证无关的 Node 进程。', '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', ], project, }; } interface HistoryEntry { lastWriteTime: string; mtimeMs: number; name: string; path: string; relativePath: string; type: 'directory' | 'file'; } const workspaceRootPreservedNames = new Set([ 'backup', 'db-sync', 'deploy', 'exports', 'live2d-pio', 'logs', 'playwright-mcp', 'subagents', 'test-artifacts', 'test-logs', 'tmp', 'verify', ]); const live2dPioPreservedNames = new Set([ '.venv', 'moc-reconstruction', 'moc-runtime', 'probe-source-wordpress-parity', 'runtime-export-moc-driven', 'runtime-publish', 'sources', 'vendor', ]); /** * Builds the directory names that represent generated artifact categories under * `.kt-workspace`; test artifact templates stay in place because KT page and * business test flows use them as local scaffolding inputs. * * @param rootRelativePath - Cleanup root relative to the KT workspace. * @returns Category names that cleanup should skip when pruning `.kt-workspace`. */ function buildPreservedNames(rootRelativePath: string): Set { const preservedNames = new Set(); if (rootRelativePath === '.kt-workspace') { for (const name of workspaceRootPreservedNames) { preservedNames.add(name); } } if (rootRelativePath === '.kt-workspace/test-artifacts') { preservedNames.add('_templates'); } if (rootRelativePath === '.kt-workspace/live2d-pio') { for (const name of live2dPioPreservedNames) { preservedNames.add(name); } } return preservedNames; } /** * Finds the latest write time anywhere inside one history entry. * * Windows does not reliably update a directory's own mtime when an existing * descendant file is rewritten, so cleanup ordering must include descendant * activity or it can delete a currently active evidence tree. * * @param entryPath - File or directory being ranked for retention. * @param isDirectory - Whether the entry may contain nested artifacts. * @returns Latest mtime in milliseconds across the entry and its descendants. */ function getLatestEntryMtimeMs(entryPath: string, isDirectory: boolean): number { let latestMtimeMs = statSync(entryPath).mtimeMs; if (!isDirectory) return latestMtimeMs; for (const child of readdirSync(entryPath, { withFileTypes: true })) { const childPath = path.join(entryPath, child.name); latestMtimeMs = Math.max( latestMtimeMs, getLatestEntryMtimeMs(childPath, child.isDirectory()), ); } return latestMtimeMs; } function listHistoryEntries(rootPath: string, preservedNames: Set): HistoryEntry[] { if (!existsSync(rootPath)) return []; return readdirSync(rootPath, { withFileTypes: true }) .filter((item) => !preservedNames.has(item.name)) .map((item) => { const entryPath = path.join(rootPath, item.name); const isDirectory = item.isDirectory(); const mtimeMs = getLatestEntryMtimeMs(entryPath, isDirectory); return { lastWriteTime: new Date(mtimeMs).toISOString(), mtimeMs, name: item.name, path: entryPath, relativePath: toPosix(path.relative(workspaceRoot, entryPath)), type: isDirectory ? ('directory' as const) : ('file' as const), }; }) .sort((a, b) => b.mtimeMs - a.mtimeMs); } export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record { const keep = Math.max(0, Math.min(input.keep ?? 3, 50)); const dryRun = input.dryRun ?? true; const roots = (input.roots?.length ?? 0) > 0 ? input.roots! : [...defaultHistoryRoots]; const result = { deleted: [] as Array>, dryRun, keep, preserved: [] as Array>, roots: [] as Array<{ candidates: number; deleteCount: number; keepCount: number; preservedNames: string[]; root: string; }>, skipped: [] as Array<{ reason: string; root: string; }>, }; for (const root of roots) { const rootPath = resolveInsideRoot(root); const rootRelativePath = toPosix(path.relative(workspaceRoot, rootPath)) || '.'; if (!existsSync(rootPath)) { result.skipped.push({ reason: 'not_exists', root: rootRelativePath, }); continue; } const preservedNames = buildPreservedNames(rootRelativePath); const entries = listHistoryEntries(rootPath, preservedNames); const preserved = entries.slice(0, keep); const deleting = entries.slice(keep); result.roots.push({ candidates: entries.length, deleteCount: deleting.length, keepCount: preserved.length, preservedNames: Array.from(preservedNames), root: rootRelativePath, }); result.preserved.push(...preserved.map(({ mtimeMs, path: _path, ...entry }) => entry)); for (const entry of deleting) { if (!dryRun) { rmSync(entry.path, { force: true, recursive: true, }); } const { mtimeMs, path: _path, ...publicEntry } = entry; result.deleted.push(publicEntry); } } return result; } export function parseCliCleanupArgs(args: string[]): CleanupHistoryInput { const keepArg = args.find((arg) => arg.startsWith('--keep=')); const rootsArg = args.find((arg) => arg.startsWith('--roots=')); return { dryRun: !(args.includes('--execute') || args.includes('--no-dry-run')), keep: keepArg ? Number.parseInt(keepArg.split('=')[1], 10) : 3, roots: rootsArg ? rootsArg.split('=')[1].split(',').filter(Boolean) : [...defaultHistoryRoots], }; }