213 lines
6.8 KiB
TypeScript
213 lines
6.8 KiB
TypeScript
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<string, unknown> {
|
|
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 <pid> 结束。',
|
|
...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<string> {
|
|
const preservedNames = new Set<string>();
|
|
|
|
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<string>): 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<string, unknown> {
|
|
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<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
|
|
dryRun,
|
|
keep,
|
|
preserved: [] as Array<Omit<HistoryEntry, 'mtimeMs' | 'path'>>,
|
|
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],
|
|
};
|
|
}
|