Path: blob/main/extensions/copilot/src/extension/prompts/node/chatDiskSessionResourcesImpl.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';6import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';7import { FileType } from '../../../platform/filesystem/common/fileTypes';8import { ILogService } from '../../../platform/log/common/logService';9import { Disposable } from '../../../util/vs/base/common/lifecycle';10import { ResourceMap } from '../../../util/vs/base/common/map';11import { URI } from '../../../util/vs/base/common/uri';12import { FileTree, IChatDiskSessionResources } from '../common/chatDiskSessionResources';1314/**15* Directory name for session resources storage within extension storage.16*/17const SESSION_RESOURCES_DIR_NAME = 'chat-session-resources';1819/**20* Retention period in milliseconds (8 hours).21*/22const RETENTION_PERIOD_MS = 8 * 60 * 60 * 1000;2324/**25* How often to run cleanup (1 hour).26*/27const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;2829/**30* Sanitizes a string to only contain alphanumeric characters, underscores, and dashes.31* This prevents path injection attacks.32*/33function sanitizePathComponent(str: string): string {34return str.replace(/[^a-zA-Z0-9_.-]/g, '_');35}3637export class ChatDiskSessionResources extends Disposable implements IChatDiskSessionResources {38declare readonly _serviceBrand: undefined;3940private readonly baseStorageUri: URI | undefined;41private readonly accessTimestamps = new ResourceMap<number>();42private cleanupTimer: ReturnType<typeof setInterval> | undefined;4344public currentCleanup?: Promise<void>;4546constructor(47@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,48@IFileSystemService private readonly fileSystem: IFileSystemService,49@ILogService private readonly logService: ILogService50) {51super();5253this.baseStorageUri = this.extensionContext.storageUri54? URI.joinPath(this.extensionContext.storageUri, SESSION_RESOURCES_DIR_NAME)55: undefined;5657// Schedule periodic cleanup58this.cleanupTimer = setInterval(() => {59this.currentCleanup = this.cleanupStaleResources().catch(err => {60this.logService.warn(`[ChatDiskSessionResources] Cleanup error: ${err}`);61});62}, CLEANUP_INTERVAL_MS);6364// Run initial cleanup65this.currentCleanup = this.cleanupStaleResources().catch(err => {66this.logService.warn(`[ChatDiskSessionResources] Initial cleanup error: ${err}`);67});68}6970override dispose(): void {71if (this.cleanupTimer) {72clearInterval(this.cleanupTimer);73this.cleanupTimer = undefined;74}75super.dispose();76}7778async ensure(sessionId: string, subdir: string, files: string | FileTree): Promise<URI> {79if (!this.baseStorageUri) {80throw new Error('Storage URI not available');81}8283const sanitizedSessionId = sanitizePathComponent(sessionId);84const sanitizedSubdir = sanitizePathComponent(subdir);8586const targetDir = URI.joinPath(this.baseStorageUri, sanitizedSessionId, sanitizedSubdir);8788// Ensure directory exists89await this.ensureDirectoryExists(targetDir);9091// Write files only if they don't already exist92if (typeof files === 'string') {93// Single file content - write as content.txt94const fileUri = URI.joinPath(targetDir, 'content.txt');95await this.writeFileIfNotExists(fileUri, files);96} else {97// FileTree structure98await this.writeFileTree(targetDir, files);99}100101this.markAccessed(targetDir);102return targetDir;103}104105isSessionResourceUri(uri: URI): boolean {106if (!this.baseStorageUri) {107return false;108}109// Check if the URI starts with our base storage path110const basePath = this.baseStorageUri.path.toLowerCase();111const uriPath = uri.path.toLowerCase();112return uri.scheme === this.baseStorageUri.scheme && uriPath.startsWith(basePath);113}114115private async writeFileTree(baseDir: URI, tree: FileTree): Promise<void> {116for (const [name, content] of Object.entries(tree)) {117const sanitizedName = sanitizePathComponent(name);118const targetPath = URI.joinPath(baseDir, sanitizedName);119120if (typeof content === 'string') {121// It's a file - only write if it doesn't exist122await this.writeFileIfNotExists(targetPath, content);123} else if (content !== undefined) {124// It's a directory125await this.ensureDirectoryExists(targetPath);126await this.writeFileTree(targetPath, content);127}128}129}130131private async writeFileIfNotExists(uri: URI, content: string): Promise<void> {132try {133await this.fileSystem.stat(uri);134// File exists, just mark as accessed135this.markAccessed(uri);136} catch {137// File doesn't exist, write it138await this.fileSystem.writeFile(uri, new TextEncoder().encode(content));139this.markAccessed(uri);140}141}142143private async ensureDirectoryExists(dir: URI): Promise<void> {144try {145const stat = await this.fileSystem.stat(dir);146if (stat.type !== FileType.Directory) {147// It exists but is not a directory - this shouldn't happen148await this.fileSystem.delete(dir, { recursive: false });149await this.fileSystem.createDirectory(dir);150}151} catch {152// Directory doesn't exist, create it153await this.fileSystem.createDirectory(dir);154}155}156157private markAccessed(uri: URI): void {158this.accessTimestamps.set(uri, Date.now());159}160161private async cleanupStaleResources(): Promise<void> {162if (!this.baseStorageUri) {163return;164}165166try {167// Check if base directory exists168try {169const stat = await this.fileSystem.stat(this.baseStorageUri);170if (stat.type !== FileType.Directory) {171return;172}173} catch {174// Directory doesn't exist, nothing to clean up175return;176}177178const now = Date.now();179const cutoffTime = now - RETENTION_PERIOD_MS;180181// Read all session directories182const entries = await this.fileSystem.readDirectory(this.baseStorageUri);183const sessionDirs = entries.filter(([, type]) => type === FileType.Directory);184185for (const [sessionName] of sessionDirs) {186const sessionUri = URI.joinPath(this.baseStorageUri, sessionName);187await this.cleanupSessionDirectory(sessionUri, cutoffTime);188}189190// Clean up empty session directories191for (const [sessionName] of sessionDirs) {192const sessionUri = URI.joinPath(this.baseStorageUri, sessionName);193try {194const sessionEntries = await this.fileSystem.readDirectory(sessionUri);195if (sessionEntries.length === 0) {196await this.fileSystem.delete(sessionUri, { recursive: true });197this.logService.debug(`[ChatDiskSessionResources] Deleted empty session directory: ${sessionUri.fsPath}`);198}199} catch {200// Ignore errors when checking/deleting empty directories201}202}203} catch (error) {204this.logService.warn(`[ChatDiskSessionResources] Error during cleanup: ${error}`);205}206}207208private async cleanupSessionDirectory(sessionUri: URI, cutoffTime: number): Promise<void> {209try {210const entries = await this.fileSystem.readDirectory(sessionUri);211212for (const [name, type] of entries) {213const entryUri = URI.joinPath(sessionUri, name);214215// Check in-memory timestamp first216const accessTime = this.accessTimestamps.get(entryUri);217if (accessTime && accessTime >= cutoffTime) {218continue; // Still fresh219}220221// Fall back to file system mtime222try {223const stat = await this.fileSystem.stat(entryUri);224if (stat.mtime >= cutoffTime) {225this.accessTimestamps.set(entryUri, stat.mtime);226continue; // Still fresh227}228} catch {229// If we can't stat, assume it's stale230}231232// Delete stale entry233try {234await this.fileSystem.delete(entryUri, { recursive: type === FileType.Directory });235this.accessTimestamps.delete(entryUri);236this.logService.debug(`[ChatDiskSessionResources] Deleted stale resource: ${entryUri.fsPath}`);237} catch (error) {238this.logService.warn(`[ChatDiskSessionResources] Failed to delete ${entryUri.fsPath}: ${error}`);239}240}241} catch (error) {242this.logService.debug(`[ChatDiskSessionResources] Error cleaning session directory ${sessionUri.fsPath}: ${error}`);243}244}245}246247248