sessions: canonicalize maintenance path matching

This commit is contained in:
Shakker
2026-02-23 22:32:48 +00:00
parent 4d2e4011c8
commit 7533b85156
2 changed files with 42 additions and 15 deletions

View File

@@ -32,11 +32,21 @@ const NOOP_LOGGER: SessionDiskBudgetLogger = {
type SessionsDirFileStat = { type SessionsDirFileStat = {
path: string; path: string;
canonicalPath: string;
name: string; name: string;
size: number; size: number;
mtimeMs: number; mtimeMs: number;
}; };
function canonicalizePathForComparison(filePath: string): string {
const resolved = path.resolve(filePath);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
function measureStoreBytes(store: Record<string, SessionEntry>): number { function measureStoreBytes(store: Record<string, SessionEntry>): number {
return Buffer.byteLength(JSON.stringify(store, null, 2), "utf-8"); return Buffer.byteLength(JSON.stringify(store, null, 2), "utf-8");
} }
@@ -89,12 +99,13 @@ function resolveSessionTranscriptPathForEntry(params: {
const resolved = resolveSessionFilePath(params.entry.sessionId, params.entry, { const resolved = resolveSessionFilePath(params.entry.sessionId, params.entry, {
sessionsDir: params.sessionsDir, sessionsDir: params.sessionsDir,
}); });
const resolvedSessionsDir = path.resolve(params.sessionsDir); const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir);
const relative = path.relative(resolvedSessionsDir, path.resolve(resolved)); const resolvedPath = canonicalizePathForComparison(resolved);
const relative = path.relative(resolvedSessionsDir, resolvedPath);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
return null; return null;
} }
return resolved; return resolvedPath;
} catch { } catch {
return null; return null;
} }
@@ -111,7 +122,7 @@ function resolveReferencedSessionTranscriptPaths(params: {
entry, entry,
}); });
if (resolved) { if (resolved) {
referenced.add(resolved); referenced.add(canonicalizePathForComparison(resolved));
} }
} }
return referenced; return referenced;
@@ -133,6 +144,7 @@ async function readSessionsDirFiles(sessionsDir: string): Promise<SessionsDirFil
} }
files.push({ files.push({
path: filePath, path: filePath,
canonicalPath: canonicalizePathForComparison(filePath),
name: dirent.name, name: dirent.name,
size: stat.size, size: stat.size,
mtimeMs: stat.mtimeMs, mtimeMs: stat.mtimeMs,
@@ -152,20 +164,22 @@ async function removeFileIfExists(filePath: string): Promise<number> {
async function removeFileForBudget(params: { async function removeFileForBudget(params: {
filePath: string; filePath: string;
canonicalPath?: string;
dryRun: boolean; dryRun: boolean;
fileSizesByPath: Map<string, number>; fileSizesByPath: Map<string, number>;
simulatedRemovedPaths: Set<string>; simulatedRemovedPaths: Set<string>;
}): Promise<number> { }): Promise<number> {
const resolvedPath = path.resolve(params.filePath); const resolvedPath = path.resolve(params.filePath);
const canonicalPath = params.canonicalPath ?? canonicalizePathForComparison(resolvedPath);
if (params.dryRun) { if (params.dryRun) {
if (params.simulatedRemovedPaths.has(resolvedPath)) { if (params.simulatedRemovedPaths.has(canonicalPath)) {
return 0; return 0;
} }
const size = params.fileSizesByPath.get(resolvedPath) ?? 0; const size = params.fileSizesByPath.get(canonicalPath) ?? 0;
if (size <= 0) { if (size <= 0) {
return 0; return 0;
} }
params.simulatedRemovedPaths.add(resolvedPath); params.simulatedRemovedPaths.add(canonicalPath);
return size; return size;
} }
return removeFileIfExists(resolvedPath); return removeFileIfExists(resolvedPath);
@@ -189,10 +203,10 @@ export async function enforceSessionDiskBudget(params: {
const dryRun = params.dryRun === true; const dryRun = params.dryRun === true;
const sessionsDir = path.dirname(params.storePath); const sessionsDir = path.dirname(params.storePath);
const files = await readSessionsDirFiles(sessionsDir); const files = await readSessionsDirFiles(sessionsDir);
const fileSizesByPath = new Map(files.map((file) => [path.resolve(file.path), file.size])); const fileSizesByPath = new Map(files.map((file) => [file.canonicalPath, file.size]));
const simulatedRemovedPaths = new Set<string>(); const simulatedRemovedPaths = new Set<string>();
const resolvedStorePath = path.resolve(params.storePath); const resolvedStorePath = canonicalizePathForComparison(params.storePath);
const storeFile = files.find((file) => path.resolve(file.path) === resolvedStorePath); const storeFile = files.find((file) => file.canonicalPath === resolvedStorePath);
let projectedStoreBytes = measureStoreBytes(params.store); let projectedStoreBytes = measureStoreBytes(params.store);
let total = let total =
files.reduce((sum, file) => sum + file.size, 0) - (storeFile?.size ?? 0) + projectedStoreBytes; files.reduce((sum, file) => sum + file.size, 0) - (storeFile?.size ?? 0) + projectedStoreBytes;
@@ -241,7 +255,7 @@ export async function enforceSessionDiskBudget(params: {
.filter( .filter(
(file) => (file) =>
isSessionArchiveArtifactName(file.name) || isSessionArchiveArtifactName(file.name) ||
(isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.path)), (isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.canonicalPath)),
) )
.toSorted((a, b) => a.mtimeMs - b.mtimeMs); .toSorted((a, b) => a.mtimeMs - b.mtimeMs);
for (const file of removableFileQueue) { for (const file of removableFileQueue) {
@@ -250,6 +264,7 @@ export async function enforceSessionDiskBudget(params: {
} }
const deletedBytes = await removeFileForBudget({ const deletedBytes = await removeFileForBudget({
filePath: file.path, filePath: file.path,
canonicalPath: file.canonicalPath,
dryRun, dryRun,
fileSizesByPath, fileSizesByPath,
simulatedRemovedPaths, simulatedRemovedPaths,

View File

@@ -164,6 +164,15 @@ export function resolveSessionTranscriptCandidates(
export type ArchiveFileReason = SessionArchiveReason; export type ArchiveFileReason = SessionArchiveReason;
function canonicalizePathForComparison(filePath: string): string {
const resolved = path.resolve(filePath);
try {
return fs.realpathSync(resolved);
} catch {
return resolved;
}
}
export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string { export function archiveFileOnDisk(filePath: string, reason: ArchiveFileReason): string {
const ts = formatSessionArchiveTimestamp(); const ts = formatSessionArchiveTimestamp();
const archived = `${filePath}.${reason}.${ts}`; const archived = `${filePath}.${reason}.${ts}`;
@@ -189,24 +198,27 @@ export function archiveSessionTranscripts(opts: {
}): string[] { }): string[] {
const archived: string[] = []; const archived: string[] = [];
const storeDir = const storeDir =
opts.restrictToStoreDir && opts.storePath ? path.resolve(path.dirname(opts.storePath)) : null; opts.restrictToStoreDir && opts.storePath
? canonicalizePathForComparison(path.dirname(opts.storePath))
: null;
for (const candidate of resolveSessionTranscriptCandidates( for (const candidate of resolveSessionTranscriptCandidates(
opts.sessionId, opts.sessionId,
opts.storePath, opts.storePath,
opts.sessionFile, opts.sessionFile,
opts.agentId, opts.agentId,
)) { )) {
const candidatePath = canonicalizePathForComparison(candidate);
if (storeDir) { if (storeDir) {
const relative = path.relative(storeDir, path.resolve(candidate)); const relative = path.relative(storeDir, candidatePath);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
continue; continue;
} }
} }
if (!fs.existsSync(candidate)) { if (!fs.existsSync(candidatePath)) {
continue; continue;
} }
try { try {
archived.push(archiveFileOnDisk(candidate, opts.reason)); archived.push(archiveFileOnDisk(candidatePath, opts.reason));
} catch { } catch {
// Best-effort. // Best-effort.
} }