ktworkflow-mcp/src/tools/cleanup.ts

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],
};
}