Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/chatDiskSessionResourcesImpl.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
7
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
8
import { FileType } from '../../../platform/filesystem/common/fileTypes';
9
import { ILogService } from '../../../platform/log/common/logService';
10
import { Disposable } from '../../../util/vs/base/common/lifecycle';
11
import { ResourceMap } from '../../../util/vs/base/common/map';
12
import { URI } from '../../../util/vs/base/common/uri';
13
import { FileTree, IChatDiskSessionResources } from '../common/chatDiskSessionResources';
14
15
/**
16
* Directory name for session resources storage within extension storage.
17
*/
18
const SESSION_RESOURCES_DIR_NAME = 'chat-session-resources';
19
20
/**
21
* Retention period in milliseconds (8 hours).
22
*/
23
const RETENTION_PERIOD_MS = 8 * 60 * 60 * 1000;
24
25
/**
26
* How often to run cleanup (1 hour).
27
*/
28
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
29
30
/**
31
* Sanitizes a string to only contain alphanumeric characters, underscores, and dashes.
32
* This prevents path injection attacks.
33
*/
34
function sanitizePathComponent(str: string): string {
35
return str.replace(/[^a-zA-Z0-9_.-]/g, '_');
36
}
37
38
export class ChatDiskSessionResources extends Disposable implements IChatDiskSessionResources {
39
declare readonly _serviceBrand: undefined;
40
41
private readonly baseStorageUri: URI | undefined;
42
private readonly accessTimestamps = new ResourceMap<number>();
43
private cleanupTimer: ReturnType<typeof setInterval> | undefined;
44
45
public currentCleanup?: Promise<void>;
46
47
constructor(
48
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
49
@IFileSystemService private readonly fileSystem: IFileSystemService,
50
@ILogService private readonly logService: ILogService
51
) {
52
super();
53
54
this.baseStorageUri = this.extensionContext.storageUri
55
? URI.joinPath(this.extensionContext.storageUri, SESSION_RESOURCES_DIR_NAME)
56
: undefined;
57
58
// Schedule periodic cleanup
59
this.cleanupTimer = setInterval(() => {
60
this.currentCleanup = this.cleanupStaleResources().catch(err => {
61
this.logService.warn(`[ChatDiskSessionResources] Cleanup error: ${err}`);
62
});
63
}, CLEANUP_INTERVAL_MS);
64
65
// Run initial cleanup
66
this.currentCleanup = this.cleanupStaleResources().catch(err => {
67
this.logService.warn(`[ChatDiskSessionResources] Initial cleanup error: ${err}`);
68
});
69
}
70
71
override dispose(): void {
72
if (this.cleanupTimer) {
73
clearInterval(this.cleanupTimer);
74
this.cleanupTimer = undefined;
75
}
76
super.dispose();
77
}
78
79
async ensure(sessionId: string, subdir: string, files: string | FileTree): Promise<URI> {
80
if (!this.baseStorageUri) {
81
throw new Error('Storage URI not available');
82
}
83
84
const sanitizedSessionId = sanitizePathComponent(sessionId);
85
const sanitizedSubdir = sanitizePathComponent(subdir);
86
87
const targetDir = URI.joinPath(this.baseStorageUri, sanitizedSessionId, sanitizedSubdir);
88
89
// Ensure directory exists
90
await this.ensureDirectoryExists(targetDir);
91
92
// Write files only if they don't already exist
93
if (typeof files === 'string') {
94
// Single file content - write as content.txt
95
const fileUri = URI.joinPath(targetDir, 'content.txt');
96
await this.writeFileIfNotExists(fileUri, files);
97
} else {
98
// FileTree structure
99
await this.writeFileTree(targetDir, files);
100
}
101
102
this.markAccessed(targetDir);
103
return targetDir;
104
}
105
106
isSessionResourceUri(uri: URI): boolean {
107
if (!this.baseStorageUri) {
108
return false;
109
}
110
// Check if the URI starts with our base storage path
111
const basePath = this.baseStorageUri.path.toLowerCase();
112
const uriPath = uri.path.toLowerCase();
113
return uri.scheme === this.baseStorageUri.scheme && uriPath.startsWith(basePath);
114
}
115
116
private async writeFileTree(baseDir: URI, tree: FileTree): Promise<void> {
117
for (const [name, content] of Object.entries(tree)) {
118
const sanitizedName = sanitizePathComponent(name);
119
const targetPath = URI.joinPath(baseDir, sanitizedName);
120
121
if (typeof content === 'string') {
122
// It's a file - only write if it doesn't exist
123
await this.writeFileIfNotExists(targetPath, content);
124
} else if (content !== undefined) {
125
// It's a directory
126
await this.ensureDirectoryExists(targetPath);
127
await this.writeFileTree(targetPath, content);
128
}
129
}
130
}
131
132
private async writeFileIfNotExists(uri: URI, content: string): Promise<void> {
133
try {
134
await this.fileSystem.stat(uri);
135
// File exists, just mark as accessed
136
this.markAccessed(uri);
137
} catch {
138
// File doesn't exist, write it
139
await this.fileSystem.writeFile(uri, new TextEncoder().encode(content));
140
this.markAccessed(uri);
141
}
142
}
143
144
private async ensureDirectoryExists(dir: URI): Promise<void> {
145
try {
146
const stat = await this.fileSystem.stat(dir);
147
if (stat.type !== FileType.Directory) {
148
// It exists but is not a directory - this shouldn't happen
149
await this.fileSystem.delete(dir, { recursive: false });
150
await this.fileSystem.createDirectory(dir);
151
}
152
} catch {
153
// Directory doesn't exist, create it
154
await this.fileSystem.createDirectory(dir);
155
}
156
}
157
158
private markAccessed(uri: URI): void {
159
this.accessTimestamps.set(uri, Date.now());
160
}
161
162
private async cleanupStaleResources(): Promise<void> {
163
if (!this.baseStorageUri) {
164
return;
165
}
166
167
try {
168
// Check if base directory exists
169
try {
170
const stat = await this.fileSystem.stat(this.baseStorageUri);
171
if (stat.type !== FileType.Directory) {
172
return;
173
}
174
} catch {
175
// Directory doesn't exist, nothing to clean up
176
return;
177
}
178
179
const now = Date.now();
180
const cutoffTime = now - RETENTION_PERIOD_MS;
181
182
// Read all session directories
183
const entries = await this.fileSystem.readDirectory(this.baseStorageUri);
184
const sessionDirs = entries.filter(([, type]) => type === FileType.Directory);
185
186
for (const [sessionName] of sessionDirs) {
187
const sessionUri = URI.joinPath(this.baseStorageUri, sessionName);
188
await this.cleanupSessionDirectory(sessionUri, cutoffTime);
189
}
190
191
// Clean up empty session directories
192
for (const [sessionName] of sessionDirs) {
193
const sessionUri = URI.joinPath(this.baseStorageUri, sessionName);
194
try {
195
const sessionEntries = await this.fileSystem.readDirectory(sessionUri);
196
if (sessionEntries.length === 0) {
197
await this.fileSystem.delete(sessionUri, { recursive: true });
198
this.logService.debug(`[ChatDiskSessionResources] Deleted empty session directory: ${sessionUri.fsPath}`);
199
}
200
} catch {
201
// Ignore errors when checking/deleting empty directories
202
}
203
}
204
} catch (error) {
205
this.logService.warn(`[ChatDiskSessionResources] Error during cleanup: ${error}`);
206
}
207
}
208
209
private async cleanupSessionDirectory(sessionUri: URI, cutoffTime: number): Promise<void> {
210
try {
211
const entries = await this.fileSystem.readDirectory(sessionUri);
212
213
for (const [name, type] of entries) {
214
const entryUri = URI.joinPath(sessionUri, name);
215
216
// Check in-memory timestamp first
217
const accessTime = this.accessTimestamps.get(entryUri);
218
if (accessTime && accessTime >= cutoffTime) {
219
continue; // Still fresh
220
}
221
222
// Fall back to file system mtime
223
try {
224
const stat = await this.fileSystem.stat(entryUri);
225
if (stat.mtime >= cutoffTime) {
226
this.accessTimestamps.set(entryUri, stat.mtime);
227
continue; // Still fresh
228
}
229
} catch {
230
// If we can't stat, assume it's stale
231
}
232
233
// Delete stale entry
234
try {
235
await this.fileSystem.delete(entryUri, { recursive: type === FileType.Directory });
236
this.accessTimestamps.delete(entryUri);
237
this.logService.debug(`[ChatDiskSessionResources] Deleted stale resource: ${entryUri.fsPath}`);
238
} catch (error) {
239
this.logService.warn(`[ChatDiskSessionResources] Failed to delete ${entryUri.fsPath}: ${error}`);
240
}
241
}
242
} catch (error) {
243
this.logService.debug(`[ChatDiskSessionResources] Error cleaning session directory ${sessionUri.fsPath}: ${error}`);
244
}
245
}
246
}
247
248