diff --git a/src/config/sessions/disk-budget.ts b/src/config/sessions/disk-budget.ts index 862992f30a3..078acd904bf 100644 --- a/src/config/sessions/disk-budget.ts +++ b/src/config/sessions/disk-budget.ts @@ -32,11 +32,21 @@ const NOOP_LOGGER: SessionDiskBudgetLogger = { type SessionsDirFileStat = { path: string; + canonicalPath: string; name: string; size: 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): number { 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, { sessionsDir: params.sessionsDir, }); - const resolvedSessionsDir = path.resolve(params.sessionsDir); - const relative = path.relative(resolvedSessionsDir, path.resolve(resolved)); + const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir); + const resolvedPath = canonicalizePathForComparison(resolved); + const relative = path.relative(resolvedSessionsDir, resolvedPath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { return null; } - return resolved; + return resolvedPath; } catch { return null; } @@ -111,7 +122,7 @@ function resolveReferencedSessionTranscriptPaths(params: { entry, }); if (resolved) { - referenced.add(resolved); + referenced.add(canonicalizePathForComparison(resolved)); } } return referenced; @@ -133,6 +144,7 @@ async function readSessionsDirFiles(sessionsDir: string): Promise { async function removeFileForBudget(params: { filePath: string; + canonicalPath?: string; dryRun: boolean; fileSizesByPath: Map; simulatedRemovedPaths: Set; }): Promise { const resolvedPath = path.resolve(params.filePath); + const canonicalPath = params.canonicalPath ?? canonicalizePathForComparison(resolvedPath); if (params.dryRun) { - if (params.simulatedRemovedPaths.has(resolvedPath)) { + if (params.simulatedRemovedPaths.has(canonicalPath)) { return 0; } - const size = params.fileSizesByPath.get(resolvedPath) ?? 0; + const size = params.fileSizesByPath.get(canonicalPath) ?? 0; if (size <= 0) { return 0; } - params.simulatedRemovedPaths.add(resolvedPath); + params.simulatedRemovedPaths.add(canonicalPath); return size; } return removeFileIfExists(resolvedPath); @@ -189,10 +203,10 @@ export async function enforceSessionDiskBudget(params: { const dryRun = params.dryRun === true; const sessionsDir = path.dirname(params.storePath); 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(); - const resolvedStorePath = path.resolve(params.storePath); - const storeFile = files.find((file) => path.resolve(file.path) === resolvedStorePath); + const resolvedStorePath = canonicalizePathForComparison(params.storePath); + const storeFile = files.find((file) => file.canonicalPath === resolvedStorePath); let projectedStoreBytes = measureStoreBytes(params.store); let total = files.reduce((sum, file) => sum + file.size, 0) - (storeFile?.size ?? 0) + projectedStoreBytes; @@ -241,7 +255,7 @@ export async function enforceSessionDiskBudget(params: { .filter( (file) => isSessionArchiveArtifactName(file.name) || - (isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.path)), + (isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.canonicalPath)), ) .toSorted((a, b) => a.mtimeMs - b.mtimeMs); for (const file of removableFileQueue) { @@ -250,6 +264,7 @@ export async function enforceSessionDiskBudget(params: { } const deletedBytes = await removeFileForBudget({ filePath: file.path, + canonicalPath: file.canonicalPath, dryRun, fileSizesByPath, simulatedRemovedPaths, diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index acf14859469..53be7392d10 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -164,6 +164,15 @@ export function resolveSessionTranscriptCandidates( 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 { const ts = formatSessionArchiveTimestamp(); const archived = `${filePath}.${reason}.${ts}`; @@ -189,24 +198,27 @@ export function archiveSessionTranscripts(opts: { }): string[] { const archived: string[] = []; 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( opts.sessionId, opts.storePath, opts.sessionFile, opts.agentId, )) { + const candidatePath = canonicalizePathForComparison(candidate); if (storeDir) { - const relative = path.relative(storeDir, path.resolve(candidate)); + const relative = path.relative(storeDir, candidatePath); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { continue; } } - if (!fs.existsSync(candidate)) { + if (!fs.existsSync(candidatePath)) { continue; } try { - archived.push(archiveFileOnDisk(candidate, opts.reason)); + archived.push(archiveFileOnDisk(candidatePath, opts.reason)); } catch { // Best-effort. }